Compare commits

...

18 Commits

Author SHA1 Message Date
advplyr
b876256736 Audiobook tools page include edit item button and item title linked to item page 2025-05-08 17:17:44 -05:00
advplyr
3ce6e45761 Support m4b encoder tool for single m4b audiobooks 2025-05-08 17:08:11 -05:00
advplyr
5ac6b85da1 Merge pull request #4270 from advplyr/episode_secondary_sorts
Update episode secondary sort to pubDate and episode #4262
2025-05-07 17:45:53 -05:00
advplyr
69e0a0732a Update episode secondary sort to pubDate and episode #4262 2025-05-07 17:30:07 -05:00
advplyr
087835a9f3 Merge pull request #4266 from advplyr/hls_stream_url_update
Update HLS stream endpoints to not include user token
2025-05-06 17:39:16 -05:00
advplyr
1f7b181b7b Update HLS stream endpoints to not include user token 2025-05-06 17:28:19 -05:00
advplyr
1afb8840db Merge pull request #4263 from advplyr/new_session_track_endpoint
Add new api endpoint for direct playing audio files using session id
2025-05-05 17:25:40 -05:00
advplyr
d9531166b6 Fix for HLS transcode urls 2025-05-05 17:07:51 -05:00
advplyr
336de49d8d Add new api endpoint for direct playing audio files using session id #4259 2025-05-05 17:00:43 -05:00
advplyr
45987ffd63 Fix library stats returning null instead of 0 #4251 2025-05-03 17:25:01 -05:00
advplyr
1a1ef9c378 Merge pull request #4253 from advplyr/audiobook_tools_enhancements
Audiobook tools enhancements
2025-05-02 17:43:39 -05:00
advplyr
342d100f3e Replace advanced options with presets/advanced card 2025-05-02 17:24:46 -05:00
advplyr
e0b90c6813 Add channels, codec and bitrate to tracks table & breakpoint updates 2025-05-02 15:06:31 -05:00
advplyr
2706a9c4aa Merge pull request #4249 from advplyr/watcher_rescans_update
Update watcher to re-scan library items for non-media file only updates
2025-05-01 17:29:25 -05:00
advplyr
2cc9d1b7f8 Update watcher to re-scan library items when non-media files are added/updated #4245 2025-05-01 17:17:40 -05:00
advplyr
2b7268c952 Merge pull request #4240 from josh-vin/feat/defaultYearInReview
Improves Year in Review display logic
2025-04-30 17:26:00 -05:00
advplyr
e097fe1e88 Merge pull request #4241 from advplyr/player_track_tooltip
Fix player track tooltip overflowing on share player
2025-04-29 18:01:25 -05:00
Josh Vincent
58cd751b43 Improves Year in Review display logic 2025-04-28 21:00:22 -06:00
21 changed files with 416 additions and 100 deletions

1
.gitignore vendored
View File

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

View File

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

View File

@@ -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 {

View File

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

View File

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

View File

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

View File

@@ -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>

View File

@@ -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}`
}
}

View File

@@ -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

View File

@@ -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",

View File

@@ -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

View File

@@ -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))

View File

@@ -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

View File

@@ -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)

View File

@@ -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)
}

View File

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

View File

@@ -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

View File

@@ -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}"`)

View File

@@ -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
}
},
/**

View File

@@ -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
}
},

View File

@@ -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)