Merge branch 'master' into feat/chapterLookUp

This commit is contained in:
advplyr 2025-05-10 16:22:30 -05:00
commit 26c976b6b9
30 changed files with 451 additions and 263 deletions

1
.gitignore vendored
View File

@ -23,3 +23,4 @@ sw.*
.DS_STORE .DS_STORE
.idea/* .idea/*
tailwind.compiled.css tailwind.compiled.css
tailwind.config.js

View File

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

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

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

View File

@ -74,6 +74,9 @@ export default {
currentChapterStart() { currentChapterStart() {
if (!this.currentChapter) return 0 if (!this.currentChapter) return 0
return this.currentChapter.start return this.currentChapter.start
},
isMobile() {
return this.$store.state.globals.isMobile
} }
}, },
methods: { methods: {
@ -145,6 +148,9 @@ export default {
}) })
}, },
mousemoveTrack(e) { mousemoveTrack(e) {
if (this.isMobile) {
return
}
const offsetX = e.offsetX const offsetX = e.offsetX
const baseTime = this.useChapterTrack ? this.currentChapterStart : 0 const baseTime = this.useChapterTrack ? this.currentChapterStart : 0
@ -198,6 +204,7 @@ export default {
setTrackWidth() { setTrackWidth() {
if (this.$refs.track) { if (this.$refs.track) {
this.trackWidth = this.$refs.track.clientWidth this.trackWidth = this.$refs.track.clientWidth
this.trackOffsetLeft = this.$refs.track.getBoundingClientRect().left
} else { } else {
console.error('Track not loaded', this.$refs) console.error('Track not loaded', this.$refs)
} }

View File

@ -164,14 +164,15 @@ export default {
beforeMount() { beforeMount() {
this.yearInReviewYear = new Date().getFullYear() this.yearInReviewYear = new Date().getFullYear()
// When not December show previous year this.availableYears = this.getAvailableYears()
if (new Date().getMonth() < 11) { const availableYearValues = this.availableYears.map((y) => y.value)
// When not December show previous year if data is available
if (new Date().getMonth() < 11 && availableYearValues.includes(this.yearInReviewYear - 1)) {
this.yearInReviewYear-- this.yearInReviewYear--
} }
}, },
mounted() { mounted() {
this.availableYears = this.getAvailableYears()
if (typeof navigator.share !== 'undefined' && navigator.share) { if (typeof navigator.share !== 'undefined' && navigator.share) {
this.showShareButton = true this.showShareButton = true
} else { } else {

View File

@ -1,4 +1,3 @@
<template> <template>
<div id="lazy-episodes-table" class="w-full py-6"> <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"> <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 return episodeProgress && !episodeProgress.isFinished
}) })
.sort((a, b) => { .sort((a, b) => {
// Swap values if sort descending
if (this.sortDesc) {
const temp = a
a = b
b = temp
}
let aValue let aValue
let bValue let bValue
@ -194,10 +200,23 @@ export default {
if (!bValue) bValue = Number.MAX_VALUE if (!bValue) bValue = Number.MAX_VALUE
} }
if (this.sortDesc) { const primaryCompare = String(aValue).localeCompare(String(bValue), undefined, { numeric: true, sensitivity: 'base' })
return String(bValue).localeCompare(String(aValue), 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() { episodesList() {

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="inline-flex toggle-btn-wrapper shadow-md"> <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 }} {{ item.text }}
</button> </button>
</div> </div>
@ -9,13 +9,17 @@
<script> <script>
export default { export default {
props: { props: {
value: String, value: [String, Number],
/** /**
* [{ "text", "", "value": "" }] * [{ "text", "", "value": "" }]
*/ */
items: { items: {
type: Array, type: Array,
default: Object default: Object
},
disabled: {
type: Boolean,
default: false
} }
}, },
data() { data() {
@ -76,10 +80,19 @@ export default {
.toggle-btn.selected { .toggle-btn.selected {
color: white; color: white;
} }
.toggle-btn.selected:disabled {
color: white;
}
.toggle-btn.selected::before { .toggle-btn.selected::before {
background-color: rgba(255, 255, 255, 0.1); 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 { button.toggle-btn:disabled::before {
background-color: rgba(0, 0, 0, 0.2); background-color: rgba(0, 0, 0, 0.2);
} }
button.toggle-btn:disabled {
cursor: not-allowed;
}
</style> </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>

View File

@ -3,7 +3,6 @@ import LazyBookCard from '@/components/cards/LazyBookCard'
import LazySeriesCard from '@/components/cards/LazySeriesCard' import LazySeriesCard from '@/components/cards/LazySeriesCard'
import LazyCollectionCard from '@/components/cards/LazyCollectionCard' import LazyCollectionCard from '@/components/cards/LazyCollectionCard'
import LazyPlaylistCard from '@/components/cards/LazyPlaylistCard' import LazyPlaylistCard from '@/components/cards/LazyPlaylistCard'
import LazyAlbumCard from '@/components/cards/LazyAlbumCard'
import AuthorCard from '@/components/cards/AuthorCard' import AuthorCard from '@/components/cards/AuthorCard'
export default { export default {
@ -20,7 +19,6 @@ export default {
if (this.entityName === 'series') return Vue.extend(LazySeriesCard) if (this.entityName === 'series') return Vue.extend(LazySeriesCard)
if (this.entityName === 'collections') return Vue.extend(LazyCollectionCard) if (this.entityName === 'collections') return Vue.extend(LazyCollectionCard)
if (this.entityName === 'playlists') return Vue.extend(LazyPlaylistCard) if (this.entityName === 'playlists') return Vue.extend(LazyPlaylistCard)
if (this.entityName === 'albums') return Vue.extend(LazyAlbumCard)
if (this.entityName === 'authors') return Vue.extend(AuthorCard) if (this.entityName === 'authors') return Vue.extend(AuthorCard)
return Vue.extend(LazyBookCard) return Vue.extend(LazyBookCard)
}, },
@ -28,7 +26,6 @@ export default {
if (this.entityName === 'series') return 'cards-lazy-series-card' if (this.entityName === 'series') return 'cards-lazy-series-card'
if (this.entityName === 'collections') return 'cards-lazy-collection-card' if (this.entityName === 'collections') return 'cards-lazy-collection-card'
if (this.entityName === 'playlists') return 'cards-lazy-playlist-card' if (this.entityName === 'playlists') return 'cards-lazy-playlist-card'
if (this.entityName === 'albums') return 'cards-lazy-album-card'
if (this.entityName === 'authors') return 'cards-author-card' if (this.entityName === 'authors') return 'cards-author-card'
return 'cards-lazy-book-card' return 'cards-lazy-book-card'
}, },

View File

@ -1,6 +1,6 @@
<template> <template>
<div id="page-wrapper" class="bg-bg page overflow-y-auto relative" :class="streamLibraryItem ? 'streaming' : ''"> <div id="page-wrapper" class="bg-bg page overflow-y-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
<div class="flex items-center py-4 px-2 md:px-0 max-w-7xl mx-auto"> <div class="flex items-center py-4 px-4 max-w-7xl mx-auto">
<nuxt-link :to="`/item/${libraryItem.id}`" class="hover:underline"> <nuxt-link :to="`/item/${libraryItem.id}`" class="hover:underline">
<h1 class="text-lg lg:text-xl">{{ title }}</h1> <h1 class="text-lg lg:text-xl">{{ title }}</h1>
</nuxt-link> </nuxt-link>
@ -12,7 +12,7 @@
<p class="text-base font-mono ml-4 hidden md:block">{{ $secondsToTimestamp(mediaDurationRounded) }}</p> <p class="text-base font-mono ml-4 hidden md:block">{{ $secondsToTimestamp(mediaDurationRounded) }}</p>
</div> </div>
<div class="flex flex-wrap-reverse justify-center py-4 px-2"> <div class="flex flex-wrap-reverse lg:flex-nowrap justify-center py-4 px-4">
<div class="w-full max-w-3xl py-4"> <div class="w-full max-w-3xl py-4">
<div class="flex items-center"> <div class="flex items-center">
<div class="w-12 hidden lg:block" /> <div class="w-12 hidden lg:block" />
@ -23,8 +23,8 @@
</div> </div>
<div class="flex items-center mb-3 py-1 -mx-1"> <div class="flex items-center mb-3 py-1 -mx-1">
<div class="w-12 hidden lg:block" /> <div class="w-12 hidden lg:block" />
<ui-btn v-if="chapters.length" color="bg-primary" small class="mx-1" @click.stop="removeAllChaptersClick">{{ $strings.ButtonRemoveAll }}</ui-btn> <ui-btn v-if="chapters.length" color="bg-primary" small class="mx-1 whitespace-nowrap" @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 v-if="newChapters.length > 1" :color="showShiftTimes ? 'bg-bg' : 'bg-primary'" class="mx-1 whitespace-nowrap" small @click="showShiftTimes = !showShiftTimes">{{ $strings.ButtonShiftTimes }}</ui-btn>
<ui-btn color="bg-primary" small :class="{ 'mx-1': newChapters.length > 1 }" @click="showFindChaptersModal = true">{{ $strings.ButtonLookup }}</ui-btn> <ui-btn color="bg-primary" small :class="{ 'mx-1': newChapters.length > 1 }" @click="showFindChaptersModal = true">{{ $strings.ButtonLookup }}</ui-btn>
<div class="grow" /> <div class="grow" />
<ui-btn v-if="hasChanges" small class="mx-1" @click.stop="resetChapters">{{ $strings.ButtonReset }}</ui-btn> <ui-btn v-if="hasChanges" small class="mx-1" @click.stop="resetChapters">{{ $strings.ButtonReset }}</ui-btn>
@ -65,7 +65,7 @@
<ui-time-picker v-else class="text-xs" v-model="chapter.start" :show-three-digit-hour="mediaDuration >= 360000" @change="checkChapters" /> <ui-time-picker v-else class="text-xs" v-model="chapter.start" :show-three-digit-hour="mediaDuration >= 360000" @change="checkChapters" />
</div> </div>
<div class="grow px-1"> <div class="grow px-1">
<ui-text-input v-model="chapter.title" @change="checkChapters" class="text-xs" /> <ui-text-input v-model="chapter.title" @change="checkChapters" class="text-xs min-w-52" />
</div> </div>
<div class="w-32 min-w-32 px-2 py-1"> <div class="w-32 min-w-32 px-2 py-1">
<div class="flex items-center"> <div class="flex items-center">

View File

@ -2,7 +2,14 @@
<div id="page-wrapper" class="bg-bg page p-8 overflow-auto relative" :class="streamLibraryItem ? 'streaming' : ''"> <div id="page-wrapper" class="bg-bg page p-8 overflow-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
<div class="flex items-center justify-center mb-6"> <div class="flex items-center justify-center mb-6">
<div class="w-full max-w-2xl"> <div class="w-full max-w-2xl">
<p class="text-2xl mb-2">{{ $strings.HeaderAudiobookTools }}</p> <div class="flex items-center mb-4">
<nuxt-link :to="`/item/${libraryItem.id}`" class="hover:underline">
<h1 class="text-lg lg:text-xl">{{ mediaMetadata.title }}</h1>
</nuxt-link>
<button class="w-7 h-7 flex items-center justify-center mx-4 hover:scale-110 duration-100 transform text-gray-200 hover:text-white" @click="editItem">
<span class="material-symbols text-base">edit</span>
</button>
</div>
</div> </div>
<div class="w-full max-w-2xl"> <div class="w-full max-w-2xl">
<div class="flex justify-end"> <div class="flex justify-end">
@ -13,13 +20,13 @@
<div class="flex justify-center mb-2"> <div class="flex justify-center mb-2">
<div class="w-full max-w-2xl"> <div class="w-full max-w-2xl">
<p class="text-xl">{{ $strings.HeaderMetadataToEmbed }}</p> <p class="text-lg">{{ $strings.HeaderMetadataToEmbed }}</p>
</div> </div>
<div class="w-full max-w-2xl"></div> <div class="w-full max-w-2xl"></div>
</div> </div>
<div class="flex justify-center flex-wrap"> <div class="flex justify-center flex-wrap lg:flex-nowrap gap-4">
<div class="w-full max-w-2xl border border-white/10 bg-bg mx-2"> <div class="w-full max-w-2xl border border-white/10 bg-bg">
<div class="flex py-2 px-4"> <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-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 class="w-2/3 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelValue }}</div>
@ -35,7 +42,7 @@
</template> </template>
</div> </div>
</div> </div>
<div class="w-full max-w-2xl border border-white/10 bg-bg mx-2"> <div class="w-full max-w-2xl border border-white/10 bg-bg">
<div class="flex py-2 px-4 bg-primary/25"> <div class="flex py-2 px-4 bg-primary/25">
<div class="grow text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelChapterTitle }}</div> <div class="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.LabelStart }}</div>
@ -77,10 +84,6 @@
</div> </div>
<!-- m4b embed action buttons --> <!-- m4b embed action buttons -->
<div v-else class="w-full flex items-center mb-4"> <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-symbols text-xl">{{ showEncodeOptions || usingCustomEncodeOptions ? 'check_box' : 'check_box_outline_blank' }}</span> <span class="pl-1">{{ $strings.LabelUseAdvancedOptions }}</span>
</button>
<div class="grow" /> <div class="grow" />
<ui-btn v-if="!isTaskFinished && processing" color="bg-error" :loading="isCancelingEncode" class="mr-2" @click.stop="cancelEncodeClick">{{ $strings.ButtonCancelEncode }}</ui-btn> <ui-btn v-if="!isTaskFinished && processing" color="bg-error" :loading="isCancelingEncode" class="mr-2" @click.stop="cancelEncodeClick">{{ $strings.ButtonCancelEncode }}</ui-btn>
@ -89,18 +92,16 @@
<p v-else class="text-success text-lg font-semibold">{{ $strings.MessageM4BFinished }}</p> <p v-else class="text-success text-lg font-semibold">{{ $strings.MessageM4BFinished }}</p>
</div> </div>
<!-- advanced encoding options --> <!-- show encoding options for running task -->
<div v-if="isM4BTool" class="overflow-hidden"> <div v-if="encodeTaskHasEncodingOptions" class="mb-4 pb-4 border-b border-white/10">
<transition name="slide"> <div class="flex flex-wrap -mx-2">
<div v-if="showEncodeOptions || usingCustomEncodeOptions" class="mb-4 pb-4 border-b border-white/10"> <ui-text-input-with-label ref="bitrateInput" v-model="encodingOptions.bitrate" readonly :label="$strings.LabelAudioBitrate" class="m-2 max-w-40" @input="bitrateChanged" />
<div class="flex flex-wrap -mx-2"> <ui-text-input-with-label ref="channelsInput" v-model="encodingOptions.channels" readonly :label="$strings.LabelAudioChannels" class="m-2 max-w-40" @input="channelsChanged" />
<ui-text-input-with-label ref="bitrateInput" v-model="encodingOptions.bitrate" :disabled="processing || isTaskFinished" :label="$strings.LabelAudioBitrate" class="m-2 max-w-40" @input="bitrateChanged" /> <ui-text-input-with-label ref="codecInput" v-model="encodingOptions.codec" readonly :label="$strings.LabelAudioCodec" class="m-2 max-w-40" @input="codecChanged" />
<ui-text-input-with-label ref="channelsInput" v-model="encodingOptions.channels" :disabled="processing || isTaskFinished" :label="$strings.LabelAudioChannels" class="m-2 max-w-40" @input="channelsChanged" /> </div>
<ui-text-input-with-label ref="codecInput" v-model="encodingOptions.codec" :disabled="processing || isTaskFinished" :label="$strings.LabelAudioCodec" class="m-2 max-w-40" @input="codecChanged" /> </div>
</div> <div v-else-if="isM4BTool" class="mb-4">
<p class="text-sm text-warning">{{ $strings.LabelEncodingWarningAdvancedSettings }}</p> <widgets-encoder-options-card ref="encoderOptionsCard" :audio-tracks="audioFiles" :disabled="processing || isTaskFinished" />
</div>
</transition>
</div> </div>
<div class="mb-4"> <div class="mb-4">
@ -146,19 +147,29 @@
<div class="flex py-2 px-4 bg-primary/25"> <div class="flex py-2 px-4 bg-primary/25">
<div class="w-10 text-xs font-semibold text-gray-200">#</div> <div class="w-10 text-xs font-semibold text-gray-200">#</div>
<div class="grow text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelFilename }}</div> <div class="grow text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelFilename }}</div>
<div class="w-20 text-xs font-semibold uppercase text-gray-200 hidden lg:block">{{ $strings.LabelChannels }}</div>
<div class="w-16 text-xs font-semibold uppercase text-gray-200 hidden md:block">{{ $strings.LabelCodec }}</div>
<div class="w-16 text-xs font-semibold uppercase text-gray-200 hidden md:block">{{ $strings.LabelBitrate }}</div>
<div class="w-16 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelSize }}</div> <div class="w-16 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelSize }}</div>
<div class="w-24"></div> <div class="w-24"></div>
</div> </div>
<template v-for="file in audioFiles"> <template v-for="file in audioFiles">
<div :key="file.index" class="flex py-2 px-4 text-sm" :class="file.index % 2 === 0 ? 'bg-primary/25' : ''"> <div :key="file.index" class="flex py-2 px-4 text-xs sm:text-sm" :class="file.index % 2 === 0 ? 'bg-primary/25' : ''">
<div class="w-10">{{ file.index }}</div> <div class="w-10 min-w-10">{{ file.index }}</div>
<div class="grow"> <div class="grow">
{{ file.metadata.filename }} {{ file.metadata.filename }}
</div> </div>
<div class="w-16 font-mono text-gray-200"> <div class="w-20 min-w-20 text-gray-200 hidden lg:block">{{ file.channels || 'unknown' }} ({{ file.channelLayout || 'unknown' }})</div>
<div class="w-16 min-w-16 text-gray-200 hidden md:block">
{{ file.codec || 'unknown' }}
</div>
<div class="w-16 min-w-16 text-gray-200 hidden md:block">
{{ $bytesPretty(file.bitRate || 0, 0) }}
</div>
<div class="w-16 min-w-16 text-gray-200">
{{ $bytesPretty(file.metadata.size) }} {{ $bytesPretty(file.metadata.size) }}
</div> </div>
<div class="w-24"> <div class="w-24 min-w-24">
<div class="flex justify-center"> <div class="flex justify-center">
<span v-if="audioFilesFinished[file.ino]" class="material-symbols text-xl text-success leading-none">check_circle</span> <span v-if="audioFilesFinished[file.ino]" class="material-symbols text-xl text-success leading-none">check_circle</span>
<div v-else-if="audioFilesEncoding[file.ino]"> <div v-else-if="audioFilesEncoding[file.ino]">
@ -214,7 +225,6 @@ export default {
metadataObject: null, metadataObject: null,
selectedTool: 'embed', selectedTool: 'embed',
isCancelingEncode: false, isCancelingEncode: false,
showEncodeOptions: false,
shouldBackupAudioFiles: true, shouldBackupAudioFiles: true,
encodingOptions: { encodingOptions: {
bitrate: '128k', bitrate: '128k',
@ -263,9 +273,6 @@ export default {
audioFiles() { audioFiles() {
return (this.media.audioFiles || []).filter((af) => !af.exclude) return (this.media.audioFiles || []).filter((af) => !af.exclude)
}, },
isSingleM4b() {
return this.audioFiles.length === 1 && this.audioFiles[0].metadata.ext.toLowerCase() === '.m4b'
},
streamLibraryItem() { streamLibraryItem() {
return this.$store.state.streamLibraryItem return this.$store.state.streamLibraryItem
}, },
@ -273,14 +280,10 @@ export default {
return this.media.chapters || [] return this.media.chapters || []
}, },
availableTools() { availableTools() {
if (this.isSingleM4b) { return [
return [{ value: 'embed', text: this.$strings.LabelToolsEmbedMetadata }] { value: 'embed', text: this.$strings.LabelToolsEmbedMetadata },
} else { { value: 'm4b', text: this.$strings.LabelToolsM4bEncoder }
return [ ]
{ value: 'embed', text: this.$strings.LabelToolsEmbedMetadata },
{ value: 'm4b', text: this.$strings.LabelToolsM4bEncoder }
]
}
}, },
taskFailed() { taskFailed() {
return this.isTaskFinished && this.task.isFailed return this.isTaskFinished && this.task.isFailed
@ -314,8 +317,8 @@ export default {
isMetadataEmbedQueued() { isMetadataEmbedQueued() {
return this.queuedEmbedLIds.some((lid) => lid === this.libraryItemId) return this.queuedEmbedLIds.some((lid) => lid === this.libraryItemId)
}, },
usingCustomEncodeOptions() { encodeTaskHasEncodingOptions() {
return this.isM4BTool && this.encodeTask && this.encodeTask.data.encodeOptions && Object.keys(this.encodeTask.data.encodeOptions).length > 0 return this.isM4BTool && !!this.encodeTask?.data.encodeOptions && Object.keys(this.encodeTask.data.encodeOptions).length > 0
} }
}, },
methods: { methods: {
@ -351,19 +354,13 @@ export default {
if (this.$refs.channelsInput) this.$refs.channelsInput.blur() if (this.$refs.channelsInput) this.$refs.channelsInput.blur()
if (this.$refs.codecInput) this.$refs.codecInput.blur() if (this.$refs.codecInput) this.$refs.codecInput.blur()
let queryStr = '' const encodeOptions = this.$refs.encoderOptionsCard.getEncodingOptions()
if (this.showEncodeOptions) {
const options = [] const queryParams = new URLSearchParams(encodeOptions)
if (this.encodingOptions.bitrate) options.push(`bitrate=${this.encodingOptions.bitrate}`)
if (this.encodingOptions.channels) options.push(`channels=${this.encodingOptions.channels}`)
if (this.encodingOptions.codec) options.push(`codec=${this.encodingOptions.codec}`)
if (options.length) {
queryStr = `?${options.join('&')}`
}
}
this.processing = true this.processing = true
this.$axios this.$axios
.$post(`/api/tools/item/${this.libraryItemId}/encode-m4b${queryStr}`) .$post(`/api/tools/item/${this.libraryItemId}/encode-m4b?${queryParams.toString()}`)
.then(() => { .then(() => {
console.log('Ab m4b merge started') console.log('Ab m4b merge started')
}) })
@ -416,14 +413,10 @@ export default {
const shouldBackupAudioFiles = localStorage.getItem('embedMetadataShouldBackup') const shouldBackupAudioFiles = localStorage.getItem('embedMetadataShouldBackup')
this.shouldBackupAudioFiles = shouldBackupAudioFiles != 0 this.shouldBackupAudioFiles = shouldBackupAudioFiles != 0
if (this.usingCustomEncodeOptions) { if (this.encodeTaskHasEncodingOptions) {
if (this.encodeTask.data.encodeOptions.bitrate) this.encodingOptions.bitrate = this.encodeTask.data.encodeOptions.bitrate if (this.encodeTask.data.encodeOptions.bitrate) this.encodingOptions.bitrate = this.encodeTask.data.encodeOptions.bitrate
if (this.encodeTask.data.encodeOptions.channels) this.encodingOptions.channels = this.encodeTask.data.encodeOptions.channels if (this.encodeTask.data.encodeOptions.channels) this.encodingOptions.channels = this.encodeTask.data.encodeOptions.channels
if (this.encodeTask.data.encodeOptions.codec) this.encodingOptions.codec = this.encodeTask.data.encodeOptions.codec if (this.encodeTask.data.encodeOptions.codec) this.encodingOptions.codec = this.encodeTask.data.encodeOptions.codec
} else {
this.encodingOptions.bitrate = localStorage.getItem('embedMetadataBitrate') || '128k'
this.encodingOptions.channels = localStorage.getItem('embedMetadataChannels') || '2'
this.encodingOptions.codec = localStorage.getItem('embedMetadataCodec') || 'aac'
} }
}, },
fetchMetadataEmbedObject() { fetchMetadataEmbedObject() {
@ -438,10 +431,24 @@ export default {
}, },
taskUpdated(task) { taskUpdated(task) {
this.processing = !task.isFinished this.processing = !task.isFinished
},
editItem() {
this.$store.commit('showEditModal', this.libraryItem)
},
libraryItemUpdated(libraryItem) {
if (libraryItem.id === this.libraryItem.id) {
this.libraryItem = libraryItem
this.fetchMetadataEmbedObject()
}
} }
}, },
mounted() { mounted() {
this.init() this.init()
this.$eventBus.$on(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)
},
beforeDestroy() {
this.$eventBus.$off(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)
} }
} }
</script> </script>

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="w-full h-dvh max-h-dvh overflow-hidden" :style="{ backgroundColor: coverRgb }"> <div class="w-full max-w-full h-dvh max-h-dvh overflow-hidden" :style="{ backgroundColor: coverRgb }">
<div class="w-screen h-screen absolute inset-0 pointer-events-none" style="background: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(38, 38, 38, 1) 80%)"></div> <div class="w-screen h-screen absolute inset-0 pointer-events-none" style="background: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(38, 38, 38, 1) 80%)"></div>
<div class="absolute inset-0 w-screen h-dvh flex items-center justify-center z-10"> <div class="absolute inset-0 w-screen h-dvh flex items-center justify-center z-10">
<div class="w-full p-2 sm:p-4 md:p-8"> <div class="w-full p-2 sm:p-4 md:p-8">
@ -335,8 +335,11 @@ export default {
} }
}, },
resize() { resize() {
this.windowWidth = window.innerWidth setTimeout(() => {
this.windowHeight = window.innerHeight this.windowWidth = window.innerWidth
this.windowHeight = window.innerHeight
this.$store.commit('globals/updateWindowSize', { width: window.innerWidth, height: window.innerHeight })
}, 100)
}, },
playerError(error) { playerError(error) {
console.error('Player error', error) console.error('Player error', error)

View File

@ -1,5 +1,5 @@
export default class AudioTrack { export default class AudioTrack {
constructor(track, userToken, routerBasePath) { constructor(track, sessionId, routerBasePath) {
this.index = track.index || 0 this.index = track.index || 0
this.startOffset = track.startOffset || 0 // Total time of all previous tracks this.startOffset = track.startOffset || 0 // Total time of all previous tracks
this.duration = track.duration || 0 this.duration = track.duration || 0
@ -8,28 +8,29 @@ export default class AudioTrack {
this.mimeType = track.mimeType this.mimeType = track.mimeType
this.metadata = track.metadata || {} this.metadata = track.metadata || {}
this.userToken = userToken this.sessionId = sessionId
this.routerBasePath = routerBasePath || '' this.routerBasePath = routerBasePath || ''
if (this.contentUrl?.startsWith('/hls')) {
this.sessionTrackUrl = this.contentUrl
} else {
this.sessionTrackUrl = `/public/session/${sessionId}/track/${this.index}`
}
} }
/** /**
* Used for CastPlayer * Used for CastPlayer
*/ */
get fullContentUrl() { get fullContentUrl() {
if (!this.contentUrl || this.contentUrl.startsWith('http')) return this.contentUrl
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
return `${process.env.serverUrl}${this.contentUrl}?token=${this.userToken}` return `${process.env.serverUrl}${this.sessionTrackUrl}`
} }
return `${window.location.origin}${this.routerBasePath}${this.contentUrl}?token=${this.userToken}` return `${window.location.origin}${this.routerBasePath}${this.sessionTrackUrl}`
} }
/** /**
* Used for LocalPlayer * Used for LocalPlayer
*/ */
get relativeContentUrl() { get relativeContentUrl() {
if (!this.contentUrl || this.contentUrl.startsWith('http')) return this.contentUrl return `${this.routerBasePath}${this.sessionTrackUrl}`
return `${this.routerBasePath}${this.contentUrl}?token=${this.userToken}`
} }
} }

View File

@ -37,9 +37,6 @@ export default class PlayerHandler {
get isPlayingLocalItem() { get isPlayingLocalItem() {
return this.libraryItem && this.player instanceof LocalAudioPlayer return this.libraryItem && this.player instanceof LocalAudioPlayer
} }
get userToken() {
return this.ctx.$store.getters['user/getToken']
}
get playerPlaying() { get playerPlaying() {
return this.playerState === 'PLAYING' return this.playerState === 'PLAYING'
} }
@ -226,7 +223,7 @@ export default class PlayerHandler {
console.log('[PlayerHandler] Preparing Session', session) console.log('[PlayerHandler] Preparing Session', session)
var audioTracks = session.audioTracks.map((at) => new AudioTrack(at, this.userToken, this.ctx.$config.routerBasePath)) var audioTracks = session.audioTracks.map((at) => new AudioTrack(at, session.id, this.ctx.$config.routerBasePath))
this.ctx.playerLoading = true this.ctx.playerLoading = true
this.isHlsTranscode = true this.isHlsTranscode = true

View File

@ -177,6 +177,7 @@
"HeaderPlaylist": "Playlist", "HeaderPlaylist": "Playlist",
"HeaderPlaylistItems": "Playlist Items", "HeaderPlaylistItems": "Playlist Items",
"HeaderPodcastsToAdd": "Podcasts to Add", "HeaderPodcastsToAdd": "Podcasts to Add",
"HeaderPresets": "Presets",
"HeaderPreviewCover": "Preview Cover", "HeaderPreviewCover": "Preview Cover",
"HeaderRSSFeedGeneral": "RSS Details", "HeaderRSSFeedGeneral": "RSS Details",
"HeaderRSSFeedIsOpen": "RSS Feed is Open", "HeaderRSSFeedIsOpen": "RSS Feed is Open",

View File

@ -313,7 +313,7 @@ class Server {
router.use(express.json({ limit: '5mb' })) router.use(express.json({ limit: '5mb' }))
router.use('/api', this.auth.ifAuthNeeded(this.authMiddleware.bind(this)), this.apiRouter.router) router.use('/api', this.auth.ifAuthNeeded(this.authMiddleware.bind(this)), this.apiRouter.router)
router.use('/hls', this.authMiddleware.bind(this), this.hlsRouter.router) router.use('/hls', this.hlsRouter.router)
router.use('/public', this.publicRouter.router) router.use('/public', this.publicRouter.router)
// Static path to generated nuxt // Static path to generated nuxt

View File

@ -834,8 +834,8 @@ class LibraryItemController {
} }
if (req.libraryItem.isMissing || !req.libraryItem.isBook || !req.libraryItem.media.includedAudioFiles.length) { if (req.libraryItem.isMissing || !req.libraryItem.isBook || !req.libraryItem.media.includedAudioFiles.length) {
Logger.error(`[LibraryItemController] Invalid library item`) Logger.error(`[LibraryItemController] getMetadataObject: Invalid library item "${req.libraryItem.media.title}"`)
return res.sendStatus(500) return res.sendStatus(400)
} }
res.json(this.audioMetadataManager.getMetadataObjectForApi(req.libraryItem)) res.json(this.audioMetadataManager.getMetadataObjectForApi(req.libraryItem))

View File

@ -222,7 +222,7 @@ class MiscController {
// Update nameIgnorePrefix column on series // Update nameIgnorePrefix column on series
const allSeries = await Database.seriesModel.findAll({ const allSeries = await Database.seriesModel.findAll({
attributes: ['id', 'name', 'nameIgnorePrefix'] attributes: ['id', 'name', 'nameIgnorePrefix', 'libraryId']
}) })
const bulkUpdateSeries = [] const bulkUpdateSeries = []
allSeries.forEach((series) => { allSeries.forEach((series) => {
@ -230,6 +230,8 @@ class MiscController {
if (nameIgnorePrefix !== series.nameIgnorePrefix) { if (nameIgnorePrefix !== series.nameIgnorePrefix) {
bulkUpdateSeries.push({ bulkUpdateSeries.push({
id: series.id, id: series.id,
name: series.name,
libraryId: series.libraryId,
nameIgnorePrefix nameIgnorePrefix
}) })
} }

View File

@ -1,7 +1,9 @@
const Path = require('path')
const { Request, Response, NextFunction } = require('express') const { Request, Response, NextFunction } = require('express')
const Logger = require('../Logger') const Logger = require('../Logger')
const Database = require('../Database') const Database = require('../Database')
const { toNumber, isUUID } = require('../utils/index') const { toNumber, isUUID } = require('../utils/index')
const { getAudioMimeTypeFromExtname, encodeUriPath } = require('../utils/fileUtils')
const ShareManager = require('../managers/ShareManager') const ShareManager = require('../managers/ShareManager')
@ -266,6 +268,51 @@ class SessionController {
this.playbackSessionManager.syncLocalSessionsRequest(req, res) this.playbackSessionManager.syncLocalSessionsRequest(req, res)
} }
/**
* GET: /public/session/:id/track/:index
* While a session is open, this endpoint can be used to stream the audio track
*
* @this {import('../routers/PublicRouter')}
*
* @param {Request} req
* @param {Response} res
*/
async getTrack(req, res) {
const audioTrackIndex = toNumber(req.params.index, null)
if (audioTrackIndex === null) {
Logger.error(`[SessionController] Invalid audio track index "${req.params.index}"`)
return res.sendStatus(400)
}
const playbackSession = this.playbackSessionManager.getSession(req.params.id)
if (!playbackSession) {
Logger.error(`[SessionController] Unable to find playback session with id=${req.params.id}`)
return res.sendStatus(404)
}
const audioTrack = playbackSession.audioTracks.find((t) => t.index === audioTrackIndex)
if (!audioTrack) {
Logger.error(`[SessionController] Unable to find audio track with index=${audioTrackIndex}`)
return res.sendStatus(404)
}
const user = await Database.userModel.getUserById(playbackSession.userId)
Logger.debug(`[SessionController] Serving audio track ${audioTrack.index} for session "${req.params.id}" belonging to user "${user.username}"`)
if (global.XAccel) {
const encodedURI = encodeUriPath(global.XAccel + audioTrack.metadata.path)
Logger.debug(`Use X-Accel to serve static file ${encodedURI}`)
return res.status(204).header({ 'X-Accel-Redirect': encodedURI }).send()
}
// Express does not set the correct mimetype for m4b files so use our defined mimetypes if available
const audioMimeType = getAudioMimeTypeFromExtname(Path.extname(audioTrack.metadata.path))
if (audioMimeType) {
res.setHeader('Content-Type', audioMimeType)
}
res.sendFile(audioTrack.metadata.path)
}
/** /**
* *
* @param {RequestWithUser} req * @param {RequestWithUser} req

View File

@ -48,6 +48,7 @@ class ToolsController {
} }
const options = req.query || {} const options = req.query || {}
Logger.info(`[ToolsController] encodeM4b: Starting audiobook merge for "${req.libraryItem.media.title}" with options: ${JSON.stringify(options)}`)
this.abMergeManager.startAudiobookMerge(req.user.id, req.libraryItem, options) this.abMergeManager.startAudiobookMerge(req.user.id, req.libraryItem, options)
res.sendStatus(200) res.sendStatus(200)

View File

@ -26,6 +26,12 @@ class PlaybackSessionManager {
this.sessions = [] this.sessions = []
} }
/**
* Get open session by id
*
* @param {string} sessionId
* @returns {PlaybackSession}
*/
getSession(sessionId) { getSession(sessionId) {
return this.sessions.find((s) => s.id === sessionId) return this.sessions.find((s) => s.id === sessionId)
} }

View File

@ -246,7 +246,6 @@ class LibraryItem extends Model {
include include
}) })
if (!libraryItem) { if (!libraryItem) {
Logger.error(`[LibraryItem] Library item not found`)
return null return null
} }

View File

@ -1,5 +1,6 @@
const express = require('express') const express = require('express')
const ShareController = require('../controllers/ShareController') const ShareController = require('../controllers/ShareController')
const SessionController = require('../controllers/SessionController')
class PublicRouter { class PublicRouter {
constructor(playbackSessionManager) { constructor(playbackSessionManager) {
@ -17,6 +18,7 @@ class PublicRouter {
this.router.get('/share/:slug/cover', ShareController.getMediaItemShareCoverImage.bind(this)) this.router.get('/share/:slug/cover', ShareController.getMediaItemShareCoverImage.bind(this))
this.router.get('/share/:slug/download', ShareController.downloadMediaItemShare.bind(this)) this.router.get('/share/:slug/download', ShareController.downloadMediaItemShare.bind(this))
this.router.patch('/share/:slug/progress', ShareController.updateMediaItemShareProgress.bind(this)) this.router.patch('/share/:slug/progress', ShareController.updateMediaItemShareProgress.bind(this))
this.router.get('/session/:id/track/:index', SessionController.getTrack.bind(this))
} }
} }
module.exports = PublicRouter module.exports = PublicRouter

View File

@ -407,7 +407,7 @@ class LibraryScanner {
const folder = library.libraryFolders[0] const folder = library.libraryFolders[0]
const filePathItems = folderGroups[folderId].fileUpdates.map((fileUpdate) => fileUtils.getFilePathItemFromFileUpdate(fileUpdate)) const filePathItems = folderGroups[folderId].fileUpdates.map((fileUpdate) => fileUtils.getFilePathItemFromFileUpdate(fileUpdate))
const fileUpdateGroup = scanUtils.groupFileItemsIntoLibraryItemDirs(library.mediaType, filePathItems, !!library.settings?.audiobooksOnly) const fileUpdateGroup = scanUtils.groupFileItemsIntoLibraryItemDirs(library.mediaType, filePathItems, !!library.settings?.audiobooksOnly, true)
if (!Object.keys(fileUpdateGroup).length) { if (!Object.keys(fileUpdateGroup).length) {
Logger.info(`[LibraryScanner] No important changes to scan for in folder "${folderId}"`) Logger.info(`[LibraryScanner] No important changes to scan for in folder "${folderId}"`)

View File

@ -242,7 +242,7 @@ module.exports.recurseFiles = async (path, relPathToReplace = null) => {
}) })
.filter((item) => { .filter((item) => {
// Filter out items in ignore directories // Filter out items in ignore directories
if (directoriesToIgnore.some((dir) => item.fullname.startsWith(dir))) { if (directoriesToIgnore.some((dir) => item.fullname.startsWith(dir + '/'))) {
Logger.debug(`[fileUtils] Ignoring path in dir with .ignore "${item.fullname}"`) Logger.debug(`[fileUtils] Ignoring path in dir with .ignore "${item.fullname}"`)
return false return false
} }

View File

@ -1247,7 +1247,12 @@ module.exports = {
libraryId libraryId
} }
}) })
return statResults[0] return {
totalSize: statResults?.[0]?.totalSize || 0,
totalDuration: statResults?.[0]?.totalDuration || 0,
numAudioFiles: statResults?.[0]?.numAudioFiles || 0,
totalItems: statResults?.[0]?.totalItems || 0
}
}, },
/** /**

View File

@ -533,8 +533,10 @@ module.exports = {
} }
}) })
return { return {
...statResults[0], totalDuration: statResults?.[0]?.totalDuration || 0,
totalSize: sizeResults[0].totalSize || 0 numAudioFiles: statResults?.[0]?.numAudioFiles || 0,
totalItems: statResults?.[0]?.totalItems || 0,
totalSize: sizeResults?.[0]?.totalSize || 0
} }
}, },

View File

@ -24,6 +24,12 @@ function isMediaFile(mediaType, ext, audiobooksOnly = false) {
return globals.SupportedAudioTypes.includes(extclean) || globals.SupportedEbookTypes.includes(extclean) return globals.SupportedAudioTypes.includes(extclean) || globals.SupportedEbookTypes.includes(extclean)
} }
function isScannableNonMediaFile(ext) {
if (!ext) return false
const extclean = ext.slice(1).toLowerCase()
return globals.TextFileTypes.includes(extclean) || globals.MetadataFileTypes.includes(extclean) || globals.SupportedImageTypes.includes(extclean)
}
function checkFilepathIsAudioFile(filepath) { function checkFilepathIsAudioFile(filepath) {
const ext = Path.extname(filepath) const ext = Path.extname(filepath)
if (!ext) return false if (!ext) return false
@ -35,27 +41,31 @@ module.exports.checkFilepathIsAudioFile = checkFilepathIsAudioFile
/** /**
* @param {string} mediaType * @param {string} mediaType
* @param {import('./fileUtils').FilePathItem[]} fileItems * @param {import('./fileUtils').FilePathItem[]} fileItems
* @param {boolean} [audiobooksOnly=false] * @param {boolean} audiobooksOnly
* @param {boolean} [includeNonMediaFiles=false] - Used by the watcher to re-scan when covers/metadata files are added/removed
* @returns {Record<string,string[]>} map of files grouped into potential libarary item dirs * @returns {Record<string,string[]>} map of files grouped into potential libarary item dirs
*/ */
function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems, audiobooksOnly = false) { function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems, audiobooksOnly, includeNonMediaFiles = false) {
// Step 1: Filter out non-book-media files in root dir (with depth of 0) // Step 1: Filter out non-book-media files in root dir (with depth of 0)
const itemsFiltered = fileItems.filter((i) => { const itemsFiltered = fileItems.filter((i) => {
return i.deep > 0 || (mediaType === 'book' && isMediaFile(mediaType, i.extension, audiobooksOnly)) return i.deep > 0 || (mediaType === 'book' && isMediaFile(mediaType, i.extension, audiobooksOnly))
}) })
// Step 2: Separate media files and other files // Step 2: Separate media files and other files
// - Directories without a media file will not be included // - Directories without a media file will not be included (unless includeNonMediaFiles is true)
/** @type {import('./fileUtils').FilePathItem[]} */ /** @type {import('./fileUtils').FilePathItem[]} */
const mediaFileItems = [] const mediaFileItems = []
/** @type {import('./fileUtils').FilePathItem[]} */ /** @type {import('./fileUtils').FilePathItem[]} */
const otherFileItems = [] const otherFileItems = []
itemsFiltered.forEach((item) => { itemsFiltered.forEach((item) => {
if (isMediaFile(mediaType, item.extension, audiobooksOnly)) mediaFileItems.push(item) if (isMediaFile(mediaType, item.extension, audiobooksOnly) || (includeNonMediaFiles && isScannableNonMediaFile(item.extension))) {
else otherFileItems.push(item) mediaFileItems.push(item)
} else {
otherFileItems.push(item)
}
}) })
// Step 3: Group audio files in library items // Step 3: Group media files (or non-media files if includeNonMediaFiles is true) in library items
const libraryItemGroup = {} const libraryItemGroup = {}
mediaFileItems.forEach((item) => { mediaFileItems.forEach((item) => {
const dirparts = item.reldirpath.split('/').filter((p) => !!p) const dirparts = item.reldirpath.split('/').filter((p) => !!p)

View File

@ -47,7 +47,7 @@ describe('fileUtils', () => {
// Mock file structure with normalized paths // Mock file structure with normalized paths
const mockDirContents = new Map([ const mockDirContents = new Map([
['/test', ['file1.mp3', 'subfolder', 'ignoreme', 'temp.mp3.tmp']], ['/test', ['file1.mp3', 'subfolder', 'ignoreme', 'ignoremenot.mp3', 'temp.mp3.tmp']],
['/test/subfolder', ['file2.m4b']], ['/test/subfolder', ['file2.m4b']],
['/test/ignoreme', ['.ignore', 'ignored.mp3']] ['/test/ignoreme', ['.ignore', 'ignored.mp3']]
]) ])
@ -59,7 +59,8 @@ describe('fileUtils', () => {
['/test/ignoreme', { isDirectory: () => true, size: 0, mtimeMs: Date.now(), ino: '4' }], ['/test/ignoreme', { isDirectory: () => true, size: 0, mtimeMs: Date.now(), ino: '4' }],
['/test/ignoreme/.ignore', { isDirectory: () => false, size: 0, mtimeMs: Date.now(), ino: '5' }], ['/test/ignoreme/.ignore', { isDirectory: () => false, size: 0, mtimeMs: Date.now(), ino: '5' }],
['/test/ignoreme/ignored.mp3', { isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '6' }], ['/test/ignoreme/ignored.mp3', { isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '6' }],
['/test/temp.mp3.tmp', { isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '7' }] ['/test/ignoremenot.mp3', { isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '7' }],
['/test/temp.mp3.tmp', { isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '8' }]
]) ])
// Stub fs.readdir // Stub fs.readdir
@ -103,7 +104,7 @@ describe('fileUtils', () => {
it('should return filtered file list', async () => { it('should return filtered file list', async () => {
const files = await fileUtils.recurseFiles('/test') const files = await fileUtils.recurseFiles('/test')
expect(files).to.be.an('array') expect(files).to.be.an('array')
expect(files).to.have.lengthOf(2) expect(files).to.have.lengthOf(3)
expect(files[0]).to.deep.equal({ expect(files[0]).to.deep.equal({
name: 'file1.mp3', name: 'file1.mp3',
@ -115,6 +116,15 @@ describe('fileUtils', () => {
}) })
expect(files[1]).to.deep.equal({ expect(files[1]).to.deep.equal({
name: 'ignoremenot.mp3',
path: 'ignoremenot.mp3',
reldirpath: '',
fullpath: '/test/ignoremenot.mp3',
extension: '.mp3',
deep: 0
})
expect(files[2]).to.deep.equal({
name: 'file2.m4b', name: 'file2.m4b',
path: 'subfolder/file2.m4b', path: 'subfolder/file2.m4b',
reldirpath: 'subfolder', reldirpath: 'subfolder',