Merge branch 'master' of github.com:advplyr/audiobookshelf

This commit is contained in:
Toni Barth 2025-05-18 19:39:07 +02:00
commit 6ea5348460
50 changed files with 1434 additions and 563 deletions

View file

@ -217,6 +217,16 @@ export default {
})
}
if (this.results.episodes?.length) {
shelves.push({
id: 'episodes',
label: 'Episodes',
labelStringKey: 'LabelEpisodes',
type: 'episode',
entities: this.results.episodes.map((res) => res.libraryItem)
})
}
if (this.results.series?.length) {
shelves.push({
id: 'series',

View file

@ -274,15 +274,10 @@ export default {
isAuthorsPage() {
return this.page === 'authors'
},
isAlbumsPage() {
return this.page === 'albums'
},
numShowing() {
return this.totalEntities
},
entityName() {
if (this.isAlbumsPage) return 'Albums'
if (this.isPodcastLibrary) return this.$strings.LabelPodcasts
if (!this.page) return this.$strings.LabelBooks
if (this.isSeriesPage) return this.$strings.LabelSeries

View file

@ -0,0 +1,60 @@
<template>
<div class="flex items-center h-full px-1 overflow-hidden">
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<div class="grow px-2 episodeSearchCardContent">
<p class="truncate text-sm">{{ episodeTitle }}</p>
<p class="text-xs text-gray-200 truncate">{{ podcastTitle }}</p>
</div>
</div>
</template>
<script>
export default {
props: {
libraryItem: {
type: Object,
default: () => {}
},
episode: {
type: Object,
default: () => {}
}
},
data() {
return {}
},
computed: {
bookCoverAspectRatio() {
return this.$store.getters['libraries/getBookCoverAspectRatio']
},
coverWidth() {
if (this.bookCoverAspectRatio === 1) return 50 * 1.2
return 50
},
media() {
return this.libraryItem?.media || {}
},
mediaMetadata() {
return this.media.metadata || {}
},
episodeTitle() {
return this.episode.title || 'No Title'
},
podcastTitle() {
return this.mediaMetadata.title || 'No Title'
}
},
methods: {},
mounted() {}
}
</script>
<style>
.episodeSearchCardContent {
width: calc(100% - 80px);
height: 75px;
display: flex;
flex-direction: column;
justify-content: center;
}
</style>

View file

@ -1,142 +0,0 @@
<template>
<div ref="card" :id="`album-card-${index}`" :style="{ width: cardWidth + 'px' }" class="absolute top-0 left-0 rounded-xs z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
<div class="relative" :style="{ height: coverHeight + 'px' }">
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
<div class="w-full h-full bg-primary relative rounded-sm overflow-hidden">
<covers-preview-cover ref="cover" :src="coverSrc" :width="cardWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div>
</div>
<div class="relative w-full">
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6e h-6e rounded-md text-center" :style="{ width: Math.min(200, cardWidth) + 'px' }">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-xs border" :style="{ padding: `0em ${0.5}em` }">
<p class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ title }}</p>
</div>
</div>
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8e h-8e py-1e rounded-md text-center">
<p class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ title }}</p>
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ artist || '&nbsp;' }}</p>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
index: Number,
width: Number,
height: {
type: Number,
default: 192
},
bookshelfView: {
type: Number,
default: 0
},
albumMount: {
type: Object,
default: () => null
}
},
data() {
return {
album: null,
isSelectionMode: false,
selected: false,
isHovering: false
}
},
computed: {
bookCoverAspectRatio() {
return this.store.getters['libraries/getBookCoverAspectRatio']
},
cardWidth() {
return this.width || this.coverHeight
},
coverHeight() {
return this.height * this.sizeMultiplier
},
/*
cardHeight() {
return this.coverHeight + this.bottomTextHeight
},
bottomTextHeight() {
if (!this.isAlternativeBookshelfView) return 0
const lineHeight = 1.5
const remSize = 16
const baseHeight = this.sizeMultiplier * lineHeight * remSize
const titleHeight = this.labelFontSize * baseHeight
const paddingHeight = 4 * 2 * this.sizeMultiplier // py-1
return titleHeight + paddingHeight
},
*/
coverSrc() {
const config = this.$config || this.$nuxt.$config
if (!this.album || !this.album.libraryItemId) return `${config.routerBasePath}/book_placeholder.jpg`
return this.store.getters['globals/getLibraryItemCoverSrcById'](this.album.libraryItemId)
},
labelFontSize() {
if (this.width < 160) return 0.75
return 0.9
},
sizeMultiplier() {
return this.store.getters['user/getSizeMultiplier']
},
title() {
return this.album ? this.album.title : ''
},
artist() {
return this.album ? this.album.artist : ''
},
store() {
return this.$store || this.$nuxt.$store
},
currentLibraryId() {
return this.store.state.libraries.currentLibraryId
},
isAlternativeBookshelfView() {
const constants = this.$constants || this.$nuxt.$constants
return this.bookshelfView == constants.BookshelfView.DETAIL
}
},
methods: {
setEntity(album) {
this.album = album
},
setSelectionMode(val) {
this.isSelectionMode = val
},
mouseover() {
this.isHovering = true
},
mouseleave() {
this.isHovering = false
},
clickCard() {
if (!this.album) return
// const router = this.$router || this.$nuxt.$router
// router.push(`/album/${this.$encode(this.title)}`)
},
clickEdit() {
this.$emit('edit', this.album)
},
destroy() {
// destroy the vue listeners, etc
this.$destroy()
// remove the element from the DOM
if (this.$el && this.$el.parentNode) {
this.$el.parentNode.removeChild(this.$el)
} else if (this.$el && this.$el.remove) {
this.$el.remove()
}
}
},
mounted() {
if (this.albumMount) {
this.setEntity(this.albumMount)
}
}
}
</script>

