mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-21 08:41:39 +00:00
Merge remote-tracking branch 'upstream/master'
This commit is contained in:
commit
b3f9a40f18
227 changed files with 11338 additions and 3382 deletions
|
|
@ -21,13 +21,14 @@
|
|||
<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-32 hidden lg:block" />
|
||||
</div>
|
||||
<div class="flex items-center mb-3 py-1">
|
||||
<div class="flex items-center mb-3 py-1 -mx-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>
|
||||
<ui-btn v-if="chapters.length" color="primary" small class="mx-1" @click.stop="removeAllChaptersClick">{{ $strings.ButtonRemoveAll }}</ui-btn>
|
||||
<ui-btn v-if="newChapters.length > 1" :color="showShiftTimes ? 'bg' : 'primary'" class="mx-1" small @click="showShiftTimes = !showShiftTimes">{{ $strings.ButtonShiftTimes }}</ui-btn>
|
||||
<ui-btn color="primary" small :class="{ 'mx-1': newChapters.length > 1 }" @click="showFindChaptersModal = true">{{ $strings.ButtonLookup }}</ui-btn>
|
||||
<div class="flex-grow" />
|
||||
<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>
|
||||
<ui-btn v-if="hasChanges" small class="mx-1" @click.stop="resetChapters">{{ $strings.ButtonReset }}</ui-btn>
|
||||
<ui-btn v-if="hasChanges" color="success" class="mx-1" :disabled="!hasChanges" small @click="saveChapters">{{ $strings.ButtonSave }}</ui-btn>
|
||||
<div class="w-32 hidden lg:block" />
|
||||
</div>
|
||||
|
||||
|
|
@ -41,7 +42,7 @@
|
|||
<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">{{ $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>
|
||||
<span class="material-icons text-gray-200 hover:text-white cursor-pointer" @click="showShiftTimes = false">expand_less</span>
|
||||
</div>
|
||||
<p class="text-xs py-1.5 text-gray-300 max-w-md">{{ $strings.NoteChapterEditorTimes }}</p>
|
||||
</div>
|
||||
|
|
@ -329,6 +330,7 @@ export default {
|
|||
chap.start = Math.max(0, chap.start + amount)
|
||||
}
|
||||
}
|
||||
this.checkChapters()
|
||||
},
|
||||
editItem() {
|
||||
this.$store.commit('showEditModal', this.libraryItem)
|
||||
|
|
@ -587,6 +589,45 @@ export default {
|
|||
]
|
||||
}
|
||||
this.checkChapters()
|
||||
},
|
||||
removeAllChaptersClick() {
|
||||
const payload = {
|
||||
message: this.$strings.MessageConfirmRemoveAllChapters,
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.removeAllChapters()
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
removeAllChapters() {
|
||||
this.saving = true
|
||||
const payload = {
|
||||
chapters: []
|
||||
}
|
||||
this.$axios
|
||||
.$post(`/api/items/${this.libraryItem.id}/chapters`, payload)
|
||||
.then((data) => {
|
||||
if (data.updated) {
|
||||
this.$toast.success('Chapters removed')
|
||||
if (this.previousRoute) {
|
||||
this.$router.push(this.previousRoute)
|
||||
} else {
|
||||
this.$router.push(`/item/${this.libraryItem.id}`)
|
||||
}
|
||||
} else {
|
||||
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to remove chapters', error)
|
||||
this.$toast.error('Failed to remove chapters')
|
||||
})
|
||||
.finally(() => {
|
||||
this.saving = false
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
|
|
|||
|
|
@ -17,24 +17,80 @@
|
|||
<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>
|
||||
<div class="w-full max-h-72 overflow-auto">
|
||||
<template v-for="(value, key, index) in toneObject">
|
||||
<div :key="key" class="flex py-1 px-4 text-sm" :class="index % 2 === 0 ? 'bg-primary bg-opacity-25' : ''">
|
||||
<div class="w-1/3 font-semibold">{{ key }}</div>
|
||||
<div class="w-2/3">
|
||||
{{ value }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</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">{{ $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">{{ $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>
|
||||
<div class="w-24">
|
||||
{{ $secondsToTimestamp(chapter.start) }}
|
||||
</div>
|
||||
<div class="w-24">
|
||||
{{ $secondsToTimestamp(chapter.end) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full h-px bg-white bg-opacity-10 my-8" />
|
||||
|
||||
<div class="w-full max-w-4xl mx-auto">
|
||||
<!-- queued alert -->
|
||||
<widgets-alert v-if="isMetadataEmbedQueued" type="warning" class="mb-4">
|
||||
<p class="text-lg">Audiobook is queued for metadata embed ({{ queuedEmbedLIds.length }} in queue)</p>
|
||||
</widgets-alert>
|
||||
<!-- metadata embed action buttons -->
|
||||
<div v-else-if="isEmbedTool" class="w-full flex justify-end items-center mb-4">
|
||||
<ui-checkbox v-if="!isTaskFinished" v-model="shouldBackupAudioFiles" :disabled="processing" label="Backup audio files" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" @input="toggleBackupAudioFiles" />
|
||||
|
||||
<div class="flex-grow" />
|
||||
|
||||
<ui-btn v-if="!isTaskFinished" 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>
|
||||
<!-- m4b embed action buttons -->
|
||||
<div v-else class="w-full flex items-center mb-4">
|
||||
<button :disabled="processing" class="text-sm uppercase text-gray-200 flex items-center pt-px pl-1 pr-2 hover:bg-white/5 rounded-md" @click="showEncodeOptions = !showEncodeOptions">
|
||||
<span class="material-icons text-xl">{{ showEncodeOptions ? 'check_box' : 'check_box_outline_blank' }}</span> <span class="pl-1">Use Advanced Options</span>
|
||||
</button>
|
||||
|
||||
<div class="flex-grow" />
|
||||
|
||||
<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="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">{{ $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">
|
||||
<div :key="key" class="flex py-1 px-4 text-sm" :class="index % 2 === 0 ? 'bg-primary bg-opacity-25' : ''">
|
||||
<div class="w-1/3 font-semibold">{{ key }}</div>
|
||||
<div class="w-2/3">
|
||||
{{ value }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- advanced encoding options -->
|
||||
<div v-if="isM4BTool" class="overflow-hidden">
|
||||
<transition name="slide">
|
||||
<div v-if="showEncodeOptions" class="mb-4 pb-4 border-b border-white/10">
|
||||
<div class="flex flex-wrap -mx-2">
|
||||
<ui-text-input-with-label ref="bitrateInput" v-model="encodingOptions.bitrate" :disabled="processing || isTaskFinished" :label="'Audio Bitrate (e.g. 64k)'" class="m-2 max-w-40" />
|
||||
<ui-text-input-with-label ref="channelsInput" v-model="encodingOptions.channels" :disabled="processing || isTaskFinished" :label="'Audio Channels (1 or 2)'" class="m-2 max-w-40" />
|
||||
<ui-text-input-with-label ref="codecInput" v-model="encodingOptions.codec" :disabled="processing || isTaskFinished" :label="'Audio Codec'" class="m-2 max-w-40" />
|
||||
</div>
|
||||
<p class="text-sm text-warning">Warning: Do not update these settings unless you are familiar with ffmpeg encoding options.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full max-w-2xl border border-white border-opacity-10 bg-bg mx-2">
|
||||
|
|
@ -242,6 +298,7 @@ export default {
|
|||
cnosole.error('No audio files')
|
||||
return redirect('/?error=no audio files')
|
||||
}
|
||||
|
||||
return {
|
||||
libraryItem
|
||||
}
|
||||
|
|
@ -251,7 +308,6 @@ export default {
|
|||
processing: false,
|
||||
audiofilesEncoding: {},
|
||||
audiofilesFinished: {},
|
||||
isFinished: false,
|
||||
toneObject: null,
|
||||
selectedTool: 'embed',
|
||||
isCancelingEncode: false,
|
||||
|
|
@ -336,11 +392,28 @@ export default {
|
|||
isTaskFinished() {
|
||||
return this.task && this.task.isFinished
|
||||
},
|
||||
tasks() {
|
||||
return this.$store.getters['tasks/getTasksByLibraryItemId'](this.libraryItemId)
|
||||
},
|
||||
embedTask() {
|
||||
return this.tasks.find((t) => t.action === 'embed-metadata')
|
||||
},
|
||||
encodeTask() {
|
||||
return this.tasks.find((t) => t.action === 'encode-m4b')
|
||||
},
|
||||
task() {
|
||||
return this.$store.getters['tasks/getTaskByLibraryItemId'](this.libraryItemId)
|
||||
if (this.isEmbedTool) return this.embedTask
|
||||
else if (this.isM4BTool) return this.encodeTask
|
||||
return null
|
||||
},
|
||||
taskRunning() {
|
||||
return this.task && !this.task.isFinished
|
||||
},
|
||||
queuedEmbedLIds() {
|
||||
return this.$store.state.tasks.queuedEmbedLIds || []
|
||||
},
|
||||
isMetadataEmbedQueued() {
|
||||
return this.queuedEmbedLIds.some((lid) => lid === this.libraryItemId)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
|
@ -386,7 +459,7 @@ export default {
|
|||
.catch((error) => {
|
||||
var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
|
||||
this.$toast.error(errorMsg)
|
||||
this.processing = true
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
embedClick() {
|
||||
|
|
@ -448,24 +521,6 @@ export default {
|
|||
this.processing = false
|
||||
})
|
||||
},
|
||||
audioMetadataStarted(data) {
|
||||
console.log('audio metadata started', data)
|
||||
if (data.libraryItemId !== this.libraryItemId) return
|
||||
this.audiofilesFinished = {}
|
||||
},
|
||||
audioMetadataFinished(data) {
|
||||
console.log('audio metadata finished', data)
|
||||
if (data.libraryItemId !== this.libraryItemId) return
|
||||
this.processing = false
|
||||
this.audiofilesEncoding = {}
|
||||
|
||||
if (data.failed) {
|
||||
this.$toast.error(data.error)
|
||||
} else {
|
||||
this.isFinished = true
|
||||
this.$toast.success('Audio file metadata updated')
|
||||
}
|
||||
},
|
||||
audiofileMetadataStarted(data) {
|
||||
if (data.libraryItemId !== this.libraryItemId) return
|
||||
this.$set(this.audiofilesEncoding, data.ino, true)
|
||||
|
|
@ -525,14 +580,10 @@ export default {
|
|||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
this.$root.socket.on('audio_metadata_started', this.audioMetadataStarted)
|
||||
this.$root.socket.on('audio_metadata_finished', this.audioMetadataFinished)
|
||||
this.$root.socket.on('audiofile_metadata_started', this.audiofileMetadataStarted)
|
||||
this.$root.socket.on('audiofile_metadata_finished', this.audiofileMetadataFinished)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$root.socket.off('audio_metadata_started', this.audioMetadataStarted)
|
||||
this.$root.socket.off('audio_metadata_finished', this.audioMetadataFinished)
|
||||
this.$root.socket.off('audiofile_metadata_started', this.audiofileMetadataStarted)
|
||||
this.$root.socket.off('audiofile_metadata_finished', this.audiofileMetadataFinished)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,8 +43,8 @@
|
|||
|
||||
<script>
|
||||
export default {
|
||||
async asyncData({ store, app, params, redirect }) {
|
||||
const author = await app.$axios.$get(`/api/authors/${params.id}?library=${store.state.libraries.currentLibraryId}&include=items,series`).catch((error) => {
|
||||
async asyncData({ store, app, params, redirect, query }) {
|
||||
const author = await app.$axios.$get(`/api/authors/${params.id}?library=${query.library || store.state.libraries.currentLibraryId}&include=items,series`).catch((error) => {
|
||||
console.error('Failed to get author', error)
|
||||
return null
|
||||
})
|
||||
|
|
@ -53,6 +53,10 @@ export default {
|
|||
return redirect(`/library/${store.state.libraries.currentLibraryId}/authors`)
|
||||
}
|
||||
|
||||
if (query.library) {
|
||||
store.commit('libraries/setCurrentLibrary', query.library)
|
||||
}
|
||||
|
||||
return {
|
||||
author
|
||||
}
|
||||
|
|
|
|||
|
|
@ -145,7 +145,7 @@ export default {
|
|||
feed: this.rssFeed
|
||||
})
|
||||
},
|
||||
contextMenuAction(action) {
|
||||
contextMenuAction({ action }) {
|
||||
if (action === 'delete') {
|
||||
this.removeClick()
|
||||
} else if (action === 'create-playlist') {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<div class="configContent" :class="`page-${currentPage}`">
|
||||
<div v-show="isMobilePortrait" class="w-full pb-4 px-2 flex border-b border-white border-opacity-10 mb-2 cursor-pointer" @click.stop.prevent="toggleShowMore">
|
||||
<span class="material-icons text-2xl cursor-pointer">arrow_forward</span>
|
||||
<p class="pl-3 capitalize">{{ $strings.HeaderSettings }}</p>
|
||||
<p class="pl-3 capitalize">{{ currentPage }}</p>
|
||||
</div>
|
||||
<nuxt-child />
|
||||
</div>
|
||||
|
|
@ -55,6 +55,7 @@ export default {
|
|||
else if (pageName === 'library-stats') return this.$strings.HeaderLibraryStats
|
||||
else if (pageName === 'users') return this.$strings.HeaderUsers
|
||||
else if (pageName === 'item-metadata-utils') return this.$strings.HeaderItemMetadataUtils
|
||||
else if (pageName === 'email') return this.$strings.HeaderEmail
|
||||
}
|
||||
return this.$strings.HeaderSettings
|
||||
}
|
||||
|
|
@ -79,14 +80,6 @@ export default {
|
|||
width: 900px;
|
||||
max-width: calc(100% - 176px);
|
||||
}
|
||||
.configContent.page-library-stats {
|
||||
width: 1200px;
|
||||
}
|
||||
@media (max-width: 1550px) {
|
||||
.configContent.page-library-stats {
|
||||
margin-left: 176px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 1240px) {
|
||||
.configContent {
|
||||
margin-left: 176px;
|
||||
|
|
@ -98,8 +91,5 @@ export default {
|
|||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
.configContent.page-library-stats {
|
||||
margin-left: 0px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -9,10 +9,17 @@
|
|||
</div>
|
||||
|
||||
<div v-if="enableBackups" class="mb-6">
|
||||
<div class="flex items-center pl-6">
|
||||
<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 class="flex items-center pl-6 mb-2">
|
||||
<span class="material-icons-outlined text-2xl text-black-50 mr-2">schedule</span>
|
||||
<div class="w-48"><span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.HeaderSchedule }}:</span></div>
|
||||
<div class="text-gray-100">{{ scheduleDescription }}</div>
|
||||
<span class="material-icons text-lg text-black-50 hover:text-yellow-500 cursor-pointer ml-2" @click="showCronBuilder = !showCronBuilder">edit</span>
|
||||
</div>
|
||||
|
||||
<div v-if="nextBackupDate" class="flex items-center pl-6 py-0.5 px-2">
|
||||
<span class="material-icons-outlined text-2xl text-black-50 mr-2">event</span>
|
||||
<div class="w-48"><span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelNextBackupDate }}:</span></div>
|
||||
<div class="text-gray-100">{{ nextBackupDate }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -64,10 +71,21 @@ export default {
|
|||
serverSettings() {
|
||||
return this.$store.state.serverSettings
|
||||
},
|
||||
dateFormat() {
|
||||
return this.serverSettings.dateFormat
|
||||
},
|
||||
timeFormat() {
|
||||
return this.serverSettings.timeFormat
|
||||
},
|
||||
scheduleDescription() {
|
||||
if (!this.cronExpression) return ''
|
||||
const parsed = this.$parseCronExpression(this.cronExpression)
|
||||
return parsed ? parsed.description : 'Custom cron expression ' + this.cronExpression
|
||||
return parsed ? parsed.description : `${this.$strings.LabelCustomCronExpression} ${this.cronExpression}`
|
||||
},
|
||||
nextBackupDate() {
|
||||
if (!this.cronExpression) return ''
|
||||
const parsed = this.$getNextScheduledDate(this.cronExpression)
|
||||
return this.$formatJsDatetime(parsed, this.dateFormat, this.timeFormat) || ''
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
|
@ -90,15 +108,15 @@ export default {
|
|||
updateServerSettings(payload) {
|
||||
this.updatingServerSettings = true
|
||||
this.$store
|
||||
.dispatch('updateServerSettings', payload)
|
||||
.then((success) => {
|
||||
console.log('Updated Server Settings', success)
|
||||
this.updatingServerSettings = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update server settings', error)
|
||||
this.updatingServerSettings = false
|
||||
})
|
||||
.dispatch('updateServerSettings', payload)
|
||||
.then((success) => {
|
||||
console.log('Updated Server Settings', success)
|
||||
this.updatingServerSettings = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update server settings', error)
|
||||
this.updatingServerSettings = false
|
||||
})
|
||||
},
|
||||
initServerSettings() {
|
||||
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
||||
|
|
@ -113,4 +131,4 @@ export default {
|
|||
this.initServerSettings()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
|
|
|||
244
client/pages/config/email.vue
Normal file
244
client/pages/config/email.vue
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
<template>
|
||||
<div>
|
||||
<app-settings-content :header-text="$strings.HeaderEmailSettings" :description="''">
|
||||
<form @submit.prevent="submitForm">
|
||||
<div class="flex items-center -mx-1 mb-2">
|
||||
<div class="w-full md:w-3/4 px-1">
|
||||
<ui-text-input-with-label ref="hostInput" v-model="newSettings.host" :disabled="savingSettings" :label="$strings.LabelHost" />
|
||||
</div>
|
||||
<div class="w-full md:w-1/4 px-1">
|
||||
<ui-text-input-with-label ref="portInput" v-model="newSettings.port" type="number" :disabled="savingSettings" :label="$strings.LabelPort" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center mb-2 py-3">
|
||||
<ui-toggle-switch labeledBy="email-settings-secure" v-model="newSettings.secure" :disabled="savingSettings" />
|
||||
<ui-tooltip :text="$strings.LabelEmailSettingsSecureHelp">
|
||||
<div class="pl-4 flex items-center">
|
||||
<span id="email-settings-secure">{{ $strings.LabelEmailSettingsSecure }}</span>
|
||||
<span class="material-icons text-lg pl-1">info_outlined</span>
|
||||
</div>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center -mx-1 mb-2">
|
||||
<div class="w-full md:w-1/2 px-1">
|
||||
<ui-text-input-with-label ref="userInput" v-model="newSettings.user" :disabled="savingSettings" :label="$strings.LabelUsername" />
|
||||
</div>
|
||||
<div class="w-full md:w-1/2 px-1">
|
||||
<ui-text-input-with-label ref="passInput" v-model="newSettings.pass" type="password" :disabled="savingSettings" :label="$strings.LabelPassword" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center -mx-1 mb-2">
|
||||
<div class="w-full md:w-1/2 px-1">
|
||||
<ui-text-input-with-label ref="fromInput" v-model="newSettings.fromAddress" :disabled="savingSettings" :label="$strings.LabelEmailSettingsFromAddress" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between pt-4">
|
||||
<ui-btn :loading="sendingTest" :disabled="savingSettings || !newSettings.host" type="button" @click="sendTestClick">{{ $strings.ButtonTest }}</ui-btn>
|
||||
<ui-btn :loading="savingSettings" :disabled="!hasUpdates" type="submit">{{ $strings.ButtonSave }}</ui-btn>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div v-show="loading" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-25 flex items-center justify-center">
|
||||
<ui-loading-indicator />
|
||||
</div>
|
||||
</app-settings-content>
|
||||
|
||||
<app-settings-content :header-text="$strings.HeaderEReaderDevices" showAddButton :description="''" @clicked="addNewDeviceClick">
|
||||
<table v-if="existingEReaderDevices.length" class="tracksTable my-4">
|
||||
<tr>
|
||||
<th class="text-left">{{ $strings.LabelName }}</th>
|
||||
<th class="text-left">{{ $strings.LabelEmail }}</th>
|
||||
<th class="w-40"></th>
|
||||
</tr>
|
||||
<tr v-for="device in existingEReaderDevices" :key="device.name">
|
||||
<td>
|
||||
<p class="text-sm md:text-base text-gray-100">{{ device.name }}</p>
|
||||
</td>
|
||||
<td class="text-left">
|
||||
<p class="text-sm md:text-base text-gray-100">{{ device.email }}</p>
|
||||
</td>
|
||||
<td class="w-40">
|
||||
<div class="flex justify-end items-center h-10">
|
||||
<ui-icon-btn icon="edit" borderless :size="8" icon-font-size="1.1rem" :disabled="deletingDeviceName === device.name" class="mx-1" @click="editDeviceClick(device)" />
|
||||
<ui-icon-btn icon="delete" borderless :size="8" icon-font-size="1.1rem" :disabled="deletingDeviceName === device.name" @click="deleteDeviceClick(device)" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div v-else class="text-center py-4">
|
||||
<p class="text-lg text-gray-100">No Devices</p>
|
||||
</div>
|
||||
</app-settings-content>
|
||||
|
||||
<modals-emails-e-reader-device-modal v-model="showEReaderDeviceModal" :existing-devices="existingEReaderDevices" :ereader-device="selectedEReaderDevice" @update="ereaderDevicesUpdated" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
savingSettings: false,
|
||||
sendingTest: false,
|
||||
deletingDeviceName: null,
|
||||
settings: null,
|
||||
newSettings: {
|
||||
host: null,
|
||||
port: 465,
|
||||
secure: true,
|
||||
user: null,
|
||||
pass: null,
|
||||
fromAddress: null
|
||||
},
|
||||
newEReaderDevice: {
|
||||
name: '',
|
||||
email: ''
|
||||
},
|
||||
selectedEReaderDevice: null,
|
||||
showEReaderDeviceModal: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
hasUpdates() {
|
||||
if (!this.settings) return true
|
||||
for (const key in this.newSettings) {
|
||||
if (key === 'ereaderDevices') continue
|
||||
if (this.newSettings[key] !== this.settings[key]) return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
existingEReaderDevices() {
|
||||
return this.settings?.ereaderDevices || []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
editDeviceClick(device) {
|
||||
this.selectedEReaderDevice = device
|
||||
this.showEReaderDeviceModal = true
|
||||
},
|
||||
deleteDeviceClick(device) {
|
||||
const payload = {
|
||||
message: `Are you sure you want to delete e-reader device "${device.name}"?`,
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.deleteDevice(device)
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
deleteDevice(device) {
|
||||
const payload = {
|
||||
ereaderDevices: this.existingEReaderDevices.filter((d) => d.name !== device.name)
|
||||
}
|
||||
this.deletingDeviceName = device.name
|
||||
this.$axios
|
||||
.$patch(`/emails/ereader-devices`, payload)
|
||||
.then((data) => {
|
||||
this.ereaderDevicesUpdated(data.ereaderDevices)
|
||||
this.$toast.success('Device deleted')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to delete device', error)
|
||||
this.$toast.error('Failed to delete device')
|
||||
})
|
||||
.finally(() => {
|
||||
this.deletingDeviceName = null
|
||||
})
|
||||
},
|
||||
ereaderDevicesUpdated(ereaderDevices) {
|
||||
this.settings.ereaderDevices = ereaderDevices
|
||||
this.newSettings.ereaderDevices = ereaderDevices.map((d) => ({ ...d }))
|
||||
},
|
||||
addNewDeviceClick() {
|
||||
this.selectedEReaderDevice = null
|
||||
this.showEReaderDeviceModal = true
|
||||
},
|
||||
sendTestClick() {
|
||||
this.sendingTest = true
|
||||
this.$axios
|
||||
.$post('/api/emails/test')
|
||||
.then(() => {
|
||||
this.$toast.success('Test Email Sent')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to send test email', error)
|
||||
const errorMsg = error.response.data || 'Failed to send test email'
|
||||
this.$toast.error(errorMsg)
|
||||
})
|
||||
.finally(() => {
|
||||
this.sendingTest = false
|
||||
})
|
||||
},
|
||||
validateForm() {
|
||||
for (const ref of [this.$refs.hostInput, this.$refs.portInput, this.$refs.userInput, this.$refs.passInput, this.$refs.fromInput]) {
|
||||
if (ref?.blur) ref.blur()
|
||||
}
|
||||
|
||||
if (this.newSettings.port) {
|
||||
this.newSettings.port = Number(this.newSettings.port)
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
submitForm() {
|
||||
if (!this.validateForm()) return
|
||||
|
||||
const updatePayload = {
|
||||
host: this.newSettings.host,
|
||||
port: this.newSettings.port,
|
||||
secure: this.newSettings.secure,
|
||||
user: this.newSettings.user,
|
||||
pass: this.newSettings.pass,
|
||||
fromAddress: this.newSettings.fromAddress
|
||||
}
|
||||
this.savingSettings = true
|
||||
this.$axios
|
||||
.$patch('/api/emails/settings', updatePayload)
|
||||
.then((data) => {
|
||||
this.settings = data.settings
|
||||
this.newSettings = {
|
||||
...data.settings
|
||||
}
|
||||
this.$toast.success('Email settings updated')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update email settings', error)
|
||||
this.$toast.error('Failed to update email settings')
|
||||
})
|
||||
.finally(() => {
|
||||
this.savingSettings = false
|
||||
})
|
||||
},
|
||||
init() {
|
||||
this.loading = true
|
||||
|
||||
this.$axios
|
||||
.$get(`/api/emails/settings`)
|
||||
.then((data) => {
|
||||
this.settings = data.settings
|
||||
this.newSettings = {
|
||||
...this.settings
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to get email settings', error)
|
||||
this.$toast.error('Failed to load email settings')
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = false
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
},
|
||||
beforeDestroy() {}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -39,11 +39,15 @@
|
|||
<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">
|
||||
<div class="flex items-center py-2 mb-2">
|
||||
<ui-toggle-switch labeledBy="settings-chromecast-support" v-model="newServerSettings.chromecastEnabled" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('chromecastEnabled', val)" />
|
||||
<p class="pl-4" id="settings-chromecast-support">{{ $strings.LabelSettingsChromecastSupport }}</p>
|
||||
</div>
|
||||
|
||||
<div class="w-44 mb-2">
|
||||
<ui-dropdown v-model="newServerSettings.metadataFileFormat" small :items="metadataFileFormats" label="Metadata File Format" @input="updateMetadataFileFormat" :disabled="updatingServerSettings" />
|
||||
</div>
|
||||
|
||||
<div class="pt-4">
|
||||
<h2 class="font-semibold">{{ $strings.HeaderSettingsDisplay }}</h2>
|
||||
</div>
|
||||
|
|
@ -68,8 +72,14 @@
|
|||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
<div class="flex-grow py-2">
|
||||
<ui-dropdown :label="$strings.LabelSettingsDateFormat" v-model="newServerSettings.dateFormat" :items="dateFormats" small class="max-w-52" @input="(val) => updateSettingsKey('dateFormat', val)" />
|
||||
<p class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelExample }}: {{ dateExample }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex-grow py-2">
|
||||
<ui-dropdown :label="$strings.LabelSettingsTimeFormat" v-model="newServerSettings.timeFormat" :items="timeFormats" small class="max-w-52" @input="(val) => updateSettingsKey('timeFormat', val)" />
|
||||
<p class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelExample }}: {{ timeExample }}</p>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
|
|
@ -276,7 +286,17 @@ export default {
|
|||
useBookshelfView: false,
|
||||
isPurgingCache: false,
|
||||
newServerSettings: {},
|
||||
showConfirmPurgeCache: false
|
||||
showConfirmPurgeCache: false,
|
||||
metadataFileFormats: [
|
||||
{
|
||||
text: '.json',
|
||||
value: 'json'
|
||||
},
|
||||
{
|
||||
text: '.abs',
|
||||
value: 'abs'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
|
|
@ -311,6 +331,17 @@ export default {
|
|||
},
|
||||
dateFormats() {
|
||||
return this.$store.state.globals.dateFormats
|
||||
},
|
||||
timeFormats() {
|
||||
return this.$store.state.globals.timeFormats
|
||||
},
|
||||
dateExample() {
|
||||
const date = new Date(2014, 2, 25)
|
||||
return this.$formatJsDate(date, this.newServerSettings.dateFormat)
|
||||
},
|
||||
timeExample() {
|
||||
const date = new Date(2014, 2, 25, 17, 30, 0)
|
||||
return this.$formatJsTime(date, this.newServerSettings.timeFormat)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
|
@ -346,6 +377,10 @@ export default {
|
|||
this.updateSettingsKey('defaultRenameString', this.newServerSettings.defaultRenameString)
|
||||
this.$store.commit('setDefaultRenameString', this.newServerSettings.defaultRenameString)
|
||||
},
|
||||
updateMetadataFileFormat(val) {
|
||||
if (this.serverSettings.metadataFileFormat === val) return
|
||||
this.updateSettingsKey('metadataFileFormat', val)
|
||||
},
|
||||
updateSettingsKey(key, val) {
|
||||
this.updateServerSettings({
|
||||
[key]: val
|
||||
|
|
@ -355,8 +390,7 @@ export default {
|
|||
this.updatingServerSettings = true
|
||||
this.$store
|
||||
.dispatch('updateServerSettings', payload)
|
||||
.then((success) => {
|
||||
console.log('Updated Server Settings', success)
|
||||
.then(() => {
|
||||
this.updatingServerSettings = false
|
||||
this.$toast.success('Server settings updated')
|
||||
|
||||
|
|
@ -442,4 +476,4 @@ export default {
|
|||
this.initServerSettings()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@
|
|||
<ui-text-input v-else v-model="newTagName" />
|
||||
<div class="flex-grow" />
|
||||
<template v-if="editingTag !== tag">
|
||||
<ui-icon-btn v-if="editingTag !== tag" icon="edit" borderless :size="8" icon-font-size="1.1rem" class="mx-1" @click="editTagClick(tag)" />
|
||||
<ui-icon-btn v-if="editingTag !== tag" icon="delete" borderless :size="8" icon-font-size="1.1rem" @click="removeTagClick(tag)" />
|
||||
<ui-icon-btn icon="edit" borderless :size="8" icon-font-size="1.1rem" class="mx-1" @click="editTagClick(tag)" />
|
||||
<ui-icon-btn icon="delete" borderless :size="8" icon-font-size="1.1rem" @click="removeTagClick(tag)" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<ui-btn color="success" small class="mx-2" @click.stop="saveTagClick">{{ $strings.ButtonSave }}</ui-btn>
|
||||
|
|
|
|||
|
|
@ -60,6 +60,25 @@
|
|||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="w-80 my-6 mx-auto">
|
||||
<h1 class="text-2xl mb-4">{{ $strings.HeaderStatsLargestItems }}</h1>
|
||||
<p v-if="!top10LargestItems.length">{{ $strings.MessageNoItems }}</p>
|
||||
<template v-for="(ab, index) in top10LargestItems">
|
||||
<div :key="index" class="w-full py-2">
|
||||
<div class="flex items-center mb-1">
|
||||
<p class="text-sm text-white text-opacity-70 w-44 pr-2 truncate">
|
||||
{{ index + 1 }}. <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.size) / largestItemSize) + '%' }" />
|
||||
</div>
|
||||
<div class="w-4 ml-3">
|
||||
<p class="text-sm font-bold">{{ $bytesPretty(ab.size) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</app-settings-content>
|
||||
</div>
|
||||
|
|
@ -105,6 +124,13 @@ export default {
|
|||
if (!this.top10LongestItems.length) return 0
|
||||
return this.top10LongestItems[0].duration
|
||||
},
|
||||
top10LargestItems() {
|
||||
return this.libraryStats ? this.libraryStats.largestItems || [] : []
|
||||
},
|
||||
largestItemSize() {
|
||||
if (!this.top10LargestItems.length) return 0
|
||||
return this.top10LargestItems[0].size
|
||||
},
|
||||
authorsWithCount() {
|
||||
return this.libraryStats ? this.libraryStats.authorsWithCount : []
|
||||
},
|
||||
|
|
@ -135,4 +161,4 @@ export default {
|
|||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@
|
|||
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
|
||||
</td>
|
||||
<td class="text-center hidden sm:table-cell">
|
||||
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDate(session.updatedAt, 'MMMM do, yyyy HH:mm')">
|
||||
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDatetime(session.updatedAt, dateFormat, timeFormat)">
|
||||
<p class="text-xs text-gray-200">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
|
||||
</ui-tooltip>
|
||||
</td>
|
||||
|
|
@ -52,9 +52,53 @@
|
|||
</div>
|
||||
</div>
|
||||
<p v-else class="text-white text-opacity-50">{{ $strings.MessageNoListeningSessions }}</p>
|
||||
|
||||
<!-- open listening sessions table -->
|
||||
<p v-if="openListeningSessions.length" class="text-lg mb-4 mt-8">Open Listening Sessions</p>
|
||||
<div v-if="openListeningSessions.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">{{ $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 openListeningSessions" :key="`open-${session.id}`" class="cursor-pointer" @click="showSession(session)">
|
||||
<td class="py-1 max-w-48">
|
||||
<p class="text-xs text-gray-200 truncate">{{ session.displayTitle }}</p>
|
||||
<p class="text-xs text-gray-400 truncate">{{ session.displayAuthor }}</p>
|
||||
</td>
|
||||
<td class="hidden md:table-cell">
|
||||
<p v-if="filteredUserUsername" class="text-xs">{{ filteredUserUsername }}</p>
|
||||
<p v-else class="text-xs">{{ session.user ? session.user.username : 'N/A' }}</p>
|
||||
</td>
|
||||
<td class="hidden md:table-cell">
|
||||
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p>
|
||||
</td>
|
||||
<td class="hidden sm:table-cell">
|
||||
<p class="text-xs" v-html="getDeviceInfoString(session.deviceInfo)" />
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
|
||||
</td>
|
||||
<td class="text-center hover:underline" @click.stop="clickCurrentTime(session)">
|
||||
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
|
||||
</td>
|
||||
<td class="text-center hidden sm:table-cell">
|
||||
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDatetime(session.updatedAt, dateFormat, timeFormat)">
|
||||
<p class="text-xs text-gray-200">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
|
||||
</ui-tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</app-settings-content>
|
||||
|
||||
<modals-listening-session-modal v-model="showSessionModal" :session="selectedSession" @removedSession="removedSession" />
|
||||
<modals-listening-session-modal v-model="showSessionModal" :session="selectedSession" @removedSession="removedSession" @closedSession="closedSession" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -81,6 +125,7 @@ export default {
|
|||
showSessionModal: false,
|
||||
selectedSession: null,
|
||||
listeningSessions: [],
|
||||
openListeningSessions: [],
|
||||
numPages: 0,
|
||||
total: 0,
|
||||
currentPage: 0,
|
||||
|
|
@ -105,9 +150,18 @@ export default {
|
|||
if (!this.userFilter) return null
|
||||
var user = this.users.find((u) => u.id === this.userFilter)
|
||||
return user ? user.username : null
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
closedSession() {
|
||||
this.loadOpenSessions()
|
||||
},
|
||||
removedSession() {
|
||||
// If on last page and this was the last session then load prev page
|
||||
if (this.currentPage == this.numPages - 1) {
|
||||
|
|
@ -149,7 +203,7 @@ export default {
|
|||
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',
|
||||
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
|
||||
duration: episode.audioFile.duration || null,
|
||||
coverPath: libraryItem.media.coverPath || null
|
||||
}
|
||||
|
|
@ -216,7 +270,7 @@ export default {
|
|||
async loadSessions(page) {
|
||||
var userFilterQuery = this.selectedUser ? `&user=${this.selectedUser}` : ''
|
||||
const data = await this.$axios.$get(`/api/sessions?page=${page}&itemsPerPage=${this.itemsPerPage}${userFilterQuery}`).catch((err) => {
|
||||
console.error('Failed to load listening sesions', err)
|
||||
console.error('Failed to load listening sessions', err)
|
||||
return null
|
||||
})
|
||||
if (!data) {
|
||||
|
|
@ -230,8 +284,24 @@ export default {
|
|||
this.listeningSessions = data.sessions
|
||||
this.userFilter = data.userFilter
|
||||
},
|
||||
async loadOpenSessions() {
|
||||
const data = await this.$axios.$get('/api/sessions/open').catch((err) => {
|
||||
console.error('Failed to load open sessions', err)
|
||||
return null
|
||||
})
|
||||
if (!data) {
|
||||
this.$toast.error('Failed to load open sessions')
|
||||
return
|
||||
}
|
||||
|
||||
this.openListeningSessions = (data.sessions || []).map((s) => {
|
||||
s.open = true
|
||||
return s
|
||||
})
|
||||
},
|
||||
init() {
|
||||
this.loadSessions(0)
|
||||
this.loadOpenSessions()
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
|
@ -266,4 +336,4 @@ export default {
|
|||
padding: 4px 8px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -79,12 +79,12 @@
|
|||
<p class="text-sm">{{ Math.floor(item.progress * 100) }}%</p>
|
||||
</td>
|
||||
<td class="text-center hidden sm:table-cell">
|
||||
<ui-tooltip v-if="item.startedAt" direction="top" :text="$formatDate(item.startedAt, 'MMMM do, yyyy HH:mm')">
|
||||
<ui-tooltip v-if="item.startedAt" direction="top" :text="$formatDatetime(item.startedAt, dateFormat, timeFormat)">
|
||||
<p class="text-sm">{{ $dateDistanceFromNow(item.startedAt) }}</p>
|
||||
</ui-tooltip>
|
||||
</td>
|
||||
<td class="text-center hidden sm:table-cell">
|
||||
<ui-tooltip v-if="item.lastUpdate" direction="top" :text="$formatDate(item.lastUpdate, 'MMMM do, yyyy HH:mm')">
|
||||
<ui-tooltip v-if="item.lastUpdate" direction="top" :text="$formatDatetime(item.lastUpdate, dateFormat, timeFormat)">
|
||||
<p class="text-sm">{{ $dateDistanceFromNow(item.lastUpdate) }}</p>
|
||||
</ui-tooltip>
|
||||
</td>
|
||||
|
|
@ -149,6 +149,12 @@ export default {
|
|||
latestSession() {
|
||||
if (!this.listeningSessions.sessions || !this.listeningSessions.sessions.length) return null
|
||||
return this.listeningSessions.sessions[0]
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@
|
|||
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
|
||||
</td>
|
||||
<td class="text-center hidden sm:table-cell">
|
||||
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDate(session.updatedAt, 'MMMM do, yyyy HH:mm')">
|
||||
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDatetime(session.updatedAt, dateFormat, timeFormat)">
|
||||
<p class="text-xs text-gray-200">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
|
||||
</ui-tooltip>
|
||||
</td>
|
||||
|
|
@ -96,6 +96,12 @@ export default {
|
|||
},
|
||||
userOnline() {
|
||||
return this.$store.getters['users/getIsUserOnline'](this.user.id)
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
|
@ -140,7 +146,7 @@ export default {
|
|||
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',
|
||||
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
|
||||
duration: episode.audioFile.duration || null,
|
||||
coverPath: libraryItem.media.coverPath || null
|
||||
}
|
||||
|
|
@ -252,4 +258,4 @@ export default {
|
|||
padding: 4px 8px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
<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 md:flex-row max-w-6xl mx-auto">
|
||||
<div class="w-full flex justify-center md:block md:w-52" style="min-width: 208px">
|
||||
<div class="w-full h-full overflow-y-auto px-2 py-6 lg:p-8">
|
||||
<div class="flex flex-col lg:flex-row max-w-6xl mx-auto">
|
||||
<div class="w-full flex justify-center lg:block lg:w-52" style="min-width: 208px">
|
||||
<div class="relative" style="height: fit-content">
|
||||
<covers-book-cover :library-item="libraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
|
||||
|
|
@ -21,11 +21,15 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow px-2 py-6 md:py-0 md:px-10">
|
||||
<div class="flex-grow px-2 py-6 lg:py-0 md:px-10">
|
||||
<div class="flex justify-center">
|
||||
<div class="mb-4">
|
||||
<h1 class="text-2xl md:text-3xl font-semibold">
|
||||
{{ title }}
|
||||
<div class="flex items-center">
|
||||
{{ title }}
|
||||
<widgets-explicit-indicator :explicit="isExplicit" />
|
||||
<widgets-abridged-indicator v-if="isAbridged" />
|
||||
</div>
|
||||
</h1>
|
||||
|
||||
<p v-if="bookSubtitle" class="text-gray-200 text-xl md:text-2xl">{{ bookSubtitle }}</p>
|
||||
|
|
@ -38,89 +42,12 @@
|
|||
<nuxt-link v-for="(artist, index) in musicArtists" :key="index" :to="`/artist/${$encode(artist)}`" class="hover:underline">{{ artist }}<span v-if="index < musicArtists.length - 1">, </span></nuxt-link>
|
||||
</p>
|
||||
<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">, </span></nuxt-link>
|
||||
by <nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}?library=${libraryItem.libraryId}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link>
|
||||
</p>
|
||||
<p v-else class="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</p>
|
||||
</template>
|
||||
|
||||
<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">{{ $strings.LabelNarrators }}</span>
|
||||
</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">, </span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="publishedYear" class="flex py-0.5">
|
||||
<div class="w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelPublishYear }}</span>
|
||||
</div>
|
||||
<div>
|
||||
{{ publishedYear }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="musicAlbum" class="flex py-0.5">
|
||||
<div class="w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">Album</span>
|
||||
</div>
|
||||
<div>
|
||||
{{ musicAlbum }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="musicAlbumArtist" class="flex py-0.5">
|
||||
<div class="w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">Album Artist</span>
|
||||
</div>
|
||||
<div>
|
||||
{{ musicAlbumArtist }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="musicTrackPretty" class="flex py-0.5">
|
||||
<div class="w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">Track</span>
|
||||
</div>
|
||||
<div>
|
||||
{{ musicTrackPretty }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="musicDiscPretty" class="flex py-0.5">
|
||||
<div class="w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">Disc</span>
|
||||
</div>
|
||||
<div>
|
||||
{{ musicDiscPretty }}
|
||||
</div>
|
||||
</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">{{ $strings.LabelGenres }}</span>
|
||||
</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">, </span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="tracks.length || audioFile || (isPodcast && totalPodcastDuration)" class="flex py-0.5">
|
||||
<div class="w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelDuration }}</span>
|
||||
</div>
|
||||
<div>
|
||||
{{ durationPretty }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex py-0.5">
|
||||
<div class="w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelSize }}</span>
|
||||
</div>
|
||||
<div>
|
||||
{{ sizePretty }}
|
||||
</div>
|
||||
</div>
|
||||
<content-library-item-details :library-item="libraryItem" />
|
||||
</div>
|
||||
<div class="hidden md:block flex-grow" />
|
||||
</div>
|
||||
|
|
@ -153,7 +80,7 @@
|
|||
<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">{{ $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 v-if="progressPercent < 1 && !useEBookProgress" 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">
|
||||
|
|
@ -190,27 +117,18 @@
|
|||
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" class="mx-0.5" @click="toggleFinished" />
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip v-if="showCollectionsButton" :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="$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="$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="$strings.LabelOpenRSSFeed" direction="top">
|
||||
<ui-icon-btn icon="rss_feed" class="mx-0.5" :bg-color="rssFeed ? 'success' : 'primary'" outlined @click="clickRSSFeed" />
|
||||
</ui-tooltip>
|
||||
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="148" @action="contextMenuAction">
|
||||
<template #default="{ showMenu, clickShowMenu, disabled }">
|
||||
<button type="button" :disabled="disabled" class="mx-0.5 icon-btn bg-primary border border-gray-600 w-9 h-9 rounded-md flex items-center justify-center relative" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
|
||||
<span class="material-icons">more_horiz</span>
|
||||
</button>
|
||||
</template>
|
||||
</ui-context-menu-dropdown>
|
||||
</div>
|
||||
|
||||
<div class="my-4 max-w-2xl">
|
||||
|
|
@ -229,7 +147,7 @@
|
|||
|
||||
<tables-chapters-table v-if="chapters.length" :library-item="libraryItem" class="mt-6" />
|
||||
|
||||
<tables-library-files-table v-if="libraryFiles.length" :is-missing="isMissing" :library-item-id="libraryItemId" :files="libraryFiles" class="mt-6" />
|
||||
<tables-library-files-table v-if="libraryFiles.length" :is-missing="isMissing" :library-item="libraryItem" class="mt-6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -273,6 +191,12 @@ export default {
|
|||
}
|
||||
},
|
||||
computed: {
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
downloadUrl() {
|
||||
return `${process.env.serverUrl}/api/items/${this.libraryItemId}/download?token=${this.userToken}`
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
},
|
||||
|
|
@ -285,9 +209,6 @@ export default {
|
|||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
isFile() {
|
||||
return this.libraryItem.isFile
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
|
|
@ -297,6 +218,9 @@ export default {
|
|||
isDeveloperMode() {
|
||||
return this.$store.state.developerMode
|
||||
},
|
||||
isFile() {
|
||||
return this.libraryItem.isFile
|
||||
},
|
||||
isBook() {
|
||||
return this.libraryItem.mediaType === 'book'
|
||||
},
|
||||
|
|
@ -315,6 +239,12 @@ export default {
|
|||
isInvalid() {
|
||||
return this.libraryItem.isInvalid
|
||||
},
|
||||
isExplicit() {
|
||||
return !!this.mediaMetadata.explicit
|
||||
},
|
||||
isAbridged() {
|
||||
return !!this.mediaMetadata.abridged
|
||||
},
|
||||
invalidAudioFiles() {
|
||||
if (!this.isBook) return []
|
||||
return this.libraryItem.media.audioFiles.filter((af) => af.invalid)
|
||||
|
|
@ -332,9 +262,6 @@ export default {
|
|||
libraryId() {
|
||||
return this.libraryItem.libraryId
|
||||
},
|
||||
folderId() {
|
||||
return this.libraryItem.folderId
|
||||
},
|
||||
libraryItemId() {
|
||||
return this.libraryItem.id
|
||||
},
|
||||
|
|
@ -360,19 +287,10 @@ export default {
|
|||
title() {
|
||||
return this.mediaMetadata.title || 'No Title'
|
||||
},
|
||||
publishedYear() {
|
||||
return this.mediaMetadata.publishedYear
|
||||
},
|
||||
narrator() {
|
||||
return this.mediaMetadata.narratorName
|
||||
},
|
||||
bookSubtitle() {
|
||||
if (this.isPodcast) return null
|
||||
return this.mediaMetadata.subtitle
|
||||
},
|
||||
genres() {
|
||||
return this.mediaMetadata.genres || []
|
||||
},
|
||||
podcastAuthor() {
|
||||
return this.mediaMetadata.author || ''
|
||||
},
|
||||
|
|
@ -382,25 +300,6 @@ export default {
|
|||
musicArtists() {
|
||||
return this.mediaMetadata.artists || []
|
||||
},
|
||||
musicAlbum() {
|
||||
return this.mediaMetadata.album || ''
|
||||
},
|
||||
musicAlbumArtist() {
|
||||
return this.mediaMetadata.albumArtist || ''
|
||||
},
|
||||
musicTrackPretty() {
|
||||
if (!this.mediaMetadata.trackNumber) return null
|
||||
if (!this.mediaMetadata.trackTotal) return this.mediaMetadata.trackNumber
|
||||
return `${this.mediaMetadata.trackNumber} / ${this.mediaMetadata.trackTotal}`
|
||||
},
|
||||
musicDiscPretty() {
|
||||
if (!this.mediaMetadata.discNumber) return null
|
||||
if (!this.mediaMetadata.discTotal) return this.mediaMetadata.discNumber
|
||||
return `${this.mediaMetadata.discNumber} / ${this.mediaMetadata.discTotal}`
|
||||
},
|
||||
narrators() {
|
||||
return this.mediaMetadata.narrators || []
|
||||
},
|
||||
series() {
|
||||
return this.mediaMetadata.series || []
|
||||
},
|
||||
|
|
@ -414,26 +313,10 @@ export default {
|
|||
}
|
||||
})
|
||||
},
|
||||
durationPretty() {
|
||||
if (this.isPodcast) return this.$elapsedPrettyExtended(this.totalPodcastDuration)
|
||||
|
||||
if (!this.tracks.length && !this.audioFile) return 'N/A'
|
||||
if (this.audioFile) return this.$elapsedPrettyExtended(this.duration)
|
||||
return this.$elapsedPretty(this.duration)
|
||||
},
|
||||
duration() {
|
||||
if (!this.tracks.length && !this.audioFile) return 0
|
||||
return this.media.duration
|
||||
},
|
||||
totalPodcastDuration() {
|
||||
if (!this.podcastEpisodes.length) return 0
|
||||
let totalDuration = 0
|
||||
this.podcastEpisodes.forEach((ep) => (totalDuration += ep.duration || 0))
|
||||
return totalDuration
|
||||
},
|
||||
sizePretty() {
|
||||
return this.$bytesPretty(this.media.size)
|
||||
},
|
||||
libraryFiles() {
|
||||
return this.libraryItem.libraryFiles || []
|
||||
},
|
||||
|
|
@ -465,7 +348,12 @@ export default {
|
|||
const duration = this.userMediaProgress.duration || this.duration
|
||||
return duration - this.userMediaProgress.currentTime
|
||||
},
|
||||
useEBookProgress() {
|
||||
if (!this.userMediaProgress || this.userMediaProgress.progress) return false
|
||||
return this.userMediaProgress.ebookProgress > 0
|
||||
},
|
||||
progressPercent() {
|
||||
if (this.useEBookProgress) return Math.max(Math.min(1, this.userMediaProgress.ebookProgress), 0)
|
||||
return this.userMediaProgress ? Math.max(Math.min(1, this.userMediaProgress.progress), 0) : 0
|
||||
},
|
||||
userProgressStartedAt() {
|
||||
|
|
@ -504,12 +392,69 @@ export default {
|
|||
},
|
||||
showCollectionsButton() {
|
||||
return this.isBook && this.userCanUpdate
|
||||
},
|
||||
contextMenuItems() {
|
||||
const items = []
|
||||
|
||||
if (this.showCollectionsButton) {
|
||||
items.push({
|
||||
text: this.$strings.LabelCollections,
|
||||
action: 'collections'
|
||||
})
|
||||
}
|
||||
|
||||
if (!this.isPodcast && this.tracks.length) {
|
||||
items.push({
|
||||
text: this.$strings.LabelYourPlaylists,
|
||||
action: 'playlists'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.bookmarks.length) {
|
||||
items.push({
|
||||
text: this.$strings.LabelYourBookmarks,
|
||||
action: 'bookmarks'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.showRssFeedBtn) {
|
||||
items.push({
|
||||
text: this.$strings.LabelOpenRSSFeed,
|
||||
action: 'rss-feeds'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.userCanDownload) {
|
||||
items.push({
|
||||
text: this.$strings.LabelDownload,
|
||||
action: 'download'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.ebookFile && this.$store.state.libraries.ereaderDevices?.length) {
|
||||
items.push({
|
||||
text: this.$strings.LabelSendEbookToDevice,
|
||||
subitems: this.$store.state.libraries.ereaderDevices.map((d) => {
|
||||
return {
|
||||
text: d.name,
|
||||
action: 'sendToDevice',
|
||||
data: d.name
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (this.userCanDelete) {
|
||||
items.push({
|
||||
text: this.$strings.ButtonDelete,
|
||||
action: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickBookmarksBtn() {
|
||||
this.showBookmarksModal = true
|
||||
},
|
||||
selectBookmark(bookmark) {
|
||||
if (!bookmark) return
|
||||
if (this.isStreaming) {
|
||||
|
|
@ -632,7 +577,7 @@ export default {
|
|||
episodeId: episode.id,
|
||||
title: episode.title,
|
||||
subtitle: this.title,
|
||||
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, 'MMM do, yyyy')}` : 'Unknown publish date',
|
||||
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
|
||||
duration: episode.audioFile.duration || null,
|
||||
coverPath: this.libraryItem.media.coverPath || null
|
||||
})
|
||||
|
|
@ -670,10 +615,11 @@ export default {
|
|||
}
|
||||
},
|
||||
clearProgressClick() {
|
||||
if (!this.userMediaProgress) return
|
||||
if (confirm(`Are you sure you want to reset your progress?`)) {
|
||||
this.resettingProgress = true
|
||||
this.$axios
|
||||
.$delete(`/api/me/progress/${this.libraryItemId}`)
|
||||
.$delete(`/api/me/progress/${this.userMediaProgress.id}`)
|
||||
.then(() => {
|
||||
console.log('Progress reset complete')
|
||||
this.$toast.success(`Your progress was reset`)
|
||||
|
|
@ -685,14 +631,6 @@ export default {
|
|||
})
|
||||
}
|
||||
},
|
||||
collectionsClick() {
|
||||
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
|
||||
this.$store.commit('globals/setShowCollectionsModal', true)
|
||||
},
|
||||
playlistsClick() {
|
||||
this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem }])
|
||||
this.$store.commit('globals/setShowPlaylistsModal', true)
|
||||
},
|
||||
clickRSSFeed() {
|
||||
this.$store.commit('globals/setRSSFeedOpenCloseModal', {
|
||||
id: this.libraryItemId,
|
||||
|
|
@ -750,12 +688,86 @@ export default {
|
|||
}
|
||||
this.$store.commit('addItemToQueue', queueItem)
|
||||
}
|
||||
},
|
||||
downloadLibraryItem() {
|
||||
this.$downloadFile(this.downloadUrl)
|
||||
},
|
||||
deleteLibraryItem() {
|
||||
const payload = {
|
||||
message: 'This will delete the library item from the database and your file system. Are you sure?',
|
||||
checkboxLabel: 'Delete from file system. Uncheck to only remove from database.',
|
||||
yesButtonText: this.$strings.ButtonDelete,
|
||||
yesButtonColor: 'error',
|
||||
checkboxDefaultValue: true,
|
||||
callback: (confirmed, hardDelete) => {
|
||||
if (confirmed) {
|
||||
this.$axios
|
||||
.$delete(`/api/items/${this.libraryItemId}?hard=${hardDelete ? 1 : 0}`)
|
||||
.then(() => {
|
||||
this.$toast.success('Item deleted')
|
||||
this.$router.replace(`/library/${this.libraryId}`)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to delete item', error)
|
||||
this.$toast.error('Failed to delete item')
|
||||
})
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
sendToDevice(deviceName) {
|
||||
const payload = {
|
||||
message: this.$getString('MessageConfirmSendEbookToDevice', [this.ebookFile.ebookFormat, this.title, deviceName]),
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
const payload = {
|
||||
libraryItemId: this.libraryItemId,
|
||||
deviceName
|
||||
}
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$post(`/api/emails/send-ebook-to-device`, payload)
|
||||
.then(() => {
|
||||
this.$toast.success(this.$getString('ToastSendEbookToDeviceSuccess', [deviceName]))
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to send ebook to device', error)
|
||||
this.$toast.error(this.$strings.ToastSendEbookToDeviceFailed)
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
contextMenuAction({ action, data }) {
|
||||
if (action === 'collections') {
|
||||
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
|
||||
this.$store.commit('globals/setShowCollectionsModal', true)
|
||||
} else if (action === 'playlists') {
|
||||
this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem }])
|
||||
this.$store.commit('globals/setShowPlaylistsModal', true)
|
||||
} else if (action === 'bookmarks') {
|
||||
this.showBookmarksModal = true
|
||||
} else if (action === 'rss-feeds') {
|
||||
this.clickRSSFeed()
|
||||
} else if (action === 'download') {
|
||||
this.downloadLibraryItem()
|
||||
} else if (action === 'delete') {
|
||||
this.deleteLibraryItem()
|
||||
} else if (action === 'sendToDevice') {
|
||||
this.sendToDevice(data)
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.libraryItem.episodesDownloading) {
|
||||
this.episodeDownloadsQueued = this.libraryItem.episodesDownloading || []
|
||||
}
|
||||
this.episodeDownloadsQueued = this.libraryItem.episodeDownloadsQueued || []
|
||||
this.episodesDownloading = this.libraryItem.episodesDownloading || []
|
||||
|
||||
// use this items library id as the current
|
||||
if (this.libraryId) {
|
||||
|
|
|
|||
161
client/pages/library/_library/narrators.vue
Normal file
161
client/pages/library/_library/narrators.vue
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
<template>
|
||||
<div class="page relative" :class="streamLibraryItem ? 'streaming' : ''">
|
||||
<app-book-shelf-toolbar page="narrators" is-home />
|
||||
<div id="bookshelf" class="w-full h-full px-1 py-4 md:p-8 relative overflow-y-auto">
|
||||
<table class="tracksTable max-w-2xl mx-auto">
|
||||
<tr>
|
||||
<th class="text-left">{{ $strings.LabelName }}</th>
|
||||
<th class="text-center w-24">{{ $strings.LabelBooks }}</th>
|
||||
<th v-if="userCanUpdate" class="w-40"></th>
|
||||
</tr>
|
||||
<tr v-for="narrator in narrators" :key="narrator.id">
|
||||
<td>
|
||||
<p v-if="selectedNarrator?.id !== narrator.id" class="text-sm md:text-base text-gray-100">{{ narrator.name }}</p>
|
||||
<form v-else @submit.prevent="saveClick">
|
||||
<ui-text-input v-model="newNarratorName" />
|
||||
</form>
|
||||
</td>
|
||||
<td class="text-center w-24">
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=narrators.${narrator.id}`" class="hover:underline">{{ narrator.numBooks }}</nuxt-link>
|
||||
</td>
|
||||
<td v-if="userCanUpdate" class="w-40">
|
||||
<div class="flex justify-end items-center h-10">
|
||||
<template v-if="selectedNarrator?.id !== narrator.id">
|
||||
<ui-icon-btn icon="edit" borderless :size="8" icon-font-size="1.1rem" class="mx-1" @click="editClick(narrator)" />
|
||||
<ui-icon-btn icon="delete" borderless :size="8" icon-font-size="1.1rem" @click="removeClick(narrator)" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<ui-btn color="success" small class="mr-2" @click.stop="saveClick">{{ $strings.ButtonSave }}</ui-btn>
|
||||
<ui-btn small @click.stop="cancelEditClick">{{ $strings.ButtonCancel }}</ui-btn>
|
||||
</template>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="absolute top-0 left-0 w-full h-[calc(100%-40px)] mt-10 flex items-center justify-center bg-black/25">
|
||||
<ui-loading-indicator />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
async asyncData({ store, params, redirect, query, app }) {
|
||||
const libraryId = params.library
|
||||
const libraryData = await store.dispatch('libraries/fetch', libraryId)
|
||||
if (!libraryData) {
|
||||
return redirect('/oops?message=Library not found')
|
||||
}
|
||||
|
||||
const library = libraryData.library
|
||||
if (library.mediaType === 'podcast') {
|
||||
return redirect(`/library/${libraryId}`)
|
||||
}
|
||||
|
||||
return {
|
||||
libraryId
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
narrators: [],
|
||||
selectedNarrator: null,
|
||||
newNarratorName: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
removeClick(narrator) {
|
||||
const payload = {
|
||||
message: this.$getString('MessageConfirmRemoveNarrator', [narrator.name]),
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.removeNarrator(narrator.id)
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
editClick(narrator) {
|
||||
this.selectedNarrator = narrator
|
||||
this.newNarratorName = narrator.name
|
||||
},
|
||||
cancelEditClick() {
|
||||
this.selectedNarrator = null
|
||||
this.newNarratorName = null
|
||||
},
|
||||
saveClick() {
|
||||
if (!this.selectedNarrator) return
|
||||
this.newNarratorName = this.newNarratorName?.trim() || ''
|
||||
if (!this.newNarratorName || this.newNarratorName === this.selectedNarrator.name) {
|
||||
this.cancelEditClick()
|
||||
return
|
||||
}
|
||||
|
||||
this.loading = true
|
||||
this.$axios
|
||||
.$patch(`/api/libraries/${this.currentLibraryId}/narrators/${this.selectedNarrator.id}`, { name: this.newNarratorName })
|
||||
.then((data) => {
|
||||
if (data.updated) {
|
||||
this.$toast.success(this.$getString('MessageItemsUpdated', [data.updated]))
|
||||
} else {
|
||||
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
|
||||
}
|
||||
this.cancelEditClick()
|
||||
this.init()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to updated narrator', error)
|
||||
this.$toast.error('Failed to update narrator')
|
||||
this.loading = false
|
||||
})
|
||||
},
|
||||
removeNarrator(id) {
|
||||
this.loading = true
|
||||
this.$axios
|
||||
.$delete(`/api/libraries/${this.currentLibraryId}/narrators/${id}`)
|
||||
.then((data) => {
|
||||
if (data.updated) {
|
||||
this.$toast.success(this.$getString('MessageItemsUpdated', [data.updated]))
|
||||
} else {
|
||||
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
|
||||
}
|
||||
this.init()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to remove narrator', error)
|
||||
this.$toast.error('Failed to remove narrator')
|
||||
this.loading = false
|
||||
})
|
||||
},
|
||||
async init() {
|
||||
this.narrators = await this.$axios
|
||||
.$get(`/api/libraries/${this.currentLibraryId}/narrators`)
|
||||
.then((response) => response.narrators)
|
||||
.catch((error) => {
|
||||
console.error('Failed to load narrators', error)
|
||||
return []
|
||||
})
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
},
|
||||
beforeDestroy() {}
|
||||
}
|
||||
</script>
|
||||
140
client/pages/library/_library/podcast/download-queue.vue
Normal file
140
client/pages/library/_library/podcast/download-queue.vue
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
<template>
|
||||
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
||||
<app-book-shelf-toolbar page="podcast-search" />
|
||||
|
||||
<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-5xl mx-auto py-4">
|
||||
<p class="text-xl mb-2 font-semibold px-4 md:px-0">{{ $strings.HeaderCurrentDownloads }}</p>
|
||||
<p v-if="!episodesDownloading.length" class="text-lg py-4">{{ $strings.MessageNoDownloadsInProgress }}</p>
|
||||
<template v-for="episode in episodesDownloading">
|
||||
<div :key="episode.id" class="flex py-5 relative">
|
||||
<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">
|
||||
<!-- 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">
|
||||
<div class="flex items-center">
|
||||
<nuxt-link :to="`/item/${episode.libraryItemId}`" class="text-sm text-gray-200 hover:underline">{{ episode.podcastTitle }}</nuxt-link>
|
||||
<widgets-explicit-indicator :explicit="episode.podcastExplicit" />
|
||||
</div>
|
||||
<p class="text-xs text-gray-300 mb-1">{{ $dateDistanceFromNow(episode.publishedAt) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- desktop -->
|
||||
<div class="hidden md:block">
|
||||
<div class="flex items-center">
|
||||
<nuxt-link :to="`/item/${episode.libraryItemId}`" class="text-sm text-gray-200 hover:underline">{{ episode.podcastTitle }}</nuxt-link>
|
||||
<widgets-explicit-indicator :explicit="episode.podcastExplicit" />
|
||||
</div>
|
||||
<p class="text-xs text-gray-300 mb-1">{{ $dateDistanceFromNow(episode.publishedAt) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center font-semibold text-gray-200">
|
||||
<div v-if="episode.season || episode.episode">#</div>
|
||||
<div v-if="episode.season">{{ episode.season }}x</div>
|
||||
<div v-if="episode.episode">{{ episode.episode }}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center mb-2">
|
||||
<span class="font-semibold text-sm md:text-base">{{ episode.episodeDisplayTitle }}</span>
|
||||
<widgets-podcast-type-indicator :type="episode.episodeType" />
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-200 mb-4">{{ episode.subtitle }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<tables-podcast-download-queue-table v-if="episodeDownloadsQueued.length" :queue="episodeDownloadsQueued"></tables-podcast-download-queue-table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
async asyncData({ params, redirect }) {
|
||||
if (!params.library) {
|
||||
console.error('No library...', params.library)
|
||||
return redirect('/')
|
||||
}
|
||||
return {
|
||||
libraryId: params.library
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
episodesDownloading: [],
|
||||
episodeDownloadsQueued: [],
|
||||
processing: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
episodeDownloadQueued(episodeDownload) {
|
||||
if (episodeDownload.libraryId === this.libraryId) {
|
||||
this.episodeDownloadsQueued.push(episodeDownload)
|
||||
}
|
||||
},
|
||||
episodeDownloadStarted(episodeDownload) {
|
||||
if (episodeDownload.libraryId === this.libraryId) {
|
||||
this.episodeDownloadsQueued = this.episodeDownloadsQueued.filter((d) => d.id !== episodeDownload.id)
|
||||
this.episodesDownloading.push(episodeDownload)
|
||||
}
|
||||
},
|
||||
episodeDownloadFinished(episodeDownload) {
|
||||
if (episodeDownload.libraryId === this.libraryId) {
|
||||
this.episodeDownloadsQueued = this.episodeDownloadsQueued.filter((d) => d.id !== episodeDownload.id)
|
||||
this.episodesDownloading = this.episodesDownloading.filter((d) => d.id !== episodeDownload.id)
|
||||
}
|
||||
},
|
||||
episodeDownloadQueueUpdated(downloadQueueDetails) {
|
||||
this.episodeDownloadsQueued = downloadQueueDetails.queue.filter((q) => q.libraryId == this.libraryId)
|
||||
},
|
||||
async loadInitialDownloadQueue() {
|
||||
this.processing = true
|
||||
const queuePayload = await this.$axios.$get(`/api/libraries/${this.libraryId}/episode-downloads`).catch((error) => {
|
||||
console.error('Failed to get download queue', error)
|
||||
this.$toast.error('Failed to get download queue')
|
||||
return null
|
||||
})
|
||||
this.processing = false
|
||||
this.episodeDownloadsQueued = queuePayload?.queue || []
|
||||
|
||||
if (queuePayload?.currentDownload) {
|
||||
this.episodesDownloading.push(queuePayload.currentDownload)
|
||||
}
|
||||
|
||||
// Initialize listeners after load to prevent event race conditions
|
||||
this.initListeners()
|
||||
},
|
||||
initListeners() {
|
||||
this.$root.socket.on('episode_download_queued', this.episodeDownloadQueued)
|
||||
this.$root.socket.on('episode_download_started', this.episodeDownloadStarted)
|
||||
this.$root.socket.on('episode_download_finished', this.episodeDownloadFinished)
|
||||
this.$root.socket.on('episode_download_queue_updated', this.episodeDownloadQueueUpdated)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.libraryId) {
|
||||
this.$store.commit('libraries/setCurrentLibrary', this.libraryId)
|
||||
}
|
||||
|
||||
this.loadInitialDownloadQueue()
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$root.socket.off('episode_download_queued', this.episodeDownloadQueued)
|
||||
this.$root.socket.off('episode_download_started', this.episodeDownloadStarted)
|
||||
this.$root.socket.off('episode_download_finished', this.episodeDownloadFinished)
|
||||
this.$root.socket.off('episode_download_queue_updated', this.episodeDownloadQueueUpdated)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -14,21 +14,38 @@
|
|||
<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>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="flex" @click.stop>
|
||||
<nuxt-link :to="`/item/${episode.libraryItemId}`" class="text-sm text-gray-200 hover:underline">{{ episode.podcast.metadata.title }}</nuxt-link>
|
||||
</div>
|
||||
<widgets-explicit-indicator :explicit="episode.podcast.metadata.explicit" />
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="flex" @click.stop>
|
||||
<nuxt-link :to="`/item/${episode.libraryItemId}`" class="text-sm text-gray-200 hover:underline">{{ episode.podcast.metadata.title }}</nuxt-link>
|
||||
</div>
|
||||
<widgets-explicit-indicator :explicit="episode.podcast.metadata.explicit" />
|
||||
</div>
|
||||
<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>
|
||||
<div class="flex items-center font-semibold text-gray-200">
|
||||
<div v-if="episode.season || episode.episode">#</div>
|
||||
<div v-if="episode.season">{{ episode.season }}x</div>
|
||||
<div v-if="episode.episode">{{ episode.episode }}</div>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-200 mb-4">{{ episode.subtitle }}</p>
|
||||
<div class="flex items-center mb-2">
|
||||
<div class="font-semibold text-sm md:text-base">{{ episode.title }}</div>
|
||||
<widgets-podcast-type-indicator :type="episode.episodeType" />
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-200 mb-4 episode-subtitle-long" v-html="episode.subtitle || episode.description" />
|
||||
|
||||
<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)">
|
||||
|
|
@ -113,6 +130,9 @@ export default {
|
|||
if (i.episodeId) episodeIds[i.episodeId] = true
|
||||
})
|
||||
return episodeIds
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
|
@ -156,7 +176,7 @@ export default {
|
|||
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',
|
||||
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
|
||||
duration: episode.duration || null,
|
||||
coverPath: episode.podcast.coverPath || null
|
||||
})
|
||||
|
|
@ -194,7 +214,7 @@ export default {
|
|||
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',
|
||||
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
|
||||
duration: episode.duration || null,
|
||||
coverPath: episode.podcast.coverPath || null
|
||||
}
|
||||
|
|
@ -206,4 +226,4 @@ export default {
|
|||
this.loadRecentEpisodes()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -5,13 +5,12 @@
|
|||
<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-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-text-input v-model="searchInput" type="search" :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">{{ $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">{{ $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">{{ $strings.MessageNoPodcastsFound }}</p>
|
||||
<template v-for="podcast in results">
|
||||
|
|
@ -20,7 +19,11 @@
|
|||
<img v-if="podcast.cover" :src="podcast.cover" class="h-full w-full" />
|
||||
</div>
|
||||
<div class="flex-grow pl-4 max-w-2xl">
|
||||
<a :href="podcast.pageUrl" class="text-base md:text-lg text-gray-200 hover:underline" target="_blank" @click.stop>{{ podcast.title }}</a>
|
||||
<div class="flex items-center">
|
||||
<a :href="podcast.pageUrl" class="text-base md:text-lg text-gray-200 hover:underline" target="_blank" @click.stop>{{ podcast.title }}</a>
|
||||
<widgets-explicit-indicator :explicit="podcast.explicit" />
|
||||
<widgets-already-in-library-indicator :already-in-library="podcast.alreadyInLibrary"/>
|
||||
</div>
|
||||
<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 }} {{ $strings.HeaderEpisodes }}</p>
|
||||
|
|
@ -68,10 +71,14 @@ export default {
|
|||
selectedPodcast: null,
|
||||
selectedPodcastFeed: null,
|
||||
showOPMLFeedsModal: false,
|
||||
opmlFeeds: []
|
||||
opmlFeeds: [],
|
||||
existentPodcasts: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
}
|
||||
|
|
@ -144,18 +151,29 @@ export default {
|
|||
return []
|
||||
})
|
||||
console.log('Got results', results)
|
||||
for (let result of results) {
|
||||
let podcast = this.existentPodcasts.find((p) => p.itunesId === result.id || p.title === result.title.toLowerCase())
|
||||
if (podcast) {
|
||||
result.alreadyInLibrary = true
|
||||
result.existentId = podcast.id
|
||||
}
|
||||
}
|
||||
this.results = results
|
||||
this.termSearched = term
|
||||
this.processing = false
|
||||
},
|
||||
async selectPodcast(podcast) {
|
||||
console.log('Selected podcast', podcast)
|
||||
if(podcast.existentId){
|
||||
this.$router.push(`/item/${podcast.existentId}`)
|
||||
return
|
||||
}
|
||||
if (!podcast.feedUrl) {
|
||||
this.$toast.error('Invalid podcast - no feed')
|
||||
return
|
||||
}
|
||||
this.processing = true
|
||||
var payload = await this.$axios.$post(`/api/podcasts/feed`, { rssFeed: podcast.feedUrl }).catch((error) => {
|
||||
var payload = await this.$axios.$post(`/api/podcasts/feed`, {rssFeed: podcast.feedUrl}).catch((error) => {
|
||||
console.error('Failed to get feed', error)
|
||||
this.$toast.error('Failed to get podcast feed')
|
||||
return null
|
||||
|
|
@ -167,8 +185,26 @@ export default {
|
|||
this.selectedPodcast = podcast
|
||||
this.showNewPodcastModal = true
|
||||
console.log('Got podcast feed', payload.podcast)
|
||||
},
|
||||
async fetchExistentPodcastsInYourLibrary() {
|
||||
this.processing = true
|
||||
|
||||
const podcasts = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/items?page=0&minified=1`).catch((error) => {
|
||||
console.error('Failed to fetch podcasts', error)
|
||||
return []
|
||||
})
|
||||
this.existentPodcasts = podcasts.results.map((p) => {
|
||||
return {
|
||||
title: p.media.metadata.title.toLowerCase(),
|
||||
itunesId: p.media.metadata.itunesId,
|
||||
id: p.id
|
||||
}
|
||||
})
|
||||
this.processing = false
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
mounted() {
|
||||
this.fetchExistentPodcastsInYourLibrary()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -11,27 +11,27 @@
|
|||
<script>
|
||||
export default {
|
||||
async asyncData({ store, params, redirect, query, app }) {
|
||||
var libraryId = params.library
|
||||
var library = await store.dispatch('libraries/fetch', libraryId)
|
||||
const libraryId = params.library
|
||||
const library = await store.dispatch('libraries/fetch', libraryId)
|
||||
if (!library) {
|
||||
return redirect('/oops?message=Library not found')
|
||||
}
|
||||
var query = query.q
|
||||
var results = await app.$axios.$get(`/api/libraries/${libraryId}/search?q=${query}`).catch((error) => {
|
||||
let results = await app.$axios.$get(`/api/libraries/${libraryId}/search?q=${query.q}`).catch((error) => {
|
||||
console.error('Failed to search library', error)
|
||||
return null
|
||||
})
|
||||
results = {
|
||||
podcasts: results && results.podcast ? results.podcast : null,
|
||||
books: results && results.book ? results.book : null,
|
||||
authors: results && results.authors.length ? results.authors : null,
|
||||
series: results && results.series.length ? results.series : null,
|
||||
tags: results && results.tags.length ? results.tags : null
|
||||
podcasts: results?.podcast || [],
|
||||
books: results?.book || [],
|
||||
authors: results?.authors || [],
|
||||
series: results?.series || [],
|
||||
tags: results?.tags || [],
|
||||
narrators: results?.narrators || []
|
||||
}
|
||||
return {
|
||||
libraryId,
|
||||
results,
|
||||
query
|
||||
query: query.q
|
||||
}
|
||||
},
|
||||
data() {
|
||||
|
|
@ -55,16 +55,17 @@ export default {
|
|||
},
|
||||
methods: {
|
||||
async search() {
|
||||
var results = await this.$axios.$get(`/api/libraries/${this.libraryId}/search?q=${this.query}`).catch((error) => {
|
||||
const results = await this.$axios.$get(`/api/libraries/${this.libraryId}/search?q=${this.query}`).catch((error) => {
|
||||
console.error('Failed to search library', error)
|
||||
return null
|
||||
})
|
||||
this.results = {
|
||||
podcasts: results && results.podcast ? results.podcast : null,
|
||||
books: results && results.book ? results.book : null,
|
||||
authors: results && results.authors.length ? results.authors : null,
|
||||
series: results && results.series.length ? results.series : null,
|
||||
tags: results && results.tags.length ? results.tags : null
|
||||
podcasts: results?.podcast || [],
|
||||
books: results?.book || [],
|
||||
authors: results?.authors || [],
|
||||
series: results?.series || [],
|
||||
tags: results?.tags || [],
|
||||
narrators: results?.narrators || []
|
||||
}
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.bookshelf) {
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ export default {
|
|||
const payload = {
|
||||
newRoot: { ...this.newRoot }
|
||||
}
|
||||
var success = await this.$axios
|
||||
const success = await this.$axios
|
||||
.$post('/init', payload)
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
|
|
@ -124,9 +124,10 @@ export default {
|
|||
|
||||
location.reload()
|
||||
},
|
||||
setUser({ user, userDefaultLibraryId, serverSettings, Source, feeds }) {
|
||||
setUser({ user, userDefaultLibraryId, serverSettings, Source, ereaderDevices }) {
|
||||
this.$store.commit('setServerSettings', serverSettings)
|
||||
this.$store.commit('setSource', Source)
|
||||
this.$store.commit('libraries/setEReaderDevices', ereaderDevices)
|
||||
this.$setServerLanguageCode(serverSettings.language)
|
||||
|
||||
if (serverSettings.chromecastEnabled) {
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import Path from 'path'
|
||||
import uploadHelpers from '@/mixins/uploadHelpers'
|
||||
|
||||
export default {
|
||||
|
|
@ -243,7 +244,7 @@ export default {
|
|||
ref.setUploadStatus(status)
|
||||
}
|
||||
},
|
||||
uploadItem(item) {
|
||||
async uploadItem(item) {
|
||||
var form = new FormData()
|
||||
form.set('title', item.title)
|
||||
if (!this.selectedLibraryIsPodcast) {
|
||||
|
|
@ -294,18 +295,41 @@ export default {
|
|||
return
|
||||
}
|
||||
|
||||
var items = this.validateItems()
|
||||
const items = this.validateItems()
|
||||
if (!items) {
|
||||
this.$toast.error('Some invalid items')
|
||||
return
|
||||
}
|
||||
this.processing = true
|
||||
var itemsUploaded = 0
|
||||
var itemsFailed = 0
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
var item = items[i]
|
||||
|
||||
const itemsToUpload = []
|
||||
|
||||
// Check if path already exists before starting upload
|
||||
// uploading fails if path already exists
|
||||
for (const item of items) {
|
||||
const filepath = Path.join(this.selectedFolder.fullPath, item.directory)
|
||||
const exists = await this.$axios
|
||||
.$post(`/api/filesystem/pathexists`, { filepath })
|
||||
.then((data) => {
|
||||
if (data.exists) {
|
||||
this.$toast.error(`Filepath "${filepath}" already exists on server`)
|
||||
}
|
||||
return data.exists
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to check if filepath exists', error)
|
||||
return false
|
||||
})
|
||||
if (!exists) {
|
||||
itemsToUpload.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
let itemsUploaded = 0
|
||||
let itemsFailed = 0
|
||||
for (const item of itemsToUpload) {
|
||||
this.updateItemCardStatus(item.index, 'uploading')
|
||||
var result = await this.uploadItem(item)
|
||||
const result = await this.uploadItem(item)
|
||||
if (result) itemsUploaded++
|
||||
else itemsFailed++
|
||||
this.updateItemCardStatus(item.index, result ? 'success' : 'failed')
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue