mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-06-21 02:08:34 +02:00
Merge branch 'master' into feat/chapterLookUp
This commit is contained in:
commit
26c976b6b9
1
.gitignore
vendored
1
.gitignore
vendored
@ -23,3 +23,4 @@ sw.*
|
||||
.DS_STORE
|
||||
.idea/*
|
||||
tailwind.compiled.css
|
||||
tailwind.config.js
|
||||
|
@ -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
|
||||
|
@ -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 || ' ' }}</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>
|
@ -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 || []
|
||||
|
@ -74,6 +74,9 @@ export default {
|
||||
currentChapterStart() {
|
||||
if (!this.currentChapter) return 0
|
||||
return this.currentChapter.start
|
||||
},
|
||||
isMobile() {
|
||||
return this.$store.state.globals.isMobile
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@ -145,6 +148,9 @@ export default {
|
||||
})
|
||||
},
|
||||
mousemoveTrack(e) {
|
||||
if (this.isMobile) {
|
||||
return
|
||||
}
|
||||
const offsetX = e.offsetX
|
||||
|
||||
const baseTime = this.useChapterTrack ? this.currentChapterStart : 0
|
||||
@ -198,6 +204,7 @@ export default {
|
||||
setTrackWidth() {
|
||||
if (this.$refs.track) {
|
||||
this.trackWidth = this.$refs.track.clientWidth
|
||||
this.trackOffsetLeft = this.$refs.track.getBoundingClientRect().left
|
||||
} else {
|
||||
console.error('Track not loaded', this.$refs)
|
||||
}
|
||||
|
@ -164,14 +164,15 @@ export default {
|
||||
beforeMount() {
|
||||
this.yearInReviewYear = new Date().getFullYear()
|
||||
|
||||
// When not December show previous year
|
||||
if (new Date().getMonth() < 11) {
|
||||
this.availableYears = this.getAvailableYears()
|
||||
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--
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.availableYears = this.getAvailableYears()
|
||||
|
||||
if (typeof navigator.share !== 'undefined' && navigator.share) {
|
||||
this.showShareButton = true
|
||||
} else {
|
||||
|
@ -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() {
|
||||
|
@ -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>
|
211
client/components/widgets/EncoderOptionsCard.vue
Normal file
211
client/components/widgets/EncoderOptionsCard.vue
Normal 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>
|
@ -3,7 +3,6 @@ import LazyBookCard from '@/components/cards/LazyBookCard'
|
||||
import LazySeriesCard from '@/components/cards/LazySeriesCard'
|
||||
import LazyCollectionCard from '@/components/cards/LazyCollectionCard'
|
||||
import LazyPlaylistCard from '@/components/cards/LazyPlaylistCard'
|
||||
import LazyAlbumCard from '@/components/cards/LazyAlbumCard'
|
||||
import AuthorCard from '@/components/cards/AuthorCard'
|
||||
|
||||
export default {
|
||||
@ -20,7 +19,6 @@ export default {
|
||||
if (this.entityName === 'series') return Vue.extend(LazySeriesCard)
|
||||
if (this.entityName === 'collections') return Vue.extend(LazyCollectionCard)
|
||||
if (this.entityName === 'playlists') return Vue.extend(LazyPlaylistCard)
|
||||
if (this.entityName === 'albums') return Vue.extend(LazyAlbumCard)
|
||||
if (this.entityName === 'authors') return Vue.extend(AuthorCard)
|
||||
return Vue.extend(LazyBookCard)
|
||||
},
|
||||
@ -28,7 +26,6 @@ export default {
|
||||
if (this.entityName === 'series') return 'cards-lazy-series-card'
|
||||
if (this.entityName === 'collections') return 'cards-lazy-collection-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'
|
||||
return 'cards-lazy-book-card'
|
||||
},
|
||||
|
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<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">
|
||||
<h1 class="text-lg lg:text-xl">{{ title }}</h1>
|
||||
</nuxt-link>
|
||||
@ -12,7 +12,7 @@
|
||||
<p class="text-base font-mono ml-4 hidden md:block">{{ $secondsToTimestamp(mediaDurationRounded) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap-reverse justify-center py-4 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="flex items-center">
|
||||
<div class="w-12 hidden lg:block" />
|
||||
@ -23,8 +23,8 @@
|
||||
</div>
|
||||
<div class="flex items-center mb-3 py-1 -mx-1">
|
||||
<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="newChapters.length > 1" :color="showShiftTimes ? 'bg' : 'primary'" class="mx-1" small @click="showShiftTimes = !showShiftTimes">{{ $strings.ButtonShiftTimes }}</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-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>
|
||||
<div class="grow" />
|
||||
<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" />
|
||||
</div>
|
||||
<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 class="w-32 min-w-32 px-2 py-1">
|
||||
<div class="flex items-center">
|
||||
|
@ -2,7 +2,14 @@
|
||||
<div id="page-wrapper" class="bg-bg page p-8 overflow-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
|
||||
<div class="flex items-center justify-center mb-6">
|
||||
<div class="w-full max-w-2xl">
|
||||
<p class="text-2xl mb-2">{{ $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 class="w-full max-w-2xl">
|
||||
<div class="flex justify-end">
|
||||
@ -13,13 +20,13 @@
|
||||
|
||||
<div class="flex justify-center mb-2">
|
||||
<div class="w-full max-w-2xl">
|
||||
<p class="text-xl">{{ $strings.HeaderMetadataToEmbed }}</p>
|
||||
<p class="text-lg">{{ $strings.HeaderMetadataToEmbed }}</p>
|
||||
</div>
|
||||
<div class="w-full max-w-2xl"></div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center flex-wrap">
|
||||
<div class="w-full max-w-2xl border border-white/10 bg-bg mx-2">
|
||||
<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">
|
||||
<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>
|
||||
@ -35,7 +42,7 @@
|
||||
</template>
|
||||
</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="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>
|
||||
@ -77,10 +84,6 @@
|
||||
</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-symbols text-xl">{{ showEncodeOptions || usingCustomEncodeOptions ? 'check_box' : 'check_box_outline_blank' }}</span> <span class="pl-1">{{ $strings.LabelUseAdvancedOptions }}</span>
|
||||
</button>
|
||||
|
||||
<div class="grow" />
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- advanced encoding options -->
|
||||
<div v-if="isM4BTool" class="overflow-hidden">
|
||||
<transition name="slide">
|
||||
<div v-if="showEncodeOptions || usingCustomEncodeOptions" 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="$strings.LabelAudioBitrate" class="m-2 max-w-40" @input="bitrateChanged" />
|
||||
<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" />
|
||||
<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>
|
||||
<p class="text-sm text-warning">{{ $strings.LabelEncodingWarningAdvancedSettings }}</p>
|
||||
</div>
|
||||
</transition>
|
||||
<!-- show encoding options for running task -->
|
||||
<div v-if="encodeTaskHasEncodingOptions" 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" readonly :label="$strings.LabelAudioBitrate" class="m-2 max-w-40" @input="bitrateChanged" />
|
||||
<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="codecInput" v-model="encodingOptions.codec" readonly :label="$strings.LabelAudioCodec" class="m-2 max-w-40" @input="codecChanged" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="isM4BTool" class="mb-4">
|
||||
<widgets-encoder-options-card ref="encoderOptionsCard" :audio-tracks="audioFiles" :disabled="processing || isTaskFinished" />
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
@ -146,19 +147,29 @@
|
||||
<div class="flex py-2 px-4 bg-primary/25">
|
||||
<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="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-24"></div>
|
||||
</div>
|
||||
<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 class="w-10">{{ file.index }}</div>
|
||||
<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 min-w-10">{{ file.index }}</div>
|
||||
<div class="grow">
|
||||
{{ file.metadata.filename }}
|
||||
</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) }}
|
||||
</div>
|
||||
<div class="w-24">
|
||||
<div class="w-24 min-w-24">
|
||||
<div class="flex justify-center">
|
||||
<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]">
|
||||
@ -214,7 +225,6 @@ export default {
|
||||
metadataObject: null,
|
||||
selectedTool: 'embed',
|
||||
isCancelingEncode: false,
|
||||
showEncodeOptions: false,
|
||||
shouldBackupAudioFiles: true,
|
||||
encodingOptions: {
|
||||
bitrate: '128k',
|
||||
@ -263,9 +273,6 @@ export default {
|
||||
audioFiles() {
|
||||
return (this.media.audioFiles || []).filter((af) => !af.exclude)
|
||||
},
|
||||
isSingleM4b() {
|
||||
return this.audioFiles.length === 1 && this.audioFiles[0].metadata.ext.toLowerCase() === '.m4b'
|
||||
},
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
},
|
||||
@ -273,14 +280,10 @@ export default {
|
||||
return this.media.chapters || []
|
||||
},
|
||||
availableTools() {
|
||||
if (this.isSingleM4b) {
|
||||
return [{ value: 'embed', text: this.$strings.LabelToolsEmbedMetadata }]
|
||||
} else {
|
||||
return [
|
||||
{ value: 'embed', text: this.$strings.LabelToolsEmbedMetadata },
|
||||
{ value: 'm4b', text: this.$strings.LabelToolsM4bEncoder }
|
||||
]
|
||||
}
|
||||
return [
|
||||
{ value: 'embed', text: this.$strings.LabelToolsEmbedMetadata },
|
||||
{ value: 'm4b', text: this.$strings.LabelToolsM4bEncoder }
|
||||
]
|
||||
},
|
||||
taskFailed() {
|
||||
return this.isTaskFinished && this.task.isFailed
|
||||
@ -314,8 +317,8 @@ export default {
|
||||
isMetadataEmbedQueued() {
|
||||
return this.queuedEmbedLIds.some((lid) => lid === this.libraryItemId)
|
||||
},
|
||||
usingCustomEncodeOptions() {
|
||||
return this.isM4BTool && this.encodeTask && this.encodeTask.data.encodeOptions && Object.keys(this.encodeTask.data.encodeOptions).length > 0
|
||||
encodeTaskHasEncodingOptions() {
|
||||
return this.isM4BTool && !!this.encodeTask?.data.encodeOptions && Object.keys(this.encodeTask.data.encodeOptions).length > 0
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@ -351,19 +354,13 @@ export default {
|
||||
if (this.$refs.channelsInput) this.$refs.channelsInput.blur()
|
||||
if (this.$refs.codecInput) this.$refs.codecInput.blur()
|
||||
|
||||
let queryStr = ''
|
||||
if (this.showEncodeOptions) {
|
||||
const options = []
|
||||
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('&')}`
|
||||
}
|
||||
}
|
||||
const encodeOptions = this.$refs.encoderOptionsCard.getEncodingOptions()
|
||||
|
||||
const queryParams = new URLSearchParams(encodeOptions)
|
||||
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$post(`/api/tools/item/${this.libraryItemId}/encode-m4b${queryStr}`)
|
||||
.$post(`/api/tools/item/${this.libraryItemId}/encode-m4b?${queryParams.toString()}`)
|
||||
.then(() => {
|
||||
console.log('Ab m4b merge started')
|
||||
})
|
||||
@ -416,14 +413,10 @@ export default {
|
||||
const shouldBackupAudioFiles = localStorage.getItem('embedMetadataShouldBackup')
|
||||
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.channels) this.encodingOptions.channels = this.encodeTask.data.encodeOptions.channels
|
||||
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() {
|
||||
@ -438,10 +431,24 @@ export default {
|
||||
},
|
||||
taskUpdated(task) {
|
||||
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() {
|
||||
this.init()
|
||||
|
||||
this.$eventBus.$on(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$eventBus.$off(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -1,5 +1,5 @@
|
||||
<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="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">
|
||||
@ -335,8 +335,11 @@ export default {
|
||||
}
|
||||
},
|
||||
resize() {
|
||||
this.windowWidth = window.innerWidth
|
||||
this.windowHeight = window.innerHeight
|
||||
setTimeout(() => {
|
||||
this.windowWidth = window.innerWidth
|
||||
this.windowHeight = window.innerHeight
|
||||
this.$store.commit('globals/updateWindowSize', { width: window.innerWidth, height: window.innerHeight })
|
||||
}, 100)
|
||||
},
|
||||
playerError(error) {
|
||||
console.error('Player error', error)
|
||||
|
@ -1,5 +1,5 @@
|
||||
export default class AudioTrack {
|
||||
constructor(track, userToken, routerBasePath) {
|
||||
constructor(track, sessionId, routerBasePath) {
|
||||
this.index = track.index || 0
|
||||
this.startOffset = track.startOffset || 0 // Total time of all previous tracks
|
||||
this.duration = track.duration || 0
|
||||
@ -8,28 +8,29 @@ export default class AudioTrack {
|
||||
this.mimeType = track.mimeType
|
||||
this.metadata = track.metadata || {}
|
||||
|
||||
this.userToken = userToken
|
||||
this.sessionId = sessionId
|
||||
this.routerBasePath = routerBasePath || ''
|
||||
if (this.contentUrl?.startsWith('/hls')) {
|
||||
this.sessionTrackUrl = this.contentUrl
|
||||
} else {
|
||||
this.sessionTrackUrl = `/public/session/${sessionId}/track/${this.index}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Used for CastPlayer
|
||||
*/
|
||||
get fullContentUrl() {
|
||||
if (!this.contentUrl || this.contentUrl.startsWith('http')) return this.contentUrl
|
||||
|
||||
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
|
||||
*/
|
||||
get relativeContentUrl() {
|
||||
if (!this.contentUrl || this.contentUrl.startsWith('http')) return this.contentUrl
|
||||
|
||||
return `${this.routerBasePath}${this.contentUrl}?token=${this.userToken}`
|
||||
return `${this.routerBasePath}${this.sessionTrackUrl}`
|
||||
}
|
||||
}
|
||||
|
@ -37,9 +37,6 @@ export default class PlayerHandler {
|
||||
get isPlayingLocalItem() {
|
||||
return this.libraryItem && this.player instanceof LocalAudioPlayer
|
||||
}
|
||||
get userToken() {
|
||||
return this.ctx.$store.getters['user/getToken']
|
||||
}
|
||||
get playerPlaying() {
|
||||
return this.playerState === 'PLAYING'
|
||||
}
|
||||
@ -226,7 +223,7 @@ export default class PlayerHandler {
|
||||
|
||||
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.isHlsTranscode = true
|
||||
|
@ -177,6 +177,7 @@
|
||||
"HeaderPlaylist": "Playlist",
|
||||
"HeaderPlaylistItems": "Playlist Items",
|
||||
"HeaderPodcastsToAdd": "Podcasts to Add",
|
||||
"HeaderPresets": "Presets",
|
||||
"HeaderPreviewCover": "Preview Cover",
|
||||
"HeaderRSSFeedGeneral": "RSS Details",
|
||||
"HeaderRSSFeedIsOpen": "RSS Feed is Open",
|
||||
|
@ -313,7 +313,7 @@ class Server {
|
||||
router.use(express.json({ limit: '5mb' }))
|
||||
|
||||
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)
|
||||
|
||||
// Static path to generated nuxt
|
||||
|
@ -834,8 +834,8 @@ class LibraryItemController {
|
||||
}
|
||||
|
||||
if (req.libraryItem.isMissing || !req.libraryItem.isBook || !req.libraryItem.media.includedAudioFiles.length) {
|
||||
Logger.error(`[LibraryItemController] Invalid library item`)
|
||||
return res.sendStatus(500)
|
||||
Logger.error(`[LibraryItemController] getMetadataObject: Invalid library item "${req.libraryItem.media.title}"`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
||||
res.json(this.audioMetadataManager.getMetadataObjectForApi(req.libraryItem))
|
||||
|
@ -222,7 +222,7 @@ class MiscController {
|
||||
|
||||
// Update nameIgnorePrefix column on series
|
||||
const allSeries = await Database.seriesModel.findAll({
|
||||
attributes: ['id', 'name', 'nameIgnorePrefix']
|
||||
attributes: ['id', 'name', 'nameIgnorePrefix', 'libraryId']
|
||||
})
|
||||
const bulkUpdateSeries = []
|
||||
allSeries.forEach((series) => {
|
||||
@ -230,6 +230,8 @@ class MiscController {
|
||||
if (nameIgnorePrefix !== series.nameIgnorePrefix) {
|
||||
bulkUpdateSeries.push({
|
||||
id: series.id,
|
||||
name: series.name,
|
||||
libraryId: series.libraryId,
|
||||
nameIgnorePrefix
|
||||
})
|
||||
}
|
||||
|
@ -1,7 +1,9 @@
|
||||
const Path = require('path')
|
||||
const { Request, Response, NextFunction } = require('express')
|
||||
const Logger = require('../Logger')
|
||||
const Database = require('../Database')
|
||||
const { toNumber, isUUID } = require('../utils/index')
|
||||
const { getAudioMimeTypeFromExtname, encodeUriPath } = require('../utils/fileUtils')
|
||||
|
||||
const ShareManager = require('../managers/ShareManager')
|
||||
|
||||
@ -266,6 +268,51 @@ class SessionController {
|
||||
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
|
||||
|
@ -48,6 +48,7 @@ class ToolsController {
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
res.sendStatus(200)
|
||||
|
@ -26,6 +26,12 @@ class PlaybackSessionManager {
|
||||
this.sessions = []
|
||||
}
|
||||
|
||||
/**
|
||||
* Get open session by id
|
||||
*
|
||||
* @param {string} sessionId
|
||||
* @returns {PlaybackSession}
|
||||
*/
|
||||
getSession(sessionId) {
|
||||
return this.sessions.find((s) => s.id === sessionId)
|
||||
}
|
||||
|
@ -246,7 +246,6 @@ class LibraryItem extends Model {
|
||||
include
|
||||
})
|
||||
if (!libraryItem) {
|
||||
Logger.error(`[LibraryItem] Library item not found`)
|
||||
return null
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
const express = require('express')
|
||||
const ShareController = require('../controllers/ShareController')
|
||||
const SessionController = require('../controllers/SessionController')
|
||||
|
||||
class PublicRouter {
|
||||
constructor(playbackSessionManager) {
|
||||
@ -17,6 +18,7 @@ class PublicRouter {
|
||||
this.router.get('/share/:slug/cover', ShareController.getMediaItemShareCoverImage.bind(this))
|
||||
this.router.get('/share/:slug/download', ShareController.downloadMediaItemShare.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
|
||||
|
@ -407,7 +407,7 @@ class LibraryScanner {
|
||||
const folder = library.libraryFolders[0]
|
||||
|
||||
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) {
|
||||
Logger.info(`[LibraryScanner] No important changes to scan for in folder "${folderId}"`)
|
||||
|
@ -242,7 +242,7 @@ module.exports.recurseFiles = async (path, relPathToReplace = null) => {
|
||||
})
|
||||
.filter((item) => {
|
||||
// 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}"`)
|
||||
return false
|
||||
}
|
||||
|
@ -1247,7 +1247,12 @@ module.exports = {
|
||||
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
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
|
@ -533,8 +533,10 @@ module.exports = {
|
||||
}
|
||||
})
|
||||
return {
|
||||
...statResults[0],
|
||||
totalSize: sizeResults[0].totalSize || 0
|
||||
totalDuration: statResults?.[0]?.totalDuration || 0,
|
||||
numAudioFiles: statResults?.[0]?.numAudioFiles || 0,
|
||||
totalItems: statResults?.[0]?.totalItems || 0,
|
||||
totalSize: sizeResults?.[0]?.totalSize || 0
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -24,6 +24,12 @@ function isMediaFile(mediaType, ext, audiobooksOnly = false) {
|
||||
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) {
|
||||
const ext = Path.extname(filepath)
|
||||
if (!ext) return false
|
||||
@ -35,27 +41,31 @@ module.exports.checkFilepathIsAudioFile = checkFilepathIsAudioFile
|
||||
/**
|
||||
* @param {string} mediaType
|
||||
* @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
|
||||
*/
|
||||
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)
|
||||
const itemsFiltered = fileItems.filter((i) => {
|
||||
return i.deep > 0 || (mediaType === 'book' && isMediaFile(mediaType, i.extension, audiobooksOnly))
|
||||
})
|
||||
|
||||
// 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[]} */
|
||||
const mediaFileItems = []
|
||||
/** @type {import('./fileUtils').FilePathItem[]} */
|
||||
const otherFileItems = []
|
||||
itemsFiltered.forEach((item) => {
|
||||
if (isMediaFile(mediaType, item.extension, audiobooksOnly)) mediaFileItems.push(item)
|
||||
else otherFileItems.push(item)
|
||||
if (isMediaFile(mediaType, item.extension, audiobooksOnly) || (includeNonMediaFiles && isScannableNonMediaFile(item.extension))) {
|
||||
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 = {}
|
||||
mediaFileItems.forEach((item) => {
|
||||
const dirparts = item.reldirpath.split('/').filter((p) => !!p)
|
||||
|
@ -47,7 +47,7 @@ describe('fileUtils', () => {
|
||||
|
||||
// Mock file structure with normalized paths
|
||||
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/ignoreme', ['.ignore', 'ignored.mp3']]
|
||||
])
|
||||
@ -59,7 +59,8 @@ describe('fileUtils', () => {
|
||||
['/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/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
|
||||
@ -103,7 +104,7 @@ describe('fileUtils', () => {
|
||||
it('should return filtered file list', async () => {
|
||||
const files = await fileUtils.recurseFiles('/test')
|
||||
expect(files).to.be.an('array')
|
||||
expect(files).to.have.lengthOf(2)
|
||||
expect(files).to.have.lengthOf(3)
|
||||
|
||||
expect(files[0]).to.deep.equal({
|
||||
name: 'file1.mp3',
|
||||
@ -115,6 +116,15 @@ describe('fileUtils', () => {
|
||||
})
|
||||
|
||||
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',
|
||||
path: 'subfolder/file2.m4b',
|
||||
reldirpath: 'subfolder',
|
||||
|
Loading…
x
Reference in New Issue
Block a user