View file

@ -39,6 +39,15 @@
</li>
</template>
<p v-if="episodeResults.length" class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">{{ $strings.LabelEpisodes }}</p>
<template v-for="item in episodeResults">
<li :key="item.libraryItem.recentEpisode.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
<nuxt-link :to="`/item/${item.libraryItem.id}`">
<cards-episode-search-card :episode="item.libraryItem.recentEpisode" :library-item="item.libraryItem" />
</nuxt-link>
</li>
</template>
<p v-if="authorResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">{{ $strings.LabelAuthors }}</p>
<template v-for="item in authorResults">
<li :key="item.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
@ -100,6 +109,7 @@ export default {
isFetching: false,
search: null,
podcastResults: [],
episodeResults: [],
bookResults: [],
authorResults: [],
seriesResults: [],
@ -115,7 +125,7 @@ export default {
return this.$store.state.libraries.currentLibraryId
},
totalResults() {
return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length + this.genreResults.length + this.podcastResults.length + this.narratorResults.length
return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length + this.genreResults.length + this.podcastResults.length + this.narratorResults.length + this.episodeResults.length
}
},
methods: {
@ -132,6 +142,7 @@ export default {
this.search = null
this.lastSearch = null
this.podcastResults = []
this.episodeResults = []
this.bookResults = []
this.authorResults = []
this.seriesResults = []
@ -175,6 +186,7 @@ export default {
if (!this.isFetching) return
this.podcastResults = searchResults.podcast || []
this.episodeResults = searchResults.episodes || []
this.bookResults = searchResults.book || []
this.authorResults = searchResults.authors || []
this.seriesResults = searchResults.series || []

View file

@ -74,19 +74,12 @@ export default {
mediaTracks() {
return this.media.tracks || []
},
isSingleM4b() {
return this.mediaTracks.length === 1 && this.mediaTracks[0].metadata.ext.toLowerCase() === '.m4b'
},
chapters() {
return this.media.chapters || []
},
showM4bDownload() {
if (!this.mediaTracks.length) return false
return !this.isSingleM4b
},
showMp3Split() {
if (!this.mediaTracks.length) return false
return this.isSingleM4b && this.chapters.length
return true
},
queuedEmbedLIds() {
return this.$store.state.tasks.queuedEmbedLIds || []

View file

@ -1,4 +1,3 @@
<template>
<div id="lazy-episodes-table" class="w-full py-6">
<div class="flex flex-wrap flex-col md:flex-row md:items-center mb-4">
@ -176,6 +175,13 @@ export default {
return episodeProgress && !episodeProgress.isFinished
})
.sort((a, b) => {
// Swap values if sort descending
if (this.sortDesc) {
const temp = a
a = b
b = temp
}
let aValue
let bValue
@ -194,10 +200,23 @@ export default {
if (!bValue) bValue = Number.MAX_VALUE
}
if (this.sortDesc) {
return String(bValue).localeCompare(String(aValue), undefined, { numeric: true, sensitivity: 'base' })
const primaryCompare = String(aValue).localeCompare(String(bValue), undefined, { numeric: true, sensitivity: 'base' })
if (primaryCompare !== 0 || this.sortKey === 'publishedAt') return primaryCompare
// When sorting by season, secondary sort is by episode number
if (this.sortKey === 'season') {
const aEpisode = a.episode || ''
const bEpisode = b.episode || ''
const secondaryCompare = String(aEpisode).localeCompare(String(bEpisode), undefined, { numeric: true, sensitivity: 'base' })
if (secondaryCompare !== 0) return secondaryCompare
}
return String(aValue).localeCompare(String(bValue), undefined, { numeric: true, sensitivity: 'base' })
// Final sort by publishedAt
let aPubDate = a.publishedAt || Number.MAX_VALUE
let bPubDate = b.publishedAt || Number.MAX_VALUE
return String(aPubDate).localeCompare(String(bPubDate), undefined, { numeric: true, sensitivity: 'base' })
})
},
episodesList() {

View file

@ -1,6 +1,6 @@
<template>
<div class="inline-flex toggle-btn-wrapper shadow-md">
<button v-for="item in items" :key="item.value" type="button" class="toggle-btn outline-hidden relative border border-gray-600 px-4 py-1" :class="{ selected: item.value === value }" @click.stop="clickBtn(item.value)">
<button v-for="item in items" :key="item.value" type="button" :disabled="disabled" class="toggle-btn outline-hidden relative border border-gray-600 px-4 py-1" :class="{ selected: item.value === value }" @click.stop="clickBtn(item.value)">
{{ item.text }}
</button>
</div>
@ -9,13 +9,17 @@
<script>
export default {
props: {
value: String,
value: [String, Number],
/**
* [{ "text", "", "value": "" }]
*/
items: {
type: Array,
default: Object
},
disabled: {
type: Boolean,
default: false
}
},
data() {
@ -76,10 +80,19 @@ export default {
.toggle-btn.selected {
color: white;
}
.toggle-btn.selected:disabled {
color: white;
}
.toggle-btn.selected::before {
background-color: rgba(255, 255, 255, 0.1);
}
button.toggle-btn.selected:disabled::before {
background-color: rgba(255, 255, 255, 0.05);
}
button.toggle-btn:disabled::before {
background-color: rgba(0, 0, 0, 0.2);
}
button.toggle-btn:disabled {
cursor: not-allowed;
}
</style>

View file

@ -0,0 +1,211 @@
<template>
<div class="w-full py-2">
<div class="flex -mb-px">
<button type="button" :disabled="disabled" class="w-1/2 h-8 rounded-tl-md relative border border-black-200 flex items-center justify-center disabled:cursor-not-allowed" :class="!showAdvancedView ? 'text-white bg-bg hover:bg-bg/60 border-b-bg' : 'text-gray-400 hover:text-gray-300 bg-primary/70 hover:bg-primary/60'" @click="showAdvancedView = false">
<p class="text-sm">{{ $strings.HeaderPresets }}</p>
</button>
<button type="button" :disabled="disabled" class="w-1/2 h-8 rounded-tr-md relative border border-black-200 flex items-center justify-center -ml-px disabled:cursor-not-allowed" :class="showAdvancedView ? 'text-white bg-bg hover:bg-bg/60 border-b-bg' : 'text-gray-400 hover:text-gray-300 bg-primary/70 hover:bg-primary/60'" @click="showAdvancedView = true">
<p class="text-sm">{{ $strings.HeaderAdvanced }}</p>
</button>
</div>
<div class="p-4 md:p-8 border border-black-200 rounded-b-md mr-px bg-bg">
<template v-if="!showAdvancedView">
<div class="flex flex-wrap gap-4 sm:gap-8 justify-start sm:justify-center">
<div class="flex flex-col items-start gap-2">
<p class="text-sm w-40">{{ $strings.LabelCodec }}</p>
<ui-toggle-btns v-model="selectedCodec" :items="codecItems" :disabled="disabled" />
<p class="text-xs text-gray-300">
{{ $strings.LabelCurrently }} <span class="text-white">{{ currentCodec }}</span> <span v-if="isCodecsDifferent" class="text-warning">(mixed)</span>
</p>
</div>
<div class="flex flex-col items-start gap-2">
<p class="text-sm w-40">{{ $strings.LabelBitrate }}</p>
<ui-toggle-btns v-model="selectedBitrate" :items="bitrateItems" :disabled="disabled" />
<p class="text-xs text-gray-300">
{{ $strings.LabelCurrently }} <span class="text-white">{{ currentBitrate }} KB/s</span>
</p>
</div>
<div class="flex flex-col items-start gap-2">
<p class="text-sm w-40">{{ $strings.LabelChannels }}</p>
<ui-toggle-btns v-model="selectedChannels" :items="channelsItems" :disabled="disabled" />
<p class="text-xs text-gray-300">
{{ $strings.LabelCurrently }} <span class="text-white">{{ currentChannels }} ({{ currentChanelLayout }})</span>
</p>
</div>
</div>
</template>
<template v-else>
<div>
<div class="flex flex-wrap gap-4 sm:gap-8 justify-start sm:justify-center mb-4">
<div class="w-40">
<ui-text-input-with-label v-model="customCodec" :label="$strings.LabelAudioCodec" :disabled="disabled" @input="customCodecChanged" />
</div>
<div class="w-40">
<ui-text-input-with-label v-model="customBitrate" :label="$strings.LabelAudioBitrate" :disabled="disabled" @input="customBitrateChanged" />
</div>
<div class="w-40">
<ui-text-input-with-label v-model="customChannels" :label="$strings.LabelAudioChannels" type="number" :disabled="disabled" @input="customChannelsChanged" />
</div>
</div>
<p class="text-xs sm:text-sm text-warning sm:text-center">{{ $strings.LabelEncodingWarningAdvancedSettings }}</p>
</div>
</template>
</div>
</div>
</template>
<script>
export default {
props: {
audioTracks: {
type: Array,
default: () => []
},
disabled: {
type: Boolean,
default: false
}
},
data() {
return {
showAdvancedView: false,
selectedCodec: 'aac',
selectedBitrate: '128k',
selectedChannels: 2,
customCodec: 'aac',
customBitrate: '128k',
customChannels: 2,
currentCodec: '',
currentBitrate: '',
currentChannels: '',
currentChanelLayout: '',
isCodecsDifferent: false
}
},
computed: {
codecItems() {
return [
{
text: 'Copy',
value: 'copy'
},
{
text: 'AAC',
value: 'aac'
},
{
text: 'OPUS',
value: 'opus'
}
]
},
bitrateItems() {
return [
{
text: '32k',
value: '32k'
},
{
text: '64k',
value: '64k'
},
{
text: '128k',
value: '128k'
},
{
text: '192k',
value: '192k'
}
]
},
channelsItems() {
return [
{
text: '1 (mono)',
value: 1
},
{
text: '2 (stereo)',
value: 2
}
]
}
},
methods: {
customBitrateChanged(val) {
localStorage.setItem('embedMetadataBitrate', val)
},
customChannelsChanged(val) {
localStorage.setItem('embedMetadataChannels', val)
},
customCodecChanged(val) {
localStorage.setItem('embedMetadataCodec', val)
},
getEncodingOptions() {
return {
codec: this.selectedCodec || 'aac',
bitrate: this.selectedBitrate || '128k',
channels: this.selectedChannels || 2
}
},
setPreset() {
// If already AAC and not mixed, set copy
if (this.currentCodec === 'aac' && !this.isCodecsDifferent) {
this.selectedCodec = 'copy'
} else {
this.selectedCodec = 'aac'
}
if (!this.currentBitrate) {
this.selectedBitrate = '128k'
} else {
// Find closest bitrate rounding up
const bitratesToMatch = [32, 64, 128, 192]
const closestBitrate = bitratesToMatch.find((bitrate) => bitrate >= this.currentBitrate)
this.selectedBitrate = closestBitrate + 'k'
}
if (!this.currentChannels || isNaN(this.currentChannels)) {
this.selectedChannels = 2
} else {
// Either 1 or 2
this.selectedChannels = Math.max(Math.min(Number(this.currentChannels), 2), 1)
}
},
setCurrentValues() {
if (this.audioTracks.length === 0) return
this.currentChannels = this.audioTracks[0].channels
this.currentChanelLayout = this.audioTracks[0].channelLayout
this.currentCodec = this.audioTracks[0].codec
let totalBitrate = 0
for (const track of this.audioTracks) {
const trackBitrate = !isNaN(track.bitRate) ? track.bitRate : 0
totalBitrate += trackBitrate
if (track.channels > this.currentChannels) this.currentChannels = track.channels
if (track.codec !== this.currentCodec) {
console.warn('Audio track codec is different from the first track', track.codec)
this.isCodecsDifferent = true
}
}
this.currentBitrate = Math.round(totalBitrate / this.audioTracks.length / 1000)
},
init() {
this.customBitrate = localStorage.getItem('embedMetadataBitrate') || '128k'
this.customChannels = localStorage.getItem('embedMetadataChannels') || 2
this.customCodec = localStorage.getItem('embedMetadataCodec') || 'aac'
this.setCurrentValues()
this.setPreset()
}
},
mounted() {
this.init()
}
}
</script>