Merge branch 'master' into feat-forwardauth

This commit is contained in:
advplyr 2022-12-18 09:49:49 -06:00
commit 8558baf30a
214 changed files with 14835 additions and 6306 deletions

View file

@ -1,35 +1,39 @@
<template>
<div id="page-wrapper" class="page p-6 overflow-y-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
<div class="w-full max-w-xl mx-auto">
<h1 class="text-2xl">Account</h1>
<h1 class="text-2xl">{{ $strings.HeaderAccount }}</h1>
<div class="my-4">
<div class="flex -mx-2">
<div class="w-2/3 px-2">
<ui-text-input-with-label disabled :value="username" label="Username" />
<ui-text-input-with-label disabled :value="username" :label="$strings.LabelUsername" />
</div>
<div class="w-1/3 px-2">
<ui-text-input-with-label disabled :value="usertype" label="Account Type" />
<ui-text-input-with-label disabled :value="usertype" :label="$strings.LabelAccountType" />
</div>
</div>
<div class="py-4">
<p class="px-1 text-sm font-semibold">{{ $strings.LabelLanguage }}</p>
<ui-dropdown v-model="selectedLanguage" :items="$languageCodeOptions" small class="max-w-48" @input="updateLocalLanguage" />
</div>
<div class="w-full h-px bg-primary my-4" />
<div class="w-full h-px bg-white/10 my-4" />
<p v-if="!isGuest" class="mb-4 text-lg">Change Password</p>
<p v-if="!isGuest" class="mb-4 text-lg">{{ $strings.HeaderChangePassword }}</p>
<form v-if="!isGuest" @submit.prevent="submitChangePassword">
<ui-text-input-with-label v-model="password" :disabled="changingPassword" type="password" label="Password" class="my-2" />
<ui-text-input-with-label v-model="newPassword" :disabled="changingPassword" type="password" label="New Password" class="my-2" />
<ui-text-input-with-label v-model="confirmPassword" :disabled="changingPassword" type="password" label="Confirm Password" class="my-2" />
<ui-text-input-with-label v-model="password" :disabled="changingPassword" type="password" :label="$strings.LabelPassword" class="my-2" />
<ui-text-input-with-label v-model="newPassword" :disabled="changingPassword" type="password" :label="$strings.LabelNewPassword" class="my-2" />
<ui-text-input-with-label v-model="confirmPassword" :disabled="changingPassword" type="password" :label="$strings.LabelConfirmPassword" class="my-2" />
<div class="flex items-center py-2">
<p v-if="isRoot" class="text-error py-2 text-xs">* Root user is the only user that can have an empty password</p>
<p v-if="isRoot" class="text-error py-2 text-xs">* {{ $strings.NoteChangeRootPassword }}</p>
<div class="flex-grow" />
<ui-btn v-show="(password && newPassword && confirmPassword) || isRoot" type="submit" :loading="changingPassword" color="success">Submit</ui-btn>
<ui-btn v-show="(password && newPassword && confirmPassword) || isRoot" type="submit" :loading="changingPassword" color="success">{{ $strings.ButtonSubmit }}</ui-btn>
</div>
</form>
</div>
<div class="py-4 mt-8 flex">
<ui-btn color="primary flex items-center text-lg" @click="logout"><span class="material-icons mr-4 icon-text">logout</span>Logout</ui-btn>
<ui-btn color="primary flex items-center text-lg" @click="logout"><span class="material-icons mr-4 icon-text">logout</span>{{ $strings.ButtonLogout }}</ui-btn>
</div>
</div>
</div>
@ -42,7 +46,8 @@ export default {
password: null,
newPassword: null,
confirmPassword: null,
changingPassword: false
changingPassword: false,
selectedLanguage: ''
}
},
computed: {
@ -66,6 +71,9 @@ export default {
}
},
methods: {
updateLocalLanguage(lang) {
this.$setLanguageCode(lang)
},
logout() {
var rootSocket = this.$root.socket || {}
const logoutPayload = {
@ -77,6 +85,8 @@ export default {
if (localStorage.getItem('token')) {
localStorage.removeItem('token')
}
this.$store.commit('libraries/setUserPlaylists', [])
this.$store.commit('libraries/setCollections', [])
this.$router.push('/login')
},
resetForm() {
@ -113,6 +123,8 @@ export default {
})
}
},
mounted() {}
mounted() {
this.selectedLanguage = this.$languageCodes.current
}
}
</script>

View file

@ -1,85 +1,92 @@
<template>
<div id="page-wrapper" class="bg-bg page overflow-y-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
<div class="flex items-center py-4 max-w-7xl mx-auto">
<div class="flex items-center py-4 px-2 md:px-0 max-w-7xl mx-auto">
<nuxt-link :to="`/item/${libraryItem.id}`" class="hover:underline">
<h1 class="text-xl">{{ title }}</h1>
<h1 class="text-lg lg:text-xl">{{ title }}</h1>
</nuxt-link>
<button class="w-7 h-7 flex items-center justify-center mx-4 hover:scale-110 duration-100 transform text-gray-200 hover:text-white" @click="editItem">
<span class="material-icons text-base">edit</span>
</button>
<div class="flex-grow" />
<p class="text-base">Duration:</p>
<p class="text-base font-mono ml-8">{{ $secondsToTimestamp(mediaDurationRounded) }}</p>
<div class="flex-grow hidden md:block" />
<p class="text-base hidden md:block">{{ $strings.LabelDuration }}:</p>
<p class="text-base font-mono ml-4 hidden md:block">{{ $secondsToTimestamp(mediaDurationRounded) }}</p>
</div>
<div class="flex flex-wrap-reverse justify-center py-4">
<div class="flex flex-wrap-reverse justify-center py-4 px-2">
<div class="w-full max-w-3xl py-4">
<div class="flex items-center">
<p class="text-lg mb-4 font-semibold">Audiobook Chapters</p>
<div class="w-12 hidden lg:block" />
<p class="text-lg mb-4 font-semibold">{{ $strings.HeaderChapters }}</p>
<div class="flex-grow" />
<ui-checkbox v-model="showSecondInputs" checkbox-bg="primary" small label-class="text-sm text-gray-200 pl-1" label="Show seconds" class="mx-2" />
<div class="w-40" />
<div class="w-32 hidden lg:block" />
</div>
<div class="flex items-center mb-3 py-1">
<div class="w-12 hidden lg:block" />
<ui-btn v-if="newChapters.length > 1" :color="showShiftTimes ? 'bg' : 'primary'" small @click="showShiftTimes = !showShiftTimes">{{ $strings.ButtonShiftTimes }}</ui-btn>
<ui-btn color="primary" small class="mx-2" @click="showFindChaptersModal = true">{{ $strings.ButtonLookup }}</ui-btn>
<div class="flex-grow" />
<ui-btn v-if="newChapters.length > 1" :color="showShiftTimes ? 'bg' : 'primary'" small @click="showShiftTimes = !showShiftTimes">Shift Times</ui-btn>
<ui-btn color="primary" small class="mx-2" @click="showFindChaptersModal = true">Lookup</ui-btn>
<ui-btn color="success" small @click="saveChapters">Save</ui-btn>
<div class="w-40" />
<ui-btn v-if="hasChanges" small class="mx-2" @click.stop="resetChapters">{{ $strings.ButtonReset }}</ui-btn>
<ui-btn v-if="hasChanges" color="success" :disabled="!hasChanges" small @click="saveChapters">{{ $strings.ButtonSave }}</ui-btn>
<div class="w-32 hidden lg:block" />
</div>
<div class="overflow-hidden">
<transition name="slide">
<div v-if="showShiftTimes" class="flex mb-4">
<div class="w-12"></div>
<div class="w-12 hidden lg:block" />
<div class="flex-grow">
<div class="flex items-center">
<p class="text-sm mb-1 font-semibold pr-2">Time to shift in seconds</p>
<p class="text-sm mb-1 font-semibold pr-2">{{ $strings.LabelTimeToShift }}</p>
<ui-text-input v-model="shiftAmount" type="number" class="max-w-20" style="height: 30px" />
<ui-btn color="primary" class="mx-1" small @click="shiftChapterTimes">Add</ui-btn>
<ui-btn color="primary" class="mx-1" small @click="shiftChapterTimes">{{ $strings.ButtonAdd }}</ui-btn>
<div class="flex-grow" />
<span class="material-icons text-gray-200 hover:text-white cursor-pointer" @click="showShiftTimes = false">close</span>
</div>
<p class="text-xs py-1.5 text-gray-300 max-w-md">Note: First chapter start time must remain at 0:00 and the last chapter start time cannot exceed this audiobooks duration.</p>
<p class="text-xs py-1.5 text-gray-300 max-w-md">{{ $strings.NoteChapterEditorTimes }}</p>
</div>
<div class="w-40"></div>
<div class="w-32 hidden lg:block" />
</div>
</transition>
</div>
<div class="flex text-xs uppercase text-gray-300 font-semibold mb-2">
<div class="w-12"></div>
<div class="w-32 px-2">Start</div>
<div class="flex-grow px-2">Title</div>
<div class="w-40"></div>
<div class="w-8 min-w-8 md:w-12 md:min-w-12"></div>
<div class="w-24 min-w-24 md:w-32 md:min-w-32 px-2">{{ $strings.LabelStart }}</div>
<div class="flex-grow px-2">{{ $strings.LabelTitle }}</div>
<div class="w-32"></div>
</div>
<template v-for="chapter in newChapters">
<div :key="chapter.id" class="flex py-1">
<div class="w-12">#{{ chapter.id + 1 }}</div>
<div class="w-32 px-1">
<div class="w-8 min-w-8 md:w-12 md:min-w-12">#{{ chapter.id + 1 }}</div>
<div class="w-24 min-w-24 md:w-32 md:min-w-32 px-1">
<ui-text-input v-if="showSecondInputs" v-model="chapter.start" type="number" class="text-xs" @change="checkChapters" />
<ui-time-picker v-else class="text-xs" v-model="chapter.start" :show-three-digit-hour="mediaDuration >= 360000" @change="checkChapters" />
</div>
<div class="flex-grow px-1">
<ui-text-input v-model="chapter.title" class="text-xs" />
<ui-text-input v-model="chapter.title" @change="checkChapters" class="text-xs" />
</div>
<div class="w-40 px-2 py-1">
<div class="w-32 min-w-32 px-2 py-1">
<div class="flex items-center">
<button v-if="newChapters.length > 1" class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-error transform hover:scale-110 duration-150" @click="removeChapter(chapter)">
<span class="material-icons-outlined text-base">remove</span>
</button>
<ui-tooltip :text="$strings.MessageRemoveChapter" direction="bottom">
<button v-if="newChapters.length > 1" class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-error transform hover:scale-110 duration-150" @click="removeChapter(chapter)">
<span class="material-icons-outlined text-base">remove</span>
</button>
</ui-tooltip>
<ui-tooltip text="Insert chapter below" direction="bottom">
<ui-tooltip :text="$strings.MessageInsertChapterBelow" direction="bottom">
<button class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-success transform hover:scale-110 duration-150" @click="addChapter(chapter)">
<span class="material-icons text-lg">add</span>
</button>
</ui-tooltip>
<button class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-white transform hover:scale-110 duration-150" @click="playChapter(chapter)">
<widgets-loading-spinner v-if="selectedChapterId === chapter.id && isLoadingChapter" />
<span v-else-if="selectedChapterId === chapter.id && isPlayingChapter" class="material-icons-outlined text-base">pause</span>
<span v-else class="material-icons-outlined text-base">play_arrow</span>
</button>
<ui-tooltip :text="selectedChapterId === chapter.id && isPlayingChapter ? $strings.MessagePauseChapter : $strings.MessagePlayChapter" direction="bottom">
<button class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-white transform hover:scale-110 duration-150" @click="playChapter(chapter)">
<widgets-loading-spinner v-if="selectedChapterId === chapter.id && isLoadingChapter" />
<span v-else-if="selectedChapterId === chapter.id && isPlayingChapter" class="material-icons-outlined text-base">pause</span>
<span v-else class="material-icons-outlined text-base">play_arrow</span>
</button>
</ui-tooltip>
<ui-tooltip v-if="chapter.error" :text="chapter.error" direction="left">
<button class="w-7 h-7 rounded-full flex items-center justify-center text-error">
@ -92,12 +99,19 @@
</template>
</div>
<div class="w-full max-w-xl py-4">
<p class="text-lg mb-4 font-semibold py-1">Audio Tracks</p>
<div class="w-full max-w-xl py-4 px-2">
<div class="flex items-center mb-4 py-1">
<p class="text-lg font-semibold">{{ $strings.HeaderAudioTracks }}</p>
<div class="flex-grow" />
<ui-btn small @click="setChaptersFromTracks">{{ $strings.ButtonSetChaptersFromTracks }}</ui-btn>
<ui-tooltip :text="$strings.MessageSetChaptersFromTracksDescription" direction="top" class="flex items-center mx-1 cursor-default">
<span class="material-icons-outlined text-xl text-gray-200">info</span>
</ui-tooltip>
</div>
<div class="flex text-xs uppercase text-gray-300 font-semibold mb-2">
<div class="flex-grow">Filename</div>
<div class="w-20">Duration</div>
<div class="w-20 text-center">Chapters</div>
<div class="flex-grow">{{ $strings.LabelFilename }}</div>
<div class="w-20">{{ $strings.LabelDuration }}</div>
<div class="w-20 text-center">{{ $strings.HeaderChapters }}</div>
</div>
<template v-for="track in audioTracks">
<div :key="track.ino" class="flex items-center py-2" :class="currentTrackIndex === track.index && isPlayingChapter ? 'bg-success bg-opacity-10' : ''">
@ -122,30 +136,34 @@
<modals-modal v-model="showFindChaptersModal" name="edit-book" :width="500" :processing="findingChapters">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none">
<p class="font-book text-3xl text-white truncate pointer-events-none">Find Chapters</p>
<p class="font-book text-3xl text-white truncate pointer-events-none">{{ $strings.HeaderFindChapters }}</p>
</div>
</template>
<div class="w-full h-full max-h-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative">
<div v-if="!chapterData" class="flex p-20">
<ui-text-input-with-label v-model="asinInput" label="ASIN" />
<ui-dropdown v-model="regionInput" label="Region" small :items="audibleRegions" class="w-32 mx-1" />
<ui-btn small color="primary" class="mt-5" @click="findChapters">Find</ui-btn>
<ui-dropdown v-model="regionInput" :label="$strings.LabelRegion" small :items="audibleRegions" class="w-32 mx-1" />
<ui-btn small color="primary" class="mt-5" @click="findChapters">{{ $strings.ButtonSearch }}</ui-btn>
</div>
<div v-else class="w-full p-4">
<div class="flex justify-between mb-4">
<p>
Duration found: <span class="font-semibold">{{ $secondsToTimestamp(chapterData.runtimeLengthSec) }}</span>
{{ $strings.LabelDurationFound }} <span class="font-semibold">{{ $secondsToTimestamp(chapterData.runtimeLengthSec) }}</span
><br />
<span class="font-semibold" :class="{ 'text-warning': chapters.length !== chapterData.chapters.length }">{{ chapterData.chapters.length }}</span> {{ $strings.LabelChaptersFound }}
</p>
<p>
Your audiobook duration: <span class="font-semibold">{{ $secondsToTimestamp(mediaDurationRounded) }}</span>
{{ $strings.LabelYourAudiobookDuration }}: <span class="font-semibold">{{ $secondsToTimestamp(mediaDurationRounded) }}</span
><br />
Your audiobook has <span class="font-semibold" :class="{ 'text-warning': chapters.length !== chapterData.chapters.length }">{{ chapters.length }}</span> chapters
</p>
</div>
<widgets-alert v-if="chapterData.runtimeLengthSec > mediaDurationRounded" type="warning" class="mb-2"> Your audiobook duration is shorter than duration found </widgets-alert>
<widgets-alert v-else-if="chapterData.runtimeLengthSec < mediaDurationRounded" type="warning" class="mb-2"> Your audiobook duration is longer than the duration found </widgets-alert>
<widgets-alert v-if="chapterData.runtimeLengthSec > mediaDurationRounded" type="warning" class="mb-2"> {{ $strings.MessageYourAudiobookDurationIsShorter }} </widgets-alert>
<widgets-alert v-else-if="chapterData.runtimeLengthSec < mediaDurationRounded" type="warning" class="mb-2"> {{ $strings.MessageYourAudiobookDurationIsLonger }} </widgets-alert>
<div class="flex py-0.5 text-xs font-semibold uppercase text-gray-300 mb-1">
<div class="w-24 px-2">Start</div>
<div class="flex-grow px-2">Title</div>
<div class="w-24 px-2">{{ $strings.LabelStart }}</div>
<div class="flex-grow px-2">{{ $strings.LabelTitle }}</div>
</div>
<div class="w-full max-h-80 overflow-y-auto my-2">
<div v-for="(chapter, index) in chapterData.chapters" :key="index" class="flex py-0.5 text-xs" :class="chapter.startOffsetSec > mediaDuration ? 'bg-error bg-opacity-20' : chapter.startOffsetSec + chapter.lengthMs / 1000 > mediaDuration ? 'bg-warning bg-opacity-20' : index % 2 === 0 ? 'bg-primary bg-opacity-30' : ''">
@ -160,20 +178,20 @@
<div v-if="chapterData.runtimeLengthSec > mediaDurationRounded" class="w-full pt-2">
<div class="flex items-center">
<div class="w-2 h-2 bg-warning bg-opacity-50" />
<p class="pl-2">Chapter end is after the end of your audiobook</p>
<p class="pl-2">{{ $strings.MessageChapterEndIsAfter }}</p>
</div>
<div class="flex items-center">
<div class="w-2 h-2 bg-error bg-opacity-50" />
<p class="pl-2">Chapter start is after the end of your audiobook</p>
<p class="pl-2">{{ $strings.MessageChapterStartIsAfter }}</p>
</div>
</div>
<div class="flex items-center pt-2">
<ui-btn small color="primary" class="mr-1" @click="applyChapterNamesOnly">Map Chapter Titles</ui-btn>
<ui-tooltip text="Map chapter titles to your existing audiobook chapters without adjusting timestamps" direction="top">
<span class="material-icons-outlined">info</span>
<ui-btn small color="primary" class="mr-1" @click="applyChapterNamesOnly">{{ $strings.ButtonMapChapterTitles }}</ui-btn>
<ui-tooltip :text="$strings.MessageMapChapterTitles" direction="top" class="flex items-center">
<span class="material-icons-outlined text-xl text-gray-200">info</span>
</ui-tooltip>
<div class="flex-grow" />
<ui-btn small color="success" @click="applyChapterData">Apply Chapters</ui-btn>
<ui-btn small color="success" @click="applyChapterData">{{ $strings.ButtonApplyChapters }}</ui-btn>
</div>
</div>
</div>
@ -182,6 +200,8 @@
</template>
<script>
import path from 'path'
export default {
async asyncData({ store, params, app, redirect, from }) {
if (!store.getters['user/getUserCanUpdate']) {
@ -224,7 +244,8 @@ export default {
showFindChaptersModal: false,
chapterData: null,
showSecondInputs: false,
audibleRegions: ['US', 'CA', 'UK', 'AU', 'FR', 'DE', 'JP', 'IT', 'IN', 'ES']
audibleRegions: ['US', 'CA', 'UK', 'AU', 'FR', 'DE', 'JP', 'IT', 'IN', 'ES'],
hasChanges: false
}
},
computed: {
@ -266,6 +287,23 @@ export default {
}
},
methods: {
setChaptersFromTracks() {
let currentStartTime = 0
let index = 0
const chapters = []
for (const track of this.audioTracks) {
chapters.push({
id: index++,
title: path.basename(track.metadata.filename, path.extname(track.metadata.filename)),
start: currentStartTime,
end: currentStartTime + track.duration
})
currentStartTime += track.duration
}
this.newChapters = chapters
this.checkChapters()
},
shiftChapterTimes() {
if (!this.shiftAmount || isNaN(this.shiftAmount) || this.newChapters.length <= 1) {
return
@ -296,7 +334,6 @@ export default {
this.$store.commit('showEditModal', this.libraryItem)
},
addChapter(chapter) {
console.log('Add chapter', chapter)
const newChapter = {
id: chapter.id + 1,
start: chapter.start,
@ -311,22 +348,41 @@ export default {
this.checkChapters()
},
checkChapters() {
var previousStart = 0
let previousStart = 0
let hasChanges = this.newChapters.length !== this.chapters.length
for (let i = 0; i < this.newChapters.length; i++) {
this.newChapters[i].id = i
this.newChapters[i].start = Number(this.newChapters[i].start)
this.newChapters[i].title = (this.newChapters[i].title || '').trim()
if (i === 0 && this.newChapters[i].start !== 0) {
this.newChapters[i].error = 'First chapter must start at 0'
this.newChapters[i].error = this.$strings.MessageChapterErrorFirstNotZero
} else if (this.newChapters[i].start <= previousStart && i > 0) {
this.newChapters[i].error = 'Invalid start time must be >= previous chapter start time'
this.newChapters[i].error = this.$strings.MessageChapterErrorStartLtPrev
} else if (this.newChapters[i].start >= this.mediaDuration) {
this.newChapters[i].error = 'Invalid start time must be < duration'
this.newChapters[i].error = this.$strings.MessageChapterErrorStartGteDuration
} else {
this.newChapters[i].error = null
}
previousStart = this.newChapters[i].start
if (hasChanges) {
continue
}
const existingChapter = this.chapters[i]
if (existingChapter) {
const { start, end, title } = this.newChapters[i]
if (start !== existingChapter.start || end !== existingChapter.end || title !== existingChapter.title) {
hasChanges = true
}
} else {
hasChanges = true
}
}
this.hasChanges = hasChanges
},
playChapter(chapter) {
console.log('Play Chapter', chapter.id)
@ -345,8 +401,6 @@ export default {
const audioTrack = this.tracks.find((at) => {
return chapter.start >= at.startOffset && chapter.start < at.startOffset + at.duration
})
console.log('audio track', audioTrack)
this.selectedChapter = chapter
this.isLoadingChapter = true
@ -361,7 +415,6 @@ export default {
if (this.$isDev) {
src = `http://localhost:3333${this.$config.routerBasePath}${src}`
}
console.log('src', src)
audioEl.src = src
audioEl.id = 'chapter-audio'
@ -405,11 +458,11 @@ export default {
for (let i = 0; i < this.newChapters.length; i++) {
if (this.newChapters[i].error) {
this.$toast.error('Chapters have errors')
this.$toast.error(this.$strings.ToastChaptersHaveErrors)
return
}
if (!this.newChapters[i].title) {
this.$toast.error('Chapters must have titles')
this.$toast.error(this.$strings.ToastChaptersMustHaveTitles)
return
}
@ -438,7 +491,7 @@ export default {
this.$router.push(`/item/${this.libraryItem.id}`)
}
} else {
this.$toast.info('No changes needed updating')
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
}
})
.catch((error) => {
@ -456,22 +509,25 @@ export default {
this.showFindChaptersModal = false
this.chapterData = null
this.checkChapters()
},
applyChapterData() {
var index = 0
let index = 0
this.newChapters = this.chapterData.chapters
.filter((chap) => chap.startOffsetSec < this.mediaDuration)
.map((chap) => {
var chapEnd = Math.min(this.mediaDuration, (chap.startOffsetMs + chap.lengthMs) / 1000)
return {
id: index++,
start: chap.startOffsetMs / 1000,
end: chapEnd,
end: Math.min(this.mediaDuration, (chap.startOffsetMs + chap.lengthMs) / 1000),
title: chap.title
}
})
this.showFindChaptersModal = false
this.chapterData = null
this.checkChapters()
},
findChapters() {
if (!this.asinInput) {
@ -505,22 +561,38 @@ export default {
this.$toast.error('Failed to find chapters')
this.showFindChaptersModal = false
})
},
resetChapters() {
const payload = {
message: this.$strings.MessageResetChaptersConfirm,
callback: (confirmed) => {
if (confirmed) {
this.initChapters()
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
initChapters() {
this.newChapters = this.chapters.map((c) => ({ ...c }))
if (!this.newChapters.length) {
this.newChapters = [
{
id: 0,
start: 0,
end: this.mediaDuration,
title: ''
}
]
}
this.checkChapters()
}
},
mounted() {
this.regionInput = localStorage.getItem('audibleRegion') || 'US'
this.asinInput = this.mediaMetadata.asin || null
this.newChapters = this.chapters.map((c) => ({ ...c }))
if (!this.newChapters.length) {
this.newChapters = [
{
id: 0,
start: 0,
end: this.mediaDuration,
title: ''
}
]
}
this.initChapters()
},
beforeDestroy() {
this.destroyAudioEl()

View file

@ -5,73 +5,68 @@
</div>
<div class="w-full h-full overflow-y-auto p-8">
<div class="w-full flex justify-between items-center pb-6 pt-2">
<p class="text-lg">Drag files into correct track order</p>
<ui-btn color="success" @click="saveTracklist">Save Tracklist</ui-btn>
<p class="text-lg">{{ $strings.MessageDragFilesIntoTrackOrder }}</p>
<ui-btn color="success" @click="saveTracklist">{{ $strings.ButtonSaveTracklist }}</ui-btn>
</div>
<div class="w-full flex items-center text-sm py-4 bg-primary border-l border-r border-t border-gray-600">
<div class="font-book text-center px-4 w-12">New</div>
<div class="font-book text-center px-4 w-24 flex items-center cursor-pointer text-white text-opacity-40 hover:text-opacity-100" @click="sortByCurrent" @mousedown.prevent>
<span class="text-white">Current</span>
<div class="text-center px-4 w-12">{{ $strings.LabelNew }}</div>
<div class="text-center px-4 w-24 flex items-center cursor-pointer text-white text-opacity-40 hover:text-opacity-100" @click="sortByCurrent" @mousedown.prevent>
<span class="text-white">{{ $strings.LabelCurrent }}</span>
<span class="material-icons ml-1" :class="currentSort === 'current' ? 'text-white text-opacity-100 text-lg' : 'text-sm'">{{ currentSort === 'current' ? 'expand_more' : 'unfold_more' }}</span>
</div>
<div class="font-book text-center px-4 w-32 flex items-center cursor-pointer text-white text-opacity-40 hover:text-opacity-100" @click="sortByFilenameTrack" @mousedown.prevent>
<span class="text-white">Track From Filename</span>
<div class="text-center px-4 w-32 flex items-center cursor-pointer text-white text-opacity-40 hover:text-opacity-100" @click="sortByFilenameTrack" @mousedown.prevent>
<span class="text-white">{{ $strings.LabelTrackFromFilename }}</span>
<span class="material-icons ml-1" :class="currentSort === 'track-filename' ? 'text-white text-opacity-100 text-lg' : 'text-sm'">{{ currentSort === 'track-filename' ? 'expand_more' : 'unfold_more' }}</span>
</div>
<div class="font-book text-center px-4 w-32 flex items-center cursor-pointer text-white text-opacity-40 hover:text-opacity-100" @click="sortByMetadataTrack" @mousedown.prevent>
<span class="text-white">Track From Metadata</span>
<div class="text-center px-4 w-32 flex items-center cursor-pointer text-white text-opacity-40 hover:text-opacity-100" @click="sortByMetadataTrack" @mousedown.prevent>
<span class="text-white">{{ $strings.LabelTrackFromMetadata }}</span>
<span class="material-icons ml-1" :class="currentSort === 'metadata' ? 'text-white text-opacity-100 text-lg' : 'text-sm'">{{ currentSort === 'metadata' ? 'expand_more' : 'unfold_more' }}</span>
</div>
<div class="font-mono w-20 text-center">Disc From Filename</div>
<div class="font-mono w-20 text-center">Disc From Metadata</div>
<div class="font-book text-center px-4 flex-grow flex items-center cursor-pointer text-white text-opacity-40 hover:text-opacity-100" @click="sortByFilename" @mousedown.prevent>
<span class="text-white">Filename</span>
<div class="w-20 text-center">{{ $strings.LabelDiscFromFilename }}</div>
<div class="w-20 text-center">{{ $strings.LabelDiscFromMetadata }}</div>
<div class="text-center px-4 flex-grow flex items-center cursor-pointer text-white text-opacity-40 hover:text-opacity-100" @click="sortByFilename" @mousedown.prevent>
<span class="text-white">{{ $strings.LabelFilename }}</span>
<span class="material-icons ml-1" :class="currentSort === 'filename' ? 'text-white text-opacity-100 text-lg' : 'text-sm'">{{ currentSort === 'filename' ? 'expand_more' : 'unfold_more' }}</span>
</div>
<!-- <div class="font-book truncate px-4 flex-grow">Filename</div> -->
<div class="font-mono w-20 text-center">Size</div>
<div class="font-mono w-20 text-center">Duration</div>
<div class="font-mono text-center w-20">Status</div>
<div class="font-mono w-56">Notes</div>
<div class="font-book w-40">Include in Tracklist</div>
<div class="w-20 text-center">{{ $strings.LabelSize }}</div>
<div class="w-20 text-center">{{ $strings.LabelDuration }}</div>
<div class="w-56">{{ $strings.LabelNotes }}</div>
<div class="w-40">{{ $strings.LabelIncludeInTracklist }}</div>
</div>
<draggable v-model="files" v-bind="dragOptions" class="list-group border border-gray-600" draggable=".item" tag="ul" @start="drag = true" @end="drag = false" @update="draggableUpdate">
<transition-group type="transition" :name="!drag ? 'flip-list' : null">
<li v-for="(audio, index) in files" :key="audio.ino" :class="audio.include ? 'item' : 'exclude'" class="w-full list-group-item flex items-center relative">
<div class="font-book text-center px-4 py-1 w-12">
<div class="font-book text-center px-4 py-1 w-12 min-w-12">
{{ audio.include ? index - numExcluded + 1 : -1 }}
</div>
<div class="font-book text-center px-4 w-24">{{ audio.index }}</div>
<div class="font-book text-center px-2 w-32">
<div class="font-book text-center px-4 w-24 min-w-24">{{ audio.index }}</div>
<div class="font-book text-center px-2 w-32 min-w-32">
{{ audio.trackNumFromFilename }}
</div>
<div class="font-book text-center w-32">
<div class="font-book text-center w-32 min-w-32">
{{ audio.trackNumFromMeta }}
</div>
<div class="font-book truncate px-4 w-20">
<div class="font-book truncate px-4 w-20 min-w-20">
{{ audio.discNumFromFilename }}
</div>
<div class="font-book truncate px-4 w-20">
<div class="font-book truncate px-4 w-20 min-w-20">
{{ audio.discNumFromMeta }}
</div>
<div class="font-book truncate px-4 flex-grow">
{{ audio.metadata.filename }}
</div>
<div class="font-mono w-20 text-center">
<div class="font-mono w-20 min-w-20 text-center text-xs">
{{ $bytesPretty(audio.metadata.size) }}
</div>
<div class="font-mono w-20">
<div class="font-mono w-20 min-w-20 text-center text-xs">
{{ $secondsToTimestamp(audio.duration) }}
</div>
<div class="font-mono text-center w-20">
<span class="material-icons text-sm" :class="audio.invalid ? 'text-error' : 'text-success'">{{ getStatusIcon(audio) }}</span>
</div>
<div class="font-sans text-xs font-normal w-56">
<div class="font-sans text-xs font-normal w-56 min-w-[224px]">
{{ audio.error }}
</div>
<div class="font-sans text-xs font-normal w-40 flex items-center justify-center">
<div class="font-sans text-xs font-normal w-40 min-w-[160px] flex items-center justify-center">
<ui-toggle-switch v-model="audio.include" :off-color="'error'" @input="includeToggled(audio)" />
</div>
</li>
@ -229,13 +224,6 @@ export default {
console.error('Failed', error)
this.saving = false
})
},
getStatusIcon(audio) {
if (audio.invalid) {
return 'error_outline'
} else {
return 'check_circle'
}
}
}
}

View file

@ -2,7 +2,7 @@
<div id="page-wrapper" class="bg-bg page p-8 overflow-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
<div class="flex items-center justify-center mb-6">
<div class="w-full max-w-2xl">
<p class="text-2xl mb-2">Audiobook File Management Tools</p>
<p class="text-2xl mb-2">{{ $strings.HeaderAudiobookTools }}</p>
</div>
<div class="w-full max-w-2xl">
<div class="flex justify-end">
@ -13,7 +13,7 @@
<div class="flex justify-center">
<div class="w-full max-w-2xl">
<p class="text-xl mb-1">Metadata to embed</p>
<p class="text-xl mb-1">{{ $strings.HeaderMetadataToEmbed }}</p>
<p class="mb-2 text-base text-gray-300">audiobookshelf uses <a href="https://github.com/sandreas/tone" target="_blank" class="hover:underline text-blue-400 hover:text-blue-300">tone</a> to write metadata.</p>
</div>
<div class="w-full max-w-2xl"></div>
@ -22,8 +22,8 @@
<div class="flex justify-center flex-wrap">
<div class="w-full max-w-2xl border border-white border-opacity-10 bg-bg mx-2">
<div class="flex py-2 px-4">
<div class="w-1/3 text-xs font-semibold uppercase text-gray-200">Meta Tag</div>
<div class="w-2/3 text-xs font-semibold uppercase text-gray-200">Value</div>
<div class="w-1/3 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelMetaTag }}</div>
<div class="w-2/3 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelValue }}</div>
</div>
<div class="w-full max-h-72 overflow-auto">
<template v-for="(value, key, index) in toneObject">
@ -38,12 +38,12 @@
</div>
<div class="w-full max-w-2xl border border-white border-opacity-10 bg-bg mx-2">
<div class="flex py-2 px-4 bg-primary bg-opacity-25">
<div class="flex-grow text-xs font-semibold uppercase text-gray-200">Chapter Title</div>
<div class="w-24 text-xs font-semibold uppercase text-gray-200">Start</div>
<div class="w-24 text-xs font-semibold uppercase text-gray-200">End</div>
<div class="flex-grow text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelChapterTitle }}</div>
<div class="w-24 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelStart }}</div>
<div class="w-24 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelEnd }}</div>
</div>
<div class="w-full max-h-72 overflow-auto">
<p v-if="!metadataChapters.length" class="py-5 text-center text-gray-200">No chapters</p>
<p v-if="!metadataChapters.length" class="py-5 text-center text-gray-200">{{ $strings.MessageNoChapters }}</p>
<template v-for="(chapter, index) in metadataChapters">
<div :key="index" class="flex py-1 px-4 text-sm" :class="index % 2 === 1 ? 'bg-primary bg-opacity-25' : ''">
<div class="flex-grow font-semibold">{{ chapter.title }}</div>
@ -63,20 +63,20 @@
<div class="w-full max-w-4xl mx-auto">
<div v-if="selectedTool === 'embed'" class="w-full flex justify-end items-center mb-4">
<ui-btn v-if="!isFinished" color="primary" :loading="processing" @click.stop="embedClick">Start Metadata Embed</ui-btn>
<p v-else class="text-success text-lg font-semibold">Embed Finished!</p>
<ui-btn v-if="!isFinished" color="primary" :loading="processing" @click.stop="embedClick">{{ $strings.ButtonStartMetadataEmbed }}</ui-btn>
<p v-else class="text-success text-lg font-semibold">{{ $strings.MessageEmbedFinished }}</p>
</div>
<div v-else class="w-full flex justify-end items-center mb-4">
<ui-btn v-if="!isTaskFinished && processing" color="error" :loading="isCancelingEncode" class="mr-2" @click.stop="cancelEncodeClick">Cancel Encode</ui-btn>
<ui-btn v-if="!isTaskFinished" color="primary" :loading="processing" @click.stop="encodeM4bClick">Start M4B Encode</ui-btn>
<p v-else-if="taskFailed" class="text-error text-lg font-semibold">M4B Failed! {{ taskError }}</p>
<p v-else class="text-success text-lg font-semibold">M4B Finished!</p>
<ui-btn v-if="!isTaskFinished && processing" color="error" :loading="isCancelingEncode" class="mr-2" @click.stop="cancelEncodeClick">{{ $strings.ButtonCancelEncode }}</ui-btn>
<ui-btn v-if="!isTaskFinished" color="primary" :loading="processing" @click.stop="encodeM4bClick">{{ $strings.ButtonStartM4BEncode }}</ui-btn>
<p v-else-if="taskFailed" class="text-error text-lg font-semibold">{{ $strings.MessageM4BFailed }} {{ taskError }}</p>
<p v-else class="text-success text-lg font-semibold">{{ $strings.MessageM4BFinished }}</p>
</div>
<div class="mb-4">
<div v-if="selectedTool === 'embed'" class="flex items-start mb-2">
<span class="material-icons text-base text-warning pt-1">star</span>
<p class="text-gray-200 ml-2">Metadata will be embedded on the audio tracks inside your audiobook folder.</p>
<p class="text-gray-200 ml-2">Metadata will be embedded in the audio tracks inside your audiobook folder.</p>
</div>
<div v-else class="flex items-start mb-2">
<span class="material-icons text-base text-warning pt-1">star</span>
@ -91,6 +91,10 @@
A backup of your original audio files will be stored in <span class="rounded-md bg-neutral-600 text-sm text-white py-0.5 px-1 font-mono">/metadata/cache/items/{{ libraryItemId }}/</span>. Make sure to periodically purge items cache.
</p>
</div>
<div v-if="selectedTool === 'embed' && audioFiles.length > 1" class="flex items-start mb-2">
<span class="material-icons text-base text-warning pt-1">star</span>
<p class="text-gray-200 ml-2">Chapters are not embedded in multi-track audiobooks.</p>
</div>
<div v-if="selectedTool === 'm4b'" class="flex items-start mb-2">
<span class="material-icons text-base text-warning pt-1">star</span>
<p class="text-gray-200 ml-2">Encoding can take up to 30 minutes.</p>
@ -107,12 +111,12 @@
</div>
<div class="w-full max-w-4xl mx-auto">
<p class="mb-2 font-semibold">Audio Tracks</p>
<p class="mb-2 font-semibold">{{ $strings.HeaderAudioTracks }}</p>
<div class="w-full mx-auto border border-white border-opacity-10 bg-bg">
<div class="flex py-2 px-4 bg-primary bg-opacity-25">
<div class="w-10 text-xs font-semibold text-gray-200">#</div>
<div class="flex-grow text-xs font-semibold uppercase text-gray-200">Filename</div>
<div class="w-16 text-xs font-semibold uppercase text-gray-200">Size</div>
<div class="flex-grow text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelFilename }}</div>
<div class="w-16 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelSize }}</div>
<div class="w-24"></div>
</div>
<template v-for="file in audioFiles">
@ -243,7 +247,7 @@ export default {
cancelEncodeClick() {
this.isCancelingEncode = true
this.$axios
.$post(`/api/encode-m4b/${this.libraryItemId}/cancel`)
.$delete(`/api/tools/item/${this.libraryItemId}/encode-m4b`)
.then(() => {
this.$toast.success('Encode canceled')
})
@ -258,7 +262,7 @@ export default {
encodeM4bClick() {
this.processing = true
this.$axios
.$get(`/api/encode-m4b/${this.libraryItemId}`)
.$post(`/api/tools/item/${this.libraryItemId}/encode-m4b`)
.then(() => {
console.log('Ab m4b merge started')
})
@ -283,7 +287,7 @@ export default {
updateAudioFileMetadata() {
this.processing = true
this.$axios
.$get(`/api/items/${this.libraryItemId}/audio-metadata?tone=1`)
.$post(`/api/tools/item/${this.libraryItemId}/embed-metadata?tone=1`)
.then(() => {
console.log('Audio metadata encode started')
})

View file

@ -16,25 +16,25 @@
</button>
</div>
<p v-if="author.description" class="text-white text-opacity-60 uppercase text-xs mb-2">Description</p>
<p v-if="author.description" class="text-white text-opacity-60 uppercase text-xs mb-2">{{ $strings.LabelDescription }}</p>
<p class="text-white max-w-3xl text-sm leading-5">{{ author.description }}</p>
</div>
</div>
<div class="py-4">
<widgets-item-slider :items="libraryItems" :bookshelf-view="$constants.BookshelfView.AUTHOR">
<widgets-item-slider :items="libraryItems" shelf-id="author-books" :bookshelf-view="$constants.BookshelfView.AUTHOR">
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(author.id)}`" class="hover:underline">
<h2 class="text-lg">{{ libraryItems.length }} Books</h2>
<h2 class="text-lg">{{ libraryItems.length }} {{ $strings.LabelBooks }}</h2>
</nuxt-link>
</widgets-item-slider>
</div>
<div v-for="series in authorSeries" :key="series.id" class="py-4">
<widgets-item-slider :items="series.items" :bookshelf-view="$constants.BookshelfView.AUTHOR">
<widgets-item-slider :items="series.items" :shelf-id="series.id" :bookshelf-view="$constants.BookshelfView.AUTHOR">
<nuxt-link :to="`/library/${currentLibraryId}/series/${series.id}`" class="hover:underline">
<h2 class="text-lg">{{ series.name }}</h2>
</nuxt-link>
<p class="text-white text-opacity-40 text-base px-2">Series</p>
<p class="text-white text-opacity-40 text-base px-2">{{ $strings.LabelSeries }}</p>
</widgets-item-slider>
</div>
</div>

View file

@ -2,56 +2,67 @@
<div ref="page" id="page-wrapper" class="page px-6 pt-6 pb-52 overflow-y-auto" :class="streamLibraryItem ? 'streaming' : ''">
<div class="border border-white border-opacity-10 max-w-7xl mx-auto mb-10 mt-5">
<div class="flex items-center px-4 py-4 cursor-pointer" @click="openMapOptions = !openMapOptions" @mousedown.prevent @mouseup.prevent>
<span class="material-icons">{{ openMapOptions ? 'expand_less' : 'expand_more' }}</span>
<span class="material-icons text-2xl">{{ openMapOptions ? 'expand_less' : 'expand_more' }}</span>
<p class="ml-4 text-gray-200 text-lg">Map details</p>
<p class="ml-4 text-gray-200 text-lg">{{ $strings.HeaderMapDetails }}</p>
<div class="flex-grow" />
<div class="w-64 flex">
<button class="w-32 h-8 rounded-l-md shadow-md border border-gray-600" :class="!isMapOverwrite ? 'bg-bg text-white/30' : 'bg-primary'" @click.stop.prevent="mapDetailsType = 'overwrite'">
<p class="text-sm">{{ $strings.LabelOverwrite }}</p>
</button>
<button class="w-32 h-8 rounded-r-md shadow-md border border-gray-600" :class="!isMapAppend ? 'bg-bg text-white/30' : 'bg-primary'" @click.stop.prevent="mapDetailsType = 'append'">
<p class="text-sm">{{ $strings.LabelAppend }}</p>
</button>
</div>
</div>
<div class="overflow-hidden">
<transition name="slide">
<div v-if="openMapOptions" class="flex flex-wrap">
<div v-if="!isPodcastLibrary" class="flex items-center px-4 w-1/2">
<div v-if="!isPodcastLibrary && !isMapAppend" 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" />
<ui-text-input-with-label ref="subtitleInput" v-model="batchDetails.subtitle" :disabled="!selectedBatchUsage.subtitle" :label="$strings.LabelSubtitle" class="mb-4 ml-4" />
</div>
<div v-if="!isPodcastLibrary" class="flex items-center px-4 w-1/2">
<ui-checkbox v-model="selectedBatchUsage.authors" />
<!-- Authors filter only contains authors in this library, use query input to query all authors -->
<ui-multi-select-query-input ref="authorsSelect" v-model="batchDetails.authors" :disabled="!selectedBatchUsage.authors" label="Authors" endpoint="authors/search" class="mb-4 ml-4" />
<ui-multi-select-query-input ref="authorsSelect" v-model="batchDetails.authors" :disabled="!selectedBatchUsage.authors" :label="$strings.LabelAuthors" endpoint="authors/search" class="mb-4 ml-4" />
</div>
<div v-if="!isPodcastLibrary" class="flex items-center px-4 w-1/2">
<div v-if="!isPodcastLibrary && !isMapAppend" class="flex items-center px-4 w-1/2">
<ui-checkbox v-model="selectedBatchUsage.publishedYear" />
<ui-text-input-with-label ref="publishedYearInput" v-model="batchDetails.publishedYear" :disabled="!selectedBatchUsage.publishedYear" label="Publish Year" class="mb-4 ml-4" />
<ui-text-input-with-label ref="publishedYearInput" v-model="batchDetails.publishedYear" :disabled="!selectedBatchUsage.publishedYear" :label="$strings.LabelPublishYear" class="mb-4 ml-4" />
</div>
<div v-if="!isPodcastLibrary" class="flex items-center px-4 w-1/2">
<ui-checkbox v-model="selectedBatchUsage.series" />
<ui-multi-select ref="seriesSelect" v-model="batchDetails.series" :disabled="!selectedBatchUsage.series" label="Series" :items="seriesItems" @newItem="newSeriesItem" @removedItem="removedSeriesItem" class="mb-4 ml-4" />
<ui-multi-select ref="seriesSelect" v-model="batchDetails.series" :disabled="!selectedBatchUsage.series" :label="$strings.LabelSeries" :items="existingSeriesNames" @newItem="newSeriesItem" @removedItem="removedSeriesItem" 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" />
<ui-multi-select ref="genresSelect" v-model="batchDetails.genres" :disabled="!selectedBatchUsage.genres" :label="$strings.LabelGenres" :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" />
<ui-multi-select ref="tagsSelect" v-model="batchDetails.tags" :label="$strings.LabelTags" :disabled="!selectedBatchUsage.tags" :items="tagItems" @newItem="newTagItem" @removedItem="removedTagItem" class="mb-4 ml-4" />
</div>
<div v-if="!isPodcastLibrary" class="flex items-center px-4 w-1/2">
<ui-checkbox v-model="selectedBatchUsage.narrators" />
<ui-multi-select ref="narratorsSelect" v-model="batchDetails.narrators" :disabled="!selectedBatchUsage.narrators" label="Narrators" :items="narratorItems" @newItem="newNarratorItem" @removedItem="removedNarratorItem" class="mb-4 ml-4" />
<ui-multi-select ref="narratorsSelect" v-model="batchDetails.narrators" :disabled="!selectedBatchUsage.narrators" :label="$strings.LabelNarrators" :items="narratorItems" @newItem="newNarratorItem" @removedItem="removedNarratorItem" class="mb-4 ml-4" />
</div>
<div v-if="!isPodcastLibrary" class="flex items-center px-4 w-1/2">
<div v-if="!isPodcastLibrary && !isMapAppend" 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" />
<ui-text-input-with-label ref="publisherInput" v-model="batchDetails.publisher" :disabled="!selectedBatchUsage.publisher" :label="$strings.LabelPublisher" class="mb-4 ml-4" />
</div>
<div class="flex items-center px-4 w-1/2">
<div v-if="!isMapAppend" 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" />
<ui-text-input-with-label ref="languageInput" v-model="batchDetails.language" :disabled="!selectedBatchUsage.language" :label="$strings.LabelLanguage" class="mb-4 ml-4" />
</div>
<div class="flex items-center px-4 w-1/2">
<div v-if="!isMapAppend" class="flex items-center px-4 w-1/2">
<ui-checkbox v-model="selectedBatchUsage.explicit" />
<div class="ml-4">
<ui-checkbox
v-model="batchDetails.explicit"
label="Explicit"
:label="$strings.LabelExplicit"
:disabled="!selectedBatchUsage.explicit"
:checkbox-bg="!selectedBatchUsage.explicit ? 'bg' : 'primary'"
:check-color="!selectedBatchUsage.explicit ? 'gray-600' : 'green-500'"
@ -62,7 +73,7 @@
</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>
<ui-btn color="success" :disabled="!hasSelectedBatchUsage" :padding-x="8" small class="text-base" :loading="isProcessing" @click="mapBatchDetails">{{ $strings.ButtonApply }}</ui-btn>
</div>
</div>
</transition>
@ -83,7 +94,7 @@
<div :class="isScrollable ? 'fixed left-0 box-shadow-lg-up bg-primary' : ''" class="w-full h-20 px-4 flex items-center border-t border-bg z-40" :style="{ bottom: streamLibraryItem ? '165px' : '0px' }">
<div class="flex-grow" />
<ui-btn color="success" :padding-x="8" class="text-lg" :loading="isProcessing" @click.prevent="saveClick">Save</ui-btn>
<ui-btn color="success" :padding-x="8" class="text-lg" :loading="isProcessing" @click.prevent="saveClick">{{ $strings.ButtonSave }}</ui-btn>
</div>
</div>
</template>
@ -91,14 +102,19 @@
<script>
export default {
async asyncData({ store, redirect, app }) {
if (!store.state.selectedLibraryItems.length) {
if (!store.state.globals.selectedMediaItems.length) {
return redirect('/')
}
var libraryItems = await app.$axios.$post(`/api/items/batch/get`, { libraryItemIds: store.state.selectedLibraryItems }).catch((error) => {
var errorMsg = error.response.data || 'Failed to get items'
console.error(errorMsg, error)
return []
})
const libraryItemIds = store.state.globals.selectedMediaItems.map((i) => i.id)
const libraryItems = await app.$axios
.$post(`/api/items/batch/get`, { libraryItemIds })
.then((res) => res.libraryItems)
.catch((error) => {
const errorMsg = error.response.data || 'Failed to get items'
console.error(errorMsg, error)
return []
})
return {
mediaType: libraryItems[0].mediaType,
libraryItems
@ -109,10 +125,10 @@ export default {
isProcessing: false,
libraryItemCopies: [],
isScrollable: false,
newSeriesNames: [],
newTagItems: [],
newGenreItems: [],
newNarratorItems: [],
mapDetailsType: 'overwrite',
batchDetails: {
subtitle: null,
authors: null,
@ -137,10 +153,17 @@ export default {
language: false,
explicit: false
},
appendableKeys: ['authors', 'genres', 'tags', 'narrators', 'series'],
openMapOptions: false
}
},
computed: {
isMapOverwrite() {
return this.mapDetailsType === 'overwrite'
},
isMapAppend() {
return this.mapDetailsType === 'append'
},
isPodcastLibrary() {
return this.mediaType === 'podcast'
},
@ -153,9 +176,6 @@ export default {
tagItems() {
return this.tags.concat(this.newTagItems)
},
seriesItems() {
return [...this.existingSeriesNames, ...this.newSeriesNames]
},
narratorItems() {
return [...this.narrators, ...this.newNarratorItems]
},
@ -214,31 +234,32 @@ export default {
mapBatchDetails() {
this.blurBatchForm()
var batchMapPayload = {}
const batchMapPayload = {}
for (const key in this.selectedBatchUsage) {
if (this.selectedBatchUsage[key]) {
if (key === 'series') {
// Map string of series to series objects
batchMapPayload[key] = this.batchDetails[key].map((seItem) => {
var existingSeries = this.series.find((se) => se.name.toLowerCase() === seItem.toLowerCase().trim())
if (existingSeries) {
return existingSeries
} else {
return {
id: `new-${Math.floor(Math.random() * 10000)}`,
name: seItem
}
if (!this.selectedBatchUsage[key]) continue
if (this.isMapAppend && !this.appendableKeys.includes(key)) continue
if (key === 'series') {
// Map string of series to series objects
batchMapPayload[key] = this.batchDetails[key].map((seItem) => {
const existingSeries = this.series.find((se) => se.name.toLowerCase() === seItem.toLowerCase().trim())
if (existingSeries) {
return existingSeries
} else {
return {
id: `new-${Math.floor(Math.random() * 10000)}`,
name: seItem
}
})
} else {
batchMapPayload[key] = this.batchDetails[key]
}
}
})
} else {
batchMapPayload[key] = this.batchDetails[key]
}
}
this.libraryItemCopies.forEach((li) => {
var ref = this.getEditFormRef(li.id)
ref.mapBatchDetails(batchMapPayload)
const ref = this.getEditFormRef(li.id)
ref.mapBatchDetails(batchMapPayload, this.mapDetailsType)
})
this.$toast.success('Details mapped')
},
@ -342,7 +363,7 @@ export default {
this.$toast.success(`Successfully updated ${data.updates} items`)
this.$router.replace(`/library/${this.currentLibraryId}/bookshelf`)
} else {
this.$toast.warning('No updates were necessary')
this.$toast.warning(this.$strings.MessageNoUpdatesWereNecessary)
}
})
.catch((error) => {

View file

@ -15,13 +15,15 @@
<div class="flex-grow" />
<ui-btn v-if="showPlayButton" :disabled="streaming" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="clickPlay">
<span v-show="!streaming" class="material-icons -ml-2 pr-1 text-white">play_arrow</span>
{{ streaming ? 'Streaming' : 'Play' }}
<span v-show="!streaming" class="material-icons text-2xl -ml-2 pr-1 text-white">play_arrow</span>
{{ streaming ? $strings.ButtonPlaying : $strings.ButtonPlay }}
</ui-btn>
<ui-icon-btn v-if="userCanUpdate" icon="edit" class="mx-0.5" @click="editClick" />
<button type="button" class="h-9 w-9 flex items-center justify-center shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5 mx-px" @click.stop.prevent="editClick">
<span class="material-icons text-xl">edit</span>
</button>
<ui-icon-btn v-if="userCanDelete" icon="delete" class="mx-0.5" @click="removeClick" />
<ui-context-menu-dropdown :items="contextMenuItems" class="mx-px" @action="contextMenuAction" />
</div>
<div class="my-8 max-w-2xl">
@ -32,7 +34,7 @@
</div>
</div>
</div>
<div v-show="processingRemove" class="absolute top-0 left-0 w-full h-full z-10 bg-black bg-opacity-40 flex items-center justify-center">
<div v-show="processing" class="absolute top-0 left-0 w-full h-full z-10 bg-black bg-opacity-40 flex items-center justify-center">
<ui-loading-indicator />
</div>
</div>
@ -52,15 +54,19 @@ export default {
return redirect('/')
}
store.commit('user/addUpdateCollection', collection)
// If collection is a different library then set library as current
if (collection.libraryId !== store.state.libraries.currentLibraryId) {
await store.dispatch('libraries/fetch', collection.libraryId)
}
store.commit('libraries/addUpdateCollection', collection)
return {
collectionId: collection.id
}
},
data() {
return {
processingRemove: false,
collectionCopy: {}
processing: false
}
},
computed: {
@ -80,7 +86,7 @@ export default {
return this.collection.description || ''
},
collection() {
return this.$store.getters['user/getCollection'](this.collectionId)
return this.$store.getters['libraries/getCollection'](this.collectionId) || {}
},
playableBooks() {
return this.bookItems.filter((book) => {
@ -88,7 +94,7 @@ export default {
})
},
streaming() {
return !!this.playableBooks.find((b) => b.id === this.$store.getters['getLibraryItemIdStreaming'])
return !!this.playableBooks.some((b) => b.id === this.$store.getters['getLibraryItemIdStreaming'])
},
showPlayButton() {
return this.playableBooks.length
@ -98,37 +104,106 @@ export default {
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
contextMenuItems() {
const items = [
{
text: this.$strings.MessagePlaylistCreateFromCollection,
action: 'create-playlist'
}
]
if (this.userCanDelete) {
items.push({
text: this.$strings.ButtonDelete,
action: 'delete'
})
}
return items
}
},
methods: {
contextMenuAction(action) {
if (action === 'delete') {
this.removeClick()
} else if (action === 'create-playlist') {
this.createPlaylistFromCollection()
}
},
createPlaylistFromCollection() {
this.processing = true
this.$axios
.$post(`/api/playlists/collection/${this.collectionId}`)
.then((playlist) => {
if (playlist) {
this.$toast.success(this.$strings.ToastPlaylistCreateSuccess)
this.$router.push(`/playlist/${playlist.id}`)
}
})
.catch((error) => {
const errMsg = error.response ? error.response.data || '' : ''
this.$toast.error(errMsg || this.$strings.ToastPlaylistCreateFailed)
})
.finally(() => {
this.processing = false
})
},
editClick() {
this.$store.commit('globals/setEditCollection', this.collection)
},
removeClick() {
if (confirm(`Are you sure you want to remove collection "${this.collectionName}"?`)) {
this.processingRemove = true
var collectionName = this.collectionName
if (confirm(this.$getString('MessageConfirmRemoveCollection', [this.collectionName]))) {
this.processing = true
this.$axios
.$delete(`/api/collections/${this.collection.id}`)
.then(() => {
this.processingRemove = false
this.$toast.success(`Collection "${collectionName}" Removed`)
this.$toast.success(this.$strings.ToastCollectionRemoveSuccess)
})
.catch((error) => {
console.error('Failed to remove collection', error)
this.processingRemove = false
this.$toast.error(`Failed to remove collection`)
this.$toast.error(this.$strings.ToastCollectionRemoveFailed)
})
.finally(() => {
this.processing = false
})
}
},
clickPlay() {
var nextBookNotRead = this.playableBooks.find((pb) => {
var prog = this.$store.getters['user/getUserMediaProgress'](pb.id)
return !prog || !prog.isFinished
const queueItems = []
// Collection queue will start at the first unfinished book
// if all books are finished then entire collection is queued
const itemsWithProgress = this.playableBooks.map((item) => {
return {
...item,
progress: this.$store.getters['user/getUserMediaProgress'](item.id)
}
})
if (nextBookNotRead) {
const hasUnfinishedItems = itemsWithProgress.some((i) => !i.progress || !i.progress.isFinished)
if (!hasUnfinishedItems) {
console.warn('All items in collection are finished - starting at first item')
}
for (let i = 0; i < itemsWithProgress.length; i++) {
const libraryItem = itemsWithProgress[i]
if (!hasUnfinishedItems || !libraryItem.progress || !libraryItem.progress.isFinished) {
queueItems.push({
libraryItemId: libraryItem.id,
libraryId: libraryItem.libraryId,
episodeId: null,
title: libraryItem.media.metadata.title,
subtitle: libraryItem.media.metadata.authors.map((au) => au.name).join(', '),
caption: '',
duration: libraryItem.media.duration || null,
coverPath: libraryItem.media.coverPath || null
})
}
}
if (queueItems.length >= 0) {
this.$eventBus.$emit('play-item', {
libraryItemId: nextBookNotRead.id
libraryItemId: queueItems[0].libraryItemId,
queueItems
})
}
}
@ -136,4 +211,4 @@ export default {
mounted() {},
beforeDestroy() {}
}
</script>
</script>

View file

@ -3,7 +3,7 @@
<app-config-side-nav :is-open.sync="sideDrawerOpen" />
<div class="configContent" :class="`page-${currentPage}`">
<div v-show="isMobile" class="w-full pb-4 px-2 flex border-b border-white border-opacity-10 mb-2">
<span class="material-icons cursor-pointer" @click.stop.prevent="showMore">more_vert</span>
<span class="material-icons text-2xl cursor-pointer" @click.stop.prevent="showMore">more_vert</span>
<p class="pl-3 capitalize">{{ currentPage }}</p>
</div>
<nuxt-child />
@ -42,10 +42,20 @@ export default {
return this.$store.state.streamLibraryItem
},
currentPage() {
if (!this.$route.name) return 'Settings'
if (!this.$route.name) return this.$strings.HeaderSettings
var routeName = this.$route.name.split('-')
if (routeName.length > 0) return routeName.slice(1).join('-')
return 'Settings'
if (routeName.length > 0) {
const pageName = routeName.slice(1).join('-')
if (pageName === 'log') return this.$strings.HeaderLogs
else if (pageName === 'backups') return this.$strings.HeaderBackups
else if (pageName === 'libraries') return this.$strings.HeaderLibraries
else if (pageName === 'notifications') return this.$strings.HeaderNotifications
else if (pageName === 'sessions') return this.$strings.HeaderListeningSessions
else if (pageName === 'stats') return this.$strings.HeaderYourStats
else if (pageName === 'library-stats') return this.$strings.HeaderLibraryStats
else if (pageName === 'users') return this.$strings.HeaderUsers
}
return this.$strings.HeaderSettings
}
},
methods: {

View file

@ -1,22 +1,16 @@
<template>
<div class="w-full h-full">
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
<div class="flex items-center mb-2">
<h1 class="text-xl">Backups</h1>
</div>
<p class="text-base mb-4 text-gray-300">Backups include users, user progress, library item details, server settings, and images stored in <span class="font-mono text-gray-100">/metadata/items</span> & <span class="font-mono text-gray-100">/metadata/authors</span>. <br />Backups <strong>do not</strong> include any files stored in your library folders.</p>
<div>
<app-settings-content :header-text="$strings.HeaderBackups" :description="$strings.MessageBackupsDescription">
<div class="flex items-center py-2">
<ui-toggle-switch v-model="enableBackups" small :disabled="updatingServerSettings" @input="updateBackupsSettings" />
<ui-tooltip :text="backupsTooltip">
<p class="pl-4 text-lg">Enable automatic backups <span class="material-icons icon-text">info_outlined</span></p>
<ui-tooltip :text="$strings.LabelBackupsEnableAutomaticBackupsHelp">
<p class="pl-4 text-lg">{{ $strings.LabelBackupsEnableAutomaticBackups }} <span class="material-icons icon-text">info_outlined</span></p>
</ui-tooltip>
</div>
<div v-if="enableBackups" class="mb-6">
<div class="flex items-center pl-6">
<span class="material-icons-outlined text-black-50">schedule</span>
<span class="material-icons-outlined text-2xl text-black-50">schedule</span>
<p class="text-gray-100 px-2">{{ scheduleDescription }}</p>
<span class="material-icons text-lg text-black-50 hover:text-yellow-500 cursor-pointer" @click="showCronBuilder = !showCronBuilder">edit</span>
</div>
@ -25,23 +19,21 @@
<div class="flex items-center py-2">
<ui-text-input type="number" v-model="backupsToKeep" no-spinner :disabled="updatingServerSettings" :padding-x="1" text-center class="w-10" @change="updateBackupsSettings" />
<ui-tooltip :text="numBackupsToKeepTooltip">
<p class="pl-4 text-lg">Number of backups to keep <span class="material-icons icon-text">info_outlined</span></p>
<ui-tooltip :text="$strings.LabelBackupsNumberToKeepHelp">
<p class="pl-4 text-lg">{{ $strings.LabelBackupsNumberToKeep }} <span class="material-icons icon-text">info_outlined</span></p>
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-text-input type="number" v-model="maxBackupSize" no-spinner :disabled="updatingServerSettings" :padding-x="1" text-center class="w-10" @change="updateBackupsSettings" />
<ui-tooltip :text="maxBackupSizeTooltip">
<p class="pl-4 text-lg">Maximum backup size (in GB) <span class="material-icons icon-text">info_outlined</span></p>
<ui-tooltip :text="$strings.LabelBackupsMaxBackupSizeHelp">
<p class="pl-4 text-lg">{{ $strings.LabelBackupsMaxBackupSize }} <span class="material-icons icon-text">info_outlined</span></p>
</ui-tooltip>
</div>
<tables-backups-table />
</div>
<modals-backup-schedule-modal v-model="showCronBuilder" :cron-expression.sync="cronExpression" />
</app-settings-content>
</div>
</template>
@ -55,10 +47,7 @@ export default {
maxBackupSize: 1,
cronExpression: '',
newServerSettings: {},
showCronBuilder: false,
backupsTooltip: 'Backups saved in /metadata/backups',
numBackupsToKeepTooltip: 'Only 1 backup will be removed at a time so if you already have more backups than this you should manually remove them.',
maxBackupSizeTooltip: 'As a safeguard against misconfiguration, backups will fail if they exceed the configured size.'
showCronBuilder: false
}
},
watch: {

View file

@ -1,104 +1,105 @@
<template>
<div>
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-2">
<div class="mb-2">
<h1 class="text-xl">Settings</h1>
</div>
<app-settings-content :header-text="$strings.HeaderSettings">
<div class="lg:flex">
<div class="flex-1">
<div class="pt-4">
<h2 class="font-semibold">General</h2>
<h2 class="font-semibold">{{ $strings.HeaderSettingsGeneral }}</h2>
</div>
<div class="flex items-end py-2">
<ui-toggle-switch v-model="newServerSettings.storeCoverWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeCoverWithItem', val)" />
<ui-tooltip :text="tooltips.storeCoverWithItem">
<ui-tooltip :text="$strings.LabelSettingsStoreCoversWithItemHelp">
<p class="pl-4">
Store covers with item
<span class="material-icons icon-text text-sm">info_outlined</span>
{{ $strings.LabelSettingsStoreCoversWithItem }}
<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.storeMetadataWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeMetadataWithItem', val)" />
<ui-tooltip :text="tooltips.storeMetadataWithItem">
<ui-tooltip :text="$strings.LabelSettingsStoreMetadataWithItemHelp">
<p class="pl-4">
Store metadata with item
<span class="material-icons icon-text text-sm">info_outlined</span>
{{ $strings.LabelSettingsStoreMetadataWithItem }}
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.sortingIgnorePrefix" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('sortingIgnorePrefix', val)" />
<ui-tooltip :text="tooltips.sortingIgnorePrefix">
<ui-tooltip :text="$strings.LabelSettingsSortingIgnorePrefixesHelp">
<p class="pl-4">
Ignore prefixes when sorting
<span class="material-icons icon-text text-sm">info_outlined</span>
{{ $strings.LabelSettingsSortingIgnorePrefixes }}
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div v-if="newServerSettings.sortingIgnorePrefix" class="w-72 ml-14 mb-2">
<ui-multi-select v-model="newServerSettings.sortingPrefixes" small :items="newServerSettings.sortingPrefixes" label="Prefixes to Ignore (case insensitive)" @input="updateSortingPrefixes" :disabled="updatingServerSettings" />
<ui-multi-select v-model="newServerSettings.sortingPrefixes" small :items="newServerSettings.sortingPrefixes" :label="$strings.LabelPrefixesToIgnore" @input="updateSortingPrefixes" :disabled="updatingServerSettings" />
</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">Chromecast support</p>
<p class="pl-4">{{ $strings.LabelSettingsChromecastSupport }}</p>
</div>
<div class="pt-4">
<h2 class="font-semibold">Display</h2>
<h2 class="font-semibold">{{ $strings.HeaderSettingsDisplay }}</h2>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="homepageUseBookshelfView" :disabled="updatingServerSettings" @input="updateHomeUseBookshelfView" />
<ui-tooltip :text="tooltips.bookshelfView">
<ui-tooltip :text="$strings.LabelSettingsBookshelfViewHelp">
<p class="pl-4">
Home page use bookshelf view
<span class="material-icons icon-text text-sm">info_outlined</span>
{{ $strings.LabelSettingsHomePageBookshelfView }}
<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="useBookshelfView" :disabled="updatingServerSettings" @input="updateUseBookshelfView" />
<ui-tooltip :text="tooltips.bookshelfView">
<ui-tooltip :text="$strings.LabelSettingsBookshelfViewHelp">
<p class="pl-4">
Library use bookshelf view
<span class="material-icons icon-text text-sm">info_outlined</span>
{{ $strings.LabelSettingsLibraryBookshelfView }}
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<p class="pr-4">Date Format</p>
<ui-dropdown v-model="newServerSettings.dateFormat" :items="dateFormats" small class="max-w-40" @input="(val) => updateSettingsKey('dateFormat', val)" />
<div class="py-2">
<p class="px-1 text-sm font-semibold">{{ $strings.LabelSettingsDateFormat }}</p>
<ui-dropdown v-model="newServerSettings.dateFormat" :items="dateFormats" small class="max-w-52" @input="(val) => updateSettingsKey('dateFormat', val)" />
</div>
<div class="py-2">
<p class="px-1 text-sm font-semibold">{{ $strings.LabelLanguageDefaultServer }}</p>
<ui-dropdown ref="langDropdown" v-model="newServerSettings.language" :items="$languageCodeOptions" small class="max-w-52" @input="updateServerLanguage" />
</div>
</div>
<div class="flex-1">
<div class="pt-4">
<h2 class="font-semibold">Scanner</h2>
<h2 class="font-semibold">{{ $strings.HeaderSettingsScanner }}</h2>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerParseSubtitle" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerParseSubtitle', val)" />
<ui-tooltip :text="tooltips.scannerParseSubtitle">
<ui-tooltip :text="$strings.LabelSettingsParseSubtitlesHelp">
<p class="pl-4">
Parse subtitles
<span class="material-icons icon-text text-sm">info_outlined</span>
{{ $strings.LabelSettingsParseSubtitles }}
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerFindCovers" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerFindCovers', val)" />
<ui-tooltip :text="tooltips.scannerFindCovers">
<ui-tooltip :text="$strings.LabelSettingsFindCoversHelp">
<p class="pl-4">
Find covers
<span class="material-icons icon-text text-sm">info_outlined</span>
{{ $strings.LabelSettingsFindCovers }}
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
<div class="flex-grow" />
@ -109,75 +110,65 @@
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerPreferOverdriveMediaMarker" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferOverdriveMediaMarker', val)" />
<ui-tooltip :text="tooltips.scannerPreferOverdriveMediaMarker">
<ui-tooltip :text="$strings.LabelSettingsOverdriveMediaMarkersHelp">
<p class="pl-4">
Use Overdrive Media Markers for chapters
<span class="material-icons icon-text text-sm">info_outlined</span>
{{ $strings.LabelSettingsOverdriveMediaMarkers }}
<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.scannerPreferAudioMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferAudioMetadata', val)" />
<ui-tooltip :text="tooltips.scannerPreferAudioMetadata">
<ui-tooltip :text="$strings.LabelSettingsPreferAudioMetadataHelp">
<p class="pl-4">
Prefer audio metadata
<span class="material-icons icon-text text-sm">info_outlined</span>
{{ $strings.LabelSettingsPreferAudioMetadata }}
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerPreferOpfMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferOpfMetadata', val)" />
<ui-tooltip :text="tooltips.scannerPreferOpfMetadata">
<ui-tooltip :text="$strings.LabelSettingsPreferOPFMetadataHelp">
<p class="pl-4">
Prefer OPF metadata
<span class="material-icons icon-text text-sm">info_outlined</span>
{{ $strings.LabelSettingsPreferOPFMetadata }}
<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.scannerPreferMatchedMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferMatchedMetadata', val)" />
<ui-tooltip :text="tooltips.scannerPreferMatchedMetadata">
<ui-tooltip :text="$strings.LabelSettingsPreferMatchedMetadataHelp">
<p class="pl-4">
Prefer matched metadata
<span class="material-icons icon-text text-sm">info_outlined</span>
{{ $strings.LabelSettingsPreferMatchedMetadata }}
<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">
<ui-tooltip :text="$strings.LabelSettingsDisableWatcherHelp">
<p class="pl-4">
Disable Watcher
<span class="material-icons icon-text text-sm">info_outlined</span>
{{ $strings.LabelSettingsDisableWatcher }}
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
<!-- <div class="flex items-center py-2">
<ui-text-input type="number" v-model="newServerSettings.scannerMaxThreads" no-spinner :disabled="updatingServerSettings" :padding-x="1" text-center class="w-10" @change="updateScannerMaxThreads" />
<ui-tooltip :text="tooltips.scannerMaxThreads">
<p class="pl-4">
Max # of threads to use
<span class="material-icons icon-text text-sm">info_outlined</span>
</p>
</ui-tooltip>
</div> -->
<div class="pt-4">
<h2 class="font-semibold">Experimental Features</h2>
<h2 class="font-semibold">{{ $strings.HeaderSettingsExperimental }}</h2>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="showExperimentalFeatures" />
<ui-tooltip :text="tooltips.experimentalFeatures">
<ui-tooltip :text="$strings.LabelSettingsExperimentalFeaturesHelp">
<p class="pl-4">
Experimental Features
{{ $strings.LabelSettingsExperimentalFeatures }}
<a href="https://github.com/advplyr/audiobookshelf/discussions/75" target="_blank">
<span class="material-icons icon-text text-sm">info_outlined</span>
<span class="material-icons icon-text">info_outlined</span>
</a>
</p>
</ui-tooltip>
@ -185,10 +176,10 @@
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.enableEReader" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('enableEReader', val)" />
<ui-tooltip :text="tooltips.enableEReader">
<ui-tooltip :text="$strings.LabelSettingsEnableEReaderHelp">
<p class="pl-4">
Enable e-reader for all users
<span class="material-icons icon-text text-sm">info_outlined</span>
{{ $strings.LabelSettingsEnableEReader }}
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
@ -198,26 +189,27 @@
<ui-tooltip text="Tone library for metadata">
<p class="pl-4">
Use Tone library for metadata
<span class="material-icons icon-text text-sm">info_outlined</span>
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div> -->
</div>
</div>
</div>
</app-settings-content>
<div class="h-0.5 bg-primary bg-opacity-30 w-full" />
<div class="flex items-center py-4">
<div class="flex-grow" />
<ui-btn color="bg" small :padding-x="4" class="hidden lg:block mr-2" :loading="isPurgingCache" @click.stop="purgeCache">Purge All Cache</ui-btn>
<ui-btn color="bg" small :padding-x="4" class="hidden lg:block mr-2" :loading="isPurgingCache" @click.stop="purgeItemsCache">Purge Items Cache</ui-btn>
<ui-btn color="bg" small :padding-x="4" class="hidden lg:block mr-2" :loading="isResettingLibraryItems" @click="resetLibraryItems">Remove All Library Items</ui-btn>
<ui-btn color="bg" small :padding-x="4" class="mr-2 text-xs md:text-sm" :loading="isPurgingCache" @click.stop="purgeCache">{{ $strings.ButtonPurgeAllCache }}</ui-btn>
<ui-btn color="bg" small :padding-x="4" class="mr-2 text-xs md:text-sm" :loading="isPurgingCache" @click.stop="purgeItemsCache">{{ $strings.ButtonPurgeItemsCache }}</ui-btn>
<ui-btn color="bg" small :padding-x="4" class="mr-2 text-xs md:text-sm" :loading="isResettingLibraryItems" @click="resetLibraryItems">{{ $strings.ButtonRemoveAllLibraryItems }}</ui-btn>
</div>
<div class="flex items-center py-4">
<div class="flex-grow" />
<p class="pr-2 text-sm font-book text-yellow-400">
Report bugs, request features, and contribute on
{{ $strings.MessageReportBugsAndContribute }}
<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">
@ -228,7 +220,7 @@
</svg>
</a>
<p class="pl-4 pr-2 text-sm font-book text-yellow-400">
Join us on
{{ $strings.MessageJoinUsOn }}
<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">
@ -276,23 +268,6 @@ export default {
useBookshelfView: false,
isPurgingCache: false,
newServerSettings: {},
tooltips: {
experimentalFeatures: 'Features in development that could use your feedback and help testing. Click to open github discussion.',
scannerDisableWatcher: 'Disables the automatic adding/updating of items when file changes are detected. *Requires server restart',
scannerPreferOpfMetadata: 'OPF file metadata will be used for book details over folder names',
scannerPreferMatchedMetadata: 'Matched data will overide book details when using Quick Match',
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"',
sortingIgnorePrefix: 'i.e. for prefix "the" book title "The Book Title" would sort as "Book Title, The"',
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: 'Skeumorphic design with wooden shelves',
storeCoverWithItem: 'By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named "cover" will be kept',
storeMetadataWithItem: 'By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders. Uses .abs file extension',
enableEReader: 'E-reader is still a work in progress, but use this setting to open it up to all your users (or use the "Experimental Features" toggle just for use by you)',
scannerPreferOverdriveMediaMarker: 'MP3 files from Overdrive come with chapter timings embedded as custom metadata. Enabling this will use these tags for chapter timings automatically',
scannerUseSingleThreadedProber: 'The old scanner used a single thread. Leaving it in to use as a comparison for now.',
scannerMaxThreads: 'Number of concurrent media files to scan at a time. Value of 1 will be a slower scan but less CPU usage. <br><br>Value of 0 defaults to # of CPU cores for this server times 2 (i.e. 4-core CPU will be 8)'
},
showConfirmPurgeCache: false
}
},
@ -323,26 +298,6 @@ export default {
}
},
methods: {
updateScannerMaxThreads(val) {
if (!val || isNaN(val)) {
this.$toast.error('Invalid max threads must be a number')
this.newServerSettings.scannerMaxThreads = 0
return
}
if (Number(val) < 0) {
this.$toast.error('Max threads must be >= 0')
this.newServerSettings.scannerMaxThreads = 0
return
}
if (Math.round(Number(val)) !== Number(val)) {
this.$toast.error('Max threads must be an integer')
this.newServerSettings.scannerMaxThreads = 0
return
}
this.updateServerSettings({
scannerMaxThreads: Number(val)
})
},
updateSortingPrefixes(val) {
if (!val || !val.length) {
this.$toast.error('Must have at least 1 prefix')
@ -368,6 +323,9 @@ export default {
bookshelfView: !val ? this.$constants.BookshelfView.DETAIL : this.$constants.BookshelfView.STANDARD
})
},
updateServerLanguage(val) {
this.updateSettingsKey('language', val)
},
updateSettingsKey(key, val) {
this.updateServerSettings({
[key]: val
@ -381,6 +339,11 @@ export default {
console.log('Updated Server Settings', success)
this.updatingServerSettings = false
this.$toast.success('Server settings updated')
if (payload.language) {
// Updating language after save allows for re-rendering
this.$setLanguageCode(payload.language)
}
})
.catch((error) => {
console.error('Failed to update server settings', error)
@ -396,7 +359,7 @@ export default {
this.useBookshelfView = this.newServerSettings.bookshelfView != this.$constants.BookshelfView.DETAIL
},
resetLibraryItems() {
if (confirm('WARNING! This action will remove all library items from the database including any updates or matches you have made. This does not do anything to your actual files. Shall we continue?')) {
if (confirm(this.$strings.MessageRemoveAllItemsWarning)) {
this.isResettingLibraryItems = true
this.$axios
.$delete('/api/items/all')

View file

@ -1,7 +1,8 @@
<template>
<div>
<tables-library-libraries-table @showLibraryModal="setShowLibraryModal" />
<app-settings-content :header-text="$strings.HeaderLibraries" show-add-button @clicked="setShowLibraryModal">
<tables-library-libraries-table @showLibraryModal="setShowLibraryModal" />
</app-settings-content>
<modals-libraries-edit-modal v-model="showLibraryModal" :library="selectedLibrary" />
</div>
</template>

View file

@ -1,66 +1,67 @@
<template>
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
<h1 class="text-xl">Stats for library {{ currentLibraryName }}</h1>
<stats-preview-icons v-if="totalItems" :library-stats="libraryStats" />
<div>
<app-settings-content :header-text="$strings.HeaderLibraryStats + ': ' + currentLibraryName">
<stats-preview-icons v-if="totalItems" :library-stats="libraryStats" />
<div class="flex lg:flex-row flex-wrap justify-between flex-col mt-8">
<div class="w-80 my-6 mx-auto">
<h1 class="text-2xl mb-4 font-book">Top 5 Genres</h1>
<p v-if="!top5Genres.length">No Genres</p>
<template v-for="genre in top5Genres">
<div :key="genre.genre" class="w-full py-2">
<div class="flex items-end mb-1">
<p class="text-2xl font-bold">{{ Math.round((100 * genre.count) / totalItems) }}&nbsp;%</p>
<div class="flex-grow" />
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=genres.${$encode(genre.genre)}`" class="text-base font-book text-white text-opacity-70 hover:underline">
{{ genre.genre }}
</nuxt-link>
<div class="flex lg:flex-row flex-wrap justify-between flex-col mt-8">
<div class="w-80 my-6 mx-auto">
<h1 class="text-2xl mb-4 font-book">{{ $strings.HeaderStatsTop5Genres }}</h1>
<p v-if="!top5Genres.length">{{ $strings.MessageNoGenres }}</p>
<template v-for="genre in top5Genres">
<div :key="genre.genre" class="w-full py-2">
<div class="flex items-end mb-1">
<p class="text-2xl font-bold">{{ Math.round((100 * genre.count) / totalItems) }}&nbsp;%</p>
<div class="flex-grow" />
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=genres.${$encode(genre.genre)}`" class="text-base font-book text-white text-opacity-70 hover:underline">
{{ genre.genre }}
</nuxt-link>
</div>
<div class="w-full rounded-full h-3 bg-primary bg-opacity-50 overflow-hidden">
<div class="bg-yellow-400 h-full rounded-full" :style="{ width: Math.round((100 * genre.count) / totalItems) + '%' }" />
</div>
</div>
<div class="w-full rounded-full h-3 bg-primary bg-opacity-50 overflow-hidden">
<div class="bg-yellow-400 h-full rounded-full" :style="{ width: Math.round((100 * genre.count) / totalItems) + '%' }" />
</template>
</div>
<div class="w-80 my-6 mx-auto">
<h1 class="text-2xl mb-4 font-book">{{ $strings.HeaderStatsTop10Authors }}</h1>
<p v-if="!top10Authors.length">{{ $strings.MessageNoAuthors }}</p>
<template v-for="(author, index) in top10Authors">
<div :key="author.id" class="w-full py-2">
<div class="flex items-center mb-1">
<p class="text-sm font-book text-white text-opacity-70 w-36 pr-2 truncate">
{{ index + 1 }}.&nbsp;&nbsp;&nbsp;&nbsp;<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(author.id)}`" class="hover:underline">{{ author.name }}</nuxt-link>
</p>
<div class="flex-grow rounded-full h-2.5 bg-primary bg-opacity-0 overflow-hidden">
<div class="bg-yellow-400 h-full rounded-full" :style="{ width: Math.round((100 * author.count) / mostUsedAuthorCount) + '%' }" />
</div>
<div class="w-4 ml-3">
<p class="text-sm font-bold">{{ author.count }}</p>
</div>
</div>
</div>
</div>
</template>
</template>
</div>
<div class="w-80 my-6 mx-auto">
<h1 class="text-2xl mb-4 font-book">{{ $strings.HeaderStatsLongestItems }}</h1>
<p v-if="!top10LongestItems.length">{{ $strings.MessageNoItems }}</p>
<template v-for="(ab, index) in top10LongestItems">
<div :key="index" class="w-full py-2">
<div class="flex items-center mb-1">
<p class="text-sm font-book text-white text-opacity-70 w-44 pr-2 truncate">
{{ index + 1 }}.&nbsp;&nbsp;&nbsp;&nbsp;<nuxt-link :to="`/item/${ab.id}`" class="hover:underline">{{ ab.title }}</nuxt-link>
</p>
<div class="flex-grow rounded-full h-2.5 bg-primary bg-opacity-0 overflow-hidden">
<div class="bg-yellow-400 h-full rounded-full" :style="{ width: Math.round((100 * ab.duration) / longestItemDuration) + '%' }" />
</div>
<div class="w-4 ml-3">
<p class="text-sm font-bold">{{ (ab.duration / 3600).toFixed(1) }}</p>
</div>
</div>
</div>
</template>
</div>
</div>
<div class="w-80 my-6 mx-auto">
<h1 class="text-2xl mb-4 font-book">Top 10 Authors</h1>
<p v-if="!top10Authors.length">No Authors</p>
<template v-for="(author, index) in top10Authors">
<div :key="author.id" class="w-full py-2">
<div class="flex items-center mb-1">
<p class="text-sm font-book text-white text-opacity-70 w-36 pr-2 truncate">
{{ index + 1 }}.&nbsp;&nbsp;&nbsp;&nbsp;<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(author.id)}`" class="hover:underline">{{ author.name }}</nuxt-link>
</p>
<div class="flex-grow rounded-full h-2.5 bg-primary bg-opacity-0 overflow-hidden">
<div class="bg-yellow-400 h-full rounded-full" :style="{ width: Math.round((100 * author.count) / mostUsedAuthorCount) + '%' }" />
</div>
<div class="w-4 ml-3">
<p class="text-sm font-bold">{{ author.count }}</p>
</div>
</div>
</div>
</template>
</div>
<div class="w-80 my-6 mx-auto">
<h1 class="text-2xl mb-4 font-book">Longest Items (hrs)</h1>
<p v-if="!top10LongestItems.length">No Items</p>
<template v-for="(ab, index) in top10LongestItems">
<div :key="index" class="w-full py-2">
<div class="flex items-center mb-1">
<p class="text-sm font-book text-white text-opacity-70 w-44 pr-2 truncate">
{{ index + 1 }}.&nbsp;&nbsp;&nbsp;&nbsp;<nuxt-link :to="`/item/${ab.id}`" class="hover:underline">{{ ab.title }}</nuxt-link>
</p>
<div class="flex-grow rounded-full h-2.5 bg-primary bg-opacity-0 overflow-hidden">
<div class="bg-yellow-400 h-full rounded-full" :style="{ width: Math.round((100 * ab.duration) / longestItemDuration) + '%' }" />
</div>
<div class="w-4 ml-3">
<p class="text-sm font-bold">{{ (ab.duration / 3600).toFixed(1) }}</p>
</div>
</div>
</div>
</template>
</div>
</div>
</app-settings-content>
</div>
</template>

View file

@ -1,9 +1,6 @@
<template>
<div class="w-full h-full">
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
<div class="flex items-center mb-2">
<h1 class="text-xl">Logs</h1>
</div>
<div>
<app-settings-content :header-text="$strings.HeaderLogs">
<div class="flex justify-between mb-2 place-items-end">
<ui-text-input ref="input" v-model="search" placeholder="Search filter.." @input="inputUpdate" clearable class="w-full sm:w-40 h-8 text-sm sm:mb-0" />
@ -22,10 +19,10 @@
</div>
<div v-if="!logs.length" class="absolute top-0 left-0 w-full h-full flex flex-col items-center justify-center text-center">
<p class="text-xl text-gray-200 mb-2">No Logs</p>
<p class="text-xl text-gray-200 mb-2">{{ $strings.MessageNoLogs }}</p>
</div>
</div>
</div>
</app-settings-content>
</div>
</template>
@ -38,20 +35,6 @@ export default {
searchText: null,
newServerSettings: {},
logColors: ['yellow-200', 'gray-400', 'info', 'warning', 'error', 'red-800', 'blue-400'],
logLevels: [
{
value: 1,
text: 'Debug'
},
{
value: 2,
text: 'Info'
},
{
value: 3,
text: 'Warn'
}
],
loadedLogs: []
}
},
@ -66,6 +49,22 @@ export default {
}
},
computed: {
logLevels() {
return [
{
value: 1,
text: this.$strings.LabelLogLevelDebug
},
{
value: 2,
text: this.$strings.LabelLogLevelInfo
},
{
value: 3,
text: this.$strings.LabelLogLevelWarn
}
]
},
logLevelItems() {
if (process.env.NODE_ENV === 'production') return this.logLevels
this.logLevels.unshift({ text: 'Trace', value: 0 })

View file

@ -1,50 +1,44 @@
<template>
<div>
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-3 md:p-8 mb-2 max-w-3xl mx-auto">
<h2 class="text-xl font-semibold mb-4">Apprise Notification Settings</h2>
<p class="mb-6 text-gray-200">
In order to use this feature you will need to have an instance of <a href="https://github.com/caronc/apprise-api" target="_blank" class="hover:underline text-blue-400 hover:text-blue-300">Apprise API</a> running or an api that will handle those same requests. <br />The Apprise API Url should be the full URL path to send the notification, e.g., if your API instance is served at
<span class="rounded-md bg-neutral-600 text-sm text-white py-0.5 px-1 font-mono">http://192.168.1.1:8337</span> then you would put <span class="rounded-md bg-neutral-600 text-sm text-white py-0.5 px-1 font-mono">http://192.168.1.1:8337/notify</span>.
</p>
<app-settings-content :header-text="$strings.HeaderAppriseNotificationSettings" :description="$strings.MessageAppriseDescription">
<form @submit.prevent="submitForm">
<ui-text-input-with-label ref="apiUrlInput" v-model="appriseApiUrl" :disabled="savingSettings" label="Apprise API Url" class="mb-2" />
<div class="flex items-center py-2">
<ui-text-input ref="maxNotificationQueueInput" type="number" v-model="maxNotificationQueue" no-spinner :disabled="savingSettings" :padding-x="1" text-center class="w-10" />
<ui-tooltip text="Events are limited to firing 1 per second. Events will be ignored if the queue is at max size. This prevents notification spamming." direction="right">
<p class="pl-2 md:pl-4 text-base md:text-lg">Max queue size for notification events<span class="material-icons icon-text ml-1">info_outlined</span></p>
<ui-tooltip :text="$strings.LabelNotificationsMaxQueueSizeHelp" direction="right">
<p class="pl-2 md:pl-4 text-base md:text-lg">{{ $strings.LabelNotificationsMaxQueueSize }}<span class="material-icons icon-text ml-1">info_outlined</span></p>
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-text-input ref="maxFailedAttemptsInput" type="number" v-model="maxFailedAttempts" no-spinner :disabled="savingSettings" :padding-x="1" text-center class="w-10" />
<ui-tooltip text="Notifications are disabled once they fail to send this many times." direction="right">
<p class="pl-2 md:pl-4 text-base md:text-lg">Max failed attempts<span class="material-icons icon-text ml-1">info_outlined</span></p>
<ui-tooltip :text="$strings.LabelNotificationsMaxFailedAttemptsHelp" direction="right">
<p class="pl-2 md:pl-4 text-base md:text-lg">{{ $strings.LabelNotificationsMaxFailedAttempts }}<span class="material-icons icon-text ml-1">info_outlined</span></p>
</ui-tooltip>
</div>
<div class="flex items-center justify-end pt-4">
<ui-btn :loading="savingSettings" type="submit">Save</ui-btn>
<ui-btn :loading="savingSettings" type="submit">{{ $strings.ButtonSave }}</ui-btn>
</div>
</form>
<div class="w-full h-px bg-white bg-opacity-10 my-6" />
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold">Notifications</h2>
<ui-btn small color="success" class="flex items-center" @click="clickCreate">Create <span class="material-icons text-lg pl-2">add</span></ui-btn>
<h2 class="text-xl font-semibold">{{ $strings.HeaderNotifications }}</h2>
<ui-btn small color="success" class="flex items-center" @click="clickCreate">{{ $strings.ButtonCreate }} <span class="material-icons text-lg pl-2">add</span></ui-btn>
</div>
<div v-if="!notifications.length" class="flex justify-center text-center">
<p class="text-lg text-gray-200">No notifications</p>
<p class="text-lg text-gray-200">{{ $strings.MessageNoNotifications }}</p>
</div>
<template v-for="notification in notifications">
<cards-notification-card :key="notification.id" :notification="notification" @update="updateSettings" @edit="editNotification" />
</template>
</div>
</app-settings-content>
<modals-notification-edit-modal v-model="showEditModal" :notification="selectedNotification" :notification-data="notificationData" @update="updateSettings" />
</div>

View file

@ -1,24 +1,20 @@
<template>
<div class="w-full h-full">
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
<div class="flex items-center mb-2">
<h1 class="text-xl">Listening Sessions</h1>
</div>
<div>
<app-settings-content :header-text="$strings.HeaderListeningSessions">
<div class="flex justify-end mb-2">
<ui-dropdown v-model="selectedUser" :items="userItems" label="Filter by User" small class="max-w-48" @input="updateUserFilter" />
<ui-dropdown v-model="selectedUser" :items="userItems" :label="$strings.LabelFilterByUser" small class="max-w-48" @input="updateUserFilter" />
</div>
<div v-if="listeningSessions.length" class="block max-w-full">
<table class="userSessionsTable">
<tr class="bg-primary bg-opacity-40">
<th class="w-48 min-w-48 text-left">Item</th>
<th class="w-20 min-w-20 text-left hidden md:table-cell">User</th>
<th class="w-32 min-w-32 text-left hidden md:table-cell">Play Method</th>
<th class="w-32 min-w-32 text-left hidden sm:table-cell">Device Info</th>
<th class="w-32 min-w-32">Listened</th>
<th class="w-16 min-w-16">Last Time</th>
<th class="flex-grow hidden sm:table-cell">Last Update</th>
<th class="w-48 min-w-48 text-left">{{ $strings.LabelItem }}</th>
<th class="w-20 min-w-20 text-left hidden md:table-cell">{{ $strings.LabelUser }}</th>
<th class="w-32 min-w-32 text-left hidden md:table-cell">{{ $strings.LabelPlayMethod }}</th>
<th class="w-32 min-w-32 text-left hidden sm:table-cell">{{ $strings.LabelDeviceInfo }}</th>
<th class="w-32 min-w-32">{{ $strings.LabelTimeListened }}</th>
<th class="w-16 min-w-16">{{ $strings.LabelLastTime }}</th>
<th class="flex-grow hidden sm:table-cell">{{ $strings.LabelLastUpdate }}</th>
</tr>
<tr v-for="session in listeningSessions" :key="session.id" class="cursor-pointer" @click="showSession(session)">
@ -55,8 +51,8 @@
<ui-icon-btn icon="arrow_forward_ios" :size="7" icon-font-size="1rem" class="mx-1" :disabled="currentPage >= numPages - 1" @click="nextPage" />
</div>
</div>
<p v-else class="text-white text-opacity-50">No sessions yet...</p>
</div>
<p v-else class="text-white text-opacity-50">{{ $strings.MessageNoListeningSessions }}</p>
</app-settings-content>
<modals-listening-session-modal v-model="showSessionModal" :session="selectedSession" @removedSession="removedSession" />
</div>
@ -65,10 +61,10 @@
<script>
export default {
async asyncData({ params, redirect, app }) {
var users = await app.$axios
const users = await app.$axios
.$get('/api/users')
.then((users) => {
return users.sort((a, b) => {
.then((res) => {
return res.users.sort((a, b) => {
return a.createdAt - b.createdAt
})
})
@ -88,6 +84,7 @@ export default {
numPages: 0,
total: 0,
currentPage: 0,
itemsPerPage: 10,
userFilter: null,
selectedUser: '',
processingGoToTimestamp: false
@ -101,7 +98,7 @@ export default {
return this.$store.getters['users/getIsUserOnline'](this.user.id)
},
userItems() {
var userItems = [{ value: '', text: 'All Users' }]
var userItems = [{ value: '', text: this.$strings.LabelAllUsers }]
return userItems.concat(this.users.map((u) => ({ value: u.id, text: u.username })))
},
filteredUserUsername() {
@ -112,6 +109,16 @@ export default {
},
methods: {
removedSession() {
// If on last page and this was the last session then load prev page
if (this.currentPage == this.numPages - 1) {
const newTotal = this.total - 1
const newNumPages = Math.ceil(newTotal / this.itemsPerPage)
if (newNumPages < this.numPages) {
this.prevPage()
return
}
}
this.loadSessions(this.currentPage)
},
async clickCurrentTime(session) {
@ -127,20 +134,47 @@ export default {
this.processingGoToTimestamp = false
return
}
if (session.episodeId && !libraryItem.media.episodes.find((ep) => ep.id === session.episodeId)) {
if (session.episodeId && !libraryItem.media.episodes.some((ep) => ep.id === session.episodeId)) {
this.$toast.error('Failed to get podcast episode')
this.processingGoToTimestamp = false
return
}
var queueItem = {}
if (session.episodeId) {
var episode = libraryItem.media.episodes.find((ep) => ep.id === session.episodeId)
queueItem = {
libraryItemId: libraryItem.id,
libraryId: libraryItem.libraryId,
episodeId: episode.id,
title: episode.title,
subtitle: libraryItem.media.metadata.title,
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, 'MMM do, yyyy')}` : 'Unknown publish date',
duration: episode.audioFile.duration || null,
coverPath: libraryItem.media.coverPath || null
}
} else {
queueItem = {
libraryItemId: libraryItem.id,
libraryId: libraryItem.libraryId,
episodeId: null,
title: libraryItem.media.metadata.title,
subtitle: libraryItem.media.metadata.authors.map((au) => au.name).join(', '),
caption: '',
duration: libraryItem.media.duration || null,
coverPath: libraryItem.media.coverPath || null
}
}
const payload = {
message: `Start playback for "${session.displayTitle}" at ${this.$secondsToTimestamp(session.currentTime)}?`,
message: this.$getString('MessageStartPlaybackAtTime', [session.displayTitle, this.$secondsToTimestamp(session.currentTime)]),
callback: (confirmed) => {
if (confirmed) {
this.$eventBus.$emit('play-item', {
libraryItemId: libraryItem.id,
episodeId: session.episodeId || null,
startTime: session.currentTime
startTime: session.currentTime,
queueItems: [queueItem]
})
}
this.processingGoToTimestamp = false
@ -181,7 +215,7 @@ export default {
},
async loadSessions(page) {
var userFilterQuery = this.selectedUser ? `&user=${this.selectedUser}` : ''
const data = await this.$axios.$get(`/api/sessions?page=${page}&itemsPerPage=10${userFilterQuery}`).catch((err) => {
const data = await this.$axios.$get(`/api/sessions?page=${page}&itemsPerPage=${this.itemsPerPage}${userFilterQuery}`).catch((err) => {
console.error('Failed to load listening sesions', err)
return null
})

View file

@ -1,69 +1,68 @@
<template>
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
<h1 class="text-xl">Stats for {{ username }}</h1>
<div>
<app-settings-content :header-text="$strings.HeaderYourStats">
<div class="flex justify-center">
<div class="flex p-2">
<svg class="hidden sm:block h-14 w-14 lg:h-18 lg:w-18" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M19 1L14 6V17L19 12.5V1M21 5V18.5C19.9 18.15 18.7 18 17.5 18C15.8 18 13.35 18.65 12 19.5V6C10.55 4.9 8.45 4.5 6.5 4.5C4.55 4.5 2.45 4.9 1 6V20.65C1 20.9 1.25 21.15 1.5 21.15C1.6 21.15 1.65 21.1 1.75 21.1C3.1 20.45 5.05 20 6.5 20C8.45 20 10.55 20.4 12 21.5C13.35 20.65 15.8 20 17.5 20C19.15 20 20.85 20.3 22.25 21.05C22.35 21.1 22.4 21.1 22.5 21.1C22.75 21.1 23 20.85 23 20.6V6C22.4 5.55 21.75 5.25 21 5M10 18.41C8.75 18.09 7.5 18 6.5 18C5.44 18 4.18 18.19 3 18.5V7.13C3.91 6.73 5.14 6.5 6.5 6.5C7.86 6.5 9.09 6.73 10 7.13V18.41Z"
/>
</svg>
<div class="px-3">
<p class="text-4xl md:text-5xl font-bold">{{ userItemsFinished.length }}</p>
<p class="font-book text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsItemsFinished }}</p>
</div>
</div>
<div class="flex justify-center">
<div class="flex p-2">
<svg class="hidden sm:block h-14 w-14 lg:h-18 lg:w-18" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M19 1L14 6V17L19 12.5V1M21 5V18.5C19.9 18.15 18.7 18 17.5 18C15.8 18 13.35 18.65 12 19.5V6C10.55 4.9 8.45 4.5 6.5 4.5C4.55 4.5 2.45 4.9 1 6V20.65C1 20.9 1.25 21.15 1.5 21.15C1.6 21.15 1.65 21.1 1.75 21.1C3.1 20.45 5.05 20 6.5 20C8.45 20 10.55 20.4 12 21.5C13.35 20.65 15.8 20 17.5 20C19.15 20 20.85 20.3 22.25 21.05C22.35 21.1 22.4 21.1 22.5 21.1C22.75 21.1 23 20.85 23 20.6V6C22.4 5.55 21.75 5.25 21 5M10 18.41C8.75 18.09 7.5 18 6.5 18C5.44 18 4.18 18.19 3 18.5V7.13C3.91 6.73 5.14 6.5 6.5 6.5C7.86 6.5 9.09 6.73 10 7.13V18.41Z"
/>
</svg>
<div class="px-3">
<p class="text-4xl md:text-5xl font-bold">{{ userItemsFinished.length }}</p>
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Items Finished</p>
<div class="flex p-2">
<div class="hidden sm:block">
<span class="hidden sm:block material-icons-outlined text-5xl lg:text-6xl">event</span>
</div>
<div class="px-1">
<p class="text-4xl md:text-5xl font-bold">{{ totalDaysListened }}</p>
<p class="font-book text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsDaysListened }}</p>
</div>
</div>
<div class="flex p-2">
<div class="hidden sm:block">
<span class="material-icons-outlined text-5xl lg:text-6xl">watch_later</span>
</div>
<div class="px-1">
<p class="text-4xl md:text-5xl font-bold">{{ totalMinutesListening }}</p>
<p class="font-book text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsMinutesListening }}</p>
</div>
</div>
</div>
<div class="flex p-2">
<div class="hidden sm:block">
<span class="hidden sm:block material-icons-outlined text-5xl lg:text-6xl">event</span>
</div>
<div class="px-1">
<p class="text-4xl md:text-5xl font-bold">{{ totalDaysListened }}</p>
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Days Listened</p>
</div>
</div>
<div class="flex p-2">
<div class="hidden sm:block">
<span class="material-icons-outlined text-5xl lg:text-6xl">watch_later</span>
</div>
<div class="px-1">
<p class="text-4xl md:text-5xl font-bold">{{ totalMinutesListening }}</p>
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Minutes Listening</p>
</div>
</div>
</div>
<div class="flex flex-col md:flex-row overflow-hidden max-w-full">
<stats-daily-listening-chart :listening-stats="listeningStats" class="origin-top-left transform scale-75 lg:scale-100" />
<div class="w-80 my-6 mx-auto">
<div class="flex mb-4 items-center">
<h1 class="text-2xl font-book">Recent Sessions</h1>
<div class="flex-grow" />
<ui-btn :to="`/config/users/${user.id}/sessions`" class="text-xs" :padding-x="1.5" :padding-y="1">View All</ui-btn>
</div>
<p v-if="!mostRecentListeningSessions.length">No Listening Sessions</p>
<template v-for="(item, index) in mostRecentListeningSessions">
<div :key="item.id" class="w-full py-0.5">
<div class="flex items-center mb-1">
<p class="text-sm font-book text-white text-opacity-70 w-8">{{ index + 1 }}.&nbsp;</p>
<div class="w-56">
<p class="text-sm font-book text-white text-opacity-80 truncate">{{ item.mediaMetadata ? item.mediaMetadata.title : '' }}</p>
<p class="text-xs text-white text-opacity-50">{{ $dateDistanceFromNow(item.updatedAt) }}</p>
</div>
<div class="flex-grow" />
<div class="w-18 text-right">
<p class="text-sm font-bold">{{ $elapsedPretty(item.timeListening) }}</p>
<div class="flex flex-col md:flex-row overflow-hidden max-w-full">
<stats-daily-listening-chart :listening-stats="listeningStats" class="origin-top-left transform scale-75 lg:scale-100" />
<div class="w-80 my-6 mx-auto">
<div class="flex mb-4 items-center">
<h1 class="text-2xl font-book">{{ $strings.HeaderStatsRecentSessions }}</h1>
<div class="flex-grow" />
<ui-btn :to="`/config/users/${user.id}/sessions`" class="text-xs" :padding-x="1.5" :padding-y="1">{{ $strings.ButtonViewAll }}</ui-btn>
</div>
<p v-if="!mostRecentListeningSessions.length">{{ $strings.MessageNoListeningSessions }}</p>
<template v-for="(item, index) in mostRecentListeningSessions">
<div :key="item.id" class="w-full py-0.5">
<div class="flex items-center mb-1">
<p class="text-sm font-book text-white text-opacity-70 w-8">{{ index + 1 }}.&nbsp;</p>
<div class="w-56">
<p class="text-sm font-book text-white text-opacity-80 truncate">{{ item.mediaMetadata ? item.mediaMetadata.title : '' }}</p>
<p class="text-xs text-white text-opacity-50">{{ $dateDistanceFromNow(item.updatedAt) }}</p>
</div>
<div class="flex-grow" />
<div class="w-18 text-right">
<p class="text-sm font-bold">{{ $elapsedPretty(item.timeListening) }}</p>
</div>
</div>
</div>
</div>
</template>
</template>
</div>
</div>
</div>
<stats-heatmap v-if="listeningStats" :days-listening="listeningStats.days" class="my-2" />
<stats-heatmap v-if="listeningStats" :days-listening="listeningStats.days" class="my-2" />
</app-settings-content>
</div>
</template>

View file

@ -6,7 +6,7 @@
<div class="h-10 w-10 flex items-center justify-center">
<span class="material-icons text-2xl">arrow_back</span>
</div>
<p class="pl-1">All Users</p>
<p class="pl-1">{{ $strings.LabelAllUsers }}</p>
</div>
</nuxt-link>
<div class="flex items-center mb-2 mt-4 px-2 sm:px-0">
@ -22,22 +22,22 @@
</div>
<div class="w-full h-px bg-white bg-opacity-10 my-2" />
<div class="py-2">
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Listening Stats</h1>
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">{{ $strings.HeaderListeningStats }}</h1>
<div class="flex items-center">
<p class="text-sm text-gray-300">{{ listeningSessions.length }} Listening Sessions</p>
<ui-btn :to="`/config/users/${user.id}/sessions`" class="text-xs mx-2" :padding-x="1.5" :padding-y="1">View All</ui-btn>
<p class="text-sm text-gray-300">{{ listeningSessions.total }} {{ $strings.HeaderListeningSessions }}</p>
<ui-btn :to="`/config/users/${user.id}/sessions`" class="text-xs mx-2" :padding-x="1.5" :padding-y="1">{{ $strings.ButtonViewAll }}</ui-btn>
</div>
<p class="text-sm text-gray-300">
Total Time Listened:&nbsp;
{{ $strings.LabelTotalTimeListened }}:&nbsp;
<span class="font-mono text-base">{{ listeningTimePretty }}</span>
</p>
<p v-if="timeListenedToday" class="text-sm text-gray-300">
Time Listened Today:&nbsp;
{{ $strings.LabelTimeListenedToday }}:&nbsp;
<span class="font-mono text-base">{{ $elapsedPrettyExtended(timeListenedToday) }}</span>
</p>
<div v-if="latestSession" class="mt-4">
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Last Listening Session</h1>
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">{{ $strings.HeaderLastListeningSession }}</h1>
<p class="text-sm text-gray-300">
<strong>{{ latestSession.displayTitle }}</strong> {{ $dateDistanceFromNow(latestSession.updatedAt) }} for <span class="font-mono text-base">{{ $elapsedPrettyExtended(this.latestSession.timeListening) }}</span>
</p>
@ -45,21 +45,21 @@
</div>
<div class="w-full h-px bg-white bg-opacity-10 my-2" />
<div class="py-2">
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Saved Media Progress</h1>
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">{{ $strings.HeaderSavedMediaProgress }}</h1>
<div v-if="mediaProgressWithoutMedia.length" class="flex items-center py-2 mb-2">
<p class="text-error">User has media progress for {{ mediaProgressWithoutMedia.length }} items that no longer exist.</p>
<div class="flex-grow" />
<ui-btn small :loading="purgingMediaProgress" @click.stop="purgeMediaProgress">Purge Media Progress</ui-btn>
<ui-btn small :loading="purgingMediaProgress" @click.stop="purgeMediaProgress">{{ $strings.ButtonPurgeMediaProgress }}</ui-btn>
</div>
<table v-if="mediaProgressWithMedia.length" class="userAudiobooksTable">
<tr class="bg-primary bg-opacity-40">
<th class="w-16 text-left">Item</th>
<th class="w-16 text-left">{{ $strings.LabelItem }}</th>
<th class="text-left"></th>
<th class="w-32">Progress</th>
<th class="w-40 hidden sm:table-cell">Started At</th>
<th class="w-40 hidden sm:table-cell">Last Update</th>
<th class="w-32">{{ $strings.LabelProgress }}</th>
<th class="w-40 hidden sm:table-cell">{{ $strings.LabelStartedAt }}</th>
<th class="w-40 hidden sm:table-cell">{{ $strings.LabelLastUpdate }}</th>
</tr>
<tr v-for="item in mediaProgressWithMedia" :key="item.id" :class="!item.isFinished ? '' : 'isFinished'">
<td>
@ -90,7 +90,7 @@
</td>
</tr>
</table>
<p v-else class="text-white text-opacity-50">Nothing listened to yet...</p>
<p v-else class="text-white text-opacity-50">{{ $strings.MessageNoMediaProgress }}</p>
</div>
</div>
</div>
@ -110,7 +110,7 @@ export default {
},
data() {
return {
listeningSessions: [],
listeningSessions: {},
listeningStats: {},
purgingMediaProgress: false
}
@ -147,8 +147,8 @@ export default {
return this.listeningStats.today || 0
},
latestSession() {
if (!this.listeningSessions.length) return null
return this.listeningSessions[0]
if (!this.listeningSessions.sessions || !this.listeningSessions.sessions.length) return null
return this.listeningSessions.sessions[0]
}
},
methods: {
@ -159,11 +159,11 @@ export default {
this.listeningSessions = await this.$axios
.$get(`/api/users/${this.user.id}/listening-sessions?page=0&itemsPerPage=10`)
.then((data) => {
return data.sessions || []
return data || {}
})
.catch((err) => {
console.error('Failed to load listening sesions', err)
return []
return {}
})
this.listeningStats = await this.$axios.$get(`/api/users/${this.user.id}/listening-stats`).catch((err) => {
console.error('Failed to load listening sesions', err)
@ -221,4 +221,4 @@ export default {
padding: 4px 8px;
font-size: 0.75rem;
}
</style>
</style>

View file

@ -6,7 +6,7 @@
<div class="h-10 w-10 flex items-center justify-center">
<span class="material-icons text-2xl">arrow_back</span>
</div>
<p class="pl-1">Back to User</p>
<p class="pl-1">{{ $strings.LabelBackToUser }}</p>
</div>
</nuxt-link>
<div class="flex items-center mb-2 mt-4 px-2 sm:px-0">
@ -17,16 +17,16 @@
<div class="w-full h-px bg-white bg-opacity-10 my-2" />
<div class="py-2">
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Listening Sessions</h1>
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">{{ $strings.HeaderListeningSessions }}</h1>
<div v-if="listeningSessions.length">
<table class="userSessionsTable">
<tr class="bg-primary bg-opacity-40">
<th class="w-48 min-w-48 text-left">Item</th>
<th class="w-32 min-w-32 text-left hidden md:table-cell">Play Method</th>
<th class="w-32 min-w-32 text-left hidden sm:table-cell">Device Info</th>
<th class="w-32 min-w-32">Listened</th>
<th class="w-16 min-w-16">Last Time</th>
<th class="flex-grow hidden sm:table-cell">Last Update</th>
<th class="w-48 min-w-48 text-left">{{ $strings.LabelItem }}</th>
<th class="w-32 min-w-32 text-left hidden md:table-cell">{{ $strings.LabelPlayMethod }}</th>
<th class="w-32 min-w-32 text-left hidden sm:table-cell">{{ $strings.LabelDeviceInfo }}</th>
<th class="w-32 min-w-32">{{ $strings.LabelTimeListened }}</th>
<th class="w-16 min-w-16">{{ $strings.LabelLastTime }}</th>
<th class="flex-grow hidden sm:table-cell">{{ $strings.LabelLastUpdate }}</th>
</tr>
<tr v-for="session in listeningSessions" :key="session.id" class="cursor-pointer" @click="showSession(session)">
<td class="py-1 max-w-48">
@ -86,6 +86,7 @@ export default {
numPages: 0,
total: 0,
currentPage: 0,
itemsPerPage: 10,
processingGoToTimestamp: false
}
},
@ -99,6 +100,16 @@ export default {
},
methods: {
removedSession() {
// If on last page and this was the last session then load prev page
if (this.currentPage == this.numPages - 1) {
const newTotal = this.total - 1
const newNumPages = Math.ceil(newTotal / this.itemsPerPage)
if (newNumPages < this.numPages) {
this.prevPage()
return
}
}
this.loadSessions(this.currentPage)
},
async clickCurrentTime(session) {
@ -114,20 +125,47 @@ export default {
this.processingGoToTimestamp = false
return
}
if (session.episodeId && !libraryItem.media.episodes.find((ep) => ep.id === session.episodeId)) {
if (session.episodeId && !libraryItem.media.episodes.some((ep) => ep.id === session.episodeId)) {
this.$toast.error('Failed to get podcast episode')
this.processingGoToTimestamp = false
return
}
var queueItem = {}
if (session.episodeId) {
var episode = libraryItem.media.episodes.find((ep) => ep.id === session.episodeId)
queueItem = {
libraryItemId: libraryItem.id,
libraryId: libraryItem.libraryId,
episodeId: episode.id,
title: episode.title,
subtitle: libraryItem.media.metadata.title,
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, 'MMM do, yyyy')}` : 'Unknown publish date',
duration: episode.audioFile.duration || null,
coverPath: libraryItem.media.coverPath || null
}
} else {
queueItem = {
libraryItemId: libraryItem.id,
libraryId: libraryItem.libraryId,
episodeId: null,
title: libraryItem.media.metadata.title,
subtitle: libraryItem.media.metadata.authors.map((au) => au.name).join(', '),
caption: '',
duration: libraryItem.media.duration || null,
coverPath: libraryItem.media.coverPath || null
}
}
const payload = {
message: `Start playback for "${session.displayTitle}" at ${this.$secondsToTimestamp(session.currentTime)}?`,
message: this.$getString('MessageStartPlaybackAtTime', [session.displayTitle, this.$secondsToTimestamp(session.currentTime)]),
callback: (confirmed) => {
if (confirmed) {
this.$eventBus.$emit('play-item', {
libraryItemId: libraryItem.id,
episodeId: session.episodeId || null,
startTime: session.currentTime
startTime: session.currentTime,
queueItems: [queueItem]
})
}
this.processingGoToTimestamp = false
@ -164,7 +202,7 @@ export default {
return 'Unknown'
},
async loadSessions(page) {
const data = await this.$axios.$get(`/api/users/${this.user.id}/listening-sessions?page=${page}&itemsPerPage=10`).catch((err) => {
const data = await this.$axios.$get(`/api/users/${this.user.id}/listening-sessions?page=${page}&itemsPerPage=${this.itemsPerPage}`).catch((err) => {
console.error('Failed to load listening sesions', err)
return null
})

View file

@ -1,16 +1,27 @@
<template>
<div>
<tables-users-table />
<app-settings-content :header-text="$strings.HeaderUsers" show-add-button @clicked="setShowUserModal">
<tables-users-table />
</app-settings-content>
<modals-account-modal ref="accountModal" v-model="showAccountModal" :account="selectedAccount" />
</div>
</template>
<script>
export default {
data() {
return {}
return {
selectedAccount: null,
showAccountModal: false
}
},
computed: {},
methods: {},
methods: {
setShowUserModal(selectedAccount) {
this.selectedAccount = selectedAccount
this.showAccountModal = true
}
},
mounted() {}
}
</script>

View file

@ -34,7 +34,7 @@
<template v-if="!isVideo">
<p v-if="isPodcast" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">by {{ podcastAuthor || 'Unknown' }}</p>
<p v-else-if="authors.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">
<p v-else-if="authors.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl max-w-[calc(100vw-2rem)] overflow-hidden overflow-ellipsis">
by <nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">,&nbsp;</span></nuxt-link>
</p>
<p v-else class="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</p>
@ -42,9 +42,9 @@
<div v-if="narrator" class="flex py-0.5 mt-4">
<div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Narrated By</span>
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelNarrators }}</span>
</div>
<div>
<div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis">
<template v-for="(narrator, index) in narrators">
<nuxt-link :key="narrator" :to="`/library/${libraryId}/bookshelf?filter=narrators.${$encode(narrator)}`" class="hover:underline">{{ narrator }}</nuxt-link
><span :key="index" v-if="index < narrators.length - 1">,&nbsp;</span>
@ -53,7 +53,7 @@
</div>
<div v-if="publishedYear" class="flex py-0.5">
<div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Publish Year</span>
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelPublishYear }}</span>
</div>
<div>
{{ publishedYear }}
@ -61,9 +61,9 @@
</div>
<div class="flex py-0.5" v-if="genres.length">
<div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Genres</span>
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelGenres }}</span>
</div>
<div>
<div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis">
<template v-for="(genre, index) in genres">
<nuxt-link :key="genre" :to="`/library/${libraryId}/bookshelf?filter=genres.${$encode(genre)}`" class="hover:underline">{{ genre }}</nuxt-link
><span :key="index" v-if="index < genres.length - 1">,&nbsp;</span>
@ -72,7 +72,7 @@
</div>
<div v-if="tracks.length" class="flex py-0.5">
<div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Duration</span>
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelDuration }}</span>
</div>
<div>
{{ durationPretty }}
@ -80,7 +80,7 @@
</div>
<div class="flex py-0.5">
<div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Size</span>
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelSize }}</span>
</div>
<div>
{{ sizePretty }}
@ -100,7 +100,7 @@
<!-- Podcast episode downloads queue -->
<div v-if="episodeDownloadsQueued.length" class="px-4 py-2 mt-4 bg-info bg-opacity-40 text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0">
<div class="flex items-center">
<p class="text-sm py-1">{{ episodeDownloadsQueued.length }} Episode{{ episodeDownloadsQueued.length === 1 ? '' : 's' }} queued for download</p>
<p class="text-sm py-1">{{ $getString('MessageEpisodesQueuedForDownload', [episodeDownloadsQueued.length]) }}</p>
<span v-if="userIsAdminOrUp" class="material-icons hover:text-error text-xl ml-3 cursor-pointer" @click="clearDownloadQueue">close</span>
</div>
@ -110,16 +110,16 @@
<div v-if="episodesDownloading.length" class="px-4 py-2 mt-4 bg-success bg-opacity-20 text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0">
<div v-for="episode in episodesDownloading" :key="episode.id" class="flex items-center">
<widgets-loading-spinner />
<p class="text-sm py-1 pl-4">Downloading episode "{{ episode.episodeDisplayTitle }}"</p>
<p class="text-sm py-1 pl-4">{{ $strings.MessageDownloadingEpisode }} "{{ episode.episodeDisplayTitle }}"</p>
</div>
</div>
<!-- Progress -->
<div v-if="!isPodcast && progressPercent > 0" class="px-4 py-2 mt-4 bg-primary text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0" :class="resettingProgress ? 'opacity-25' : ''">
<p v-if="progressPercent < 1" class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p>
<p v-else class="text-xs">Finished {{ $formatDate(userProgressFinishedAt, dateFormat) }}</p>
<p v-if="progressPercent < 1" class="text-gray-200 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p>
<p class="text-gray-400 text-xs pt-1">Started {{ $formatDate(userProgressStartedAt, dateFormat) }}</p>
<p v-if="progressPercent < 1" class="leading-6">{{ $strings.LabelYourProgress }}: {{ Math.round(progressPercent * 100) }}%</p>
<p v-else class="text-xs">{{ $strings.LabelFinished }} {{ $formatDate(userProgressFinishedAt, dateFormat) }}</p>
<p v-if="progressPercent < 1" class="text-gray-200 text-xs">{{ $getString('LabelTimeRemaining', [$elapsedPretty(userTimeRemaining)]) }}</p>
<p class="text-gray-400 text-xs pt-1">{{ $strings.LabelStarted }} {{ $formatDate(userProgressStartedAt, dateFormat) }}</p>
<div v-if="!resettingProgress" class="absolute -top-1.5 -right-1.5 p-1 w-5 h-5 rounded-full bg-bg hover:bg-error border border-primary flex items-center justify-center cursor-pointer" @click.stop="clearProgressClick">
<span class="material-icons text-sm">close</span>
@ -129,42 +129,50 @@
<!-- Icon buttons -->
<div class="flex items-center justify-center md:justify-start pt-4">
<ui-btn v-if="showPlayButton" :disabled="isStreaming" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="playItem">
<span v-show="!isStreaming" class="material-icons -ml-2 pr-1 text-white">play_arrow</span>
{{ isStreaming ? 'Playing' : 'Play' }}
<span v-show="!isStreaming" class="material-icons text-2xl -ml-2 pr-1 text-white">play_arrow</span>
{{ isStreaming ? $strings.ButtonPlaying : $strings.ButtonPlay }}
</ui-btn>
<ui-btn v-else-if="isMissing || isInvalid" color="error" :padding-x="4" small class="flex items-center h-9 mr-2">
<span v-show="!isStreaming" class="material-icons -ml-2 pr-1 text-white">error</span>
{{ isMissing ? 'Missing' : 'Incomplete' }}
<span v-show="!isStreaming" class="material-icons text-2xl -ml-2 pr-1 text-white">error</span>
{{ isMissing ? $strings.LabelMissing : $strings.LabelIncomplete }}
</ui-btn>
<ui-tooltip v-if="showQueueBtn" :text="isQueued ? $strings.ButtonQueueRemoveItem : $strings.ButtonQueueAddItem" direction="top">
<ui-icon-btn :icon="isQueued ? 'playlist_add_check' : 'playlist_play'" :bg-color="isQueued ? 'primary' : 'success bg-opacity-60'" class="mx-0.5" :class="isQueued ? 'text-success' : ''" @click="queueBtnClick" />
</ui-tooltip>
<ui-btn v-if="showReadButton" color="info" :padding-x="4" small class="flex items-center h-9 mr-2" @click="openEbook">
<span class="material-icons -ml-2 pr-2 text-white">auto_stories</span>
Read
<span class="material-icons text-2xl -ml-2 pr-2 text-white">auto_stories</span>
{{ $strings.ButtonRead }}
</ui-btn>
<ui-tooltip v-if="userCanUpdate" text="Edit" direction="top">
<ui-tooltip v-if="userCanUpdate" :text="$strings.LabelEdit" direction="top">
<ui-icon-btn icon="edit" class="mx-0.5" @click="editClick" />
</ui-tooltip>
<ui-tooltip v-if="!isPodcast" :text="userIsFinished ? 'Mark as Not Finished' : 'Mark as Finished'" direction="top">
<ui-tooltip v-if="!isPodcast" :text="userIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="top">
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" class="mx-0.5" @click="toggleFinished" />
</ui-tooltip>
<ui-tooltip v-if="!isPodcast && userCanUpdate" text="Collections" direction="top">
<ui-tooltip v-if="!isPodcast && userCanUpdate" :text="$strings.LabelCollections" direction="top">
<ui-icon-btn icon="collections_bookmark" class="mx-0.5" outlined @click="collectionsClick" />
</ui-tooltip>
<ui-tooltip v-if="!isPodcast && tracks.length" :text="$strings.LabelYourPlaylists" direction="top">
<ui-icon-btn icon="playlist_add" class="mx-0.5" outlined @click="playlistsClick" />
</ui-tooltip>
<!-- Only admin or root user can download new episodes -->
<ui-tooltip v-if="isPodcast && userIsAdminOrUp" text="Find Episodes" direction="top">
<ui-tooltip v-if="isPodcast && userIsAdminOrUp" :text="$strings.LabelFindEpisodes" direction="top">
<ui-icon-btn icon="search" class="mx-0.5" :loading="fetchingRSSFeed" outlined @click="findEpisodesClick" />
</ui-tooltip>
<ui-tooltip v-if="bookmarks.length" text="Your Bookmarks" direction="top">
<ui-tooltip v-if="bookmarks.length" :text="$strings.LabelYourBookmarks" direction="top">
<ui-icon-btn :icon="bookmarks.length ? 'bookmarks' : 'bookmark_border'" class="mx-0.5" @click="clickBookmarksBtn" />
</ui-tooltip>
<!-- RSS feed -->
<ui-tooltip v-if="showRssFeedBtn" text="Open RSS Feed" direction="top">
<ui-tooltip v-if="showRssFeedBtn" :text="$strings.LabelOpenRSSFeed" direction="top">
<ui-icon-btn icon="rss_feed" class="mx-0.5" :bg-color="rssFeedUrl ? 'success' : 'primary'" outlined @click="clickRSSFeed" />
</ui-tooltip>
</div>
@ -398,6 +406,9 @@ export default {
isStreaming() {
return this.streamLibraryItem && this.streamLibraryItem.id === this.libraryItemId
},
isQueued() {
return this.$store.getters['getIsMediaQueued'](this.libraryItemId)
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
@ -412,6 +423,10 @@ export default {
// If rss feed is open then show feed url to users otherwise just show to admins
return this.userIsAdminOrUp || this.rssFeedUrl
},
showQueueBtn() {
if (this.isPodcast || this.isVideo) return false
return !this.$store.getters['getIsStreamingFromDifferentLibrary'] && this.streamLibraryItem
}
},
methods: {
@ -507,12 +522,12 @@ export default {
.$patch(`/api/me/progress/${this.libraryItemId}`, updatePayload)
.then(() => {
this.isProcessingReadUpdate = false
this.$toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
this.$toast.success(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedSuccess : this.$strings.ToastItemMarkedAsNotFinishedSuccess)
})
.catch((error) => {
console.error('Failed', error)
this.isProcessingReadUpdate = false
this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
this.$toast.error(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedFailed : this.$strings.ToastItemMarkedAsNotFinishedFailed)
})
},
playItem(startTime = null) {
@ -536,6 +551,7 @@ export default {
if (!podcastProgress || !podcastProgress.isFinished) {
queueItems.push({
libraryItemId: this.libraryItemId,
libraryId: this.libraryId,
episodeId: episode.id,
title: episode.title,
subtitle: this.title,
@ -545,6 +561,18 @@ export default {
})
}
}
} else {
const queueItem = {
libraryItemId: this.libraryItemId,
libraryId: this.libraryId,
episodeId: null,
title: this.title,
subtitle: this.authors.map((au) => au.name).join(', '),
caption: '',
duration: this.duration || null,
coverPath: this.media.coverPath || null
}
queueItems.push(queueItem)
}
this.$eventBus.$emit('play-item', {
@ -582,7 +610,11 @@ export default {
},
collectionsClick() {
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
this.$store.commit('globals/setShowUserCollectionsModal', true)
this.$store.commit('globals/setShowCollectionsModal', true)
},
playlistsClick() {
this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem }])
this.$store.commit('globals/setShowPlaylistsModal', true)
},
clickRSSFeed() {
this.showRssFeedModal = true
@ -615,6 +647,26 @@ export default {
console.log('RSS Feed Closed', data)
this.rssFeedUrl = null
}
},
queueBtnClick() {
if (this.isQueued) {
// Remove from queue
this.$store.commit('removeItemFromQueue', { libraryItemId: this.libraryItemId })
} else {
// Add to queue
const queueItem = {
libraryItemId: this.libraryItemId,
libraryId: this.libraryId,
episodeId: null,
title: this.title,
subtitle: this.authors.map((au) => au.name).join(', '),
caption: '',
duration: this.duration || null,
coverPath: this.media.coverPath || null
}
this.$store.commit('addItemToQueue', queueItem)
}
}
},
mounted() {

View file

@ -48,10 +48,13 @@ export default {
},
methods: {
async init() {
this.authors = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/authors`).catch((error) => {
console.error('Failed to load authors', error)
return []
})
this.authors = await this.$axios
.$get(`/api/libraries/${this.currentLibraryId}/authors`)
.then((response) => response.authors)
.catch((error) => {
console.error('Failed to load authors', error)
return []
})
this.loading = false
},
authorAdded(author) {

View file

@ -7,7 +7,7 @@
<script>
export default {
async asyncData({ params, query, store, app, redirect }) {
async asyncData({ params, query, store, redirect }) {
var libraryId = params.library
var libraryData = await store.dispatch('libraries/fetch', libraryId)
if (!libraryData) {
@ -15,18 +15,14 @@ export default {
}
// Set series sort by
if (params.id === 'series') {
console.log('Series page', query)
if (query.sort) {
store.commit('libraries/setSeriesSortBy', query.sort)
store.commit('libraries/setSeriesSortDesc', !!query.desc)
if (query.filter || query.sort || query.desc) {
const isSeries = params.id === 'series'
const settingsUpdate = {
[isSeries ? 'seriesFilterBy' : 'filterBy']: query.filter || undefined,
[isSeries ? 'seriesSortBy' : 'orderBy']: query.sort || undefined,
[isSeries ? 'seriesSortDesc' : 'orderDesc']: query.desc == '0' ? false : query.desc == '1' ? true : undefined
}
if (query.filter) {
console.log('has filter', query.filter)
store.commit('libraries/setSeriesFilterBy', query.filter)
}
} else if (query.filter) {
store.dispatch('user/updateUserSettings', { filterBy: query.filter })
store.dispatch('user/updateUserSettings', settingsUpdate)
}
// Redirect podcast libraries

View file

@ -1,31 +1,49 @@
<template>
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
<div class="page" :class="libraryItemIdStreaming ? 'streaming' : ''">
<app-book-shelf-toolbar page="recent-episodes" />
<div id="bookshelf" class="w-full overflow-y-auto px-2 py-6 sm:px-4 md:p-12 relative">
<div class="w-full max-w-3xl mx-auto py-4">
<p class="text-xl mb-2 font-semibold">Latest episodes</p>
<p v-if="!recentEpisodes.length && !processing" class="text-center text-xl">No podcasts found</p>
<p class="text-xl mb-2 font-semibold px-4 md:px-0">{{ $strings.HeaderLatestEpisodes }}</p>
<p v-if="!recentEpisodes.length && !processing" class="text-center text-xl">{{ $strings.MessageNoEpisodes }}</p>
<template v-for="(episode, index) in episodesMapped">
<div :key="episode.id" class="flex py-5 cursor-pointer relative" @click.stop="clickEpisode(episode)">
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](episode.libraryItemId)" :width="96" :book-cover-aspect-ratio="bookCoverAspectRatio" :show-resolution="false" />
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](episode.libraryItemId)" :width="96" :book-cover-aspect-ratio="bookCoverAspectRatio" :show-resolution="false" class="hidden md:block" />
<div class="flex-grow pl-4 max-w-2xl">
<nuxt-link :to="`/item/${episode.libraryItemId}`" class="text-sm text-gray-200 hover:underline">{{ episode.podcast.metadata.title }}</nuxt-link>
<!-- mobile -->
<div class="flex md:hidden mb-2">
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](episode.libraryItemId)" :width="48" :book-cover-aspect-ratio="bookCoverAspectRatio" :show-resolution="false" class="md:hidden" />
<div class="flex-grow px-2">
<nuxt-link :to="`/item/${episode.libraryItemId}`" class="text-sm text-gray-200 hover:underline">{{ episode.podcast.metadata.title }}</nuxt-link>
<p class="text-xs text-gray-300 mb-1">{{ $dateDistanceFromNow(episode.publishedAt) }}</p>
<p class="text-xs text-gray-300 mb-1">{{ $dateDistanceFromNow(episode.publishedAt) }}</p>
</div>
</div>
<!-- desktop -->
<div class="hidden md:block">
<nuxt-link :to="`/item/${episode.libraryItemId}`" class="text-sm text-gray-200 hover:underline">{{ episode.podcast.metadata.title }}</nuxt-link>
<p class="font-semibold mb-2">{{ episode.title }}</p>
<p class="text-xs text-gray-300 mb-1">{{ $dateDistanceFromNow(episode.publishedAt) }}</p>
</div>
<p class="font-semibold mb-2 text-sm md:text-base">{{ episode.title }}</p>
<p class="text-sm text-gray-200 mb-4">{{ episode.subtitle }}</p>
<button class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer focus:outline-none" :class="episode.progress && episode.progress.isFinished ? 'text-white text-opacity-40' : ''" @click.stop="playClick(episode)">
<span v-if="episodeIdStreaming === episode.id" class="material-icons" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
<span v-else class="material-icons text-success">play_arrow</span>
<p class="pl-2 pr-1 text-sm font-semibold">{{ getButtonText(episode) }}</p>
</button>
<div class="flex items-center">
<button class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer focus:outline-none" :class="episode.progress && episode.progress.isFinished ? 'text-white text-opacity-40' : ''" @click.stop="playClick(episode)">
<span v-if="episodeIdStreaming === episode.id" class="material-icons text-2xl" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
<span v-else class="material-icons text-2xl text-success">play_arrow</span>
<p class="pl-2 pr-1 text-sm font-semibold">{{ getButtonText(episode) }}</p>
</button>
<button v-if="libraryItemIdStreaming && !isStreamingFromDifferentLibrary" class="h-8 w-8 flex justify-center items-center mx-2" :class="playerQueueEpisodeIdMap[episode.id] ? 'text-success' : ''" @click.stop="queueBtnClick(episode)">
<span class="material-icons-outlined text-2xl">{{ playerQueueEpisodeIdMap[episode.id] ? 'playlist_add_check' : 'playlist_add' }}</span>
</button>
</div>
</div>
<div v-if="episode.progress" class="absolute bottom-0 left-0 h-0.5 pointer-events-none bg-warning" :style="{ width: episode.progress.progress * 100 + '%' }" />
<div v-if="episode.progress" class="absolute bottom-0 left-0 h-0.5 pointer-events-none bg-warning" :style="{ width: episode.progress.progress * 100 + '%' }" />
</div>
<div :key="index" v-if="index !== recentEpisodes.length" class="w-full h-px bg-white bg-opacity-10" />
</template>
@ -63,9 +81,6 @@ export default {
}
},
computed: {
streamLibraryItem() {
return this.$store.state.streamLibraryItem
},
bookCoverAspectRatio() {
return this.$store.getters['libraries/getBookCoverAspectRatio']
},
@ -78,6 +93,9 @@ export default {
streamIsPlaying() {
return this.$store.state.streamIsPlaying
},
isStreamingFromDifferentLibrary() {
return this.$store.getters['getIsStreamingFromDifferentLibrary']
},
episodesMapped() {
return this.recentEpisodes.map((ep) => {
return {
@ -85,6 +103,16 @@ export default {
progress: this.$store.getters['user/getUserMediaProgress'](ep.libraryItemId, ep.id)
}
})
},
playerQueueItems() {
return this.$store.state.playerQueueItems || []
},
playerQueueEpisodeIdMap() {
const episodeIds = {}
this.playerQueueItems.forEach((i) => {
if (i.episodeId) episodeIds[i.episodeId] = true
})
return episodeIds
}
},
methods: {
@ -124,6 +152,7 @@ export default {
if (!episode.progress || !episode.isFinished) {
queueItems.push({
libraryItemId: episode.libraryItemId,
libraryId: episode.libraryId,
episodeId: episode.id,
title: episode.title,
subtitle: episode.podcast.metadata.title,
@ -152,6 +181,25 @@ export default {
this.recentEpisodes = episodePayload.episodes || []
this.totalEpisodes = episodePayload.total
this.currentPage = page
},
queueBtnClick(episode) {
if (this.playerQueueEpisodeIdMap[episode.id]) {
// Remove from queue
this.$store.commit('removeItemFromQueue', { libraryItemId: episode.libraryItemId, episodeId: episode.id })
} else {
// Add to queue
const queueItem = {
libraryItemId: episode.libraryItemId,
libraryId: episode.libraryId,
episodeId: episode.id,
title: episode.title,
subtitle: episode.podcast.metadata.title,
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, 'MMM do, yyyy')}` : 'Unknown publish date',
duration: episode.duration || null,
coverPath: episode.podcast.coverPath || null
}
this.$store.commit('addItemToQueue', queueItem)
}
}
},
mounted() {

View file

@ -6,14 +6,14 @@
<div class="w-full max-w-4xl mx-auto flex">
<form @submit.prevent="submit" class="flex flex-grow">
<ui-text-input v-model="searchInput" :disabled="processing" placeholder="Enter search term or RSS feed URL" class="flex-grow mr-2 text-sm md:text-base" />
<ui-btn type="submit" :disabled="processing" class="hidden md:block">Submit</ui-btn>
<ui-btn type="submit" :disabled="processing" class="block md:hidden" small>Submit</ui-btn>
<ui-btn type="submit" :disabled="processing" class="hidden md:block">{{ $strings.ButtonSubmit }}</ui-btn>
<ui-btn type="submit" :disabled="processing" class="block md:hidden" small>{{ $strings.ButtonSubmit }}</ui-btn>
</form>
<ui-file-input ref="fileInput" :accept="'.opml, .txt'" class="ml-2" @change="opmlFileUpload"> Upload OPML File </ui-file-input>
<ui-file-input ref="fileInput" :accept="'.opml, .txt'" class="ml-2" @change="opmlFileUpload">{{ $strings.ButtonUploadOPMLFile }}</ui-file-input>
</div>
<div class="w-full max-w-3xl mx-auto py-4">
<p v-if="termSearched && !results.length && !processing" class="text-center text-xl">No podcasts found</p>
<p v-if="termSearched && !results.length && !processing" class="text-center text-xl">{{ $strings.MessageNoPodcastsFound }}</p>
<template v-for="podcast in results">
<div :key="podcast.id" class="flex p-1 hover:bg-primary hover:bg-opacity-25 cursor-pointer" @click="selectPodcast(podcast)">
<div class="w-20 min-w-20 h-20 md:w-24 md:min-w-24 md:h-24 bg-primary">
@ -23,7 +23,7 @@
<a :href="podcast.pageUrl" class="text-base md:text-lg text-gray-200 hover:underline" target="_blank" @click.stop>{{ podcast.title }}</a>
<p class="text-sm md:text-base text-gray-300 whitespace-nowrap truncate">by {{ podcast.artistName }}</p>
<p class="text-xs text-gray-400 leading-5">{{ podcast.genres.join(', ') }}</p>
<p class="text-xs text-gray-400 leading-5">{{ podcast.trackCount }} Episodes</p>
<p class="text-xs text-gray-400 leading-5">{{ podcast.trackCount }} {{ $strings.HeaderEpisodes }}</p>
</div>
</div>
</template>

View file

@ -3,7 +3,7 @@
<app-book-shelf-toolbar is-home page="search" :search-query="query" />
<app-book-shelf-categorized v-if="hasResults" ref="bookshelf" search :results="results" />
<div v-else class="w-full py-16">
<p class="text-xl text-center">No Search results for "{{ query }}"</p>
<p class="text-xl text-center">{{ $getString('MessageNoSearchResultsFor', [query]) }}</p>
</div>
</div>
</template>

View file

@ -2,7 +2,7 @@
<div class="w-full h-screen bg-bg">
<div class="w-full flex h-full items-center justify-center">
<div v-if="criticalError" class="w-full max-w-md rounded border border-error border-opacity-25 bg-error bg-opacity-10 p-4">
<p class="text-center text-lg font-semibold">Server could not be reached</p>
<p class="text-center text-lg font-semibold">{{ $strings.MessageServerCouldNotBeReached }}</p>
</div>
<div v-else-if="showInitScreen" class="w-full max-w-lg px-4 md:px-8 pb-8 pt-4">
<p class="text-3xl text-white text-center mb-4">Initial Server Setup</p>
@ -19,22 +19,22 @@
<ui-text-input-with-label v-model="MetadataPath" label="Metadata Path" disabled class="w-full mb-3 text-sm" />
<div class="w-full flex justify-end py-3">
<ui-btn type="submit" :disabled="processing" color="primary" class="leading-none">{{ processing ? 'Initializing...' : 'Submit' }}</ui-btn>
<ui-btn type="submit" :disabled="processing" color="primary" class="leading-none">{{ processing ? 'Initializing...' : $strings.ButtonSubmit }}</ui-btn>
</div>
</form>
</div>
<div v-else-if="isInit" class="w-full max-w-md px-8 pb-8 pt-4 -mt-40">
<p class="text-3xl text-white text-center mb-4">Login</p>
<p class="text-3xl text-white text-center mb-4">{{ $strings.HeaderLogin }}</p>
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
<p v-if="error" class="text-error text-center py-2">{{ error }}</p>
<form @submit.prevent="submitForm">
<label class="text-xs text-gray-300 uppercase">Username</label>
<label class="text-xs text-gray-300 uppercase">{{ $strings.LabelUsername }}</label>
<ui-text-input v-model="username" :disabled="processing" class="mb-3 w-full" />
<label class="text-xs text-gray-300 uppercase">Password</label>
<label class="text-xs text-gray-300 uppercase">{{ $strings.LabelPassword }}</label>
<ui-text-input v-model="password" type="password" :disabled="processing" class="w-full mb-3" />
<div class="w-full flex justify-end py-3">
<ui-btn type="submit" :disabled="processing" color="primary" class="leading-none">{{ processing ? 'Checking...' : 'Submit' }}</ui-btn>
<ui-btn type="submit" :disabled="processing" color="primary" class="leading-none">{{ processing ? 'Checking...' : $strings.ButtonSubmit }}</ui-btn>
</div>
</form>
</div>
@ -128,6 +128,7 @@ export default {
this.$store.commit('setServerSettings', serverSettings)
this.$store.commit('setSource', Source)
this.$store.commit('feeds/setFeeds', feeds)
this.$setServerLanguageCode(serverSettings.language)
if (serverSettings.chromecastEnabled) {
console.log('Chromecast enabled import script')
@ -136,6 +137,8 @@ export default {
this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId)
this.$store.commit('user/setUser', user)
this.$store.dispatch('user/loadUserSettings')
},
/**
* Checks for forward proxy authentication
@ -217,6 +220,7 @@ export default {
this.processing = false
this.isInit = res.isInit
this.showInitScreen = !res.isInit
this.$setServerLanguageCode(res.language)
if (this.showInitScreen) {
this.ConfigPath = res.ConfigPath || ''
this.MetadataPath = res.MetadataPath || ''

View file

@ -0,0 +1,187 @@
<template>
<div id="page-wrapper" class="bg-bg page overflow-hidden" :class="streamLibraryItem ? 'streaming' : ''">
<div class="w-full h-full overflow-y-auto px-2 py-6 md:p-8">
<div class="flex flex-col sm:flex-row max-w-6xl mx-auto">
<div class="w-full flex justify-center md:block sm:w-32 md:w-52" style="min-width: 200px">
<div class="relative" style="height: fit-content">
<covers-playlist-cover :items="playlistItems" :width="200" :height="200" />
</div>
</div>
<div class="flex-grow px-2 py-6 md:py-0 md:px-10">
<div class="flex items-end flex-row flex-wrap md:flex-nowrap">
<h1 class="text-2xl md:text-3xl font-sans w-full md:w-fit mb-4 md:mb-0">
{{ playlistName }}
</h1>
<div class="flex-grow" />
<ui-btn v-if="showPlayButton" :disabled="streaming" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="clickPlay">
<span v-show="!streaming" class="material-icons text-2xl -ml-2 pr-1 text-white">play_arrow</span>
{{ streaming ? $strings.ButtonPlaying : $strings.ButtonPlay }}
</ui-btn>
<ui-icon-btn v-if="userCanUpdate" icon="edit" class="mx-0.5" @click="editClick" />
<ui-icon-btn v-if="userCanDelete" icon="delete" class="mx-0.5" @click="removeClick" />
</div>
<div class="my-8 max-w-2xl">
<p class="text-base text-gray-100">{{ description }}</p>
</div>
<tables-playlist-items-table :items="playlistItems" :playlist-id="playlistId" />
</div>
</div>
</div>
<div v-show="processingRemove" class="absolute top-0 left-0 w-full h-full z-10 bg-black bg-opacity-40 flex items-center justify-center">
<ui-loading-indicator />
</div>
</div>
</template>
<script>
export default {
async asyncData({ store, params, app, redirect, route }) {
if (!store.state.user.user) {
return redirect(`/login?redirect=${route.path}`)
}
var playlist = await app.$axios.$get(`/api/playlists/${params.id}`).catch((error) => {
console.error('Failed', error)
return false
})
if (!playlist) {
return redirect('/')
}
// If playlist is a different library then set library as current
if (playlist.libraryId !== store.state.libraries.currentLibraryId) {
await store.dispatch('libraries/fetch', playlist.libraryId)
}
store.commit('libraries/addUpdateUserPlaylist', playlist)
return {
playlistId: playlist.id
}
},
data() {
return {
processingRemove: false
}
},
computed: {
streamLibraryItem() {
return this.$store.state.streamLibraryItem
},
playlistItems() {
return this.playlist.items || []
},
playlistName() {
return this.playlist.name || ''
},
description() {
return this.playlist.description || ''
},
playlist() {
return this.$store.getters['libraries/getPlaylist'](this.playlistId) || {}
},
playableItems() {
return this.playlistItems.filter((item) => {
const libraryItem = item.libraryItem
if (libraryItem.isMissing || libraryItem.isInvalid) return false
if (item.episode) return item.episode.audioFile
return libraryItem.media.tracks.length
})
},
streaming() {
return !!this.playableItems.find((i) => this.$store.getters['getIsMediaStreaming'](i.libraryItemId, i.episodeId))
},
showPlayButton() {
return this.playableItems.length
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
}
},
methods: {
editClick() {
this.$store.commit('globals/setEditPlaylist', this.playlist)
},
removeClick() {
if (confirm(`Are you sure you want to remove playlist "${this.playlistName}"?`)) {
this.processingRemove = true
var playlistName = this.playlistName
this.$axios
.$delete(`/api/playlists/${this.playlist.id}`)
.then(() => {
this.processingRemove = false
this.$toast.success(`Playlist "${playlistName}" Removed`)
})
.catch((error) => {
console.error('Failed to remove playlist', error)
this.processingRemove = false
this.$toast.error(`Failed to remove playlist`)
})
}
},
clickPlay() {
const queueItems = []
// Playlist queue will start at the first unfinished item
// if all items are finished then entire playlist is queued
const itemsWithProgress = this.playableItems.map((item) => {
return {
...item,
progress: this.$store.getters['user/getUserMediaProgress'](item.libraryItemId, item.episodeId)
}
})
const hasUnfinishedItems = itemsWithProgress.some((i) => !i.progress || !i.progress.isFinished)
if (!hasUnfinishedItems) {
console.warn('All items in playlist are finished - starting at first item')
}
for (let i = 0; i < itemsWithProgress.length; i++) {
const playlistItem = itemsWithProgress[i]
if (!hasUnfinishedItems || !playlistItem.progress || !playlistItem.progress.isFinished) {
const libraryItem = playlistItem.libraryItem
if (playlistItem.episode) {
queueItems.push({
libraryItemId: libraryItem.id,
libraryId: libraryItem.libraryId,
episodeId: playlistItem.episode.id,
title: playlistItem.episode.title,
subtitle: libraryItem.media.metadata.title,
caption: '',
duration: playlistItem.episode.duration || null,
coverPath: libraryItem.media.coverPath || null
})
} else {
queueItems.push({
libraryItemId: libraryItem.id,
libraryId: libraryItem.libraryId,
episodeId: null,
title: libraryItem.media.metadata.title,
subtitle: libraryItem.media.metadata.authors.map((au) => au.name).join(', '),
caption: '',
duration: libraryItem.media.duration || null,
coverPath: libraryItem.media.coverPath || null
})
}
}
}
if (queueItems.length >= 0) {
this.$eventBus.$emit('play-item', {
libraryItemId: queueItems[0].libraryItemId,
episodeId: queueItems[0].episodeId,
queueItems
})
}
}
},
mounted() {},
beforeDestroy() {}
}
</script>

View file

@ -4,13 +4,13 @@
<!-- Library & folder picker -->
<div class="flex my-6 -mx-2">
<div class="w-1/5 px-2">
<ui-dropdown v-model="selectedLibraryId" :items="libraryItems" label="Library" :disabled="!!items.length" @input="libraryChanged" />
<ui-dropdown v-model="selectedLibraryId" :items="libraryItems" :label="$strings.LabelLibrary" :disabled="!!items.length" @input="libraryChanged" />
</div>
<div class="w-3/5 px-2">
<ui-dropdown v-model="selectedFolderId" :items="folderItems" :disabled="!selectedLibraryId || !!items.length" label="Folder" />
<ui-dropdown v-model="selectedFolderId" :items="folderItems" :disabled="!selectedLibraryId || !!items.length" :label="$strings.LabelFolder" />
</div>
<div class="w-1/5 px-2">
<ui-text-input-with-label :value="selectedLibraryMediaType" readonly label="Media Type" />
<ui-text-input-with-label :value="selectedLibraryMediaType" readonly :label="$strings.LabelMediaType" />
</div>
</div>
@ -20,18 +20,22 @@
<!-- Picker display -->
<div v-if="!items.length && !ignoredFiles.length" class="w-full mx-auto border border-white border-opacity-20 px-12 pt-12 pb-4 my-12 relative" :class="isDragging ? 'bg-primary bg-opacity-40' : 'border-dashed'">
<p class="text-2xl text-center">{{ isDragging ? 'Drop files' : "Drag n' drop files or folders" }}</p>
<p class="text-center text-sm my-5">or</p>
<p class="text-2xl text-center">{{ isDragging ? $strings.LabelUploaderDropFiles : $strings.LabelUploaderDragAndDrop }}</p>
<p class="text-center text-sm my-5">{{ $strings.MessageOr }}</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>
<ui-btn class="w-full mx-1" @click="openFilePicker">{{ $strings.ButtonChooseFiles }}</ui-btn>
<ui-btn class="w-full mx-1" @click="openFolderPicker">{{ $strings.ButtonChooseAFolder }}</ui-btn>
</div>
</div>
<div class="pt-8 text-center">
<p class="text-xs text-white text-opacity-50 font-mono mb-4"><strong>Supported File Types: </strong>{{ inputAccept.join(', ') }}</p>
<p class="text-xs text-white text-opacity-50 font-mono mb-4">
<strong>{{ $strings.LabelSupportedFileTypes }}: </strong>{{ inputAccept.join(', ') }}
</p>
<p class="text-sm text-white text-opacity-70">Folders with media files will be treated as separate library items. <span v-if="selectedLibraryMediaType === 'book'">If uploading only audio files then each audio file will be treated as a separate audiobook.</span></p>
<p class="text-sm text-white text-opacity-70">
{{ $strings.NoteUploaderFoldersWithMediaFiles }} <span v-if="selectedLibraryMediaType === 'book'">{{ $strings.NoteUploaderOnlyAudioFiles }}</span>
</p>
</div>
</div>
<!-- Item list header -->
@ -39,18 +43,20 @@
<p class="text-lg">{{ items.length }} item{{ items.length === 1 ? '' : 's' }}</p>
<p v-if="ignoredFiles.length" class="text-lg">&nbsp;|&nbsp;{{ ignoredFiles.length }} file{{ ignoredFiles.length === 1 ? '' : 's' }} ignored</p>
<div class="flex-grow" />
<ui-btn :disabled="processing" small @click="reset">Reset</ui-btn>
<ui-btn :disabled="processing" small @click="reset">{{ $strings.ButtonReset }}</ui-btn>
</div>
<!-- Alerts -->
<widgets-alert v-if="!items.length && !uploadReady" type="error" class="my-4">
<p class="text-lg">No items found</p>
<p class="text-lg">{{ $strings.MessageNoItemsFound }}</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 an item 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>
<p class="text-base mb-1">{{ $strings.NoteUploaderUnsupportedFiles }}</p>
<tables-uploaded-files-table :files="ignoredFiles" :title="$strings.HeaderIgnoredFiles" class="text-white" />
<p class="text-xs text-white text-opacity-50 font-mono pt-1">
<strong>{{ $strings.LabelSupportedFileTypes }}: </strong>{{ inputAccept.join(', ') }}
</p>
</div>
</widgets-alert>
@ -61,8 +67,8 @@
<!-- Upload/Reset btns -->
<div v-show="items.length" class="flex justify-end pb-8 pt-4">
<ui-btn v-if="!uploadFinished" color="success" :loading="processing" @click="submit">Upload</ui-btn>
<ui-btn v-else @click="reset">Reset</ui-btn>
<ui-btn v-if="!uploadFinished" color="success" :loading="processing" @click="submit">{{ $strings.ButtonUpload }}</ui-btn>
<ui-btn v-else @click="reset">{{ $strings.ButtonReset }}</ui-btn>
</div>
</div>