merge upstream

This commit is contained in:
David Leimroth 2022-02-28 18:18:25 +01:00
commit 61bf30e08a
89 changed files with 2963 additions and 1805 deletions

View file

@ -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 });
})));

View file

@ -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">&nbsp;/&nbsp;{{ 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()

View file

@ -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: {

View file

@ -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)

View file

@ -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

View file

@ -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>

View 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>

View file

@ -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 }}&nbsp;</span>{{ title }} <span v-if="volumeNumber">#{{ volumeNumber }}&nbsp;</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

View file

@ -59,6 +59,14 @@ export default {
{ {
text: 'Size', text: 'Size',
value: 'size' value: 'size'
},
{
text: 'File Birthtime',
value: 'birthtimeMs'
},
{
text: 'File Modified',
value: 'mtimeMs'
} }
] ]
} }

View file

@ -89,6 +89,8 @@ export default {
this.showMenu = val this.showMenu = val
} }
}, },
mounted() {} mounted() {
this.currentPlaybackRate = this.playbackRate
}
} }
</script> </script>

View file

@ -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>

View file

@ -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' }">

View file

@ -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">

View file

@ -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) {

View file

@ -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)

View file

@ -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)
}, },

View file

@ -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>

View 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>

View file

@ -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)

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View 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>

View file

@ -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

View file

@ -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({

View 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
}
},
}
}

View file

@ -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' }
] ]
}, },

View file

@ -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": {

View file

@ -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', [])

View file

@ -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>

View file

@ -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)
} }
} }
}, },

View file

@ -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() {}
} }

View file

@ -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

View file

@ -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">&nbsp;|&nbsp;{{ 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>

View 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}`
}
}

View 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()
}
}

View 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
}
}

View 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)
}
}
}

View 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
}

View 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)
}

View file

@ -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
} }

View file

@ -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`)

File diff suppressed because one or more lines are too long

View file

@ -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
} }
} }

View file

@ -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

View file

@ -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

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

381
package-lock.json generated
View file

@ -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"
}
} }
} }
} }

View file

@ -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": {}
} }

View file

@ -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 />

View file

@ -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

View file

@ -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)

View file

@ -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()
} }

View file

@ -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()

View file

@ -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')
} }

View file

@ -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

View file

@ -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

View file

@ -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)
} }

View file

@ -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') {

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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}"`)

View file

@ -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()

View file

@ -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)) {

View file

@ -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

View file

@ -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

View file

@ -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()
} }
} }

View file

@ -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

View file

@ -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

View file

@ -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
} }

View file

@ -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

View file

@ -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) {

View file

@ -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() {

View file

@ -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()))

View file

@ -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
} }

View file

@ -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

View 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

View file

@ -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

View file

@ -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

View file

@ -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)
}) })
} }

View file

@ -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)

View file

@ -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)
} }

View file

@ -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)

View file

@ -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)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB