mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-27 11:41:36 +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>
|
<template>
|
||||||
<div class="w-full -mt-6">
|
<div class="w-full -mt-6">
|
||||||
<div class="w-full relative mb-1">
|
<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 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">
|
<div class="cursor-pointer text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="showChapters">
|
||||||
<span class="material-icons text-3xl">format_list_bulleted</span>
|
<span class="material-icons text-3xl">format_list_bulleted</span>
|
||||||
|
|
@ -19,7 +12,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute top-0 bottom-0 h-full hidden md:flex items-end" :class="chapters.length ? ' right-44' : 'right-32'">
|
<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>
|
||||||
|
|
||||||
<div class="flex pb-4 md:pb-2">
|
<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">
|
<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>
|
<span class="material-icons text-3xl">first_page</span>
|
||||||
</div>
|
</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>
|
<span class="material-icons text-3xl">replay_10</span>
|
||||||
</div>
|
</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">
|
<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' : isPaused ? 'play_arrow' : 'pause' }}</span>
|
<span class="material-icons">{{ seekLoading ? 'autorenew' : paused ? 'play_arrow' : 'pause' }}</span>
|
||||||
</div>
|
</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>
|
<span class="material-icons text-3xl">forward_10</span>
|
||||||
</div>
|
</div>
|
||||||
<controls-playback-speed-control v-model="playbackRate" @input="playbackRateUpdated" @change="playbackRateChanged" />
|
<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>
|
<p class="font-mono text-sm text-gray-100 pointer-events-auto">{{ timeRemainingPretty }}</p>
|
||||||
</div>
|
</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" />
|
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import Hls from 'hls.js'
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
streamId: String,
|
|
||||||
audiobookId: String,
|
|
||||||
loading: Boolean,
|
loading: Boolean,
|
||||||
|
paused: Boolean,
|
||||||
chapters: {
|
chapters: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => []
|
default: () => []
|
||||||
|
|
@ -107,57 +95,41 @@ export default {
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
hlsInstance: null,
|
|
||||||
staleHlsInstance: null,
|
|
||||||
usingNativeAudioPlayer: false,
|
|
||||||
playOnLoad: false,
|
|
||||||
startTime: 0,
|
|
||||||
volume: 1,
|
volume: 1,
|
||||||
playbackRate: 1,
|
playbackRate: 1,
|
||||||
trackWidth: 0,
|
trackWidth: 0,
|
||||||
isPaused: true,
|
|
||||||
url: null,
|
|
||||||
src: null,
|
|
||||||
playedTrackWidth: 0,
|
playedTrackWidth: 0,
|
||||||
bufferTrackWidth: 0,
|
bufferTrackWidth: 0,
|
||||||
readyTrackWidth: 0,
|
readyTrackWidth: 0,
|
||||||
audioEl: null,
|
audioEl: null,
|
||||||
totalDuration: 0,
|
|
||||||
seekedTime: 0,
|
|
||||||
seekLoading: false,
|
seekLoading: false,
|
||||||
showChaptersModal: false,
|
showChaptersModal: false,
|
||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
trackOffsetLeft: 16, // Track is 16px from edge
|
trackOffsetLeft: 16, // Track is 16px from edge
|
||||||
listenTimeInterval: null,
|
duration: 0
|
||||||
listeningTimeSinceLastUpdate: 0,
|
|
||||||
totalListeningTimeInSession: 0
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
token() {
|
token() {
|
||||||
return this.$store.getters['user/getToken']
|
return this.$store.getters['user/getToken']
|
||||||
},
|
},
|
||||||
totalDurationPretty() {
|
|
||||||
return this.$secondsToTimestamp(this.totalDuration)
|
|
||||||
},
|
|
||||||
timeRemaining() {
|
timeRemaining() {
|
||||||
if (!this.audioEl) return 0
|
return (this.duration - this.currentTime) / this.playbackRate
|
||||||
return (this.totalDuration - this.currentTime) / this.playbackRate
|
|
||||||
},
|
},
|
||||||
timeRemainingPretty() {
|
timeRemainingPretty() {
|
||||||
if (this.timeRemaining < 0) {
|
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 * -1)
|
||||||
}
|
}
|
||||||
return '-' + this.$secondsToTimestamp(this.timeRemaining)
|
return '-' + this.$secondsToTimestamp(this.timeRemaining)
|
||||||
},
|
},
|
||||||
progressPercent() {
|
progressPercent() {
|
||||||
if (!this.totalDuration) return 0
|
if (!this.duration) return 0
|
||||||
return Math.round((100 * this.currentTime) / this.totalDuration)
|
return Math.round((100 * this.currentTime) / this.duration)
|
||||||
},
|
},
|
||||||
chapterTicks() {
|
chapterTicks() {
|
||||||
return this.chapters.map((chap) => {
|
return this.chapters.map((chap) => {
|
||||||
var perc = chap.start / this.totalDuration
|
var perc = chap.start / this.duration
|
||||||
return {
|
return {
|
||||||
title: chap.title,
|
title: chap.title,
|
||||||
left: perc * this.trackWidth
|
left: perc * this.trackWidth
|
||||||
|
|
@ -175,188 +147,77 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
audioPlayed() {
|
setDuration(duration) {
|
||||||
if (!this.$refs.audio) return
|
this.duration = duration
|
||||||
console.log('Audio Played', this.$refs.audio.currentTime, 'Total Duration', this.$refs.audio.duration)
|
|
||||||
this.startListenTimeInterval()
|
|
||||||
this.isPaused = this.$refs.audio.paused
|
|
||||||
},
|
},
|
||||||
audioPaused() {
|
setCurrentTime(time) {
|
||||||
if (!this.$refs.audio) return
|
this.currentTime = time
|
||||||
// console.log('Audio Paused', this.$refs.audio.paused, this.$refs.audio.currentTime)
|
this.updateTimestamp()
|
||||||
this.isPaused = this.$refs.audio.paused
|
this.updatePlayedTrack()
|
||||||
this.cancelListenTimeInterval()
|
|
||||||
},
|
},
|
||||||
audioError(err) {
|
playPause() {
|
||||||
if (!this.$refs.audio) return
|
this.$emit('playPause')
|
||||||
console.error('Audio Error', this.$refs.audio.paused, this.$refs.audio.currentTime, err)
|
|
||||||
},
|
},
|
||||||
audioEnded() {
|
jumpBackward() {
|
||||||
if (!this.$refs.audio) return
|
this.$emit('jumpBackward')
|
||||||
console.log('Audio Ended', this.$refs.audio.paused, this.$refs.audio.currentTime)
|
|
||||||
},
|
},
|
||||||
audioStalled() {
|
jumpForward() {
|
||||||
if (!this.$refs.audio) return
|
this.$emit('jumpForward')
|
||||||
console.warn('Audio Stalled', this.$refs.audio.paused, this.$refs.audio.currentTime)
|
|
||||||
},
|
},
|
||||||
audioSuspended() {
|
increaseVolume() {
|
||||||
if (!this.$refs.audio) return
|
if (this.volume >= 1) return
|
||||||
console.warn('Audio Suspended', this.$refs.audio.paused, this.$refs.audio.currentTime)
|
this.volume = Math.min(1, this.volume + 0.1)
|
||||||
|
this.setVolume(this.volume)
|
||||||
},
|
},
|
||||||
sendStreamSync(timeListened = 0) {
|
decreaseVolume() {
|
||||||
// If currentTime is null then currentTime wont be updated
|
if (this.volume <= 0) return
|
||||||
var currentTime = null
|
this.volume = Math.max(0, this.volume - 0.1)
|
||||||
if (this.$refs.audio) {
|
this.setVolume(this.volume)
|
||||||
currentTime = this.$refs.audio.currentTime
|
},
|
||||||
} else if (!timeListened) {
|
setVolume(volume) {
|
||||||
console.warn('Not sending stream sync, no data to sync')
|
this.$emit('setVolume', volume)
|
||||||
return
|
},
|
||||||
|
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() {
|
increasePlaybackRate() {
|
||||||
var listeningTimeToAdd = Math.floor(this.listeningTimeSinceLastUpdate)
|
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
|
||||||
this.listeningTimeSinceLastUpdate = Math.max(0, this.listeningTimeSinceLastUpdate - listeningTimeToAdd)
|
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
|
||||||
this.sendStreamSync(listeningTimeToAdd)
|
if (currentRateIndex >= rates.length - 1) return
|
||||||
|
this.playbackRate = rates[currentRateIndex + 1] || 1
|
||||||
|
this.playbackRateChanged(this.playbackRate)
|
||||||
},
|
},
|
||||||
cancelListenTimeInterval() {
|
decreasePlaybackRate() {
|
||||||
this.sendAddListeningTime()
|
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
|
||||||
clearInterval(this.listenTimeInterval)
|
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
|
||||||
this.listenTimeInterval = null
|
if (currentRateIndex <= 0) return
|
||||||
|
this.playbackRate = rates[currentRateIndex - 1] || 1
|
||||||
|
this.playbackRateChanged(this.playbackRate)
|
||||||
},
|
},
|
||||||
startListenTimeInterval() {
|
setPlaybackRate(playbackRate) {
|
||||||
if (!this.$refs.audio) return
|
this.$emit('setPlaybackRate', playbackRate)
|
||||||
|
|
||||||
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)
|
|
||||||
},
|
},
|
||||||
selectChapter(chapter) {
|
selectChapter(chapter) {
|
||||||
this.seek(chapter.start)
|
this.seek(chapter.start)
|
||||||
this.showChaptersModal = false
|
this.showChaptersModal = false
|
||||||
},
|
},
|
||||||
selectBookmark(bookmark) {
|
|
||||||
if (bookmark) {
|
|
||||||
this.seek(bookmark.time)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
seek(time) {
|
seek(time) {
|
||||||
if (this.loading) {
|
this.$emit('seek', time)
|
||||||
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')
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
playbackRateUpdated(playbackRate) {
|
playbackRateUpdated(playbackRate) {
|
||||||
this.updatePlaybackRate(playbackRate)
|
this.setPlaybackRate(playbackRate)
|
||||||
},
|
},
|
||||||
playbackRateChanged(playbackRate) {
|
playbackRateChanged(playbackRate) {
|
||||||
this.updatePlaybackRate(playbackRate)
|
this.setPlaybackRate(playbackRate)
|
||||||
this.$store.dispatch('user/updateUserSettings', { playbackRate }).catch((err) => {
|
this.$store.dispatch('user/updateUserSettings', { playbackRate }).catch((err) => {
|
||||||
console.error('Failed to update settings', err)
|
console.error('Failed to update settings', err)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
mousemoveTrack(e) {
|
mousemoveTrack(e) {
|
||||||
var offsetX = e.offsetX
|
var offsetX = e.offsetX
|
||||||
var time = (offsetX / this.trackWidth) * this.totalDuration
|
var time = (offsetX / this.trackWidth) * this.duration
|
||||||
if (this.$refs.hoverTimestamp) {
|
if (this.$refs.hoverTimestamp) {
|
||||||
var width = this.$refs.hoverTimestamp.clientWidth
|
var width = this.$refs.hoverTimestamp.clientWidth
|
||||||
this.$refs.hoverTimestamp.style.opacity = 1
|
this.$refs.hoverTimestamp.style.opacity = 1
|
||||||
|
|
@ -402,17 +263,6 @@ export default {
|
||||||
},
|
},
|
||||||
restart() {
|
restart() {
|
||||||
this.seek(0)
|
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() {
|
setStreamReady() {
|
||||||
this.readyTrackWidth = this.trackWidth
|
this.readyTrackWidth = this.trackWidth
|
||||||
|
|
@ -442,114 +292,11 @@ export default {
|
||||||
console.error('No timestamp el')
|
console.error('No timestamp el')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!this.audioEl) {
|
var currTimeClean = this.$secondsToTimestamp(this.currentTime)
|
||||||
console.error('No Audio El')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var currTimeClean = this.$secondsToTimestamp(this.audioEl.currentTime)
|
|
||||||
ts.innerText = currTimeClean
|
ts.innerText = currTimeClean
|
||||||
},
|
},
|
||||||
clickTrack(e) {
|
updatePlayedTrack() {
|
||||||
var offsetX = e.offsetX
|
var perc = this.currentTime / this.duration
|
||||||
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
|
|
||||||
var ptWidth = Math.round(perc * this.trackWidth)
|
var ptWidth = Math.round(perc * this.trackWidth)
|
||||||
if (this.playedTrackWidth === ptWidth) {
|
if (this.playedTrackWidth === ptWidth) {
|
||||||
return
|
return
|
||||||
|
|
@ -557,83 +304,27 @@ export default {
|
||||||
this.$refs.playedTrack.style.width = ptWidth + 'px'
|
this.$refs.playedTrack.style.width = ptWidth + 'px'
|
||||||
this.playedTrackWidth = ptWidth
|
this.playedTrackWidth = ptWidth
|
||||||
},
|
},
|
||||||
audioLoadedMetadata() {
|
clickTrack(e) {
|
||||||
this.totalDuration = this.audioEl.duration
|
if (this.loading) return
|
||||||
this.$emit('loaded', this.totalDuration)
|
|
||||||
if (this.usingNativeAudioPlayer) {
|
var offsetX = e.offsetX
|
||||||
this.audioEl.currentTime = this.startTime
|
var perc = offsetX / this.trackWidth
|
||||||
this.play()
|
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) {
|
setBufferTime(bufferTime) {
|
||||||
if (this.hlsInstance) {
|
if (!this.audioEl) {
|
||||||
this.terminateStream()
|
|
||||||
}
|
|
||||||
if (!this.$refs.audio) {
|
|
||||||
console.error('No audio widget')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.listeningTimeSinceLastUpdate = 0
|
var bufferlen = (bufferTime / this.duration) * this.trackWidth
|
||||||
|
bufferlen = Math.round(bufferlen)
|
||||||
this.playOnLoad = playOnLoad
|
if (this.bufferTrackWidth === bufferlen || !this.$refs.bufferTrack) return
|
||||||
this.startTime = currentTime
|
this.$refs.bufferTrack.style.width = bufferlen + 'px'
|
||||||
this.url = url
|
this.bufferTrackWidth = bufferlen
|
||||||
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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
showChapters() {
|
showChapters() {
|
||||||
if (!this.chapters.length) return
|
if (!this.chapters.length) return
|
||||||
|
|
@ -642,39 +333,9 @@ export default {
|
||||||
showBookmarks() {
|
showBookmarks() {
|
||||||
this.$emit('showBookmarks', this.currentTime)
|
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() {
|
init() {
|
||||||
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
|
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
|
||||||
|
this.$emit('setPlaybackRate', this.playbackRate)
|
||||||
this.audioEl = this.$refs.audio
|
|
||||||
this.setTrackWidth()
|
this.setTrackWidth()
|
||||||
},
|
},
|
||||||
setTrackWidth() {
|
setTrackWidth() {
|
||||||
|
|
@ -686,48 +347,19 @@ export default {
|
||||||
},
|
},
|
||||||
settingsUpdated(settings) {
|
settingsUpdated(settings) {
|
||||||
if (settings.playbackRate && this.playbackRate !== settings.playbackRate) {
|
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() {
|
closePlayer() {
|
||||||
if (this.loading) return
|
if (this.loading) return
|
||||||
this.$emit('close')
|
this.$emit('close')
|
||||||
},
|
},
|
||||||
hotkey(action) {
|
hotkey(action) {
|
||||||
if (action === this.$hotkeys.AudioPlayer.PLAY_PAUSE) this.playPauseClick()
|
if (action === this.$hotkeys.AudioPlayer.PLAY_PAUSE) this.playPause()
|
||||||
else if (action === this.$hotkeys.AudioPlayer.JUMP_FORWARD) this.forward10()
|
else if (action === this.$hotkeys.AudioPlayer.JUMP_FORWARD) this.jumpForward()
|
||||||
else if (action === this.$hotkeys.AudioPlayer.JUMP_BACKWARD) this.backward10()
|
else if (action === this.$hotkeys.AudioPlayer.JUMP_BACKWARD) this.jumpBackward()
|
||||||
else if (action === this.$hotkeys.AudioPlayer.VOLUME_UP) this.volumeUp()
|
else if (action === this.$hotkeys.AudioPlayer.VOLUME_UP) this.increaseVolume()
|
||||||
else if (action === this.$hotkeys.AudioPlayer.VOLUME_DOWN) this.volumeDown()
|
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.MUTE_UNMUTE) this.toggleMute()
|
||||||
else if (action === this.$hotkeys.AudioPlayer.SHOW_CHAPTERS) this.showChapters()
|
else if (action === this.$hotkeys.AudioPlayer.SHOW_CHAPTERS) this.showChapters()
|
||||||
else if (action === this.$hotkeys.AudioPlayer.INCREASE_PLAYBACK_RATE) this.increasePlaybackRate()
|
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>
|
<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">
|
<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>
|
<span class="material-icons">equalizer</span>
|
||||||
</nuxt-link>
|
</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">
|
<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>
|
<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" />
|
<div class="flex-grow" />
|
||||||
<ui-tooltip :text="`Mark as ${selectedIsRead ? 'Not Read' : 'Read'}`" direction="bottom">
|
<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" />
|
<ui-read-icon-btn :disabled="processingBatch" :is-read="selectedIsRead" @click="toggleBatchRead" class="mx-1.5" />
|
||||||
|
|
@ -124,6 +127,15 @@ export default {
|
||||||
},
|
},
|
||||||
showExperimentalFeatures() {
|
showExperimentalFeatures() {
|
||||||
return this.$store.state.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: {
|
methods: {
|
||||||
|
|
|
||||||
|
|
@ -168,8 +168,7 @@ export default {
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
startStream() {
|
startStream() {
|
||||||
this.$store.commit('setStreamAudiobook', this.book)
|
this.$eventBus.$emit('play-audiobook', this.book.id)
|
||||||
this.$root.socket.emit('open_stream', this.book.id)
|
|
||||||
},
|
},
|
||||||
editClick() {
|
editClick() {
|
||||||
this.$emit('edit', this.book)
|
this.$emit('edit', this.book)
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,6 @@
|
||||||
<div id="bookshelf" class="w-full overflow-y-auto">
|
<div id="bookshelf" class="w-full overflow-y-auto">
|
||||||
<template v-for="shelf in totalShelves">
|
<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 :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 v-if="!isAlternativeBookshelfView" class="bookshelfDivider w-full absolute bottom-0 left-0 right-0 z-20" :class="`h-${shelfDividerHeightIndex}`" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -26,7 +23,9 @@
|
||||||
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
|
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
|
||||||
<!-- Experimental Bookshelf Texture -->
|
<!-- Experimental Bookshelf Texture -->
|
||||||
<div v-show="showExperimentalFeatures" class="fixed bottom-4 right-28 z-40">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -114,6 +113,9 @@ export default {
|
||||||
bookshelfView() {
|
bookshelfView() {
|
||||||
return this.$store.getters['settings/getServerSetting']('bookshelfView')
|
return this.$store.getters['settings/getServerSetting']('bookshelfView')
|
||||||
},
|
},
|
||||||
|
sortingIgnorePrefix() {
|
||||||
|
return this.$store.getters['getServerSetting']('sortingIgnorePrefix')
|
||||||
|
},
|
||||||
isCoverSquareAspectRatio() {
|
isCoverSquareAspectRatio() {
|
||||||
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE
|
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE
|
||||||
},
|
},
|
||||||
|
|
@ -245,7 +247,7 @@ export default {
|
||||||
console.error('failed to fetch books', error)
|
console.error('failed to fetch books', error)
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
console.log('payload', payload)
|
|
||||||
this.isFetchingEntities = false
|
this.isFetchingEntities = false
|
||||||
if (this.pendingReset) {
|
if (this.pendingReset) {
|
||||||
this.pendingReset = false
|
this.pendingReset = false
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
<div class="flex items-start pl-24 mb-6 md:mb-0">
|
<div class="flex items-start pl-24 mb-6 md:mb-0">
|
||||||
<div>
|
<div>
|
||||||
<nuxt-link :to="`/audiobook/${streamAudiobook.id}`" class="hover:underline cursor-pointer text-base sm:text-lg">
|
<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>
|
</nuxt-link>
|
||||||
<div class="text-gray-400 flex items-center">
|
<div class="text-gray-400 flex items-center">
|
||||||
<span class="material-icons text-sm">person</span>
|
<span class="material-icons text-sm">person</span>
|
||||||
|
|
@ -22,26 +22,29 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow" />
|
<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>
|
</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" />
|
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :audiobook-id="bookmarkAudiobookId" :current-time="bookmarkCurrentTime" @select="selectBookmark" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import PlayerHandler from '@/players/PlayerHandler'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
audioPlayerReady: false,
|
playerHandler: new PlayerHandler(this),
|
||||||
lastServerUpdateSentSeconds: 0,
|
|
||||||
stream: null,
|
|
||||||
totalDuration: 0,
|
totalDuration: 0,
|
||||||
showBookmarksModal: false,
|
showBookmarksModal: false,
|
||||||
bookmarkCurrentTime: 0,
|
bookmarkCurrentTime: 0,
|
||||||
bookmarkAudiobookId: null
|
bookmarkAudiobookId: null,
|
||||||
|
playerLoading: false,
|
||||||
|
isPlaying: false,
|
||||||
|
currentTime: 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
|
@ -69,18 +72,13 @@ export default {
|
||||||
if (!this.audiobookId) return
|
if (!this.audiobookId) return
|
||||||
return this.$store.getters['user/getUserAudiobook'](this.audiobookId)
|
return this.$store.getters['user/getUserAudiobook'](this.audiobookId)
|
||||||
},
|
},
|
||||||
|
userAudiobookCurrentTime() {
|
||||||
|
return this.userAudiobook ? this.userAudiobook.currentTime || 0 : 0
|
||||||
|
},
|
||||||
bookmarks() {
|
bookmarks() {
|
||||||
if (!this.userAudiobook) return []
|
if (!this.userAudiobook) return []
|
||||||
return (this.userAudiobook.bookmarks || []).map((bm) => ({ ...bm })).sort((a, b) => a.time - b.time)
|
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() {
|
streamAudiobook() {
|
||||||
return this.$store.state.streamAudiobook
|
return this.$store.state.streamAudiobook
|
||||||
},
|
},
|
||||||
|
|
@ -105,12 +103,6 @@ export default {
|
||||||
authorsList() {
|
authorsList() {
|
||||||
return this.authorFL ? this.authorFL.split(', ') : []
|
return this.authorFL ? this.authorFL.split(', ') : []
|
||||||
},
|
},
|
||||||
streamId() {
|
|
||||||
return this.stream ? this.stream.id : null
|
|
||||||
},
|
|
||||||
playlistUrl() {
|
|
||||||
return this.stream ? this.stream.clientPlaylistUri : null
|
|
||||||
},
|
|
||||||
libraryId() {
|
libraryId() {
|
||||||
return this.streamAudiobook ? this.streamAudiobook.libraryId : null
|
return this.streamAudiobook ? this.streamAudiobook.libraryId : null
|
||||||
},
|
},
|
||||||
|
|
@ -119,8 +111,40 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
addListeningTime(time) {
|
playPause() {
|
||||||
console.log('Send listening time to server', time)
|
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) {
|
showBookmarks(currentTime) {
|
||||||
this.bookmarkAudiobookId = this.audiobookId
|
this.bookmarkAudiobookId = this.audiobookId
|
||||||
|
|
@ -128,47 +152,12 @@ export default {
|
||||||
this.showBookmarksModal = true
|
this.showBookmarksModal = true
|
||||||
},
|
},
|
||||||
selectBookmark(bookmark) {
|
selectBookmark(bookmark) {
|
||||||
if (this.$refs.audioPlayer) {
|
this.seek(bookmark.time)
|
||||||
this.$refs.audioPlayer.selectBookmark(bookmark)
|
|
||||||
}
|
|
||||||
this.showBookmarksModal = false
|
this.showBookmarksModal = false
|
||||||
},
|
},
|
||||||
filterByAuthor() {
|
closePlayer() {
|
||||||
if (this.$route.name !== 'index') {
|
this.playerHandler.closePlayer()
|
||||||
this.$router.push(`/library/${this.libraryId || this.$store.state.libraries.currentLibraryId}/bookshelf`)
|
this.$store.commit('setStreamAudiobook', null)
|
||||||
}
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
streamProgress(data) {
|
streamProgress(data) {
|
||||||
if (!data.numSegments) return
|
if (!data.numSegments) return
|
||||||
|
|
@ -181,21 +170,14 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
streamOpen(stream) {
|
streamOpen(stream) {
|
||||||
this.stream = stream
|
this.$store.commit('setStreamAudiobook', stream.audiobook)
|
||||||
this.$store.commit('updateStreamAudiobook', stream.audiobook)
|
this.playerHandler.prepareStream(stream)
|
||||||
|
|
||||||
if (this.$refs.audioPlayer) {
|
|
||||||
console.log('[StreamContainer] streamOpen', stream)
|
|
||||||
this.openStream()
|
|
||||||
} else if (this.audioPlayerReady) {
|
|
||||||
console.error('No Audio Ref')
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
streamClosed(streamId) {
|
streamClosed(streamId) {
|
||||||
if (this.stream && (this.stream.id === streamId || streamId === 'n/a')) {
|
// Stream was closed from the server
|
||||||
this.terminateStream()
|
if (this.playerHandler.isPlayingLocalAudiobook && this.playerHandler.currentStreamId === streamId) {
|
||||||
this.$store.commit('clearStreamAudiobook', this.stream.audiobook.id)
|
console.warn('[StreamContainer] Closing stream due to request from server')
|
||||||
this.stream = null
|
this.playerHandler.closePlayer()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
streamReady() {
|
streamReady() {
|
||||||
|
|
@ -207,41 +189,42 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
streamError(streamId) {
|
streamError(streamId) {
|
||||||
if (this.stream && (this.stream.id === streamId || streamId === 'n/a')) {
|
// Stream had critical error from the server
|
||||||
this.terminateStream()
|
if (this.playerHandler.isPlayingLocalAudiobook && this.playerHandler.currentStreamId === streamId) {
|
||||||
this.$store.commit('clearStreamAudiobook', this.stream.audiobook.id)
|
console.warn('[StreamContainer] Closing stream due to stream error from server')
|
||||||
this.stream = null
|
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 }) {
|
streamReset({ startTime, streamId }) {
|
||||||
if (streamId !== this.streamId) {
|
this.playerHandler.resetStream(startTime, streamId)
|
||||||
console.error('resetStream StreamId Mismatch', streamId, this.streamId)
|
},
|
||||||
return
|
castSessionActive(isActive) {
|
||||||
}
|
if (isActive && this.playerHandler.isPlayingLocalAudiobook) {
|
||||||
if (this.$refs.audioPlayer) {
|
// Cast session started switch to cast player
|
||||||
console.log(`[STREAM-CONTAINER] streamReset Received for time ${startTime}`)
|
this.playerHandler.switchPlayer()
|
||||||
this.$refs.audioPlayer.resetStream(startTime)
|
} 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>
|
</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 class="absolute cover-bg" ref="coverBg" />
|
||||||
</div>
|
</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' }">
|
<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>
|
||||||
<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>
|
||||||
|
|
||||||
<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>
|
<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 -->
|
<!-- 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="!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 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>
|
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">play_circle_filled</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-show="showReadButton" class="h-full flex items-center justify-center pointer-events-none">
|
<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>
|
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">auto_stories</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -61,16 +63,20 @@
|
||||||
<span class="material-icons" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">more_vert</span>
|
<span class="material-icons" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">more_vert</span>
|
||||||
</div>
|
</div>
|
||||||
</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' }">
|
<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>
|
<p class="text-gray-200 text-center" :style="{ fontSize: 1.1 * sizeMultiplier + 'rem' }">{{ series }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Error widget -->
|
||||||
<ui-tooltip v-if="showError" :text="errorText" class="absolute bottom-4 left-0 z-10">
|
<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">
|
<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>
|
<span class="material-icons text-red-100 pr-1" :style="{ fontSize: 0.875 * sizeMultiplier + 'rem' }">priority_high</span>
|
||||||
</div>
|
</div>
|
||||||
</ui-tooltip>
|
</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` }">
|
<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>
|
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ volumeNumber }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -99,7 +105,10 @@ export default {
|
||||||
// Book can be passed as prop or set with setEntity()
|
// Book can be passed as prop or set with setEntity()
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => null
|
default: () => null
|
||||||
}
|
},
|
||||||
|
orderBy: String,
|
||||||
|
filterBy: String,
|
||||||
|
sortingIgnorePrefix: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
|
@ -114,6 +123,15 @@ export default {
|
||||||
showCoverBg: false
|
showCoverBg: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
watch: {
|
||||||
|
bookMount: {
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.audiobook = newVal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
computed: {
|
computed: {
|
||||||
showExperimentalFeatures() {
|
showExperimentalFeatures() {
|
||||||
return this.store.state.showExperimentalFeatures
|
return this.store.state.showExperimentalFeatures
|
||||||
|
|
@ -180,6 +198,24 @@ export default {
|
||||||
volumeNumber() {
|
volumeNumber() {
|
||||||
return this.book.volumeNumber || null
|
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() {
|
userProgress() {
|
||||||
return this.store.getters['user/getUserAudiobook'](this.audiobookId)
|
return this.store.getters['user/getUserAudiobook'](this.audiobookId)
|
||||||
},
|
},
|
||||||
|
|
@ -322,6 +358,11 @@ export default {
|
||||||
isAlternativeBookshelfView() {
|
isAlternativeBookshelfView() {
|
||||||
var constants = this.$constants || this.$nuxt.$constants
|
var constants = this.$constants || this.$nuxt.$constants
|
||||||
return this.bookshelfView === constants.BookshelfView.TITLES
|
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: {
|
methods: {
|
||||||
|
|
@ -461,8 +502,8 @@ export default {
|
||||||
this.$emit('select', this.audiobook)
|
this.$emit('select', this.audiobook)
|
||||||
},
|
},
|
||||||
play() {
|
play() {
|
||||||
this.store.commit('setStreamAudiobook', this.audiobook)
|
var eventBus = this.$eventBus || this.$nuxt.$eventBus
|
||||||
this._socket.emit('open_stream', this.audiobookId)
|
eventBus.$emit('play-audiobook', this.audiobookId)
|
||||||
},
|
},
|
||||||
mouseover() {
|
mouseover() {
|
||||||
this.isHovering = true
|
this.isHovering = true
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,14 @@ export default {
|
||||||
{
|
{
|
||||||
text: 'Size',
|
text: 'Size',
|
||||||
value: 'size'
|
value: 'size'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'File Birthtime',
|
||||||
|
value: 'birthtimeMs'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'File Modified',
|
||||||
|
value: 'mtimeMs'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,8 @@ export default {
|
||||||
this.showMenu = val
|
this.showMenu = val
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {
|
||||||
|
this.currentPlaybackRate = this.playbackRate
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -51,11 +51,25 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
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() {
|
mouseover() {
|
||||||
|
if (!this.isHovering) {
|
||||||
|
window.addEventListener('mousewheel', this.scroll)
|
||||||
|
}
|
||||||
this.isHovering = true
|
this.isHovering = true
|
||||||
this.setOpen()
|
this.setOpen()
|
||||||
},
|
},
|
||||||
mouseleave() {
|
mouseleave() {
|
||||||
|
if (this.isHovering) {
|
||||||
|
window.removeEventListener('mousewheel', this.scroll)
|
||||||
|
}
|
||||||
this.isHovering = false
|
this.isHovering = false
|
||||||
},
|
},
|
||||||
setOpen() {
|
setOpen() {
|
||||||
|
|
@ -127,6 +141,11 @@ export default {
|
||||||
if (this.value === 0) {
|
if (this.value === 0) {
|
||||||
this.isMute = true
|
this.isMute = true
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
window.removeEventListener('mousewheel', this.scroll)
|
||||||
|
document.body.removeEventListener('mousemove', this.mousemove)
|
||||||
|
document.body.removeEventListener('mouseup', this.mouseup)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</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 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 class="absolute cover-bg" ref="coverBg" />
|
||||||
</div>
|
</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'" />
|
<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' }">
|
<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>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
<form @submit.prevent="submitSearchForm">
|
<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-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>
|
||||||
|
|
||||||
|
<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-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-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>
|
</ui-tooltip>
|
||||||
|
|
@ -113,7 +117,8 @@ export default {
|
||||||
resettingProgress: false,
|
resettingProgress: false,
|
||||||
isScrollable: false,
|
isScrollable: false,
|
||||||
savingMetadata: false,
|
savingMetadata: false,
|
||||||
rescanning: false
|
rescanning: false,
|
||||||
|
quickMatching: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
|
|
@ -163,12 +168,41 @@ export default {
|
||||||
libraryId() {
|
libraryId() {
|
||||||
return this.audiobook ? this.audiobook.libraryId : null
|
return this.audiobook ? this.audiobook.libraryId : null
|
||||||
},
|
},
|
||||||
|
libraryProvider() {
|
||||||
|
return this.$store.getters['libraries/getLibraryProvider'](this.libraryId) || 'google'
|
||||||
|
},
|
||||||
libraryScan() {
|
libraryScan() {
|
||||||
if (!this.libraryId) return null
|
if (!this.libraryId) return null
|
||||||
return this.$store.getters['scanners/getLibraryScan'](this.libraryId)
|
return this.$store.getters['scanners/getLibraryScan'](this.libraryId)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
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) {
|
audiobookScanComplete(result) {
|
||||||
this.rescanning = false
|
this.rescanning = false
|
||||||
if (!result) {
|
if (!result) {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,19 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="w-full h-full px-4 py-2 mb-12">
|
<div class="w-full h-full px-4 py-2 mb-4">
|
||||||
<div class="flex items-center py-1 mb-2">
|
<div v-show="showDirectoryPicker" 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>
|
<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>
|
<p class="px-4 text-xl">{{ title }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!showDirectoryPicker" class="w-full h-full py-4">
|
<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">
|
<div class="w-full py-4">
|
||||||
<p class="px-1 text-sm font-semibold">Folders</p>
|
<p class="px-1 text-sm font-semibold">Folders</p>
|
||||||
|
|
@ -26,7 +33,17 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<modals-libraries-folder-chooser v-else :paths="folderPaths" @select="selectFolder" />
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -42,8 +59,10 @@ export default {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
name: '',
|
name: '',
|
||||||
|
provider: '',
|
||||||
folders: [],
|
folders: [],
|
||||||
showDirectoryPicker: false
|
showDirectoryPicker: false,
|
||||||
|
disableWatcher: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
|
@ -61,7 +80,13 @@ export default {
|
||||||
var newfolderpaths = this.folderPaths.join(',')
|
var newfolderpaths = this.folderPaths.join(',')
|
||||||
var origfolderpaths = this.library.folders.map((f) => f.fullPath).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: {
|
methods: {
|
||||||
|
|
@ -75,7 +100,9 @@ export default {
|
||||||
},
|
},
|
||||||
init() {
|
init() {
|
||||||
this.name = this.library ? this.library.name : ''
|
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.folders = this.library ? this.library.folders.map((p) => ({ ...p })) : []
|
||||||
|
this.disableWatcher = this.library ? !!this.library.disableWatcher : false
|
||||||
this.showDirectoryPicker = false
|
this.showDirectoryPicker = false
|
||||||
},
|
},
|
||||||
selectFolder(fullPath) {
|
selectFolder(fullPath) {
|
||||||
|
|
@ -100,7 +127,9 @@ export default {
|
||||||
}
|
}
|
||||||
var newLibraryPayload = {
|
var newLibraryPayload = {
|
||||||
name: this.name,
|
name: this.name,
|
||||||
folders: this.folders
|
provider: this.provider,
|
||||||
|
folders: this.folders,
|
||||||
|
disableWatcher: this.disableWatcher
|
||||||
}
|
}
|
||||||
|
|
||||||
this.$emit('update:processing', true)
|
this.$emit('update:processing', true)
|
||||||
|
|
@ -132,7 +161,9 @@ export default {
|
||||||
}
|
}
|
||||||
var newLibraryPayload = {
|
var newLibraryPayload = {
|
||||||
name: this.name,
|
name: this.name,
|
||||||
folders: this.folders
|
provider: this.provider,
|
||||||
|
folders: this.folders,
|
||||||
|
disableWatcher: this.disableWatcher
|
||||||
}
|
}
|
||||||
|
|
||||||
this.$emit('update:processing', true)
|
this.$emit('update:processing', true)
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,11 @@
|
||||||
</svg>
|
</svg>
|
||||||
<p class="text-xl font-book pl-4 hover:underline cursor-pointer" @click.stop="$emit('click', library)">{{ library.name }}</p>
|
<p class="text-xl font-book pl-4 hover:underline cursor-pointer" @click.stop="$emit('click', library)">{{ library.name }}</p>
|
||||||
<div class="flex-grow" />
|
<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="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="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>
|
<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">
|
<div v-show="isDeleting" class="text-xl text-gray-300 ml-3 animate-spin">
|
||||||
|
|
@ -59,6 +62,18 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
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() {
|
editClick() {
|
||||||
this.$emit('edit', this.library)
|
this.$emit('edit', this.library)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@
|
||||||
<modals-edit-library-modal v-model="showLibraryModal" :library="selectedLibrary" />
|
<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>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>
|
</div>
|
||||||
</template>
|
</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
|
this.isHovering = false
|
||||||
},
|
},
|
||||||
playClick() {
|
playClick() {
|
||||||
this.$store.commit('setStreamAudiobook', this.book)
|
this.$eventBus.$emit('play-audiobook', this.book.id)
|
||||||
this.$root.socket.emit('open_stream', this.book.id)
|
|
||||||
},
|
},
|
||||||
clickEdit() {
|
clickEdit() {
|
||||||
this.$emit('edit', this.book)
|
this.$emit('edit', this.book)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="relative w-full" v-click-outside="clickOutsideObj">
|
<div class="relative w-full" v-click-outside="clickOutsideObj">
|
||||||
<p class="text-sm font-semibold">{{ label }}</p>
|
<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="flex items-center">
|
||||||
<span class="block truncate" :class="small ? 'text-sm' : ''">{{ selectedText }}</span>
|
<span class="block truncate" :class="small ? 'text-sm' : ''">{{ selectedText }}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="w-full" :class="disabled ? 'cursor-not-allowed' : ''">
|
<div class="w-full">
|
||||||
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
|
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p>
|
||||||
<div ref="wrapper" class="relative">
|
<div ref="wrapper" class="relative">
|
||||||
<form @submit.prevent="submitForm">
|
<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" />
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="w-full">
|
<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">
|
<div ref="wrapper" class="relative">
|
||||||
<form @submit.prevent="submitForm">
|
<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>
|
<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;
|
border-style: inherit !important;
|
||||||
}
|
}
|
||||||
input:read-only {
|
input:read-only {
|
||||||
color: #aaa;
|
color: #bbb;
|
||||||
background-color: #444;
|
background-color: #444;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">
|
<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>
|
{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
|
||||||
</p>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -32,7 +32,13 @@ export default {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {},
|
methods: {
|
||||||
|
blur() {
|
||||||
|
if (this.$refs.input && this.$refs.input.blur) {
|
||||||
|
this.$refs.input.blur()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</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: {
|
computed: {
|
||||||
user() {
|
user() {
|
||||||
return this.$store.state.user.user
|
return this.$store.state.user.user
|
||||||
|
},
|
||||||
|
isCasting() {
|
||||||
|
return this.$store.state.globals.isCasting
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|
@ -99,7 +102,6 @@ export default {
|
||||||
console.log('Init Payload', payload)
|
console.log('Init Payload', payload)
|
||||||
if (payload.stream) {
|
if (payload.stream) {
|
||||||
if (this.$refs.streamContainer) {
|
if (this.$refs.streamContainer) {
|
||||||
this.$store.commit('setStream', payload.stream)
|
|
||||||
this.$refs.streamContainer.streamOpen(payload.stream)
|
this.$refs.streamContainer.streamOpen(payload.stream)
|
||||||
} else {
|
} else {
|
||||||
console.warn('Stream Container not mounted')
|
console.warn('Stream Container not mounted')
|
||||||
|
|
@ -110,7 +112,12 @@ export default {
|
||||||
this.$store.commit('user/setSettings', payload.user.settings)
|
this.$store.commit('user/setSettings', payload.user.settings)
|
||||||
}
|
}
|
||||||
if (payload.serverSettings) {
|
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) {
|
if (payload.SSOSettings) {
|
||||||
this.$store.commit('settings/setSSOSettings', payload.SSOSettings)
|
this.$store.commit('settings/setSSOSettings', payload.SSOSettings)
|
||||||
|
|
@ -208,7 +215,7 @@ export default {
|
||||||
scanComplete(data) {
|
scanComplete(data) {
|
||||||
console.log('Scan complete received', 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) {
|
if (data.results) {
|
||||||
var scanResultMsgs = []
|
var scanResultMsgs = []
|
||||||
var results = data.results
|
var results = data.results
|
||||||
|
|
@ -219,7 +226,7 @@ export default {
|
||||||
if (!scanResultMsgs.length) message += '\nEverything was up to date'
|
if (!scanResultMsgs.length) message += '\nEverything was up to date'
|
||||||
else message += '\n' + scanResultMsgs.join('\n')
|
else message += '\n' + scanResultMsgs.join('\n')
|
||||||
} else {
|
} 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)
|
var existingScan = this.$store.getters['scanners/getLibraryScan'](data.id)
|
||||||
|
|
@ -235,7 +242,7 @@ export default {
|
||||||
this.$root.socket.emit('cancel_scan', id)
|
this.$root.socket.emit('cancel_scan', id)
|
||||||
},
|
},
|
||||||
scanStart(data) {
|
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)
|
this.$store.commit('scanners/addUpdate', data)
|
||||||
},
|
},
|
||||||
scanProgress(data) {
|
scanProgress(data) {
|
||||||
|
|
@ -514,6 +521,7 @@ export default {
|
||||||
this.resize()
|
this.resize()
|
||||||
window.addEventListener('resize', this.resize)
|
window.addEventListener('resize', this.resize)
|
||||||
window.addEventListener('keydown', this.keyDown)
|
window.addEventListener('keydown', this.keyDown)
|
||||||
|
|
||||||
this.$store.dispatch('libraries/load')
|
this.$store.dispatch('libraries/load')
|
||||||
|
|
||||||
// If experimental features set in local storage
|
// If experimental features set in local storage
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,11 @@ export default {
|
||||||
bookshelfView: this.bookshelfView
|
bookshelfView: this.bookshelfView
|
||||||
}
|
}
|
||||||
if (this.entityName === 'series-books') props.showVolumeNumber = true
|
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 _this = this
|
||||||
var instance = new ComponentClass({
|
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',
|
dev: process.env.NODE_ENV !== 'production',
|
||||||
env: {
|
env: {
|
||||||
serverUrl: process.env.NODE_ENV === 'production' ? '' : 'http://localhost:3333',
|
serverUrl: process.env.NODE_ENV === 'production' ? '' : 'http://localhost:3333',
|
||||||
// serverUrl: '',
|
chromecastReceiver: 'FD1F76C5',
|
||||||
baseUrl: process.env.BASE_URL || 'http://0.0.0.0'
|
baseUrl: process.env.BASE_URL || 'http://0.0.0.0'
|
||||||
},
|
},
|
||||||
// rootDir: process.env.NODE_ENV !== 'production' ? 'client/' : '',
|
// rootDir: process.env.NODE_ENV !== 'production' ? 'client/' : '',
|
||||||
|
|
@ -30,13 +30,12 @@ module.exports = {
|
||||||
],
|
],
|
||||||
script: [
|
script: [
|
||||||
{
|
{
|
||||||
src: '//cdn.jsdelivr.net/npm/sortablejs@1.8.4/Sortable.min.js'
|
src: '/libs/sortable.js'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
link: [
|
link: [
|
||||||
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
|
{ 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/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",
|
"name": "audiobookshelf-client",
|
||||||
"version": "1.6.66",
|
"version": "1.7.1",
|
||||||
"description": "Audiobook manager and player",
|
"description": "Audiobook manager and player",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,20 @@
|
||||||
<div class="w-full flex justify-center md:block md:w-52" style="min-width: 208px">
|
<div class="w-full flex justify-center md:block md:w-52" style="min-width: 208px">
|
||||||
<div class="relative" style="height: fit-content">
|
<div class="relative" style="height: fit-content">
|
||||||
<covers-book-cover :audiobook="audiobook" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<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>
|
<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>
|
</div>
|
||||||
<div class="flex-grow px-2 py-6 md:py-0 md:px-10">
|
<div class="flex-grow px-2 py-6 md:py-0 md:px-10">
|
||||||
|
|
@ -378,6 +391,10 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
showEditCover() {
|
||||||
|
this.$store.commit('setBookshelfBookIds', [])
|
||||||
|
this.$store.commit('showEditModalOnTab', { audiobook: this.audiobook, tab: 'cover' })
|
||||||
|
},
|
||||||
openEbook() {
|
openEbook() {
|
||||||
this.$store.commit('showEReader', this.audiobook)
|
this.$store.commit('showEReader', this.audiobook)
|
||||||
},
|
},
|
||||||
|
|
@ -411,8 +428,7 @@ export default {
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
startStream() {
|
startStream() {
|
||||||
this.$store.commit('setStreamAudiobook', this.audiobook)
|
this.$eventBus.$emit('play-audiobook', this.audiobook.id)
|
||||||
this.$root.socket.emit('open_stream', this.audiobook.id)
|
|
||||||
},
|
},
|
||||||
editClick() {
|
editClick() {
|
||||||
this.$store.commit('setBookshelfBookIds', [])
|
this.$store.commit('setBookshelfBookIds', [])
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,58 @@
|
||||||
<template>
|
<template>
|
||||||
<div ref="page" id="page-wrapper" class="page px-6 pt-6 pb-52 overflow-y-auto" :class="streamAudiobook ? 'streaming' : ''">
|
<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="border border-white border-opacity-10 max-w-7xl mx-auto mb-10 mt-5">
|
||||||
<div class="flex-grow">
|
<div class="flex items-center px-4 py-4 cursor-pointer" @click="openMapOptions = !openMapOptions" @mousedown.prevent @mouseup.prevent>
|
||||||
<p class="text-xl mb-4">Batch edit {{ audiobooks.length }} books</p>
|
<span class="material-icons">{{ openMapOptions ? 'expand_less' : 'expand_more' }}</span>
|
||||||
|
|
||||||
<div class="flex items-center px-1">
|
<p class="ml-4 text-gray-200 text-lg">Map details</p>
|
||||||
<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>
|
|
||||||
</div>
|
</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">
|
<div class="flex justify-center flex-wrap">
|
||||||
<template v-for="audiobook in audiobookCopies">
|
<template v-for="audiobook in audiobookCopies">
|
||||||
|
|
@ -130,19 +157,29 @@ export default {
|
||||||
newSeriesItems: [],
|
newSeriesItems: [],
|
||||||
newTagItems: [],
|
newTagItems: [],
|
||||||
newGenreItems: [],
|
newGenreItems: [],
|
||||||
batchBook: {
|
batchDetails: {
|
||||||
|
subtitle: null,
|
||||||
author: null,
|
author: null,
|
||||||
genres: [],
|
publishYear: null,
|
||||||
series: null,
|
series: null,
|
||||||
narrator: null
|
genres: [],
|
||||||
|
tags: [],
|
||||||
|
narrator: null,
|
||||||
|
publisher: null,
|
||||||
|
language: null
|
||||||
},
|
},
|
||||||
selectedBatchUsage: {
|
selectedBatchUsage: {
|
||||||
|
subtitle: false,
|
||||||
author: false,
|
author: false,
|
||||||
genres: false,
|
publishYear: false,
|
||||||
series: false,
|
series: false,
|
||||||
narrator: false
|
genres: false,
|
||||||
|
tags: false,
|
||||||
|
narrator: false,
|
||||||
|
publisher: false,
|
||||||
|
language: false
|
||||||
},
|
},
|
||||||
batchTags: []
|
openMapOptions: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
|
@ -178,9 +215,46 @@ export default {
|
||||||
},
|
},
|
||||||
currentLibraryId() {
|
currentLibraryId() {
|
||||||
return this.$store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
|
},
|
||||||
|
hasSelectedBatchUsage() {
|
||||||
|
return Object.values(this.selectedBatchUsage).some((b) => !!b)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
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) {
|
newTagItem(item) {
|
||||||
if (item && !this.newTagItems.includes(item)) {
|
if (item && !this.newTagItems.includes(item)) {
|
||||||
this.newTagItems.push(item)
|
this.newTagItems.push(item)
|
||||||
|
|
@ -306,7 +380,7 @@ export default {
|
||||||
},
|
},
|
||||||
applyBatchUpdates() {
|
applyBatchUpdates() {
|
||||||
this.audiobookCopies = this.audiobookCopies.map((ab) => {
|
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>
|
</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() {
|
clickPlay() {
|
||||||
var nextBookNotRead = this.playableBooks.find((pb) => !this.userAudiobooks[pb.id] || !this.userAudiobooks[pb.id].isRead)
|
var nextBookNotRead = this.playableBooks.find((pb) => !this.userAudiobooks[pb.id] || !this.userAudiobooks[pb.id].isRead)
|
||||||
if (nextBookNotRead) {
|
if (nextBookNotRead) {
|
||||||
this.$store.commit('setStreamAudiobook', nextBookNotRead)
|
this.$eventBus.$emit('play-audiobook', nextBookNotRead.id)
|
||||||
this.$root.socket.emit('open_stream', nextBookNotRead.id)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -57,16 +57,6 @@ export default {
|
||||||
this.$store.commit('setDeveloperMode', value)
|
this.$store.commit('setDeveloperMode', value)
|
||||||
this.$toast.info(`Developer Mode ${value ? 'Enabled' : 'Disabled'}`)
|
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() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,41 +8,76 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center py-2">
|
<div class="flex items-center py-2">
|
||||||
<ui-toggle-switch v-model="storeCoversInAudiobookDir" :disabled="updatingServerSettings" @input="updateCoverStorageDestination" />
|
<ui-toggle-switch v-model="newServerSettings.storeCoverWithBook" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeCoverWithBook', val)" />
|
||||||
<ui-tooltip :text="coverDestinationTooltip">
|
<ui-tooltip :text="tooltips.storeCoverWithBook">
|
||||||
<p class="pl-4 text-lg">Store covers with audiobook <span class="material-icons icon-text">info_outlined</span></p>
|
<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>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center py-2">
|
<div class="flex items-center py-2">
|
||||||
<ui-toggle-switch v-model="useSquareBookCovers" :disabled="updatingServerSettings" @input="updateBookCoverAspectRatio" />
|
<ui-toggle-switch v-model="useSquareBookCovers" :disabled="updatingServerSettings" @input="updateBookCoverAspectRatio" />
|
||||||
<ui-tooltip :text="coverAspectRatioTooltip">
|
<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>
|
<p class="pl-4 text-lg">
|
||||||
|
Use square book covers
|
||||||
|
<span class="material-icons icon-text">info_outlined</span>
|
||||||
|
</p>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center py-2">
|
<div class="flex items-center py-2">
|
||||||
<ui-toggle-switch v-model="useAlternativeBookshelfView" :disabled="updatingServerSettings" @input="updateAlternativeBookshelfView" />
|
<ui-toggle-switch v-model="useAlternativeBookshelfView" :disabled="updatingServerSettings" @input="updateAlternativeBookshelfView" />
|
||||||
<ui-tooltip :text="bookshelfViewTooltip">
|
<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>
|
<p class="pl-4 text-lg">
|
||||||
|
Use alternative library bookshelf view
|
||||||
|
<span class="material-icons icon-text">info_outlined</span>
|
||||||
|
</p>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</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">
|
<div class="flex items-center mb-2 mt-8">
|
||||||
<h1 class="text-xl">Scanner Settings</h1>
|
<h1 class="text-xl">Scanner Settings</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center py-2">
|
<div class="flex items-center py-2">
|
||||||
<ui-toggle-switch v-model="newServerSettings.scannerParseSubtitle" small :disabled="updatingServerSettings" @input="updateScannerParseSubtitle" />
|
<ui-toggle-switch v-model="newServerSettings.scannerParseSubtitle" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerParseSubtitle', val)" />
|
||||||
<ui-tooltip :text="parseSubtitleTooltip">
|
<ui-tooltip :text="tooltips.scannerParseSubtitle">
|
||||||
<p class="pl-4 text-lg">Scanner parse subtitles <span class="material-icons icon-text">info_outlined</span></p>
|
<p class="pl-4 text-lg">
|
||||||
|
Scanner parse subtitles
|
||||||
|
<span class="material-icons icon-text">info_outlined</span>
|
||||||
|
</p>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center py-2">
|
<div class="flex items-center py-2">
|
||||||
<ui-toggle-switch v-model="newServerSettings.scannerFindCovers" :disabled="updatingServerSettings" @input="updateScannerFindCovers" />
|
<ui-toggle-switch v-model="newServerSettings.scannerFindCovers" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerFindCovers', val)" />
|
||||||
<ui-tooltip :text="scannerFindCoversTooltip">
|
<ui-tooltip :text="tooltips.scannerFindCovers">
|
||||||
<p class="pl-4 text-lg">Scanner find covers <span class="material-icons icon-text">info_outlined</span></p>
|
<p class="pl-4 text-lg">
|
||||||
|
Scanner find covers
|
||||||
|
<span class="material-icons icon-text">info_outlined</span>
|
||||||
|
</p>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -51,16 +86,32 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center py-2">
|
<div class="flex items-center py-2">
|
||||||
<ui-toggle-switch v-model="newServerSettings.scannerPreferAudioMetadata" :disabled="updatingServerSettings" @input="updateScannerPreferAudioMeta" />
|
<ui-toggle-switch v-model="newServerSettings.scannerPreferAudioMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferAudioMetadata', val)" />
|
||||||
<ui-tooltip :text="scannerPreferAudioMetaTooltip">
|
<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>
|
<p class="pl-4 text-lg">
|
||||||
|
Scanner prefer audio metadata
|
||||||
|
<span class="material-icons icon-text">info_outlined</span>
|
||||||
|
</p>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center py-2">
|
<div class="flex items-center py-2">
|
||||||
<ui-toggle-switch v-model="newServerSettings.scannerPreferOpfMetadata" :disabled="updatingServerSettings" @input="updateScannerPreferOpfMeta" />
|
<ui-toggle-switch v-model="newServerSettings.scannerPreferOpfMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferOpfMetadata', val)" />
|
||||||
<ui-tooltip :text="scannerPreferOpfMetaTooltip">
|
<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>
|
<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>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
</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 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>
|
<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" />
|
<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">
|
<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">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" width="24" height="24" viewBox="0 0 24 24">
|
||||||
<path
|
<path
|
||||||
|
|
@ -79,6 +133,25 @@
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</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>
|
||||||
|
|
||||||
<div class="h-0.5 bg-primary bg-opacity-30 w-full" />
|
<div class="h-0.5 bg-primary bg-opacity-30 w-full" />
|
||||||
|
|
@ -89,13 +162,16 @@
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<ui-toggle-switch v-model="showExperimentalFeatures" />
|
<ui-toggle-switch v-model="showExperimentalFeatures" />
|
||||||
<ui-tooltip :text="experimentalFeaturesTooltip">
|
<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>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- <div class="hidden md:block">
|
<!-- <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>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -106,12 +182,22 @@ export default {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
isResettingAudiobooks: false,
|
isResettingAudiobooks: false,
|
||||||
storeCoversInAudiobookDir: false,
|
|
||||||
updatingServerSettings: false,
|
updatingServerSettings: false,
|
||||||
useSquareBookCovers: false,
|
useSquareBookCovers: false,
|
||||||
useAlternativeBookshelfView: false,
|
useAlternativeBookshelfView: false,
|
||||||
isPurgingCache: 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: {
|
watch: {
|
||||||
|
|
@ -123,35 +209,11 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
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() {
|
experimentalFeaturesTooltip() {
|
||||||
return 'Features in development that could use your feedback and help testing.'
|
return 'Features in development that could use your feedback and help testing.'
|
||||||
},
|
},
|
||||||
parseSubtitleTooltip() {
|
serverSettings() {
|
||||||
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"'
|
return this.$store.state.serverSettings
|
||||||
},
|
|
||||||
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'
|
|
||||||
},
|
},
|
||||||
providers() {
|
providers() {
|
||||||
return this.$store.state.scanners.providers
|
return this.$store.state.scanners.providers
|
||||||
|
|
@ -166,37 +228,14 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
updateScannerFindCovers(val) {
|
updateEnableChromecast(val) {
|
||||||
this.updateServerSettings({
|
this.updateServerSettings({ enableChromecast: val })
|
||||||
scannerFindCovers: !!val
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
updateScannerCoverProvider(val) {
|
updateScannerCoverProvider(val) {
|
||||||
this.updateServerSettings({
|
this.updateServerSettings({
|
||||||
scannerCoverProvider: val
|
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) {
|
updateBookCoverAspectRatio(val) {
|
||||||
this.updateServerSettings({
|
this.updateServerSettings({
|
||||||
coverAspectRatio: val ? this.$constants.BookCoverAspectRatio.SQUARE : this.$constants.BookCoverAspectRatio.STANDARD
|
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
|
bookshelfView: val ? this.$constants.BookshelfView.TITLES : this.$constants.BookshelfView.STANDARD
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
updateSettingsKey(key, val) {
|
||||||
|
this.updateServerSettings({
|
||||||
|
[key]: val
|
||||||
|
})
|
||||||
|
},
|
||||||
updateServerSettings(payload) {
|
updateServerSettings(payload) {
|
||||||
this.updatingServerSettings = true
|
this.updatingServerSettings = true
|
||||||
this.$store
|
this.$store
|
||||||
|
|
@ -223,8 +267,6 @@ export default {
|
||||||
initServerSettings() {
|
initServerSettings() {
|
||||||
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
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.useSquareBookCovers = this.newServerSettings.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE
|
||||||
|
|
||||||
this.useAlternativeBookshelfView = this.newServerSettings.bookshelfView === this.$constants.BookshelfView.TITLES
|
this.useAlternativeBookshelfView = this.newServerSettings.bookshelfView === this.$constants.BookshelfView.TITLES
|
||||||
|
|
|
||||||
|
|
@ -1,161 +1,99 @@
|
||||||
<template>
|
<template>
|
||||||
<div id="page-wrapper" class="page p-0 sm:p-6 overflow-y-auto" :class="streamAudiobook ? 'streaming' : ''">
|
<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">
|
<div class="w-full max-w-6xl mx-auto">
|
||||||
<article class="max-h-full md:overflow-y-auto relative flex flex-col rounded-md" @drop="drop" @dragover="dragover" @dragleave="dragleave" @dragenter="dragenter">
|
<!-- Library & folder picker -->
|
||||||
<h1 class="text-xl font-book px-8 pt-4 pb-2">Audiobook Uploader</h1>
|
<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">
|
<widgets-alert v-if="error" type="error">
|
||||||
<div class="w-1/3 px-2">
|
<p class="text-lg">{{ error }}</p>
|
||||||
<!-- <ui-text-input-with-label v-model="title" label="Title" /> -->
|
</widgets-alert>
|
||||||
<ui-dropdown v-model="selectedLibraryId" :items="libraryItems" label="Library" @input="libraryChanged" />
|
|
||||||
</div>
|
<!-- Picker display -->
|
||||||
<div class="w-2/3 px-2">
|
<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'">
|
||||||
<ui-dropdown v-model="selectedFolderId" :items="folderItems" :disabled="!selectedLibraryId" label="Folder" />
|
<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>
|
</div>
|
||||||
<div class="flex my-2 px-6">
|
<div class="pt-8 text-center">
|
||||||
<div class="w-1/2 px-2">
|
<p class="text-xs text-white text-opacity-50 font-mono"><strong>Supported File Types: </strong>{{ inputAccept.join(', ') }}</p>
|
||||||
<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>
|
</div>
|
||||||
<div class="flex my-2 px-6">
|
</div>
|
||||||
<div class="w-1/2 px-2">
|
<!-- Book list header -->
|
||||||
<ui-text-input-with-label v-model="series" label="Series" note="(optional)" />
|
<div v-else class="w-full flex items-center pb-4 border-b border-white border-opacity-10">
|
||||||
</div>
|
<p class="text-lg">{{ books.length }} book{{ books.length === 1 ? '' : 's' }}</p>
|
||||||
<div class="w-1/2 px-2">
|
<p v-if="ignoredFiles.length" class="text-lg"> | {{ ignoredFiles.length }} file{{ ignoredFiles.length === 1 ? '' : 's' }} ignored</p>
|
||||||
<div class="w-full">
|
<div class="flex-grow" />
|
||||||
<p class="px-1 text-sm font-semibold">Directory <em class="font-normal text-xs pl-2">(auto)</em></p>
|
<ui-btn :disabled="processing" small @click="reset">Reset</ui-btn>
|
||||||
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs" style="height: 42px" />
|
</div>
|
||||||
</div>
|
|
||||||
</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>
|
</div>
|
||||||
|
</widgets-alert>
|
||||||
|
|
||||||
<section v-if="showUploader" class="h-full overflow-auto p-8 w-full flex flex-col">
|
<!-- Book Upload cards -->
|
||||||
<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' : ''">
|
<template v-for="(book, index) in books">
|
||||||
<p v-show="isDragOver" class="mb-3 font-semibold text-gray-200 flex flex-wrap justify-center">Drop em'</p>
|
<cards-book-upload-card :ref="`bookCard-${book.index}`" :key="index" :book="book" :processing="processing" @remove="removeBook(book)" />
|
||||||
<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>
|
</template>
|
||||||
|
|
||||||
<input ref="fileInput" id="hidden-input" type="file" multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
|
<!-- Upload/Reset btns -->
|
||||||
<ui-btn @click="clickSelectAudioFiles">Select files</ui-btn>
|
<div v-show="books.length" class="flex justify-end pb-8 pt-4">
|
||||||
<p class="text-xs text-gray-300 absolute bottom-3 right-3">{{ inputAccept }}</p>
|
<ui-btn v-if="!uploadFinished" color="success" :loading="processing" @click="submit">Upload</ui-btn>
|
||||||
</header>
|
<ui-btn v-else @click="reset">Reset</ui-btn>
|
||||||
</section>
|
</div>
|
||||||
<section v-else class="h-full overflow-auto px-8 pb-8 w-full flex flex-col">
|
</div>
|
||||||
<p v-if="!hasValidAudioFiles" class="text-error text-lg pt-4">* No valid audio tracks</p>
|
|
||||||
|
|
||||||
<div v-if="validImageFiles.length">
|
<input ref="fileInput" id="hidden-input" type="file" multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
|
||||||
<h1 class="pt-8 pb-3 font-semibold sm:text-lg text-gray-200">Cover Image(s)</h1>
|
<input ref="fileFolderInput" id="hidden-input" type="file" webkitdirectory multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import Path from 'path'
|
import uploadHelpers from '@/mixins/uploadHelpers'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
mixins: [uploadHelpers],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
processing: false,
|
isDragging: false,
|
||||||
title: null,
|
error: '',
|
||||||
author: null,
|
books: [],
|
||||||
series: null,
|
ignoredFiles: [],
|
||||||
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: [],
|
|
||||||
selectedLibraryId: null,
|
selectedLibraryId: null,
|
||||||
selectedFolderId: null
|
selectedFolderId: null,
|
||||||
|
processing: false,
|
||||||
|
uploadFinished: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
inputAccept() {
|
||||||
|
var extensions = []
|
||||||
|
Object.values(this.$constants.SupportedFileTypes).forEach((types) => {
|
||||||
|
extensions = extensions.concat(types.map((t) => `.${t}`))
|
||||||
|
})
|
||||||
|
return extensions
|
||||||
|
},
|
||||||
streamAudiobook() {
|
streamAudiobook() {
|
||||||
return this.$store.state.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() {
|
libraries() {
|
||||||
return this.$store.state.libraries.libraries
|
return this.$store.state.libraries.libraries
|
||||||
},
|
},
|
||||||
|
|
@ -182,6 +120,9 @@ export default {
|
||||||
text: fold.fullPath
|
text: fold.fullPath
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
uploadReady() {
|
||||||
|
return !this.books.length && !this.ignoredFiles.length && !this.uploadFinished
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|
@ -200,114 +141,171 @@ export default {
|
||||||
this.selectedFolderId = this.selectedLibrary.folders[0].id
|
this.selectedFolderId = this.selectedLibrary.folders[0].id
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
reset() {
|
removeBook(book) {
|
||||||
this.title = ''
|
this.books = this.books.filter((b) => b.index !== book.index)
|
||||||
this.author = ''
|
if (!this.books.length) {
|
||||||
this.series = ''
|
this.reset()
|
||||||
this.cancel()
|
|
||||||
},
|
|
||||||
cancel() {
|
|
||||||
this.validAudioFiles = []
|
|
||||||
this.validImageFiles = []
|
|
||||||
this.invalidFiles = []
|
|
||||||
if (this.$refs.fileInput) {
|
|
||||||
this.$refs.fileInput.value = ''
|
|
||||||
}
|
}
|
||||||
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) {
|
inputChanged(e) {
|
||||||
if (!e.target || !e.target.files) return
|
if (!e.target || !e.target.files) return
|
||||||
var _files = Array.from(e.target.files)
|
var _files = Array.from(e.target.files)
|
||||||
if (_files && _files.length) {
|
if (_files && _files.length) {
|
||||||
this.filesChanged(_files)
|
var bookResults = this.uploadHelpers.getBooksFromPicker(_files)
|
||||||
|
this.setResults(bookResults)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
drop(evt) {
|
setResults(bookResults) {
|
||||||
this.isDragOver = false
|
if (bookResults.error) {
|
||||||
this.preventDefaults(evt)
|
this.error = bookResults.error
|
||||||
const files = [...evt.dataTransfer.files]
|
this.books = []
|
||||||
this.filesChanged(files)
|
this.ignoredFiles = []
|
||||||
|
} else {
|
||||||
|
this.error = ''
|
||||||
|
this.books = bookResults.books
|
||||||
|
this.ignoredFiles = bookResults.ignoredFiles
|
||||||
|
}
|
||||||
|
console.log('Upload results', bookResults)
|
||||||
},
|
},
|
||||||
dragover(evt) {
|
updateBookCardStatus(index, status) {
|
||||||
this.isDragOver = true
|
var ref = this.$refs[`bookCard-${index}`]
|
||||||
this.preventDefaults(evt)
|
if (ref && ref.length) ref = ref[0]
|
||||||
},
|
if (!ref) {
|
||||||
dragleave(evt) {
|
console.error('Book card ref not found', index, this.$refs)
|
||||||
this.isDragOver = false
|
} else {
|
||||||
this.preventDefaults(evt)
|
ref.setUploadStatus(status)
|
||||||
},
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
clickSelectAudioFiles() {
|
uploadBook(book) {
|
||||||
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
|
|
||||||
|
|
||||||
var form = new FormData()
|
var form = new FormData()
|
||||||
form.set('title', this.title)
|
form.set('title', book.title)
|
||||||
form.set('author', this.author)
|
form.set('author', book.author)
|
||||||
form.set('series', this.series)
|
form.set('series', book.series)
|
||||||
form.set('library', this.selectedLibraryId)
|
form.set('library', this.selectedLibraryId)
|
||||||
form.set('folder', this.selectedFolderId)
|
form.set('folder', this.selectedFolderId)
|
||||||
|
|
||||||
var index = 0
|
var index = 0
|
||||||
var files = this.validAudioFiles.concat(this.validImageFiles)
|
book.files.forEach((file) => {
|
||||||
files.forEach((file) => {
|
|
||||||
form.set(`${index++}`, file)
|
form.set(`${index++}`, file)
|
||||||
})
|
})
|
||||||
|
|
||||||
this.$axios
|
return this.$axios
|
||||||
.$post('/upload', form)
|
.$post('/upload', form)
|
||||||
.then((data) => {
|
.then(() => true)
|
||||||
this.$toast.success('Audiobook Uploaded Successfully')
|
|
||||||
this.reset()
|
|
||||||
this.processing = false
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
var errorMessage = error.response && error.response.data ? error.response.data : 'Oops, something went wrong...'
|
var errorMessage = error.response && error.response.data ? error.response.data : 'Oops, something went wrong...'
|
||||||
this.$toast.error(errorMessage)
|
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() {
|
mounted() {
|
||||||
this.selectedLibraryId = this.$store.state.libraries.currentLibraryId
|
this.selectedLibraryId = this.$store.state.libraries.currentLibraryId
|
||||||
this.setDefaultFolder()
|
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>
|
</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 = {
|
const DownloadStatus = {
|
||||||
PENDING: 0,
|
PENDING: 0,
|
||||||
READY: 1,
|
READY: 1,
|
||||||
|
|
@ -5,11 +14,6 @@ const DownloadStatus = {
|
||||||
FAILED: 3
|
FAILED: 3
|
||||||
}
|
}
|
||||||
|
|
||||||
const CoverDestination = {
|
|
||||||
METADATA: 0,
|
|
||||||
AUDIOBOOK: 1
|
|
||||||
}
|
|
||||||
|
|
||||||
const BookCoverAspectRatio = {
|
const BookCoverAspectRatio = {
|
||||||
STANDARD: 0,
|
STANDARD: 0,
|
||||||
SQUARE: 1
|
SQUARE: 1
|
||||||
|
|
@ -21,8 +25,8 @@ const BookshelfView = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const Constants = {
|
const Constants = {
|
||||||
|
SupportedFileTypes,
|
||||||
DownloadStatus,
|
DownloadStatus,
|
||||||
CoverDestination,
|
|
||||||
BookCoverAspectRatio,
|
BookCoverAspectRatio,
|
||||||
BookshelfView
|
BookshelfView
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ Vue.prototype.$addDaysToToday = (daysToAdd) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
Vue.prototype.$bytesPretty = (bytes, decimals = 2) => {
|
Vue.prototype.$bytesPretty = (bytes, decimals = 2) => {
|
||||||
if (bytes === 0) {
|
if (isNaN(bytes) || !bytes === 0) {
|
||||||
return '0 Bytes'
|
return '0 Bytes'
|
||||||
}
|
}
|
||||||
const k = 1024
|
const k = 1024
|
||||||
|
|
@ -61,13 +61,20 @@ Vue.prototype.$secondsToTimestamp = (seconds) => {
|
||||||
return `${_hours}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}`
|
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)
|
var minutes = Math.floor(seconds / 60)
|
||||||
seconds -= minutes * 60
|
seconds -= minutes * 60
|
||||||
var hours = Math.floor(minutes / 60)
|
var hours = Math.floor(minutes / 60)
|
||||||
minutes -= hours * 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 = []
|
var strs = []
|
||||||
if (days) strs.push(`${days}d`)
|
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,
|
showUserCollectionsModal: false,
|
||||||
showEditCollectionModal: false,
|
showEditCollectionModal: false,
|
||||||
selectedCollection: null,
|
selectedCollection: null,
|
||||||
showBookshelfTextureModal: false
|
showBookshelfTextureModal: false,
|
||||||
|
isCasting: false, // Actively casting
|
||||||
|
isChromecastInitialized: false // Script loaded
|
||||||
})
|
})
|
||||||
|
|
||||||
export const getters = {}
|
export const getters = {}
|
||||||
|
|
@ -33,5 +35,11 @@ export const mutations = {
|
||||||
},
|
},
|
||||||
setShowBookshelfTextureModal(state, val) {
|
setShowBookshelfTextureModal(state, val) {
|
||||||
state.showBookshelfTextureModal = 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,
|
showEReader: false,
|
||||||
selectedAudiobook: null,
|
selectedAudiobook: null,
|
||||||
selectedAudiobookFile: null,
|
selectedAudiobookFile: null,
|
||||||
playOnLoad: false,
|
|
||||||
developerMode: false,
|
developerMode: false,
|
||||||
selectedAudiobooks: [],
|
selectedAudiobooks: [],
|
||||||
processingBatch: false,
|
processingBatch: false,
|
||||||
|
|
@ -78,25 +77,8 @@ export const mutations = {
|
||||||
state.versionData = versionData
|
state.versionData = versionData
|
||||||
},
|
},
|
||||||
setStreamAudiobook(state, audiobook) {
|
setStreamAudiobook(state, audiobook) {
|
||||||
state.playOnLoad = true
|
|
||||||
state.streamAudiobook = audiobook
|
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) {
|
showEditModal(state, audiobook) {
|
||||||
state.editModalTab = 'details'
|
state.editModalTab = 'details'
|
||||||
state.selectedAudiobook = audiobook
|
state.selectedAudiobook = audiobook
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,11 @@ export const getters = {
|
||||||
},
|
},
|
||||||
getSortedLibraries: state => () => {
|
getSortedLibraries: state => () => {
|
||||||
return state.libraries.map(lib => ({ ...lib })).sort((a, b) => a.displayOrder - b.displayOrder)
|
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",
|
"name": "audiobookshelf",
|
||||||
"version": "1.6.66",
|
"version": "1.7.1",
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
|
|
@ -2868,12 +2868,6 @@
|
||||||
"@types/node": "*"
|
"@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": {
|
"aborter": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/aborter/-/aborter-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/aborter/-/aborter-1.1.0.tgz",
|
||||||
|
|
@ -2888,23 +2882,6 @@
|
||||||
"negotiator": "0.6.2"
|
"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": {
|
"archiver": {
|
||||||
"version": "5.3.0",
|
"version": "5.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.0.tgz",
|
||||||
|
|
@ -2960,33 +2937,6 @@
|
||||||
"is-primitive": "^3.0.1"
|
"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": {
|
"array-back": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/array-back/-/array-back-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/array-back/-/array-back-3.1.0.tgz",
|
||||||
|
|
@ -3134,12 +3084,6 @@
|
||||||
"responselike": "^2.0.0"
|
"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": {
|
"clone-response": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.2.tgz",
|
||||||
|
|
@ -3148,12 +3092,6 @@
|
||||||
"mimic-response": "^1.0.0"
|
"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": {
|
"command-line-args": {
|
||||||
"version": "5.2.0",
|
"version": "5.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-5.2.0.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
|
"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": {
|
"content-disposition": {
|
||||||
"version": "0.5.3",
|
"version": "0.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.3.tgz",
|
"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": {
|
"defer-to-connect": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
|
||||||
"integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="
|
"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": {
|
"depd": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz",
|
||||||
"integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA="
|
"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": {
|
"dicer": {
|
||||||
"version": "0.3.0",
|
"version": "0.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/dicer/-/dicer-0.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/dicer/-/dicer-0.3.0.tgz",
|
||||||
|
|
@ -3405,16 +3319,6 @@
|
||||||
"base64-arraybuffer": "0.1.4"
|
"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": {
|
"escape-html": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||||
|
|
@ -3583,36 +3487,11 @@
|
||||||
"universalify": "^2.0.0"
|
"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": {
|
"fs.realpath": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||||
"integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
|
"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": {
|
"get-stream": {
|
||||||
"version": "5.2.0",
|
"version": "5.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.6.tgz",
|
||||||
"integrity": "sha512-nTnJ528pbqxYanhpDYsi4Rd8MAeaBA67+RZ10CM1m3bTAVFEDcd5AuA4a6W5YkGZ1iNXHzZz8T6TBKLeBuNriQ=="
|
"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": {
|
"html-entities": {
|
||||||
"version": "2.3.2",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.2.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="
|
"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": {
|
"image-type": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/image-type/-/image-type-4.1.0.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
|
||||||
"integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4="
|
"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": {
|
"ip": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/ip/-/ip-1.1.5.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="
|
"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": {
|
"is-primitive": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-primitive/-/is-primitive-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-primitive/-/is-primitive-3.0.1.tgz",
|
||||||
|
|
@ -4002,40 +3851,6 @@
|
||||||
"brace-expansion": "^1.1.7"
|
"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": {
|
"moment": {
|
||||||
"version": "2.29.1",
|
"version": "2.29.1",
|
||||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
|
"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": {
|
"negotiator": {
|
||||||
"version": "0.6.2",
|
"version": "0.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/node-ffprobe/-/node-ffprobe-3.0.0.tgz",
|
||||||
"integrity": "sha512-2LNTLStz2hw/urwo4xJ00TIOvthgepcl3tF4HB8BWnhJ4nhJ7S08YThapBHkGLYV+GUuY9pML/kX76+dqY2iUg=="
|
"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": {
|
"node-stream-zip": {
|
||||||
"version": "1.15.0",
|
"version": "1.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-stream-zip/-/node-stream-zip-1.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-stream-zip/-/node-stream-zip-1.15.0.tgz",
|
||||||
"integrity": "sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw=="
|
"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": {
|
"normalize-path": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz",
|
||||||
"integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A=="
|
"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": {
|
"object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
|
|
@ -4232,28 +3936,6 @@
|
||||||
"wrappy": "1"
|
"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": {
|
"p-cancelable": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
|
||||||
|
|
@ -4437,18 +4119,6 @@
|
||||||
"unpipe": "1.0.0"
|
"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": {
|
"read-chunk": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/read-chunk/-/read-chunk-3.1.0.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
|
||||||
"integrity": "sha1-G0KmJmoh8HQh0bC1S33BZ7AcATs="
|
"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": {
|
"ripstat": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/ripstat/-/ripstat-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/ripstat/-/ripstat-1.1.1.tgz",
|
||||||
|
|
@ -4603,12 +4264,6 @@
|
||||||
"send": "0.17.1"
|
"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": {
|
"setprototypeof": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz",
|
||||||
|
|
@ -4744,6 +4399,7 @@
|
||||||
"@babel/runtime": "^7.14.0"
|
"@babel/runtime": "^7.14.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
<<<<<<< HEAD
|
||||||
"string-width": {
|
"string-width": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz",
|
||||||
|
|
@ -4791,6 +4447,14 @@
|
||||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||||
"optional": true
|
"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": {
|
"tar-stream": {
|
||||||
|
|
@ -4886,15 +4550,6 @@
|
||||||
"isexe": "^2.0.0"
|
"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": {
|
"with-open-file": {
|
||||||
"version": "0.1.7",
|
"version": "0.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/with-open-file/-/with-open-file-0.1.7.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
|
||||||
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="
|
"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": {
|
"zip-stream": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.0.tgz",
|
||||||
|
|
@ -4950,16 +4599,6 @@
|
||||||
"compress-commons": "^4.1.0",
|
"compress-commons": "^4.1.0",
|
||||||
"readable-stream": "^3.6.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",
|
"name": "audiobookshelf",
|
||||||
"version": "1.6.66",
|
"version": "1.7.1",
|
||||||
"description": "Self-hosted audiobook server for managing and playing audiobooks",
|
"description": "Self-hosted audiobook server for managing and playing audiobooks",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
@ -33,7 +33,6 @@
|
||||||
"command-line-args": "^5.2.0",
|
"command-line-args": "^5.2.0",
|
||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.6",
|
||||||
"date-and-time": "^2.0.1",
|
"date-and-time": "^2.0.1",
|
||||||
"epub": "^1.2.1",
|
|
||||||
"express": "^4.17.1",
|
"express": "^4.17.1",
|
||||||
"express-fileupload": "^1.2.1",
|
"express-fileupload": "^1.2.1",
|
||||||
"express-rate-limit": "^5.3.0",
|
"express-rate-limit": "^5.3.0",
|
||||||
|
|
@ -58,5 +57,6 @@
|
||||||
"string-strip-html": "^8.3.0",
|
"string-strip-html": "^8.3.0",
|
||||||
"watcher": "^1.2.0",
|
"watcher": "^1.2.0",
|
||||||
"xml2js": "^0.4.23"
|
"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
|
* Multi-user support w/ custom permissions
|
||||||
* Keeps progress per user and syncs across devices
|
* Keeps progress per user and syncs across devices
|
||||||
* Auto-detects library updates, no need to re-scan
|
* 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
|
* 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 />
|
<br />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,10 @@ const AuthorFinder = require('./AuthorFinder')
|
||||||
const FileSystemController = require('./controllers/FileSystemController')
|
const FileSystemController = require('./controllers/FileSystemController')
|
||||||
|
|
||||||
class ApiController {
|
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.db = db
|
||||||
this.auth = auth
|
this.auth = auth
|
||||||
|
this.scanner = scanner
|
||||||
this.streamManager = streamManager
|
this.streamManager = streamManager
|
||||||
this.rssFeeds = rssFeeds
|
this.rssFeeds = rssFeeds
|
||||||
this.downloadManager = downloadManager
|
this.downloadManager = downloadManager
|
||||||
|
|
@ -30,10 +31,9 @@ class ApiController {
|
||||||
this.cacheManager = cacheManager
|
this.cacheManager = cacheManager
|
||||||
this.emitter = emitter
|
this.emitter = emitter
|
||||||
this.clientEmitter = clientEmitter
|
this.clientEmitter = clientEmitter
|
||||||
this.MetadataPath = MetadataPath
|
|
||||||
|
|
||||||
this.bookFinder = new BookFinder()
|
this.bookFinder = new BookFinder()
|
||||||
this.authorFinder = new AuthorFinder(this.MetadataPath)
|
this.authorFinder = new AuthorFinder()
|
||||||
|
|
||||||
this.router = express()
|
this.router = express()
|
||||||
this.init()
|
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/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/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.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))
|
this.router.post('/libraries/order', LibraryController.reorder.bind(this))
|
||||||
|
|
||||||
// TEMP: Support old syntax for mobile app
|
// 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.post('/books/:id/cover', BookController.uploadCover.bind(this))
|
||||||
this.router.get('/books/:id/cover', BookController.getCover.bind(this))
|
this.router.get('/books/:id/cover', BookController.getCover.bind(this))
|
||||||
this.router.patch('/books/:id/coverfile', BookController.updateCoverFromFile.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
|
// TEMP: Support old syntax for mobile app
|
||||||
this.router.get('/audiobooks', BookController.findAll.bind(this)) // Old route should pass library id
|
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('/syncStream', this.syncStream.bind(this))
|
||||||
this.router.post('/syncLocal', this.syncLocal.bind(this))
|
this.router.post('/syncLocal', this.syncLocal.bind(this))
|
||||||
|
|
||||||
|
this.router.post('/streams/:id/close', this.closeStream.bind(this))
|
||||||
}
|
}
|
||||||
|
|
||||||
async findBooks(req, res) {
|
async findBooks(req, res) {
|
||||||
|
|
@ -283,7 +287,7 @@ class ApiController {
|
||||||
this.backupManager.updateCronSchedule()
|
this.backupManager.updateCronSchedule()
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.db.updateEntity('settings', this.db.serverSettings)
|
await this.db.updateServerSettings()
|
||||||
}
|
}
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -416,6 +420,7 @@ class ApiController {
|
||||||
data: audiobookProgress || null
|
data: audiobookProgress || null
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
|
|
@ -537,5 +542,12 @@ class ApiController {
|
||||||
await this.cacheManager.purgeAll()
|
await this.cacheManager.purgeAll()
|
||||||
res.sendStatus(200)
|
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
|
module.exports = ApiController
|
||||||
|
|
@ -30,7 +30,7 @@ class Auth {
|
||||||
cors(req, res, next) {
|
cors(req, res, next) {
|
||||||
res.header('Access-Control-Allow-Origin', '*')
|
res.header('Access-Control-Allow-Origin', '*')
|
||||||
res.header("Access-Control-Allow-Methods", 'GET, POST, PATCH, PUT, DELETE, OPTIONS')
|
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)
|
res.header('Access-Control-Allow-Credentials', true)
|
||||||
if (req.method === 'OPTIONS') {
|
if (req.method === 'OPTIONS') {
|
||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,8 @@ const Audnexus = require('./providers/Audnexus')
|
||||||
const { downloadFile } = require('./utils/fileUtils')
|
const { downloadFile } = require('./utils/fileUtils')
|
||||||
|
|
||||||
class AuthorFinder {
|
class AuthorFinder {
|
||||||
constructor(MetadataPath) {
|
constructor() {
|
||||||
this.MetadataPath = MetadataPath
|
this.AuthorPath = Path.join(global.MetadataPath, 'authors')
|
||||||
this.AuthorPath = Path.join(MetadataPath, 'authors')
|
|
||||||
|
|
||||||
this.audnexus = new Audnexus()
|
this.audnexus = new Audnexus()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,9 @@ const Logger = require('./Logger')
|
||||||
const Backup = require('./objects/Backup')
|
const Backup = require('./objects/Backup')
|
||||||
|
|
||||||
class BackupManager {
|
class BackupManager {
|
||||||
constructor(MetadataPath, Uid, Gid, db) {
|
constructor(Uid, Gid, db) {
|
||||||
this.MetadataPath = MetadataPath
|
this.BackupPath = Path.join(global.MetadataPath, 'backups')
|
||||||
this.BackupPath = Path.join(this.MetadataPath, 'backups')
|
this.MetadataBooksPath = Path.join(global.MetadataPath, 'books')
|
||||||
|
|
||||||
this.Uid = Uid
|
this.Uid = Uid
|
||||||
this.Gid = Gid
|
this.Gid = Gid
|
||||||
|
|
@ -142,10 +142,9 @@ class BackupManager {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const zip = new StreamZip.async({ file: backup.fullPath })
|
const zip = new StreamZip.async({ file: backup.fullPath })
|
||||||
await zip.extract('config/', this.db.ConfigPath)
|
await zip.extract('config/', global.ConfigPath)
|
||||||
if (backup.backupMetadataCovers) {
|
if (backup.backupMetadataCovers) {
|
||||||
var metadataBooksPath = Path.join(this.MetadataPath, 'books')
|
await zip.extract('metadata-books/', this.MetadataBooksPath)
|
||||||
await zip.extract('metadata-books/', metadataBooksPath)
|
|
||||||
}
|
}
|
||||||
await this.db.reinit()
|
await this.db.reinit()
|
||||||
socket.emit('apply_backup_complete', true)
|
socket.emit('apply_backup_complete', true)
|
||||||
|
|
@ -157,7 +156,7 @@ class BackupManager {
|
||||||
var lastBackup = this.backups.shift()
|
var lastBackup = this.backups.shift()
|
||||||
|
|
||||||
const zip = new StreamZip.async({ file: lastBackup.fullPath })
|
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')
|
console.log('Set Last Backup')
|
||||||
await this.db.reinit()
|
await this.db.reinit()
|
||||||
}
|
}
|
||||||
|
|
@ -196,7 +195,7 @@ class BackupManager {
|
||||||
async runBackup() {
|
async runBackup() {
|
||||||
// Check if Metadata Path is inside Config Path (otherwise there will be an infinite loop as the archiver tries to zip itself)
|
// 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`)
|
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()
|
var newBackup = new Backup()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,8 @@ const Logger = require('./Logger')
|
||||||
const { resizeImage } = require('./utils/ffmpegHelpers')
|
const { resizeImage } = require('./utils/ffmpegHelpers')
|
||||||
|
|
||||||
class CacheManager {
|
class CacheManager {
|
||||||
constructor(MetadataPath) {
|
constructor() {
|
||||||
this.MetadataPath = MetadataPath
|
this.CachePath = Path.join(global.MetadataPath, 'cache')
|
||||||
this.CachePath = Path.join(this.MetadataPath, 'cache')
|
|
||||||
this.CoverCachePath = Path.join(this.CachePath, 'covers')
|
this.CoverCachePath = Path.join(this.CachePath, 'covers')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,21 +6,18 @@ const readChunk = require('read-chunk')
|
||||||
const imageType = require('image-type')
|
const imageType = require('image-type')
|
||||||
|
|
||||||
const globals = require('./utils/globals')
|
const globals = require('./utils/globals')
|
||||||
const { CoverDestination } = require('./utils/constants')
|
|
||||||
const { downloadFile } = require('./utils/fileUtils')
|
const { downloadFile } = require('./utils/fileUtils')
|
||||||
|
|
||||||
class CoverController {
|
class CoverController {
|
||||||
constructor(db, cacheManager, MetadataPath, AudiobookPath) {
|
constructor(db, cacheManager) {
|
||||||
this.db = db
|
this.db = db
|
||||||
this.cacheManager = cacheManager
|
this.cacheManager = cacheManager
|
||||||
|
|
||||||
this.MetadataPath = MetadataPath.replace(/\\/g, '/')
|
this.BookMetadataPath = Path.posix.join(global.MetadataPath, 'books')
|
||||||
this.BookMetadataPath = Path.posix.join(this.MetadataPath, 'books')
|
|
||||||
this.AudiobookPath = AudiobookPath
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getCoverDirectory(audiobook) {
|
getCoverDirectory(audiobook) {
|
||||||
if (this.db.serverSettings.coverDestination === CoverDestination.AUDIOBOOK) {
|
if (this.db.serverSettings.storeCoverWithBook) {
|
||||||
return {
|
return {
|
||||||
fullPath: audiobook.fullPath,
|
fullPath: audiobook.fullPath,
|
||||||
relPath: '/s/book/' + audiobook.id
|
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')
|
const SSOSettings = require('./objects/SSOSettings')
|
||||||
|
|
||||||
class Db {
|
class Db {
|
||||||
constructor(ConfigPath, AudiobookPath) {
|
constructor() {
|
||||||
this.ConfigPath = ConfigPath
|
this.AudiobooksPath = Path.join(global.ConfigPath, 'audiobooks')
|
||||||
this.AudiobookPath = AudiobookPath
|
this.UsersPath = Path.join(global.ConfigPath, 'users')
|
||||||
|
this.SessionsPath = Path.join(global.ConfigPath, 'sessions')
|
||||||
this.AudiobooksPath = Path.join(ConfigPath, 'audiobooks')
|
this.LibrariesPath = Path.join(global.ConfigPath, 'libraries')
|
||||||
this.UsersPath = Path.join(ConfigPath, 'users')
|
this.SettingsPath = Path.join(global.ConfigPath, 'settings')
|
||||||
this.SessionsPath = Path.join(ConfigPath, 'sessions')
|
this.CollectionsPath = Path.join(global.ConfigPath, 'collections')
|
||||||
this.LibrariesPath = Path.join(ConfigPath, 'libraries')
|
this.AuthorsPath = Path.join(global.ConfigPath, 'authors')
|
||||||
this.SettingsPath = Path.join(ConfigPath, 'settings')
|
this.SSOPath = Path.join(global.ConfigPath, 'sso')
|
||||||
this.SSOPath = Path.join(ConfigPath, 'sso')
|
|
||||||
this.CollectionsPath = Path.join(ConfigPath, 'collections')
|
|
||||||
this.AuthorsPath = Path.join(ConfigPath, 'authors')
|
|
||||||
|
|
||||||
this.audiobooksDb = new njodb.Database(this.AudiobooksPath)
|
this.audiobooksDb = new njodb.Database(this.AudiobooksPath)
|
||||||
this.usersDb = new njodb.Database(this.UsersPath)
|
this.usersDb = new njodb.Database(this.UsersPath)
|
||||||
|
|
@ -91,7 +88,7 @@ class Db {
|
||||||
name: 'Main',
|
name: 'Main',
|
||||||
folder: { // Generates default folder
|
folder: { // Generates default folder
|
||||||
id: 'audiobooks',
|
id: 'audiobooks',
|
||||||
fullPath: this.AudiobookPath,
|
fullPath: global.AudiobookPath,
|
||||||
libraryId: 'main'
|
libraryId: 'main'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -136,6 +133,7 @@ class Db {
|
||||||
this.SSOSettings = new SSOSettings()
|
this.SSOSettings = new SSOSettings()
|
||||||
await this.insertEntity('settings', this.SSOSettings)
|
await this.insertEntity('settings', this.SSOSettings)
|
||||||
}
|
}
|
||||||
|
global.ServerSettings = this.serverSettings.toJSON()
|
||||||
}
|
}
|
||||||
|
|
||||||
async load() {
|
async load() {
|
||||||
|
|
@ -184,11 +182,19 @@ class Db {
|
||||||
// Update server version in server settings
|
// Update server version in server settings
|
||||||
if (this.previousVersion) {
|
if (this.previousVersion) {
|
||||||
this.serverSettings.version = version
|
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) => {
|
return this.audiobooksDb.update((record) => record.id === audiobook.id, () => audiobook).then((results) => {
|
||||||
Logger.debug(`[DB] Audiobook updated ${results.updated}`)
|
Logger.debug(`[DB] Audiobook updated ${results.updated}`)
|
||||||
return true
|
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) {
|
updateUserStream(userId, streamId) {
|
||||||
return this.usersDb.update((record) => record.id === userId, (user) => {
|
return this.usersDb.update((record) => record.id === userId, (user) => {
|
||||||
user.stream = streamId
|
user.stream = streamId
|
||||||
|
|
@ -215,6 +243,11 @@ class Db {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateServerSettings() {
|
||||||
|
global.ServerSettings = this.serverSettings.toJSON()
|
||||||
|
return this.updateEntity('settings', this.serverSettings)
|
||||||
|
}
|
||||||
|
|
||||||
insertEntities(entityName, entities) {
|
insertEntities(entityName, entities) {
|
||||||
var entityDb = this.getEntityDb(entityName)
|
var entityDb = this.getEntityDb(entityName)
|
||||||
return entityDb.insert(entities).then((results) => {
|
return entityDb.insert(entities).then((results) => {
|
||||||
|
|
@ -322,5 +355,12 @@ class Db {
|
||||||
return []
|
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
|
module.exports = Db
|
||||||
|
|
|
||||||
|
|
@ -11,14 +11,12 @@ const { writeConcatFile, writeMetadataFile } = require('./utils/ffmpegHelpers')
|
||||||
const { getFileSize } = require('./utils/fileUtils')
|
const { getFileSize } = require('./utils/fileUtils')
|
||||||
const TAG = 'DownloadManager'
|
const TAG = 'DownloadManager'
|
||||||
class DownloadManager {
|
class DownloadManager {
|
||||||
constructor(db, MetadataPath, AudiobookPath, Uid, Gid) {
|
constructor(db, Uid, Gid) {
|
||||||
this.Uid = Uid
|
this.Uid = Uid
|
||||||
this.Gid = Gid
|
this.Gid = Gid
|
||||||
this.db = db
|
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.pendingDownloads = []
|
||||||
this.downloads = []
|
this.downloads = []
|
||||||
|
|
@ -248,7 +246,7 @@ class DownloadManager {
|
||||||
// Supporting old local file prefix
|
// Supporting old local file prefix
|
||||||
var bookCoverPath = audiobook.book.cover ? audiobook.book.cover.replace(/\\/g, '/') : null
|
var bookCoverPath = audiobook.book.cover ? audiobook.book.cover.replace(/\\/g, '/') : null
|
||||||
if (!_cover && bookCoverPath && bookCoverPath.startsWith('/local')) {
|
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)
|
Logger.debug('Local cover url', _cover)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,11 @@ const fs = require('fs-extra')
|
||||||
const Logger = require('./Logger')
|
const Logger = require('./Logger')
|
||||||
|
|
||||||
class HlsController {
|
class HlsController {
|
||||||
constructor(db, auth, streamManager, emitter, StreamsPath) {
|
constructor(db, auth, streamManager, emitter) {
|
||||||
this.db = db
|
this.db = db
|
||||||
this.auth = auth
|
this.auth = auth
|
||||||
this.streamManager = streamManager
|
this.streamManager = streamManager
|
||||||
this.emitter = emitter
|
this.emitter = emitter
|
||||||
this.StreamsPath = StreamsPath
|
|
||||||
|
|
||||||
this.router = express()
|
this.router = express()
|
||||||
this.init()
|
this.init()
|
||||||
|
|
@ -27,7 +26,7 @@ class HlsController {
|
||||||
|
|
||||||
async streamFileRequest(req, res) {
|
async streamFileRequest(req, res) {
|
||||||
var streamId = req.params.stream
|
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
|
// development test stream - ignore
|
||||||
if (streamId === 'test') {
|
if (streamId === 'test') {
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,10 @@ const Logger = require('./Logger')
|
||||||
const TAG = '[LogManager]'
|
const TAG = '[LogManager]'
|
||||||
|
|
||||||
class LogManager {
|
class LogManager {
|
||||||
constructor(MetadataPath, db) {
|
constructor(db) {
|
||||||
this.db = 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.dailyLogDirPath = Path.join(this.logDirPath, 'daily')
|
||||||
|
|
||||||
this.currentDailyLog = null
|
this.currentDailyLog = null
|
||||||
|
|
|
||||||
|
|
@ -39,27 +39,35 @@ class Server {
|
||||||
this.Uid = isNaN(UID) ? 0 : Number(UID)
|
this.Uid = isNaN(UID) ? 0 : Number(UID)
|
||||||
this.Gid = isNaN(GID) ? 0 : Number(GID)
|
this.Gid = isNaN(GID) ? 0 : Number(GID)
|
||||||
this.Host = '0.0.0.0'
|
this.Host = '0.0.0.0'
|
||||||
this.ConfigPath = Path.normalize(CONFIG_PATH)
|
global.Uid = this.Uid
|
||||||
this.AudiobookPath = Path.normalize(AUDIOBOOK_PATH)
|
global.Gid = this.Gid
|
||||||
this.MetadataPath = Path.normalize(METADATA_PATH)
|
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(global.ConfigPath, 0o774)
|
||||||
fs.ensureDirSync(METADATA_PATH, 0o774)
|
fs.ensureDirSync(global.MetadataPath, 0o774)
|
||||||
fs.ensureDirSync(AUDIOBOOK_PATH, 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.auth = new Auth(this.db)
|
||||||
this.backupManager = new BackupManager(this.MetadataPath, this.Uid, this.Gid, this.db)
|
this.backupManager = new BackupManager(this.Uid, this.Gid, this.db)
|
||||||
this.logManager = new LogManager(this.MetadataPath, this.db)
|
this.logManager = new LogManager(this.db)
|
||||||
this.cacheManager = new CacheManager(this.MetadataPath)
|
this.cacheManager = new CacheManager()
|
||||||
this.watcher = new Watcher(this.AudiobookPath)
|
this.watcher = new Watcher()
|
||||||
this.coverController = new CoverController(this.db, this.cacheManager, this.MetadataPath, this.AudiobookPath)
|
this.coverController = new CoverController(this.db, this.cacheManager)
|
||||||
this.scanner = new Scanner(this.AudiobookPath, this.MetadataPath, this.db, this.coverController, this.emitter.bind(this))
|
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.rssFeeds = new RssFeeds(this.Port, this.db)
|
||||||
this.downloadManager = new DownloadManager(this.db, this.MetadataPath, this.AudiobookPath, this.Uid, this.Gid)
|
this.downloadManager = new DownloadManager(this.db, 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.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)
|
this.hlsController = new HlsController(this.db, this.auth, this.streamManager, this.emitter.bind(this), this.streamManager.StreamsPath)
|
||||||
|
|
||||||
Logger.logManager = this.logManager
|
Logger.logManager = this.logManager
|
||||||
|
|
@ -101,7 +109,7 @@ class Server {
|
||||||
clientEmitter(userId, ev, data) {
|
clientEmitter(userId, ev, data) {
|
||||||
var clients = this.getClientsForUser(userId)
|
var clients = this.getClientsForUser(userId)
|
||||||
if (!clients.length) {
|
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) => {
|
clients.forEach((client) => {
|
||||||
if (client.socket) {
|
if (client.socket) {
|
||||||
|
|
@ -133,10 +141,18 @@ class Server {
|
||||||
Logger.info(`[Server] Running scan for duplicate book IDs`)
|
Logger.info(`[Server] Running scan for duplicate book IDs`)
|
||||||
await this.scanner.fixDuplicateIds()
|
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)
|
if (this.db.serverSettings.scannerDisableWatcher) {
|
||||||
this.watcher.on('files', this.filesChanged.bind(this))
|
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()
|
this.passportInit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -194,10 +210,10 @@ class Server {
|
||||||
app.use(express.static(distPath))
|
app.use(express.static(distPath))
|
||||||
|
|
||||||
// Old static path for covers
|
// 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
|
// 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
|
// Downloads folder static path
|
||||||
app.use('/downloads', this.authMiddleware.bind(this), express.static(this.downloadManager.downloadDirPath))
|
app.use('/downloads', this.authMiddleware.bind(this), express.static(this.downloadManager.downloadDirPath))
|
||||||
|
|
@ -314,7 +330,6 @@ class Server {
|
||||||
// Streaming
|
// Streaming
|
||||||
socket.on('open_stream', (audiobookId) => this.streamManager.openStreamSocketRequest(socket, audiobookId))
|
socket.on('open_stream', (audiobookId) => this.streamManager.openStreamSocketRequest(socket, audiobookId))
|
||||||
socket.on('close_stream', () => this.streamManager.closeStreamRequest(socket))
|
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('stream_sync', (syncData) => this.streamManager.streamSync(socket, syncData))
|
||||||
|
|
||||||
socket.on('progress_update', (payload) => this.audiobookProgressUpdate(socket, payload))
|
socket.on('progress_update', (payload) => this.audiobookProgressUpdate(socket, payload))
|
||||||
|
|
@ -399,7 +414,7 @@ class Server {
|
||||||
|
|
||||||
// Remove unused /metadata/books/{id} folders
|
// Remove unused /metadata/books/{id} folders
|
||||||
async purgeMetadata() {
|
async purgeMetadata() {
|
||||||
var booksMetadata = Path.join(this.MetadataPath, 'books')
|
var booksMetadata = Path.join(global.MetadataPath, 'books')
|
||||||
var booksMetadataExists = await fs.pathExists(booksMetadata)
|
var booksMetadataExists = await fs.pathExists(booksMetadata)
|
||||||
if (!booksMetadataExists) return
|
if (!booksMetadataExists) return
|
||||||
var foldersInBooksMetadata = await fs.readdir(booksMetadata)
|
var foldersInBooksMetadata = await fs.readdir(booksMetadata)
|
||||||
|
|
@ -458,25 +473,27 @@ class Server {
|
||||||
|
|
||||||
var library = this.db.libraries.find(lib => lib.id === libraryId)
|
var library = this.db.libraries.find(lib => lib.id === libraryId)
|
||||||
if (!library) {
|
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)
|
var folder = library.folders.find(fold => fold.id === folderId)
|
||||||
if (!folder) {
|
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) {
|
if (!files.length || !title) {
|
||||||
return res.status(500).error(`Invalid post data`)
|
return res.status(500).send(`Invalid post data`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// For setting permissions recursively
|
// For setting permissions recursively
|
||||||
var firstDirPath = Path.join(folder.fullPath, author)
|
var firstDirPath = Path.join(folder.fullPath, author)
|
||||||
|
|
||||||
var outputDirectory = ''
|
var outputDirectory = ''
|
||||||
if (series && series.length && series !== 'null') {
|
if (series && author) {
|
||||||
outputDirectory = Path.join(folder.fullPath, author, series, title)
|
outputDirectory = Path.join(folder.fullPath, author, series, title)
|
||||||
} else {
|
} else if (author) {
|
||||||
outputDirectory = Path.join(folder.fullPath, author, title)
|
outputDirectory = Path.join(folder.fullPath, author, title)
|
||||||
|
} else {
|
||||||
|
outputDirectory = Path.join(folder.fullPath, title)
|
||||||
}
|
}
|
||||||
|
|
||||||
var exists = await fs.pathExists(outputDirectory)
|
var exists = await fs.pathExists(outputDirectory)
|
||||||
|
|
@ -672,9 +689,9 @@ class Server {
|
||||||
|
|
||||||
const initialPayload = {
|
const initialPayload = {
|
||||||
serverSettings: this.serverSettings.toJSON(),
|
serverSettings: this.serverSettings.toJSON(),
|
||||||
audiobookPath: this.AudiobookPath,
|
audiobookPath: global.AudiobookPath,
|
||||||
metadataPath: this.MetadataPath,
|
metadataPath: global.MetadataPath,
|
||||||
configPath: this.ConfigPath,
|
configPath: global.ConfigPath,
|
||||||
user: client.user.toJSONForBrowser(),
|
user: client.user.toJSONForBrowser(),
|
||||||
stream: client.stream || null,
|
stream: client.stream || null,
|
||||||
librariesScanning: this.scanner.librariesScanning,
|
librariesScanning: this.scanner.librariesScanning,
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,14 @@ const fs = require('fs-extra')
|
||||||
const Path = require('path')
|
const Path = require('path')
|
||||||
|
|
||||||
class StreamManager {
|
class StreamManager {
|
||||||
constructor(db, MetadataPath, emitter, clientEmitter) {
|
constructor(db, emitter, clientEmitter) {
|
||||||
this.db = db
|
this.db = db
|
||||||
|
|
||||||
this.emitter = emitter
|
this.emitter = emitter
|
||||||
this.clientEmitter = clientEmitter
|
this.clientEmitter = clientEmitter
|
||||||
|
|
||||||
this.MetadataPath = MetadataPath
|
|
||||||
this.streams = []
|
this.streams = []
|
||||||
this.StreamsPath = Path.join(this.MetadataPath, 'streams')
|
this.StreamsPath = Path.join(global.MetadataPath, 'streams')
|
||||||
}
|
}
|
||||||
|
|
||||||
get audiobooks() {
|
get audiobooks() {
|
||||||
|
|
@ -68,12 +67,12 @@ class StreamManager {
|
||||||
|
|
||||||
async tempCheckStrayStreams() {
|
async tempCheckStrayStreams() {
|
||||||
try {
|
try {
|
||||||
var dirs = await fs.readdir(this.MetadataPath)
|
var dirs = await fs.readdir(global.MetadataPath)
|
||||||
if (!dirs || !dirs.length) return true
|
if (!dirs || !dirs.length) return true
|
||||||
|
|
||||||
await Promise.all(dirs.map(async (dirname) => {
|
await Promise.all(dirs.map(async (dirname) => {
|
||||||
if (dirname !== 'streams' && dirname !== 'books' && dirname !== 'downloads' && dirname !== 'backups' && dirname !== 'logs' && dirname !== 'cache') {
|
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}`)
|
Logger.warn(`Removing OLD Orphan Stream ${dirname}`)
|
||||||
return fs.remove(fullPath)
|
return fs.remove(fullPath)
|
||||||
}
|
}
|
||||||
|
|
@ -155,6 +154,30 @@ class StreamManager {
|
||||||
this.emitter('user_stream_update', client.user.toJSONForPublic(this.streams))
|
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) {
|
streamSync(socket, syncData) {
|
||||||
const client = socket.sheepClient
|
const client = socket.sheepClient
|
||||||
if (!client || !client.stream) {
|
if (!client || !client.stream) {
|
||||||
|
|
@ -233,35 +256,5 @@ class StreamManager {
|
||||||
|
|
||||||
res.sendStatus(200)
|
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
|
module.exports = StreamManager
|
||||||
|
|
@ -13,6 +13,8 @@ class FolderWatcher extends EventEmitter {
|
||||||
this.pendingFileUpdates = []
|
this.pendingFileUpdates = []
|
||||||
this.pendingDelay = 4000
|
this.pendingDelay = 4000
|
||||||
this.pendingTimeout = null
|
this.pendingTimeout = null
|
||||||
|
|
||||||
|
this.disabled = false
|
||||||
}
|
}
|
||||||
|
|
||||||
get pendingFilePaths() {
|
get pendingFilePaths() {
|
||||||
|
|
@ -66,15 +68,19 @@ class FolderWatcher extends EventEmitter {
|
||||||
|
|
||||||
initWatcher(libraries) {
|
initWatcher(libraries) {
|
||||||
libraries.forEach((lib) => {
|
libraries.forEach((lib) => {
|
||||||
this.buildLibraryWatcher(lib)
|
if (!lib.disableWatcher) {
|
||||||
|
this.buildLibraryWatcher(lib)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
addLibrary(library) {
|
addLibrary(library) {
|
||||||
|
if (this.disabled || library.disableWatcher) return
|
||||||
this.buildLibraryWatcher(library)
|
this.buildLibraryWatcher(library)
|
||||||
}
|
}
|
||||||
|
|
||||||
updateLibrary(library) {
|
updateLibrary(library) {
|
||||||
|
if (this.disabled || library.disableWatcher) return
|
||||||
var libwatcher = this.libraryWatchers.find(lib => lib.id === library.id)
|
var libwatcher = this.libraryWatchers.find(lib => lib.id === library.id)
|
||||||
if (libwatcher) {
|
if (libwatcher) {
|
||||||
libwatcher.name = library.name
|
libwatcher.name = library.name
|
||||||
|
|
@ -90,6 +96,7 @@ class FolderWatcher extends EventEmitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
removeLibrary(library) {
|
removeLibrary(library) {
|
||||||
|
if (this.disabled) return
|
||||||
var libwatcher = this.libraryWatchers.find(lib => lib.id === library.id)
|
var libwatcher = this.libraryWatchers.find(lib => lib.id === library.id)
|
||||||
if (libwatcher) {
|
if (libwatcher) {
|
||||||
Logger.info(`[Watcher] Removed watcher for "${library.name}"`)
|
Logger.info(`[Watcher] Removed watcher for "${library.name}"`)
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,6 @@ class BookController {
|
||||||
}
|
}
|
||||||
|
|
||||||
findOne(req, res) {
|
findOne(req, res) {
|
||||||
if (!req.user) {
|
|
||||||
return res.sendStatus(403)
|
|
||||||
}
|
|
||||||
var audiobook = this.db.audiobooks.find(a => a.id === req.params.id)
|
var audiobook = this.db.audiobooks.find(a => a.id === req.params.id)
|
||||||
if (!audiobook) return res.sendStatus(404)
|
if (!audiobook) return res.sendStatus(404)
|
||||||
|
|
||||||
|
|
@ -48,8 +45,8 @@ class BookController {
|
||||||
var hasUpdates = audiobook.update(req.body)
|
var hasUpdates = audiobook.update(req.body)
|
||||||
if (hasUpdates) {
|
if (hasUpdates) {
|
||||||
await this.db.updateAudiobook(audiobook)
|
await this.db.updateAudiobook(audiobook)
|
||||||
|
this.emitter('audiobook_updated', audiobook.toJSONExpanded())
|
||||||
}
|
}
|
||||||
this.emitter('audiobook_updated', audiobook.toJSONExpanded())
|
|
||||||
res.json(audiobook.toJSON())
|
res.json(audiobook.toJSON())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -259,5 +256,24 @@ class BookController {
|
||||||
}
|
}
|
||||||
return this.cacheManager.handleCoverCache(res, audiobook, options)
|
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()
|
module.exports = new BookController()
|
||||||
|
|
@ -144,10 +144,19 @@ class LibraryController {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.sortBy) {
|
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'
|
var direction = payload.sortDesc ? 'desc' : 'asc'
|
||||||
audiobooks = naturalSort(audiobooks)[direction]((ab) => {
|
audiobooks = naturalSort(audiobooks)[direction]((ab) => {
|
||||||
|
|
||||||
// Supports dot notation strings i.e. "book.title"
|
// 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)
|
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
|
payload.total = series.length
|
||||||
|
|
||||||
if (payload.limit) {
|
if (payload.limit) {
|
||||||
|
|
@ -318,7 +334,7 @@ class LibraryController {
|
||||||
async reorder(req, res) {
|
async reorder(req, res) {
|
||||||
if (!req.user.isRoot) {
|
if (!req.user.isRoot) {
|
||||||
Logger.error('[ApiController] ReorderLibraries invalid user', req.user)
|
Logger.error('[ApiController] ReorderLibraries invalid user', req.user)
|
||||||
return res.sendStatus(401)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
|
|
||||||
var orderdata = req.body
|
var orderdata = req.body
|
||||||
|
|
@ -454,6 +470,15 @@ class LibraryController {
|
||||||
res.json(Object.values(authors))
|
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) {
|
middleware(req, res, next) {
|
||||||
var librariesAccessible = req.user.librariesAccessible || []
|
var librariesAccessible = req.user.librariesAccessible || []
|
||||||
if (librariesAccessible && librariesAccessible.length && !librariesAccessible.includes(req.params.id)) {
|
if (librariesAccessible && librariesAccessible.length && !librariesAccessible.includes(req.params.id)) {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ class AudioFile {
|
||||||
this.ext = null
|
this.ext = null
|
||||||
this.path = null
|
this.path = null
|
||||||
this.fullPath = null
|
this.fullPath = null
|
||||||
|
this.mtimeMs = null
|
||||||
|
this.ctimeMs = null
|
||||||
|
this.birthtimeMs = null
|
||||||
this.addedAt = null
|
this.addedAt = null
|
||||||
|
|
||||||
this.trackNumFromMeta = null
|
this.trackNumFromMeta = null
|
||||||
|
|
@ -51,6 +54,9 @@ class AudioFile {
|
||||||
ext: this.ext,
|
ext: this.ext,
|
||||||
path: this.path,
|
path: this.path,
|
||||||
fullPath: this.fullPath,
|
fullPath: this.fullPath,
|
||||||
|
mtimeMs: this.mtimeMs,
|
||||||
|
ctimeMs: this.ctimeMs,
|
||||||
|
birthtimeMs: this.birthtimeMs,
|
||||||
addedAt: this.addedAt,
|
addedAt: this.addedAt,
|
||||||
trackNumFromMeta: this.trackNumFromMeta,
|
trackNumFromMeta: this.trackNumFromMeta,
|
||||||
discNumFromMeta: this.discNumFromMeta,
|
discNumFromMeta: this.discNumFromMeta,
|
||||||
|
|
@ -82,6 +88,9 @@ class AudioFile {
|
||||||
this.ext = data.ext
|
this.ext = data.ext
|
||||||
this.path = data.path
|
this.path = data.path
|
||||||
this.fullPath = data.fullPath
|
this.fullPath = data.fullPath
|
||||||
|
this.mtimeMs = data.mtimeMs || 0
|
||||||
|
this.ctimeMs = data.ctimeMs || 0
|
||||||
|
this.birthtimeMs = data.birthtimeMs || 0
|
||||||
this.addedAt = data.addedAt
|
this.addedAt = data.addedAt
|
||||||
this.manuallyVerified = !!data.manuallyVerified
|
this.manuallyVerified = !!data.manuallyVerified
|
||||||
this.invalid = !!data.invalid
|
this.invalid = !!data.invalid
|
||||||
|
|
@ -124,6 +133,9 @@ class AudioFile {
|
||||||
this.ext = fileData.ext
|
this.ext = fileData.ext
|
||||||
this.path = fileData.path
|
this.path = fileData.path
|
||||||
this.fullPath = fileData.fullPath
|
this.fullPath = fileData.fullPath
|
||||||
|
this.mtimeMs = fileData.mtimeMs || 0
|
||||||
|
this.ctimeMs = fileData.ctimeMs || 0
|
||||||
|
this.birthtimeMs = fileData.birthtimeMs || 0
|
||||||
this.addedAt = Date.now()
|
this.addedAt = Date.now()
|
||||||
|
|
||||||
this.trackNumFromMeta = fileData.trackNumFromMeta
|
this.trackNumFromMeta = fileData.trackNumFromMeta
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
const Path = require('path')
|
const Path = require('path')
|
||||||
const fs = require('fs-extra')
|
const fs = require('fs-extra')
|
||||||
const { bytesPretty, readTextFile } = require('../utils/fileUtils')
|
const { bytesPretty, readTextFile, getIno } = require('../utils/fileUtils')
|
||||||
const { comparePaths, getIno, getId, elapsedPretty } = require('../utils/index')
|
const { comparePaths, getId, elapsedPretty } = require('../utils/index')
|
||||||
const { parseOpfMetadataXML } = require('../utils/parseOpfMetadata')
|
const { parseOpfMetadataXML } = require('../utils/parseOpfMetadata')
|
||||||
const { extractCoverArt } = require('../utils/ffmpegHelpers')
|
const { extractCoverArt } = require('../utils/ffmpegHelpers')
|
||||||
const nfoGenerator = require('../utils/nfoGenerator')
|
const nfoGenerator = require('../utils/nfoGenerator')
|
||||||
|
const abmetadataGenerator = require('../utils/abmetadataGenerator')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const Book = require('./Book')
|
const Book = require('./Book')
|
||||||
const AudioTrack = require('./AudioTrack')
|
const AudioTrack = require('./AudioTrack')
|
||||||
|
|
@ -21,6 +22,9 @@ class Audiobook {
|
||||||
|
|
||||||
this.path = null
|
this.path = null
|
||||||
this.fullPath = null
|
this.fullPath = null
|
||||||
|
this.mtimeMs = null
|
||||||
|
this.ctimeMs = null
|
||||||
|
this.birthtimeMs = null
|
||||||
this.addedAt = null
|
this.addedAt = null
|
||||||
this.lastUpdate = null
|
this.lastUpdate = null
|
||||||
this.lastScan = null
|
this.lastScan = null
|
||||||
|
|
@ -44,6 +48,9 @@ class Audiobook {
|
||||||
if (audiobook) {
|
if (audiobook) {
|
||||||
this.construct(audiobook)
|
this.construct(audiobook)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Temp flags
|
||||||
|
this.isSavingMetadata = false
|
||||||
}
|
}
|
||||||
|
|
||||||
construct(audiobook) {
|
construct(audiobook) {
|
||||||
|
|
@ -53,6 +60,9 @@ class Audiobook {
|
||||||
this.folderId = audiobook.folderId || 'audiobooks'
|
this.folderId = audiobook.folderId || 'audiobooks'
|
||||||
this.path = audiobook.path
|
this.path = audiobook.path
|
||||||
this.fullPath = audiobook.fullPath
|
this.fullPath = audiobook.fullPath
|
||||||
|
this.mtimeMs = audiobook.mtimeMs || 0
|
||||||
|
this.ctimeMs = audiobook.ctimeMs || 0
|
||||||
|
this.birthtimeMs = audiobook.birthtimeMs || 0
|
||||||
this.addedAt = audiobook.addedAt
|
this.addedAt = audiobook.addedAt
|
||||||
this.lastUpdate = audiobook.lastUpdate || this.addedAt
|
this.lastUpdate = audiobook.lastUpdate || this.addedAt
|
||||||
this.lastScan = audiobook.lastScan || null
|
this.lastScan = audiobook.lastScan || null
|
||||||
|
|
@ -173,11 +183,11 @@ class Audiobook {
|
||||||
ino: this.ino,
|
ino: this.ino,
|
||||||
libraryId: this.libraryId,
|
libraryId: this.libraryId,
|
||||||
folderId: this.folderId,
|
folderId: this.folderId,
|
||||||
title: this.title,
|
|
||||||
author: this.author,
|
|
||||||
cover: this.cover,
|
|
||||||
path: this.path,
|
path: this.path,
|
||||||
fullPath: this.fullPath,
|
fullPath: this.fullPath,
|
||||||
|
mtimeMs: this.mtimeMs,
|
||||||
|
ctimeMs: this.ctimeMs,
|
||||||
|
birthtimeMs: this.birthtimeMs,
|
||||||
addedAt: this.addedAt,
|
addedAt: this.addedAt,
|
||||||
lastUpdate: this.lastUpdate,
|
lastUpdate: this.lastUpdate,
|
||||||
lastScan: this.lastScan,
|
lastScan: this.lastScan,
|
||||||
|
|
@ -204,6 +214,9 @@ class Audiobook {
|
||||||
tags: this.tags,
|
tags: this.tags,
|
||||||
path: this.path,
|
path: this.path,
|
||||||
fullPath: this.fullPath,
|
fullPath: this.fullPath,
|
||||||
|
mtimeMs: this.mtimeMs,
|
||||||
|
ctimeMs: this.ctimeMs,
|
||||||
|
birthtimeMs: this.birthtimeMs,
|
||||||
addedAt: this.addedAt,
|
addedAt: this.addedAt,
|
||||||
lastUpdate: this.lastUpdate,
|
lastUpdate: this.lastUpdate,
|
||||||
duration: this.duration,
|
duration: this.duration,
|
||||||
|
|
@ -227,6 +240,9 @@ class Audiobook {
|
||||||
folderId: this.folderId,
|
folderId: this.folderId,
|
||||||
path: this.path,
|
path: this.path,
|
||||||
fullPath: this.fullPath,
|
fullPath: this.fullPath,
|
||||||
|
mtimeMs: this.mtimeMs,
|
||||||
|
ctimeMs: this.ctimeMs,
|
||||||
|
birthtimeMs: this.birthtimeMs,
|
||||||
addedAt: this.addedAt,
|
addedAt: this.addedAt,
|
||||||
lastUpdate: this.lastUpdate,
|
lastUpdate: this.lastUpdate,
|
||||||
duration: this.duration,
|
duration: this.duration,
|
||||||
|
|
@ -334,6 +350,9 @@ class Audiobook {
|
||||||
|
|
||||||
this.path = data.path
|
this.path = data.path
|
||||||
this.fullPath = data.fullPath
|
this.fullPath = data.fullPath
|
||||||
|
this.mtimeMs = data.mtimeMs || 0
|
||||||
|
this.ctimeMs = data.ctimeMs || 0
|
||||||
|
this.birthtimeMs = data.birthtimeMs || 0
|
||||||
this.addedAt = Date.now()
|
this.addedAt = Date.now()
|
||||||
this.lastUpdate = this.addedAt
|
this.lastUpdate = this.addedAt
|
||||||
|
|
||||||
|
|
@ -425,13 +444,8 @@ class Audiobook {
|
||||||
hasUpdates = true
|
hasUpdates = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.book) {
|
if (payload.book && this.book.update(payload.book)) {
|
||||||
if (!this.book) {
|
hasUpdates = true
|
||||||
this.setBook(payload.book)
|
|
||||||
hasUpdates = true
|
|
||||||
} else if (this.book.update(payload.book)) {
|
|
||||||
hasUpdates = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasUpdates) {
|
if (hasUpdates) {
|
||||||
|
|
@ -526,7 +540,7 @@ class Audiobook {
|
||||||
}
|
}
|
||||||
|
|
||||||
// On scan check other files found with other files saved
|
// 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 hasUpdates = false
|
||||||
|
|
||||||
var currOtherFileNum = this.otherFiles.length
|
var currOtherFileNum = this.otherFiles.length
|
||||||
|
|
@ -535,6 +549,8 @@ class Audiobook {
|
||||||
var alreadyHasDescTxt = otherFilenamesAlreadyInBook.includes('desc.txt')
|
var alreadyHasDescTxt = otherFilenamesAlreadyInBook.includes('desc.txt')
|
||||||
var alreadyHasReaderTxt = otherFilenamesAlreadyInBook.includes('reader.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
|
// Filter out other files no longer in directory
|
||||||
var newOtherFilePaths = newOtherFiles.map(f => f.path)
|
var newOtherFilePaths = newOtherFiles.map(f => f.path)
|
||||||
this.otherFiles = this.otherFiles.filter(f => newOtherFilePaths.includes(f.path))
|
this.otherFiles = this.otherFiles.filter(f => newOtherFilePaths.includes(f.path))
|
||||||
|
|
@ -543,9 +559,9 @@ class Audiobook {
|
||||||
hasUpdates = true
|
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')
|
var descriptionTxt = newOtherFiles.find(file => file.filename === 'desc.txt')
|
||||||
if (descriptionTxt && (!alreadyHasDescTxt || forceRescan)) {
|
if (descriptionTxt && !alreadyHasDescTxt) {
|
||||||
var newDescription = await readTextFile(descriptionTxt.fullPath)
|
var newDescription = await readTextFile(descriptionTxt.fullPath)
|
||||||
if (newDescription) {
|
if (newDescription) {
|
||||||
Logger.debug(`[Audiobook] Sync Other File desc.txt: ${newDescription}`)
|
Logger.debug(`[Audiobook] Sync Other File desc.txt: ${newDescription}`)
|
||||||
|
|
@ -553,9 +569,9 @@ class Audiobook {
|
||||||
hasUpdates = true
|
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')
|
var readerTxt = newOtherFiles.find(file => file.filename === 'reader.txt')
|
||||||
if (readerTxt && (!alreadyHasReaderTxt || forceRescan)) {
|
if (readerTxt && !alreadyHasReaderTxt) {
|
||||||
var newReader = await readTextFile(readerTxt.fullPath)
|
var newReader = await readTextFile(readerTxt.fullPath)
|
||||||
if (newReader) {
|
if (newReader) {
|
||||||
Logger.debug(`[Audiobook] Sync Other File reader.txt: ${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')
|
var metadataOpf = newOtherFiles.find(file => file.ext === '.opf' || file.filename === 'metadata.xml')
|
||||||
if (metadataOpf && (!otherFilenamesAlreadyInBook.includes(metadataOpf.filename) || opfMetadataOverrideDetails)) {
|
if (metadataOpf && (!otherFilenamesAlreadyInBook.includes(metadataOpf.filename) || opfMetadataOverrideDetails)) {
|
||||||
var xmlText = await readTextFile(metadataOpf.fullPath)
|
var xmlText = await readTextFile(metadataOpf.fullPath)
|
||||||
|
|
@ -643,7 +680,7 @@ class Audiobook {
|
||||||
if (bookCoverPath && bookCoverPath.startsWith('/metadata')) {
|
if (bookCoverPath && bookCoverPath.startsWith('/metadata')) {
|
||||||
// Fixing old cover paths
|
// Fixing old cover paths
|
||||||
if (!this.book.coverFullPath) {
|
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}"`)
|
Logger.debug(`[Audiobook] Metadata cover full path set "${this.book.coverFullPath}" for "${this.title}"`)
|
||||||
hasUpdates = true
|
hasUpdates = true
|
||||||
}
|
}
|
||||||
|
|
@ -800,9 +837,10 @@ class Audiobook {
|
||||||
return false
|
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) {
|
async saveDataFromTextFiles(opfMetadataOverrideDetails) {
|
||||||
var bookUpdatePayload = {}
|
var bookUpdatePayload = {}
|
||||||
|
|
||||||
var descriptionText = await this.fetchTextFromTextFile('desc.txt')
|
var descriptionText = await this.fetchTextFromTextFile('desc.txt')
|
||||||
if (descriptionText) {
|
if (descriptionText) {
|
||||||
Logger.debug(`[Audiobook] "${this.title}" found desc.txt updating description with "${descriptionText.slice(0, 20)}..."`)
|
Logger.debug(`[Audiobook] "${this.title}" found desc.txt updating description with "${descriptionText.slice(0, 20)}..."`)
|
||||||
|
|
@ -814,6 +852,22 @@ class Audiobook {
|
||||||
bookUpdatePayload.narrator = readerText
|
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')
|
var metadataOpf = this.otherFiles.find(file => file.isOPFFile || file.filename === 'metadata.xml')
|
||||||
if (metadataOpf) {
|
if (metadataOpf) {
|
||||||
var xmlText = await readTextFile(metadataOpf.fullPath)
|
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) {
|
if (existingFile.path !== fileFound.path) {
|
||||||
existingFile.path = fileFound.path
|
existingFile.path = fileFound.path
|
||||||
existingFile.fullPath = fileFound.fullPath
|
existingFile.fullPath = fileFound.fullPath
|
||||||
|
|
@ -888,6 +936,20 @@ class Audiobook {
|
||||||
hasUpdated = true
|
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) {
|
if (!isAudioFile && existingFile.filetype !== fileFound.filetype) {
|
||||||
existingFile.filetype = fileFound.filetype
|
existingFile.filetype = fileFound.filetype
|
||||||
hasUpdated = true
|
hasUpdated = true
|
||||||
|
|
@ -927,6 +989,14 @@ class Audiobook {
|
||||||
hasUpdated = true
|
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 newAudioFileData = []
|
||||||
var newOtherFileData = []
|
var newOtherFileData = []
|
||||||
var existingAudioFileData = []
|
var existingAudioFileData = []
|
||||||
|
|
@ -1017,14 +1087,14 @@ class Audiobook {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Temp fix for cover is set but coverFullPath is not set
|
// Temp fix for cover is set but coverFullPath is not set
|
||||||
fixFullCoverPath(metadataPath) {
|
fixFullCoverPath() {
|
||||||
if (!this.book.cover) return
|
if (!this.book.cover) return
|
||||||
var bookCoverPath = this.book.cover.replace(/\\/g, '/')
|
var bookCoverPath = this.book.cover.replace(/\\/g, '/')
|
||||||
var newFullCoverPath = null
|
var newFullCoverPath = null
|
||||||
if (bookCoverPath.startsWith('/s/book/')) {
|
if (bookCoverPath.startsWith('/s/book/')) {
|
||||||
newFullCoverPath = Path.join(this.fullPath, bookCoverPath.substr(`/s/book/${this.id}`.length)).replace(/\/\//g, '/')
|
newFullCoverPath = Path.join(this.fullPath, bookCoverPath.substr(`/s/book/${this.id}`.length)).replace(/\/\//g, '/')
|
||||||
} else if (bookCoverPath.startsWith('/metadata/')) {
|
} 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) {
|
if (newFullCoverPath) {
|
||||||
Logger.debug(`[Audiobook] "${this.title}" fixing full cover path "${this.book.cover}" => "${newFullCoverPath}"`)
|
Logger.debug(`[Audiobook] "${this.title}" fixing full cover path "${this.book.cover}" => "${newFullCoverPath}"`)
|
||||||
|
|
@ -1033,5 +1103,26 @@ class Audiobook {
|
||||||
}
|
}
|
||||||
return false
|
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
|
module.exports = Audiobook
|
||||||
|
|
@ -6,6 +6,11 @@ class AudiobookFile {
|
||||||
this.ext = null
|
this.ext = null
|
||||||
this.path = null
|
this.path = null
|
||||||
this.fullPath = null
|
this.fullPath = null
|
||||||
|
this.size = null
|
||||||
|
this.mtimeMs = null
|
||||||
|
this.ctimeMs = null
|
||||||
|
this.birthtimeMs = null
|
||||||
|
|
||||||
this.addedAt = null
|
this.addedAt = null
|
||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
|
|
@ -25,6 +30,10 @@ class AudiobookFile {
|
||||||
ext: this.ext,
|
ext: this.ext,
|
||||||
path: this.path,
|
path: this.path,
|
||||||
fullPath: this.fullPath,
|
fullPath: this.fullPath,
|
||||||
|
size: this.size,
|
||||||
|
mtimeMs: this.mtimeMs,
|
||||||
|
ctimeMs: this.ctimeMs,
|
||||||
|
birthtimeMs: this.birthtimeMs,
|
||||||
addedAt: this.addedAt
|
addedAt: this.addedAt
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -36,6 +45,10 @@ class AudiobookFile {
|
||||||
this.ext = data.ext
|
this.ext = data.ext
|
||||||
this.path = data.path
|
this.path = data.path
|
||||||
this.fullPath = data.fullPath
|
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
|
this.addedAt = data.addedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -46,6 +59,10 @@ class AudiobookFile {
|
||||||
this.ext = data.ext
|
this.ext = data.ext
|
||||||
this.path = data.path
|
this.path = data.path
|
||||||
this.fullPath = data.fullPath
|
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()
|
this.addedAt = Date.now()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,20 @@ class Book {
|
||||||
get _asin() { return this.asin || '' }
|
get _asin() { return this.asin || '' }
|
||||||
get genresCommaSeparated() { return this._genres.join(', ') }
|
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() {
|
get shouldSearchForCover() {
|
||||||
if (this.cover) return false
|
if (this.cover) return false
|
||||||
if (this.authorFL !== this.lastCoverSearchAuthor || this.title !== this.lastCoverSearchTitle || !this.lastCoverSearch) return true
|
if (this.authorFL !== this.lastCoverSearchAuthor || this.title !== this.lastCoverSearchTitle || !this.lastCoverSearch) return true
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ class Library {
|
||||||
this.folders = []
|
this.folders = []
|
||||||
this.displayOrder = 1
|
this.displayOrder = 1
|
||||||
this.icon = 'database'
|
this.icon = 'database'
|
||||||
|
this.provider = 'google'
|
||||||
|
this.disableWatcher = false
|
||||||
|
|
||||||
this.lastScan = 0
|
this.lastScan = 0
|
||||||
|
|
||||||
|
|
@ -29,6 +31,8 @@ class Library {
|
||||||
this.folders = (library.folders || []).map(f => new Folder(f))
|
this.folders = (library.folders || []).map(f => new Folder(f))
|
||||||
this.displayOrder = library.displayOrder || 1
|
this.displayOrder = library.displayOrder || 1
|
||||||
this.icon = library.icon || 'database'
|
this.icon = library.icon || 'database'
|
||||||
|
this.provider = library.provider || 'google'
|
||||||
|
this.disableWatcher = !!library.disableWatcher
|
||||||
|
|
||||||
this.createdAt = library.createdAt
|
this.createdAt = library.createdAt
|
||||||
this.lastUpdate = library.lastUpdate
|
this.lastUpdate = library.lastUpdate
|
||||||
|
|
@ -41,6 +45,8 @@ class Library {
|
||||||
folders: (this.folders || []).map(f => f.toJSON()),
|
folders: (this.folders || []).map(f => f.toJSON()),
|
||||||
displayOrder: this.displayOrder,
|
displayOrder: this.displayOrder,
|
||||||
icon: this.icon,
|
icon: this.icon,
|
||||||
|
provider: this.provider,
|
||||||
|
disableWatcher: this.disableWatcher,
|
||||||
createdAt: this.createdAt,
|
createdAt: this.createdAt,
|
||||||
lastUpdate: this.lastUpdate
|
lastUpdate: this.lastUpdate
|
||||||
}
|
}
|
||||||
|
|
@ -65,6 +71,7 @@ class Library {
|
||||||
}
|
}
|
||||||
this.displayOrder = data.displayOrder || 1
|
this.displayOrder = data.displayOrder || 1
|
||||||
this.icon = data.icon || 'database'
|
this.icon = data.icon || 'database'
|
||||||
|
this.disableWatcher = !!data.disableWatcher
|
||||||
this.createdAt = Date.now()
|
this.createdAt = Date.now()
|
||||||
this.lastUpdate = Date.now()
|
this.lastUpdate = Date.now()
|
||||||
}
|
}
|
||||||
|
|
@ -75,6 +82,14 @@ class Library {
|
||||||
this.name = payload.name
|
this.name = payload.name
|
||||||
hasUpdates = true
|
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) {
|
if (!isNaN(payload.displayOrder) && payload.displayOrder !== this.displayOrder) {
|
||||||
this.displayOrder = Number(payload.displayOrder)
|
this.displayOrder = Number(payload.displayOrder)
|
||||||
hasUpdates = true
|
hasUpdates = true
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
const { CoverDestination, BookCoverAspectRatio, BookshelfView } = require('../utils/constants')
|
const { BookCoverAspectRatio, BookshelfView } = require('../utils/constants')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
|
|
||||||
class ServerSettings {
|
class ServerSettings {
|
||||||
|
|
@ -15,10 +15,11 @@ class ServerSettings {
|
||||||
this.scannerCoverProvider = 'google'
|
this.scannerCoverProvider = 'google'
|
||||||
this.scannerPreferAudioMetadata = false
|
this.scannerPreferAudioMetadata = false
|
||||||
this.scannerPreferOpfMetadata = false
|
this.scannerPreferOpfMetadata = false
|
||||||
|
this.scannerDisableWatcher = false
|
||||||
|
|
||||||
// Metadata
|
// Metadata
|
||||||
this.coverDestination = CoverDestination.METADATA
|
this.storeCoverWithBook = false
|
||||||
this.saveMetadataFile = false
|
this.storeMetadataWithBook = false
|
||||||
|
|
||||||
// Security/Rate limits
|
// Security/Rate limits
|
||||||
this.rateLimitLoginRequests = 10
|
this.rateLimitLoginRequests = 10
|
||||||
|
|
@ -38,6 +39,8 @@ class ServerSettings {
|
||||||
this.coverAspectRatio = BookCoverAspectRatio.SQUARE
|
this.coverAspectRatio = BookCoverAspectRatio.SQUARE
|
||||||
this.bookshelfView = BookshelfView.STANDARD
|
this.bookshelfView = BookshelfView.STANDARD
|
||||||
|
|
||||||
|
this.sortingIgnorePrefix = false
|
||||||
|
this.chromecastEnabled = false
|
||||||
this.logLevel = Logger.logLevel
|
this.logLevel = Logger.logLevel
|
||||||
this.version = null
|
this.version = null
|
||||||
|
|
||||||
|
|
@ -54,9 +57,14 @@ class ServerSettings {
|
||||||
this.scannerParseSubtitle = settings.scannerParseSubtitle
|
this.scannerParseSubtitle = settings.scannerParseSubtitle
|
||||||
this.scannerPreferAudioMetadata = !!settings.scannerPreferAudioMetadata
|
this.scannerPreferAudioMetadata = !!settings.scannerPreferAudioMetadata
|
||||||
this.scannerPreferOpfMetadata = !!settings.scannerPreferOpfMetadata
|
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.rateLimitLoginRequests = !isNaN(settings.rateLimitLoginRequests) ? Number(settings.rateLimitLoginRequests) : 10
|
||||||
this.rateLimitLoginWindow = !isNaN(settings.rateLimitLoginWindow) ? Number(settings.rateLimitLoginWindow) : 10 * 60 * 1000 // 10 Minutes
|
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.coverAspectRatio = !isNaN(settings.coverAspectRatio) ? settings.coverAspectRatio : BookCoverAspectRatio.SQUARE
|
||||||
this.bookshelfView = settings.bookshelfView || BookshelfView.STANDARD
|
this.bookshelfView = settings.bookshelfView || BookshelfView.STANDARD
|
||||||
|
|
||||||
|
this.sortingIgnorePrefix = !!settings.sortingIgnorePrefix
|
||||||
|
this.chromecastEnabled = !!settings.chromecastEnabled
|
||||||
this.logLevel = settings.logLevel || Logger.logLevel
|
this.logLevel = settings.logLevel || Logger.logLevel
|
||||||
this.version = settings.version || null
|
this.version = settings.version || null
|
||||||
|
|
||||||
|
|
@ -88,8 +98,9 @@ class ServerSettings {
|
||||||
scannerParseSubtitle: this.scannerParseSubtitle,
|
scannerParseSubtitle: this.scannerParseSubtitle,
|
||||||
scannerPreferAudioMetadata: this.scannerPreferAudioMetadata,
|
scannerPreferAudioMetadata: this.scannerPreferAudioMetadata,
|
||||||
scannerPreferOpfMetadata: this.scannerPreferOpfMetadata,
|
scannerPreferOpfMetadata: this.scannerPreferOpfMetadata,
|
||||||
coverDestination: this.coverDestination,
|
scannerDisableWatcher: this.scannerDisableWatcher,
|
||||||
saveMetadataFile: !!this.saveMetadataFile,
|
storeCoverWithBook: this.storeCoverWithBook,
|
||||||
|
storeMetadataWithBook: this.storeMetadataWithBook,
|
||||||
rateLimitLoginRequests: this.rateLimitLoginRequests,
|
rateLimitLoginRequests: this.rateLimitLoginRequests,
|
||||||
rateLimitLoginWindow: this.rateLimitLoginWindow,
|
rateLimitLoginWindow: this.rateLimitLoginWindow,
|
||||||
backupSchedule: this.backupSchedule,
|
backupSchedule: this.backupSchedule,
|
||||||
|
|
@ -99,6 +110,8 @@ class ServerSettings {
|
||||||
loggerScannerLogsToKeep: this.loggerScannerLogsToKeep,
|
loggerScannerLogsToKeep: this.loggerScannerLogsToKeep,
|
||||||
coverAspectRatio: this.coverAspectRatio,
|
coverAspectRatio: this.coverAspectRatio,
|
||||||
bookshelfView: this.bookshelfView,
|
bookshelfView: this.bookshelfView,
|
||||||
|
sortingIgnorePrefix: this.sortingIgnorePrefix,
|
||||||
|
chromecastEnabled: this.chromecastEnabled,
|
||||||
logLevel: this.logLevel,
|
logLevel: this.logLevel,
|
||||||
version: this.version
|
version: this.version
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -175,11 +175,6 @@ class Stream extends EventEmitter {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
updateClientCurrentTime(currentTime) {
|
|
||||||
Logger.debug('[Stream] Updated client current time', secondsToTimestamp(currentTime))
|
|
||||||
this.clientCurrentTime = currentTime
|
|
||||||
}
|
|
||||||
|
|
||||||
syncStream({ timeListened, currentTime }) {
|
syncStream({ timeListened, currentTime }) {
|
||||||
var syncLog = ''
|
var syncLog = ''
|
||||||
// Set user current time
|
// Set user current time
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,10 @@ class Audible {
|
||||||
}
|
}
|
||||||
|
|
||||||
getBestImageLink(images) {
|
getBestImageLink(images) {
|
||||||
var keys = Object.keys(images);
|
if (!images) return null
|
||||||
return images[keys[keys.length - 1]];
|
var keys = Object.keys(images)
|
||||||
|
if (!keys.length) return null
|
||||||
|
return images[keys[keys.length - 1]]
|
||||||
}
|
}
|
||||||
|
|
||||||
getPrimarySeries(series, publication_name) {
|
getPrimarySeries(series, publication_name) {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
const AuthorFinder = require('../AuthorFinder')
|
const AuthorFinder = require('../AuthorFinder')
|
||||||
|
|
||||||
class AuthorScanner {
|
class AuthorScanner {
|
||||||
constructor(db, MetadataPath) {
|
constructor(db) {
|
||||||
this.db = db
|
this.db = db
|
||||||
this.MetadataPath = MetadataPath
|
this.authorFinder = new AuthorFinder()
|
||||||
this.authorFinder = new AuthorFinder(MetadataPath)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getUniqueAuthors() {
|
getUniqueAuthors() {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ const { getId, secondsToTimestamp } = require('../utils/index')
|
||||||
class LibraryScan {
|
class LibraryScan {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.id = null
|
this.id = null
|
||||||
|
this.type = null
|
||||||
this.libraryId = null
|
this.libraryId = null
|
||||||
this.libraryName = null
|
this.libraryName = null
|
||||||
this.folders = null
|
this.folders = null
|
||||||
|
|
@ -46,6 +47,7 @@ class LibraryScan {
|
||||||
get getScanEmitData() {
|
get getScanEmitData() {
|
||||||
return {
|
return {
|
||||||
id: this.libraryId,
|
id: this.libraryId,
|
||||||
|
type: this.type,
|
||||||
name: this.libraryName,
|
name: this.libraryName,
|
||||||
results: {
|
results: {
|
||||||
added: this.resultsAdded,
|
added: this.resultsAdded,
|
||||||
|
|
@ -64,10 +66,11 @@ class LibraryScan {
|
||||||
toJSON() {
|
toJSON() {
|
||||||
return {
|
return {
|
||||||
id: this.id,
|
id: this.id,
|
||||||
|
type: this.type,
|
||||||
libraryId: this.libraryId,
|
libraryId: this.libraryId,
|
||||||
libraryName: this.libraryName,
|
libraryName: this.libraryName,
|
||||||
folders: this.folders.map(f => f.toJSON()),
|
folders: this.folders.map(f => f.toJSON()),
|
||||||
scanOptions: this.scanOptions.toJSON(),
|
scanOptions: this.scanOptions ? this.scanOptions.toJSON() : null,
|
||||||
startedAt: this.startedAt,
|
startedAt: this.startedAt,
|
||||||
finishedAt: this.finishedAt,
|
finishedAt: this.finishedAt,
|
||||||
elapsed: this.elapsed,
|
elapsed: this.elapsed,
|
||||||
|
|
@ -77,8 +80,9 @@ class LibraryScan {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setData(library, scanOptions) {
|
setData(library, scanOptions, type = 'scan') {
|
||||||
this.id = getId('lscan')
|
this.id = getId('lscan')
|
||||||
|
this.type = type
|
||||||
this.libraryId = library.id
|
this.libraryId = library.id
|
||||||
this.libraryName = library.name
|
this.libraryName = library.name
|
||||||
this.folders = library.folders.map(folder => new Folder(folder.toJSON()))
|
this.folders = library.folders.map(folder => new Folder(folder.toJSON()))
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
const { CoverDestination } = require('../utils/constants')
|
|
||||||
|
|
||||||
class ScanOptions {
|
class ScanOptions {
|
||||||
constructor(options) {
|
constructor(options) {
|
||||||
this.forceRescan = false
|
this.forceRescan = false
|
||||||
|
|
@ -7,7 +5,7 @@ class ScanOptions {
|
||||||
// Server settings
|
// Server settings
|
||||||
this.parseSubtitles = false
|
this.parseSubtitles = false
|
||||||
this.findCovers = false
|
this.findCovers = false
|
||||||
this.coverDestination = CoverDestination.METADATA
|
this.storeCoverWithBook = false
|
||||||
this.preferAudioMetadata = false
|
this.preferAudioMetadata = false
|
||||||
this.preferOpfMetadata = false
|
this.preferOpfMetadata = false
|
||||||
|
|
||||||
|
|
@ -32,7 +30,7 @@ class ScanOptions {
|
||||||
metadataPrecedence: this.metadataPrecedence,
|
metadataPrecedence: this.metadataPrecedence,
|
||||||
parseSubtitles: this.parseSubtitles,
|
parseSubtitles: this.parseSubtitles,
|
||||||
findCovers: this.findCovers,
|
findCovers: this.findCovers,
|
||||||
coverDestination: this.coverDestination,
|
storeCoverWithBook: this.storeCoverWithBook,
|
||||||
preferAudioMetadata: this.preferAudioMetadata,
|
preferAudioMetadata: this.preferAudioMetadata,
|
||||||
preferOpfMetadata: this.preferOpfMetadata
|
preferOpfMetadata: this.preferOpfMetadata
|
||||||
}
|
}
|
||||||
|
|
@ -43,7 +41,7 @@ class ScanOptions {
|
||||||
|
|
||||||
this.parseSubtitles = !!serverSettings.scannerParseSubtitle
|
this.parseSubtitles = !!serverSettings.scannerParseSubtitle
|
||||||
this.findCovers = !!serverSettings.scannerFindCovers
|
this.findCovers = !!serverSettings.scannerFindCovers
|
||||||
this.coverDestination = serverSettings.coverDestination
|
this.storeCoverWithBook = serverSettings.storeCoverWithBook
|
||||||
this.preferAudioMetadata = serverSettings.scannerPreferAudioMetadata
|
this.preferAudioMetadata = serverSettings.scannerPreferAudioMetadata
|
||||||
this.preferOpfMetadata = serverSettings.scannerPreferOpfMetadata
|
this.preferOpfMetadata = serverSettings.scannerPreferOpfMetadata
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@ const Path = require('path')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const { version } = require('../../package.json')
|
const { version } = require('../../package.json')
|
||||||
const { groupFilesIntoAudiobookPaths, getAudiobookFileData, scanRootDir } = require('../utils/scandir')
|
const { groupFilesIntoAudiobookPaths, getAudiobookFileData, scanRootDir } = require('../utils/scandir')
|
||||||
const { comparePaths, getIno, getId, msToTimestamp } = require('../utils/index')
|
const { comparePaths, getId } = require('../utils/index')
|
||||||
const { ScanResult, CoverDestination, LogLevel } = require('../utils/constants')
|
const { ScanResult, LogLevel } = require('../utils/constants')
|
||||||
|
|
||||||
const AudioFileScanner = require('./AudioFileScanner')
|
const AudioFileScanner = require('./AudioFileScanner')
|
||||||
const BookFinder = require('../BookFinder')
|
const BookFinder = require('../BookFinder')
|
||||||
|
|
@ -15,12 +15,9 @@ const LibraryScan = require('./LibraryScan')
|
||||||
const ScanOptions = require('./ScanOptions')
|
const ScanOptions = require('./ScanOptions')
|
||||||
|
|
||||||
class Scanner {
|
class Scanner {
|
||||||
constructor(AUDIOBOOK_PATH, METADATA_PATH, db, coverController, emitter) {
|
constructor(db, coverController, emitter) {
|
||||||
this.AudiobookPath = AUDIOBOOK_PATH
|
this.BookMetadataPath = Path.posix.join(global.MetadataPath, 'books')
|
||||||
this.MetadataPath = METADATA_PATH
|
this.ScanLogPath = Path.posix.join(global.MetadataPath, 'logs', 'scans')
|
||||||
this.BookMetadataPath = Path.posix.join(this.MetadataPath.replace(/\\/g, '/'), 'books')
|
|
||||||
var LogDirPath = Path.join(this.MetadataPath, 'logs')
|
|
||||||
this.ScanLogPath = Path.join(LogDirPath, 'scans')
|
|
||||||
|
|
||||||
this.db = db
|
this.db = db
|
||||||
this.coverController = coverController
|
this.coverController = coverController
|
||||||
|
|
@ -33,7 +30,7 @@ class Scanner {
|
||||||
}
|
}
|
||||||
|
|
||||||
getCoverDirectory(audiobook) {
|
getCoverDirectory(audiobook) {
|
||||||
if (this.db.serverSettings.coverDestination === CoverDestination.AUDIOBOOK) {
|
if (this.db.serverSettings.storeCoverWithBook) {
|
||||||
return {
|
return {
|
||||||
fullPath: audiobook.fullPath,
|
fullPath: audiobook.fullPath,
|
||||||
relPath: '/s/book/' + audiobook.id
|
relPath: '/s/book/' + audiobook.id
|
||||||
|
|
@ -88,8 +85,8 @@ class Scanner {
|
||||||
|
|
||||||
// Sync other files first so that local images are used as cover art
|
// Sync other files first so that local images are used as cover art
|
||||||
// TODO: Cleanup other file sync
|
// TODO: Cleanup other file sync
|
||||||
var allOtherFiles = checkRes.newOtherFileData.concat(audiobook._otherFiles)
|
var allOtherFiles = checkRes.newOtherFileData.concat(checkRes.existingOtherFileData)
|
||||||
if (await audiobook.syncOtherFiles(allOtherFiles, this.MetadataPath, this.db.serverSettings.scannerPreferOpfMetadata)) {
|
if (await audiobook.syncOtherFiles(allOtherFiles, this.db.serverSettings.scannerPreferOpfMetadata)) {
|
||||||
hasUpdated = true
|
hasUpdated = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -120,7 +117,7 @@ class Scanner {
|
||||||
|
|
||||||
if (hasUpdated) {
|
if (hasUpdated) {
|
||||||
this.emitter('audiobook_updated', audiobook.toJSONExpanded())
|
this.emitter('audiobook_updated', audiobook.toJSONExpanded())
|
||||||
await this.db.updateEntity('audiobook', audiobook)
|
await this.db.updateAudiobook(audiobook)
|
||||||
return ScanResult.UPDATED
|
return ScanResult.UPDATED
|
||||||
}
|
}
|
||||||
return ScanResult.UPTODATE
|
return ScanResult.UPTODATE
|
||||||
|
|
@ -208,6 +205,7 @@ class Scanner {
|
||||||
// Check for existing & removed audiobooks
|
// Check for existing & removed audiobooks
|
||||||
for (let i = 0; i < audiobooksInLibrary.length; i++) {
|
for (let i = 0; i < audiobooksInLibrary.length; i++) {
|
||||||
var audiobook = audiobooksInLibrary[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))
|
var dataFound = audiobookDataFound.find(abd => abd.ino === audiobook.ino || comparePaths(abd.path, audiobook.path))
|
||||||
if (!dataFound) {
|
if (!dataFound) {
|
||||||
libraryScan.addLog(LogLevel.WARN, `Audiobook "${audiobook.title}" is missing`)
|
libraryScan.addLog(LogLevel.WARN, `Audiobook "${audiobook.title}" is missing`)
|
||||||
|
|
@ -317,7 +315,7 @@ class Scanner {
|
||||||
}))
|
}))
|
||||||
newAudiobooks = newAudiobooks.filter(ab => ab) // Filter out nulls
|
newAudiobooks = newAudiobooks.filter(ab => ab) // Filter out nulls
|
||||||
libraryScan.resultsAdded += newAudiobooks.length
|
libraryScan.resultsAdded += newAudiobooks.length
|
||||||
await this.db.insertEntities('audiobook', newAudiobooks)
|
await this.db.insertAudiobooks(newAudiobooks)
|
||||||
this.emitter('audiobooks_added', newAudiobooks.map(ab => ab.toJSONExpanded()))
|
this.emitter('audiobooks_added', newAudiobooks.map(ab => ab.toJSONExpanded()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -330,7 +328,7 @@ class Scanner {
|
||||||
if (newOtherFileData.length || libraryScan.scanOptions.forceRescan) {
|
if (newOtherFileData.length || libraryScan.scanOptions.forceRescan) {
|
||||||
// TODO: Cleanup other file sync
|
// TODO: Cleanup other file sync
|
||||||
var allOtherFiles = newOtherFileData.concat(existingOtherFileData)
|
var allOtherFiles = newOtherFileData.concat(existingOtherFileData)
|
||||||
if (await audiobook.syncOtherFiles(allOtherFiles, this.MetadataPath, libraryScan.preferOpfMetadata)) {
|
if (await audiobook.syncOtherFiles(allOtherFiles, libraryScan.preferOpfMetadata)) {
|
||||||
hasUpdated = true
|
hasUpdated = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -525,7 +523,7 @@ class Scanner {
|
||||||
Logger.debug(`[Scanner] Folder update group must be a new book "${bookDir}" in library "${library.name}"`)
|
Logger.debug(`[Scanner] Folder update group must be a new book "${bookDir}" in library "${library.name}"`)
|
||||||
var newAudiobook = await this.scanPotentialNewAudiobook(folder, fullPath)
|
var newAudiobook = await this.scanPotentialNewAudiobook(folder, fullPath)
|
||||||
if (newAudiobook) {
|
if (newAudiobook) {
|
||||||
await this.db.insertEntity('audiobook', newAudiobook)
|
await this.db.insertAudiobook(newAudiobook)
|
||||||
this.emitter('audiobook_added', newAudiobook.toJSONExpanded())
|
this.emitter('audiobook_added', newAudiobook.toJSONExpanded())
|
||||||
}
|
}
|
||||||
bookGroupingResults[bookDir] = newAudiobook ? ScanResult.ADDED : ScanResult.NOTHING
|
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)
|
Logger.warn('Found duplicate ID - updating from', ab.id, 'to', abCopy.id)
|
||||||
await this.db.removeEntity('audiobook', ab.id)
|
await this.db.removeEntity('audiobook', ab.id)
|
||||||
await this.db.insertEntity('audiobook', abCopy)
|
await this.db.insertAudiobook(abCopy)
|
||||||
audiobooksUpdated++
|
audiobooksUpdated++
|
||||||
} else {
|
} else {
|
||||||
ids[ab.id] = true
|
ids[ab.id] = true
|
||||||
|
|
@ -625,5 +623,101 @@ class Scanner {
|
||||||
Logger.info(`[Scanner] Updated ${audiobooksUpdated} audiobook IDs`)
|
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
|
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
|
UPTODATE: 4
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports.CoverDestination = {
|
|
||||||
METADATA: 0,
|
|
||||||
AUDIOBOOK: 1
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports.BookCoverAspectRatio = {
|
module.exports.BookCoverAspectRatio = {
|
||||||
STANDARD: 0, // 1.6:1
|
STANDARD: 0, // 1.6:1
|
||||||
SQUARE: 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
|
// any error other than ENOTDIR means it's not readable, or
|
||||||
// doesn't exist. give up.
|
// doesn't exist. give up.
|
||||||
if (er && er.code !== 'ENOTDIR') return cb(er)
|
if (er && er.code !== 'ENOTDIR') return cb(er)
|
||||||
if (er) {
|
if (er) { // Is a file
|
||||||
return fs.chmod(p, mode).then(() => {
|
return fs.chmod(p, mode).then(() => {
|
||||||
fs.chown(p, uid, gid, cb)
|
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) => {
|
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)
|
chmodr(path, mode, uid, gid, resolve)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -20,6 +20,23 @@ async function getFileStat(path) {
|
||||||
}
|
}
|
||||||
module.exports.getFileStat = getFileStat
|
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) {
|
async function getFileSize(path) {
|
||||||
var stat = await getFileStat(path)
|
var stat = await getFileStat(path)
|
||||||
if (!stat) return 0
|
if (!stat) return 0
|
||||||
|
|
@ -27,6 +44,15 @@ async function getFileSize(path) {
|
||||||
}
|
}
|
||||||
module.exports.getFileSize = getFileSize
|
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) {
|
async function readTextFile(path) {
|
||||||
try {
|
try {
|
||||||
var data = await fs.readFile(path)
|
var data = await fs.readFile(path)
|
||||||
|
|
|
||||||
|
|
@ -38,13 +38,6 @@ module.exports.comparePaths = (path1, path2) => {
|
||||||
return path1 === path2 || Path.normalize(path1) === Path.normalize(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) => {
|
module.exports.isNullOrNaN = (num) => {
|
||||||
return num === null || isNaN(num)
|
return num === null || isNaN(num)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,4 @@
|
||||||
const ffprobe = require('node-ffprobe')
|
const ffprobe = require('node-ffprobe')
|
||||||
|
|
||||||
if (process.env.FFPROBE_PATH) {
|
|
||||||
ffprobe.FFPROBE_PATH = process.env.FFPROBE_PATH
|
|
||||||
}
|
|
||||||
|
|
||||||
const AudioProbeData = require('../scanner/AudioProbeData')
|
const AudioProbeData = require('../scanner/AudioProbeData')
|
||||||
|
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
|
|
@ -281,6 +276,10 @@ function parseProbeData(data, verbose = false) {
|
||||||
|
|
||||||
// Updated probe returns AudioProbeData object
|
// Updated probe returns AudioProbeData object
|
||||||
function probe(filepath, verbose = false) {
|
function probe(filepath, verbose = false) {
|
||||||
|
if (process.env.FFPROBE_PATH) {
|
||||||
|
ffprobe.FFPROBE_PATH = process.env.FFPROBE_PATH
|
||||||
|
}
|
||||||
|
|
||||||
return ffprobe(filepath)
|
return ffprobe(filepath)
|
||||||
.then(raw => {
|
.then(raw => {
|
||||||
var rawProbeData = parseProbeData(raw, verbose)
|
var rawProbeData = parseProbeData(raw, verbose)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
const Path = require('path')
|
const Path = require('path')
|
||||||
const fs = require('fs-extra')
|
const fs = require('fs-extra')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const { getIno } = require('./index')
|
const { recurseFiles, getFileTimestampsWithIno } = require('./fileUtils')
|
||||||
const { recurseFiles } = require('./fileUtils')
|
|
||||||
const globals = require('./globals')
|
const globals = require('./globals')
|
||||||
|
|
||||||
function isBookFile(path) {
|
function isBookFile(path) {
|
||||||
|
|
@ -114,16 +113,20 @@ function groupFileItemsIntoBooks(fileItems) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanFileObjects(basepath, abrelpath, files) {
|
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)
|
var ext = Path.extname(file)
|
||||||
return {
|
return {
|
||||||
filetype: getFileType(ext),
|
filetype: getFileType(ext),
|
||||||
filename: Path.basename(file),
|
filename: Path.basename(file),
|
||||||
path: Path.posix.join(abrelpath, file), // /AUDIOBOOK/PATH/filename.mp3
|
path: Path.posix.join(abrelpath, file), // /AUDIOBOOK/PATH/filename.mp3
|
||||||
fullPath: Path.posix.join(basepath, file), // /audiobooks/AUDIOBOOK/PATH/filename.mp3
|
fullPath, // /audiobooks/AUDIOBOOK/PATH/filename.mp3
|
||||||
ext: ext
|
ext: ext,
|
||||||
|
...fileTsData
|
||||||
}
|
}
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFileType(ext) {
|
function getFileType(ext) {
|
||||||
|
|
@ -162,15 +165,15 @@ async function scanRootDir(folder, serverSettings = {}) {
|
||||||
for (const audiobookPath in audiobookGrouping) {
|
for (const audiobookPath in audiobookGrouping) {
|
||||||
var audiobookData = getAudiobookDataFromDir(folderPath, audiobookPath, parseSubtitle)
|
var audiobookData = getAudiobookDataFromDir(folderPath, audiobookPath, parseSubtitle)
|
||||||
|
|
||||||
var fileObjs = cleanFileObjects(audiobookData.fullPath, audiobookPath, audiobookGrouping[audiobookPath])
|
var fileObjs = await cleanFileObjects(audiobookData.fullPath, audiobookPath, audiobookGrouping[audiobookPath])
|
||||||
for (let i = 0; i < fileObjs.length; i++) {
|
var audiobookFolderStats = await getFileTimestampsWithIno(audiobookData.fullPath)
|
||||||
fileObjs[i].ino = await getIno(fileObjs[i].fullPath)
|
|
||||||
}
|
|
||||||
var audiobookIno = await getIno(audiobookData.fullPath)
|
|
||||||
audiobooks.push({
|
audiobooks.push({
|
||||||
folderId: folder.id,
|
folderId: folder.id,
|
||||||
libraryId: folder.libraryId,
|
libraryId: folder.libraryId,
|
||||||
ino: audiobookIno,
|
ino: audiobookFolderStats.ino,
|
||||||
|
mtimeMs: audiobookFolderStats.mtimeMs || 0,
|
||||||
|
ctimeMs: audiobookFolderStats.ctimeMs || 0,
|
||||||
|
birthtimeMs: audiobookFolderStats.birthtimeMs || 0,
|
||||||
...audiobookData,
|
...audiobookData,
|
||||||
audioFiles: fileObjs.filter(f => f.filetype === 'audio'),
|
audioFiles: fileObjs.filter(f => f.filetype === 'audio'),
|
||||||
otherFiles: 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
|
// If in a series directory check for volume number match
|
||||||
/* ACCEPTS:
|
/* ACCEPTS
|
||||||
Book 2 - Title Here - Subtitle Here
|
Book 2 - Title Here - Subtitle Here
|
||||||
Title Here - Subtitle Here - Vol 12
|
Title Here - Subtitle Here - Vol 12
|
||||||
Title Here - volume 9 - Subtitle Here
|
Title Here - volume 9 - Subtitle Here
|
||||||
Vol. 3 Title Here - Subtitle Here
|
Vol. 3 Title Here - Subtitle Here
|
||||||
1980 - Book 2-Title Here
|
1980 - Book 2-Title Here
|
||||||
Title Here-Volume 999-Subtitle Here
|
Title Here-Volume 999-Subtitle Here
|
||||||
|
2 - Book Title
|
||||||
|
100 - Book Title
|
||||||
|
0.5 - Book Title
|
||||||
*/
|
*/
|
||||||
var volumeNumber = null
|
var volumeNumber = null
|
||||||
if (series) {
|
if (series) {
|
||||||
// New volume regex to match volumes with decimal (OLD: /(-? ?)\b((?:Book|Vol.?|Volume) (\d{1,3}))\b( ?-?)/i)
|
// 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(/(-? ?)\b((?:Book|Vol.?|Volume) (\d{0,3}(?:\.\d{1,2})?))\b( ?-?)/i)
|
var volumeMatch = title.match(/^(\d{1,3}(?:\.\d{1,2})?) - ./)
|
||||||
if (volumeMatch && volumeMatch.length > 3 && volumeMatch[2] && volumeMatch[3]) {
|
if (volumeMatch && volumeMatch.length > 1) {
|
||||||
volumeNumber = volumeMatch[3]
|
volumeNumber = volumeMatch[1]
|
||||||
var replaceChunk = volumeMatch[2]
|
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"
|
// "1980 - Book 2-Title Here"
|
||||||
// Group 1 would be "- "
|
// Group 1 would be "- "
|
||||||
// Group 3 would be "-"
|
// Group 3 would be "-"
|
||||||
// Only remove the first group
|
// Only remove the first group
|
||||||
if (volumeMatch[1]) {
|
if (volumeMatch[1]) {
|
||||||
replaceChunk = volumeMatch[1] + replaceChunk
|
replaceChunk = volumeMatch[1] + replaceChunk
|
||||||
} else if (volumeMatch[4]) {
|
} else if (volumeMatch[4]) {
|
||||||
replaceChunk += 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 audiobookDir = audiobookPath.replace(folderFullPath, '').slice(1)
|
||||||
var audiobookData = getAudiobookDataFromDir(folderFullPath, audiobookDir, parseSubtitle)
|
var audiobookData = getAudiobookDataFromDir(folderFullPath, audiobookDir, parseSubtitle)
|
||||||
|
var audiobookFolderStats = await getFileTimestampsWithIno(audiobookData.fullPath)
|
||||||
var audiobook = {
|
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,
|
folderId: folder.id,
|
||||||
libraryId: folder.libraryId,
|
libraryId: folder.libraryId,
|
||||||
...audiobookData,
|
...audiobookData,
|
||||||
|
|
@ -284,14 +301,14 @@ async function getAudiobookFileData(folder, audiobookPath, serverSettings = {})
|
||||||
for (let i = 0; i < fileItems.length; i++) {
|
for (let i = 0; i < fileItems.length; i++) {
|
||||||
var fileItem = fileItems[i]
|
var fileItem = fileItems[i]
|
||||||
|
|
||||||
var ino = await getIno(fileItem.fullpath)
|
var fileStatData = await getFileTimestampsWithIno(fileItem.fullpath)
|
||||||
var fileObj = {
|
var fileObj = {
|
||||||
ino,
|
|
||||||
filetype: getFileType(fileItem.extension),
|
filetype: getFileType(fileItem.extension),
|
||||||
filename: fileItem.name,
|
filename: fileItem.name,
|
||||||
path: fileItem.path,
|
path: fileItem.path,
|
||||||
fullPath: fileItem.fullpath,
|
fullPath: fileItem.fullpath,
|
||||||
ext: fileItem.extension
|
ext: fileItem.extension,
|
||||||
|
...fileStatData
|
||||||
}
|
}
|
||||||
if (fileObj.filetype === 'audio') {
|
if (fileObj.filetype === 'audio') {
|
||||||
audiobook.audioFiles.push(fileObj)
|
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