mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-06-21 10:18:20 +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
|
.DS_STORE
|
||||||
.idea/*
|
.idea/*
|
||||||
tailwind.compiled.css
|
tailwind.compiled.css
|
||||||
|
tailwind.config.js
|
||||||
|
@ -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
|
||||||
|
@ -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() {
|
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 || []
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
|
@ -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 {
|
||||||
|
@ -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() {
|
||||||
|
@ -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>
|
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 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'
|
||||||
},
|
},
|
||||||
|
@ -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">
|
||||||
|
@ -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 v-if="showEncodeOptions || usingCustomEncodeOptions" class="mb-4 pb-4 border-b border-white/10">
|
|
||||||
<div class="flex flex-wrap -mx-2">
|
<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="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" :disabled="processing || isTaskFinished" :label="$strings.LabelAudioChannels" class="m-2 max-w-40" @input="channelsChanged" />
|
<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" :disabled="processing || isTaskFinished" :label="$strings.LabelAudioCodec" class="m-2 max-w-40" @input="codecChanged" />
|
<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>
|
||||||
<p class="text-sm text-warning">{{ $strings.LabelEncodingWarningAdvancedSettings }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
<div v-else-if="isM4BTool" class="mb-4">
|
||||||
|
<widgets-encoder-options-card ref="encoderOptionsCard" :audio-tracks="audioFiles" :disabled="processing || isTaskFinished" />
|
||||||
</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 [{ value: 'embed', text: this.$strings.LabelToolsEmbedMetadata }]
|
|
||||||
} else {
|
|
||||||
return [
|
return [
|
||||||
{ value: 'embed', text: this.$strings.LabelToolsEmbedMetadata },
|
{ value: 'embed', text: this.$strings.LabelToolsEmbedMetadata },
|
||||||
{ value: 'm4b', text: this.$strings.LabelToolsM4bEncoder }
|
{ 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>
|
||||||
|
@ -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() {
|
||||||
|
setTimeout(() => {
|
||||||
this.windowWidth = window.innerWidth
|
this.windowWidth = window.innerWidth
|
||||||
this.windowHeight = window.innerHeight
|
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)
|
||||||
|
@ -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}`
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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",
|
||||||
|
@ -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
|
||||||
|
@ -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))
|
||||||
|
@ -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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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}"`)
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
@ -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',
|
||||||
|
Loading…
x
Reference in New Issue
Block a user