mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-27 03:31:46 +00:00
merge upstream
This commit is contained in:
commit
61bf30e08a
89 changed files with 2963 additions and 1805 deletions
|
|
@ -1,158 +0,0 @@
|
|||
(function (global, factory) {
|
||||
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
|
||||
typeof define === 'function' && define.amd ? define(['exports'], factory) :
|
||||
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global['fast-sort'] = {}));
|
||||
}(this, (function (exports) {
|
||||
'use strict';
|
||||
|
||||
// >>> INTERFACES <<<
|
||||
// >>> HELPERS <<<
|
||||
var castComparer = function (comparer) { return function (a, b, order) { return comparer(a, b, order) * order; }; };
|
||||
var throwInvalidConfigErrorIfTrue = function (condition, context) {
|
||||
if (condition)
|
||||
throw Error("Invalid sort config: " + context);
|
||||
};
|
||||
var unpackObjectSorter = function (sortByObj) {
|
||||
var _a = sortByObj || {}, asc = _a.asc, desc = _a.desc;
|
||||
var order = asc ? 1 : -1;
|
||||
var sortBy = (asc || desc);
|
||||
// Validate object config
|
||||
throwInvalidConfigErrorIfTrue(!sortBy, 'Expected `asc` or `desc` property');
|
||||
throwInvalidConfigErrorIfTrue(asc && desc, 'Ambiguous object with `asc` and `desc` config properties');
|
||||
var comparer = sortByObj.comparer && castComparer(sortByObj.comparer);
|
||||
return { order: order, sortBy: sortBy, comparer: comparer };
|
||||
};
|
||||
// >>> SORTERS <<<
|
||||
var multiPropertySorterProvider = function (defaultComparer) {
|
||||
return function multiPropertySorter(sortBy, sortByArr, depth, order, comparer, a, b) {
|
||||
var valA;
|
||||
var valB;
|
||||
if (typeof sortBy === 'string') {
|
||||
valA = a[sortBy];
|
||||
valB = b[sortBy];
|
||||
}
|
||||
else if (typeof sortBy === 'function') {
|
||||
valA = sortBy(a);
|
||||
valB = sortBy(b);
|
||||
}
|
||||
else {
|
||||
var objectSorterConfig = unpackObjectSorter(sortBy);
|
||||
return multiPropertySorter(objectSorterConfig.sortBy, sortByArr, depth, objectSorterConfig.order, objectSorterConfig.comparer || defaultComparer, a, b);
|
||||
}
|
||||
var equality = comparer(valA, valB, order);
|
||||
if ((equality === 0 || (valA == null && valB == null)) &&
|
||||
sortByArr.length > depth) {
|
||||
return multiPropertySorter(sortByArr[depth], sortByArr, depth + 1, order, comparer, a, b);
|
||||
}
|
||||
return equality;
|
||||
};
|
||||
};
|
||||
function getSortStrategy(sortBy, comparer, order) {
|
||||
// Flat array sorter
|
||||
if (sortBy === undefined || sortBy === true) {
|
||||
return function (a, b) { return comparer(a, b, order); };
|
||||
}
|
||||
// Sort list of objects by single object key
|
||||
if (typeof sortBy === 'string') {
|
||||
throwInvalidConfigErrorIfTrue(sortBy.includes('.'), 'String syntax not allowed for nested properties.');
|
||||
return function (a, b) { return comparer(a[sortBy], b[sortBy], order); };
|
||||
}
|
||||
// Sort list of objects by single function sorter
|
||||
if (typeof sortBy === 'function') {
|
||||
return function (a, b) { return comparer(sortBy(a), sortBy(b), order); };
|
||||
}
|
||||
// Sort by multiple properties
|
||||
if (Array.isArray(sortBy)) {
|
||||
var multiPropSorter_1 = multiPropertySorterProvider(comparer);
|
||||
return function (a, b) { return multiPropSorter_1(sortBy[0], sortBy, 1, order, comparer, a, b); };
|
||||
}
|
||||
// Unpack object config to get actual sorter strategy
|
||||
var objectSorterConfig = unpackObjectSorter(sortBy);
|
||||
return getSortStrategy(objectSorterConfig.sortBy, objectSorterConfig.comparer || comparer, objectSorterConfig.order);
|
||||
}
|
||||
var sortArray = function (order, ctx, sortBy, comparer) {
|
||||
var _a;
|
||||
if (!Array.isArray(ctx)) {
|
||||
return ctx;
|
||||
}
|
||||
// Unwrap sortBy if array with only 1 value to get faster sort strategy
|
||||
if (Array.isArray(sortBy) && sortBy.length < 2) {
|
||||
_a = sortBy, sortBy = _a[0];
|
||||
}
|
||||
return ctx.sort(getSortStrategy(sortBy, comparer, order));
|
||||
};
|
||||
// >>> Public <<<
|
||||
var createNewSortInstance = function (opts) {
|
||||
var comparer = castComparer(opts.comparer);
|
||||
return function (_ctx) {
|
||||
var ctx = Array.isArray(_ctx) && !opts.inPlaceSorting
|
||||
? _ctx.slice()
|
||||
: _ctx;
|
||||
return {
|
||||
/**
|
||||
* Sort array in ascending order.
|
||||
* @example
|
||||
* sort([3, 1, 4]).asc();
|
||||
* sort(users).asc(u => u.firstName);
|
||||
* sort(users).asc([
|
||||
* U => u.firstName
|
||||
* u => u.lastName,
|
||||
* ]);
|
||||
*/
|
||||
asc: function (sortBy) {
|
||||
return sortArray(1, ctx, sortBy, comparer);
|
||||
},
|
||||
/**
|
||||
* Sort array in descending order.
|
||||
* @example
|
||||
* sort([3, 1, 4]).desc();
|
||||
* sort(users).desc(u => u.firstName);
|
||||
* sort(users).desc([
|
||||
* U => u.firstName
|
||||
* u => u.lastName,
|
||||
* ]);
|
||||
*/
|
||||
desc: function (sortBy) {
|
||||
return sortArray(-1, ctx, sortBy, comparer);
|
||||
},
|
||||
/**
|
||||
* Sort array in ascending or descending order. It allows sorting on multiple props
|
||||
* in different order for each of them.
|
||||
* @example
|
||||
* sort(users).by([
|
||||
* { asc: u => u.score }
|
||||
* { desc: u => u.age }
|
||||
* ]);
|
||||
*/
|
||||
by: function (sortBy) {
|
||||
return sortArray(1, ctx, sortBy, comparer);
|
||||
},
|
||||
};
|
||||
};
|
||||
};
|
||||
var defaultComparer = function (a, b, order) {
|
||||
if (a == null)
|
||||
return order;
|
||||
if (b == null)
|
||||
return -order;
|
||||
if (a < b)
|
||||
return -1;
|
||||
if (a === b)
|
||||
return 0;
|
||||
return 1;
|
||||
};
|
||||
var sort = createNewSortInstance({
|
||||
comparer: defaultComparer,
|
||||
});
|
||||
var inPlaceSort = createNewSortInstance({
|
||||
comparer: defaultComparer,
|
||||
inPlaceSorting: true,
|
||||
});
|
||||
|
||||
exports.createNewSortInstance = createNewSortInstance;
|
||||
exports.inPlaceSort = inPlaceSort;
|
||||
exports.sort = sort;
|
||||
|
||||
Object.defineProperty(exports, '__esModule', { value: true });
|
||||
|
||||
})));
|
||||
|
|
@ -1,13 +1,6 @@
|
|||
<template>
|
||||
<div class="w-full -mt-6">
|
||||
<div class="w-full relative mb-1">
|
||||
<!-- <div class="absolute top-0 left-0 w-full h-full bg-red flex items-end pointer-events-none">
|
||||
<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>
|
||||
<div class="flex-grow" />
|
||||
<p class="font-mono text-sm text-gray-100 pointer-events-auto">{{ timeRemainingPretty }}</p>
|
||||
</div> -->
|
||||
|
||||
<div v-if="chapters.length" class="hidden md:flex absolute right-20 top-0 bottom-0 h-full items-end">
|
||||
<div class="cursor-pointer text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="showChapters">
|
||||
<span class="material-icons text-3xl">format_list_bulleted</span>
|
||||
|
|
@ -19,7 +12,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="absolute top-0 bottom-0 h-full hidden md:flex items-end" :class="chapters.length ? ' right-44' : 'right-32'">
|
||||
<controls-volume-control ref="volumeControl" v-model="volume" @input="updateVolume" />
|
||||
<controls-volume-control ref="volumeControl" v-model="volume" @input="setVolume" />
|
||||
</div>
|
||||
|
||||
<div class="flex pb-4 md:pb-2">
|
||||
|
|
@ -28,13 +21,13 @@
|
|||
<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="backward10">
|
||||
<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="playPauseClick">
|
||||
<span class="material-icons">{{ seekLoading ? 'autorenew' : isPaused ? 'play_arrow' : 'pause' }}</span>
|
||||
<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="forward10">
|
||||
<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" />
|
||||
|
|
@ -82,20 +75,15 @@
|
|||
<p class="font-mono text-sm text-gray-100 pointer-events-auto">{{ timeRemainingPretty }}</p>
|
||||
</div>
|
||||
|
||||
<audio ref="audio" @progress="progress" @timeupdate="timeupdate" @loadedmetadata="audioLoadedMetadata" @play="audioPlayed" @pause="audioPaused" @error="audioError" @ended="audioEnded" @stalled="audioStalled" @suspend="audioSuspended" />
|
||||
|
||||
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Hls from 'hls.js'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
streamId: String,
|
||||
audiobookId: String,
|
||||
loading: Boolean,
|
||||
paused: Boolean,
|
||||
chapters: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
|
|
@ -107,57 +95,41 @@ export default {
|
|||
},
|
||||
data() {
|
||||
return {
|
||||
hlsInstance: null,
|
||||
staleHlsInstance: null,
|
||||
usingNativeAudioPlayer: false,
|
||||
playOnLoad: false,
|
||||
startTime: 0,
|
||||
volume: 1,
|
||||
playbackRate: 1,
|
||||
trackWidth: 0,
|
||||
isPaused: true,
|
||||
url: null,
|
||||
src: null,
|
||||
playedTrackWidth: 0,
|
||||
bufferTrackWidth: 0,
|
||||
readyTrackWidth: 0,
|
||||
audioEl: null,
|
||||
totalDuration: 0,
|
||||
seekedTime: 0,
|
||||
seekLoading: false,
|
||||
showChaptersModal: false,
|
||||
currentTime: 0,
|
||||
trackOffsetLeft: 16, // Track is 16px from edge
|
||||
listenTimeInterval: null,
|
||||
listeningTimeSinceLastUpdate: 0,
|
||||
totalListeningTimeInSession: 0
|
||||
duration: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
token() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
totalDurationPretty() {
|
||||
return this.$secondsToTimestamp(this.totalDuration)
|
||||
},
|
||||
timeRemaining() {
|
||||
if (!this.audioEl) return 0
|
||||
return (this.totalDuration - this.currentTime) / this.playbackRate
|
||||
return (this.duration - this.currentTime) / this.playbackRate
|
||||
},
|
||||
timeRemainingPretty() {
|
||||
if (this.timeRemaining < 0) {
|
||||
console.warn('Time remaining < 0', this.totalDuration, this.currentTime, this.timeRemaining)
|
||||
console.warn('Time remaining < 0', this.duration, this.currentTime, this.timeRemaining)
|
||||
return this.$secondsToTimestamp(this.timeRemaining * -1)
|
||||
}
|
||||
return '-' + this.$secondsToTimestamp(this.timeRemaining)
|
||||
},
|
||||
progressPercent() {
|
||||
if (!this.totalDuration) return 0
|
||||
return Math.round((100 * this.currentTime) / this.totalDuration)
|
||||
if (!this.duration) return 0
|
||||
return Math.round((100 * this.currentTime) / this.duration)
|
||||
},
|
||||
chapterTicks() {
|
||||
return this.chapters.map((chap) => {
|
||||
var perc = chap.start / this.totalDuration
|
||||
var perc = chap.start / this.duration
|
||||
return {
|
||||
title: chap.title,
|
||||
left: perc * this.trackWidth
|
||||
|
|
@ -175,188 +147,77 @@ export default {
|
|||
}
|
||||
},
|
||||
methods: {
|
||||
audioPlayed() {
|
||||
if (!this.$refs.audio) return
|
||||
console.log('Audio Played', this.$refs.audio.currentTime, 'Total Duration', this.$refs.audio.duration)
|
||||
this.startListenTimeInterval()
|
||||
this.isPaused = this.$refs.audio.paused
|
||||
setDuration(duration) {
|
||||
this.duration = duration
|
||||
},
|
||||
audioPaused() {
|
||||
if (!this.$refs.audio) return
|
||||
// console.log('Audio Paused', this.$refs.audio.paused, this.$refs.audio.currentTime)
|
||||
this.isPaused = this.$refs.audio.paused
|
||||
this.cancelListenTimeInterval()
|
||||
setCurrentTime(time) {
|
||||
this.currentTime = time
|
||||
this.updateTimestamp()
|
||||
this.updatePlayedTrack()
|
||||
},
|
||||
audioError(err) {
|
||||
if (!this.$refs.audio) return
|
||||
console.error('Audio Error', this.$refs.audio.paused, this.$refs.audio.currentTime, err)
|
||||
playPause() {
|
||||
this.$emit('playPause')
|
||||
},
|
||||
audioEnded() {
|
||||
if (!this.$refs.audio) return
|
||||
console.log('Audio Ended', this.$refs.audio.paused, this.$refs.audio.currentTime)
|
||||
jumpBackward() {
|
||||
this.$emit('jumpBackward')
|
||||
},
|
||||
audioStalled() {
|
||||
if (!this.$refs.audio) return
|
||||
console.warn('Audio Stalled', this.$refs.audio.paused, this.$refs.audio.currentTime)
|
||||
jumpForward() {
|
||||
this.$emit('jumpForward')
|
||||
},
|
||||
audioSuspended() {
|
||||
if (!this.$refs.audio) return
|
||||
console.warn('Audio Suspended', this.$refs.audio.paused, this.$refs.audio.currentTime)
|
||||
increaseVolume() {
|
||||
if (this.volume >= 1) return
|
||||
this.volume = Math.min(1, this.volume + 0.1)
|
||||
this.setVolume(this.volume)
|
||||
},
|
||||
sendStreamSync(timeListened = 0) {
|
||||
// If currentTime is null then currentTime wont be updated
|
||||
var currentTime = null
|
||||
if (this.$refs.audio) {
|
||||
currentTime = this.$refs.audio.currentTime
|
||||
} else if (!timeListened) {
|
||||
console.warn('Not sending stream sync, no data to sync')
|
||||
return
|
||||
decreaseVolume() {
|
||||
if (this.volume <= 0) return
|
||||
this.volume = Math.max(0, this.volume - 0.1)
|
||||
this.setVolume(this.volume)
|
||||
},
|
||||
setVolume(volume) {
|
||||
this.$emit('setVolume', volume)
|
||||
},
|
||||
toggleMute() {
|
||||
if (this.$refs.volumeControl && this.$refs.volumeControl.toggleMute) {
|
||||
this.$refs.volumeControl.toggleMute()
|
||||
}
|
||||
var syncData = {
|
||||
timeListened,
|
||||
currentTime,
|
||||
streamId: this.streamId,
|
||||
audiobookId: this.audiobookId
|
||||
}
|
||||
this.$emit('sync', syncData)
|
||||
},
|
||||
sendAddListeningTime() {
|
||||
var listeningTimeToAdd = Math.floor(this.listeningTimeSinceLastUpdate)
|
||||
this.listeningTimeSinceLastUpdate = Math.max(0, this.listeningTimeSinceLastUpdate - listeningTimeToAdd)
|
||||
this.sendStreamSync(listeningTimeToAdd)
|
||||
increasePlaybackRate() {
|
||||
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
|
||||
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
|
||||
if (currentRateIndex >= rates.length - 1) return
|
||||
this.playbackRate = rates[currentRateIndex + 1] || 1
|
||||
this.playbackRateChanged(this.playbackRate)
|
||||
},
|
||||
cancelListenTimeInterval() {
|
||||
this.sendAddListeningTime()
|
||||
clearInterval(this.listenTimeInterval)
|
||||
this.listenTimeInterval = null
|
||||
decreasePlaybackRate() {
|
||||
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
|
||||
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
|
||||
if (currentRateIndex <= 0) return
|
||||
this.playbackRate = rates[currentRateIndex - 1] || 1
|
||||
this.playbackRateChanged(this.playbackRate)
|
||||
},
|
||||
startListenTimeInterval() {
|
||||
if (!this.$refs.audio) return
|
||||
|
||||
clearInterval(this.listenTimeInterval)
|
||||
var lastTime = this.$refs.audio.currentTime
|
||||
var lastTick = Date.now()
|
||||
var noProgressCount = 0
|
||||
this.listenTimeInterval = setInterval(() => {
|
||||
if (!this.$refs.audio) {
|
||||
console.error('Canceling audio played interval no audio player')
|
||||
this.cancelListenTimeInterval()
|
||||
return
|
||||
}
|
||||
if (this.$refs.audio.paused) {
|
||||
console.warn('Canceling audio played interval audio player paused')
|
||||
this.cancelListenTimeInterval()
|
||||
return
|
||||
}
|
||||
|
||||
var timeSinceLastTick = Date.now() - lastTick
|
||||
lastTick = Date.now()
|
||||
|
||||
var expectedAudioTime = lastTime + timeSinceLastTick / 1000
|
||||
var currentTime = this.$refs.audio.currentTime
|
||||
var differenceFromExpected = expectedAudioTime - currentTime
|
||||
if (currentTime === lastTime) {
|
||||
noProgressCount++
|
||||
if (noProgressCount > 3) {
|
||||
console.error('Audio current time has not increased - cancel interval and pause player')
|
||||
this.cancelListenTimeInterval()
|
||||
this.pause()
|
||||
}
|
||||
} else if (Math.abs(differenceFromExpected) > 0.1) {
|
||||
noProgressCount = 0
|
||||
console.warn('Invalid time between interval - resync last', differenceFromExpected)
|
||||
lastTime = currentTime
|
||||
} else {
|
||||
noProgressCount = 0
|
||||
var exactPlayTimeDifference = currentTime - lastTime
|
||||
// console.log('Difference from expected', differenceFromExpected, 'Exact play time diff', exactPlayTimeDifference)
|
||||
lastTime = currentTime
|
||||
this.listeningTimeSinceLastUpdate += exactPlayTimeDifference
|
||||
this.totalListeningTimeInSession += exactPlayTimeDifference
|
||||
// console.log('Time since last update:', this.listeningTimeSinceLastUpdate, 'Session listening time:', this.totalListeningTimeInSession)
|
||||
if (this.listeningTimeSinceLastUpdate > 5) {
|
||||
this.sendAddListeningTime()
|
||||
}
|
||||
}
|
||||
}, 1000)
|
||||
setPlaybackRate(playbackRate) {
|
||||
this.$emit('setPlaybackRate', playbackRate)
|
||||
},
|
||||
selectChapter(chapter) {
|
||||
this.seek(chapter.start)
|
||||
this.showChaptersModal = false
|
||||
},
|
||||
selectBookmark(bookmark) {
|
||||
if (bookmark) {
|
||||
this.seek(bookmark.time)
|
||||
}
|
||||
},
|
||||
seek(time) {
|
||||
if (this.loading) {
|
||||
return
|
||||
}
|
||||
if (this.seekLoading) {
|
||||
console.error('Already seek loading', this.seekedTime)
|
||||
return
|
||||
}
|
||||
if (!this.audioEl) {
|
||||
console.error('No Audio el for seek', time)
|
||||
return
|
||||
}
|
||||
if (!this.audioEl.paused) {
|
||||
this.cancelListenTimeInterval()
|
||||
}
|
||||
|
||||
this.seekedTime = time
|
||||
this.seekLoading = true
|
||||
|
||||
this.audioEl.currentTime = time
|
||||
|
||||
this.sendStreamSync()
|
||||
|
||||
this.$nextTick(() => {
|
||||
if (this.audioEl && !this.audioEl.paused) {
|
||||
this.startListenTimeInterval()
|
||||
}
|
||||
})
|
||||
|
||||
if (this.$refs.playedTrack) {
|
||||
var perc = time / this.audioEl.duration
|
||||
var ptWidth = Math.round(perc * this.trackWidth)
|
||||
this.$refs.playedTrack.style.width = ptWidth + 'px'
|
||||
this.playedTrackWidth = ptWidth
|
||||
|
||||
this.$refs.playedTrack.classList.remove('bg-gray-200')
|
||||
this.$refs.playedTrack.classList.add('bg-yellow-300')
|
||||
}
|
||||
},
|
||||
updateVolume(volume) {
|
||||
if (this.audioEl) {
|
||||
this.audioEl.volume = volume
|
||||
}
|
||||
},
|
||||
updatePlaybackRate(playbackRate) {
|
||||
if (this.audioEl) {
|
||||
try {
|
||||
this.audioEl.playbackRate = playbackRate
|
||||
this.audioEl.defaultPlaybackRate = playbackRate
|
||||
} catch (error) {
|
||||
console.error('Update playback rate failed', error)
|
||||
}
|
||||
} else {
|
||||
console.error('No Audio El updatePlaybackRate')
|
||||
}
|
||||
this.$emit('seek', time)
|
||||
},
|
||||
playbackRateUpdated(playbackRate) {
|
||||
this.updatePlaybackRate(playbackRate)
|
||||
this.setPlaybackRate(playbackRate)
|
||||
},
|
||||
playbackRateChanged(playbackRate) {
|
||||
this.updatePlaybackRate(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.totalDuration
|
||||
var time = (offsetX / this.trackWidth) * this.duration
|
||||
if (this.$refs.hoverTimestamp) {
|
||||
var width = this.$refs.hoverTimestamp.clientWidth
|
||||
this.$refs.hoverTimestamp.style.opacity = 1
|
||||
|
|
@ -402,17 +263,6 @@ export default {
|
|||
},
|
||||
restart() {
|
||||
this.seek(0)
|
||||
this.$nextTick(this.sendStreamSync)
|
||||
},
|
||||
backward10() {
|
||||
var newTime = this.audioEl.currentTime - 10
|
||||
newTime = Math.max(0, newTime)
|
||||
this.seek(newTime)
|
||||
},
|
||||
forward10() {
|
||||
var newTime = this.audioEl.currentTime + 10
|
||||
newTime = Math.min(this.audioEl.duration, newTime)
|
||||
this.seek(newTime)
|
||||
},
|
||||
setStreamReady() {
|
||||
this.readyTrackWidth = this.trackWidth
|
||||
|
|
@ -442,114 +292,11 @@ export default {
|
|||
console.error('No timestamp el')
|
||||
return
|
||||
}
|
||||
if (!this.audioEl) {
|
||||
console.error('No Audio El')
|
||||
return
|
||||
}
|
||||
var currTimeClean = this.$secondsToTimestamp(this.audioEl.currentTime)
|
||||
var currTimeClean = this.$secondsToTimestamp(this.currentTime)
|
||||
ts.innerText = currTimeClean
|
||||
},
|
||||
clickTrack(e) {
|
||||
var offsetX = e.offsetX
|
||||
var perc = offsetX / this.trackWidth
|
||||
var time = perc * this.audioEl.duration
|
||||
if (isNaN(time) || time === null) {
|
||||
console.error('Invalid time', perc, time)
|
||||
return
|
||||
}
|
||||
this.seek(time)
|
||||
},
|
||||
playPauseClick() {
|
||||
if (this.isPaused) {
|
||||
this.play()
|
||||
} else {
|
||||
this.pause()
|
||||
}
|
||||
},
|
||||
isValidDuration(duration) {
|
||||
if (duration && !isNaN(duration) && duration !== Number.POSITIVE_INFINITY && duration !== Number.NEGATIVE_INFINITY) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
getBufferedRanges() {
|
||||
if (!this.audioEl) return []
|
||||
|
||||
const ranges = []
|
||||
const seekable = this.audioEl.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.audioEl.currentTime && buff.end > this.audioEl.currentTime)
|
||||
if (buff) return buff.end
|
||||
|
||||
var last = bufferedRanges[bufferedRanges.length - 1]
|
||||
return last.end
|
||||
},
|
||||
progress() {
|
||||
if (!this.audioEl) {
|
||||
return
|
||||
}
|
||||
var lastbuff = this.getLastBufferedTime()
|
||||
|
||||
var bufferlen = (lastbuff / this.audioEl.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
|
||||
},
|
||||
timeupdate() {
|
||||
if (!this.$refs.playedTrack) {
|
||||
console.error('Invalid no played track ref')
|
||||
return
|
||||
}
|
||||
if (!this.audioEl) {
|
||||
console.error('No Audio El')
|
||||
return
|
||||
}
|
||||
|
||||
if (this.seekLoading) {
|
||||
this.seekLoading = false
|
||||
if (this.$refs.playedTrack) {
|
||||
this.$refs.playedTrack.classList.remove('bg-yellow-300')
|
||||
this.$refs.playedTrack.classList.add('bg-gray-200')
|
||||
}
|
||||
}
|
||||
|
||||
this.updateTimestamp()
|
||||
|
||||
// Send update to server when currentTime > 0
|
||||
// this prevents errors when seeking to position not yet transcoded
|
||||
// seeking to position not yet transcoded will cause audio element to set currentTime to 0
|
||||
// if (this.audioEl.currentTime) {
|
||||
// this.sendStreamUpdate()
|
||||
// }
|
||||
|
||||
this.currentTime = this.audioEl.currentTime
|
||||
|
||||
var perc = this.audioEl.currentTime / this.audioEl.duration
|
||||
updatePlayedTrack() {
|
||||
var perc = this.currentTime / this.duration
|
||||
var ptWidth = Math.round(perc * this.trackWidth)
|
||||
if (this.playedTrackWidth === ptWidth) {
|
||||
return
|
||||
|
|
@ -557,83 +304,27 @@ export default {
|
|||
this.$refs.playedTrack.style.width = ptWidth + 'px'
|
||||
this.playedTrackWidth = ptWidth
|
||||
},
|
||||
audioLoadedMetadata() {
|
||||
this.totalDuration = this.audioEl.duration
|
||||
this.$emit('loaded', this.totalDuration)
|
||||
if (this.usingNativeAudioPlayer) {
|
||||
this.audioEl.currentTime = this.startTime
|
||||
this.play()
|
||||
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)
|
||||
},
|
||||
set(url, currentTime, playOnLoad = false) {
|
||||
if (this.hlsInstance) {
|
||||
this.terminateStream()
|
||||
}
|
||||
if (!this.$refs.audio) {
|
||||
console.error('No audio widget')
|
||||
setBufferTime(bufferTime) {
|
||||
if (!this.audioEl) {
|
||||
return
|
||||
}
|
||||
this.listeningTimeSinceLastUpdate = 0
|
||||
|
||||
this.playOnLoad = playOnLoad
|
||||
this.startTime = currentTime
|
||||
this.url = url
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
url = `${process.env.serverUrl}${url}`
|
||||
}
|
||||
this.src = url
|
||||
console.log('[AudioPlayer-Set] Set url', url)
|
||||
|
||||
var audio = this.$refs.audio
|
||||
audio.volume = this.volume
|
||||
audio.defaultPlaybackRate = this.playbackRate
|
||||
|
||||
// iOS does not support Media Elements but allows for HLS in the native audio player
|
||||
if (!Hls.isSupported()) {
|
||||
console.warn('HLS is not supported - fallback to using audio element')
|
||||
this.usingNativeAudioPlayer = true
|
||||
audio.src = this.src + '?token=' + this.token
|
||||
audio.currentTime = currentTime
|
||||
return
|
||||
}
|
||||
|
||||
var hlsOptions = {
|
||||
startPosition: currentTime || -1,
|
||||
xhrSetup: (xhr) => {
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${this.token}`)
|
||||
}
|
||||
}
|
||||
console.log('Starting HLS audio stream at time', currentTime)
|
||||
// console.log('[AudioPlayer-Set] HLS Config', hlsOptions)
|
||||
this.hlsInstance = new Hls(hlsOptions)
|
||||
|
||||
this.hlsInstance.attachMedia(audio)
|
||||
this.hlsInstance.on(Hls.Events.MEDIA_ATTACHED, () => {
|
||||
// console.log('[HLS] MEDIA ATTACHED')
|
||||
this.hlsInstance.loadSource(url)
|
||||
|
||||
this.hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
console.log('[HLS] Manifest Parsed')
|
||||
if (playOnLoad) {
|
||||
this.play()
|
||||
}
|
||||
})
|
||||
|
||||
this.hlsInstance.on(Hls.Events.ERROR, (e, data) => {
|
||||
console.error('[HLS] Error', data.type, data.details, data)
|
||||
|
||||
if (this.$refs.audio) {
|
||||
console.log('Hls error check audio', this.$refs.audio.paused, this.$refs.audio.currentTime, this.$refs.audio.readyState)
|
||||
}
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
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
|
||||
},
|
||||
showChapters() {
|
||||
if (!this.chapters.length) return
|
||||
|
|
@ -642,39 +333,9 @@ export default {
|
|||
showBookmarks() {
|
||||
this.$emit('showBookmarks', this.currentTime)
|
||||
},
|
||||
play() {
|
||||
if (!this.$refs.audio) {
|
||||
console.error('No Audio ref')
|
||||
return
|
||||
}
|
||||
this.$refs.audio.play()
|
||||
},
|
||||
pause() {
|
||||
if (!this.$refs.audio) return
|
||||
this.$refs.audio.pause()
|
||||
},
|
||||
terminateStream() {
|
||||
if (this.hlsInstance) {
|
||||
if (!this.hlsInstance.destroy) {
|
||||
console.error('HLS Instance has no destroy property', this.hlsInstance)
|
||||
return
|
||||
}
|
||||
this.staleHlsInstance = this.hlsInstance
|
||||
this.staleHlsInstance.destroy()
|
||||
this.hlsInstance = null
|
||||
}
|
||||
},
|
||||
async resetStream(startTime) {
|
||||
if (this.$refs.audio) this.$refs.audio.pause()
|
||||
this.terminateStream()
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
console.log('Waited 1 second after terminating stream to start again')
|
||||
this.set(this.url, startTime, true)
|
||||
},
|
||||
init() {
|
||||
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
|
||||
|
||||
this.audioEl = this.$refs.audio
|
||||
this.$emit('setPlaybackRate', this.playbackRate)
|
||||
this.setTrackWidth()
|
||||
},
|
||||
setTrackWidth() {
|
||||
|
|
@ -686,48 +347,19 @@ export default {
|
|||
},
|
||||
settingsUpdated(settings) {
|
||||
if (settings.playbackRate && this.playbackRate !== settings.playbackRate) {
|
||||
this.updatePlaybackRate(settings.playbackRate)
|
||||
this.setPlaybackRate(settings.playbackRate)
|
||||
}
|
||||
},
|
||||
volumeUp() {
|
||||
if (this.volume >= 1) return
|
||||
this.volume = Math.min(1, this.volume + 0.1)
|
||||
this.updateVolume(this.volume)
|
||||
},
|
||||
volumeDown() {
|
||||
if (this.volume <= 0) return
|
||||
this.volume = Math.max(0, this.volume - 0.1)
|
||||
this.updateVolume(this.volume)
|
||||
},
|
||||
toggleMute() {
|
||||
if (this.$refs.volumeControl && this.$refs.volumeControl.toggleMute) {
|
||||
this.$refs.volumeControl.toggleMute()
|
||||
}
|
||||
},
|
||||
increasePlaybackRate() {
|
||||
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
|
||||
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
|
||||
if (currentRateIndex >= rates.length - 1) return
|
||||
this.playbackRate = rates[currentRateIndex + 1] || 1
|
||||
this.playbackRateChanged(this.playbackRate)
|
||||
},
|
||||
decreasePlaybackRate() {
|
||||
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
|
||||
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
|
||||
if (currentRateIndex <= 0) return
|
||||
this.playbackRate = rates[currentRateIndex - 1] || 1
|
||||
this.playbackRateChanged(this.playbackRate)
|
||||
},
|
||||
closePlayer() {
|
||||
if (this.loading) return
|
||||
this.$emit('close')
|
||||
},
|
||||
hotkey(action) {
|
||||
if (action === this.$hotkeys.AudioPlayer.PLAY_PAUSE) this.playPauseClick()
|
||||
else if (action === this.$hotkeys.AudioPlayer.JUMP_FORWARD) this.forward10()
|
||||
else if (action === this.$hotkeys.AudioPlayer.JUMP_BACKWARD) this.backward10()
|
||||
else if (action === this.$hotkeys.AudioPlayer.VOLUME_UP) this.volumeUp()
|
||||
else if (action === this.$hotkeys.AudioPlayer.VOLUME_DOWN) this.volumeDown()
|
||||
if (action === this.$hotkeys.AudioPlayer.PLAY_PAUSE) this.playPause()
|
||||
else if (action === this.$hotkeys.AudioPlayer.JUMP_FORWARD) this.jumpForward()
|
||||
else if (action === this.$hotkeys.AudioPlayer.JUMP_BACKWARD) this.jumpBackward()
|
||||
else if (action === this.$hotkeys.AudioPlayer.VOLUME_UP) this.increaseVolume()
|
||||
else if (action === this.$hotkeys.AudioPlayer.VOLUME_DOWN) this.decreaseVolume()
|
||||
else if (action === this.$hotkeys.AudioPlayer.MUTE_UNMUTE) this.toggleMute()
|
||||
else if (action === this.$hotkeys.AudioPlayer.SHOW_CHAPTERS) this.showChapters()
|
||||
else if (action === this.$hotkeys.AudioPlayer.INCREASE_PLAYBACK_RATE) this.increasePlaybackRate()
|
||||
|
|
|
|||
|
|
@ -15,6 +15,13 @@
|
|||
|
||||
<span v-if="showExperimentalFeatures" class="material-icons text-4xl text-warning pr-0 sm:pr-2 md:pr-4">logo_dev</span>
|
||||
|
||||
<ui-tooltip v-if="isChromecastInitialized && !isHttps" direction="bottom" text="Casting requires a secure connection" class="flex items-center">
|
||||
<span class="material-icons-outlined text-warning text-opacity-50"> cast </span>
|
||||
</ui-tooltip>
|
||||
<div v-if="isChromecastInitialized" class="w-6 h-6 mr-2 cursor-pointer">
|
||||
<google-cast-launcher></google-cast-launcher>
|
||||
</div>
|
||||
|
||||
<nuxt-link to="/config/stats" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
||||
<span class="material-icons">equalizer</span>
|
||||
</nuxt-link>
|
||||
|
|
@ -39,10 +46,6 @@
|
|||
|
||||
<div v-show="numAudiobooksSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center">
|
||||
<h1 class="text-2xl px-4">{{ numAudiobooksSelected }} Selected</h1>
|
||||
<!-- <ui-btn v-show="!isHome" small class="text-sm mx-2" @click="toggleSelectAll"
|
||||
>{{ isAllSelected ? 'Select None' : 'Select All' }}<span class="pl-2">({{ entitiesLoaded }})</span></ui-btn
|
||||
> -->
|
||||
|
||||
<div class="flex-grow" />
|
||||
<ui-tooltip :text="`Mark as ${selectedIsRead ? 'Not Read' : 'Read'}`" direction="bottom">
|
||||
<ui-read-icon-btn :disabled="processingBatch" :is-read="selectedIsRead" @click="toggleBatchRead" class="mx-1.5" />
|
||||
|
|
@ -124,6 +127,15 @@ export default {
|
|||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
},
|
||||
isChromecastEnabled() {
|
||||
return this.$store.getters['getServerSetting']('chromecastEnabled')
|
||||
},
|
||||
isChromecastInitialized() {
|
||||
return this.$store.state.globals.isChromecastInitialized
|
||||
},
|
||||
isHttps() {
|
||||
return location.protocol === 'https:' || process.env.NODE_ENV === 'development'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
|
|
|||
|
|
@ -168,8 +168,7 @@ export default {
|
|||
})
|
||||
},
|
||||
startStream() {
|
||||
this.$store.commit('setStreamAudiobook', this.book)
|
||||
this.$root.socket.emit('open_stream', this.book.id)
|
||||
this.$eventBus.$emit('play-audiobook', this.book.id)
|
||||
},
|
||||
editClick() {
|
||||
this.$emit('edit', this.book)
|
||||
|
|
|
|||
|
|
@ -2,9 +2,6 @@
|
|||
<div id="bookshelf" class="w-full overflow-y-auto">
|
||||
<template v-for="shelf in totalShelves">
|
||||
<div :key="shelf" :id="`shelf-${shelf - 1}`" class="w-full px-4 sm:px-8 relative" :class="{ bookshelfRow: !isAlternativeBookshelfView }" :style="{ height: shelfHeight + 'px' }">
|
||||
<!-- <div class="absolute top-0 left-0 bottom-0 p-4 z-10">
|
||||
<p class="text-white text-2xl">{{ shelf }}</p>
|
||||
</div> -->
|
||||
<div v-if="!isAlternativeBookshelfView" class="bookshelfDivider w-full absolute bottom-0 left-0 right-0 z-20" :class="`h-${shelfDividerHeightIndex}`" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -26,7 +23,9 @@
|
|||
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
|
||||
<!-- Experimental Bookshelf Texture -->
|
||||
<div v-show="showExperimentalFeatures" 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 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>
|
||||
|
|
@ -114,6 +113,9 @@ export default {
|
|||
bookshelfView() {
|
||||
return this.$store.getters['settings/getServerSetting']('bookshelfView')
|
||||
},
|
||||
sortingIgnorePrefix() {
|
||||
return this.$store.getters['getServerSetting']('sortingIgnorePrefix')
|
||||
},
|
||||
isCoverSquareAspectRatio() {
|
||||
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE
|
||||
},
|
||||
|
|
@ -245,7 +247,7 @@ export default {
|
|||
console.error('failed to fetch books', error)
|
||||
return null
|
||||
})
|
||||
console.log('payload', payload)
|
||||
|
||||
this.isFetchingEntities = false
|
||||
if (this.pendingReset) {
|
||||
this.pendingReset = false
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
<div class="flex items-start pl-24 mb-6 md:mb-0">
|
||||
<div>
|
||||
<nuxt-link :to="`/audiobook/${streamAudiobook.id}`" class="hover:underline cursor-pointer text-base sm:text-lg">
|
||||
{{ title }} <span v-if="stream && $isDev" class="text-xs text-gray-400">({{ stream.id }})</span>
|
||||
{{ title }}
|
||||
</nuxt-link>
|
||||
<div class="text-gray-400 flex items-center">
|
||||
<span class="material-icons text-sm">person</span>
|
||||
|
|
@ -22,26 +22,29 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<span v-if="stream" class="material-icons p-4 cursor-pointer" @click="cancelStream">close</span>
|
||||
<span class="material-icons p-4 cursor-pointer" @click="closePlayer">close</span>
|
||||
</div>
|
||||
|
||||
<audio-player ref="audioPlayer" :stream-id="streamId" :audiobook-id="audiobookId" :chapters="chapters" :loading="isLoading" :bookmarks="bookmarks" @close="cancelStream" @loaded="(d) => (totalDuration = d)" @showBookmarks="showBookmarks" @sync="sendStreamSync" @hook:mounted="audioPlayerMounted" />
|
||||
<audio-player ref="audioPlayer" :chapters="chapters" :paused="!isPlaying" :loading="playerLoading" :bookmarks="bookmarks" @playPause="playPause" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setVolume="setVolume" @setPlaybackRate="setPlaybackRate" @seek="seek" @close="closePlayer" @showBookmarks="showBookmarks" />
|
||||
|
||||
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :audiobook-id="bookmarkAudiobookId" :current-time="bookmarkCurrentTime" @select="selectBookmark" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import PlayerHandler from '@/players/PlayerHandler'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
audioPlayerReady: false,
|
||||
lastServerUpdateSentSeconds: 0,
|
||||
stream: null,
|
||||
playerHandler: new PlayerHandler(this),
|
||||
totalDuration: 0,
|
||||
showBookmarksModal: false,
|
||||
bookmarkCurrentTime: 0,
|
||||
bookmarkAudiobookId: null
|
||||
bookmarkAudiobookId: null,
|
||||
playerLoading: false,
|
||||
isPlaying: false,
|
||||
currentTime: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
|
@ -69,18 +72,13 @@ export default {
|
|||
if (!this.audiobookId) return
|
||||
return this.$store.getters['user/getUserAudiobook'](this.audiobookId)
|
||||
},
|
||||
userAudiobookCurrentTime() {
|
||||
return this.userAudiobook ? this.userAudiobook.currentTime || 0 : 0
|
||||
},
|
||||
bookmarks() {
|
||||
if (!this.userAudiobook) return []
|
||||
return (this.userAudiobook.bookmarks || []).map((bm) => ({ ...bm })).sort((a, b) => a.time - b.time)
|
||||
},
|
||||
isLoading() {
|
||||
if (!this.streamAudiobook) return false
|
||||
if (this.stream) {
|
||||
// IF Stream exists, set loading if stream is diff from next stream
|
||||
return this.stream.audiobook.id !== this.streamAudiobook.id
|
||||
}
|
||||
return true
|
||||
},
|
||||
streamAudiobook() {
|
||||
return this.$store.state.streamAudiobook
|
||||
},
|
||||
|
|
@ -105,12 +103,6 @@ export default {
|
|||
authorsList() {
|
||||
return this.authorFL ? this.authorFL.split(', ') : []
|
||||
},
|
||||
streamId() {
|
||||
return this.stream ? this.stream.id : null
|
||||
},
|
||||
playlistUrl() {
|
||||
return this.stream ? this.stream.clientPlaylistUri : null
|
||||
},
|
||||
libraryId() {
|
||||
return this.streamAudiobook ? this.streamAudiobook.libraryId : null
|
||||
},
|
||||
|
|
@ -119,8 +111,40 @@ export default {
|
|||
}
|
||||
},
|
||||
methods: {
|
||||
addListeningTime(time) {
|
||||
console.log('Send listening time to server', time)
|
||||
playPause() {
|
||||
this.playerHandler.playPause()
|
||||
},
|
||||
jumpForward() {
|
||||
this.playerHandler.jumpForward()
|
||||
},
|
||||
jumpBackward() {
|
||||
this.playerHandler.jumpBackward()
|
||||
},
|
||||
setVolume(volume) {
|
||||
this.playerHandler.setVolume(volume)
|
||||
},
|
||||
setPlaybackRate(playbackRate) {
|
||||
this.playerHandler.setPlaybackRate(playbackRate)
|
||||
},
|
||||
seek(time) {
|
||||
this.playerHandler.seek(time)
|
||||
},
|
||||
setCurrentTime(time) {
|
||||
this.currentTime = time
|
||||
if (this.$refs.audioPlayer) {
|
||||
this.$refs.audioPlayer.setCurrentTime(time)
|
||||
}
|
||||
},
|
||||
setDuration(duration) {
|
||||
this.totalDuration = duration
|
||||
if (this.$refs.audioPlayer) {
|
||||
this.$refs.audioPlayer.setDuration(duration)
|
||||
}
|
||||
},
|
||||
setBufferTime(buffertime) {
|
||||
if (this.$refs.audioPlayer) {
|
||||
this.$refs.audioPlayer.setBufferTime(buffertime)
|
||||
}
|
||||
},
|
||||
showBookmarks(currentTime) {
|
||||
this.bookmarkAudiobookId = this.audiobookId
|
||||
|
|
@ -128,47 +152,12 @@ export default {
|
|||
this.showBookmarksModal = true
|
||||
},
|
||||
selectBookmark(bookmark) {
|
||||
if (this.$refs.audioPlayer) {
|
||||
this.$refs.audioPlayer.selectBookmark(bookmark)
|
||||
}
|
||||
this.seek(bookmark.time)
|
||||
this.showBookmarksModal = false
|
||||
},
|
||||
filterByAuthor() {
|
||||
if (this.$route.name !== 'index') {
|
||||
this.$router.push(`/library/${this.libraryId || this.$store.state.libraries.currentLibraryId}/bookshelf`)
|
||||
}
|
||||
var settingsUpdate = {
|
||||
filterBy: `authors.${this.$encode(this.author)}`
|
||||
}
|
||||
this.$store.dispatch('user/updateUserSettings', settingsUpdate)
|
||||
},
|
||||
audioPlayerMounted() {
|
||||
this.audioPlayerReady = true
|
||||
if (this.stream) {
|
||||
console.log('[STREAM-CONTAINER] audioPlayer Mounted w/ Stream', this.stream)
|
||||
this.openStream()
|
||||
}
|
||||
},
|
||||
cancelStream() {
|
||||
this.$root.socket.emit('close_stream')
|
||||
},
|
||||
terminateStream() {
|
||||
if (this.$refs.audioPlayer) {
|
||||
this.$refs.audioPlayer.terminateStream()
|
||||
}
|
||||
},
|
||||
openStream() {
|
||||
var playOnLoad = this.$store.state.playOnLoad
|
||||
console.log(`[StreamContainer] openStream PlayOnLoad`, playOnLoad)
|
||||
if (!this.$refs.audioPlayer) {
|
||||
console.error('NO Audio Player')
|
||||
return
|
||||
}
|
||||
var currentTime = this.stream.clientCurrentTime || 0
|
||||
this.$refs.audioPlayer.set(this.playlistUrl, currentTime, playOnLoad)
|
||||
if (this.stream.isTranscodeComplete) {
|
||||
this.$refs.audioPlayer.setStreamReady()
|
||||
}
|
||||
closePlayer() {
|
||||
this.playerHandler.closePlayer()
|
||||
this.$store.commit('setStreamAudiobook', null)
|
||||
},
|
||||
streamProgress(data) {
|
||||
if (!data.numSegments) return
|
||||
|
|
@ -181,21 +170,14 @@ export default {
|
|||
}
|
||||
},
|
||||
streamOpen(stream) {
|
||||
this.stream = stream
|
||||
this.$store.commit('updateStreamAudiobook', stream.audiobook)
|
||||
|
||||
if (this.$refs.audioPlayer) {
|
||||
console.log('[StreamContainer] streamOpen', stream)
|
||||
this.openStream()
|
||||
} else if (this.audioPlayerReady) {
|
||||
console.error('No Audio Ref')
|
||||
}
|
||||
this.$store.commit('setStreamAudiobook', stream.audiobook)
|
||||
this.playerHandler.prepareStream(stream)
|
||||
},
|
||||
streamClosed(streamId) {
|
||||
if (this.stream && (this.stream.id === streamId || streamId === 'n/a')) {
|
||||
this.terminateStream()
|
||||
this.$store.commit('clearStreamAudiobook', this.stream.audiobook.id)
|
||||
this.stream = null
|
||||
// Stream was closed from the server
|
||||
if (this.playerHandler.isPlayingLocalAudiobook && this.playerHandler.currentStreamId === streamId) {
|
||||
console.warn('[StreamContainer] Closing stream due to request from server')
|
||||
this.playerHandler.closePlayer()
|
||||
}
|
||||
},
|
||||
streamReady() {
|
||||
|
|
@ -207,41 +189,42 @@ export default {
|
|||
}
|
||||
},
|
||||
streamError(streamId) {
|
||||
if (this.stream && (this.stream.id === streamId || streamId === 'n/a')) {
|
||||
this.terminateStream()
|
||||
this.$store.commit('clearStreamAudiobook', this.stream.audiobook.id)
|
||||
this.stream = null
|
||||
// Stream had critical error from the server
|
||||
if (this.playerHandler.isPlayingLocalAudiobook && this.playerHandler.currentStreamId === streamId) {
|
||||
console.warn('[StreamContainer] Closing stream due to stream error from server')
|
||||
this.playerHandler.closePlayer()
|
||||
}
|
||||
},
|
||||
sendStreamSync(syncData) {
|
||||
var diff = syncData.currentTime - this.lastServerUpdateSentSeconds
|
||||
if (Math.abs(diff) < 1 && !syncData.timeListened) {
|
||||
// No need to sync
|
||||
return
|
||||
}
|
||||
this.$root.socket.emit('stream_sync', syncData)
|
||||
},
|
||||
// updateTime(currentTime) {
|
||||
// var diff = currentTime - this.lastServerUpdateSentSeconds
|
||||
// if (diff > 4 || diff < 0) {
|
||||
// this.lastServerUpdateSentSeconds = currentTime
|
||||
// var updatePayload = {
|
||||
// currentTime,
|
||||
// streamId: this.streamId
|
||||
// }
|
||||
// this.$root.socket.emit('stream_update', updatePayload)
|
||||
// }
|
||||
// },
|
||||
streamReset({ startTime, streamId }) {
|
||||
if (streamId !== this.streamId) {
|
||||
console.error('resetStream StreamId Mismatch', streamId, this.streamId)
|
||||
return
|
||||
}
|
||||
if (this.$refs.audioPlayer) {
|
||||
console.log(`[STREAM-CONTAINER] streamReset Received for time ${startTime}`)
|
||||
this.$refs.audioPlayer.resetStream(startTime)
|
||||
this.playerHandler.resetStream(startTime, streamId)
|
||||
},
|
||||
castSessionActive(isActive) {
|
||||
if (isActive && this.playerHandler.isPlayingLocalAudiobook) {
|
||||
// Cast session started switch to cast player
|
||||
this.playerHandler.switchPlayer()
|
||||
} else if (!isActive && this.playerHandler.isPlayingCastedAudiobook) {
|
||||
// Cast session ended switch to local player
|
||||
this.playerHandler.switchPlayer()
|
||||
}
|
||||
},
|
||||
async playAudiobook(audiobookId) {
|
||||
var audiobook = await this.$axios.$get(`/api/books/${audiobookId}`).catch((error) => {
|
||||
console.error('Failed to fetch full audiobook', error)
|
||||
return null
|
||||
})
|
||||
if (!audiobook) return
|
||||
this.$store.commit('setStreamAudiobook', audiobook)
|
||||
|
||||
this.playerHandler.load(audiobook, true, this.userAudiobookCurrentTime)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$eventBus.$on('cast-session-active', this.castSessionActive)
|
||||
this.$eventBus.$on('play-audiobook', this.playAudiobook)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$eventBus.$off('cast-session-active', this.castSessionActive)
|
||||
this.$eventBus.$off('play-audiobook', this.playAudiobook)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
120
client/components/cards/BookUploadCard.vue
Normal file
120
client/components/cards/BookUploadCard.vue
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
<template>
|
||||
<div class="relative w-full py-4 px-6 border border-white border-opacity-10 shadow-lg rounded-md my-6">
|
||||
<div class="absolute -top-3 -left-3 w-8 h-8 bg-bg border border-white border-opacity-10 flex items-center justify-center rounded-full">
|
||||
<p class="text-base text-white text-opacity-80 font-mono">#{{ book.index }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!processing && !uploadFailed && !uploadSuccess" class="absolute -top-3 -right-3 w-8 h-8 bg-bg border border-white border-opacity-10 flex items-center justify-center rounded-full hover:bg-error cursor-pointer" @click="$emit('remove')">
|
||||
<span class="text-base text-white text-opacity-80 font-mono material-icons">close</span>
|
||||
</div>
|
||||
|
||||
<template v-if="!uploadSuccess && !uploadFailed">
|
||||
<widgets-alert v-if="error" type="error">
|
||||
<p class="text-base">{{ error }}</p>
|
||||
</widgets-alert>
|
||||
|
||||
<div class="flex my-2 -mx-2">
|
||||
<div class="w-1/2 px-2">
|
||||
<ui-text-input-with-label v-model="bookData.title" :disabled="processing" label="Title" @input="titleUpdated" />
|
||||
</div>
|
||||
<div class="w-1/2 px-2">
|
||||
<ui-text-input-with-label v-model="bookData.author" :disabled="processing" label="Author" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex my-2 -mx-2">
|
||||
<div class="w-1/2 px-2">
|
||||
<ui-text-input-with-label v-model="bookData.series" :disabled="processing" label="Series" note="(optional)" />
|
||||
</div>
|
||||
<div class="w-1/2 px-2">
|
||||
<div class="w-full">
|
||||
<p class="px-1 text-sm font-semibold">Directory <em class="font-normal text-xs pl-2">(auto)</em></p>
|
||||
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs" style="height: 38px" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<tables-uploaded-files-table :files="book.bookFiles" title="Book Files" class="mt-8" />
|
||||
<tables-uploaded-files-table v-if="book.otherFiles.length" title="Other Files" :files="book.otherFiles" />
|
||||
<tables-uploaded-files-table v-if="book.ignoredFiles.length" title="Ignored Files" :files="book.ignoredFiles" />
|
||||
</template>
|
||||
<widgets-alert v-if="uploadSuccess" type="success">
|
||||
<p class="text-base">Successfully Uploaded!</p>
|
||||
</widgets-alert>
|
||||
<widgets-alert v-if="uploadFailed" type="error">
|
||||
<p class="text-base">Failed to upload</p>
|
||||
</widgets-alert>
|
||||
|
||||
<div v-if="isUploading" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 flex items-center justify-center z-20">
|
||||
<ui-loading-indicator text="Uploading..." />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Path from 'path'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
book: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
processing: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
bookData: {
|
||||
title: '',
|
||||
author: '',
|
||||
series: ''
|
||||
},
|
||||
error: '',
|
||||
isUploading: false,
|
||||
uploadFailed: false,
|
||||
uploadSuccess: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
directory() {
|
||||
if (!this.bookData.title) return ''
|
||||
if (this.bookData.series && this.bookData.author) {
|
||||
return Path.join(this.bookData.author, this.bookData.series, this.bookData.title)
|
||||
} else if (this.bookData.author) {
|
||||
return Path.join(this.bookData.author, this.bookData.title)
|
||||
} else {
|
||||
return this.bookData.title
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setUploadStatus(status) {
|
||||
this.isUploading = status === 'uploading'
|
||||
this.uploadFailed = status === 'failed'
|
||||
this.uploadSuccess = status === 'success'
|
||||
},
|
||||
titleUpdated() {
|
||||
this.error = ''
|
||||
},
|
||||
getData() {
|
||||
if (!this.bookData.title) {
|
||||
this.error = 'Must have a title'
|
||||
return null
|
||||
}
|
||||
this.error = ''
|
||||
var files = this.book.bookFiles.concat(this.book.otherFiles)
|
||||
return {
|
||||
index: this.book.index,
|
||||
...this.bookData,
|
||||
files
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.book) {
|
||||
this.bookData.title = this.book.title
|
||||
this.bookData.author = this.book.author
|
||||
this.bookData.series = this.book.series
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -5,11 +5,13 @@
|
|||
<div class="absolute cover-bg" ref="coverBg" />
|
||||
</div>
|
||||
|
||||
<div v-if="isAlternativeBookshelfView" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${sizeMultiplier * 3}rem` }">
|
||||
<!-- Alternative bookshelf title/author/sort -->
|
||||
<div v-if="isAlternativeBookshelfView" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }">
|
||||
<p class="truncate" :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
|
||||
<span v-if="volumeNumber">#{{ volumeNumber }} </span>{{ title }}
|
||||
<span v-if="volumeNumber">#{{ volumeNumber }} </span>{{ displayTitle }}
|
||||
</p>
|
||||
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ authorFL }}</p>
|
||||
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayAuthor }}</p>
|
||||
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="booksInSeries" class="absolute z-20 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ booksInSeries }}</div>
|
||||
|
|
@ -38,13 +40,13 @@
|
|||
<!-- Overlay is not shown if collapsing series in library -->
|
||||
<div v-show="!booksInSeries && audiobook && (isHovering || isSelectionMode || isMoreMenuOpen)" class="w-full h-full absolute top-0 left-0 z-10 bg-black rounded hidden md:block" :class="overlayWrapperClasslist">
|
||||
<div v-show="showPlayButton" class="h-full flex items-center justify-center pointer-events-none">
|
||||
<div class="hover:text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto" @click.stop.prevent="play">
|
||||
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto" @click.stop.prevent="play">
|
||||
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">play_circle_filled</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="showReadButton" class="h-full flex items-center justify-center pointer-events-none">
|
||||
<div class="hover:text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto" @click.stop.prevent="clickReadEBook">
|
||||
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto" @click.stop.prevent="clickReadEBook">
|
||||
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">auto_stories</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -61,16 +63,20 @@
|
|||
<span class="material-icons" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">more_vert</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Series name overlay -->
|
||||
<div v-if="booksInSeries && audiobook && isHovering" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-60 rounded flex items-center justify-center" :style="{ padding: sizeMultiplier + 'rem' }">
|
||||
<p class="text-gray-200 text-center" :style="{ fontSize: 1.1 * sizeMultiplier + 'rem' }">{{ series }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Error widget -->
|
||||
<ui-tooltip v-if="showError" :text="errorText" class="absolute bottom-4 left-0 z-10">
|
||||
<div :style="{ height: 1.5 * sizeMultiplier + 'rem', width: 2.5 * sizeMultiplier + 'rem' }" class="bg-error rounded-r-full shadow-md flex items-center justify-end border-r border-b border-red-300">
|
||||
<span class="material-icons text-red-100 pr-1" :style="{ fontSize: 0.875 * sizeMultiplier + 'rem' }">priority_high</span>
|
||||
</div>
|
||||
</ui-tooltip>
|
||||
|
||||
<!-- Volume number -->
|
||||
<div v-if="volumeNumber && showVolumeNumber && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ volumeNumber }}</p>
|
||||
</div>
|
||||
|
|
@ -99,7 +105,10 @@ export default {
|
|||
// Book can be passed as prop or set with setEntity()
|
||||
type: Object,
|
||||
default: () => null
|
||||
}
|
||||
},
|
||||
orderBy: String,
|
||||
filterBy: String,
|
||||
sortingIgnorePrefix: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
|
@ -114,6 +123,15 @@ export default {
|
|||
showCoverBg: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
bookMount: {
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.audiobook = newVal
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
showExperimentalFeatures() {
|
||||
return this.store.state.showExperimentalFeatures
|
||||
|
|
@ -180,6 +198,24 @@ export default {
|
|||
volumeNumber() {
|
||||
return this.book.volumeNumber || null
|
||||
},
|
||||
displayTitle() {
|
||||
if (this.orderBy === 'book.title' && this.sortingIgnorePrefix && this.title.toLowerCase().startsWith('the ')) {
|
||||
return this.title.substr(4) + ', The'
|
||||
}
|
||||
return this.title
|
||||
},
|
||||
displayAuthor() {
|
||||
if (this.orderBy === 'book.authorLF') return this.authorLF
|
||||
return this.authorFL
|
||||
},
|
||||
displaySortLine() {
|
||||
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._audiobook.mtimeMs)
|
||||
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._audiobook.birthtimeMs)
|
||||
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._audiobook.addedAt)
|
||||
if (this.orderBy === 'duration') return 'Duration: ' + this.$elapsedPrettyExtended(this._audiobook.duration, false)
|
||||
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._audiobook.size)
|
||||
return null
|
||||
},
|
||||
userProgress() {
|
||||
return this.store.getters['user/getUserAudiobook'](this.audiobookId)
|
||||
},
|
||||
|
|
@ -322,6 +358,11 @@ export default {
|
|||
isAlternativeBookshelfView() {
|
||||
var constants = this.$constants || this.$nuxt.$constants
|
||||
return this.bookshelfView === constants.BookshelfView.TITLES
|
||||
},
|
||||
titleDisplayBottomOffset() {
|
||||
if (!this.isAlternativeBookshelfView) return 0
|
||||
else if (!this.displaySortLine) return 3 * this.sizeMultiplier
|
||||
return 4.25 * this.sizeMultiplier
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
|
@ -461,8 +502,8 @@ export default {
|
|||
this.$emit('select', this.audiobook)
|
||||
},
|
||||
play() {
|
||||
this.store.commit('setStreamAudiobook', this.audiobook)
|
||||
this._socket.emit('open_stream', this.audiobookId)
|
||||
var eventBus = this.$eventBus || this.$nuxt.$eventBus
|
||||
eventBus.$emit('play-audiobook', this.audiobookId)
|
||||
},
|
||||
mouseover() {
|
||||
this.isHovering = true
|
||||
|
|
|
|||
|
|
@ -59,6 +59,14 @@ export default {
|
|||
{
|
||||
text: 'Size',
|
||||
value: 'size'
|
||||
},
|
||||
{
|
||||
text: 'File Birthtime',
|
||||
value: 'birthtimeMs'
|
||||
},
|
||||
{
|
||||
text: 'File Modified',
|
||||
value: 'mtimeMs'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,6 +89,8 @@ export default {
|
|||
this.showMenu = val
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
mounted() {
|
||||
this.currentPlaybackRate = this.playbackRate
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -51,11 +51,25 @@ export default {
|
|||
}
|
||||
},
|
||||
methods: {
|
||||
scroll(e) {
|
||||
if (!e || !e.wheelDeltaY) return
|
||||
if (e.wheelDeltaY > 0) {
|
||||
this.volume = Math.min(1, this.volume + 0.1)
|
||||
} else {
|
||||
this.volume = Math.max(0, this.volume - 0.1)
|
||||
}
|
||||
},
|
||||
mouseover() {
|
||||
if (!this.isHovering) {
|
||||
window.addEventListener('mousewheel', this.scroll)
|
||||
}
|
||||
this.isHovering = true
|
||||
this.setOpen()
|
||||
},
|
||||
mouseleave() {
|
||||
if (this.isHovering) {
|
||||
window.removeEventListener('mousewheel', this.scroll)
|
||||
}
|
||||
this.isHovering = false
|
||||
},
|
||||
setOpen() {
|
||||
|
|
@ -127,6 +141,11 @@ export default {
|
|||
if (this.value === 0) {
|
||||
this.isMute = true
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('mousewheel', this.scroll)
|
||||
document.body.removeEventListener('mousemove', this.mousemove)
|
||||
document.body.removeEventListener('mouseup', this.mouseup)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -4,9 +4,6 @@
|
|||
<div v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary">
|
||||
<div class="absolute cover-bg" ref="coverBg" />
|
||||
</div>
|
||||
<!-- <div v-if="showCoverBg" class="bg-primary absolute top-0 left-0 w-full h-full">
|
||||
<div class="w-full h-full z-0" ref="coverBg" />
|
||||
</div> -->
|
||||
<img ref="cover" :src="cover" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0" :class="showCoverBg ? 'object-contain' : 'object-cover'" />
|
||||
|
||||
<a v-if="!imageFailed && showOpenNewTab && isHovering" :href="cover" @click.stop target="_blank" class="absolute bg-primary flex items-center justify-center shadow-sm rounded-full hover:scale-110 transform duration-100" :style="{ top: sizeMultiplier * 0.5 + 'rem', right: sizeMultiplier * 0.5 + 'rem', width: 2.5 * sizeMultiplier + 'rem', height: 2.5 * sizeMultiplier + 'rem' }">
|
||||
|
|
|
|||
|
|
@ -39,20 +39,6 @@
|
|||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- <form @submit.prevent="submitSearchForm">
|
||||
<div class="flex items-center justify-start -mx-1 py-2 mt-2">
|
||||
<div class="flex-grow px-1">
|
||||
<ui-text-input-with-label v-model="searchTitle" label="Search Title" placeholder="Search" :disabled="processing" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-text-input-with-label v-model="searchAuthor" label="Author" :disabled="processing" />
|
||||
</div>
|
||||
<div class="w-24 px-1">
|
||||
<ui-btn type="submit" class="mt-5 w-full" :padding-x="0">Search</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</form> -->
|
||||
</div>
|
||||
</div>
|
||||
<form @submit.prevent="submitSearchForm">
|
||||
|
|
|
|||
|
|
@ -72,6 +72,10 @@
|
|||
<ui-btn v-if="isRootUser" :loading="savingMetadata" color="bg" type="button" class="h-full" small @click.stop.prevent="saveMetadata">Save Metadata</ui-btn>
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip :disabled="!!quickMatching" :text="`(Root User Only) Populate empty book details & cover with first book result from '${libraryProvider}'. Does not overwrite details.`" direction="bottom" class="mr-4">
|
||||
<ui-btn v-if="isRootUser" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">Quick Match</ui-btn>
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip :disabled="!!libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="mr-4">
|
||||
<ui-btn v-if="isRootUser" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn>
|
||||
</ui-tooltip>
|
||||
|
|
@ -113,7 +117,8 @@ export default {
|
|||
resettingProgress: false,
|
||||
isScrollable: false,
|
||||
savingMetadata: false,
|
||||
rescanning: false
|
||||
rescanning: false,
|
||||
quickMatching: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
|
|
@ -163,12 +168,41 @@ export default {
|
|||
libraryId() {
|
||||
return this.audiobook ? this.audiobook.libraryId : null
|
||||
},
|
||||
libraryProvider() {
|
||||
return this.$store.getters['libraries/getLibraryProvider'](this.libraryId) || 'google'
|
||||
},
|
||||
libraryScan() {
|
||||
if (!this.libraryId) return null
|
||||
return this.$store.getters['scanners/getLibraryScan'](this.libraryId)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
quickMatch() {
|
||||
this.quickMatching = true
|
||||
var matchOptions = {
|
||||
provider: this.libraryProvider,
|
||||
title: this.details.title,
|
||||
author: this.details.author !== this.book.author ? this.details.author : null
|
||||
}
|
||||
this.$axios
|
||||
.$post(`/api/books/${this.audiobookId}/match`, matchOptions)
|
||||
.then((res) => {
|
||||
this.quickMatching = false
|
||||
if (res.warning) {
|
||||
this.$toast.warning(res.warning)
|
||||
} else if (res.updated) {
|
||||
this.$toast.success('Audiobook details updated')
|
||||
} else {
|
||||
this.$toast.info('No updates were made')
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
var errMsg = error.response ? error.response.data || '' : ''
|
||||
console.error('Failed to match', error)
|
||||
this.$toast.error(errMsg || 'Failed to match')
|
||||
this.quickMatching = false
|
||||
})
|
||||
},
|
||||
audiobookScanComplete(result) {
|
||||
this.rescanning = false
|
||||
if (!result) {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,19 @@
|
|||
<template>
|
||||
<div class="w-full h-full px-4 py-2 mb-12">
|
||||
<div class="flex items-center py-1 mb-2">
|
||||
<span v-show="showDirectoryPicker" class="material-icons text-3xl cursor-pointer hover:text-gray-300" @click="backArrowPress">arrow_back</span>
|
||||
<div class="w-full h-full px-4 py-2 mb-4">
|
||||
<div v-show="showDirectoryPicker" class="flex items-center py-1 mb-2">
|
||||
<span class="material-icons text-3xl cursor-pointer hover:text-gray-300" @click="backArrowPress">arrow_back</span>
|
||||
<p class="px-4 text-xl">{{ title }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!showDirectoryPicker" class="w-full h-full py-4">
|
||||
<ui-text-input-with-label v-model="name" label="Library Name" />
|
||||
<div class="flex flex-wrap -mx-1">
|
||||
<div class="w-full md:w-2/3 px-1">
|
||||
<ui-text-input-with-label v-model="name" label="Library Name" />
|
||||
</div>
|
||||
<div class="w-full md:w-1/3 px-1">
|
||||
<ui-dropdown v-model="provider" :items="providers" label="Metadata Provider" small />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full py-4">
|
||||
<p class="px-1 text-sm font-semibold">Folders</p>
|
||||
|
|
@ -26,7 +33,17 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<modals-libraries-folder-chooser v-else :paths="folderPaths" @select="selectFolder" />
|
||||
|
||||
<div v-if="!showDirectoryPicker">
|
||||
<div class="flex items-center pt-2">
|
||||
<ui-toggle-switch v-if="!globalWatcherDisabled" v-model="disableWatcher" />
|
||||
<ui-toggle-switch v-else disabled :value="false" />
|
||||
<p class="pl-4 text-lg">Disable folder watcher for library</p>
|
||||
</div>
|
||||
<p v-if="globalWatcherDisabled" class="text-xs text-warning">*Watcher is disabled globally in server settings</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -42,8 +59,10 @@ export default {
|
|||
data() {
|
||||
return {
|
||||
name: '',
|
||||
provider: '',
|
||||
folders: [],
|
||||
showDirectoryPicker: false
|
||||
showDirectoryPicker: false,
|
||||
disableWatcher: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
|
@ -61,7 +80,13 @@ export default {
|
|||
var newfolderpaths = this.folderPaths.join(',')
|
||||
var origfolderpaths = this.library.folders.map((f) => f.fullPath).join(',')
|
||||
|
||||
return newfolderpaths === origfolderpaths && this.name === this.library.name
|
||||
return newfolderpaths === origfolderpaths && this.name === this.library.name && this.provider === this.library.provider && this.disableWatcher === this.library.disableWatcher
|
||||
},
|
||||
providers() {
|
||||
return this.$store.state.scanners.providers
|
||||
},
|
||||
globalWatcherDisabled() {
|
||||
return this.$store.getters['getServerSetting']('scannerDisableWatcher')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
|
@ -75,7 +100,9 @@ export default {
|
|||
},
|
||||
init() {
|
||||
this.name = this.library ? this.library.name : ''
|
||||
this.provider = this.library ? this.library.provider : ''
|
||||
this.folders = this.library ? this.library.folders.map((p) => ({ ...p })) : []
|
||||
this.disableWatcher = this.library ? !!this.library.disableWatcher : false
|
||||
this.showDirectoryPicker = false
|
||||
},
|
||||
selectFolder(fullPath) {
|
||||
|
|
@ -100,7 +127,9 @@ export default {
|
|||
}
|
||||
var newLibraryPayload = {
|
||||
name: this.name,
|
||||
folders: this.folders
|
||||
provider: this.provider,
|
||||
folders: this.folders,
|
||||
disableWatcher: this.disableWatcher
|
||||
}
|
||||
|
||||
this.$emit('update:processing', true)
|
||||
|
|
@ -132,7 +161,9 @@ export default {
|
|||
}
|
||||
var newLibraryPayload = {
|
||||
name: this.name,
|
||||
folders: this.folders
|
||||
provider: this.provider,
|
||||
folders: this.folders,
|
||||
disableWatcher: this.disableWatcher
|
||||
}
|
||||
|
||||
this.$emit('update:processing', true)
|
||||
|
|
|
|||
|
|
@ -9,8 +9,11 @@
|
|||
</svg>
|
||||
<p class="text-xl font-book pl-4 hover:underline cursor-pointer" @click.stop="$emit('click', library)">{{ library.name }}</p>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn v-show="isHovering && !libraryScan && canScan" small color="bg" @click.stop="scan">Scan</ui-btn>
|
||||
<ui-btn v-show="isHovering && !libraryScan && canScan" small color="success" @click.stop="scan">Scan</ui-btn>
|
||||
<ui-btn v-show="isHovering && !libraryScan && canScan" small color="bg" class="ml-2" @click.stop="forceScan">Force Re-Scan</ui-btn>
|
||||
|
||||
<ui-btn v-show="isHovering && !libraryScan && canScan" small color="bg" class="ml-2" @click.stop="matchAll">Match Books</ui-btn>
|
||||
|
||||
<span v-show="isHovering && !libraryScan && showEdit && canEdit" class="material-icons text-xl text-gray-300 hover:text-gray-50 ml-4 cursor-pointer" @click.stop="editClick">edit</span>
|
||||
<span v-show="!libraryScan && isHovering && showEdit && canDelete && !isDeleting" class="material-icons text-xl text-gray-300 ml-3" :class="isMain ? 'text-opacity-5 cursor-not-allowed' : 'hover:text-gray-50 cursor-pointer'" @click.stop="deleteClick">delete</span>
|
||||
<div v-show="isDeleting" class="text-xl text-gray-300 ml-3 animate-spin">
|
||||
|
|
@ -59,6 +62,18 @@ export default {
|
|||
}
|
||||
},
|
||||
methods: {
|
||||
matchAll() {
|
||||
this.$axios
|
||||
.$post(`/api/libraries/${this.library.id}/matchbooks`)
|
||||
.then(() => {
|
||||
console.log('Starting scan for matches')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
var errorMsg = err.response ? err.response.data : ''
|
||||
this.$toast.error(errorMsg || 'Match all failed')
|
||||
})
|
||||
},
|
||||
editClick() {
|
||||
this.$emit('edit', this.library)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@
|
|||
<modals-edit-library-modal v-model="showLibraryModal" :library="selectedLibrary" />
|
||||
|
||||
<p class="text-xs mt-4 text-gray-200">*<strong>Force Re-Scan</strong> will scan all files again like a fresh scan. Audio file ID3 tags, OPF files, and text files will be probed/parsed and used for book details.</p>
|
||||
|
||||
<p class="text-xs mt-4 text-gray-200">**<strong>Match Books</strong> will attempt to match books in library with a book from the selected search provider and fill in empty details and cover art. Does not overwrite details.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
60
client/components/tables/UploadedFilesTable.vue
Normal file
60
client/components/tables/UploadedFilesTable.vue
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<template>
|
||||
<div class="w-full my-4" @mousedown.prevent @mouseup.prevent>
|
||||
<div class="w-full bg-primary px-6 py-1 flex items-center cursor-pointer" @click.stop="clickBar">
|
||||
<p class="pr-4">{{ title }}</p>
|
||||
<span class="bg-black-400 rounded-xl py-0.5 px-2 text-sm font-mono">{{ files.length }}</span>
|
||||
<div class="flex-grow" />
|
||||
<div class="cursor-pointer h-9 w-9 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="expand ? 'transform rotate-180' : ''">
|
||||
<span class="material-icons text-3xl">expand_more</span>
|
||||
</div>
|
||||
</div>
|
||||
<transition name="slide">
|
||||
<div class="w-full" v-show="expand">
|
||||
<table class="text-sm tracksTable">
|
||||
<tr class="font-book">
|
||||
<th class="text-left">Filename</th>
|
||||
<th class="text-left">Size</th>
|
||||
<th class="text-left">Type</th>
|
||||
</tr>
|
||||
<template v-for="file in files">
|
||||
<tr :key="file.path">
|
||||
<td class="font-book pl-2">
|
||||
{{ file.name }}
|
||||
</td>
|
||||
<td class="font-mono">
|
||||
{{ $bytesPretty(file.size) }}
|
||||
</td>
|
||||
<td class="font-book">
|
||||
{{ file.filetype }}
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</table>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
title: String,
|
||||
files: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
expand: false
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
clickBar() {
|
||||
this.expand = !this.expand
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -133,8 +133,7 @@ export default {
|
|||
this.isHovering = false
|
||||
},
|
||||
playClick() {
|
||||
this.$store.commit('setStreamAudiobook', this.book)
|
||||
this.$root.socket.emit('open_stream', this.book.id)
|
||||
this.$eventBus.$emit('play-audiobook', this.book.id)
|
||||
},
|
||||
clickEdit() {
|
||||
this.$emit('edit', this.book)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<div class="relative w-full" v-click-outside="clickOutsideObj">
|
||||
<p class="text-sm font-semibold">{{ label }}</p>
|
||||
<button type="button" :disabled="disabled" class="relative w-full border border-gray-500 rounded shadow-sm pl-3 pr-8 py-2 text-left focus:outline-none sm:text-sm cursor-pointer bg-primary" :class="small ? 'h-9' : 'h-10'" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
||||
<button type="button" :disabled="disabled" class="relative w-full border border-gray-600 rounded shadow-sm pl-3 pr-8 py-2 text-left focus:outline-none sm:text-sm cursor-pointer bg-primary" :class="small ? 'h-9' : 'h-10'" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
||||
<span class="flex items-center">
|
||||
<span class="block truncate" :class="small ? 'text-sm' : ''">{{ selectedText }}</span>
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
<template>
|
||||
<div class="w-full" :class="disabled ? 'cursor-not-allowed' : ''">
|
||||
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
|
||||
<div class="w-full">
|
||||
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p>
|
||||
<div ref="wrapper" class="relative">
|
||||
<form @submit.prevent="submitForm">
|
||||
<div ref="inputWrapper" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-2" :class="disabled ? 'bg-bg pointer-events-none text-gray-400' : 'bg-primary'">
|
||||
<div ref="inputWrapper" class="input-wrapper flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-2" :class="disabled ? 'pointer-events-none bg-black-300 text-gray-400' : 'bg-primary'">
|
||||
<input ref="input" v-model="textInput" :disabled="disabled" :readonly="!editable" class="h-full w-full bg-transparent focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div class="w-full">
|
||||
<p class="px-1 text-sm font-semibold">{{ label }}</p>
|
||||
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p>
|
||||
<div ref="wrapper" class="relative">
|
||||
<form @submit.prevent="submitForm">
|
||||
<div ref="inputWrapper" style="min-height: 40px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded-md px-2 py-1 cursor-text" :class="disabled ? 'bg-black-300' : 'bg-primary'" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ input {
|
|||
border-style: inherit !important;
|
||||
}
|
||||
input:read-only {
|
||||
color: #aaa;
|
||||
color: #bbb;
|
||||
background-color: #444;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">
|
||||
{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
|
||||
</p>
|
||||
<ui-text-input v-model="inputValue" :disabled="disabled" :type="type" class="w-full" />
|
||||
<ui-text-input ref="input" v-model="inputValue" :disabled="disabled" :type="type" class="w-full" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -32,7 +32,13 @@ export default {
|
|||
}
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
methods: {
|
||||
blur() {
|
||||
if (this.$refs.input && this.$refs.input.blur) {
|
||||
this.$refs.input.blur()
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
33
client/components/widgets/Alert.vue
Normal file
33
client/components/widgets/Alert.vue
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<template>
|
||||
<div class="w-full bg-opacity-5 border border-opacity-60 rounded-lg flex items-center relative py-4 pl-16" :class="wrapperClass">
|
||||
<div class="absolute top-0 left-4 h-full flex items-center">
|
||||
<span class="material-icons-outlined text-2xl">{{ icon }}</span>
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
type: {
|
||||
type: String,
|
||||
default: 'error'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
icon() {
|
||||
if (this.type === 'error' || this.type === 'warning') return 'report'
|
||||
return 'info'
|
||||
},
|
||||
wrapperClass() {
|
||||
return `bg-${this.type} border-${this.type} text-${this.type}`
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -43,6 +43,9 @@ export default {
|
|||
computed: {
|
||||
user() {
|
||||
return this.$store.state.user.user
|
||||
},
|
||||
isCasting() {
|
||||
return this.$store.state.globals.isCasting
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
|
@ -99,7 +102,6 @@ export default {
|
|||
console.log('Init Payload', payload)
|
||||
if (payload.stream) {
|
||||
if (this.$refs.streamContainer) {
|
||||
this.$store.commit('setStream', payload.stream)
|
||||
this.$refs.streamContainer.streamOpen(payload.stream)
|
||||
} else {
|
||||
console.warn('Stream Container not mounted')
|
||||
|
|
@ -110,7 +112,12 @@ export default {
|
|||
this.$store.commit('user/setSettings', payload.user.settings)
|
||||
}
|
||||
if (payload.serverSettings) {
|
||||
this.$store.commit('settings/setServerSettings', payload.serverSettings)
|
||||
this.$store.commit('setServerSettings', payload.serverSettings)
|
||||
|
||||
if (payload.serverSettings.chromecastEnabled) {
|
||||
console.log('Chromecast enabled import script')
|
||||
require('@/plugins/chromecast.js').default(this)
|
||||
}
|
||||
}
|
||||
if (payload.SSOSettings) {
|
||||
this.$store.commit('settings/setSSOSettings', payload.SSOSettings)
|
||||
|
|
@ -208,7 +215,7 @@ export default {
|
|||
scanComplete(data) {
|
||||
console.log('Scan complete received', data)
|
||||
|
||||
var message = `Scan "${data.name}" complete!`
|
||||
var message = `${data.type === 'match' ? 'Match' : 'Scan'} "${data.name}" complete!`
|
||||
if (data.results) {
|
||||
var scanResultMsgs = []
|
||||
var results = data.results
|
||||
|
|
@ -219,7 +226,7 @@ export default {
|
|||
if (!scanResultMsgs.length) message += '\nEverything was up to date'
|
||||
else message += '\n' + scanResultMsgs.join('\n')
|
||||
} else {
|
||||
message = `Scan "${data.name}" was canceled`
|
||||
message = `${data.type === 'match' ? 'Match' : 'Scan'} "${data.name}" was canceled`
|
||||
}
|
||||
|
||||
var existingScan = this.$store.getters['scanners/getLibraryScan'](data.id)
|
||||
|
|
@ -235,7 +242,7 @@ export default {
|
|||
this.$root.socket.emit('cancel_scan', id)
|
||||
},
|
||||
scanStart(data) {
|
||||
data.toastId = this.$toast(`Scanning "${data.name}"...`, { timeout: false, type: 'info', draggable: false, closeOnClick: false, closeButton: CloseButton, closeButtonClassName: 'cancel-scan-btn', showCloseButtonOnHover: false, position: 'bottom-center', onClose: () => this.onScanToastCancel(data.id) })
|
||||
data.toastId = this.$toast(`${data.type === 'match' ? 'Matching' : 'Scanning'} "${data.name}"...`, { timeout: false, type: 'info', draggable: false, closeOnClick: false, closeButton: CloseButton, closeButtonClassName: 'cancel-scan-btn', showCloseButtonOnHover: false, position: 'bottom-center', onClose: () => this.onScanToastCancel(data.id) })
|
||||
this.$store.commit('scanners/addUpdate', data)
|
||||
},
|
||||
scanProgress(data) {
|
||||
|
|
@ -514,6 +521,7 @@ export default {
|
|||
this.resize()
|
||||
window.addEventListener('resize', this.resize)
|
||||
window.addEventListener('keydown', this.keyDown)
|
||||
|
||||
this.$store.dispatch('libraries/load')
|
||||
|
||||
// If experimental features set in local storage
|
||||
|
|
|
|||
|
|
@ -55,6 +55,11 @@ export default {
|
|||
bookshelfView: this.bookshelfView
|
||||
}
|
||||
if (this.entityName === 'series-books') props.showVolumeNumber = true
|
||||
if (this.entityName === 'books') {
|
||||
props.filterBy = this.filterBy
|
||||
props.orderBy = this.orderBy
|
||||
props.sortingIgnorePrefix = !!this.sortingIgnorePrefix
|
||||
}
|
||||
|
||||
var _this = this
|
||||
var instance = new ComponentClass({
|
||||
|
|
|
|||
229
client/mixins/uploadHelpers.js
Normal file
229
client/mixins/uploadHelpers.js
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
import Path from 'path'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
uploadHelpers: {
|
||||
getBooksFromDrop: this.getBooksFromDataTransferItems,
|
||||
getBooksFromPicker: this.getBooksFromFileList
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
checkFileType(filename) {
|
||||
var ext = Path.extname(filename)
|
||||
if (!ext) return false
|
||||
if (ext.startsWith('.')) ext = ext.slice(1)
|
||||
ext = ext.toLowerCase()
|
||||
|
||||
for (const filetype in this.$constants.SupportedFileTypes) {
|
||||
if (this.$constants.SupportedFileTypes[filetype].includes(ext)) {
|
||||
return filetype
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
filterAudiobookFiles(files) {
|
||||
var validBookFiles = []
|
||||
var validOtherFiles = []
|
||||
var ignoredFiles = []
|
||||
files.forEach((file) => {
|
||||
var filetype = this.checkFileType(file.name)
|
||||
if (!filetype) ignoredFiles.push(file)
|
||||
else {
|
||||
file.filetype = filetype
|
||||
if (filetype === 'audio' || filetype === 'ebook') validBookFiles.push(file)
|
||||
else validOtherFiles.push(file)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
bookFiles: validBookFiles,
|
||||
otherFiles: validOtherFiles,
|
||||
ignoredFiles
|
||||
}
|
||||
},
|
||||
audiobookFromItems(items) {
|
||||
var { bookFiles, otherFiles, ignoredFiles } = this.filterAudiobookFiles(items)
|
||||
if (!bookFiles.length) {
|
||||
ignoredFiles = ignoredFiles.concat(otherFiles)
|
||||
otherFiles = []
|
||||
}
|
||||
return [
|
||||
{
|
||||
bookFiles,
|
||||
otherFiles,
|
||||
ignoredFiles
|
||||
}
|
||||
]
|
||||
},
|
||||
traverseForAudiobook(folder, depth = 1) {
|
||||
if (folder.items.some((f) => f.isDirectory)) {
|
||||
var audiobooks = []
|
||||
folder.items.forEach((file) => {
|
||||
if (file.isDirectory) {
|
||||
var audiobookResults = this.traverseForAudiobook(file, ++depth)
|
||||
audiobooks = audiobooks.concat(audiobookResults)
|
||||
}
|
||||
})
|
||||
return audiobooks
|
||||
} else {
|
||||
return this.audiobookFromItems(folder.items)
|
||||
}
|
||||
},
|
||||
fileTreeToAudiobooks(filetree) {
|
||||
// Has directores - Is Multi Book Drop
|
||||
if (filetree.some((f) => f.isDirectory)) {
|
||||
var ignoredFilesInRoot = filetree.filter((f) => !f.isDirectory)
|
||||
if (ignoredFilesInRoot.length) filetree = filetree.filter((f) => f.isDirectory)
|
||||
|
||||
var audiobookResults = this.traverseForAudiobook({ items: filetree })
|
||||
return {
|
||||
audiobooks: audiobookResults,
|
||||
ignoredFiles: ignoredFilesInRoot
|
||||
}
|
||||
} else {
|
||||
// Single Book drop
|
||||
return {
|
||||
audiobooks: this.audiobookFromItems(filetree),
|
||||
ignoredFiles: []
|
||||
}
|
||||
}
|
||||
},
|
||||
getFilesDropped(dataTransferItems) {
|
||||
var treemap = {
|
||||
path: '/',
|
||||
items: []
|
||||
}
|
||||
function traverseFileTreePromise(item, currtreemap) {
|
||||
return new Promise((resolve) => {
|
||||
if (item.isFile) {
|
||||
item.file((file) => {
|
||||
file.filepath = currtreemap.path + file.name //save full path
|
||||
currtreemap.items.push(file)
|
||||
resolve(file)
|
||||
})
|
||||
} else if (item.isDirectory) {
|
||||
let dirReader = item.createReader()
|
||||
currtreemap.items.push({
|
||||
isDirectory: true,
|
||||
dirname: item.name,
|
||||
path: currtreemap.path + item.name + '/',
|
||||
items: []
|
||||
})
|
||||
var newtreemap = currtreemap.items[currtreemap.items.length - 1]
|
||||
dirReader.readEntries((entries) => {
|
||||
let entriesPromises = []
|
||||
for (let entr of entries) entriesPromises.push(traverseFileTreePromise(entr, newtreemap))
|
||||
resolve(Promise.all(entriesPromises))
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let entriesPromises = []
|
||||
for (let it of dataTransferItems) {
|
||||
var filetree = traverseFileTreePromise(it.webkitGetAsEntry(), treemap)
|
||||
entriesPromises.push(filetree)
|
||||
}
|
||||
Promise.all(entriesPromises).then(() => {
|
||||
resolve(treemap.items)
|
||||
})
|
||||
})
|
||||
},
|
||||
cleanBook(book, index) {
|
||||
var audiobook = {
|
||||
index,
|
||||
title: '',
|
||||
author: '',
|
||||
series: '',
|
||||
...book
|
||||
}
|
||||
var firstBookFile = book.bookFiles[0]
|
||||
if (!firstBookFile.filepath) return audiobook // No path
|
||||
|
||||
var firstBookPath = Path.dirname(firstBookFile.filepath)
|
||||
|
||||
var dirs = firstBookPath.split('/').filter(d => !!d && d !== '.')
|
||||
if (dirs.length) {
|
||||
audiobook.title = dirs.pop()
|
||||
if (dirs.length > 1) {
|
||||
audiobook.series = dirs.pop()
|
||||
}
|
||||
if (dirs.length) {
|
||||
audiobook.author = dirs.pop()
|
||||
}
|
||||
}
|
||||
return audiobook
|
||||
},
|
||||
async getBooksFromDataTransferItems(items) {
|
||||
var files = await this.getFilesDropped(items)
|
||||
if (!files || !files.length) return { error: 'No files found ' }
|
||||
var audiobooksData = this.fileTreeToAudiobooks(files)
|
||||
if (!audiobooksData.audiobooks.length && !audiobooksData.ignoredFiles.length) {
|
||||
return { error: 'Invalid file drop' }
|
||||
}
|
||||
var ignoredFiles = audiobooksData.ignoredFiles
|
||||
var index = 1
|
||||
var books = audiobooksData.audiobooks.filter((ab) => {
|
||||
if (!ab.bookFiles.length) {
|
||||
if (ab.otherFiles.length) ignoredFiles = ignoredFiles.concat(ab.otherFiles)
|
||||
if (ab.ignoredFiles.length) ignoredFiles = ignoredFiles.concat(ab.ignoredFiles)
|
||||
}
|
||||
return ab.bookFiles.length
|
||||
}).map(ab => this.cleanBook(ab, index++))
|
||||
return {
|
||||
books,
|
||||
ignoredFiles
|
||||
}
|
||||
},
|
||||
getBooksFromFileList(filelist) {
|
||||
var ignoredFiles = []
|
||||
var otherFiles = []
|
||||
|
||||
var bookMap = {}
|
||||
|
||||
filelist.forEach((file) => {
|
||||
var filetype = this.checkFileType(file.name)
|
||||
if (!filetype) ignoredFiles.push(file)
|
||||
else {
|
||||
file.filetype = filetype
|
||||
if (file.webkitRelativePath) file.filepath = file.webkitRelativePath
|
||||
|
||||
if (filetype === 'audio' || filetype === 'ebook') {
|
||||
var dir = file.filepath ? Path.dirname(file.filepath) : ''
|
||||
if (!bookMap[dir]) {
|
||||
bookMap[dir] = {
|
||||
path: dir,
|
||||
ignoredFiles: [],
|
||||
bookFiles: [],
|
||||
otherFiles: []
|
||||
}
|
||||
}
|
||||
bookMap[dir].bookFiles.push(file)
|
||||
} else {
|
||||
otherFiles.push(file)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
otherFiles.forEach((file) => {
|
||||
var dir = Path.dirname(file.filepath)
|
||||
var findBook = Object.values(bookMap).find(b => dir.startsWith(b.path))
|
||||
if (findBook) {
|
||||
bookMap[dir].otherFiles.push(file)
|
||||
} else {
|
||||
ignoredFiles.push(file)
|
||||
}
|
||||
})
|
||||
|
||||
var index = 1
|
||||
var books = Object.values(bookMap).map(ab => this.cleanBook(ab, index++))
|
||||
return {
|
||||
books,
|
||||
ignoredFiles: ignoredFiles
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ module.exports = {
|
|||
dev: process.env.NODE_ENV !== 'production',
|
||||
env: {
|
||||
serverUrl: process.env.NODE_ENV === 'production' ? '' : 'http://localhost:3333',
|
||||
// serverUrl: '',
|
||||
chromecastReceiver: 'FD1F76C5',
|
||||
baseUrl: process.env.BASE_URL || 'http://0.0.0.0'
|
||||
},
|
||||
// rootDir: process.env.NODE_ENV !== 'production' ? 'client/' : '',
|
||||
|
|
@ -30,13 +30,12 @@ module.exports = {
|
|||
],
|
||||
script: [
|
||||
{
|
||||
src: '//cdn.jsdelivr.net/npm/sortablejs@1.8.4/Sortable.min.js'
|
||||
src: '/libs/sortable.js'
|
||||
}
|
||||
],
|
||||
link: [
|
||||
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
|
||||
{ rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Ubuntu+Mono&family=Source+Sans+Pro:wght@300;400;600' },
|
||||
// { rel: 'stylesheet', href: 'https://fonts.googleapis.com/icon?family=Material+Icons' }
|
||||
]
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "1.6.66",
|
||||
"version": "1.7.1",
|
||||
"description": "Audiobook manager and player",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,20 @@
|
|||
<div class="w-full flex justify-center md:block md:w-52" style="min-width: 208px">
|
||||
<div class="relative" style="height: fit-content">
|
||||
<covers-book-cover :audiobook="audiobook" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
|
||||
<!-- Book Progress Bar -->
|
||||
<div class="absolute bottom-0 left-0 h-1.5 bg-yellow-400 shadow-sm z-10" :class="userIsRead ? 'bg-success' : 'bg-yellow-400'" :style="{ width: 208 * progressPercent + 'px' }"></div>
|
||||
|
||||
<!-- Book Cover Overlay -->
|
||||
<div class="absolute top-0 left-0 w-full h-full z-10 bg-black bg-opacity-30 opacity-0 hover:opacity-100 transition-opacity" @mousedown.prevent @mouseup.prevent>
|
||||
<div v-show="showPlayButton && !streaming" class="h-full flex items-center justify-center pointer-events-none">
|
||||
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto cursor-pointer" @click.stop.prevent="startStream">
|
||||
<span class="material-icons text-4xl">play_circle_filled</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="absolute bottom-2.5 right-2.5 z-10 material-icons text-lg cursor-pointer text-white text-opacity-75 hover:text-opacity-100 hover:scale-110 transform duration-200" @click="showEditCover">edit</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow px-2 py-6 md:py-0 md:px-10">
|
||||
|
|
@ -378,6 +391,10 @@ export default {
|
|||
}
|
||||
},
|
||||
methods: {
|
||||
showEditCover() {
|
||||
this.$store.commit('setBookshelfBookIds', [])
|
||||
this.$store.commit('showEditModalOnTab', { audiobook: this.audiobook, tab: 'cover' })
|
||||
},
|
||||
openEbook() {
|
||||
this.$store.commit('showEReader', this.audiobook)
|
||||
},
|
||||
|
|
@ -411,8 +428,7 @@ export default {
|
|||
})
|
||||
},
|
||||
startStream() {
|
||||
this.$store.commit('setStreamAudiobook', this.audiobook)
|
||||
this.$root.socket.emit('open_stream', this.audiobook.id)
|
||||
this.$eventBus.$emit('play-audiobook', this.audiobook.id)
|
||||
},
|
||||
editClick() {
|
||||
this.$store.commit('setBookshelfBookIds', [])
|
||||
|
|
|
|||
|
|
@ -1,31 +1,58 @@
|
|||
<template>
|
||||
<div ref="page" id="page-wrapper" class="page px-6 pt-6 pb-52 overflow-y-auto" :class="streamAudiobook ? 'streaming' : ''">
|
||||
<!-- <div class="flex justify-center max-w-3xl mx-auto border border-black-300 p-6 mb-2">
|
||||
<div class="flex-grow">
|
||||
<p class="text-xl mb-4">Batch edit {{ audiobooks.length }} books</p>
|
||||
<div class="border border-white border-opacity-10 max-w-7xl mx-auto mb-10 mt-5">
|
||||
<div class="flex items-center px-4 py-4 cursor-pointer" @click="openMapOptions = !openMapOptions" @mousedown.prevent @mouseup.prevent>
|
||||
<span class="material-icons">{{ openMapOptions ? 'expand_less' : 'expand_more' }}</span>
|
||||
|
||||
<div class="flex items-center px-1">
|
||||
<ui-checkbox v-model="selectedBatchUsage.author" />
|
||||
<ui-text-input-with-label v-model="batchBook.author" :disabled="!selectedBatchUsage.author" label="Author" class="mb-4 ml-4" />
|
||||
</div>
|
||||
<div class="flex items-center px-1">
|
||||
<ui-checkbox v-model="selectedBatchUsage.series" />
|
||||
<ui-input-dropdown v-model="batchBook.series" :disabled="!selectedBatchUsage.series" label="Series" :items="seriesItems" @input="seriesChanged" @newItem="newSeriesItem" class="mb-4 ml-4" />
|
||||
</div>
|
||||
<div class="flex items-center px-1">
|
||||
<ui-checkbox v-model="selectedBatchUsage.genres" />
|
||||
<ui-multi-select v-model="batchBook.genres" :disabled="!selectedBatchUsage.genres" label="Genres" :items="genreItems" @newItem="newGenreItem" @removedItem="removedGenreItem" class="mb-4 ml-4" />
|
||||
</div>
|
||||
<div class="flex items-center px-1">
|
||||
<ui-checkbox v-model="selectedBatchUsage.narrator" />
|
||||
<ui-text-input-with-label v-model="batchBook.narrator" :disabled="!selectedBatchUsage.narrator" label="Narrator" class="mb-4 ml-4" />
|
||||
</div>
|
||||
<div class="flex items-center px-1">
|
||||
<ui-checkbox v-model="selectedBatchUsage.tags" />
|
||||
<ui-multi-select v-model="batchTags" label="Tags" :disabled="!selectedBatchUsage.tags" :items="tagItems" @newItem="newTagItem" @removedItem="removedTagItem" class="mb-4 ml-4" />
|
||||
</div>
|
||||
<p class="ml-4 text-gray-200 text-lg">Map details</p>
|
||||
</div>
|
||||
</div> -->
|
||||
<div class="overflow-hidden">
|
||||
<transition name="slide">
|
||||
<div v-if="openMapOptions" class="flex flex-wrap">
|
||||
<div class="flex items-center px-4 w-1/2">
|
||||
<ui-checkbox v-model="selectedBatchUsage.subtitle" />
|
||||
<ui-text-input-with-label ref="subtitleInput" v-model="batchDetails.subtitle" :disabled="!selectedBatchUsage.subtitle" label="Subtitle" class="mb-4 ml-4" />
|
||||
</div>
|
||||
<div class="flex items-center px-4 w-1/2">
|
||||
<ui-checkbox v-model="selectedBatchUsage.author" />
|
||||
<ui-text-input-with-label ref="authorInput" v-model="batchDetails.author" :disabled="!selectedBatchUsage.author" label="Author" class="mb-4 ml-4" />
|
||||
</div>
|
||||
<div class="flex items-center px-4 w-1/2">
|
||||
<ui-checkbox v-model="selectedBatchUsage.publishYear" />
|
||||
<ui-text-input-with-label ref="publishYearInput" v-model="batchDetails.publishYear" :disabled="!selectedBatchUsage.publishYear" label="Publish Year" class="mb-4 ml-4" />
|
||||
</div>
|
||||
<div class="flex items-center px-4 w-1/2">
|
||||
<ui-checkbox v-model="selectedBatchUsage.series" />
|
||||
<ui-input-dropdown ref="seriesDropdown" v-model="batchDetails.series" :disabled="!selectedBatchUsage.series" label="Series" :items="seriesItems" @input="seriesChanged" @newItem="newSeriesItem" class="mb-4 ml-4" />
|
||||
</div>
|
||||
<div class="flex items-center px-4 w-1/2">
|
||||
<ui-checkbox v-model="selectedBatchUsage.genres" />
|
||||
<ui-multi-select ref="genresSelect" v-model="batchDetails.genres" :disabled="!selectedBatchUsage.genres" label="Genres" :items="genreItems" @newItem="newGenreItem" @removedItem="removedGenreItem" class="mb-4 ml-4" />
|
||||
</div>
|
||||
<div class="flex items-center px-4 w-1/2">
|
||||
<ui-checkbox v-model="selectedBatchUsage.tags" />
|
||||
<ui-multi-select ref="tagsSelect" v-model="batchDetails.tags" label="Tags" :disabled="!selectedBatchUsage.tags" :items="tagItems" @newItem="newTagItem" @removedItem="removedTagItem" class="mb-4 ml-4" />
|
||||
</div>
|
||||
<div class="flex items-center px-4 w-1/2">
|
||||
<ui-checkbox v-model="selectedBatchUsage.narrator" />
|
||||
<ui-text-input-with-label ref="narratorInput" v-model="batchDetails.narrator" :disabled="!selectedBatchUsage.narrator" label="Narrator" class="mb-4 ml-4" />
|
||||
</div>
|
||||
<div class="flex items-center px-4 w-1/2">
|
||||
<ui-checkbox v-model="selectedBatchUsage.publisher" />
|
||||
<ui-text-input-with-label ref="publisherInput" v-model="batchDetails.publisher" :disabled="!selectedBatchUsage.publisher" label="Publisher" class="mb-4 ml-4" />
|
||||
</div>
|
||||
<div class="flex items-center px-4 w-1/2">
|
||||
<ui-checkbox v-model="selectedBatchUsage.language" />
|
||||
<ui-text-input-with-label ref="languageInput" v-model="batchDetails.language" :disabled="!selectedBatchUsage.language" label="Language" class="mb-4 ml-4" />
|
||||
</div>
|
||||
|
||||
<div class="w-full flex items-center justify-end p-4">
|
||||
<ui-btn color="success" :disabled="!hasSelectedBatchUsage" :padding-x="8" small class="text-base" :loading="isProcessing" @click="mapBatchDetails">Apply</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center flex-wrap">
|
||||
<template v-for="audiobook in audiobookCopies">
|
||||
|
|
@ -130,19 +157,29 @@ export default {
|
|||
newSeriesItems: [],
|
||||
newTagItems: [],
|
||||
newGenreItems: [],
|
||||
batchBook: {
|
||||
batchDetails: {
|
||||
subtitle: null,
|
||||
author: null,
|
||||
genres: [],
|
||||
publishYear: null,
|
||||
series: null,
|
||||
narrator: null
|
||||
genres: [],
|
||||
tags: [],
|
||||
narrator: null,
|
||||
publisher: null,
|
||||
language: null
|
||||
},
|
||||
selectedBatchUsage: {
|
||||
subtitle: false,
|
||||
author: false,
|
||||
genres: false,
|
||||
publishYear: false,
|
||||
series: false,
|
||||
narrator: false
|
||||
genres: false,
|
||||
tags: false,
|
||||
narrator: false,
|
||||
publisher: false,
|
||||
language: false
|
||||
},
|
||||
batchTags: []
|
||||
openMapOptions: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
|
@ -178,9 +215,46 @@ export default {
|
|||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
hasSelectedBatchUsage() {
|
||||
return Object.values(this.selectedBatchUsage).some((b) => !!b)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
blurBatchForm() {
|
||||
if (this.$refs.seriesDropdown && this.$refs.seriesDropdown.isFocused) {
|
||||
this.$refs.seriesDropdown.blur()
|
||||
}
|
||||
if (this.$refs.genresSelect && this.$refs.genresSelect.isFocused) {
|
||||
this.$refs.genresSelect.forceBlur()
|
||||
}
|
||||
if (this.$refs.tagsSelect && this.$refs.tagsSelect.isFocused) {
|
||||
this.$refs.tagsSelect.forceBlur()
|
||||
}
|
||||
|
||||
for (const key in this.batchDetails) {
|
||||
if (this.$refs[`${key}Input`] && this.$refs[`${key}Input`].blur) {
|
||||
this.$refs[`${key}Input`].blur()
|
||||
}
|
||||
}
|
||||
},
|
||||
mapBatchDetails() {
|
||||
this.blurBatchForm()
|
||||
|
||||
this.audiobookCopies = this.audiobookCopies.map((ab) => {
|
||||
for (const key in this.selectedBatchUsage) {
|
||||
if (this.selectedBatchUsage[key]) {
|
||||
if (key === 'tags') {
|
||||
ab.tags = this.batchDetails.tags
|
||||
} else {
|
||||
ab.book[key] = this.batchDetails[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
return ab
|
||||
})
|
||||
this.$toast.success('Details mapped')
|
||||
},
|
||||
newTagItem(item) {
|
||||
if (item && !this.newTagItems.includes(item)) {
|
||||
this.newTagItems.push(item)
|
||||
|
|
@ -306,7 +380,7 @@ export default {
|
|||
},
|
||||
applyBatchUpdates() {
|
||||
this.audiobookCopies = this.audiobookCopies.map((ab) => {
|
||||
if (this.batchBook.series) ab.book.series = this.batchBook.series
|
||||
if (this.batchDetails.series) ab.book.series = this.batchDetails.series
|
||||
})
|
||||
}
|
||||
},
|
||||
|
|
@ -316,3 +390,15 @@ export default {
|
|||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.slide-enter,
|
||||
.slide-leave-to {
|
||||
transform: translateY(-100%);
|
||||
transition: all 150ms ease-in 0s;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -123,8 +123,7 @@ export default {
|
|||
clickPlay() {
|
||||
var nextBookNotRead = this.playableBooks.find((pb) => !this.userAudiobooks[pb.id] || !this.userAudiobooks[pb.id].isRead)
|
||||
if (nextBookNotRead) {
|
||||
this.$store.commit('setStreamAudiobook', nextBookNotRead)
|
||||
this.$root.socket.emit('open_stream', nextBookNotRead.id)
|
||||
this.$eventBus.$emit('play-audiobook', nextBookNotRead.id)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -57,16 +57,6 @@ export default {
|
|||
this.$store.commit('setDeveloperMode', value)
|
||||
this.$toast.info(`Developer Mode ${value ? 'Enabled' : 'Disabled'}`)
|
||||
}
|
||||
// saveMetadataComplete(result) {
|
||||
// this.savingMetadata = false
|
||||
// if (!result) return
|
||||
// this.$toast.success(`Metadata saved for ${result.success} audiobooks`)
|
||||
// },
|
||||
// saveMetadataFiles() {
|
||||
// this.savingMetadata = true
|
||||
// this.$root.socket.once('save_metadata_complete', this.saveMetadataComplete)
|
||||
// this.$root.socket.emit('save_metadata')
|
||||
// }
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,41 +8,76 @@
|
|||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="storeCoversInAudiobookDir" :disabled="updatingServerSettings" @input="updateCoverStorageDestination" />
|
||||
<ui-tooltip :text="coverDestinationTooltip">
|
||||
<p class="pl-4 text-lg">Store covers with audiobook <span class="material-icons icon-text">info_outlined</span></p>
|
||||
<ui-toggle-switch v-model="newServerSettings.storeCoverWithBook" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeCoverWithBook', val)" />
|
||||
<ui-tooltip :text="tooltips.storeCoverWithBook">
|
||||
<p class="pl-4 text-lg">
|
||||
Store covers with book
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.storeMetadataWithBook" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeMetadataWithBook', val)" />
|
||||
<ui-tooltip :text="tooltips.storeMetadataWithBook">
|
||||
<p class="pl-4 text-lg">
|
||||
Store metadata with book
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="useSquareBookCovers" :disabled="updatingServerSettings" @input="updateBookCoverAspectRatio" />
|
||||
<ui-tooltip :text="coverAspectRatioTooltip">
|
||||
<p class="pl-4 text-lg">Use square book covers <span class="material-icons icon-text">info_outlined</span></p>
|
||||
<ui-tooltip :text="tooltips.coverAspectRatio">
|
||||
<p class="pl-4 text-lg">
|
||||
Use square book covers
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="useAlternativeBookshelfView" :disabled="updatingServerSettings" @input="updateAlternativeBookshelfView" />
|
||||
<ui-tooltip :text="bookshelfViewTooltip">
|
||||
<p class="pl-4 text-lg">Use alternative library bookshelf view <span class="material-icons icon-text">info_outlined</span></p>
|
||||
<ui-tooltip :text="tooltips.bookshelfView">
|
||||
<p class="pl-4 text-lg">
|
||||
Use alternative library bookshelf view
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.sortingIgnorePrefix" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('sortingIgnorePrefix', val)" />
|
||||
<p class="pl-4 text-lg">Ignore prefix "The" when sorting title and series</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.chromecastEnabled" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('chromecastEnabled', val)" />
|
||||
<p class="pl-4 text-lg">Enable Chromecast</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center mb-2 mt-8">
|
||||
<h1 class="text-xl">Scanner Settings</h1>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerParseSubtitle" small :disabled="updatingServerSettings" @input="updateScannerParseSubtitle" />
|
||||
<ui-tooltip :text="parseSubtitleTooltip">
|
||||
<p class="pl-4 text-lg">Scanner parse subtitles <span class="material-icons icon-text">info_outlined</span></p>
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerParseSubtitle" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerParseSubtitle', val)" />
|
||||
<ui-tooltip :text="tooltips.scannerParseSubtitle">
|
||||
<p class="pl-4 text-lg">
|
||||
Scanner parse subtitles
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerFindCovers" :disabled="updatingServerSettings" @input="updateScannerFindCovers" />
|
||||
<ui-tooltip :text="scannerFindCoversTooltip">
|
||||
<p class="pl-4 text-lg">Scanner find covers <span class="material-icons icon-text">info_outlined</span></p>
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerFindCovers" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerFindCovers', val)" />
|
||||
<ui-tooltip :text="tooltips.scannerFindCovers">
|
||||
<p class="pl-4 text-lg">
|
||||
Scanner find covers
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
<div class="flex-grow" />
|
||||
</div>
|
||||
|
|
@ -51,16 +86,32 @@
|
|||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerPreferAudioMetadata" :disabled="updatingServerSettings" @input="updateScannerPreferAudioMeta" />
|
||||
<ui-tooltip :text="scannerPreferAudioMetaTooltip">
|
||||
<p class="pl-4 text-lg">Scanner prefer audio metadata <span class="material-icons icon-text">info_outlined</span></p>
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerPreferAudioMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferAudioMetadata', val)" />
|
||||
<ui-tooltip :text="tooltips.scannerPreferAudioMetadata">
|
||||
<p class="pl-4 text-lg">
|
||||
Scanner prefer audio metadata
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerPreferOpfMetadata" :disabled="updatingServerSettings" @input="updateScannerPreferOpfMeta" />
|
||||
<ui-tooltip :text="scannerPreferOpfMetaTooltip">
|
||||
<p class="pl-4 text-lg">Scanner prefer OPF metadata <span class="material-icons icon-text">info_outlined</span></p>
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerPreferOpfMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferOpfMetadata', val)" />
|
||||
<ui-tooltip :text="tooltips.scannerPreferOpfMetadata">
|
||||
<p class="pl-4 text-lg">
|
||||
Scanner prefer OPF metadata
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerDisableWatcher" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerDisableWatcher', val)" />
|
||||
<ui-tooltip :text="tooltips.scannerDisableWatcher">
|
||||
<p class="pl-4 text-lg">
|
||||
Disable Watcher
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -71,7 +122,10 @@
|
|||
<ui-btn color="bg" small :padding-x="4" class="hidden lg:block mr-2" :loading="isPurgingCache" @click="purgeCache">Purge Cache</ui-btn>
|
||||
<ui-btn color="bg" small :padding-x="4" class="hidden lg:block" :loading="isResettingAudiobooks" @click="resetAudiobooks">Remove All Audiobooks</ui-btn>
|
||||
<div class="flex-grow" />
|
||||
<p class="pr-2 text-sm font-book text-yellow-400">Report bugs, request features, provide feedback, and contribute on <a class="underline" href="https://github.com/advplyr/audiobookshelf" target="_blank">github</a>.</p>
|
||||
<p class="pr-2 text-sm font-book text-yellow-400">
|
||||
Report bugs, request features, and contribute on
|
||||
<a class="underline" href="https://github.com/advplyr/audiobookshelf" target="_blank">github</a>
|
||||
</p>
|
||||
<a href="https://github.com/advplyr/audiobookshelf" target="_blank" class="text-white hover:text-gray-200 hover:scale-150 hover:rotate-6 transform duration-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" width="24" height="24" viewBox="0 0 24 24">
|
||||
<path
|
||||
|
|
@ -79,6 +133,25 @@
|
|||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<p class="pl-4 pr-2 text-sm font-book text-yellow-400">
|
||||
Join us on
|
||||
<a class="underline" href="https://discord.gg/pJsjuNCKRq" target="_blank">discord</a>
|
||||
</p>
|
||||
<a href="https://discord.gg/pJsjuNCKRq" target="_blank" class="text-white hover:text-gray-200 hover:scale-150 hover:rotate-6 transform duration-500">
|
||||
<svg width="31" height="24" viewBox="0 0 71 55" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0)">
|
||||
<path
|
||||
d="M60.1045 4.8978C55.5792 2.8214 50.7265 1.2916 45.6527 0.41542C45.5603 0.39851 45.468 0.440769 45.4204 0.525289C44.7963 1.6353 44.105 3.0834 43.6209 4.2216C38.1637 3.4046 32.7345 3.4046 27.3892 4.2216C26.905 3.0581 26.1886 1.6353 25.5617 0.525289C25.5141 0.443589 25.4218 0.40133 25.3294 0.41542C20.2584 1.2888 15.4057 2.8186 10.8776 4.8978C10.8384 4.9147 10.8048 4.9429 10.7825 4.9795C1.57795 18.7309 -0.943561 32.1443 0.293408 45.3914C0.299005 45.4562 0.335386 45.5182 0.385761 45.5576C6.45866 50.0174 12.3413 52.7249 18.1147 54.5195C18.2071 54.5477 18.305 54.5139 18.3638 54.4378C19.7295 52.5728 20.9469 50.6063 21.9907 48.5383C22.0523 48.4172 21.9935 48.2735 21.8676 48.2256C19.9366 47.4931 18.0979 46.6 16.3292 45.5858C16.1893 45.5041 16.1781 45.304 16.3068 45.2082C16.679 44.9293 17.0513 44.6391 17.4067 44.3461C17.471 44.2926 17.5606 44.2813 17.6362 44.3151C29.2558 49.6202 41.8354 49.6202 53.3179 44.3151C53.3935 44.2785 53.4831 44.2898 53.5502 44.3433C53.9057 44.6363 54.2779 44.9293 54.6529 45.2082C54.7816 45.304 54.7732 45.5041 54.6333 45.5858C52.8646 46.6197 51.0259 47.4931 49.0921 48.2228C48.9662 48.2707 48.9102 48.4172 48.9718 48.5383C50.038 50.6034 51.2554 52.5699 52.5959 54.435C52.6519 54.5139 52.7526 54.5477 52.845 54.5195C58.6464 52.7249 64.529 50.0174 70.6019 45.5576C70.6551 45.5182 70.6887 45.459 70.6943 45.3942C72.1747 30.0791 68.2147 16.7757 60.1968 4.9823C60.1772 4.9429 60.1437 4.9147 60.1045 4.8978ZM23.7259 37.3253C20.2276 37.3253 17.3451 34.1136 17.3451 30.1693C17.3451 26.225 20.1717 23.0133 23.7259 23.0133C27.308 23.0133 30.1626 26.2532 30.1066 30.1693C30.1066 34.1136 27.28 37.3253 23.7259 37.3253ZM47.3178 37.3253C43.8196 37.3253 40.9371 34.1136 40.9371 30.1693C40.9371 26.225 43.7636 23.0133 47.3178 23.0133C50.9 23.0133 53.7545 26.2532 53.6986 30.1693C53.6986 34.1136 50.9 37.3253 47.3178 37.3253Z"
|
||||
fill="#ffffff"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0">
|
||||
<rect width="71" height="55" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="h-0.5 bg-primary bg-opacity-30 w-full" />
|
||||
|
|
@ -89,13 +162,16 @@
|
|||
<div class="flex items-center">
|
||||
<ui-toggle-switch v-model="showExperimentalFeatures" />
|
||||
<ui-tooltip :text="experimentalFeaturesTooltip">
|
||||
<p class="pl-4 text-lg">Experimental Features <span class="material-icons icon-text">info_outlined</span></p>
|
||||
<p class="pl-4 text-lg">
|
||||
Experimental Features
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div class="hidden md:block">
|
||||
<a href="https://github.com/advplyr/audiobookshelf/discussions/75#discussion-3604812" target="_blank" class="text-blue-500 hover:text-blue-300 underline">Join the discussion</a>
|
||||
</div> -->
|
||||
</div>-->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -106,12 +182,22 @@ export default {
|
|||
data() {
|
||||
return {
|
||||
isResettingAudiobooks: false,
|
||||
storeCoversInAudiobookDir: false,
|
||||
updatingServerSettings: false,
|
||||
useSquareBookCovers: false,
|
||||
useAlternativeBookshelfView: false,
|
||||
isPurgingCache: false,
|
||||
newServerSettings: {}
|
||||
newServerSettings: {},
|
||||
tooltips: {
|
||||
scannerDisableWatcher: 'Disables the automatic adding/updating of audiobooks when file changes are detected. *Requires server restart',
|
||||
scannerPreferOpfMetadata: 'OPF file metadata will be used for book details over folder names',
|
||||
scannerPreferAudioMetadata: 'Audio file ID3 meta tags will be used for book details over folder names',
|
||||
scannerParseSubtitle: 'Extract subtitles from audiobook folder names.<br>Subtitle must be seperated by " - "<br>i.e. "Book Title - A Subtitle Here" has the subtitle "A Subtitle Here"',
|
||||
scannerFindCovers: 'If your audiobook does not have an embedded cover or a cover image inside the folder, the scanner will attempt to find a cover.<br>Note: This will extend scan time',
|
||||
bookshelfView: 'Alternative bookshelf view that shows title & author under book covers',
|
||||
storeCoverWithBook: 'By default covers are stored in /metadata/books, enabling this setting will store covers in the books folder. Only one file named "cover" will be kept',
|
||||
storeMetadataWithBook: 'By default metadata files are stored in /metadata/books, enabling this setting will store metadata files in the books folder. Uses .abs file extension',
|
||||
coverAspectRatio: 'Prefer to use square covers over standard 1.6:1 book covers'
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
|
|
@ -123,35 +209,11 @@ export default {
|
|||
}
|
||||
},
|
||||
computed: {
|
||||
serverSettings() {
|
||||
return this.$store.state.settings.serverSettings
|
||||
},
|
||||
scannerPreferAudioMetaTooltip() {
|
||||
return 'Audio file ID3 meta tags will be used for book details over folder names'
|
||||
},
|
||||
scannerPreferOpfMetaTooltip() {
|
||||
return 'OPF file metadata will be used for book details over folder names'
|
||||
},
|
||||
saveMetadataTooltip() {
|
||||
return 'This will write a "metadata.nfo" file in all of your audiobook directories.'
|
||||
},
|
||||
experimentalFeaturesTooltip() {
|
||||
return 'Features in development that could use your feedback and help testing.'
|
||||
},
|
||||
parseSubtitleTooltip() {
|
||||
return 'Extract subtitles from audiobook directory names.<br>Subtitle must be seperated by " - "<br>i.e. "Book Title - A Subtitle Here" has the subtitle "A Subtitle Here"'
|
||||
},
|
||||
coverDestinationTooltip() {
|
||||
return 'By default covers are stored in /metadata/books, enabling this setting will store covers inside your audiobooks directory. Only one file named "cover" will be kept.'
|
||||
},
|
||||
scannerFindCoversTooltip() {
|
||||
return 'If your audiobook does not have an embedded cover or a cover image inside the folder, the scanner will attempt to find a cover.<br>Note: This will extend scan time'
|
||||
},
|
||||
coverAspectRatioTooltip() {
|
||||
return 'Prefer to use square covers over standard 1.6:1 book covers'
|
||||
},
|
||||
bookshelfViewTooltip() {
|
||||
return 'Alternative bookshelf view that shows title & author under book covers'
|
||||
serverSettings() {
|
||||
return this.$store.state.serverSettings
|
||||
},
|
||||
providers() {
|
||||
return this.$store.state.scanners.providers
|
||||
|
|
@ -166,37 +228,14 @@ export default {
|
|||
}
|
||||
},
|
||||
methods: {
|
||||
updateScannerFindCovers(val) {
|
||||
this.updateServerSettings({
|
||||
scannerFindCovers: !!val
|
||||
})
|
||||
updateEnableChromecast(val) {
|
||||
this.updateServerSettings({ enableChromecast: val })
|
||||
},
|
||||
updateScannerCoverProvider(val) {
|
||||
this.updateServerSettings({
|
||||
scannerCoverProvider: val
|
||||
})
|
||||
},
|
||||
updateCoverStorageDestination(val) {
|
||||
this.newServerSettings.coverDestination = val ? this.$constants.CoverDestination.AUDIOBOOK : this.$constants.CoverDestination.METADATA
|
||||
this.updateServerSettings({
|
||||
coverDestination: this.newServerSettings.coverDestination
|
||||
})
|
||||
},
|
||||
updateScannerParseSubtitle(val) {
|
||||
this.updateServerSettings({
|
||||
scannerParseSubtitle: !!val
|
||||
})
|
||||
},
|
||||
updateScannerPreferAudioMeta(val) {
|
||||
this.updateServerSettings({
|
||||
scannerPreferAudioMetadata: !!val
|
||||
})
|
||||
},
|
||||
updateScannerPreferOpfMeta(val) {
|
||||
this.updateServerSettings({
|
||||
scannerPreferOpfMetadata: !!val
|
||||
})
|
||||
},
|
||||
updateBookCoverAspectRatio(val) {
|
||||
this.updateServerSettings({
|
||||
coverAspectRatio: val ? this.$constants.BookCoverAspectRatio.SQUARE : this.$constants.BookCoverAspectRatio.STANDARD
|
||||
|
|
@ -207,6 +246,11 @@ export default {
|
|||
bookshelfView: val ? this.$constants.BookshelfView.TITLES : this.$constants.BookshelfView.STANDARD
|
||||
})
|
||||
},
|
||||
updateSettingsKey(key, val) {
|
||||
this.updateServerSettings({
|
||||
[key]: val
|
||||
})
|
||||
},
|
||||
updateServerSettings(payload) {
|
||||
this.updatingServerSettings = true
|
||||
this.$store
|
||||
|
|
@ -223,8 +267,6 @@ export default {
|
|||
initServerSettings() {
|
||||
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
||||
|
||||
this.storeCoversInAudiobookDir = this.newServerSettings.coverDestination === this.$constants.CoverDestination.AUDIOBOOK
|
||||
|
||||
this.useSquareBookCovers = this.newServerSettings.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE
|
||||
|
||||
this.useAlternativeBookshelfView = this.newServerSettings.bookshelfView === this.$constants.BookshelfView.TITLES
|
||||
|
|
|
|||
|
|
@ -1,161 +1,99 @@
|
|||
<template>
|
||||
<div id="page-wrapper" class="page p-0 sm:p-6 overflow-y-auto" :class="streamAudiobook ? 'streaming' : ''">
|
||||
<main class="md:container mx-auto md:h-full max-w-screen-lg p-0 md:p-6">
|
||||
<article class="max-h-full md:overflow-y-auto relative flex flex-col rounded-md" @drop="drop" @dragover="dragover" @dragleave="dragleave" @dragenter="dragenter">
|
||||
<h1 class="text-xl font-book px-8 pt-4 pb-2">Audiobook Uploader</h1>
|
||||
<div class="w-full max-w-6xl mx-auto">
|
||||
<!-- Library & folder picker -->
|
||||
<div class="flex my-6 -mx-2">
|
||||
<div class="w-1/3 px-2">
|
||||
<ui-dropdown v-model="selectedLibraryId" :items="libraryItems" label="Library" :disabled="processing" @input="libraryChanged" />
|
||||
</div>
|
||||
<div class="w-2/3 px-2">
|
||||
<ui-dropdown v-model="selectedFolderId" :items="folderItems" :disabled="!selectedLibraryId || processing" label="Folder" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex my-2 px-6">
|
||||
<div class="w-1/3 px-2">
|
||||
<!-- <ui-text-input-with-label v-model="title" label="Title" /> -->
|
||||
<ui-dropdown v-model="selectedLibraryId" :items="libraryItems" label="Library" @input="libraryChanged" />
|
||||
</div>
|
||||
<div class="w-2/3 px-2">
|
||||
<ui-dropdown v-model="selectedFolderId" :items="folderItems" :disabled="!selectedLibraryId" label="Folder" />
|
||||
<widgets-alert v-if="error" type="error">
|
||||
<p class="text-lg">{{ error }}</p>
|
||||
</widgets-alert>
|
||||
|
||||
<!-- Picker display -->
|
||||
<div v-if="!books.length && !ignoredFiles.length" class="w-full mx-auto border border-white border-opacity-20 px-12 pt-12 pb-4 my-12 relative" :class="isDragging ? 'bg-primary bg-opacity-40' : 'border-dashed'">
|
||||
<p class="text-2xl text-center">{{ isDragging ? 'Drop files' : "Drag n' drop files or folders" }}</p>
|
||||
<p class="text-center text-sm my-5">or</p>
|
||||
<div class="w-full max-w-xl mx-auto">
|
||||
<div class="flex">
|
||||
<ui-btn class="w-full mx-1" @click="openFilePicker">Choose files</ui-btn>
|
||||
<ui-btn class="w-full mx-1" @click="openFolderPicker">Choose a folder</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex my-2 px-6">
|
||||
<div class="w-1/2 px-2">
|
||||
<ui-text-input-with-label v-model="title" label="Title" />
|
||||
</div>
|
||||
<div class="w-1/2 px-2">
|
||||
<ui-text-input-with-label v-model="author" label="Author" />
|
||||
</div>
|
||||
<div class="pt-8 text-center">
|
||||
<p class="text-xs text-white text-opacity-50 font-mono"><strong>Supported File Types: </strong>{{ inputAccept.join(', ') }}</p>
|
||||
</div>
|
||||
<div class="flex my-2 px-6">
|
||||
<div class="w-1/2 px-2">
|
||||
<ui-text-input-with-label v-model="series" label="Series" note="(optional)" />
|
||||
</div>
|
||||
<div class="w-1/2 px-2">
|
||||
<div class="w-full">
|
||||
<p class="px-1 text-sm font-semibold">Directory <em class="font-normal text-xs pl-2">(auto)</em></p>
|
||||
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs" style="height: 42px" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Book list header -->
|
||||
<div v-else class="w-full flex items-center pb-4 border-b border-white border-opacity-10">
|
||||
<p class="text-lg">{{ books.length }} book{{ books.length === 1 ? '' : 's' }}</p>
|
||||
<p v-if="ignoredFiles.length" class="text-lg"> | {{ ignoredFiles.length }} file{{ ignoredFiles.length === 1 ? '' : 's' }} ignored</p>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn :disabled="processing" small @click="reset">Reset</ui-btn>
|
||||
</div>
|
||||
|
||||
<!-- Alerts -->
|
||||
<widgets-alert v-if="!books.length && !uploadReady" type="error" class="my-4">
|
||||
<p class="text-lg">No books found</p>
|
||||
</widgets-alert>
|
||||
<widgets-alert v-if="ignoredFiles.length" type="warning" class="my-4">
|
||||
<div class="w-full pr-12">
|
||||
<p class="text-base mb-1">Unsupported files are ignored. When choosing or dropping a folder, other files that are not in a book folder are ignored.</p>
|
||||
<tables-uploaded-files-table :files="ignoredFiles" title="Ignored Files" class="text-white" />
|
||||
<p class="text-xs text-white text-opacity-50 font-mono pt-1"><strong>Supported File Types: </strong>{{ inputAccept.join(', ') }}</p>
|
||||
</div>
|
||||
</widgets-alert>
|
||||
|
||||
<section v-if="showUploader" class="h-full overflow-auto p-8 w-full flex flex-col">
|
||||
<header class="border-dashed border-2 border-gray-400 py-12 flex flex-col justify-center items-center relative h-40" :class="isDragOver ? 'bg-white bg-opacity-10' : ''">
|
||||
<p v-show="isDragOver" class="mb-3 font-semibold text-gray-200 flex flex-wrap justify-center">Drop em'</p>
|
||||
<p v-show="!isDragOver" class="mb-3 font-semibold text-gray-200 flex flex-wrap justify-center">Drop your audio and image files or</p>
|
||||
<!-- Book Upload cards -->
|
||||
<template v-for="(book, index) in books">
|
||||
<cards-book-upload-card :ref="`bookCard-${book.index}`" :key="index" :book="book" :processing="processing" @remove="removeBook(book)" />
|
||||
</template>
|
||||
|
||||
<input ref="fileInput" id="hidden-input" type="file" multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
|
||||
<ui-btn @click="clickSelectAudioFiles">Select files</ui-btn>
|
||||
<p class="text-xs text-gray-300 absolute bottom-3 right-3">{{ inputAccept }}</p>
|
||||
</header>
|
||||
</section>
|
||||
<section v-else class="h-full overflow-auto px-8 pb-8 w-full flex flex-col">
|
||||
<p v-if="!hasValidAudioFiles" class="text-error text-lg pt-4">* No valid audio tracks</p>
|
||||
<!-- Upload/Reset btns -->
|
||||
<div v-show="books.length" class="flex justify-end pb-8 pt-4">
|
||||
<ui-btn v-if="!uploadFinished" color="success" :loading="processing" @click="submit">Upload</ui-btn>
|
||||
<ui-btn v-else @click="reset">Reset</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="validImageFiles.length">
|
||||
<h1 class="pt-8 pb-3 font-semibold sm:text-lg text-gray-200">Cover Image(s)</h1>
|
||||
<div class="flex">
|
||||
<template v-for="file in validImageFiles">
|
||||
<div :key="file.name" class="h-28 w-20 bg-bg">
|
||||
<img :src="file.src" class="h-full w-full object-contain" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="validAudioFiles.length">
|
||||
<h1 class="pt-8 pb-3 font-semibold sm:text-lg text-gray-200">Audio Tracks</h1>
|
||||
|
||||
<table class="text-sm tracksTable">
|
||||
<tr class="font-book">
|
||||
<th class="text-left">Filename</th>
|
||||
<th class="text-left">Type</th>
|
||||
<th class="text-left">Size</th>
|
||||
</tr>
|
||||
<template v-for="file in validAudioFiles">
|
||||
<tr :key="file.name">
|
||||
<td class="font-book">
|
||||
<p class="truncate">{{ file.name }}</p>
|
||||
</td>
|
||||
<td class="font-sm">
|
||||
{{ file.type }}
|
||||
</td>
|
||||
<td class="font-mono">
|
||||
{{ $bytesPretty(file.size) }}
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="invalidFiles.length">
|
||||
<h1 class="pt-8 pb-3 font-semibold sm:text-lg text-gray-200">Invalid Files</h1>
|
||||
<table class="text-sm tracksTable">
|
||||
<tr class="font-book">
|
||||
<th class="text-left">Filename</th>
|
||||
<th class="text-left">Type</th>
|
||||
<th class="text-left">Size</th>
|
||||
</tr>
|
||||
<template v-for="file in invalidFiles">
|
||||
<tr :key="file.name">
|
||||
<td class="font-book">
|
||||
<p class="truncate">{{ file.name }}</p>
|
||||
</td>
|
||||
<td class="font-sm">
|
||||
{{ file.type }}
|
||||
</td>
|
||||
<td class="font-mono">
|
||||
{{ $bytesPretty(file.size) }}
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<footer v-show="!showUploader" class="flex justify-end px-8 pb-8 pt-4">
|
||||
<ui-btn :disabled="!hasValidAudioFiles" color="success" @click="submit">Upload Audiobook</ui-btn>
|
||||
<button id="cancel" class="ml-3 rounded-sm px-3 py-1 hover:bg-white hover:bg-opacity-10 focus:shadow-outline focus:outline-none" @click="cancel">Cancel</button>
|
||||
</footer>
|
||||
|
||||
<div v-if="processing" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 flex items-center justify-center z-20">
|
||||
<ui-loading-indicator text="Uploading..." />
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
<input ref="fileInput" id="hidden-input" type="file" multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
|
||||
<input ref="fileFolderInput" id="hidden-input" type="file" webkitdirectory multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Path from 'path'
|
||||
import uploadHelpers from '@/mixins/uploadHelpers'
|
||||
|
||||
export default {
|
||||
mixins: [uploadHelpers],
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
title: null,
|
||||
author: null,
|
||||
series: null,
|
||||
acceptedAudioFormats: ['.mp3', '.m4b', '.m4a', '.flac', '.opus', '.mp4', '.aac'],
|
||||
acceptedImageFormats: ['.png', '.jpg', '.jpeg', '.webp'],
|
||||
inputAccept: '.png, .jpg, .jpeg, .webp, .mp3, .m4b, .m4a, .flac, .opus, .mp4, .aac',
|
||||
isDragOver: false,
|
||||
showUploader: true,
|
||||
validAudioFiles: [],
|
||||
validImageFiles: [],
|
||||
invalidFiles: [],
|
||||
isDragging: false,
|
||||
error: '',
|
||||
books: [],
|
||||
ignoredFiles: [],
|
||||
selectedLibraryId: null,
|
||||
selectedFolderId: null
|
||||
selectedFolderId: null,
|
||||
processing: false,
|
||||
uploadFinished: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
inputAccept() {
|
||||
var extensions = []
|
||||
Object.values(this.$constants.SupportedFileTypes).forEach((types) => {
|
||||
extensions = extensions.concat(types.map((t) => `.${t}`))
|
||||
})
|
||||
return extensions
|
||||
},
|
||||
streamAudiobook() {
|
||||
return this.$store.state.streamAudiobook
|
||||
},
|
||||
hasValidAudioFiles() {
|
||||
return this.validAudioFiles.length
|
||||
},
|
||||
directory() {
|
||||
if (!this.author || !this.title) return ''
|
||||
if (this.series) {
|
||||
return Path.join(this.author, this.series, this.title)
|
||||
} else {
|
||||
return Path.join(this.author, this.title)
|
||||
}
|
||||
},
|
||||
libraries() {
|
||||
return this.$store.state.libraries.libraries
|
||||
},
|
||||
|
|
@ -182,6 +120,9 @@ export default {
|
|||
text: fold.fullPath
|
||||
}
|
||||
})
|
||||
},
|
||||
uploadReady() {
|
||||
return !this.books.length && !this.ignoredFiles.length && !this.uploadFinished
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
|
@ -200,114 +141,171 @@ export default {
|
|||
this.selectedFolderId = this.selectedLibrary.folders[0].id
|
||||
}
|
||||
},
|
||||
reset() {
|
||||
this.title = ''
|
||||
this.author = ''
|
||||
this.series = ''
|
||||
this.cancel()
|
||||
},
|
||||
cancel() {
|
||||
this.validAudioFiles = []
|
||||
this.validImageFiles = []
|
||||
this.invalidFiles = []
|
||||
if (this.$refs.fileInput) {
|
||||
this.$refs.fileInput.value = ''
|
||||
removeBook(book) {
|
||||
this.books = this.books.filter((b) => b.index !== book.index)
|
||||
if (!this.books.length) {
|
||||
this.reset()
|
||||
}
|
||||
this.showUploader = true
|
||||
},
|
||||
reset() {
|
||||
this.error = ''
|
||||
this.books = []
|
||||
this.ignoredFiles = []
|
||||
this.uploadFinished = false
|
||||
if (this.$refs.fileInput) this.$refs.fileInput.value = ''
|
||||
if (this.$refs.fileFolderInput) this.$refs.fileFolderInput.value = ''
|
||||
},
|
||||
openFilePicker() {
|
||||
if (this.$refs.fileInput) this.$refs.fileInput.click()
|
||||
},
|
||||
openFolderPicker() {
|
||||
if (this.$refs.fileFolderInput) this.$refs.fileFolderInput.click()
|
||||
},
|
||||
isDraggingFile(e) {
|
||||
// Checks dragging file or folder and not an element on the page
|
||||
var dt = e.dataTransfer || {}
|
||||
return dt.types && dt.types.indexOf('Files') >= 0
|
||||
},
|
||||
dragenter(e) {
|
||||
e.preventDefault()
|
||||
if (this.uploadReady && this.isDraggingFile(e) && !this.isDragging) {
|
||||
this.isDragging = true
|
||||
}
|
||||
},
|
||||
dragleave(e) {
|
||||
e.preventDefault()
|
||||
if (!e.fromElement && this.isDragging) {
|
||||
this.isDragging = false
|
||||
}
|
||||
},
|
||||
dragover(e) {
|
||||
// This is required to catch the drop event
|
||||
e.preventDefault()
|
||||
},
|
||||
async drop(e) {
|
||||
e.preventDefault()
|
||||
this.isDragging = false
|
||||
var items = e.dataTransfer.items || []
|
||||
var bookResults = await this.uploadHelpers.getBooksFromDrop(items)
|
||||
this.setResults(bookResults)
|
||||
},
|
||||
inputChanged(e) {
|
||||
if (!e.target || !e.target.files) return
|
||||
var _files = Array.from(e.target.files)
|
||||
if (_files && _files.length) {
|
||||
this.filesChanged(_files)
|
||||
var bookResults = this.uploadHelpers.getBooksFromPicker(_files)
|
||||
this.setResults(bookResults)
|
||||
}
|
||||
},
|
||||
drop(evt) {
|
||||
this.isDragOver = false
|
||||
this.preventDefaults(evt)
|
||||
const files = [...evt.dataTransfer.files]
|
||||
this.filesChanged(files)
|
||||
setResults(bookResults) {
|
||||
if (bookResults.error) {
|
||||
this.error = bookResults.error
|
||||
this.books = []
|
||||
this.ignoredFiles = []
|
||||
} else {
|
||||
this.error = ''
|
||||
this.books = bookResults.books
|
||||
this.ignoredFiles = bookResults.ignoredFiles
|
||||
}
|
||||
console.log('Upload results', bookResults)
|
||||
},
|
||||
dragover(evt) {
|
||||
this.isDragOver = true
|
||||
this.preventDefaults(evt)
|
||||
},
|
||||
dragleave(evt) {
|
||||
this.isDragOver = false
|
||||
this.preventDefaults(evt)
|
||||
},
|
||||
dragenter(evt) {
|
||||
this.isDragOver = true
|
||||
this.preventDefaults(evt)
|
||||
},
|
||||
preventDefaults(e) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
},
|
||||
filesChanged(files) {
|
||||
this.showUploader = false
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
var file = files[i]
|
||||
var ext = Path.extname(file.name)
|
||||
|
||||
if (this.acceptedAudioFormats.includes(ext)) {
|
||||
this.validAudioFiles.push(file)
|
||||
} else if (file.type.startsWith('image/')) {
|
||||
file.src = URL.createObjectURL(file)
|
||||
this.validImageFiles.push(file)
|
||||
} else {
|
||||
this.invalidFiles.push(file)
|
||||
}
|
||||
updateBookCardStatus(index, status) {
|
||||
var ref = this.$refs[`bookCard-${index}`]
|
||||
if (ref && ref.length) ref = ref[0]
|
||||
if (!ref) {
|
||||
console.error('Book card ref not found', index, this.$refs)
|
||||
} else {
|
||||
ref.setUploadStatus(status)
|
||||
}
|
||||
},
|
||||
clickSelectAudioFiles() {
|
||||
if (this.$refs.fileInput) {
|
||||
this.$refs.fileInput.click()
|
||||
}
|
||||
},
|
||||
submit() {
|
||||
if (!this.title || !this.author) {
|
||||
this.$toast.error('Must enter a title and author')
|
||||
return
|
||||
}
|
||||
if (!this.selectedLibraryId || !this.selectedFolderId) {
|
||||
this.$toast.error('Must select a library and folder')
|
||||
return
|
||||
}
|
||||
this.processing = true
|
||||
|
||||
uploadBook(book) {
|
||||
var form = new FormData()
|
||||
form.set('title', this.title)
|
||||
form.set('author', this.author)
|
||||
form.set('series', this.series)
|
||||
form.set('title', book.title)
|
||||
form.set('author', book.author)
|
||||
form.set('series', book.series)
|
||||
form.set('library', this.selectedLibraryId)
|
||||
form.set('folder', this.selectedFolderId)
|
||||
|
||||
var index = 0
|
||||
var files = this.validAudioFiles.concat(this.validImageFiles)
|
||||
files.forEach((file) => {
|
||||
book.files.forEach((file) => {
|
||||
form.set(`${index++}`, file)
|
||||
})
|
||||
|
||||
this.$axios
|
||||
return this.$axios
|
||||
.$post('/upload', form)
|
||||
.then((data) => {
|
||||
this.$toast.success('Audiobook Uploaded Successfully')
|
||||
this.reset()
|
||||
this.processing = false
|
||||
})
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
var errorMessage = error.response && error.response.data ? error.response.data : 'Oops, something went wrong...'
|
||||
this.$toast.error(errorMessage)
|
||||
this.processing = false
|
||||
return false
|
||||
})
|
||||
},
|
||||
validateBooks() {
|
||||
var bookData = []
|
||||
for (var book of this.books) {
|
||||
var bookref = this.$refs[`bookCard-${book.index}`]
|
||||
if (bookref && bookref.length) bookref = bookref[0]
|
||||
|
||||
if (!bookref) {
|
||||
console.error('Invalid book index no ref', book.index, this.$refs.bookCard)
|
||||
return false
|
||||
} else {
|
||||
var data = bookref.getData()
|
||||
if (!data) {
|
||||
return false
|
||||
}
|
||||
bookData.push(data)
|
||||
}
|
||||
}
|
||||
return bookData
|
||||
},
|
||||
async submit() {
|
||||
if (!this.selectedFolderId || !this.selectedLibraryId) {
|
||||
this.$toast.error('Must select library and folder')
|
||||
document.getElementById('page-wrapper').scroll({ top: 0, left: 0, behavior: 'smooth' })
|
||||
return
|
||||
}
|
||||
|
||||
var books = this.validateBooks()
|
||||
if (!books) {
|
||||
this.$toast.error('Some invalid books')
|
||||
return
|
||||
}
|
||||
this.processing = true
|
||||
var booksUploaded = 0
|
||||
var booksFailed = 0
|
||||
for (let i = 0; i < books.length; i++) {
|
||||
var book = books[i]
|
||||
this.updateBookCardStatus(book.index, 'uploading')
|
||||
var result = await this.uploadBook(book)
|
||||
if (result) booksUploaded++
|
||||
else booksFailed++
|
||||
this.updateBookCardStatus(book.index, result ? 'success' : 'failed')
|
||||
}
|
||||
if (booksUploaded) {
|
||||
this.$toast.success(`Successfully uploaded ${booksUploaded} book${booksUploaded > 1 ? 's' : ''}`)
|
||||
}
|
||||
if (booksFailed) {
|
||||
this.$toast.success(`Failed to upload ${booksFailed} book${booksFailed > 1 ? 's' : ''}`)
|
||||
}
|
||||
this.processing = false
|
||||
this.uploadFinished = true
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.selectedLibraryId = this.$store.state.libraries.currentLibraryId
|
||||
this.setDefaultFolder()
|
||||
window.addEventListener('dragenter', this.dragenter)
|
||||
window.addEventListener('dragleave', this.dragleave)
|
||||
window.addEventListener('dragover', this.dragover)
|
||||
window.addEventListener('drop', this.drop)
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('dragenter', this.dragenter)
|
||||
window.removeEventListener('dragleave', this.dragleave)
|
||||
window.removeEventListener('dragover', this.dragover)
|
||||
window.removeEventListener('drop', this.drop)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
19
client/players/AudioTrack.js
Normal file
19
client/players/AudioTrack.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
export default class AudioTrack {
|
||||
constructor(track) {
|
||||
this.index = track.index || 0
|
||||
this.startOffset = track.startOffset || 0 // Total time of all previous tracks
|
||||
this.duration = track.duration || 0
|
||||
this.title = track.filename || ''
|
||||
this.contentUrl = track.contentUrl || null
|
||||
this.mimeType = track.mimeType
|
||||
}
|
||||
|
||||
get fullContentUrl() {
|
||||
if (!this.contentUrl || this.contentUrl.startsWith('http')) return this.contentUrl
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return `${process.env.serverUrl}${this.contentUrl}`
|
||||
}
|
||||
return `${window.location.origin}${this.contentUrl}`
|
||||
}
|
||||
}
|
||||
140
client/players/CastPlayer.js
Normal file
140
client/players/CastPlayer.js
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { buildCastLoadRequest, castLoadMedia } from "./castUtils"
|
||||
import EventEmitter from 'events'
|
||||
|
||||
export default class CastPlayer extends EventEmitter {
|
||||
constructor(ctx) {
|
||||
super()
|
||||
|
||||
this.ctx = ctx
|
||||
this.player = null
|
||||
this.playerController = null
|
||||
|
||||
this.audiobook = null
|
||||
this.audioTracks = []
|
||||
this.currentTrackIndex = 0
|
||||
this.hlsStreamId = null
|
||||
this.currentTime = 0
|
||||
this.playWhenReady = false
|
||||
this.defaultPlaybackRate = 1
|
||||
|
||||
this.coverUrl = ''
|
||||
this.castPlayerState = 'IDLE'
|
||||
|
||||
// Supported audio codecs for chromecast
|
||||
this.supportedAudioCodecs = ['opus', 'mp3', 'aac', 'flac', 'webma', 'wav']
|
||||
|
||||
this.initialize()
|
||||
}
|
||||
|
||||
get currentTrack() {
|
||||
return this.audioTracks[this.currentTrackIndex] || {}
|
||||
}
|
||||
|
||||
initialize() {
|
||||
this.player = this.ctx.$root.castPlayer
|
||||
this.playerController = this.ctx.$root.castPlayerController
|
||||
this.playerController.addEventListener(
|
||||
cast.framework.RemotePlayerEventType.MEDIA_INFO_CHANGED, this.evtMediaInfoChanged.bind(this))
|
||||
}
|
||||
|
||||
evtMediaInfoChanged() {
|
||||
// Use the current session to get an up to date media status.
|
||||
let session = cast.framework.CastContext.getInstance().getCurrentSession()
|
||||
if (!session) {
|
||||
return
|
||||
}
|
||||
let media = session.getMediaSession()
|
||||
if (!media) {
|
||||
return
|
||||
}
|
||||
|
||||
// var currentItemId = media.currentItemId
|
||||
var currentItemId = media.media.itemId
|
||||
if (currentItemId && this.currentTrackIndex !== currentItemId - 1) {
|
||||
this.currentTrackIndex = currentItemId - 1
|
||||
}
|
||||
|
||||
if (media.playerState !== this.castPlayerState) {
|
||||
this.emit('stateChange', media.playerState)
|
||||
this.castPlayerState = media.playerState
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.playerController) {
|
||||
this.playerController.stop()
|
||||
}
|
||||
}
|
||||
|
||||
async set(audiobook, tracks, hlsStreamId, startTime, playWhenReady = false) {
|
||||
this.audiobook = audiobook
|
||||
this.audioTracks = tracks
|
||||
this.hlsStreamId = hlsStreamId
|
||||
this.playWhenReady = playWhenReady
|
||||
|
||||
this.currentTime = startTime
|
||||
|
||||
var coverImg = this.ctx.$store.getters['audiobooks/getBookCoverSrc'](audiobook)
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
this.coverUrl = coverImg
|
||||
} else {
|
||||
this.coverUrl = `${window.location.origin}${coverImg}`
|
||||
}
|
||||
|
||||
var request = buildCastLoadRequest(this.audiobook, this.coverUrl, this.audioTracks, this.currentTime, playWhenReady, this.defaultPlaybackRate)
|
||||
|
||||
var castSession = cast.framework.CastContext.getInstance().getCurrentSession()
|
||||
await castLoadMedia(castSession, request)
|
||||
}
|
||||
|
||||
resetStream(startTime) {
|
||||
// Cast only direct play for now
|
||||
}
|
||||
|
||||
playPause() {
|
||||
if (this.playerController) this.playerController.playOrPause()
|
||||
}
|
||||
|
||||
play() {
|
||||
if (this.playerController) this.playerController.playOrPause()
|
||||
}
|
||||
|
||||
pause() {
|
||||
if (this.playerController) this.playerController.playOrPause()
|
||||
}
|
||||
|
||||
getCurrentTime() {
|
||||
var currentTrackOffset = this.currentTrack.startOffset || 0
|
||||
return this.player ? currentTrackOffset + this.player.currentTime : 0
|
||||
}
|
||||
|
||||
getDuration() {
|
||||
if (!this.audioTracks.length) return 0
|
||||
var lastTrack = this.audioTracks[this.audioTracks.length - 1]
|
||||
return lastTrack.startOffset + lastTrack.duration
|
||||
}
|
||||
|
||||
setPlaybackRate(playbackRate) {
|
||||
this.defaultPlaybackRate = playbackRate
|
||||
}
|
||||
|
||||
async seek(time, playWhenReady) {
|
||||
if (!this.player) return
|
||||
if (time < this.currentTrack.startOffset || time > this.currentTrack.startOffset + this.currentTrack.duration) {
|
||||
// Change Track
|
||||
var request = buildCastLoadRequest(this.audiobook, this.coverUrl, this.audioTracks, time, playWhenReady, this.defaultPlaybackRate)
|
||||
var castSession = cast.framework.CastContext.getInstance().getCurrentSession()
|
||||
await castLoadMedia(castSession, request)
|
||||
} else {
|
||||
var offsetTime = time - (this.currentTrack.startOffset || 0)
|
||||
this.player.currentTime = Math.max(0, offsetTime)
|
||||
this.playerController.seek()
|
||||
}
|
||||
}
|
||||
|
||||
setVolume(volume) {
|
||||
if (!this.player) return
|
||||
this.player.volumeLevel = volume
|
||||
this.playerController.setVolumeLevel()
|
||||
}
|
||||
}
|
||||
238
client/players/LocalPlayer.js
Normal file
238
client/players/LocalPlayer.js
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
import Hls from 'hls.js'
|
||||
import EventEmitter from 'events'
|
||||
|
||||
export default class LocalPlayer extends EventEmitter {
|
||||
constructor(ctx) {
|
||||
super()
|
||||
|
||||
this.ctx = ctx
|
||||
this.player = null
|
||||
|
||||
this.audiobook = null
|
||||
this.audioTracks = []
|
||||
this.currentTrackIndex = 0
|
||||
this.hlsStreamId = null
|
||||
this.hlsInstance = null
|
||||
this.usingNativeplayer = false
|
||||
this.currentTime = 0
|
||||
this.playWhenReady = false
|
||||
this.defaultPlaybackRate = 1
|
||||
|
||||
this.initialize()
|
||||
}
|
||||
|
||||
get currentTrack() {
|
||||
return this.audioTracks[this.currentTrackIndex] || {}
|
||||
}
|
||||
|
||||
initialize() {
|
||||
if (document.getElementById('audio-player')) {
|
||||
document.getElementById('audio-player').remove()
|
||||
}
|
||||
var audioEl = document.createElement('audio')
|
||||
audioEl.id = 'audio-player'
|
||||
audioEl.style.display = 'none'
|
||||
document.body.appendChild(audioEl)
|
||||
this.player = audioEl
|
||||
|
||||
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('error', this.evtError.bind(this))
|
||||
this.player.addEventListener('loadedmetadata', this.evtLoadedMetadata.bind(this))
|
||||
this.player.addEventListener('timeupdate', this.evtTimeupdate.bind(this))
|
||||
}
|
||||
|
||||
evtPlay() {
|
||||
this.emit('stateChange', 'PLAYING')
|
||||
}
|
||||
evtPause() {
|
||||
this.emit('stateChange', 'PAUSED')
|
||||
}
|
||||
evtProgress() {
|
||||
var lastBufferTime = this.getLastBufferedTime()
|
||||
this.emit('buffertimeUpdate', lastBufferTime)
|
||||
}
|
||||
evtError(error) {
|
||||
console.error('Player error', error)
|
||||
}
|
||||
evtLoadedMetadata(data) {
|
||||
console.log('Audio Loaded Metadata', data)
|
||||
this.emit('stateChange', 'LOADED')
|
||||
if (this.playWhenReady) {
|
||||
this.playWhenReady = false
|
||||
this.play()
|
||||
}
|
||||
}
|
||||
evtTimeupdate() {
|
||||
if (this.player.paused) {
|
||||
this.emit('timeupdate', this.getCurrentTime())
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.hlsStreamId) {
|
||||
// Close HLS Stream
|
||||
console.log('Closing HLS Streams', this.hlsStreamId)
|
||||
this.ctx.$axios.$post(`/api/streams/${this.hlsStreamId}/close`).catch((error) => {
|
||||
console.error('Failed to request close hls stream', this.hlsStreamId, error)
|
||||
})
|
||||
}
|
||||
this.destroyHlsInstance()
|
||||
if (this.player) {
|
||||
this.player.remove()
|
||||
}
|
||||
}
|
||||
|
||||
set(audiobook, tracks, hlsStreamId, startTime, playWhenReady = false) {
|
||||
this.audiobook = audiobook
|
||||
this.audioTracks = tracks
|
||||
this.hlsStreamId = hlsStreamId
|
||||
this.playWhenReady = playWhenReady
|
||||
if (this.hlsInstance) {
|
||||
this.destroyHlsInstance()
|
||||
}
|
||||
|
||||
this.currentTime = startTime
|
||||
|
||||
// iOS does not support Media Elements but allows for HLS in the native audio player
|
||||
if (!Hls.isSupported()) {
|
||||
console.warn('HLS is not supported - fallback to using audio element')
|
||||
this.usingNativeplayer = true
|
||||
this.player.src = this.currentTrack.contentUrl
|
||||
this.player.currentTime = this.currentTime
|
||||
return
|
||||
}
|
||||
|
||||
var hlsOptions = {
|
||||
startPosition: this.currentTime || -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.currentTrack.contentUrl)
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
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.audiobook, this.audioTracks, this.hlsStreamId, 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() {
|
||||
var currentTrackOffset = this.currentTrack.startOffset || 0
|
||||
return this.player ? currentTrackOffset + this.player.currentTime : 0
|
||||
}
|
||||
|
||||
getDuration() {
|
||||
if (!this.audioTracks.length) return 0
|
||||
var lastTrack = this.audioTracks[this.audioTracks.length - 1]
|
||||
return lastTrack.startOffset + lastTrack.duration
|
||||
}
|
||||
|
||||
setPlaybackRate(playbackRate) {
|
||||
if (!this.player) return
|
||||
this.defaultPlaybackRate = playbackRate
|
||||
this.player.playbackRate = playbackRate
|
||||
}
|
||||
|
||||
seek(time) {
|
||||
if (!this.player) return
|
||||
var offsetTime = time - (this.currentTrack.startOffset || 0)
|
||||
this.player.currentTime = Math.max(0, offsetTime)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
306
client/players/PlayerHandler.js
Normal file
306
client/players/PlayerHandler.js
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
import LocalPlayer from './LocalPlayer'
|
||||
import CastPlayer from './CastPlayer'
|
||||
import AudioTrack from './AudioTrack'
|
||||
|
||||
export default class PlayerHandler {
|
||||
constructor(ctx) {
|
||||
this.ctx = ctx
|
||||
this.audiobook = null
|
||||
this.playWhenReady = false
|
||||
this.player = null
|
||||
this.playerState = 'IDLE'
|
||||
this.currentStreamId = null
|
||||
this.startTime = 0
|
||||
|
||||
this.lastSyncTime = 0
|
||||
this.lastSyncedAt = 0
|
||||
this.listeningTimeSinceSync = 0
|
||||
|
||||
this.playInterval = null
|
||||
}
|
||||
|
||||
get isCasting() {
|
||||
return this.ctx.$store.state.globals.isCasting
|
||||
}
|
||||
get isPlayingCastedAudiobook() {
|
||||
return this.audiobook && (this.player instanceof CastPlayer)
|
||||
}
|
||||
get isPlayingLocalAudiobook() {
|
||||
return this.audiobook && (this.player instanceof LocalPlayer)
|
||||
}
|
||||
get userToken() {
|
||||
return this.ctx.$store.getters['user/getToken']
|
||||
}
|
||||
get playerPlaying() {
|
||||
return this.playerState === 'PLAYING'
|
||||
}
|
||||
|
||||
load(audiobook, playWhenReady, startTime = 0) {
|
||||
if (!this.player) this.switchPlayer()
|
||||
|
||||
console.log('Load audiobook', audiobook)
|
||||
this.audiobook = audiobook
|
||||
this.startTime = startTime
|
||||
this.playWhenReady = playWhenReady
|
||||
this.prepare()
|
||||
}
|
||||
|
||||
switchPlayer() {
|
||||
if (this.isCasting && !(this.player instanceof CastPlayer)) {
|
||||
console.log('[PlayerHandler] Switching to cast player')
|
||||
|
||||
this.stopPlayInterval()
|
||||
this.playerStateChange('LOADING')
|
||||
|
||||
this.startTime = this.player ? this.player.getCurrentTime() : this.startTime
|
||||
if (this.player) {
|
||||
this.player.destroy()
|
||||
}
|
||||
this.player = new CastPlayer(this.ctx)
|
||||
this.setPlayerListeners()
|
||||
|
||||
if (this.audiobook) {
|
||||
// Audiobook was already loaded - prepare for cast
|
||||
this.playWhenReady = false
|
||||
this.prepare()
|
||||
}
|
||||
} else if (!this.isCasting && !(this.player instanceof LocalPlayer)) {
|
||||
console.log('[PlayerHandler] Switching to local player')
|
||||
|
||||
this.stopPlayInterval()
|
||||
this.playerStateChange('LOADING')
|
||||
|
||||
if (this.player) {
|
||||
this.player.destroy()
|
||||
}
|
||||
this.player = new LocalPlayer(this.ctx)
|
||||
this.setPlayerListeners()
|
||||
|
||||
if (this.audiobook) {
|
||||
// Audiobook was already loaded - prepare for local play
|
||||
this.playWhenReady = false
|
||||
this.prepare()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setPlayerListeners() {
|
||||
this.player.on('stateChange', this.playerStateChange.bind(this))
|
||||
this.player.on('timeupdate', this.playerTimeupdate.bind(this))
|
||||
this.player.on('buffertimeUpdate', this.playerBufferTimeUpdate.bind(this))
|
||||
}
|
||||
|
||||
playerStateChange(state) {
|
||||
console.log('[PlayerHandler] Player state change', state)
|
||||
this.playerState = state
|
||||
if (this.playerState === 'PLAYING') {
|
||||
this.startPlayInterval()
|
||||
} else {
|
||||
this.stopPlayInterval()
|
||||
}
|
||||
if (this.playerState === 'LOADED' || this.playerState === 'PLAYING') {
|
||||
this.ctx.setDuration(this.player.getDuration())
|
||||
}
|
||||
if (this.playerState !== 'LOADING') {
|
||||
this.ctx.setCurrentTime(this.player.getCurrentTime())
|
||||
}
|
||||
|
||||
this.ctx.isPlaying = this.playerState === 'PLAYING'
|
||||
this.ctx.playerLoading = this.playerState === 'LOADING'
|
||||
}
|
||||
|
||||
playerTimeupdate(time) {
|
||||
this.ctx.setCurrentTime(time)
|
||||
}
|
||||
|
||||
playerBufferTimeUpdate(buffertime) {
|
||||
this.ctx.setBufferTime(buffertime)
|
||||
}
|
||||
|
||||
async prepare() {
|
||||
var useHls = !this.isCasting
|
||||
if (useHls) {
|
||||
var stream = await this.ctx.$axios.$get(`/api/books/${this.audiobook.id}/stream`).catch((error) => {
|
||||
console.error('Failed to start stream', error)
|
||||
})
|
||||
if (stream) {
|
||||
console.log(`[PlayerHandler] prepare hls stream`, stream)
|
||||
this.setHlsStream(stream)
|
||||
}
|
||||
} else {
|
||||
// Setup tracks
|
||||
var runningTotal = 0
|
||||
var audioTracks = (this.audiobook.tracks || []).map((track) => {
|
||||
var audioTrack = new AudioTrack(track)
|
||||
audioTrack.startOffset = runningTotal
|
||||
audioTrack.contentUrl = `/lib/${this.audiobook.libraryId}/${this.audiobook.folderId}/${track.path}?token=${this.userToken}`
|
||||
audioTrack.mimeType = (track.codec === 'm4b' || track.codec === 'm4a') ? 'audio/mp4' : `audio/${track.codec}`
|
||||
|
||||
runningTotal += audioTrack.duration
|
||||
return audioTrack
|
||||
})
|
||||
this.setDirectPlay(audioTracks)
|
||||
}
|
||||
}
|
||||
|
||||
closePlayer() {
|
||||
console.log('[PlayerHandler] CLose Player')
|
||||
if (this.player) {
|
||||
this.player.destroy()
|
||||
}
|
||||
this.player = null
|
||||
this.playerState = 'IDLE'
|
||||
this.audiobook = null
|
||||
this.currentStreamId = null
|
||||
this.startTime = 0
|
||||
this.stopPlayInterval()
|
||||
}
|
||||
|
||||
prepareStream(stream) {
|
||||
if (!this.player) this.switchPlayer()
|
||||
this.audiobook = stream.audiobook
|
||||
this.setHlsStream({
|
||||
streamId: stream.id,
|
||||
streamUrl: stream.clientPlaylistUri,
|
||||
startTime: stream.clientCurrentTime
|
||||
})
|
||||
}
|
||||
|
||||
setHlsStream(stream) {
|
||||
this.currentStreamId = stream.streamId
|
||||
var audioTrack = new AudioTrack({
|
||||
duration: this.audiobook.duration,
|
||||
contentUrl: stream.streamUrl + '?token=' + this.userToken,
|
||||
mimeType: 'application/vnd.apple.mpegurl'
|
||||
})
|
||||
this.startTime = stream.startTime
|
||||
this.ctx.playerLoading = true
|
||||
this.player.set(this.audiobook, [audioTrack], this.currentStreamId, stream.startTime, this.playWhenReady)
|
||||
}
|
||||
|
||||
setDirectPlay(audioTracks) {
|
||||
this.currentStreamId = null
|
||||
this.ctx.playerLoading = true
|
||||
this.player.set(this.audiobook, audioTracks, null, this.startTime, this.playWhenReady)
|
||||
}
|
||||
|
||||
resetStream(startTime, streamId) {
|
||||
if (this.currentStreamId === streamId) {
|
||||
this.player.resetStream(startTime)
|
||||
} else {
|
||||
console.warn('resetStream mismatch streamId', this.currentStreamId, streamId)
|
||||
}
|
||||
}
|
||||
|
||||
startPlayInterval() {
|
||||
clearInterval(this.playInterval)
|
||||
var lastTick = Date.now()
|
||||
this.playInterval = setInterval(() => {
|
||||
// Update UI
|
||||
if (!this.player) return
|
||||
var currentTime = this.player.getCurrentTime()
|
||||
this.ctx.setCurrentTime(currentTime)
|
||||
|
||||
var exactTimeElapsed = ((Date.now() - lastTick) / 1000)
|
||||
lastTick = Date.now()
|
||||
this.listeningTimeSinceSync += exactTimeElapsed
|
||||
if (this.listeningTimeSinceSync >= 5) {
|
||||
this.sendProgressSync(currentTime)
|
||||
this.listeningTimeSinceSync = 0
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
sendProgressSync(currentTime) {
|
||||
var diffSinceLastSync = Math.abs(this.lastSyncTime - currentTime)
|
||||
if (diffSinceLastSync < 1) return
|
||||
|
||||
this.lastSyncTime = currentTime
|
||||
if (this.currentStreamId) { // Updating stream progress (HLS stream)
|
||||
var listeningTimeToAdd = Math.max(0, Math.floor(this.listeningTimeSinceSync))
|
||||
var syncData = {
|
||||
timeListened: listeningTimeToAdd,
|
||||
currentTime,
|
||||
streamId: this.currentStreamId,
|
||||
audiobookId: this.audiobook.id
|
||||
}
|
||||
this.ctx.$axios.$post('/api/syncStream', syncData, { timeout: 1000 }).catch((error) => {
|
||||
console.error('Failed to update stream progress', error)
|
||||
})
|
||||
} else {
|
||||
// Direct play via chromecast does not yet have backend stream session model
|
||||
// so the progress update for the audiobook is updated this way (instead of through the stream)
|
||||
var duration = this.getDuration()
|
||||
var syncData = {
|
||||
totalDuration: duration,
|
||||
currentTime,
|
||||
progress: duration > 0 ? currentTime / duration : 0,
|
||||
isRead: false,
|
||||
audiobookId: this.audiobook.id,
|
||||
lastUpdate: Date.now()
|
||||
}
|
||||
this.ctx.$axios.$post('/api/syncLocal', syncData, { timeout: 1000 }).catch((error) => {
|
||||
console.error('Failed to update local progress', error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
stopPlayInterval() {
|
||||
clearInterval(this.playInterval)
|
||||
this.playInterval = null
|
||||
}
|
||||
|
||||
playPause() {
|
||||
if (this.player) this.player.playPause()
|
||||
}
|
||||
|
||||
play() {
|
||||
if (!this.player) return
|
||||
this.player.play()
|
||||
}
|
||||
|
||||
pause() {
|
||||
if (this.player) this.player.pause()
|
||||
}
|
||||
|
||||
getCurrentTime() {
|
||||
return this.player ? this.player.getCurrentTime() : 0
|
||||
}
|
||||
|
||||
getDuration() {
|
||||
return this.player ? this.player.getDuration() : 0
|
||||
}
|
||||
|
||||
jumpBackward() {
|
||||
if (!this.player) return
|
||||
var currentTime = this.getCurrentTime()
|
||||
this.seek(Math.max(0, currentTime - 10))
|
||||
}
|
||||
|
||||
jumpForward() {
|
||||
if (!this.player) return
|
||||
var currentTime = this.getCurrentTime()
|
||||
this.seek(Math.min(currentTime + 10, this.getDuration()))
|
||||
}
|
||||
|
||||
setVolume(volume) {
|
||||
if (!this.player) return
|
||||
this.player.setVolume(volume)
|
||||
}
|
||||
|
||||
setPlaybackRate(playbackRate) {
|
||||
if (!this.player) return
|
||||
this.player.setPlaybackRate(playbackRate)
|
||||
}
|
||||
|
||||
seek(time) {
|
||||
if (!this.player) return
|
||||
this.player.seek(time, this.playerPlaying)
|
||||
this.ctx.setCurrentTime(time)
|
||||
|
||||
// Update progress if paused
|
||||
if (!this.playerPlaying) {
|
||||
this.sendProgressSync(time)
|
||||
}
|
||||
}
|
||||
}
|
||||
74
client/players/castUtils.js
Normal file
74
client/players/castUtils.js
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
|
||||
function getMediaInfoFromTrack(audiobook, castImage, track) {
|
||||
// https://developers.google.com/cast/docs/reference/web_sender/chrome.cast.media.AudiobookChapterMediaMetadata
|
||||
var metadata = new chrome.cast.media.AudiobookChapterMediaMetadata()
|
||||
metadata.bookTitle = audiobook.book.title
|
||||
metadata.chapterNumber = track.index
|
||||
metadata.chapterTitle = track.title
|
||||
metadata.images = [castImage]
|
||||
metadata.title = track.title
|
||||
metadata.subtitle = audiobook.book.title
|
||||
|
||||
var trackurl = track.fullContentUrl
|
||||
var mimeType = track.mimeType
|
||||
var mediainfo = new chrome.cast.media.MediaInfo(trackurl, mimeType)
|
||||
mediainfo.metadata = metadata
|
||||
mediainfo.itemId = track.index
|
||||
mediainfo.duration = track.duration
|
||||
return mediainfo
|
||||
}
|
||||
|
||||
function buildCastMediaInfo(audiobook, coverUrl, tracks) {
|
||||
const castImage = new chrome.cast.Image(coverUrl)
|
||||
return tracks.map(t => getMediaInfoFromTrack(audiobook, castImage, t))
|
||||
}
|
||||
|
||||
function buildCastQueueRequest(audiobook, coverUrl, tracks, startTime) {
|
||||
var mediaInfoItems = buildCastMediaInfo(audiobook, coverUrl, tracks)
|
||||
|
||||
var containerMetadata = new chrome.cast.media.AudiobookContainerMetadata()
|
||||
containerMetadata.authors = [audiobook.book.authorFL]
|
||||
containerMetadata.narrators = [audiobook.book.narratorFL]
|
||||
containerMetadata.publisher = audiobook.book.publisher || undefined
|
||||
|
||||
var mediaQueueItems = mediaInfoItems.map((mi) => {
|
||||
var queueItem = new chrome.cast.media.QueueItem(mi)
|
||||
return queueItem
|
||||
})
|
||||
|
||||
// Find track to start playback and calculate track start offset
|
||||
var track = tracks.find(at => at.startOffset <= startTime && at.startOffset + at.duration > startTime)
|
||||
var trackStartIndex = track ? track.index - 1 : 0
|
||||
var trackStartTime = Math.floor(track ? startTime - track.startOffset : 0)
|
||||
|
||||
var queueData = new chrome.cast.media.QueueData(audiobook.id, audiobook.book.title, '', false, mediaQueueItems, trackStartIndex, trackStartTime)
|
||||
queueData.containerMetadata = containerMetadata
|
||||
queueData.queueType = chrome.cast.media.QueueType.AUDIOBOOK
|
||||
return queueData
|
||||
}
|
||||
|
||||
function castLoadMedia(castSession, request) {
|
||||
return new Promise((resolve) => {
|
||||
castSession.loadMedia(request)
|
||||
.then(() => resolve(true), (reason) => {
|
||||
console.error('Load media failed', reason)
|
||||
resolve(false)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function buildCastLoadRequest(audiobook, coverUrl, tracks, startTime, autoplay, playbackRate) {
|
||||
var request = new chrome.cast.media.LoadRequest()
|
||||
|
||||
request.queueData = buildCastQueueRequest(audiobook, coverUrl, tracks, startTime)
|
||||
request.currentTime = request.queueData.startTime
|
||||
|
||||
request.autoplay = autoplay
|
||||
request.playbackRate = playbackRate
|
||||
return request
|
||||
}
|
||||
|
||||
export {
|
||||
buildCastLoadRequest,
|
||||
castLoadMedia
|
||||
}
|
||||
80
client/plugins/chromecast.js
Normal file
80
client/plugins/chromecast.js
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
export default (ctx) => {
|
||||
var sendInit = async (castContext) => {
|
||||
// Fetch background covers for chromecast (temp)
|
||||
var covers = await ctx.$axios.$get(`/api/libraries/${ctx.$store.state.libraries.currentLibraryId}/books/all?limit=40&minified=1`).then((data) => {
|
||||
return data.results.filter((b) => b.book.cover).map((ab) => {
|
||||
var coverUrl = ctx.$store.getters['audiobooks/getBookCoverSrc'](ab)
|
||||
if (process.env.NODE_ENV === 'development') return coverUrl
|
||||
return `${window.location.origin}${coverUrl}`
|
||||
})
|
||||
}).catch((error) => {
|
||||
console.error('failed to fetch books', error)
|
||||
return null
|
||||
})
|
||||
|
||||
// Custom message to receiver
|
||||
var castSession = castContext.getCurrentSession()
|
||||
castSession.sendMessage('urn:x-cast:com.audiobookshelf.cast', {
|
||||
covers
|
||||
})
|
||||
}
|
||||
|
||||
var initializeCastApi = () => {
|
||||
var castContext = cast.framework.CastContext.getInstance()
|
||||
castContext.setOptions({
|
||||
receiverApplicationId: process.env.chromecastReceiver,
|
||||
autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED
|
||||
});
|
||||
|
||||
castContext.addEventListener(
|
||||
cast.framework.CastContextEventType.SESSION_STATE_CHANGED,
|
||||
(event) => {
|
||||
console.log('Session state changed event', event)
|
||||
|
||||
switch (event.sessionState) {
|
||||
case cast.framework.SessionState.SESSION_STARTED:
|
||||
console.log('[chromecast] CAST SESSION STARTED')
|
||||
|
||||
ctx.$store.commit('globals/setCasting', true)
|
||||
sendInit(castContext)
|
||||
|
||||
setTimeout(() => {
|
||||
ctx.$eventBus.$emit('cast-session-active', true)
|
||||
}, 500)
|
||||
|
||||
break;
|
||||
case cast.framework.SessionState.SESSION_RESUMED:
|
||||
console.log('[chromecast] CAST SESSION RESUMED')
|
||||
|
||||
setTimeout(() => {
|
||||
ctx.$eventBus.$emit('cast-session-active', true)
|
||||
}, 500)
|
||||
break;
|
||||
case cast.framework.SessionState.SESSION_ENDED:
|
||||
console.log('[chromecast] CAST SESSION DISCONNECTED')
|
||||
|
||||
ctx.$store.commit('globals/setCasting', false)
|
||||
ctx.$eventBus.$emit('cast-session-active', false)
|
||||
break;
|
||||
}
|
||||
})
|
||||
|
||||
ctx.$store.commit('globals/setChromecastInitialized', true)
|
||||
|
||||
var player = new cast.framework.RemotePlayer()
|
||||
var playerController = new cast.framework.RemotePlayerController(player)
|
||||
ctx.$root.castPlayer = player
|
||||
ctx.$root.castPlayerController = playerController
|
||||
}
|
||||
|
||||
window['__onGCastApiAvailable'] = function (isAvailable) {
|
||||
if (isAvailable) {
|
||||
initializeCastApi()
|
||||
}
|
||||
}
|
||||
|
||||
var script = document.createElement('script')
|
||||
script.type = 'text/javascript'
|
||||
script.src = 'https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1'
|
||||
document.head.appendChild(script)
|
||||
}
|
||||
|
|
@ -1,3 +1,12 @@
|
|||
const SupportedFileTypes = {
|
||||
image: ['png', 'jpg', 'jpeg', 'webp'],
|
||||
audio: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'mp4', 'aac'],
|
||||
ebook: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'],
|
||||
info: ['nfo'],
|
||||
text: ['txt'],
|
||||
opf: ['opf']
|
||||
}
|
||||
|
||||
const DownloadStatus = {
|
||||
PENDING: 0,
|
||||
READY: 1,
|
||||
|
|
@ -5,11 +14,6 @@ const DownloadStatus = {
|
|||
FAILED: 3
|
||||
}
|
||||
|
||||
const CoverDestination = {
|
||||
METADATA: 0,
|
||||
AUDIOBOOK: 1
|
||||
}
|
||||
|
||||
const BookCoverAspectRatio = {
|
||||
STANDARD: 0,
|
||||
SQUARE: 1
|
||||
|
|
@ -21,8 +25,8 @@ const BookshelfView = {
|
|||
}
|
||||
|
||||
const Constants = {
|
||||
SupportedFileTypes,
|
||||
DownloadStatus,
|
||||
CoverDestination,
|
||||
BookCoverAspectRatio,
|
||||
BookshelfView
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ Vue.prototype.$addDaysToToday = (daysToAdd) => {
|
|||
}
|
||||
|
||||
Vue.prototype.$bytesPretty = (bytes, decimals = 2) => {
|
||||
if (bytes === 0) {
|
||||
if (isNaN(bytes) || !bytes === 0) {
|
||||
return '0 Bytes'
|
||||
}
|
||||
const k = 1024
|
||||
|
|
@ -61,13 +61,20 @@ Vue.prototype.$secondsToTimestamp = (seconds) => {
|
|||
return `${_hours}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
Vue.prototype.$elapsedPrettyExtended = (seconds) => {
|
||||
Vue.prototype.$elapsedPrettyExtended = (seconds, useDays = true) => {
|
||||
if (isNaN(seconds) || seconds === null) return ''
|
||||
seconds = Math.round(seconds)
|
||||
|
||||
var minutes = Math.floor(seconds / 60)
|
||||
seconds -= minutes * 60
|
||||
var hours = Math.floor(minutes / 60)
|
||||
minutes -= hours * 60
|
||||
var days = Math.floor(hours / 24)
|
||||
hours -= days * 24
|
||||
|
||||
var days = 0
|
||||
if (useDays || Math.floor(hours / 24) >= 100) {
|
||||
days = Math.floor(hours / 24)
|
||||
hours -= days * 24
|
||||
}
|
||||
|
||||
var strs = []
|
||||
if (days) strs.push(`${days}d`)
|
||||
|
|
|
|||
3
client/static/libs/sortable.js
Normal file
3
client/static/libs/sortable.js
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -6,7 +6,9 @@ export const state = () => ({
|
|||
showUserCollectionsModal: false,
|
||||
showEditCollectionModal: false,
|
||||
selectedCollection: null,
|
||||
showBookshelfTextureModal: false
|
||||
showBookshelfTextureModal: false,
|
||||
isCasting: false, // Actively casting
|
||||
isChromecastInitialized: false // Script loaded
|
||||
})
|
||||
|
||||
export const getters = {}
|
||||
|
|
@ -33,5 +35,11 @@ export const mutations = {
|
|||
},
|
||||
setShowBookshelfTextureModal(state, val) {
|
||||
state.showBookshelfTextureModal = val
|
||||
},
|
||||
setChromecastInitialized(state, val) {
|
||||
state.isChromecastInitialized = val
|
||||
},
|
||||
setCasting(state, val) {
|
||||
state.isCasting = val
|
||||
}
|
||||
}
|
||||
|
|
@ -9,7 +9,6 @@ export const state = () => ({
|
|||
showEReader: false,
|
||||
selectedAudiobook: null,
|
||||
selectedAudiobookFile: null,
|
||||
playOnLoad: false,
|
||||
developerMode: false,
|
||||
selectedAudiobooks: [],
|
||||
processingBatch: false,
|
||||
|
|
@ -78,25 +77,8 @@ export const mutations = {
|
|||
state.versionData = versionData
|
||||
},
|
||||
setStreamAudiobook(state, audiobook) {
|
||||
state.playOnLoad = true
|
||||
state.streamAudiobook = audiobook
|
||||
},
|
||||
updateStreamAudiobook(state, audiobook) { // Initial stream audiobook is minified, on open audiobook is updated to full
|
||||
state.streamAudiobook = audiobook
|
||||
},
|
||||
setStream(state, stream) {
|
||||
state.playOnLoad = false
|
||||
state.streamAudiobook = stream ? stream.audiobook : null
|
||||
},
|
||||
clearStreamAudiobook(state, audiobookId) {
|
||||
if (state.streamAudiobook && state.streamAudiobook.id === audiobookId) {
|
||||
state.playOnLoad = false
|
||||
state.streamAudiobook = null
|
||||
}
|
||||
},
|
||||
setPlayOnLoad(state, val) {
|
||||
state.playOnLoad = val
|
||||
},
|
||||
showEditModal(state, audiobook) {
|
||||
state.editModalTab = 'details'
|
||||
state.selectedAudiobook = audiobook
|
||||
|
|
|
|||
|
|
@ -20,6 +20,11 @@ export const getters = {
|
|||
},
|
||||
getSortedLibraries: state => () => {
|
||||
return state.libraries.map(lib => ({ ...lib })).sort((a, b) => a.displayOrder - b.displayOrder)
|
||||
},
|
||||
getLibraryProvider: state => libraryId => {
|
||||
var library = state.libraries.find(l => l.id === libraryId)
|
||||
if (!library) return null
|
||||
return library.provider
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 936 KiB After Width: | Height: | Size: 221 KiB |
BIN
images/LibraryStreamSquare.png
Normal file
BIN
images/LibraryStreamSquare.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 226 KiB |
381
package-lock.json
generated
381
package-lock.json
generated
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "1.6.66",
|
||||
"version": "1.7.1",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
|
|
@ -2868,12 +2868,6 @@
|
|||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"abbrev": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
|
||||
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
|
||||
"optional": true
|
||||
},
|
||||
"aborter": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/aborter/-/aborter-1.1.0.tgz",
|
||||
|
|
@ -2888,23 +2882,6 @@
|
|||
"negotiator": "0.6.2"
|
||||
}
|
||||
},
|
||||
"adm-zip": {
|
||||
"version": "0.4.16",
|
||||
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.4.16.tgz",
|
||||
"integrity": "sha512-TFi4HBKSGfIKsK5YCkKaaFG2m4PEDyViZmEwof3MTIgzimHLto6muaHVpbrljdIvIrFZzEq/p4nafOeLcYegrg=="
|
||||
},
|
||||
"ansi-regex": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
|
||||
"integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=",
|
||||
"optional": true
|
||||
},
|
||||
"aproba": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz",
|
||||
"integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==",
|
||||
"optional": true
|
||||
},
|
||||
"archiver": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.0.tgz",
|
||||
|
|
@ -2960,33 +2937,6 @@
|
|||
"is-primitive": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"are-we-there-yet": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.7.tgz",
|
||||
"integrity": "sha512-nxwy40TuMiUGqMyRHgCSWZ9FM4VAoRP4xUYSTv5ImRog+h9yISPbVH7H8fASCIzYn9wlEv4zvFL7uKDMCFQm3g==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"delegates": "^1.0.0",
|
||||
"readable-stream": "^2.0.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"readable-stream": {
|
||||
"version": "2.3.7",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
|
||||
"integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"array-back": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/array-back/-/array-back-3.1.0.tgz",
|
||||
|
|
@ -3134,12 +3084,6 @@
|
|||
"responselike": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"chownr": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||
"optional": true
|
||||
},
|
||||
"clone-response": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.2.tgz",
|
||||
|
|
@ -3148,12 +3092,6 @@
|
|||
"mimic-response": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"code-point-at": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz",
|
||||
"integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=",
|
||||
"optional": true
|
||||
},
|
||||
"command-line-args": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-5.2.0.tgz",
|
||||
|
|
@ -3186,12 +3124,6 @@
|
|||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
|
||||
},
|
||||
"console-control-strings": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
|
||||
"integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=",
|
||||
"optional": true
|
||||
},
|
||||
"content-disposition": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.3.tgz",
|
||||
|
|
@ -3296,23 +3228,11 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"deep-extend": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
|
||||
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
|
||||
"optional": true
|
||||
},
|
||||
"defer-to-connect": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
|
||||
"integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="
|
||||
},
|
||||
"delegates": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
|
||||
"integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=",
|
||||
"optional": true
|
||||
},
|
||||
"depd": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz",
|
||||
|
|
@ -3323,12 +3243,6 @@
|
|||
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz",
|
||||
"integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA="
|
||||
},
|
||||
"detect-libc": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
|
||||
"integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=",
|
||||
"optional": true
|
||||
},
|
||||
"dicer": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/dicer/-/dicer-0.3.0.tgz",
|
||||
|
|
@ -3405,16 +3319,6 @@
|
|||
"base64-arraybuffer": "0.1.4"
|
||||
}
|
||||
},
|
||||
"epub": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/epub/-/epub-1.2.1.tgz",
|
||||
"integrity": "sha512-2GDDr2qcH3dvwX1lgwCQ3gki0CwwrxELLI005SauhT2TacJUiDqZrQuGuOSWEYIHX6ox5kXHpn1ZjsHqkNCb+g==",
|
||||
"requires": {
|
||||
"adm-zip": "^0.4.11",
|
||||
"xml2js": "^0.4.23",
|
||||
"zipfile": "^0.5.11"
|
||||
}
|
||||
},
|
||||
"escape-html": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
|
|
@ -3583,36 +3487,11 @@
|
|||
"universalify": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"fs-minipass": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-1.2.7.tgz",
|
||||
"integrity": "sha512-GWSSJGFy4e9GUeCcbIkED+bgAoFyj7XF1mV8rma3QW4NIqX9Kyx79N/PF61H5udOV3aY1IaMLs6pGbH71nlCTA==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"minipass": "^2.6.0"
|
||||
}
|
||||
},
|
||||
"fs.realpath": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
"integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
|
||||
},
|
||||
"gauge": {
|
||||
"version": "2.7.4",
|
||||
"resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz",
|
||||
"integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"aproba": "^1.0.3",
|
||||
"console-control-strings": "^1.0.0",
|
||||
"has-unicode": "^2.0.0",
|
||||
"object-assign": "^4.1.0",
|
||||
"signal-exit": "^3.0.0",
|
||||
"string-width": "^1.0.1",
|
||||
"strip-ansi": "^3.0.1",
|
||||
"wide-align": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"get-stream": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
|
||||
|
|
@ -3658,12 +3537,6 @@
|
|||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.6.tgz",
|
||||
"integrity": "sha512-nTnJ528pbqxYanhpDYsi4Rd8MAeaBA67+RZ10CM1m3bTAVFEDcd5AuA4a6W5YkGZ1iNXHzZz8T6TBKLeBuNriQ=="
|
||||
},
|
||||
"has-unicode": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
|
||||
"integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=",
|
||||
"optional": true
|
||||
},
|
||||
"html-entities": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.2.tgz",
|
||||
|
|
@ -3708,15 +3581,6 @@
|
|||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="
|
||||
},
|
||||
"ignore-walk": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-3.0.4.tgz",
|
||||
"integrity": "sha512-PY6Ii8o1jMRA1z4F2hRkH/xN59ox43DavKvD3oDpfurRlOJyAHpifIwpbdv1n4jt4ov0jSpw3kQ4GhJnpBL6WQ==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"minimatch": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"image-type": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/image-type/-/image-type-4.1.0.tgz",
|
||||
|
|
@ -3739,12 +3603,6 @@
|
|||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
|
||||
"integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4="
|
||||
},
|
||||
"ini": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
|
||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||
"optional": true
|
||||
},
|
||||
"ip": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/ip/-/ip-1.1.5.tgz",
|
||||
|
|
@ -3755,15 +3613,6 @@
|
|||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="
|
||||
},
|
||||
"is-fullwidth-code-point": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz",
|
||||
"integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"number-is-nan": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"is-primitive": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-primitive/-/is-primitive-3.0.1.tgz",
|
||||
|
|
@ -4002,40 +3851,6 @@
|
|||
"brace-expansion": "^1.1.7"
|
||||
}
|
||||
},
|
||||
"minimist": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
|
||||
"integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==",
|
||||
"optional": true
|
||||
},
|
||||
"minipass": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-2.9.0.tgz",
|
||||
"integrity": "sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"safe-buffer": "^5.1.2",
|
||||
"yallist": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"minizlib": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-1.3.3.tgz",
|
||||
"integrity": "sha512-6ZYMOEnmVsdCeTJVE0W9ZD+pVnE8h9Hma/iOwwRDsdQoePpoX56/8B6z3P9VNwppJuBKNRuFDRNRqRWexT9G9Q==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"minipass": "^2.9.0"
|
||||
}
|
||||
},
|
||||
"mkdirp": {
|
||||
"version": "0.5.5",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz",
|
||||
"integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"minimist": "^1.2.5"
|
||||
}
|
||||
},
|
||||
"moment": {
|
||||
"version": "2.29.1",
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz",
|
||||
|
|
@ -4054,40 +3869,6 @@
|
|||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
|
||||
},
|
||||
"nan": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.10.0.tgz",
|
||||
"integrity": "sha512-bAdJv7fBLhWC+/Bls0Oza+mvTaNQtP+1RyhhhvD95pgUJz6XM5IzgmxOkItJ9tkoCiplvAnXI1tNmmUD/eScyA==",
|
||||
"optional": true
|
||||
},
|
||||
"needle": {
|
||||
"version": "2.9.1",
|
||||
"resolved": "https://registry.npmjs.org/needle/-/needle-2.9.1.tgz",
|
||||
"integrity": "sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"debug": "^3.2.6",
|
||||
"iconv-lite": "^0.4.4",
|
||||
"sax": "^1.2.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"debug": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
|
||||
"integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"ms": "^2.1.1"
|
||||
}
|
||||
},
|
||||
"ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"negotiator": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz",
|
||||
|
|
@ -4114,39 +3895,11 @@
|
|||
"resolved": "https://registry.npmjs.org/node-ffprobe/-/node-ffprobe-3.0.0.tgz",
|
||||
"integrity": "sha512-2LNTLStz2hw/urwo4xJ00TIOvthgepcl3tF4HB8BWnhJ4nhJ7S08YThapBHkGLYV+GUuY9pML/kX76+dqY2iUg=="
|
||||
},
|
||||
"node-pre-gyp": {
|
||||
"version": "0.10.3",
|
||||
"resolved": "https://registry.npmjs.org/node-pre-gyp/-/node-pre-gyp-0.10.3.tgz",
|
||||
"integrity": "sha512-d1xFs+C/IPS8Id0qPTZ4bUT8wWryfR/OzzAFxweG+uLN85oPzyo2Iw6bVlLQ/JOdgNonXLCoRyqDzDWq4iw72A==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"detect-libc": "^1.0.2",
|
||||
"mkdirp": "^0.5.1",
|
||||
"needle": "^2.2.1",
|
||||
"nopt": "^4.0.1",
|
||||
"npm-packlist": "^1.1.6",
|
||||
"npmlog": "^4.0.2",
|
||||
"rc": "^1.2.7",
|
||||
"rimraf": "^2.6.1",
|
||||
"semver": "^5.3.0",
|
||||
"tar": "^4"
|
||||
}
|
||||
},
|
||||
"node-stream-zip": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/node-stream-zip/-/node-stream-zip-1.15.0.tgz",
|
||||
"integrity": "sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw=="
|
||||
},
|
||||
"nopt": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/nopt/-/nopt-4.0.3.tgz",
|
||||
"integrity": "sha512-CvaGwVMztSMJLOeXPrez7fyfObdZqNUK1cPAEzLHrTybIua9pMdmmPR5YwtfNftIOMv3DPUhFaxsZMNTQO20Kg==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"abbrev": "1",
|
||||
"osenv": "^0.1.4"
|
||||
}
|
||||
},
|
||||
"normalize-path": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
|
|
@ -4157,55 +3910,6 @@
|
|||
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz",
|
||||
"integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A=="
|
||||
},
|
||||
"npm-bundled": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-1.1.2.tgz",
|
||||
"integrity": "sha512-x5DHup0SuyQcmL3s7Rx/YQ8sbw/Hzg0rj48eN0dV7hf5cmQq5PXIeioroH3raV1QC1yh3uTYuMThvEQF3iKgGQ==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"npm-normalize-package-bin": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"npm-normalize-package-bin": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-1.0.1.tgz",
|
||||
"integrity": "sha512-EPfafl6JL5/rU+ot6P3gRSCpPDW5VmIzX959Ob1+ySFUuuYHWHekXpwdUZcKP5C+DS4GEtdJluwBjnsNDl+fSA==",
|
||||
"optional": true
|
||||
},
|
||||
"npm-packlist": {
|
||||
"version": "1.4.8",
|
||||
"resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-1.4.8.tgz",
|
||||
"integrity": "sha512-5+AZgwru5IevF5ZdnFglB5wNlHG1AOOuw28WhUq8/8emhBmLv6jX5by4WJCh7lW0uSYZYS6DXqIsyZVIXRZU9A==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"ignore-walk": "^3.0.1",
|
||||
"npm-bundled": "^1.0.1",
|
||||
"npm-normalize-package-bin": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"npmlog": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz",
|
||||
"integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"are-we-there-yet": "~1.1.2",
|
||||
"console-control-strings": "~1.1.0",
|
||||
"gauge": "~2.7.3",
|
||||
"set-blocking": "~2.0.0"
|
||||
}
|
||||
},
|
||||
"number-is-nan": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz",
|
||||
"integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=",
|
||||
"optional": true
|
||||
},
|
||||
"oauth": {
|
||||
"version": "0.9.15",
|
||||
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
|
||||
"integrity": "sha1-vR/vr2hslrdUda7VGWQS/2DPucE="
|
||||
},
|
||||
"object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
|
|
@ -4232,28 +3936,6 @@
|
|||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"os-homedir": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz",
|
||||
"integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=",
|
||||
"optional": true
|
||||
},
|
||||
"os-tmpdir": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz",
|
||||
"integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=",
|
||||
"optional": true
|
||||
},
|
||||
"osenv": {
|
||||
"version": "0.1.5",
|
||||
"resolved": "https://registry.npmjs.org/osenv/-/osenv-0.1.5.tgz",
|
||||
"integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"os-homedir": "^1.0.0",
|
||||
"os-tmpdir": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"p-cancelable": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
|
||||
|
|
@ -4437,18 +4119,6 @@
|
|||
"unpipe": "1.0.0"
|
||||
}
|
||||
},
|
||||
"rc": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
||||
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"deep-extend": "^0.6.0",
|
||||
"ini": "~1.3.0",
|
||||
"minimist": "^1.2.0",
|
||||
"strip-json-comments": "~2.0.1"
|
||||
}
|
||||
},
|
||||
"read-chunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/read-chunk/-/read-chunk-3.1.0.tgz",
|
||||
|
|
@ -4504,15 +4174,6 @@
|
|||
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
|
||||
"integrity": "sha1-G0KmJmoh8HQh0bC1S33BZ7AcATs="
|
||||
},
|
||||
"rimraf": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
|
||||
"integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"glob": "^7.1.3"
|
||||
}
|
||||
},
|
||||
"ripstat": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ripstat/-/ripstat-1.1.1.tgz",
|
||||
|
|
@ -4603,12 +4264,6 @@
|
|||
"send": "0.17.1"
|
||||
}
|
||||
},
|
||||
"set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=",
|
||||
"optional": true
|
||||
},
|
||||
"setprototypeof": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz",
|
||||
|
|
@ -4744,6 +4399,7 @@
|
|||
"@babel/runtime": "^7.14.0"
|
||||
}
|
||||
},
|
||||
<<<<<<< HEAD
|
||||
"string-width": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz",
|
||||
|
|
@ -4791,6 +4447,14 @@
|
|||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"optional": true
|
||||
}
|
||||
=======
|
||||
"string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"requires": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
>>>>>>> advplyr/master
|
||||
}
|
||||
},
|
||||
"tar-stream": {
|
||||
|
|
@ -4886,15 +4550,6 @@
|
|||
"isexe": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"wide-align": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz",
|
||||
"integrity": "sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"string-width": "^1.0.2 || 2"
|
||||
}
|
||||
},
|
||||
"with-open-file": {
|
||||
"version": "0.1.7",
|
||||
"resolved": "https://registry.npmjs.org/with-open-file/-/with-open-file-0.1.7.tgz",
|
||||
|
|
@ -4935,12 +4590,6 @@
|
|||
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
|
||||
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="
|
||||
},
|
||||
"yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||
"optional": true
|
||||
},
|
||||
"zip-stream": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.0.tgz",
|
||||
|
|
@ -4950,16 +4599,6 @@
|
|||
"compress-commons": "^4.1.0",
|
||||
"readable-stream": "^3.6.0"
|
||||
}
|
||||
},
|
||||
"zipfile": {
|
||||
"version": "0.5.12",
|
||||
"resolved": "https://registry.npmjs.org/zipfile/-/zipfile-0.5.12.tgz",
|
||||
"integrity": "sha512-zA60gW+XgQBu/Q4qV3BCXNIDRald6Xi5UOPj3jWGlnkjmBHaKDwIz7kyXWV3kq7VEsQN/2t/IWjdXdKeVNm6Eg==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"nan": "~2.10.0",
|
||||
"node-pre-gyp": "~0.10.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "1.6.66",
|
||||
"version": "1.7.1",
|
||||
"description": "Self-hosted audiobook server for managing and playing audiobooks",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
|
@ -33,7 +33,6 @@
|
|||
"command-line-args": "^5.2.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"date-and-time": "^2.0.1",
|
||||
"epub": "^1.2.1",
|
||||
"express": "^4.17.1",
|
||||
"express-fileupload": "^1.2.1",
|
||||
"express-rate-limit": "^5.3.0",
|
||||
|
|
@ -58,5 +57,6 @@
|
|||
"string-strip-html": "^8.3.0",
|
||||
"watcher": "^1.2.0",
|
||||
"xml2js": "^0.4.23"
|
||||
}
|
||||
},
|
||||
"devDependencies": {}
|
||||
}
|
||||
|
|
|
|||
19
readme.md
19
readme.md
|
|
@ -23,16 +23,25 @@ Audiobookshelf is a self-hosted audiobook server for managing and playing your a
|
|||
* Multi-user support w/ custom permissions
|
||||
* Keeps progress per user and syncs across devices
|
||||
* Auto-detects library updates, no need to re-scan
|
||||
* Upload full audiobooks and covers
|
||||
* Upload audiobooks w/ bulk upload drag and drop folders
|
||||
* Backup your metadata + automated daily backups
|
||||
* Progressive Web App (PWA)
|
||||
* Chromecast support on the web app
|
||||
* Fetch metadata and cover art from several sources
|
||||
|
||||
Is there a feature you are looking for? [Suggest it](https://github.com/advplyr/audiobookshelf/issues/new)
|
||||
Is there a feature you are looking for? [Suggest it](https://github.com/advplyr/audiobookshelf/issues/new/choose)
|
||||
|
||||
Android app is in beta, try it out on the [Google Play Store](https://play.google.com/store/apps/details?id=com.audiobookshelf.app)
|
||||
Join us on [discord](https://discord.gg/pJsjuNCKRq)
|
||||
|
||||
iOS early beta available using Test Flight: https://testflight.apple.com/join/wiic7QIW - [Join the discussion](https://github.com/advplyr/audiobookshelf-app/discussions/60)
|
||||
### Android App (beta)
|
||||
Try it out on the [Google Play Store](https://play.google.com/store/apps/details?id=com.audiobookshelf.app)
|
||||
|
||||
<img alt="Library Screenshot" src="https://github.com/advplyr/audiobookshelf/raw/master/images/LibraryStream.png" />
|
||||
### iOS App (early beta)
|
||||
Available using Test Flight: https://testflight.apple.com/join/wiic7QIW - [Join the discussion](https://github.com/advplyr/audiobookshelf-app/discussions/60)
|
||||
|
||||
<br />
|
||||
|
||||
<img alt="Library Screenshot" src="https://github.com/advplyr/audiobookshelf/raw/master/images/LibraryStreamSquare.png" />
|
||||
|
||||
<br />
|
||||
|
||||
|
|
|
|||
|
|
@ -18,9 +18,10 @@ const AuthorFinder = require('./AuthorFinder')
|
|||
const FileSystemController = require('./controllers/FileSystemController')
|
||||
|
||||
class ApiController {
|
||||
constructor(MetadataPath, db, auth, streamManager, rssFeeds, downloadManager, coverController, backupManager, watcher, cacheManager, emitter, clientEmitter) {
|
||||
constructor(db, auth, scanner, streamManager, rssFeeds, downloadManager, coverController, backupManager, watcher, cacheManager, emitter, clientEmitter) {
|
||||
this.db = db
|
||||
this.auth = auth
|
||||
this.scanner = scanner
|
||||
this.streamManager = streamManager
|
||||
this.rssFeeds = rssFeeds
|
||||
this.downloadManager = downloadManager
|
||||
|
|
@ -30,10 +31,9 @@ class ApiController {
|
|||
this.cacheManager = cacheManager
|
||||
this.emitter = emitter
|
||||
this.clientEmitter = clientEmitter
|
||||
this.MetadataPath = MetadataPath
|
||||
|
||||
this.bookFinder = new BookFinder()
|
||||
this.authorFinder = new AuthorFinder(this.MetadataPath)
|
||||
this.authorFinder = new AuthorFinder()
|
||||
|
||||
this.router = express()
|
||||
this.init()
|
||||
|
|
@ -59,6 +59,7 @@ class ApiController {
|
|||
this.router.get('/libraries/:id/search', LibraryController.middleware.bind(this), LibraryController.search.bind(this))
|
||||
this.router.get('/libraries/:id/stats', LibraryController.middleware.bind(this), LibraryController.stats.bind(this))
|
||||
this.router.get('/libraries/:id/authors', LibraryController.middleware.bind(this), LibraryController.getAuthors.bind(this))
|
||||
this.router.post('/libraries/:id/matchbooks', LibraryController.middleware.bind(this), LibraryController.matchBooks.bind(this))
|
||||
this.router.post('/libraries/order', LibraryController.reorder.bind(this))
|
||||
|
||||
// TEMP: Support old syntax for mobile app
|
||||
|
|
@ -82,6 +83,7 @@ class ApiController {
|
|||
this.router.post('/books/:id/cover', BookController.uploadCover.bind(this))
|
||||
this.router.get('/books/:id/cover', BookController.getCover.bind(this))
|
||||
this.router.patch('/books/:id/coverfile', BookController.updateCoverFromFile.bind(this))
|
||||
this.router.post('/books/:id/match', BookController.match.bind(this))
|
||||
|
||||
// TEMP: Support old syntax for mobile app
|
||||
this.router.get('/audiobooks', BookController.findAll.bind(this)) // Old route should pass library id
|
||||
|
|
@ -176,6 +178,8 @@ class ApiController {
|
|||
|
||||
this.router.post('/syncStream', this.syncStream.bind(this))
|
||||
this.router.post('/syncLocal', this.syncLocal.bind(this))
|
||||
|
||||
this.router.post('/streams/:id/close', this.closeStream.bind(this))
|
||||
}
|
||||
|
||||
async findBooks(req, res) {
|
||||
|
|
@ -283,7 +287,7 @@ class ApiController {
|
|||
this.backupManager.updateCronSchedule()
|
||||
}
|
||||
|
||||
await this.db.updateEntity('settings', this.db.serverSettings)
|
||||
await this.db.updateServerSettings()
|
||||
}
|
||||
return res.json({
|
||||
success: true,
|
||||
|
|
@ -416,6 +420,7 @@ class ApiController {
|
|||
data: audiobookProgress || null
|
||||
})
|
||||
}
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
//
|
||||
|
|
@ -537,5 +542,12 @@ class ApiController {
|
|||
await this.cacheManager.purgeAll()
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
async closeStream(req, res) {
|
||||
const streamId = req.params.id
|
||||
const userId = req.user.id
|
||||
this.streamManager.closeStreamApiRequest(userId, streamId)
|
||||
res.sendStatus(200)
|
||||
}
|
||||
}
|
||||
module.exports = ApiController
|
||||
|
|
@ -30,7 +30,7 @@ class Auth {
|
|||
cors(req, res, next) {
|
||||
res.header('Access-Control-Allow-Origin', '*')
|
||||
res.header("Access-Control-Allow-Methods", 'GET, POST, PATCH, PUT, DELETE, OPTIONS')
|
||||
res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Authorization")
|
||||
res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Accept-Encoding, Range, Authorization")
|
||||
res.header('Access-Control-Allow-Credentials', true)
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.sendStatus(200)
|
||||
|
|
|
|||
|
|
@ -7,9 +7,8 @@ const Audnexus = require('./providers/Audnexus')
|
|||
const { downloadFile } = require('./utils/fileUtils')
|
||||
|
||||
class AuthorFinder {
|
||||
constructor(MetadataPath) {
|
||||
this.MetadataPath = MetadataPath
|
||||
this.AuthorPath = Path.join(MetadataPath, 'authors')
|
||||
constructor() {
|
||||
this.AuthorPath = Path.join(global.MetadataPath, 'authors')
|
||||
|
||||
this.audnexus = new Audnexus()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,9 +13,9 @@ const Logger = require('./Logger')
|
|||
const Backup = require('./objects/Backup')
|
||||
|
||||
class BackupManager {
|
||||
constructor(MetadataPath, Uid, Gid, db) {
|
||||
this.MetadataPath = MetadataPath
|
||||
this.BackupPath = Path.join(this.MetadataPath, 'backups')
|
||||
constructor(Uid, Gid, db) {
|
||||
this.BackupPath = Path.join(global.MetadataPath, 'backups')
|
||||
this.MetadataBooksPath = Path.join(global.MetadataPath, 'books')
|
||||
|
||||
this.Uid = Uid
|
||||
this.Gid = Gid
|
||||
|
|
@ -142,10 +142,9 @@ class BackupManager {
|
|||
return
|
||||
}
|
||||
const zip = new StreamZip.async({ file: backup.fullPath })
|
||||
await zip.extract('config/', this.db.ConfigPath)
|
||||
await zip.extract('config/', global.ConfigPath)
|
||||
if (backup.backupMetadataCovers) {
|
||||
var metadataBooksPath = Path.join(this.MetadataPath, 'books')
|
||||
await zip.extract('metadata-books/', metadataBooksPath)
|
||||
await zip.extract('metadata-books/', this.MetadataBooksPath)
|
||||
}
|
||||
await this.db.reinit()
|
||||
socket.emit('apply_backup_complete', true)
|
||||
|
|
@ -157,7 +156,7 @@ class BackupManager {
|
|||
var lastBackup = this.backups.shift()
|
||||
|
||||
const zip = new StreamZip.async({ file: lastBackup.fullPath })
|
||||
await zip.extract('config/', this.db.ConfigPath)
|
||||
await zip.extract('config/', global.ConfigPath)
|
||||
console.log('Set Last Backup')
|
||||
await this.db.reinit()
|
||||
}
|
||||
|
|
@ -196,7 +195,7 @@ class BackupManager {
|
|||
async runBackup() {
|
||||
// Check if Metadata Path is inside Config Path (otherwise there will be an infinite loop as the archiver tries to zip itself)
|
||||
Logger.info(`[BackupManager] Running Backup`)
|
||||
var metadataBooksPath = this.serverSettings.backupMetadataCovers ? Path.join(this.MetadataPath, 'books') : null
|
||||
var metadataBooksPath = this.serverSettings.backupMetadataCovers ? this.MetadataBooksPath : null
|
||||
|
||||
var newBackup = new Backup()
|
||||
|
||||
|
|
|
|||
|
|
@ -5,9 +5,8 @@ const Logger = require('./Logger')
|
|||
const { resizeImage } = require('./utils/ffmpegHelpers')
|
||||
|
||||
class CacheManager {
|
||||
constructor(MetadataPath) {
|
||||
this.MetadataPath = MetadataPath
|
||||
this.CachePath = Path.join(this.MetadataPath, 'cache')
|
||||
constructor() {
|
||||
this.CachePath = Path.join(global.MetadataPath, 'cache')
|
||||
this.CoverCachePath = Path.join(this.CachePath, 'covers')
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,21 +6,18 @@ const readChunk = require('read-chunk')
|
|||
const imageType = require('image-type')
|
||||
|
||||
const globals = require('./utils/globals')
|
||||
const { CoverDestination } = require('./utils/constants')
|
||||
const { downloadFile } = require('./utils/fileUtils')
|
||||
|
||||
class CoverController {
|
||||
constructor(db, cacheManager, MetadataPath, AudiobookPath) {
|
||||
constructor(db, cacheManager) {
|
||||
this.db = db
|
||||
this.cacheManager = cacheManager
|
||||
|
||||
this.MetadataPath = MetadataPath.replace(/\\/g, '/')
|
||||
this.BookMetadataPath = Path.posix.join(this.MetadataPath, 'books')
|
||||
this.AudiobookPath = AudiobookPath
|
||||
this.BookMetadataPath = Path.posix.join(global.MetadataPath, 'books')
|
||||
}
|
||||
|
||||
getCoverDirectory(audiobook) {
|
||||
if (this.db.serverSettings.coverDestination === CoverDestination.AUDIOBOOK) {
|
||||
if (this.db.serverSettings.storeCoverWithBook) {
|
||||
return {
|
||||
fullPath: audiobook.fullPath,
|
||||
relPath: '/s/book/' + audiobook.id
|
||||
|
|
|
|||
70
server/Db.js
70
server/Db.js
|
|
@ -13,18 +13,15 @@ const ServerSettings = require('./objects/ServerSettings')
|
|||
const SSOSettings = require('./objects/SSOSettings')
|
||||
|
||||
class Db {
|
||||
constructor(ConfigPath, AudiobookPath) {
|
||||
this.ConfigPath = ConfigPath
|
||||
this.AudiobookPath = AudiobookPath
|
||||
|
||||
this.AudiobooksPath = Path.join(ConfigPath, 'audiobooks')
|
||||
this.UsersPath = Path.join(ConfigPath, 'users')
|
||||
this.SessionsPath = Path.join(ConfigPath, 'sessions')
|
||||
this.LibrariesPath = Path.join(ConfigPath, 'libraries')
|
||||
this.SettingsPath = Path.join(ConfigPath, 'settings')
|
||||
this.SSOPath = Path.join(ConfigPath, 'sso')
|
||||
this.CollectionsPath = Path.join(ConfigPath, 'collections')
|
||||
this.AuthorsPath = Path.join(ConfigPath, 'authors')
|
||||
constructor() {
|
||||
this.AudiobooksPath = Path.join(global.ConfigPath, 'audiobooks')
|
||||
this.UsersPath = Path.join(global.ConfigPath, 'users')
|
||||
this.SessionsPath = Path.join(global.ConfigPath, 'sessions')
|
||||
this.LibrariesPath = Path.join(global.ConfigPath, 'libraries')
|
||||
this.SettingsPath = Path.join(global.ConfigPath, 'settings')
|
||||
this.CollectionsPath = Path.join(global.ConfigPath, 'collections')
|
||||
this.AuthorsPath = Path.join(global.ConfigPath, 'authors')
|
||||
this.SSOPath = Path.join(global.ConfigPath, 'sso')
|
||||
|
||||
this.audiobooksDb = new njodb.Database(this.AudiobooksPath)
|
||||
this.usersDb = new njodb.Database(this.UsersPath)
|
||||
|
|
@ -91,7 +88,7 @@ class Db {
|
|||
name: 'Main',
|
||||
folder: { // Generates default folder
|
||||
id: 'audiobooks',
|
||||
fullPath: this.AudiobookPath,
|
||||
fullPath: global.AudiobookPath,
|
||||
libraryId: 'main'
|
||||
}
|
||||
})
|
||||
|
|
@ -136,6 +133,7 @@ class Db {
|
|||
this.SSOSettings = new SSOSettings()
|
||||
await this.insertEntity('settings', this.SSOSettings)
|
||||
}
|
||||
global.ServerSettings = this.serverSettings.toJSON()
|
||||
}
|
||||
|
||||
async load() {
|
||||
|
|
@ -184,11 +182,19 @@ class Db {
|
|||
// Update server version in server settings
|
||||
if (this.previousVersion) {
|
||||
this.serverSettings.version = version
|
||||
await this.updateEntity('settings', this.serverSettings)
|
||||
await this.updateServerSettings()
|
||||
}
|
||||
}
|
||||
|
||||
updateAudiobook(audiobook) {
|
||||
async updateAudiobook(audiobook) {
|
||||
if (audiobook && audiobook.saveAbMetadata) {
|
||||
// TODO: Book may have updates where this save is not necessary
|
||||
// add check first if metadata update is needed
|
||||
await audiobook.saveAbMetadata()
|
||||
} else {
|
||||
Logger.error(`[Db] Invalid audiobook object passed to updateAudiobook`, audiobook)
|
||||
}
|
||||
|
||||
return this.audiobooksDb.update((record) => record.id === audiobook.id, () => audiobook).then((results) => {
|
||||
Logger.debug(`[DB] Audiobook updated ${results.updated}`)
|
||||
return true
|
||||
|
|
@ -198,6 +204,28 @@ class Db {
|
|||
})
|
||||
}
|
||||
|
||||
insertAudiobook(audiobook) {
|
||||
return this.insertAudiobooks([audiobook])
|
||||
}
|
||||
|
||||
async insertAudiobooks(audiobooks) {
|
||||
// TODO: Books may have updates where this save is not necessary
|
||||
// add check first if metadata update is needed
|
||||
await Promise.all(audiobooks.map(async (ab) => {
|
||||
if (ab && ab.saveAbMetadata) return ab.saveAbMetadata()
|
||||
return null
|
||||
}))
|
||||
|
||||
return this.audiobooksDb.insert(audiobooks).then((results) => {
|
||||
Logger.debug(`[DB] Audiobooks inserted ${results.inserted}`)
|
||||
this.audiobooks = this.audiobooks.concat(audiobooks)
|
||||
return true
|
||||
}).catch((error) => {
|
||||
Logger.error(`[DB] Audiobooks insert failed ${error}`)
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
updateUserStream(userId, streamId) {
|
||||
return this.usersDb.update((record) => record.id === userId, (user) => {
|
||||
user.stream = streamId
|
||||
|
|
@ -215,6 +243,11 @@ class Db {
|
|||
})
|
||||
}
|
||||
|
||||
updateServerSettings() {
|
||||
global.ServerSettings = this.serverSettings.toJSON()
|
||||
return this.updateEntity('settings', this.serverSettings)
|
||||
}
|
||||
|
||||
insertEntities(entityName, entities) {
|
||||
var entityDb = this.getEntityDb(entityName)
|
||||
return entityDb.insert(entities).then((results) => {
|
||||
|
|
@ -322,5 +355,12 @@ class Db {
|
|||
return []
|
||||
})
|
||||
}
|
||||
|
||||
// Check if server was updated and previous version was earlier than param
|
||||
checkPreviousVersionIsBefore(version) {
|
||||
if (!this.previousVersion) return false
|
||||
// true if version > previousVersion
|
||||
return version.localeCompare(this.previousVersion) >= 0
|
||||
}
|
||||
}
|
||||
module.exports = Db
|
||||
|
|
|
|||
|
|
@ -11,14 +11,12 @@ const { writeConcatFile, writeMetadataFile } = require('./utils/ffmpegHelpers')
|
|||
const { getFileSize } = require('./utils/fileUtils')
|
||||
const TAG = 'DownloadManager'
|
||||
class DownloadManager {
|
||||
constructor(db, MetadataPath, AudiobookPath, Uid, Gid) {
|
||||
constructor(db, Uid, Gid) {
|
||||
this.Uid = Uid
|
||||
this.Gid = Gid
|
||||
this.db = db
|
||||
this.MetadataPath = MetadataPath
|
||||
this.AudiobookPath = AudiobookPath
|
||||
|
||||
this.downloadDirPath = Path.join(this.MetadataPath, 'downloads')
|
||||
this.downloadDirPath = Path.join(global.MetadataPath, 'downloads')
|
||||
|
||||
this.pendingDownloads = []
|
||||
this.downloads = []
|
||||
|
|
@ -248,7 +246,7 @@ class DownloadManager {
|
|||
// Supporting old local file prefix
|
||||
var bookCoverPath = audiobook.book.cover ? audiobook.book.cover.replace(/\\/g, '/') : null
|
||||
if (!_cover && bookCoverPath && bookCoverPath.startsWith('/local')) {
|
||||
_cover = Path.posix.join(this.AudiobookPath.replace(/\\/g, '/'), _cover.replace('/local', ''))
|
||||
_cover = Path.posix.join(global.AudiobookPath, _cover.replace('/local', ''))
|
||||
Logger.debug('Local cover url', _cover)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,12 +4,11 @@ const fs = require('fs-extra')
|
|||
const Logger = require('./Logger')
|
||||
|
||||
class HlsController {
|
||||
constructor(db, auth, streamManager, emitter, StreamsPath) {
|
||||
constructor(db, auth, streamManager, emitter) {
|
||||
this.db = db
|
||||
this.auth = auth
|
||||
this.streamManager = streamManager
|
||||
this.emitter = emitter
|
||||
this.StreamsPath = StreamsPath
|
||||
|
||||
this.router = express()
|
||||
this.init()
|
||||
|
|
@ -27,7 +26,7 @@ class HlsController {
|
|||
|
||||
async streamFileRequest(req, res) {
|
||||
var streamId = req.params.stream
|
||||
var fullFilePath = Path.join(this.StreamsPath, streamId, req.params.file)
|
||||
var fullFilePath = Path.join(this.streamManager.StreamsPath, streamId, req.params.file)
|
||||
|
||||
// development test stream - ignore
|
||||
if (streamId === 'test') {
|
||||
|
|
|
|||
|
|
@ -8,11 +8,10 @@ const Logger = require('./Logger')
|
|||
const TAG = '[LogManager]'
|
||||
|
||||
class LogManager {
|
||||
constructor(MetadataPath, db) {
|
||||
constructor(db) {
|
||||
this.db = db
|
||||
this.MetadataPath = MetadataPath
|
||||
|
||||
this.logDirPath = Path.join(this.MetadataPath, 'logs')
|
||||
this.logDirPath = Path.join(global.MetadataPath, 'logs')
|
||||
this.dailyLogDirPath = Path.join(this.logDirPath, 'daily')
|
||||
|
||||
this.currentDailyLog = null
|
||||
|
|
|
|||
|
|
@ -39,27 +39,35 @@ class Server {
|
|||
this.Uid = isNaN(UID) ? 0 : Number(UID)
|
||||
this.Gid = isNaN(GID) ? 0 : Number(GID)
|
||||
this.Host = '0.0.0.0'
|
||||
this.ConfigPath = Path.normalize(CONFIG_PATH)
|
||||
this.AudiobookPath = Path.normalize(AUDIOBOOK_PATH)
|
||||
this.MetadataPath = Path.normalize(METADATA_PATH)
|
||||
global.Uid = this.Uid
|
||||
global.Gid = this.Gid
|
||||
global.ConfigPath = Path.normalize(CONFIG_PATH)
|
||||
global.AudiobookPath = Path.normalize(AUDIOBOOK_PATH)
|
||||
global.MetadataPath = Path.normalize(METADATA_PATH)
|
||||
// Fix backslash if not on Windows
|
||||
if (process.platform !== 'win32') {
|
||||
global.ConfigPath = global.ConfigPath.replace(/\\/g, '/')
|
||||
global.AudiobookPath = global.AudiobookPath.replace(/\\/g, '/')
|
||||
global.MetadataPath = global.MetadataPath.replace(/\\/g, '/')
|
||||
}
|
||||
|
||||
fs.ensureDirSync(CONFIG_PATH, 0o774)
|
||||
fs.ensureDirSync(METADATA_PATH, 0o774)
|
||||
fs.ensureDirSync(AUDIOBOOK_PATH, 0o774)
|
||||
fs.ensureDirSync(global.ConfigPath, 0o774)
|
||||
fs.ensureDirSync(global.MetadataPath, 0o774)
|
||||
fs.ensureDirSync(global.AudiobookPath, 0o774)
|
||||
|
||||
this.db = new Db(this.ConfigPath, this.AudiobookPath)
|
||||
this.db = new Db()
|
||||
this.auth = new Auth(this.db)
|
||||
this.backupManager = new BackupManager(this.MetadataPath, this.Uid, this.Gid, this.db)
|
||||
this.logManager = new LogManager(this.MetadataPath, this.db)
|
||||
this.cacheManager = new CacheManager(this.MetadataPath)
|
||||
this.watcher = new Watcher(this.AudiobookPath)
|
||||
this.coverController = new CoverController(this.db, this.cacheManager, this.MetadataPath, this.AudiobookPath)
|
||||
this.scanner = new Scanner(this.AudiobookPath, this.MetadataPath, this.db, this.coverController, this.emitter.bind(this))
|
||||
this.backupManager = new BackupManager(this.Uid, this.Gid, this.db)
|
||||
this.logManager = new LogManager(this.db)
|
||||
this.cacheManager = new CacheManager()
|
||||
this.watcher = new Watcher()
|
||||
this.coverController = new CoverController(this.db, this.cacheManager)
|
||||
this.scanner = new Scanner(this.db, this.coverController, this.emitter.bind(this))
|
||||
|
||||
this.streamManager = new StreamManager(this.db, this.MetadataPath, this.emitter.bind(this), this.clientEmitter.bind(this))
|
||||
this.streamManager = new StreamManager(this.db, this.emitter.bind(this), this.clientEmitter.bind(this))
|
||||
this.rssFeeds = new RssFeeds(this.Port, this.db)
|
||||
this.downloadManager = new DownloadManager(this.db, this.MetadataPath, this.AudiobookPath, this.Uid, this.Gid)
|
||||
this.apiController = new ApiController(this.MetadataPath, this.db, this.auth, this.streamManager, this.rssFeeds, this.downloadManager, this.coverController, this.backupManager, this.watcher, this.cacheManager, this.emitter.bind(this), this.clientEmitter.bind(this))
|
||||
this.downloadManager = new DownloadManager(this.db, this.Uid, this.Gid)
|
||||
this.apiController = new ApiController(this.db, this.auth, this.scanner, this.streamManager, this.rssFeeds, this.downloadManager, this.coverController, this.backupManager, this.watcher, this.cacheManager, this.emitter.bind(this), this.clientEmitter.bind(this))
|
||||
this.hlsController = new HlsController(this.db, this.auth, this.streamManager, this.emitter.bind(this), this.streamManager.StreamsPath)
|
||||
|
||||
Logger.logManager = this.logManager
|
||||
|
|
@ -101,7 +109,7 @@ class Server {
|
|||
clientEmitter(userId, ev, data) {
|
||||
var clients = this.getClientsForUser(userId)
|
||||
if (!clients.length) {
|
||||
return Logger.error(`[Server] clientEmitter - no clients found for user ${userId}`)
|
||||
return Logger.debug(`[Server] clientEmitter - no clients found for user ${userId}`)
|
||||
}
|
||||
clients.forEach((client) => {
|
||||
if (client.socket) {
|
||||
|
|
@ -133,10 +141,18 @@ class Server {
|
|||
Logger.info(`[Server] Running scan for duplicate book IDs`)
|
||||
await this.scanner.fixDuplicateIds()
|
||||
}
|
||||
// If server upgrade and last version was 1.7.0 or earlier - add abmetadata files
|
||||
// if (this.db.checkPreviousVersionIsBefore('1.7.1')) {
|
||||
// TODO: wait until stable
|
||||
// }
|
||||
|
||||
this.watcher.initWatcher(this.libraries)
|
||||
this.watcher.on('files', this.filesChanged.bind(this))
|
||||
|
||||
if (this.db.serverSettings.scannerDisableWatcher) {
|
||||
Logger.info(`[Server] Watcher is disabled`)
|
||||
this.watcher.disabled = true
|
||||
} else {
|
||||
this.watcher.initWatcher(this.libraries)
|
||||
this.watcher.on('files', this.filesChanged.bind(this))
|
||||
}
|
||||
this.passportInit()
|
||||
}
|
||||
|
||||
|
|
@ -194,10 +210,10 @@ class Server {
|
|||
app.use(express.static(distPath))
|
||||
|
||||
// Old static path for covers
|
||||
app.use('/local', this.authMiddleware.bind(this), express.static(this.AudiobookPath))
|
||||
app.use('/local', this.authMiddleware.bind(this), express.static(global.AudiobookPath))
|
||||
|
||||
// Metadata folder static path
|
||||
app.use('/metadata', this.authMiddleware.bind(this), express.static(this.MetadataPath))
|
||||
app.use('/metadata', this.authMiddleware.bind(this), express.static(global.MetadataPath))
|
||||
|
||||
// Downloads folder static path
|
||||
app.use('/downloads', this.authMiddleware.bind(this), express.static(this.downloadManager.downloadDirPath))
|
||||
|
|
@ -314,7 +330,6 @@ class Server {
|
|||
// Streaming
|
||||
socket.on('open_stream', (audiobookId) => this.streamManager.openStreamSocketRequest(socket, audiobookId))
|
||||
socket.on('close_stream', () => this.streamManager.closeStreamRequest(socket))
|
||||
socket.on('stream_update', (payload) => this.streamManager.streamUpdate(socket, payload))
|
||||
socket.on('stream_sync', (syncData) => this.streamManager.streamSync(socket, syncData))
|
||||
|
||||
socket.on('progress_update', (payload) => this.audiobookProgressUpdate(socket, payload))
|
||||
|
|
@ -399,7 +414,7 @@ class Server {
|
|||
|
||||
// Remove unused /metadata/books/{id} folders
|
||||
async purgeMetadata() {
|
||||
var booksMetadata = Path.join(this.MetadataPath, 'books')
|
||||
var booksMetadata = Path.join(global.MetadataPath, 'books')
|
||||
var booksMetadataExists = await fs.pathExists(booksMetadata)
|
||||
if (!booksMetadataExists) return
|
||||
var foldersInBooksMetadata = await fs.readdir(booksMetadata)
|
||||
|
|
@ -458,25 +473,27 @@ class Server {
|
|||
|
||||
var library = this.db.libraries.find(lib => lib.id === libraryId)
|
||||
if (!library) {
|
||||
return res.status(500).error(`Library not found with id ${libraryId}`)
|
||||
return res.status(500).send(`Library not found with id ${libraryId}`)
|
||||
}
|
||||
var folder = library.folders.find(fold => fold.id === folderId)
|
||||
if (!folder) {
|
||||
return res.status(500).error(`Folder not found with id ${folderId} in library ${library.name}`)
|
||||
return res.status(500).send(`Folder not found with id ${folderId} in library ${library.name}`)
|
||||
}
|
||||
|
||||
if (!files.length || !title || !author) {
|
||||
return res.status(500).error(`Invalid post data`)
|
||||
if (!files.length || !title) {
|
||||
return res.status(500).send(`Invalid post data`)
|
||||
}
|
||||
|
||||
// For setting permissions recursively
|
||||
var firstDirPath = Path.join(folder.fullPath, author)
|
||||
|
||||
var outputDirectory = ''
|
||||
if (series && series.length && series !== 'null') {
|
||||
if (series && author) {
|
||||
outputDirectory = Path.join(folder.fullPath, author, series, title)
|
||||
} else {
|
||||
} else if (author) {
|
||||
outputDirectory = Path.join(folder.fullPath, author, title)
|
||||
} else {
|
||||
outputDirectory = Path.join(folder.fullPath, title)
|
||||
}
|
||||
|
||||
var exists = await fs.pathExists(outputDirectory)
|
||||
|
|
@ -672,9 +689,9 @@ class Server {
|
|||
|
||||
const initialPayload = {
|
||||
serverSettings: this.serverSettings.toJSON(),
|
||||
audiobookPath: this.AudiobookPath,
|
||||
metadataPath: this.MetadataPath,
|
||||
configPath: this.ConfigPath,
|
||||
audiobookPath: global.AudiobookPath,
|
||||
metadataPath: global.MetadataPath,
|
||||
configPath: global.ConfigPath,
|
||||
user: client.user.toJSONForBrowser(),
|
||||
stream: client.stream || null,
|
||||
librariesScanning: this.scanner.librariesScanning,
|
||||
|
|
|
|||
|
|
@ -5,15 +5,14 @@ const fs = require('fs-extra')
|
|||
const Path = require('path')
|
||||
|
||||
class StreamManager {
|
||||
constructor(db, MetadataPath, emitter, clientEmitter) {
|
||||
constructor(db, emitter, clientEmitter) {
|
||||
this.db = db
|
||||
|
||||
this.emitter = emitter
|
||||
this.clientEmitter = clientEmitter
|
||||
|
||||
this.MetadataPath = MetadataPath
|
||||
this.streams = []
|
||||
this.StreamsPath = Path.join(this.MetadataPath, 'streams')
|
||||
this.StreamsPath = Path.join(global.MetadataPath, 'streams')
|
||||
}
|
||||
|
||||
get audiobooks() {
|
||||
|
|
@ -68,12 +67,12 @@ class StreamManager {
|
|||
|
||||
async tempCheckStrayStreams() {
|
||||
try {
|
||||
var dirs = await fs.readdir(this.MetadataPath)
|
||||
var dirs = await fs.readdir(global.MetadataPath)
|
||||
if (!dirs || !dirs.length) return true
|
||||
|
||||
await Promise.all(dirs.map(async (dirname) => {
|
||||
if (dirname !== 'streams' && dirname !== 'books' && dirname !== 'downloads' && dirname !== 'backups' && dirname !== 'logs' && dirname !== 'cache') {
|
||||
var fullPath = Path.join(this.MetadataPath, dirname)
|
||||
var fullPath = Path.join(global.MetadataPath, dirname)
|
||||
Logger.warn(`Removing OLD Orphan Stream ${dirname}`)
|
||||
return fs.remove(fullPath)
|
||||
}
|
||||
|
|
@ -155,6 +154,30 @@ class StreamManager {
|
|||
this.emitter('user_stream_update', client.user.toJSONForPublic(this.streams))
|
||||
}
|
||||
|
||||
async closeStreamApiRequest(userId, streamId) {
|
||||
Logger.info('[StreamManager] Close Stream Api Request', streamId)
|
||||
|
||||
var stream = this.streams.find(s => s.id === streamId)
|
||||
if (!stream) {
|
||||
Logger.warn('[StreamManager] Stream not found', streamId)
|
||||
return
|
||||
}
|
||||
|
||||
if (!stream.client || !stream.client.user || stream.client.user.id !== userId) {
|
||||
Logger.warn(`[StreamManager] Stream close request from invalid user ${userId}`, stream.client)
|
||||
return
|
||||
}
|
||||
|
||||
stream.client.user.stream = null
|
||||
stream.client.stream = null
|
||||
this.db.updateUserStream(stream.client.user.id, null)
|
||||
|
||||
await stream.close()
|
||||
|
||||
this.streams = this.streams.filter(s => s.id !== streamId)
|
||||
Logger.info(`[StreamManager] Stream ${streamId} closed via API request by ${userId}`)
|
||||
}
|
||||
|
||||
streamSync(socket, syncData) {
|
||||
const client = socket.sheepClient
|
||||
if (!client || !client.stream) {
|
||||
|
|
@ -233,35 +256,5 @@ class StreamManager {
|
|||
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
streamUpdate(socket, { currentTime, streamId }) {
|
||||
var client = socket.sheepClient
|
||||
if (!client || !client.stream) {
|
||||
Logger.error('No stream for client', (client && client.user) ? client.user.id : 'No Client')
|
||||
return
|
||||
}
|
||||
if (client.stream.id !== streamId) {
|
||||
Logger.error('Stream id mismatch on stream update', streamId, client.stream.id)
|
||||
return
|
||||
}
|
||||
client.stream.updateClientCurrentTime(currentTime)
|
||||
if (!client.user) {
|
||||
Logger.error('No User for client', client)
|
||||
return
|
||||
}
|
||||
if (!client.user.updateAudiobookProgressFromStream) {
|
||||
Logger.error('Invalid User for client', client)
|
||||
return
|
||||
}
|
||||
var userAudiobook = client.user.updateAudiobookProgressFromStream(client.stream)
|
||||
this.db.updateEntity('user', client.user)
|
||||
|
||||
if (userAudiobook) {
|
||||
this.clientEmitter(client.user.id, 'current_user_audiobook_update', {
|
||||
id: userAudiobook.audiobookId,
|
||||
data: userAudiobook.toJSON()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = StreamManager
|
||||
|
|
@ -13,6 +13,8 @@ class FolderWatcher extends EventEmitter {
|
|||
this.pendingFileUpdates = []
|
||||
this.pendingDelay = 4000
|
||||
this.pendingTimeout = null
|
||||
|
||||
this.disabled = false
|
||||
}
|
||||
|
||||
get pendingFilePaths() {
|
||||
|
|
@ -66,15 +68,19 @@ class FolderWatcher extends EventEmitter {
|
|||
|
||||
initWatcher(libraries) {
|
||||
libraries.forEach((lib) => {
|
||||
this.buildLibraryWatcher(lib)
|
||||
if (!lib.disableWatcher) {
|
||||
this.buildLibraryWatcher(lib)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
addLibrary(library) {
|
||||
if (this.disabled || library.disableWatcher) return
|
||||
this.buildLibraryWatcher(library)
|
||||
}
|
||||
|
||||
updateLibrary(library) {
|
||||
if (this.disabled || library.disableWatcher) return
|
||||
var libwatcher = this.libraryWatchers.find(lib => lib.id === library.id)
|
||||
if (libwatcher) {
|
||||
libwatcher.name = library.name
|
||||
|
|
@ -90,6 +96,7 @@ class FolderWatcher extends EventEmitter {
|
|||
}
|
||||
|
||||
removeLibrary(library) {
|
||||
if (this.disabled) return
|
||||
var libwatcher = this.libraryWatchers.find(lib => lib.id === library.id)
|
||||
if (libwatcher) {
|
||||
Logger.info(`[Watcher] Removed watcher for "${library.name}"`)
|
||||
|
|
|
|||
|
|
@ -18,9 +18,6 @@ class BookController {
|
|||
}
|
||||
|
||||
findOne(req, res) {
|
||||
if (!req.user) {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
var audiobook = this.db.audiobooks.find(a => a.id === req.params.id)
|
||||
if (!audiobook) return res.sendStatus(404)
|
||||
|
||||
|
|
@ -48,8 +45,8 @@ class BookController {
|
|||
var hasUpdates = audiobook.update(req.body)
|
||||
if (hasUpdates) {
|
||||
await this.db.updateAudiobook(audiobook)
|
||||
this.emitter('audiobook_updated', audiobook.toJSONExpanded())
|
||||
}
|
||||
this.emitter('audiobook_updated', audiobook.toJSONExpanded())
|
||||
res.json(audiobook.toJSON())
|
||||
}
|
||||
|
||||
|
|
@ -259,5 +256,24 @@ class BookController {
|
|||
}
|
||||
return this.cacheManager.handleCoverCache(res, audiobook, options)
|
||||
}
|
||||
|
||||
// POST api/books/:id/match
|
||||
async match(req, res) {
|
||||
if (!req.user.canUpdate) {
|
||||
Logger.warn('User attempted to match without permission', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
var audiobook = this.db.audiobooks.find(a => a.id === req.params.id)
|
||||
if (!audiobook) return res.sendStatus(404)
|
||||
|
||||
// Check user can access this audiobooks library
|
||||
if (!req.user.checkCanAccessLibrary(audiobook.libraryId)) {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
var options = req.body || {}
|
||||
var matchResult = await this.scanner.quickMatchBook(audiobook, options)
|
||||
res.json(matchResult)
|
||||
}
|
||||
}
|
||||
module.exports = new BookController()
|
||||
|
|
@ -144,10 +144,19 @@ class LibraryController {
|
|||
}
|
||||
|
||||
if (payload.sortBy) {
|
||||
var sortKey = payload.sortBy
|
||||
|
||||
// Handle server setting sortingIgnorePrefix
|
||||
if ((sortKey === 'book.series' || sortKey === 'book.title') && this.db.serverSettings.sortingIgnorePrefix) {
|
||||
// Book.js has seriesIgnorePrefix and titleIgnorePrefix getters
|
||||
sortKey += 'IgnorePrefix'
|
||||
}
|
||||
|
||||
var direction = payload.sortDesc ? 'desc' : 'asc'
|
||||
audiobooks = naturalSort(audiobooks)[direction]((ab) => {
|
||||
|
||||
// Supports dot notation strings i.e. "book.title"
|
||||
return payload.sortBy.split('.').reduce((a, b) => a[b], ab)
|
||||
return sortKey.split('.').reduce((a, b) => a[b], ab)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -202,7 +211,14 @@ class LibraryController {
|
|||
}
|
||||
|
||||
var series = libraryHelpers.getSeriesFromBooks(audiobooks, payload.minified)
|
||||
series = sort(series).asc(s => s.name)
|
||||
|
||||
var sortingIgnorePrefix = this.db.serverSettings.sortingIgnorePrefix
|
||||
series = sort(series).asc(s => {
|
||||
if (sortingIgnorePrefix && s.name.toLowerCase().startsWith('the ')) {
|
||||
return s.name.substr(4)
|
||||
}
|
||||
return s.name
|
||||
})
|
||||
payload.total = series.length
|
||||
|
||||
if (payload.limit) {
|
||||
|
|
@ -318,7 +334,7 @@ class LibraryController {
|
|||
async reorder(req, res) {
|
||||
if (!req.user.isRoot) {
|
||||
Logger.error('[ApiController] ReorderLibraries invalid user', req.user)
|
||||
return res.sendStatus(401)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
var orderdata = req.body
|
||||
|
|
@ -454,6 +470,15 @@ class LibraryController {
|
|||
res.json(Object.values(authors))
|
||||
}
|
||||
|
||||
async matchBooks(req, res) {
|
||||
if (!req.user.isRoot) {
|
||||
Logger.error(`[LibraryController] Non-root user attempted to match library books`, req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
this.scanner.matchLibraryBooks(req.library)
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
middleware(req, res, next) {
|
||||
var librariesAccessible = req.user.librariesAccessible || []
|
||||
if (librariesAccessible && librariesAccessible.length && !librariesAccessible.includes(req.params.id)) {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ class AudioFile {
|
|||
this.ext = null
|
||||
this.path = null
|
||||
this.fullPath = null
|
||||
this.mtimeMs = null
|
||||
this.ctimeMs = null
|
||||
this.birthtimeMs = null
|
||||
this.addedAt = null
|
||||
|
||||
this.trackNumFromMeta = null
|
||||
|
|
@ -51,6 +54,9 @@ class AudioFile {
|
|||
ext: this.ext,
|
||||
path: this.path,
|
||||
fullPath: this.fullPath,
|
||||
mtimeMs: this.mtimeMs,
|
||||
ctimeMs: this.ctimeMs,
|
||||
birthtimeMs: this.birthtimeMs,
|
||||
addedAt: this.addedAt,
|
||||
trackNumFromMeta: this.trackNumFromMeta,
|
||||
discNumFromMeta: this.discNumFromMeta,
|
||||
|
|
@ -82,6 +88,9 @@ class AudioFile {
|
|||
this.ext = data.ext
|
||||
this.path = data.path
|
||||
this.fullPath = data.fullPath
|
||||
this.mtimeMs = data.mtimeMs || 0
|
||||
this.ctimeMs = data.ctimeMs || 0
|
||||
this.birthtimeMs = data.birthtimeMs || 0
|
||||
this.addedAt = data.addedAt
|
||||
this.manuallyVerified = !!data.manuallyVerified
|
||||
this.invalid = !!data.invalid
|
||||
|
|
@ -124,6 +133,9 @@ class AudioFile {
|
|||
this.ext = fileData.ext
|
||||
this.path = fileData.path
|
||||
this.fullPath = fileData.fullPath
|
||||
this.mtimeMs = fileData.mtimeMs || 0
|
||||
this.ctimeMs = fileData.ctimeMs || 0
|
||||
this.birthtimeMs = fileData.birthtimeMs || 0
|
||||
this.addedAt = Date.now()
|
||||
|
||||
this.trackNumFromMeta = fileData.trackNumFromMeta
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
const Path = require('path')
|
||||
const fs = require('fs-extra')
|
||||
const { bytesPretty, readTextFile } = require('../utils/fileUtils')
|
||||
const { comparePaths, getIno, getId, elapsedPretty } = require('../utils/index')
|
||||
const { bytesPretty, readTextFile, getIno } = require('../utils/fileUtils')
|
||||
const { comparePaths, getId, elapsedPretty } = require('../utils/index')
|
||||
const { parseOpfMetadataXML } = require('../utils/parseOpfMetadata')
|
||||
const { extractCoverArt } = require('../utils/ffmpegHelpers')
|
||||
const nfoGenerator = require('../utils/nfoGenerator')
|
||||
const abmetadataGenerator = require('../utils/abmetadataGenerator')
|
||||
const Logger = require('../Logger')
|
||||
const Book = require('./Book')
|
||||
const AudioTrack = require('./AudioTrack')
|
||||
|
|
@ -21,6 +22,9 @@ class Audiobook {
|
|||
|
||||
this.path = null
|
||||
this.fullPath = null
|
||||
this.mtimeMs = null
|
||||
this.ctimeMs = null
|
||||
this.birthtimeMs = null
|
||||
this.addedAt = null
|
||||
this.lastUpdate = null
|
||||
this.lastScan = null
|
||||
|
|
@ -44,6 +48,9 @@ class Audiobook {
|
|||
if (audiobook) {
|
||||
this.construct(audiobook)
|
||||
}
|
||||
|
||||
// Temp flags
|
||||
this.isSavingMetadata = false
|
||||
}
|
||||
|
||||
construct(audiobook) {
|
||||
|
|
@ -53,6 +60,9 @@ class Audiobook {
|
|||
this.folderId = audiobook.folderId || 'audiobooks'
|
||||
this.path = audiobook.path
|
||||
this.fullPath = audiobook.fullPath
|
||||
this.mtimeMs = audiobook.mtimeMs || 0
|
||||
this.ctimeMs = audiobook.ctimeMs || 0
|
||||
this.birthtimeMs = audiobook.birthtimeMs || 0
|
||||
this.addedAt = audiobook.addedAt
|
||||
this.lastUpdate = audiobook.lastUpdate || this.addedAt
|
||||
this.lastScan = audiobook.lastScan || null
|
||||
|
|
@ -173,11 +183,11 @@ class Audiobook {
|
|||
ino: this.ino,
|
||||
libraryId: this.libraryId,
|
||||
folderId: this.folderId,
|
||||
title: this.title,
|
||||
author: this.author,
|
||||
cover: this.cover,
|
||||
path: this.path,
|
||||
fullPath: this.fullPath,
|
||||
mtimeMs: this.mtimeMs,
|
||||
ctimeMs: this.ctimeMs,
|
||||
birthtimeMs: this.birthtimeMs,
|
||||
addedAt: this.addedAt,
|
||||
lastUpdate: this.lastUpdate,
|
||||
lastScan: this.lastScan,
|
||||
|
|
@ -204,6 +214,9 @@ class Audiobook {
|
|||
tags: this.tags,
|
||||
path: this.path,
|
||||
fullPath: this.fullPath,
|
||||
mtimeMs: this.mtimeMs,
|
||||
ctimeMs: this.ctimeMs,
|
||||
birthtimeMs: this.birthtimeMs,
|
||||
addedAt: this.addedAt,
|
||||
lastUpdate: this.lastUpdate,
|
||||
duration: this.duration,
|
||||
|
|
@ -227,6 +240,9 @@ class Audiobook {
|
|||
folderId: this.folderId,
|
||||
path: this.path,
|
||||
fullPath: this.fullPath,
|
||||
mtimeMs: this.mtimeMs,
|
||||
ctimeMs: this.ctimeMs,
|
||||
birthtimeMs: this.birthtimeMs,
|
||||
addedAt: this.addedAt,
|
||||
lastUpdate: this.lastUpdate,
|
||||
duration: this.duration,
|
||||
|
|
@ -334,6 +350,9 @@ class Audiobook {
|
|||
|
||||
this.path = data.path
|
||||
this.fullPath = data.fullPath
|
||||
this.mtimeMs = data.mtimeMs || 0
|
||||
this.ctimeMs = data.ctimeMs || 0
|
||||
this.birthtimeMs = data.birthtimeMs || 0
|
||||
this.addedAt = Date.now()
|
||||
this.lastUpdate = this.addedAt
|
||||
|
||||
|
|
@ -425,13 +444,8 @@ class Audiobook {
|
|||
hasUpdates = true
|
||||
}
|
||||
|
||||
if (payload.book) {
|
||||
if (!this.book) {
|
||||
this.setBook(payload.book)
|
||||
hasUpdates = true
|
||||
} else if (this.book.update(payload.book)) {
|
||||
hasUpdates = true
|
||||
}
|
||||
if (payload.book && this.book.update(payload.book)) {
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
if (hasUpdates) {
|
||||
|
|
@ -526,7 +540,7 @@ class Audiobook {
|
|||
}
|
||||
|
||||
// On scan check other files found with other files saved
|
||||
async syncOtherFiles(newOtherFiles, metadataPath, opfMetadataOverrideDetails, forceRescan = false) {
|
||||
async syncOtherFiles(newOtherFiles, opfMetadataOverrideDetails) {
|
||||
var hasUpdates = false
|
||||
|
||||
var currOtherFileNum = this.otherFiles.length
|
||||
|
|
@ -535,6 +549,8 @@ class Audiobook {
|
|||
var alreadyHasDescTxt = otherFilenamesAlreadyInBook.includes('desc.txt')
|
||||
var alreadyHasReaderTxt = otherFilenamesAlreadyInBook.includes('reader.txt')
|
||||
|
||||
var existingAbMetadata = this.otherFiles.find(file => file.filename === 'metadata.abs')
|
||||
|
||||
// Filter out other files no longer in directory
|
||||
var newOtherFilePaths = newOtherFiles.map(f => f.path)
|
||||
this.otherFiles = this.otherFiles.filter(f => newOtherFilePaths.includes(f.path))
|
||||
|
|
@ -543,9 +559,9 @@ class Audiobook {
|
|||
hasUpdates = true
|
||||
}
|
||||
|
||||
// If desc.txt is new or forcing rescan then read it and update description (will overwrite)
|
||||
// If desc.txt is new then read it and update description (will overwrite)
|
||||
var descriptionTxt = newOtherFiles.find(file => file.filename === 'desc.txt')
|
||||
if (descriptionTxt && (!alreadyHasDescTxt || forceRescan)) {
|
||||
if (descriptionTxt && !alreadyHasDescTxt) {
|
||||
var newDescription = await readTextFile(descriptionTxt.fullPath)
|
||||
if (newDescription) {
|
||||
Logger.debug(`[Audiobook] Sync Other File desc.txt: ${newDescription}`)
|
||||
|
|
@ -553,9 +569,9 @@ class Audiobook {
|
|||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
// If reader.txt is new or forcing rescan then read it and update narrator (will overwrite)
|
||||
// If reader.txt is new then read it and update narrator (will overwrite)
|
||||
var readerTxt = newOtherFiles.find(file => file.filename === 'reader.txt')
|
||||
if (readerTxt && (!alreadyHasReaderTxt || forceRescan)) {
|
||||
if (readerTxt && !alreadyHasReaderTxt) {
|
||||
var newReader = await readTextFile(readerTxt.fullPath)
|
||||
if (newReader) {
|
||||
Logger.debug(`[Audiobook] Sync Other File reader.txt: ${newReader}`)
|
||||
|
|
@ -564,7 +580,28 @@ class Audiobook {
|
|||
}
|
||||
}
|
||||
|
||||
// If OPF file and was not already there
|
||||
|
||||
// If metadata.abs is new OR modified then read it and set all defined keys (will overwrite)
|
||||
var metadataAbs = newOtherFiles.find(file => file.filename === 'metadata.abs')
|
||||
var shouldUpdateAbs = !!metadataAbs && (metadataAbs.modified || !existingAbMetadata)
|
||||
if (metadataAbs && metadataAbs.modified) {
|
||||
Logger.debug(`[Audiobook] metadata.abs file was modified for "${this.title}"`)
|
||||
}
|
||||
|
||||
if (shouldUpdateAbs) {
|
||||
var abmetadataText = await readTextFile(metadataAbs.fullPath)
|
||||
if (abmetadataText) {
|
||||
var metadataUpdateObject = abmetadataGenerator.parse(abmetadataText)
|
||||
if (metadataUpdateObject && metadataUpdateObject.book) {
|
||||
if (this.update(metadataUpdateObject)) {
|
||||
Logger.debug(`[Audiobook] Some details were updated from metadata.abs for "${this.title}"`, metadataUpdateObject)
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If OPF file and was not already there OR prefer opf metadata
|
||||
var metadataOpf = newOtherFiles.find(file => file.ext === '.opf' || file.filename === 'metadata.xml')
|
||||
if (metadataOpf && (!otherFilenamesAlreadyInBook.includes(metadataOpf.filename) || opfMetadataOverrideDetails)) {
|
||||
var xmlText = await readTextFile(metadataOpf.fullPath)
|
||||
|
|
@ -643,7 +680,7 @@ class Audiobook {
|
|||
if (bookCoverPath && bookCoverPath.startsWith('/metadata')) {
|
||||
// Fixing old cover paths
|
||||
if (!this.book.coverFullPath) {
|
||||
this.book.coverFullPath = Path.join(metadataPath, this.book.cover.substr('/metadata/'.length)).replace(/\\/g, '/').replace(/\/\//g, '/')
|
||||
this.book.coverFullPath = Path.join(global.MetadataPath, this.book.cover.substr('/metadata/'.length)).replace(/\\/g, '/').replace(/\/\//g, '/')
|
||||
Logger.debug(`[Audiobook] Metadata cover full path set "${this.book.coverFullPath}" for "${this.title}"`)
|
||||
hasUpdates = true
|
||||
}
|
||||
|
|
@ -800,9 +837,10 @@ class Audiobook {
|
|||
return false
|
||||
}
|
||||
|
||||
// Look for desc.txt and reader.txt and update details if found
|
||||
// Look for desc.txt, reader.txt, metadata.abs and opf file then update details if found
|
||||
async saveDataFromTextFiles(opfMetadataOverrideDetails) {
|
||||
var bookUpdatePayload = {}
|
||||
|
||||
var descriptionText = await this.fetchTextFromTextFile('desc.txt')
|
||||
if (descriptionText) {
|
||||
Logger.debug(`[Audiobook] "${this.title}" found desc.txt updating description with "${descriptionText.slice(0, 20)}..."`)
|
||||
|
|
@ -814,6 +852,22 @@ class Audiobook {
|
|||
bookUpdatePayload.narrator = readerText
|
||||
}
|
||||
|
||||
// abmetadata will always overwrite
|
||||
var abmetadataText = await this.fetchTextFromTextFile('metadata.abs')
|
||||
if (abmetadataText) {
|
||||
var metadataUpdateObject = abmetadataGenerator.parse(abmetadataText)
|
||||
if (metadataUpdateObject && metadataUpdateObject.book) {
|
||||
Logger.debug(`[Audiobook] "${this.title}" found metadata.abs file`)
|
||||
for (const key in metadataUpdateObject.book) {
|
||||
var value = metadataUpdateObject.book[key]
|
||||
if (key && value !== undefined) {
|
||||
bookUpdatePayload[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Opf only overwrites if detail is empty
|
||||
var metadataOpf = this.otherFiles.find(file => file.isOPFFile || file.filename === 'metadata.xml')
|
||||
if (metadataOpf) {
|
||||
var xmlText = await readTextFile(metadataOpf.fullPath)
|
||||
|
|
@ -873,12 +927,6 @@ class Audiobook {
|
|||
}
|
||||
}
|
||||
|
||||
if (existingFile.filename !== fileFound.filename) {
|
||||
existingFile.filename = fileFound.filename
|
||||
existingFile.ext = fileFound.ext
|
||||
hasUpdated = true
|
||||
}
|
||||
|
||||
if (existingFile.path !== fileFound.path) {
|
||||
existingFile.path = fileFound.path
|
||||
existingFile.fullPath = fileFound.fullPath
|
||||
|
|
@ -888,6 +936,20 @@ class Audiobook {
|
|||
hasUpdated = true
|
||||
}
|
||||
|
||||
var keysToCheck = ['filename', 'ext', 'mtimeMs', 'ctimeMs', 'birthtimeMs', 'size']
|
||||
keysToCheck.forEach((key) => {
|
||||
if (existingFile[key] !== fileFound[key]) {
|
||||
|
||||
// Add modified flag on file data object if exists and was changed
|
||||
if (key === 'mtimeMs' && existingFile[key]) {
|
||||
fileFound.modified = true
|
||||
}
|
||||
|
||||
existingFile[key] = fileFound[key]
|
||||
hasUpdated = true
|
||||
}
|
||||
})
|
||||
|
||||
if (!isAudioFile && existingFile.filetype !== fileFound.filetype) {
|
||||
existingFile.filetype = fileFound.filetype
|
||||
hasUpdated = true
|
||||
|
|
@ -927,6 +989,14 @@ class Audiobook {
|
|||
hasUpdated = true
|
||||
}
|
||||
|
||||
var keysToCheck = ['mtimeMs', 'ctimeMs', 'birthtimeMs']
|
||||
keysToCheck.forEach((key) => {
|
||||
if (dataFound[key] != this[key]) {
|
||||
this[key] = dataFound[key] || 0
|
||||
hasUpdated = true
|
||||
}
|
||||
})
|
||||
|
||||
var newAudioFileData = []
|
||||
var newOtherFileData = []
|
||||
var existingAudioFileData = []
|
||||
|
|
@ -1017,14 +1087,14 @@ class Audiobook {
|
|||
}
|
||||
|
||||
// Temp fix for cover is set but coverFullPath is not set
|
||||
fixFullCoverPath(metadataPath) {
|
||||
fixFullCoverPath() {
|
||||
if (!this.book.cover) return
|
||||
var bookCoverPath = this.book.cover.replace(/\\/g, '/')
|
||||
var newFullCoverPath = null
|
||||
if (bookCoverPath.startsWith('/s/book/')) {
|
||||
newFullCoverPath = Path.join(this.fullPath, bookCoverPath.substr(`/s/book/${this.id}`.length)).replace(/\/\//g, '/')
|
||||
} else if (bookCoverPath.startsWith('/metadata/')) {
|
||||
newFullCoverPath = Path.join(metadataPath, bookCoverPath.substr('/metadata/'.length)).replace(/\/\//g, '/')
|
||||
newFullCoverPath = Path.join(global.MetadataPath, bookCoverPath.substr('/metadata/'.length)).replace(/\/\//g, '/')
|
||||
}
|
||||
if (newFullCoverPath) {
|
||||
Logger.debug(`[Audiobook] "${this.title}" fixing full cover path "${this.book.cover}" => "${newFullCoverPath}"`)
|
||||
|
|
@ -1033,5 +1103,26 @@ class Audiobook {
|
|||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async saveAbMetadata() {
|
||||
if (this.isSavingMetadata) return
|
||||
this.isSavingMetadata = true
|
||||
|
||||
var metadataPath = Path.join(global.MetadataPath, 'books', this.id)
|
||||
if (global.ServerSettings.storeMetadataWithBook) {
|
||||
metadataPath = this.fullPath
|
||||
} else {
|
||||
// Make sure metadata book dir exists
|
||||
await fs.ensureDir(metadataPath)
|
||||
}
|
||||
metadataPath = Path.join(metadataPath, 'metadata.abs')
|
||||
|
||||
return abmetadataGenerator.generate(this, metadataPath).then((success) => {
|
||||
this.isSavingMetadata = false
|
||||
if (!success) Logger.error(`[Audiobook] Failed saving abmetadata to "${metadataPath}"`)
|
||||
else Logger.debug(`[Audiobook] Success saving abmetadata to "${metadataPath}"`)
|
||||
return success
|
||||
})
|
||||
}
|
||||
}
|
||||
module.exports = Audiobook
|
||||
|
|
@ -6,6 +6,11 @@ class AudiobookFile {
|
|||
this.ext = null
|
||||
this.path = null
|
||||
this.fullPath = null
|
||||
this.size = null
|
||||
this.mtimeMs = null
|
||||
this.ctimeMs = null
|
||||
this.birthtimeMs = null
|
||||
|
||||
this.addedAt = null
|
||||
|
||||
if (data) {
|
||||
|
|
@ -25,6 +30,10 @@ class AudiobookFile {
|
|||
ext: this.ext,
|
||||
path: this.path,
|
||||
fullPath: this.fullPath,
|
||||
size: this.size,
|
||||
mtimeMs: this.mtimeMs,
|
||||
ctimeMs: this.ctimeMs,
|
||||
birthtimeMs: this.birthtimeMs,
|
||||
addedAt: this.addedAt
|
||||
}
|
||||
}
|
||||
|
|
@ -36,6 +45,10 @@ class AudiobookFile {
|
|||
this.ext = data.ext
|
||||
this.path = data.path
|
||||
this.fullPath = data.fullPath
|
||||
this.size = data.size || 0
|
||||
this.mtimeMs = data.mtimeMs || 0
|
||||
this.ctimeMs = data.ctimeMs || 0
|
||||
this.birthtimeMs = data.birthtimeMs || 0
|
||||
this.addedAt = data.addedAt
|
||||
}
|
||||
|
||||
|
|
@ -46,6 +59,10 @@ class AudiobookFile {
|
|||
this.ext = data.ext
|
||||
this.path = data.path
|
||||
this.fullPath = data.fullPath
|
||||
this.size = data.size || 0
|
||||
this.mtimeMs = data.mtimeMs || 0
|
||||
this.ctimeMs = data.ctimeMs || 0
|
||||
this.birthtimeMs = data.birthtimeMs || 0
|
||||
this.addedAt = Date.now()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,20 @@ class Book {
|
|||
get _asin() { return this.asin || '' }
|
||||
get genresCommaSeparated() { return this._genres.join(', ') }
|
||||
|
||||
get titleIgnorePrefix() {
|
||||
if (this._title.toLowerCase().startsWith('the ')) {
|
||||
return this._title.substr(4) + ', The'
|
||||
}
|
||||
return this._title
|
||||
}
|
||||
|
||||
get seriesIgnorePrefix() {
|
||||
if (this._series.toLowerCase().startsWith('the ')) {
|
||||
return this._series.substr(4) + ', The'
|
||||
}
|
||||
return this._series
|
||||
}
|
||||
|
||||
get shouldSearchForCover() {
|
||||
if (this.cover) return false
|
||||
if (this.authorFL !== this.lastCoverSearchAuthor || this.title !== this.lastCoverSearchTitle || !this.lastCoverSearch) return true
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ class Library {
|
|||
this.folders = []
|
||||
this.displayOrder = 1
|
||||
this.icon = 'database'
|
||||
this.provider = 'google'
|
||||
this.disableWatcher = false
|
||||
|
||||
this.lastScan = 0
|
||||
|
||||
|
|
@ -29,6 +31,8 @@ class Library {
|
|||
this.folders = (library.folders || []).map(f => new Folder(f))
|
||||
this.displayOrder = library.displayOrder || 1
|
||||
this.icon = library.icon || 'database'
|
||||
this.provider = library.provider || 'google'
|
||||
this.disableWatcher = !!library.disableWatcher
|
||||
|
||||
this.createdAt = library.createdAt
|
||||
this.lastUpdate = library.lastUpdate
|
||||
|
|
@ -41,6 +45,8 @@ class Library {
|
|||
folders: (this.folders || []).map(f => f.toJSON()),
|
||||
displayOrder: this.displayOrder,
|
||||
icon: this.icon,
|
||||
provider: this.provider,
|
||||
disableWatcher: this.disableWatcher,
|
||||
createdAt: this.createdAt,
|
||||
lastUpdate: this.lastUpdate
|
||||
}
|
||||
|
|
@ -65,6 +71,7 @@ class Library {
|
|||
}
|
||||
this.displayOrder = data.displayOrder || 1
|
||||
this.icon = data.icon || 'database'
|
||||
this.disableWatcher = !!data.disableWatcher
|
||||
this.createdAt = Date.now()
|
||||
this.lastUpdate = Date.now()
|
||||
}
|
||||
|
|
@ -75,6 +82,14 @@ class Library {
|
|||
this.name = payload.name
|
||||
hasUpdates = true
|
||||
}
|
||||
if (payload.provider && payload.provider !== this.provider) {
|
||||
this.provider = payload.provider
|
||||
hasUpdates = true
|
||||
}
|
||||
if (payload.disableWatcher !== this.disableWatcher) {
|
||||
this.disableWatcher = !!payload.disableWatcher
|
||||
hasUpdates = true
|
||||
}
|
||||
if (!isNaN(payload.displayOrder) && payload.displayOrder !== this.displayOrder) {
|
||||
this.displayOrder = Number(payload.displayOrder)
|
||||
hasUpdates = true
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
const { CoverDestination, BookCoverAspectRatio, BookshelfView } = require('../utils/constants')
|
||||
const { BookCoverAspectRatio, BookshelfView } = require('../utils/constants')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
class ServerSettings {
|
||||
|
|
@ -15,10 +15,11 @@ class ServerSettings {
|
|||
this.scannerCoverProvider = 'google'
|
||||
this.scannerPreferAudioMetadata = false
|
||||
this.scannerPreferOpfMetadata = false
|
||||
this.scannerDisableWatcher = false
|
||||
|
||||
// Metadata
|
||||
this.coverDestination = CoverDestination.METADATA
|
||||
this.saveMetadataFile = false
|
||||
this.storeCoverWithBook = false
|
||||
this.storeMetadataWithBook = false
|
||||
|
||||
// Security/Rate limits
|
||||
this.rateLimitLoginRequests = 10
|
||||
|
|
@ -38,6 +39,8 @@ class ServerSettings {
|
|||
this.coverAspectRatio = BookCoverAspectRatio.SQUARE
|
||||
this.bookshelfView = BookshelfView.STANDARD
|
||||
|
||||
this.sortingIgnorePrefix = false
|
||||
this.chromecastEnabled = false
|
||||
this.logLevel = Logger.logLevel
|
||||
this.version = null
|
||||
|
||||
|
|
@ -54,9 +57,14 @@ class ServerSettings {
|
|||
this.scannerParseSubtitle = settings.scannerParseSubtitle
|
||||
this.scannerPreferAudioMetadata = !!settings.scannerPreferAudioMetadata
|
||||
this.scannerPreferOpfMetadata = !!settings.scannerPreferOpfMetadata
|
||||
this.scannerDisableWatcher = !!settings.scannerDisableWatcher
|
||||
|
||||
this.storeCoverWithBook = settings.storeCoverWithBook
|
||||
if (this.storeCoverWithBook == undefined) { // storeCoverWithBook added in 1.7.1 to replace coverDestination
|
||||
this.storeCoverWithBook = !!settings.coverDestination
|
||||
}
|
||||
this.storeMetadataWithBook = !!settings.storeCoverWithBook
|
||||
|
||||
this.coverDestination = settings.coverDestination || CoverDestination.METADATA
|
||||
this.saveMetadataFile = !!settings.saveMetadataFile
|
||||
this.rateLimitLoginRequests = !isNaN(settings.rateLimitLoginRequests) ? Number(settings.rateLimitLoginRequests) : 10
|
||||
this.rateLimitLoginWindow = !isNaN(settings.rateLimitLoginWindow) ? Number(settings.rateLimitLoginWindow) : 10 * 60 * 1000 // 10 Minutes
|
||||
|
||||
|
|
@ -70,6 +78,8 @@ class ServerSettings {
|
|||
this.coverAspectRatio = !isNaN(settings.coverAspectRatio) ? settings.coverAspectRatio : BookCoverAspectRatio.SQUARE
|
||||
this.bookshelfView = settings.bookshelfView || BookshelfView.STANDARD
|
||||
|
||||
this.sortingIgnorePrefix = !!settings.sortingIgnorePrefix
|
||||
this.chromecastEnabled = !!settings.chromecastEnabled
|
||||
this.logLevel = settings.logLevel || Logger.logLevel
|
||||
this.version = settings.version || null
|
||||
|
||||
|
|
@ -88,8 +98,9 @@ class ServerSettings {
|
|||
scannerParseSubtitle: this.scannerParseSubtitle,
|
||||
scannerPreferAudioMetadata: this.scannerPreferAudioMetadata,
|
||||
scannerPreferOpfMetadata: this.scannerPreferOpfMetadata,
|
||||
coverDestination: this.coverDestination,
|
||||
saveMetadataFile: !!this.saveMetadataFile,
|
||||
scannerDisableWatcher: this.scannerDisableWatcher,
|
||||
storeCoverWithBook: this.storeCoverWithBook,
|
||||
storeMetadataWithBook: this.storeMetadataWithBook,
|
||||
rateLimitLoginRequests: this.rateLimitLoginRequests,
|
||||
rateLimitLoginWindow: this.rateLimitLoginWindow,
|
||||
backupSchedule: this.backupSchedule,
|
||||
|
|
@ -99,6 +110,8 @@ class ServerSettings {
|
|||
loggerScannerLogsToKeep: this.loggerScannerLogsToKeep,
|
||||
coverAspectRatio: this.coverAspectRatio,
|
||||
bookshelfView: this.bookshelfView,
|
||||
sortingIgnorePrefix: this.sortingIgnorePrefix,
|
||||
chromecastEnabled: this.chromecastEnabled,
|
||||
logLevel: this.logLevel,
|
||||
version: this.version
|
||||
}
|
||||
|
|
|
|||
|
|
@ -175,11 +175,6 @@ class Stream extends EventEmitter {
|
|||
return false
|
||||
}
|
||||
|
||||
updateClientCurrentTime(currentTime) {
|
||||
Logger.debug('[Stream] Updated client current time', secondsToTimestamp(currentTime))
|
||||
this.clientCurrentTime = currentTime
|
||||
}
|
||||
|
||||
syncStream({ timeListened, currentTime }) {
|
||||
var syncLog = ''
|
||||
// Set user current time
|
||||
|
|
|
|||
|
|
@ -26,8 +26,10 @@ class Audible {
|
|||
}
|
||||
|
||||
getBestImageLink(images) {
|
||||
var keys = Object.keys(images);
|
||||
return images[keys[keys.length - 1]];
|
||||
if (!images) return null
|
||||
var keys = Object.keys(images)
|
||||
if (!keys.length) return null
|
||||
return images[keys[keys.length - 1]]
|
||||
}
|
||||
|
||||
getPrimarySeries(series, publication_name) {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
const AuthorFinder = require('../AuthorFinder')
|
||||
|
||||
class AuthorScanner {
|
||||
constructor(db, MetadataPath) {
|
||||
constructor(db) {
|
||||
this.db = db
|
||||
this.MetadataPath = MetadataPath
|
||||
this.authorFinder = new AuthorFinder(MetadataPath)
|
||||
this.authorFinder = new AuthorFinder()
|
||||
}
|
||||
|
||||
getUniqueAuthors() {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ const { getId, secondsToTimestamp } = require('../utils/index')
|
|||
class LibraryScan {
|
||||
constructor() {
|
||||
this.id = null
|
||||
this.type = null
|
||||
this.libraryId = null
|
||||
this.libraryName = null
|
||||
this.folders = null
|
||||
|
|
@ -46,6 +47,7 @@ class LibraryScan {
|
|||
get getScanEmitData() {
|
||||
return {
|
||||
id: this.libraryId,
|
||||
type: this.type,
|
||||
name: this.libraryName,
|
||||
results: {
|
||||
added: this.resultsAdded,
|
||||
|
|
@ -64,10 +66,11 @@ class LibraryScan {
|
|||
toJSON() {
|
||||
return {
|
||||
id: this.id,
|
||||
type: this.type,
|
||||
libraryId: this.libraryId,
|
||||
libraryName: this.libraryName,
|
||||
folders: this.folders.map(f => f.toJSON()),
|
||||
scanOptions: this.scanOptions.toJSON(),
|
||||
scanOptions: this.scanOptions ? this.scanOptions.toJSON() : null,
|
||||
startedAt: this.startedAt,
|
||||
finishedAt: this.finishedAt,
|
||||
elapsed: this.elapsed,
|
||||
|
|
@ -77,8 +80,9 @@ class LibraryScan {
|
|||
}
|
||||
}
|
||||
|
||||
setData(library, scanOptions) {
|
||||
setData(library, scanOptions, type = 'scan') {
|
||||
this.id = getId('lscan')
|
||||
this.type = type
|
||||
this.libraryId = library.id
|
||||
this.libraryName = library.name
|
||||
this.folders = library.folders.map(folder => new Folder(folder.toJSON()))
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
const { CoverDestination } = require('../utils/constants')
|
||||
|
||||
class ScanOptions {
|
||||
constructor(options) {
|
||||
this.forceRescan = false
|
||||
|
|
@ -7,7 +5,7 @@ class ScanOptions {
|
|||
// Server settings
|
||||
this.parseSubtitles = false
|
||||
this.findCovers = false
|
||||
this.coverDestination = CoverDestination.METADATA
|
||||
this.storeCoverWithBook = false
|
||||
this.preferAudioMetadata = false
|
||||
this.preferOpfMetadata = false
|
||||
|
||||
|
|
@ -32,7 +30,7 @@ class ScanOptions {
|
|||
metadataPrecedence: this.metadataPrecedence,
|
||||
parseSubtitles: this.parseSubtitles,
|
||||
findCovers: this.findCovers,
|
||||
coverDestination: this.coverDestination,
|
||||
storeCoverWithBook: this.storeCoverWithBook,
|
||||
preferAudioMetadata: this.preferAudioMetadata,
|
||||
preferOpfMetadata: this.preferOpfMetadata
|
||||
}
|
||||
|
|
@ -43,7 +41,7 @@ class ScanOptions {
|
|||
|
||||
this.parseSubtitles = !!serverSettings.scannerParseSubtitle
|
||||
this.findCovers = !!serverSettings.scannerFindCovers
|
||||
this.coverDestination = serverSettings.coverDestination
|
||||
this.storeCoverWithBook = serverSettings.storeCoverWithBook
|
||||
this.preferAudioMetadata = serverSettings.scannerPreferAudioMetadata
|
||||
this.preferOpfMetadata = serverSettings.scannerPreferOpfMetadata
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ const Path = require('path')
|
|||
const Logger = require('../Logger')
|
||||
const { version } = require('../../package.json')
|
||||
const { groupFilesIntoAudiobookPaths, getAudiobookFileData, scanRootDir } = require('../utils/scandir')
|
||||
const { comparePaths, getIno, getId, msToTimestamp } = require('../utils/index')
|
||||
const { ScanResult, CoverDestination, LogLevel } = require('../utils/constants')
|
||||
const { comparePaths, getId } = require('../utils/index')
|
||||
const { ScanResult, LogLevel } = require('../utils/constants')
|
||||
|
||||
const AudioFileScanner = require('./AudioFileScanner')
|
||||
const BookFinder = require('../BookFinder')
|
||||
|
|
@ -15,12 +15,9 @@ const LibraryScan = require('./LibraryScan')
|
|||
const ScanOptions = require('./ScanOptions')
|
||||
|
||||
class Scanner {
|
||||
constructor(AUDIOBOOK_PATH, METADATA_PATH, db, coverController, emitter) {
|
||||
this.AudiobookPath = AUDIOBOOK_PATH
|
||||
this.MetadataPath = METADATA_PATH
|
||||
this.BookMetadataPath = Path.posix.join(this.MetadataPath.replace(/\\/g, '/'), 'books')
|
||||
var LogDirPath = Path.join(this.MetadataPath, 'logs')
|
||||
this.ScanLogPath = Path.join(LogDirPath, 'scans')
|
||||
constructor(db, coverController, emitter) {
|
||||
this.BookMetadataPath = Path.posix.join(global.MetadataPath, 'books')
|
||||
this.ScanLogPath = Path.posix.join(global.MetadataPath, 'logs', 'scans')
|
||||
|
||||
this.db = db
|
||||
this.coverController = coverController
|
||||
|
|
@ -33,7 +30,7 @@ class Scanner {
|
|||
}
|
||||
|
||||
getCoverDirectory(audiobook) {
|
||||
if (this.db.serverSettings.coverDestination === CoverDestination.AUDIOBOOK) {
|
||||
if (this.db.serverSettings.storeCoverWithBook) {
|
||||
return {
|
||||
fullPath: audiobook.fullPath,
|
||||
relPath: '/s/book/' + audiobook.id
|
||||
|
|
@ -88,8 +85,8 @@ class Scanner {
|
|||
|
||||
// Sync other files first so that local images are used as cover art
|
||||
// TODO: Cleanup other file sync
|
||||
var allOtherFiles = checkRes.newOtherFileData.concat(audiobook._otherFiles)
|
||||
if (await audiobook.syncOtherFiles(allOtherFiles, this.MetadataPath, this.db.serverSettings.scannerPreferOpfMetadata)) {
|
||||
var allOtherFiles = checkRes.newOtherFileData.concat(checkRes.existingOtherFileData)
|
||||
if (await audiobook.syncOtherFiles(allOtherFiles, this.db.serverSettings.scannerPreferOpfMetadata)) {
|
||||
hasUpdated = true
|
||||
}
|
||||
|
||||
|
|
@ -120,7 +117,7 @@ class Scanner {
|
|||
|
||||
if (hasUpdated) {
|
||||
this.emitter('audiobook_updated', audiobook.toJSONExpanded())
|
||||
await this.db.updateEntity('audiobook', audiobook)
|
||||
await this.db.updateAudiobook(audiobook)
|
||||
return ScanResult.UPDATED
|
||||
}
|
||||
return ScanResult.UPTODATE
|
||||
|
|
@ -208,6 +205,7 @@ class Scanner {
|
|||
// Check for existing & removed audiobooks
|
||||
for (let i = 0; i < audiobooksInLibrary.length; i++) {
|
||||
var audiobook = audiobooksInLibrary[i]
|
||||
// Find audiobook folder with matching inode or matching path
|
||||
var dataFound = audiobookDataFound.find(abd => abd.ino === audiobook.ino || comparePaths(abd.path, audiobook.path))
|
||||
if (!dataFound) {
|
||||
libraryScan.addLog(LogLevel.WARN, `Audiobook "${audiobook.title}" is missing`)
|
||||
|
|
@ -317,7 +315,7 @@ class Scanner {
|
|||
}))
|
||||
newAudiobooks = newAudiobooks.filter(ab => ab) // Filter out nulls
|
||||
libraryScan.resultsAdded += newAudiobooks.length
|
||||
await this.db.insertEntities('audiobook', newAudiobooks)
|
||||
await this.db.insertAudiobooks(newAudiobooks)
|
||||
this.emitter('audiobooks_added', newAudiobooks.map(ab => ab.toJSONExpanded()))
|
||||
}
|
||||
|
||||
|
|
@ -330,7 +328,7 @@ class Scanner {
|
|||
if (newOtherFileData.length || libraryScan.scanOptions.forceRescan) {
|
||||
// TODO: Cleanup other file sync
|
||||
var allOtherFiles = newOtherFileData.concat(existingOtherFileData)
|
||||
if (await audiobook.syncOtherFiles(allOtherFiles, this.MetadataPath, libraryScan.preferOpfMetadata)) {
|
||||
if (await audiobook.syncOtherFiles(allOtherFiles, libraryScan.preferOpfMetadata)) {
|
||||
hasUpdated = true
|
||||
}
|
||||
}
|
||||
|
|
@ -525,7 +523,7 @@ class Scanner {
|
|||
Logger.debug(`[Scanner] Folder update group must be a new book "${bookDir}" in library "${library.name}"`)
|
||||
var newAudiobook = await this.scanPotentialNewAudiobook(folder, fullPath)
|
||||
if (newAudiobook) {
|
||||
await this.db.insertEntity('audiobook', newAudiobook)
|
||||
await this.db.insertAudiobook(newAudiobook)
|
||||
this.emitter('audiobook_added', newAudiobook.toJSONExpanded())
|
||||
}
|
||||
bookGroupingResults[bookDir] = newAudiobook ? ScanResult.ADDED : ScanResult.NOTHING
|
||||
|
|
@ -615,7 +613,7 @@ class Scanner {
|
|||
}
|
||||
Logger.warn('Found duplicate ID - updating from', ab.id, 'to', abCopy.id)
|
||||
await this.db.removeEntity('audiobook', ab.id)
|
||||
await this.db.insertEntity('audiobook', abCopy)
|
||||
await this.db.insertAudiobook(abCopy)
|
||||
audiobooksUpdated++
|
||||
} else {
|
||||
ids[ab.id] = true
|
||||
|
|
@ -625,5 +623,101 @@ class Scanner {
|
|||
Logger.info(`[Scanner] Updated ${audiobooksUpdated} audiobook IDs`)
|
||||
}
|
||||
}
|
||||
|
||||
async quickMatchBook(audiobook, options = {}) {
|
||||
var provider = options.provider || 'google'
|
||||
var searchTitle = options.title || audiobook.book._title
|
||||
var searchAuthor = options.author || audiobook.book._author
|
||||
|
||||
var results = await this.bookFinder.search(provider, searchTitle, searchAuthor)
|
||||
if (!results.length) {
|
||||
return {
|
||||
warning: `No ${provider} match found`
|
||||
}
|
||||
}
|
||||
var matchData = results[0]
|
||||
|
||||
// Update cover if not set OR overrideCover flag
|
||||
var hasUpdated = false
|
||||
if (matchData.cover && (!audiobook.book.cover || options.overrideCover)) {
|
||||
Logger.debug(`[BookController] Updating cover "${matchData.cover}"`)
|
||||
var coverResult = await this.coverController.downloadCoverFromUrl(audiobook, matchData.cover)
|
||||
if (!coverResult || coverResult.error || !coverResult.cover) {
|
||||
Logger.warn(`[BookController] Match cover "${matchData.cover}" failed to use: ${coverResult ? coverResult.error : 'Unknown Error'}`)
|
||||
} else {
|
||||
hasUpdated = true
|
||||
}
|
||||
}
|
||||
|
||||
// Update book details if not set OR overrideDetails flag
|
||||
const detailKeysToUpdate = ['title', 'subtitle', 'author', 'narrator', 'publisher', 'publishYear', 'series', 'volumeNumber', 'asin', 'isbn']
|
||||
const updatePayload = {}
|
||||
for (const key in matchData) {
|
||||
if (matchData[key] && detailKeysToUpdate.includes(key) && (!audiobook.book[key] || options.overrideDetails)) {
|
||||
updatePayload[key] = matchData[key]
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updatePayload).length) {
|
||||
Logger.debug('[BookController] Updating details', updatePayload)
|
||||
if (audiobook.update({ book: updatePayload })) {
|
||||
hasUpdated = true
|
||||
}
|
||||
}
|
||||
|
||||
if (hasUpdated) {
|
||||
await this.db.updateAudiobook(audiobook)
|
||||
this.emitter('audiobook_updated', audiobook.toJSONExpanded())
|
||||
}
|
||||
|
||||
return {
|
||||
updated: hasUpdated,
|
||||
audiobook: audiobook.toJSONExpanded()
|
||||
}
|
||||
}
|
||||
|
||||
async matchLibraryBooks(library) {
|
||||
if (this.isLibraryScanning(library.id)) {
|
||||
Logger.error(`[Scanner] Already scanning ${library.id}`)
|
||||
return
|
||||
}
|
||||
|
||||
const provider = library.provider || 'google'
|
||||
var audiobooksInLibrary = this.db.audiobooks.filter(ab => ab.libraryId === library.id)
|
||||
if (!audiobooksInLibrary.length) {
|
||||
return
|
||||
}
|
||||
|
||||
var libraryScan = new LibraryScan()
|
||||
libraryScan.setData(library, null, 'match')
|
||||
this.librariesScanning.push(libraryScan.getScanEmitData)
|
||||
this.emitter('scan_start', libraryScan.getScanEmitData)
|
||||
|
||||
Logger.info(`[Scanner] Starting library match books scan ${libraryScan.id} for ${libraryScan.libraryName}`)
|
||||
|
||||
for (let i = 0; i < audiobooksInLibrary.length; i++) {
|
||||
var audiobook = audiobooksInLibrary[i]
|
||||
Logger.debug(`[Scanner] Quick matching "${audiobook.title}" (${i + 1} of ${audiobooksInLibrary.length})`)
|
||||
var result = await this.quickMatchBook(audiobook, { provider })
|
||||
if (result.warning) {
|
||||
Logger.warn(`[Scanner] Match warning ${result.warning} for audiobook "${audiobook.title}"`)
|
||||
} else if (result.updated) {
|
||||
libraryScan.resultsUpdated++
|
||||
}
|
||||
|
||||
if (this.cancelLibraryScan[libraryScan.libraryId]) {
|
||||
Logger.info(`[Scanner] Library match scan canceled for "${libraryScan.libraryName}"`)
|
||||
delete this.cancelLibraryScan[libraryScan.libraryId]
|
||||
var scanData = libraryScan.getScanEmitData
|
||||
scanData.results = false
|
||||
this.emitter('scan_complete', scanData)
|
||||
this.librariesScanning = this.librariesScanning.filter(ls => ls.id !== library.id)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
this.librariesScanning = this.librariesScanning.filter(ls => ls.id !== library.id)
|
||||
this.emitter('scan_complete', libraryScan.getScanEmitData)
|
||||
}
|
||||
}
|
||||
module.exports = Scanner
|
||||
102
server/utils/abmetadataGenerator.js
Normal file
102
server/utils/abmetadataGenerator.js
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
const fs = require('fs-extra')
|
||||
const filePerms = require('./filePerms')
|
||||
const package = require('../../package.json')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
const bookKeyMap = {
|
||||
title: 'title',
|
||||
subtitle: 'subtitle',
|
||||
author: 'authorFL',
|
||||
narrator: 'narratorFL',
|
||||
series: 'series',
|
||||
volumeNumber: 'volumeNumber',
|
||||
publishYear: 'publishYear',
|
||||
publisher: 'publisher',
|
||||
description: 'description',
|
||||
isbn: 'isbn',
|
||||
asin: 'asin',
|
||||
language: 'language',
|
||||
genres: 'genresCommaSeparated'
|
||||
}
|
||||
|
||||
function generate(audiobook, outputPath) {
|
||||
var fileString = ';ABMETADATA1\n'
|
||||
fileString += `#audiobookshelf v${package.version}\n\n`
|
||||
|
||||
for (const key in bookKeyMap) {
|
||||
const value = audiobook.book[bookKeyMap[key]] || ''
|
||||
fileString += `${key}=${value}\n`
|
||||
}
|
||||
|
||||
if (audiobook.chapters.length) {
|
||||
fileString += '\n'
|
||||
audiobook.chapters.forEach((chapter) => {
|
||||
fileString += `[CHAPTER]\n`
|
||||
fileString += `start=${chapter.start}\n`
|
||||
fileString += `end=${chapter.end}\n`
|
||||
fileString += `title=${chapter.title}\n`
|
||||
})
|
||||
}
|
||||
|
||||
return fs.writeFile(outputPath, fileString).then(() => {
|
||||
return filePerms(outputPath, 0o774, global.Uid, global.Gid, true).then((data) => true)
|
||||
}).catch((error) => {
|
||||
Logger.error(`[absMetaFileGenerator] Failed to save abs file`, error)
|
||||
return false
|
||||
})
|
||||
}
|
||||
module.exports.generate = generate
|
||||
|
||||
function parseAbMetadataText(text) {
|
||||
if (!text) return null
|
||||
var lines = text.split(/\r?\n/)
|
||||
|
||||
// Check first line and get abmetadata version number
|
||||
var firstLine = lines.shift().toLowerCase()
|
||||
if (!firstLine.startsWith(';abmetadata')) {
|
||||
Logger.error(`Invalid abmetadata file first line is not ;abmetadata "${firstLine}"`)
|
||||
return null
|
||||
}
|
||||
var abmetadataVersion = Number(firstLine.replace(';abmetadata', '').trim())
|
||||
if (isNaN(abmetadataVersion)) {
|
||||
Logger.warn(`Invalid abmetadata version ${abmetadataVersion} - using 1`)
|
||||
abmetadataVersion = 1
|
||||
}
|
||||
|
||||
// Remove comments and empty lines
|
||||
const ignoreFirstChars = [' ', '#', ';'] // Ignore any line starting with the following
|
||||
lines = lines.filter(line => !!line.trim() && !ignoreFirstChars.includes(line[0]))
|
||||
|
||||
// Get lines that map to book details (all lines before the first chapter section)
|
||||
var firstSectionLine = lines.findIndex(l => l.startsWith('['))
|
||||
var detailLines = firstSectionLine > 0 ? lines.slice(0, firstSectionLine) : lines
|
||||
|
||||
// Put valid book detail values into map
|
||||
const bookDetails = {}
|
||||
for (let i = 0; i < detailLines.length; i++) {
|
||||
var line = detailLines[i]
|
||||
var keyValue = line.split('=')
|
||||
if (keyValue.length < 2) {
|
||||
Logger.warn('abmetadata invalid line has no =', line)
|
||||
} else if (!bookKeyMap[keyValue[0].trim()]) {
|
||||
Logger.warn(`abmetadata key "${keyValue[0].trim()}" is not a valid book detail key`)
|
||||
} else {
|
||||
var key = keyValue[0].trim()
|
||||
bookDetails[key] = keyValue[1].trim()
|
||||
|
||||
// Genres convert to array of strings
|
||||
if (key === 'genres') {
|
||||
bookDetails[key] = bookDetails[key] ? bookDetails[key].split(',').map(genre => genre.trim()) : []
|
||||
} else if (!bookDetails[key]) { // Use null for empty details
|
||||
bookDetails[key] = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Chapter support
|
||||
|
||||
return {
|
||||
book: bookDetails
|
||||
}
|
||||
}
|
||||
module.exports.parse = parseAbMetadataText
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
const fs = require('fs-extra')
|
||||
const filePerms = require('./filePerms')
|
||||
const package = require('../../package.json')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
const bookKeyMap = {
|
||||
title: 'title',
|
||||
subtitle: 'subtitle',
|
||||
author: 'authorFL',
|
||||
narrator: 'narratorFL',
|
||||
series: 'series',
|
||||
volumeNumber: 'volumeNumber',
|
||||
publishYear: 'publishYear',
|
||||
publisher: 'publisher',
|
||||
description: 'description',
|
||||
isbn: 'isbn',
|
||||
asin: 'asin',
|
||||
language: 'language',
|
||||
genres: 'genresCommaSeparated'
|
||||
}
|
||||
|
||||
function generate(audiobook, outputPath, uid, gid) {
|
||||
var fileString = `[audiobookshelf v${package.version}]\n`
|
||||
|
||||
for (const key in bookKeyMap) {
|
||||
const value = audiobook.book[bookKeyMap[key]] || ''
|
||||
fileString += `${key}=${value}\n`
|
||||
}
|
||||
|
||||
return fs.writeFile(outputPath, fileString).then(() => {
|
||||
return filePerms(outputPath, 0o774, uid, gid).then(() => true)
|
||||
}).catch((error) => {
|
||||
Logger.error(`[absMetaFileGenerator] Failed to save abs file`, error)
|
||||
return false
|
||||
})
|
||||
}
|
||||
module.exports.generate = generate
|
||||
|
|
@ -6,11 +6,6 @@ module.exports.ScanResult = {
|
|||
UPTODATE: 4
|
||||
}
|
||||
|
||||
module.exports.CoverDestination = {
|
||||
METADATA: 0,
|
||||
AUDIOBOOK: 1
|
||||
}
|
||||
|
||||
module.exports.BookCoverAspectRatio = {
|
||||
STANDARD: 0, // 1.6:1
|
||||
SQUARE: 1
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ const chmodr = (p, mode, uid, gid, cb) => {
|
|||
// any error other than ENOTDIR means it's not readable, or
|
||||
// doesn't exist. give up.
|
||||
if (er && er.code !== 'ENOTDIR') return cb(er)
|
||||
if (er) {
|
||||
if (er) { // Is a file
|
||||
return fs.chmod(p, mode).then(() => {
|
||||
fs.chown(p, uid, gid, cb)
|
||||
})
|
||||
|
|
@ -77,9 +77,9 @@ const chmodr = (p, mode, uid, gid, cb) => {
|
|||
})
|
||||
}
|
||||
|
||||
module.exports = (path, mode, uid, gid) => {
|
||||
module.exports = (path, mode, uid, gid, silent = false) => {
|
||||
return new Promise((resolve) => {
|
||||
Logger.debug(`[FilePerms] Setting permission "${mode}" for uid ${uid} and gid ${gid} | "${path}"`)
|
||||
if (!silent) Logger.debug(`[FilePerms] Setting permission "${mode}" for uid ${uid} and gid ${gid} | "${path}"`)
|
||||
chmodr(path, mode, uid, gid, resolve)
|
||||
})
|
||||
}
|
||||
|
|
@ -20,6 +20,23 @@ async function getFileStat(path) {
|
|||
}
|
||||
module.exports.getFileStat = getFileStat
|
||||
|
||||
async function getFileTimestampsWithIno(path) {
|
||||
try {
|
||||
var stat = await fs.stat(path, { bigint: true })
|
||||
return {
|
||||
size: Number(stat.size),
|
||||
mtimeMs: Number(stat.mtimeMs),
|
||||
ctimeMs: Number(stat.ctimeMs),
|
||||
birthtimeMs: Number(stat.birthtimeMs),
|
||||
ino: String(stat.ino)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to getFileTimestampsWithIno', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
module.exports.getFileTimestampsWithIno = getFileTimestampsWithIno
|
||||
|
||||
async function getFileSize(path) {
|
||||
var stat = await getFileStat(path)
|
||||
if (!stat) return 0
|
||||
|
|
@ -27,6 +44,15 @@ async function getFileSize(path) {
|
|||
}
|
||||
module.exports.getFileSize = getFileSize
|
||||
|
||||
|
||||
function getIno(path) {
|
||||
return fs.stat(path, { bigint: true }).then((data => String(data.ino))).catch((err) => {
|
||||
Logger.error('[Utils] Failed to get ino for path', path, err)
|
||||
return null
|
||||
})
|
||||
}
|
||||
module.exports.getIno = getIno
|
||||
|
||||
async function readTextFile(path) {
|
||||
try {
|
||||
var data = await fs.readFile(path)
|
||||
|
|
|
|||
|
|
@ -38,13 +38,6 @@ module.exports.comparePaths = (path1, path2) => {
|
|||
return path1 === path2 || Path.normalize(path1) === Path.normalize(path2)
|
||||
}
|
||||
|
||||
module.exports.getIno = (path) => {
|
||||
return fs.promises.stat(path, { bigint: true }).then((data => String(data.ino))).catch((err) => {
|
||||
Logger.error('[Utils] Failed to get ino for path', path, err)
|
||||
return null
|
||||
})
|
||||
}
|
||||
|
||||
module.exports.isNullOrNaN = (num) => {
|
||||
return num === null || isNaN(num)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,4 @@
|
|||
const ffprobe = require('node-ffprobe')
|
||||
|
||||
if (process.env.FFPROBE_PATH) {
|
||||
ffprobe.FFPROBE_PATH = process.env.FFPROBE_PATH
|
||||
}
|
||||
|
||||
const AudioProbeData = require('../scanner/AudioProbeData')
|
||||
|
||||
const Logger = require('../Logger')
|
||||
|
|
@ -281,6 +276,10 @@ function parseProbeData(data, verbose = false) {
|
|||
|
||||
// Updated probe returns AudioProbeData object
|
||||
function probe(filepath, verbose = false) {
|
||||
if (process.env.FFPROBE_PATH) {
|
||||
ffprobe.FFPROBE_PATH = process.env.FFPROBE_PATH
|
||||
}
|
||||
|
||||
return ffprobe(filepath)
|
||||
.then(raw => {
|
||||
var rawProbeData = parseProbeData(raw, verbose)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
const Path = require('path')
|
||||
const fs = require('fs-extra')
|
||||
const Logger = require('../Logger')
|
||||
const { getIno } = require('./index')
|
||||
const { recurseFiles } = require('./fileUtils')
|
||||
const { recurseFiles, getFileTimestampsWithIno } = require('./fileUtils')
|
||||
const globals = require('./globals')
|
||||
|
||||
function isBookFile(path) {
|
||||
|
|
@ -114,16 +113,20 @@ function groupFileItemsIntoBooks(fileItems) {
|
|||
}
|
||||
|
||||
function cleanFileObjects(basepath, abrelpath, files) {
|
||||
return files.map((file) => {
|
||||
return Promise.all(files.map(async (file) => {
|
||||
var fullPath = Path.posix.join(basepath, file)
|
||||
var fileTsData = await getFileTimestampsWithIno(fullPath)
|
||||
|
||||
var ext = Path.extname(file)
|
||||
return {
|
||||
filetype: getFileType(ext),
|
||||
filename: Path.basename(file),
|
||||
path: Path.posix.join(abrelpath, file), // /AUDIOBOOK/PATH/filename.mp3
|
||||
fullPath: Path.posix.join(basepath, file), // /audiobooks/AUDIOBOOK/PATH/filename.mp3
|
||||
ext: ext
|
||||
fullPath, // /audiobooks/AUDIOBOOK/PATH/filename.mp3
|
||||
ext: ext,
|
||||
...fileTsData
|
||||
}
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
function getFileType(ext) {
|
||||
|
|
@ -162,15 +165,15 @@ async function scanRootDir(folder, serverSettings = {}) {
|
|||
for (const audiobookPath in audiobookGrouping) {
|
||||
var audiobookData = getAudiobookDataFromDir(folderPath, audiobookPath, parseSubtitle)
|
||||
|
||||
var fileObjs = cleanFileObjects(audiobookData.fullPath, audiobookPath, audiobookGrouping[audiobookPath])
|
||||
for (let i = 0; i < fileObjs.length; i++) {
|
||||
fileObjs[i].ino = await getIno(fileObjs[i].fullPath)
|
||||
}
|
||||
var audiobookIno = await getIno(audiobookData.fullPath)
|
||||
var fileObjs = await cleanFileObjects(audiobookData.fullPath, audiobookPath, audiobookGrouping[audiobookPath])
|
||||
var audiobookFolderStats = await getFileTimestampsWithIno(audiobookData.fullPath)
|
||||
audiobooks.push({
|
||||
folderId: folder.id,
|
||||
libraryId: folder.libraryId,
|
||||
ino: audiobookIno,
|
||||
ino: audiobookFolderStats.ino,
|
||||
mtimeMs: audiobookFolderStats.mtimeMs || 0,
|
||||
ctimeMs: audiobookFolderStats.ctimeMs || 0,
|
||||
birthtimeMs: audiobookFolderStats.birthtimeMs || 0,
|
||||
...audiobookData,
|
||||
audioFiles: fileObjs.filter(f => f.filetype === 'audio'),
|
||||
otherFiles: fileObjs.filter(f => f.filetype !== 'audio')
|
||||
|
|
@ -196,32 +199,42 @@ function getAudiobookDataFromDir(folderPath, dir, parseSubtitle = false) {
|
|||
|
||||
|
||||
// If in a series directory check for volume number match
|
||||
/* ACCEPTS:
|
||||
/* ACCEPTS
|
||||
Book 2 - Title Here - Subtitle Here
|
||||
Title Here - Subtitle Here - Vol 12
|
||||
Title Here - volume 9 - Subtitle Here
|
||||
Vol. 3 Title Here - Subtitle Here
|
||||
1980 - Book 2-Title Here
|
||||
Title Here-Volume 999-Subtitle Here
|
||||
2 - Book Title
|
||||
100 - Book Title
|
||||
0.5 - Book Title
|
||||
*/
|
||||
var volumeNumber = null
|
||||
if (series) {
|
||||
// New volume regex to match volumes with decimal (OLD: /(-? ?)\b((?:Book|Vol.?|Volume) (\d{1,3}))\b( ?-?)/i)
|
||||
var volumeMatch = title.match(/(-? ?)\b((?:Book|Vol.?|Volume) (\d{0,3}(?:\.\d{1,2})?))\b( ?-?)/i)
|
||||
if (volumeMatch && volumeMatch.length > 3 && volumeMatch[2] && volumeMatch[3]) {
|
||||
volumeNumber = volumeMatch[3]
|
||||
var replaceChunk = volumeMatch[2]
|
||||
// Added 1.7.1: If title starts with a # that is 3 digits or less (or w/ 2 decimal), then use as volume number
|
||||
var volumeMatch = title.match(/^(\d{1,3}(?:\.\d{1,2})?) - ./)
|
||||
if (volumeMatch && volumeMatch.length > 1) {
|
||||
volumeNumber = volumeMatch[1]
|
||||
title = title.replace(`${volumeNumber} - `, '')
|
||||
} else {
|
||||
// Match volumes with decimal (OLD: /(-? ?)\b((?:Book|Vol.?|Volume) (\d{1,3}))\b( ?-?)/i)
|
||||
var volumeMatch = title.match(/(-? ?)\b((?:Book|Vol.?|Volume) (\d{0,3}(?:\.\d{1,2})?))\b( ?-?)/i)
|
||||
if (volumeMatch && volumeMatch.length > 3 && volumeMatch[2] && volumeMatch[3]) {
|
||||
volumeNumber = volumeMatch[3]
|
||||
var replaceChunk = volumeMatch[2]
|
||||
|
||||
// "1980 - Book 2-Title Here"
|
||||
// Group 1 would be "- "
|
||||
// Group 3 would be "-"
|
||||
// Only remove the first group
|
||||
if (volumeMatch[1]) {
|
||||
replaceChunk = volumeMatch[1] + replaceChunk
|
||||
} else if (volumeMatch[4]) {
|
||||
replaceChunk += volumeMatch[4]
|
||||
// "1980 - Book 2-Title Here"
|
||||
// Group 1 would be "- "
|
||||
// Group 3 would be "-"
|
||||
// Only remove the first group
|
||||
if (volumeMatch[1]) {
|
||||
replaceChunk = volumeMatch[1] + replaceChunk
|
||||
} else if (volumeMatch[4]) {
|
||||
replaceChunk += volumeMatch[4]
|
||||
}
|
||||
title = title.replace(replaceChunk, '').trim()
|
||||
}
|
||||
title = title.replace(replaceChunk, '').trim()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -272,8 +285,12 @@ async function getAudiobookFileData(folder, audiobookPath, serverSettings = {})
|
|||
|
||||
var audiobookDir = audiobookPath.replace(folderFullPath, '').slice(1)
|
||||
var audiobookData = getAudiobookDataFromDir(folderFullPath, audiobookDir, parseSubtitle)
|
||||
var audiobookFolderStats = await getFileTimestampsWithIno(audiobookData.fullPath)
|
||||
var audiobook = {
|
||||
ino: await getIno(audiobookData.fullPath),
|
||||
ino: audiobookFolderStats.ino,
|
||||
mtimeMs: audiobookFolderStats.mtimeMs || 0,
|
||||
ctimeMs: audiobookFolderStats.ctimeMs || 0,
|
||||
birthtimeMs: audiobookFolderStats.birthtimeMs || 0,
|
||||
folderId: folder.id,
|
||||
libraryId: folder.libraryId,
|
||||
...audiobookData,
|
||||
|
|
@ -284,14 +301,14 @@ async function getAudiobookFileData(folder, audiobookPath, serverSettings = {})
|
|||
for (let i = 0; i < fileItems.length; i++) {
|
||||
var fileItem = fileItems[i]
|
||||
|
||||
var ino = await getIno(fileItem.fullpath)
|
||||
var fileStatData = await getFileTimestampsWithIno(fileItem.fullpath)
|
||||
var fileObj = {
|
||||
ino,
|
||||
filetype: getFileType(fileItem.extension),
|
||||
filename: fileItem.name,
|
||||
path: fileItem.path,
|
||||
fullPath: fileItem.fullpath,
|
||||
ext: fileItem.extension
|
||||
ext: fileItem.extension,
|
||||
...fileStatData
|
||||
}
|
||||
if (fileObj.filetype === 'audio') {
|
||||
audiobook.audioFiles.push(fileObj)
|
||||
|
|
|
|||
BIN
static/Logo.png
BIN
static/Logo.png
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB |
Loading…
Add table
Add a link
Reference in a new issue