mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-06-03 17:30:39 +02:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 14ee17de47 | |||
| 034b8956a2 | |||
| 1a3f0e332e | |||
| fc36e86db7 | |||
| 60b4bc1a7e | |||
| 9fdc8df8bc | |||
| 212b97fa20 | |||
| 704fbaced8 | |||
| 575a162f8b | |||
| d2e0844493 | |||
| f2baf3fafd | |||
| 916fd039ca | |||
| e248b6d8d8 | |||
| 936de68622 | |||
| a99257e758 | |||
| c89d77dd06 | |||
| 3138865d69 | |||
| 4d29ebd647 | |||
| fd58df4729 | |||
| 5078818295 | |||
| 7181df0479 | |||
| 6c618d7760 | |||
| 17b8cf19b7 | |||
| e018f8341e | |||
| 59b5f8cbbe | |||
| d6108a0722 | |||
| 1af7e59d88 | |||
| 7b425e9a9d | |||
| 596a03900b | |||
| b283644d95 | |||
| 808690c137 | |||
| 136c347586 | |||
| e81238038e | |||
| fcf6964d7d |
@@ -58,9 +58,6 @@
|
|||||||
<span class="material-icons text-2xl -ml-2 pr-1 text-white">play_arrow</span>
|
<span class="material-icons text-2xl -ml-2 pr-1 text-white">play_arrow</span>
|
||||||
{{ $strings.ButtonPlay }}
|
{{ $strings.ButtonPlay }}
|
||||||
</ui-btn>
|
</ui-btn>
|
||||||
<ui-tooltip v-if="userIsAdminOrUp && isBookLibrary" :text="$strings.ButtonQuickMatch" direction="bottom">
|
|
||||||
<ui-icon-btn :disabled="processingBatch" icon="auto_awesome" @click="batchAutoMatchClick" class="mx-1.5" />
|
|
||||||
</ui-tooltip>
|
|
||||||
<ui-tooltip v-if="isBookLibrary" :text="selectedIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="bottom">
|
<ui-tooltip v-if="isBookLibrary" :text="selectedIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="bottom">
|
||||||
<ui-read-icon-btn :disabled="processingBatch" :is-read="selectedIsFinished" @click="toggleBatchRead" class="mx-1.5" />
|
<ui-read-icon-btn :disabled="processingBatch" :is-read="selectedIsFinished" @click="toggleBatchRead" class="mx-1.5" />
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
@@ -75,8 +72,11 @@
|
|||||||
<ui-tooltip v-if="userCanDelete" :text="$strings.ButtonRemove" direction="bottom">
|
<ui-tooltip v-if="userCanDelete" :text="$strings.ButtonRemove" direction="bottom">
|
||||||
<ui-icon-btn :disabled="processingBatch" icon="delete" bg-color="error" class="mx-1.5" @click="batchDeleteClick" />
|
<ui-icon-btn :disabled="processingBatch" icon="delete" bg-color="error" class="mx-1.5" @click="batchDeleteClick" />
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
<ui-tooltip :text="$strings.LabelDeselectAll" direction="bottom">
|
|
||||||
<span class="material-icons text-4xl px-4 hover:text-gray-100 cursor-pointer" :class="processingBatch ? 'text-gray-400' : ''" @click="cancelSelectionMode">close</span>
|
<ui-context-menu-dropdown v-if="contextMenuItems.length && !processingBatch" :items="contextMenuItems" class="ml-1" @action="contextMenuAction" />
|
||||||
|
|
||||||
|
<ui-tooltip :text="$strings.LabelDeselectAll" direction="bottom" class="flex items-center">
|
||||||
|
<span class="material-icons text-3xl px-4 hover:text-gray-100 cursor-pointer" :class="processingBatch ? 'text-gray-400' : ''" @click="cancelSelectionMode">close</span>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -160,9 +160,59 @@ export default {
|
|||||||
},
|
},
|
||||||
isHttps() {
|
isHttps() {
|
||||||
return location.protocol === 'https:' || process.env.NODE_ENV === 'development'
|
return location.protocol === 'https:' || process.env.NODE_ENV === 'development'
|
||||||
|
},
|
||||||
|
contextMenuItems() {
|
||||||
|
if (!this.userIsAdminOrUp) return []
|
||||||
|
|
||||||
|
const options = [
|
||||||
|
{
|
||||||
|
text: this.$strings.ButtonQuickMatch,
|
||||||
|
action: 'quick-match'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
if (!this.isPodcastLibrary && this.selectedMediaItemsArePlayable) {
|
||||||
|
options.push({
|
||||||
|
text: 'Quick Embed Metadata',
|
||||||
|
action: 'quick-embed'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return options
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
requestBatchQuickEmbed() {
|
||||||
|
const payload = {
|
||||||
|
message: 'Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?',
|
||||||
|
callback: (confirmed) => {
|
||||||
|
if (confirmed) {
|
||||||
|
this.$axios
|
||||||
|
.$post(`/api/tools/batch/embed-metadata`, {
|
||||||
|
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
console.log('Audio metadata embed started')
|
||||||
|
this.cancelSelectionMode()
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Audio metadata embed failed', error)
|
||||||
|
const errorMsg = error.response.data || 'Failed to embed metadata'
|
||||||
|
this.$toast.error(errorMsg)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'yesNo'
|
||||||
|
}
|
||||||
|
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||||
|
},
|
||||||
|
contextMenuAction(action) {
|
||||||
|
if (action === 'quick-embed') {
|
||||||
|
this.requestBatchQuickEmbed()
|
||||||
|
} else if (action === 'quick-match') {
|
||||||
|
this.batchAutoMatchClick()
|
||||||
|
}
|
||||||
|
},
|
||||||
async playSelectedItems() {
|
async playSelectedItems() {
|
||||||
this.$store.commit('setProcessingBatch', true)
|
this.$store.commit('setProcessingBatch', true)
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="streamLibraryItem" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 sm:h-44 md:h-40 z-50 bg-primary px-2 md:px-4 pb-1 md:pb-4 pt-2">
|
<div v-if="streamLibraryItem" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 md:h-40 z-50 bg-primary px-2 md:px-4 pb-1 md:pb-4 pt-2">
|
||||||
<div id="videoDock" />
|
<div id="videoDock" />
|
||||||
<nuxt-link v-if="!playerHandler.isVideo" :to="`/item/${streamLibraryItem.id}`" class="absolute left-2 top-2 md:left-4 cursor-pointer">
|
<nuxt-link v-if="!playerHandler.isVideo" :to="`/item/${streamLibraryItem.id}`" class="absolute left-2 top-2 md:left-4 cursor-pointer">
|
||||||
<covers-book-cover :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" />
|
<covers-book-cover :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
<div class="flex items-start mb-6 md:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : isSquareCover ? 'pl-18 sm:pl-24' : 'pl-12 sm:pl-16'">
|
<div class="flex items-start mb-6 md:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : isSquareCover ? 'pl-18 sm:pl-24' : 'pl-12 sm:pl-16'">
|
||||||
<div>
|
<div class="min-w-0">
|
||||||
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-sm sm:text-lg">
|
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-sm sm:text-lg block truncate">
|
||||||
{{ title }}
|
{{ title }}
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
<div v-if="!playerHandler.isVideo" class="text-gray-400 flex items-center">
|
<div v-if="!playerHandler.isVideo" class="text-gray-400 flex items-center">
|
||||||
|
|||||||
@@ -325,8 +325,13 @@ export default {
|
|||||||
if (this.episodeProgress) return this.episodeProgress
|
if (this.episodeProgress) return this.episodeProgress
|
||||||
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
|
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
|
||||||
},
|
},
|
||||||
|
useEBookProgress() {
|
||||||
|
if (!this.userProgress || this.userProgress.progress) return false
|
||||||
|
return this.userProgress.ebookProgress > 0
|
||||||
|
},
|
||||||
userProgressPercent() {
|
userProgressPercent() {
|
||||||
return this.userProgress ? this.userProgress.progress || 0 : 0
|
if (this.useEBookProgress) return Math.max(Math.min(1, this.userProgress.ebookProgress), 0)
|
||||||
|
return this.userProgress ? Math.max(Math.min(1, this.userProgress.progress), 0) || 0 : 0
|
||||||
},
|
},
|
||||||
itemIsFinished() {
|
itemIsFinished() {
|
||||||
return this.userProgress ? !!this.userProgress.isFinished : false
|
return this.userProgress ? !!this.userProgress.isFinished : false
|
||||||
|
|||||||
@@ -185,6 +185,11 @@ export default {
|
|||||||
value: 'tracks',
|
value: 'tracks',
|
||||||
sublist: true
|
sublist: true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelAbridged,
|
||||||
|
value: 'abridged',
|
||||||
|
sublist: false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
text: this.$strings.ButtonIssues,
|
text: this.$strings.ButtonIssues,
|
||||||
value: 'issues',
|
value: 'issues',
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-2 sm:px-4 py-6 relative">
|
<div class="w-full h-full overflow-hidden overflow-y-auto px-2 sm:px-4 py-6 relative">
|
||||||
<div class="flex flex-wrap">
|
<div class="flex flex-wrap">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<covers-book-cover :library-item="libraryItem" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId, null, true)" :width="120" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
|
|
||||||
<!-- book cover overlay -->
|
<!-- book cover overlay -->
|
||||||
<div v-if="media.coverPath" class="absolute top-0 left-0 w-full h-full z-10 opacity-0 hover:opacity-100 transition-opacity duration-100">
|
<div v-if="media.coverPath" class="absolute top-0 left-0 w-full h-full z-10 opacity-0 hover:opacity-100 transition-opacity duration-100">
|
||||||
<div class="absolute top-0 left-0 w-full h-16 bg-gradient-to-b from-black-600 to-transparent" />
|
<div class="absolute top-0 left-0 w-full h-16 bg-gradient-to-b from-black-600 to-transparent" />
|
||||||
@@ -27,14 +28,14 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="localCovers.length" class="mb-4 mt-6 border-t border-b border-primary">
|
<div v-if="localCovers.length" class="mb-4 mt-6 border-t border-b border-white border-opacity-10">
|
||||||
<div class="flex items-center justify-center py-2">
|
<div class="flex items-center justify-center py-2">
|
||||||
<p>{{ localCovers.length }} local image{{ localCovers.length !== 1 ? 's' : '' }}</p>
|
<p>{{ localCovers.length }} local image{{ localCovers.length !== 1 ? 's' : '' }}</p>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<ui-btn small @click="showLocalCovers = !showLocalCovers">{{ showLocalCovers ? $strings.ButtonHide : $strings.ButtonShow }}</ui-btn>
|
<ui-btn small @click="showLocalCovers = !showLocalCovers">{{ showLocalCovers ? $strings.ButtonHide : $strings.ButtonShow }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="showLocalCovers" class="flex items-center justify-center">
|
<div v-if="showLocalCovers" class="flex items-center justify-center pb-2">
|
||||||
<template v-for="cover in localCovers">
|
<template v-for="cover in localCovers">
|
||||||
<div :key="cover.path" class="m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover.metadata.path === coverPath ? 'border-yellow-300' : ''" @click="setCover(cover)">
|
<div :key="cover.path" class="m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover.metadata.path === coverPath ? 'border-yellow-300' : ''" @click="setCover(cover)">
|
||||||
<div class="h-24 bg-primary" :style="{ width: 96 / bookCoverAspectRatio + 'px' }">
|
<div class="h-24 bg-primary" :style="{ width: 96 / bookCoverAspectRatio + 'px' }">
|
||||||
|
|||||||
@@ -34,13 +34,25 @@
|
|||||||
</div>
|
</div>
|
||||||
<ui-checkbox v-model="selectAll" checkbox-bg="bg" @input="selectAllToggled" />
|
<ui-checkbox v-model="selectAll" checkbox-bg="bg" @input="selectAllToggled" />
|
||||||
<form @submit.prevent="submitMatchUpdate">
|
<form @submit.prevent="submitMatchUpdate">
|
||||||
<div v-if="selectedMatchOrig.cover" class="flex items-center py-2">
|
<div v-if="selectedMatchOrig.cover" class="flex flex-wrap md:flex-nowrap items-center justify-center">
|
||||||
<ui-checkbox v-model="selectedMatchUsage.cover" checkbox-bg="bg" @input="checkboxToggled" />
|
<div class="flex flex-grow items-center py-2">
|
||||||
<ui-text-input-with-label v-model="selectedMatch.cover" :disabled="!selectedMatchUsage.cover" readonly :label="$strings.LabelCover" class="flex-grow mx-4" />
|
<ui-checkbox v-model="selectedMatchUsage.cover" checkbox-bg="bg" @input="checkboxToggled" />
|
||||||
<div class="min-w-12 max-w-12 md:min-w-16 md:max-w-16">
|
<ui-text-input-with-label v-model="selectedMatch.cover" :disabled="!selectedMatchUsage.cover" readonly :label="$strings.LabelCover" class="flex-grow mx-4" />
|
||||||
<a :href="selectedMatch.cover" target="_blank" class="w-full bg-primary">
|
</div>
|
||||||
<img :src="selectedMatch.cover" class="h-full w-full object-contain" />
|
|
||||||
</a>
|
<div class="flex py-2">
|
||||||
|
<div>
|
||||||
|
<p class="text-center text-gray-200">New</p>
|
||||||
|
<a :href="selectedMatch.cover" target="_blank" class="bg-primary">
|
||||||
|
<covers-preview-cover :src="selectedMatch.cover" :width="100" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div v-if="media.coverPath">
|
||||||
|
<p class="text-center text-gray-200">Current</p>
|
||||||
|
<a :href="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId, null, true)" target="_blank" class="bg-primary">
|
||||||
|
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId, null, true)" :width="100" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="selectedMatchOrig.title" class="flex items-center py-2">
|
<div v-if="selectedMatchOrig.title" class="flex items-center py-2">
|
||||||
@@ -164,13 +176,20 @@
|
|||||||
<p v-if="mediaMetadata.releaseDate" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.releaseDate || '' }}</p>
|
<p v-if="mediaMetadata.releaseDate" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.releaseDate || '' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="selectedMatchOrig.explicit != null" class="flex items-center py-2">
|
<div v-if="selectedMatchOrig.explicit != null" class="flex items-center pb-2" :class="{ 'pt-2': mediaMetadata.explicit == null }">
|
||||||
<ui-checkbox v-model="selectedMatchUsage.explicit" checkbox-bg="bg" @input="checkboxToggled" />
|
<ui-checkbox v-model="selectedMatchUsage.explicit" checkbox-bg="bg" @input="checkboxToggled" />
|
||||||
<div class="flex-grow ml-4">
|
<div class="flex-grow ml-4" :class="{ 'pt-4': mediaMetadata.explicit != null }">
|
||||||
<ui-checkbox v-model="selectedMatch.explicit" :label="$strings.LabelExplicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
<ui-checkbox v-model="selectedMatch.explicit" :label="$strings.LabelExplicit" :disabled="!selectedMatchUsage.explicit" :checkbox-bg="!selectedMatchUsage.explicit ? 'bg' : 'primary'" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||||
<p v-if="mediaMetadata.explicit != null" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.explicit ? 'Explicit (checked)' : 'Not Explicit (unchecked)' }}</p>
|
<p v-if="mediaMetadata.explicit != null" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.explicit ? 'Explicit (checked)' : 'Not Explicit (unchecked)' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="selectedMatchOrig.abridged != null" class="flex items-center pb-2" :class="{ 'pt-2': mediaMetadata.abridged == null }">
|
||||||
|
<ui-checkbox v-model="selectedMatchUsage.abridged" checkbox-bg="bg" @input="checkboxToggled" />
|
||||||
|
<div class="flex-grow ml-4" :class="{ 'pt-4': mediaMetadata.abridged != null }">
|
||||||
|
<ui-checkbox v-model="selectedMatch.abridged" :label="$strings.LabelAbridged" :disabled="!selectedMatchUsage.abridged" :checkbox-bg="!selectedMatchUsage.abridged ? 'bg' : 'primary'" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||||
|
<p v-if="mediaMetadata.abridged != null" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.abridged ? 'Abridged (checked)' : 'Unabridged (unchecked)' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-end py-2">
|
<div class="flex items-center justify-end py-2">
|
||||||
<ui-btn color="success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
<ui-btn color="success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
||||||
@@ -216,6 +235,7 @@ export default {
|
|||||||
explicit: true,
|
explicit: true,
|
||||||
asin: true,
|
asin: true,
|
||||||
isbn: true,
|
isbn: true,
|
||||||
|
abridged: true,
|
||||||
// Podcast specific
|
// Podcast specific
|
||||||
itunesPageUrl: true,
|
itunesPageUrl: true,
|
||||||
itunesId: true,
|
itunesId: true,
|
||||||
@@ -360,6 +380,7 @@ export default {
|
|||||||
explicit: true,
|
explicit: true,
|
||||||
asin: true,
|
asin: true,
|
||||||
isbn: true,
|
isbn: true,
|
||||||
|
abridged: true,
|
||||||
// Podcast specific
|
// Podcast specific
|
||||||
itunesPageUrl: true,
|
itunesPageUrl: true,
|
||||||
itunesId: true,
|
itunesId: true,
|
||||||
@@ -476,7 +497,6 @@ export default {
|
|||||||
} else if (key === 'narrator') {
|
} else if (key === 'narrator') {
|
||||||
updatePayload.metadata.narrators = this.selectedMatch[key].split(',').map((v) => v.trim())
|
updatePayload.metadata.narrators = this.selectedMatch[key].split(',').map((v) => v.trim())
|
||||||
} else if (key === 'genres') {
|
} else if (key === 'genres') {
|
||||||
// updatePayload.metadata.genres = this.selectedMatch[key].split(',').map((v) => v.trim())
|
|
||||||
updatePayload.metadata.genres = [...this.selectedMatch[key]]
|
updatePayload.metadata.genres = [...this.selectedMatch[key]]
|
||||||
} else if (key === 'tags') {
|
} else if (key === 'tags') {
|
||||||
updatePayload.tags = this.selectedMatch[key].split(',').map((v) => v.trim())
|
updatePayload.tags = this.selectedMatch[key].split(',').map((v) => v.trim())
|
||||||
|
|||||||
@@ -46,8 +46,20 @@
|
|||||||
>{{ $strings.ButtonOpenManager }}
|
>{{ $strings.ButtonOpenManager }}
|
||||||
<span class="material-icons text-lg ml-2">launch</span>
|
<span class="material-icons text-lg ml-2">launch</span>
|
||||||
</ui-btn>
|
</ui-btn>
|
||||||
|
|
||||||
|
<ui-btn v-if="!isMetadataEmbedQueued && !isEmbedTaskRunning" class="w-full mt-4" small @click.stop="quickEmbed">Quick Embed</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- queued alert -->
|
||||||
|
<widgets-alert v-if="isMetadataEmbedQueued" type="warning" class="mt-4">
|
||||||
|
<p class="text-lg">Queued for metadata embed ({{ queuedEmbedLIds.length }} in queue)</p>
|
||||||
|
</widgets-alert>
|
||||||
|
|
||||||
|
<!-- processing alert -->
|
||||||
|
<widgets-alert v-if="isEmbedTaskRunning" type="warning" class="mt-4">
|
||||||
|
<p class="text-lg">Currently embedding metadata</p>
|
||||||
|
</widgets-alert>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="!mediaTracks.length" class="text-lg text-center my-8">{{ $strings.MessageNoAudioTracks }}</p>
|
<p v-if="!mediaTracks.length" class="text-lg text-center my-8">{{ $strings.MessageNoAudioTracks }}</p>
|
||||||
@@ -71,10 +83,10 @@ export default {
|
|||||||
return this.$store.state.showExperimentalFeatures
|
return this.$store.state.showExperimentalFeatures
|
||||||
},
|
},
|
||||||
libraryItemId() {
|
libraryItemId() {
|
||||||
return this.libraryItem ? this.libraryItem.id : null
|
return this.libraryItem?.id || null
|
||||||
},
|
},
|
||||||
media() {
|
media() {
|
||||||
return this.libraryItem ? this.libraryItem.media || {} : {}
|
return this.libraryItem?.media || {}
|
||||||
},
|
},
|
||||||
mediaTracks() {
|
mediaTracks() {
|
||||||
return this.media.tracks || []
|
return this.media.tracks || []
|
||||||
@@ -92,9 +104,49 @@ export default {
|
|||||||
showMp3Split() {
|
showMp3Split() {
|
||||||
if (!this.mediaTracks.length) return false
|
if (!this.mediaTracks.length) return false
|
||||||
return this.isSingleM4b && this.chapters.length
|
return this.isSingleM4b && this.chapters.length
|
||||||
|
},
|
||||||
|
queuedEmbedLIds() {
|
||||||
|
return this.$store.state.tasks.queuedEmbedLIds || []
|
||||||
|
},
|
||||||
|
isMetadataEmbedQueued() {
|
||||||
|
return this.queuedEmbedLIds.some((lid) => lid === this.libraryItemId)
|
||||||
|
},
|
||||||
|
tasks() {
|
||||||
|
return this.$store.getters['tasks/getTasksByLibraryItemId'](this.libraryItemId)
|
||||||
|
},
|
||||||
|
embedTask() {
|
||||||
|
return this.tasks.find((t) => t.action === 'embed-metadata')
|
||||||
|
},
|
||||||
|
encodeTask() {
|
||||||
|
return this.tasks.find((t) => t.action === 'encode-m4b')
|
||||||
|
},
|
||||||
|
isEmbedTaskRunning() {
|
||||||
|
return this.embedTask && !this.embedTask?.isFinished
|
||||||
|
},
|
||||||
|
isEncodeTaskRunning() {
|
||||||
|
return this.encodeTask && !this.encodeTask?.isFinished
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {},
|
methods: {
|
||||||
mounted() {}
|
quickEmbed() {
|
||||||
|
const payload = {
|
||||||
|
message: 'Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?',
|
||||||
|
callback: (confirmed) => {
|
||||||
|
if (confirmed) {
|
||||||
|
this.$axios
|
||||||
|
.$post(`/api/tools/item/${this.libraryItemId}/embed-metadata`)
|
||||||
|
.then(() => {
|
||||||
|
console.log('Audio metadata encode started')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Audio metadata encode failed', error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'yesNo'
|
||||||
|
}
|
||||||
|
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -16,11 +16,11 @@
|
|||||||
v-for="(episode, index) in episodesList"
|
v-for="(episode, index) in episodesList"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="relative"
|
class="relative"
|
||||||
:class="itemEpisodeMap[episode.enclosure.url] ? 'bg-primary bg-opacity-40' : selectedEpisodes[String(index)] ? 'cursor-pointer bg-success bg-opacity-10' : index % 2 == 0 ? 'cursor-pointer bg-primary bg-opacity-25 hover:bg-opacity-40' : 'cursor-pointer bg-primary bg-opacity-5 hover:bg-opacity-25'"
|
:class="itemEpisodeMap[episode.enclosure.url?.split('?')[0]] ? 'bg-primary bg-opacity-40' : selectedEpisodes[String(index)] ? 'cursor-pointer bg-success bg-opacity-10' : index % 2 == 0 ? 'cursor-pointer bg-primary bg-opacity-25 hover:bg-opacity-40' : 'cursor-pointer bg-primary bg-opacity-5 hover:bg-opacity-25'"
|
||||||
@click="toggleSelectEpisode(index, episode)"
|
@click="toggleSelectEpisode(index, episode)"
|
||||||
>
|
>
|
||||||
<div class="absolute top-0 left-0 h-full flex items-center p-2">
|
<div class="absolute top-0 left-0 h-full flex items-center p-2">
|
||||||
<span v-if="itemEpisodeMap[episode.enclosure.url]" class="material-icons text-success text-xl">download_done</span>
|
<span v-if="itemEpisodeMap[episode.enclosure.url?.split('?')[0]]" class="material-icons text-success text-xl">download_done</span>
|
||||||
<ui-checkbox v-else v-model="selectedEpisodes[String(index)]" small checkbox-bg="primary" border-color="gray-600" />
|
<ui-checkbox v-else v-model="selectedEpisodes[String(index)]" small checkbox-bg="primary" border-color="gray-600" />
|
||||||
</div>
|
</div>
|
||||||
<div class="px-8 py-2">
|
<div class="px-8 py-2">
|
||||||
@@ -67,7 +67,7 @@ export default {
|
|||||||
selectAll: false,
|
selectAll: false,
|
||||||
search: null,
|
search: null,
|
||||||
searchTimeout: null,
|
searchTimeout: null,
|
||||||
searchText: null,
|
searchText: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -92,7 +92,7 @@ export default {
|
|||||||
return this.libraryItem.media.metadata.title || 'Unknown'
|
return this.libraryItem.media.metadata.title || 'Unknown'
|
||||||
},
|
},
|
||||||
allDownloaded() {
|
allDownloaded() {
|
||||||
return !this.episodes.some((episode) => !this.itemEpisodeMap[episode.enclosure.url])
|
return !this.episodes.some((episode) => !this.itemEpisodeMap[episode.enclosure.url?.split('?')[0]])
|
||||||
},
|
},
|
||||||
episodesSelected() {
|
episodesSelected() {
|
||||||
return Object.keys(this.selectedEpisodes).filter((key) => !!this.selectedEpisodes[key])
|
return Object.keys(this.selectedEpisodes).filter((key) => !!this.selectedEpisodes[key])
|
||||||
@@ -108,17 +108,14 @@ export default {
|
|||||||
itemEpisodeMap() {
|
itemEpisodeMap() {
|
||||||
var map = {}
|
var map = {}
|
||||||
this.itemEpisodes.forEach((item) => {
|
this.itemEpisodes.forEach((item) => {
|
||||||
if (item.enclosure) map[item.enclosure.url] = true
|
if (item.enclosure) map[item.enclosure.url.split('?')[0]] = true
|
||||||
})
|
})
|
||||||
return map
|
return map
|
||||||
},
|
},
|
||||||
episodesList() {
|
episodesList() {
|
||||||
return this.episodes.filter((episode) => {
|
return this.episodes.filter((episode) => {
|
||||||
if (!this.searchText) return true
|
if (!this.searchText) return true
|
||||||
return (
|
return (episode.title && episode.title.toLowerCase().includes(this.searchText)) || (episode.subtitle && episode.subtitle.toLowerCase().includes(this.searchText))
|
||||||
(episode.title && episode.title.toLowerCase().includes(this.searchText)) ||
|
|
||||||
(episode.subtitle && episode.subtitle.toLowerCase().includes(this.searchText))
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -136,14 +133,14 @@ export default {
|
|||||||
toggleSelectAll(val) {
|
toggleSelectAll(val) {
|
||||||
for (let i = 0; i < this.episodes.length; i++) {
|
for (let i = 0; i < this.episodes.length; i++) {
|
||||||
const episode = this.episodes[i]
|
const episode = this.episodes[i]
|
||||||
if (this.itemEpisodeMap[episode.enclosure.url]) this.selectedEpisodes[String(i)] = false
|
if (this.itemEpisodeMap[episode.enclosure.url?.split('?')[0]]) this.selectedEpisodes[String(i)] = false
|
||||||
else this.$set(this.selectedEpisodes, String(i), val)
|
else this.$set(this.selectedEpisodes, String(i), val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
checkSetIsSelectedAll() {
|
checkSetIsSelectedAll() {
|
||||||
for (let i = 0; i < this.episodes.length; i++) {
|
for (let i = 0; i < this.episodes.length; i++) {
|
||||||
const episode = this.episodes[i]
|
const episode = this.episodes[i]
|
||||||
if (!this.itemEpisodeMap[episode.enclosure.url] && !this.selectedEpisodes[String(i)]) {
|
if (!this.itemEpisodeMap[episode.enclosure.url?.split('?')[0]] && !this.selectedEpisodes[String(i)]) {
|
||||||
this.selectAll = false
|
this.selectAll = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -151,7 +148,7 @@ export default {
|
|||||||
this.selectAll = true
|
this.selectAll = true
|
||||||
},
|
},
|
||||||
toggleSelectEpisode(index, episode) {
|
toggleSelectEpisode(index, episode) {
|
||||||
if (this.itemEpisodeMap[episode.enclosure.url]) return
|
if (this.itemEpisodeMap[episode.enclosure.url?.split('?')[0]]) return
|
||||||
this.$set(this.selectedEpisodes, String(index), !this.selectedEpisodes[String(index)])
|
this.$set(this.selectedEpisodes, String(index), !this.selectedEpisodes[String(index)])
|
||||||
this.checkSetIsSelectedAll()
|
this.checkSetIsSelectedAll()
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,18 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="h-full w-full">
|
<div class="h-full w-full">
|
||||||
<div class="h-full flex items-center">
|
<div class="h-full flex items-center justify-center">
|
||||||
<div style="width: 100px; max-width: 100px" class="h-full flex items-center overflow-x-hidden justify-center">
|
<div style="width: 100px; max-width: 100px" class="h-full hidden sm:flex items-center overflow-x-hidden justify-center">
|
||||||
<span v-show="hasPrev" class="material-icons text-white text-opacity-50 hover:text-opacity-80 cursor-pointer text-6xl" @mousedown.prevent @click="prev">chevron_left</span>
|
<span v-if="hasPrev" class="material-icons text-white text-opacity-50 hover:text-opacity-80 cursor-pointer text-6xl" @mousedown.prevent @click="prev">chevron_left</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="frame" class="w-full" style="height: 650px">
|
<div id="frame" class="w-full" style="height: 80%">
|
||||||
<div id="viewer" class="border border-gray-100 bg-white shadow-md"></div>
|
<div id="viewer"></div>
|
||||||
|
|
||||||
<div class="py-4 flex justify-center" style="height: 50px">
|
|
||||||
<p>{{ progress }}%</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div style="width: 100px; max-width: 100px" class="h-full flex items-center justify-center overflow-x-hidden">
|
<div style="width: 100px; max-width: 100px" class="h-full hidden sm:flex items-center justify-center overflow-x-hidden">
|
||||||
<span v-show="hasNext" class="material-icons text-white text-opacity-50 hover:text-opacity-80 cursor-pointer text-6xl" @mousedown.prevent @click="next">chevron_right</span>
|
<span v-if="hasNext" class="material-icons text-white text-opacity-50 hover:text-opacity-80 cursor-pointer text-6xl" @mousedown.prevent @click="next">chevron_right</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -21,108 +17,252 @@
|
|||||||
<script>
|
<script>
|
||||||
import ePub from 'epubjs'
|
import ePub from 'epubjs'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {object} EpubReader
|
||||||
|
* @property {ePub.Book} book
|
||||||
|
* @property {ePub.Rendition} rendition
|
||||||
|
*/
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
url: String
|
url: String,
|
||||||
|
libraryItem: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
windowWidth: 0,
|
||||||
|
/** @type {ePub.Book} */
|
||||||
book: null,
|
book: null,
|
||||||
rendition: null,
|
/** @type {ePub.Rendition} */
|
||||||
chapters: [],
|
rendition: null
|
||||||
title: '',
|
|
||||||
author: '',
|
|
||||||
progress: 0,
|
|
||||||
hasNext: true,
|
|
||||||
hasPrev: false
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {},
|
computed: {
|
||||||
methods: {
|
/** @returns {string} */
|
||||||
changedChapter() {
|
libraryItemId() {
|
||||||
if (this.rendition) {
|
return this.libraryItem?.id
|
||||||
this.rendition.display(this.selectedChapter)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
hasPrev() {
|
||||||
|
return !this.rendition?.location?.atStart
|
||||||
|
},
|
||||||
|
hasNext() {
|
||||||
|
return !this.rendition?.location?.atEnd
|
||||||
|
},
|
||||||
|
/** @returns {Array<ePub.NavItem>} */
|
||||||
|
chapters() {
|
||||||
|
return this.book ? this.book.navigation.toc : []
|
||||||
|
},
|
||||||
|
userMediaProgress() {
|
||||||
|
if (!this.libraryItemId) return
|
||||||
|
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
|
||||||
|
},
|
||||||
|
localStorageLocationsKey() {
|
||||||
|
return `ebookLocations-${this.libraryItemId}`
|
||||||
|
},
|
||||||
|
readerWidth() {
|
||||||
|
if (this.windowWidth < 640) return this.windowWidth
|
||||||
|
return this.windowWidth - 200
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
prev() {
|
prev() {
|
||||||
if (this.rendition) {
|
return this.rendition?.prev()
|
||||||
this.rendition.prev()
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
next() {
|
next() {
|
||||||
if (this.rendition) {
|
return this.rendition?.next()
|
||||||
this.rendition.next()
|
},
|
||||||
|
goToChapter(href) {
|
||||||
|
return this.rendition?.display(href)
|
||||||
|
},
|
||||||
|
keyUp(e) {
|
||||||
|
const rtl = this.book.package.metadata.direction === 'rtl'
|
||||||
|
if ((e.keyCode || e.which) == 37) {
|
||||||
|
return rtl ? this.next() : this.prev()
|
||||||
|
} else if ((e.keyCode || e.which) == 39) {
|
||||||
|
return rtl ? this.prev() : this.next()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
keyUp() {
|
/**
|
||||||
if ((e.keyCode || e.which) == 37) {
|
* @param {object} payload
|
||||||
this.prev()
|
* @param {string} payload.ebookLocation - CFI of the current location
|
||||||
} else if ((e.keyCode || e.which) == 39) {
|
* @param {string} payload.ebookProgress - eBook Progress Percentage
|
||||||
this.next()
|
*/
|
||||||
|
updateProgress(payload) {
|
||||||
|
this.$axios.$patch(`/api/me/progress/${this.libraryItemId}`, payload).catch((error) => {
|
||||||
|
console.error('EpubReader.updateProgress failed:', error)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
getAllEbookLocationData() {
|
||||||
|
const locations = []
|
||||||
|
let totalSize = 0 // Total in bytes
|
||||||
|
|
||||||
|
for (const key in localStorage) {
|
||||||
|
if (!localStorage.hasOwnProperty(key) || !key.startsWith('ebookLocations-')) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ebookLocations = JSON.parse(localStorage[key])
|
||||||
|
if (!ebookLocations.locations) throw new Error('Invalid locations object')
|
||||||
|
|
||||||
|
ebookLocations.key = key
|
||||||
|
ebookLocations.size = (localStorage[key].length + key.length) * 2
|
||||||
|
locations.push(ebookLocations)
|
||||||
|
totalSize += ebookLocations.size
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse ebook locations', key, error)
|
||||||
|
localStorage.removeItem(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by oldest lastAccessed first
|
||||||
|
locations.sort((a, b) => a.lastAccessed - b.lastAccessed)
|
||||||
|
|
||||||
|
return {
|
||||||
|
locations,
|
||||||
|
totalSize
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/** @param {string} locationString */
|
||||||
|
checkSaveLocations(locationString) {
|
||||||
|
const maxSizeInBytes = 3000000 // Allow epub locations to take up to 3MB of space
|
||||||
|
const newLocationsSize = JSON.stringify({ lastAccessed: Date.now(), locations: locationString }).length * 2
|
||||||
|
|
||||||
|
// Too large overall
|
||||||
|
if (newLocationsSize > maxSizeInBytes) {
|
||||||
|
console.error('Epub locations are too large to store. Size =', newLocationsSize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const ebookLocationsData = this.getAllEbookLocationData()
|
||||||
|
|
||||||
|
let availableSpace = maxSizeInBytes - ebookLocationsData.totalSize
|
||||||
|
|
||||||
|
// Remove epub locations until there is room for locations
|
||||||
|
while (availableSpace < newLocationsSize && ebookLocationsData.locations.length) {
|
||||||
|
const oldestLocation = ebookLocationsData.locations.shift()
|
||||||
|
console.log(`Removing cached locations for epub "${oldestLocation.key}" taking up ${oldestLocation.size} bytes`)
|
||||||
|
availableSpace += oldestLocation.size
|
||||||
|
localStorage.removeItem(oldestLocation.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Cacheing epub locations with key "${this.localStorageLocationsKey}" taking up ${newLocationsSize} bytes`)
|
||||||
|
this.saveLocations(locationString)
|
||||||
|
},
|
||||||
|
/** @param {string} locationString */
|
||||||
|
saveLocations(locationString) {
|
||||||
|
localStorage.setItem(
|
||||||
|
this.localStorageLocationsKey,
|
||||||
|
JSON.stringify({
|
||||||
|
lastAccessed: Date.now(),
|
||||||
|
locations: locationString
|
||||||
|
})
|
||||||
|
)
|
||||||
|
},
|
||||||
|
loadLocations() {
|
||||||
|
const locationsObjString = localStorage.getItem(this.localStorageLocationsKey)
|
||||||
|
if (!locationsObjString) return null
|
||||||
|
|
||||||
|
const locationsObject = JSON.parse(locationsObjString)
|
||||||
|
|
||||||
|
// Remove invalid location objects
|
||||||
|
if (!locationsObject.locations) {
|
||||||
|
console.error('Invalid epub locations stored', this.localStorageLocationsKey)
|
||||||
|
localStorage.removeItem(this.localStorageLocationsKey)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update lastAccessed
|
||||||
|
this.saveLocations(locationsObject.locations)
|
||||||
|
|
||||||
|
return locationsObject.locations
|
||||||
|
},
|
||||||
|
/** @param {string} location - CFI of the new location */
|
||||||
|
relocated(location) {
|
||||||
|
if (this.userMediaProgress?.ebookLocation === location.start.cfi) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (location.end.percentage) {
|
||||||
|
this.updateProgress({
|
||||||
|
ebookLocation: location.start.cfi,
|
||||||
|
ebookProgress: location.end.percentage
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
this.updateProgress({
|
||||||
|
ebookLocation: location.start.cfi
|
||||||
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
initEpub() {
|
initEpub() {
|
||||||
// var book = ePub(this.url, {
|
/** @type {EpubReader} */
|
||||||
// requestHeaders: {
|
const reader = this
|
||||||
// Authorization: `Bearer ${this.userToken}`
|
|
||||||
// }
|
|
||||||
// })
|
|
||||||
var book = ePub(this.url)
|
|
||||||
this.book = book
|
|
||||||
|
|
||||||
this.rendition = book.renderTo('viewer', {
|
/** @type {ePub.Book} */
|
||||||
width: window.innerWidth - 200,
|
reader.book = new ePub(reader.url, {
|
||||||
height: 600,
|
width: this.readerWidth,
|
||||||
ignoreClass: 'annotator-hl',
|
height: window.innerHeight - 50
|
||||||
manager: 'continuous',
|
|
||||||
spread: 'always'
|
|
||||||
})
|
})
|
||||||
var displayed = this.rendition.display()
|
|
||||||
|
|
||||||
book.ready
|
/** @type {ePub.Rendition} */
|
||||||
.then(() => {
|
reader.rendition = reader.book.renderTo('viewer', {
|
||||||
console.log('Book ready')
|
width: this.readerWidth,
|
||||||
return book.locations.generate(1600)
|
height: window.innerHeight * 0.8
|
||||||
|
})
|
||||||
|
|
||||||
|
// load saved progress
|
||||||
|
reader.rendition.display(this.userMediaProgress?.ebookLocation || reader.book.locations.start)
|
||||||
|
|
||||||
|
// load style
|
||||||
|
reader.rendition.themes.default({ '*': { color: '#fff!important' } })
|
||||||
|
|
||||||
|
reader.book.ready.then(() => {
|
||||||
|
// set up event listeners
|
||||||
|
reader.rendition.on('relocated', reader.relocated)
|
||||||
|
reader.rendition.on('keydown', reader.keyUp)
|
||||||
|
|
||||||
|
let touchStart = 0
|
||||||
|
let touchEnd = 0
|
||||||
|
reader.rendition.on('touchstart', (event) => {
|
||||||
|
touchStart = event.changedTouches[0].screenX
|
||||||
})
|
})
|
||||||
.then((locations) => {
|
|
||||||
// console.log('Loaded locations', locations)
|
reader.rendition.on('touchend', (event) => {
|
||||||
// Wait for book to be rendered to get current page
|
touchEnd = event.changedTouches[0].screenX
|
||||||
displayed.then(() => {
|
const touchDistanceX = Math.abs(touchEnd - touchStart)
|
||||||
// Get the current CFI
|
if (touchStart < touchEnd && touchDistanceX > 120) {
|
||||||
var currentLocation = this.rendition.currentLocation()
|
this.next()
|
||||||
if (!currentLocation.start) {
|
}
|
||||||
console.error('No Start', currentLocation)
|
if (touchStart > touchEnd && touchDistanceX > 120) {
|
||||||
} else {
|
this.prev()
|
||||||
var currentPage = book.locations.percentageFromCfi(currentLocation.start.cfi)
|
}
|
||||||
// console.log('current page', currentPage)
|
})
|
||||||
}
|
|
||||||
|
// load ebook cfi locations
|
||||||
|
const savedLocations = this.loadLocations()
|
||||||
|
if (savedLocations) {
|
||||||
|
reader.book.locations.load(savedLocations)
|
||||||
|
} else {
|
||||||
|
reader.book.locations.generate().then(() => {
|
||||||
|
this.checkSaveLocations(reader.book.locations.save())
|
||||||
})
|
})
|
||||||
})
|
}
|
||||||
|
|
||||||
book.loaded.navigation.then((toc) => {
|
|
||||||
var _chapters = []
|
|
||||||
toc.forEach((chapter) => {
|
|
||||||
_chapters.push(chapter)
|
|
||||||
})
|
|
||||||
this.chapters = _chapters
|
|
||||||
})
|
|
||||||
book.loaded.metadata.then((metadata) => {
|
|
||||||
this.author = metadata.creator
|
|
||||||
this.title = metadata.title
|
|
||||||
})
|
|
||||||
|
|
||||||
this.rendition.on('keyup', this.keyUp)
|
|
||||||
|
|
||||||
this.rendition.on('relocated', (location) => {
|
|
||||||
var percent = book.locations.percentageFromCfi(location.start.cfi)
|
|
||||||
this.progress = Math.floor(percent * 100)
|
|
||||||
|
|
||||||
this.hasNext = !location.atEnd
|
|
||||||
this.hasPrev = !location.atStart
|
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
resize() {
|
||||||
|
this.windowWidth = window.innerWidth
|
||||||
|
this.rendition?.resize(this.readerWidth, window.innerHeight * 0.8)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
window.removeEventListener('resize', this.resize)
|
||||||
|
this.book?.destroy()
|
||||||
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
this.windowWidth = window.innerWidth
|
||||||
|
window.addEventListener('resize', this.resize)
|
||||||
this.initEpub()
|
this.initEpub()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="h-full w-full">
|
|
||||||
<div id="viewer" class="border border-gray-100 bg-white text-black shadow-md h-screen overflow-y-auto p-4" v-html="pageHtml"></div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
url: String,
|
|
||||||
libraryItem: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
bookInfo: {},
|
|
||||||
page: 0,
|
|
||||||
numPages: 0,
|
|
||||||
pageHtml: '',
|
|
||||||
progress: 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
libraryItemId() {
|
|
||||||
return this.libraryItem ? this.libraryItem.id : null
|
|
||||||
},
|
|
||||||
hasPrev() {
|
|
||||||
return this.page > 0
|
|
||||||
},
|
|
||||||
hasNext() {
|
|
||||||
return this.page < this.numPages - 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
prev() {
|
|
||||||
if (!this.hasPrev) return
|
|
||||||
this.page--
|
|
||||||
this.loadPage()
|
|
||||||
},
|
|
||||||
next() {
|
|
||||||
if (!this.hasNext) return
|
|
||||||
this.page++
|
|
||||||
this.loadPage()
|
|
||||||
},
|
|
||||||
keyUp() {
|
|
||||||
if ((e.keyCode || e.which) == 37) {
|
|
||||||
this.prev()
|
|
||||||
} else if ((e.keyCode || e.which) == 39) {
|
|
||||||
this.next()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
loadPage() {
|
|
||||||
this.$axios
|
|
||||||
.$get(`/api/ebooks/${this.libraryItemId}/page/${this.page}?dev=${this.$isDev ? 1 : 0}`)
|
|
||||||
.then((html) => {
|
|
||||||
this.pageHtml = html
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed to load page', error)
|
|
||||||
this.$toast.error('Failed to load page')
|
|
||||||
})
|
|
||||||
},
|
|
||||||
loadInfo() {
|
|
||||||
this.$axios
|
|
||||||
.$get(`/api/ebooks/${this.libraryItemId}/info?dev=${this.$isDev ? 1 : 0}`)
|
|
||||||
.then((bookInfo) => {
|
|
||||||
this.bookInfo = bookInfo
|
|
||||||
this.numPages = bookInfo.pages
|
|
||||||
this.page = 0
|
|
||||||
this.loadPage()
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed to load page', error)
|
|
||||||
this.$toast.error('Failed to load info')
|
|
||||||
})
|
|
||||||
},
|
|
||||||
initEpub() {
|
|
||||||
if (!this.libraryItemId) return
|
|
||||||
this.loadInfo()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
this.initEpub()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,24 +1,48 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="show" class="w-screen h-screen fixed top-0 left-0 z-60 bg-primary text-white">
|
<div v-if="show" class="w-screen h-screen fixed top-0 left-0 z-60 bg-primary text-white">
|
||||||
<div class="absolute top-4 right-4 z-20">
|
<div class="absolute top-4 left-4 z-20">
|
||||||
<span class="material-icons cursor-pointer text-4xl" @click="close">close</span>
|
<span v-if="hasToC && !tocOpen" ref="tocButton" class="material-icons cursor-pointer text-2xl" @click="toggleToC">menu</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="absolute top-4 left-4">
|
<div class="absolute top-4 left-1/2 transform -translate-x-1/2">
|
||||||
<h1 class="text-2xl mb-1">{{ abTitle }}</h1>
|
<h1 class="text-lg sm:text-xl md:text-2xl mb-1" style="line-height: 1.15; font-weight: 100">
|
||||||
<p v-if="abAuthor">by {{ abAuthor }}</p>
|
<span style="font-weight: 600">{{ abTitle }}</span>
|
||||||
|
<span v-if="abAuthor" style="display: inline"> – </span>
|
||||||
|
<span v-if="abAuthor">{{ abAuthor }}</span>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="absolute top-4 right-4 z-20">
|
||||||
|
<span v-if="hasSettings" class="material-icons cursor-pointer text-2xl" @click="openSettings">settings</span>
|
||||||
|
<span class="material-icons cursor-pointer text-2xl" @click="close">close</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<component v-if="componentName" ref="readerComponent" :is="componentName" :url="ebookUrl" :library-item="selectedLibraryItem" />
|
<component v-if="componentName" ref="readerComponent" :is="componentName" :url="ebookUrl" :library-item="selectedLibraryItem" />
|
||||||
|
|
||||||
<div class="absolute bottom-2 left-2">{{ ebookType }}</div>
|
<!-- TOC side nav -->
|
||||||
|
<div v-if="tocOpen" class="w-full h-full fixed inset-0 bg-black/20 z-20" @click.stop.prevent="toggleToC"></div>
|
||||||
|
<div v-if="hasToC" class="w-72 h-full max-h-full absolute top-0 left-0 bg-bg shadow-xl transition-transform z-30" :class="tocOpen ? 'translate-x-0' : '-translate-x-72'" @click.stop.prevent="toggleToC">
|
||||||
|
<div class="p-4 h-full overflow-hidden">
|
||||||
|
<p class="text-lg font-semibold mb-2">Table of Contents</p>
|
||||||
|
<div class="tocContent">
|
||||||
|
<ul>
|
||||||
|
<li v-for="chapter in chapters" :key="chapter.id" class="py-1">
|
||||||
|
<a :href="chapter.href" @click.prevent="$refs.readerComponent.goToChapter(chapter.href)">{{ chapter.label }}</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {
|
||||||
|
chapters: [],
|
||||||
|
tocOpen: false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
show(newVal) {
|
show(newVal) {
|
||||||
@@ -37,13 +61,18 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
componentName() {
|
componentName() {
|
||||||
if (this.ebookType === 'epub' && this.$isDev) return 'readers-epub-reader2'
|
if (this.ebookType === 'epub') return 'readers-epub-reader'
|
||||||
else if (this.ebookType === 'epub') return 'readers-epub-reader'
|
|
||||||
else if (this.ebookType === 'mobi') return 'readers-mobi-reader'
|
else if (this.ebookType === 'mobi') return 'readers-mobi-reader'
|
||||||
else if (this.ebookType === 'pdf') return 'readers-pdf-reader'
|
else if (this.ebookType === 'pdf') return 'readers-pdf-reader'
|
||||||
else if (this.ebookType === 'comic') return 'readers-comic-reader'
|
else if (this.ebookType === 'comic') return 'readers-comic-reader'
|
||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
|
hasToC() {
|
||||||
|
return this.isEpub
|
||||||
|
},
|
||||||
|
hasSettings() {
|
||||||
|
return false
|
||||||
|
},
|
||||||
abTitle() {
|
abTitle() {
|
||||||
return this.mediaMetadata.title
|
return this.mediaMetadata.title
|
||||||
},
|
},
|
||||||
@@ -111,18 +140,29 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
toggleToC() {
|
||||||
|
this.tocOpen = !this.tocOpen
|
||||||
|
this.chapters = this.$refs.readerComponent.chapters
|
||||||
|
},
|
||||||
|
openSettings() {},
|
||||||
hotkey(action) {
|
hotkey(action) {
|
||||||
console.log('Reader hotkey', action)
|
console.log('Reader hotkey', action)
|
||||||
if (!this.$refs.readerComponent) return
|
if (!this.$refs.readerComponent) return
|
||||||
|
|
||||||
if (action === this.$hotkeys.EReader.NEXT_PAGE) {
|
if (action === this.$hotkeys.EReader.NEXT_PAGE) {
|
||||||
if (this.$refs.readerComponent.next) this.$refs.readerComponent.next()
|
this.next()
|
||||||
} else if (action === this.$hotkeys.EReader.PREV_PAGE) {
|
} else if (action === this.$hotkeys.EReader.PREV_PAGE) {
|
||||||
if (this.$refs.readerComponent.prev) this.$refs.readerComponent.prev()
|
this.prev()
|
||||||
} else if (action === this.$hotkeys.EReader.CLOSE) {
|
} else if (action === this.$hotkeys.EReader.CLOSE) {
|
||||||
this.close()
|
this.close()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
next() {
|
||||||
|
if (this.$refs.readerComponent?.next) this.$refs.readerComponent.next()
|
||||||
|
},
|
||||||
|
prev() {
|
||||||
|
if (this.$refs.readerComponent?.prev) this.$refs.readerComponent.prev()
|
||||||
|
},
|
||||||
registerListeners() {
|
registerListeners() {
|
||||||
this.$eventBus.$on('reader-hotkey', this.hotkey)
|
this.$eventBus.$on('reader-hotkey', this.hotkey)
|
||||||
},
|
},
|
||||||
@@ -151,4 +191,8 @@ export default {
|
|||||||
.ebook-viewer {
|
.ebook-viewer {
|
||||||
height: calc(100% - 96px);
|
height: calc(100% - 96px);
|
||||||
}
|
}
|
||||||
|
.tocContent {
|
||||||
|
height: calc(100% - 36px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<input v-model="selected" :disabled="disabled" type="checkbox" class="opacity-0 absolute" :class="!disabled ? 'cursor-pointer' : ''" />
|
<input v-model="selected" :disabled="disabled" type="checkbox" class="opacity-0 absolute" :class="!disabled ? 'cursor-pointer' : ''" />
|
||||||
<svg v-if="selected" class="fill-current pointer-events-none" :class="svgClass" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg>
|
<svg v-if="selected" class="fill-current pointer-events-none" :class="svgClass" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="label" class="select-none text-gray-100" :class="labelClassname">{{ label }}</div>
|
<div v-if="label" class="select-none" :class="[labelClassname, disabled ? 'text-gray-400' : 'text-gray-100']">{{ label }}</div>
|
||||||
</label>
|
</label>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap mt-2 -mx-1">
|
<div class="flex flex-wrap mt-2 -mx-1">
|
||||||
<div class="w-full md:w-1/2 px-1">
|
<div class="w-full md:w-1/4 px-1">
|
||||||
<ui-text-input-with-label ref="publisherInput" v-model="details.publisher" :label="$strings.LabelPublisher" />
|
<ui-text-input-with-label ref="publisherInput" v-model="details.publisher" :label="$strings.LabelPublisher" />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-1/2 md:w-1/4 px-1 mt-2 md:mt-0">
|
<div class="w-1/2 md:w-1/4 px-1 mt-2 md:mt-0">
|
||||||
@@ -61,6 +61,11 @@
|
|||||||
<ui-checkbox v-model="details.explicit" :label="$strings.LabelExplicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
<ui-checkbox v-model="details.explicit" :label="$strings.LabelExplicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex-grow px-1 pt-6 mt-2 md:mt-0">
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<ui-checkbox v-model="details.abridged" :label="$strings.LabelAbridged" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -89,7 +94,8 @@ export default {
|
|||||||
isbn: null,
|
isbn: null,
|
||||||
asin: null,
|
asin: null,
|
||||||
genres: [],
|
genres: [],
|
||||||
explicit: false
|
explicit: false,
|
||||||
|
abridged: false
|
||||||
},
|
},
|
||||||
newTags: []
|
newTags: []
|
||||||
}
|
}
|
||||||
@@ -271,6 +277,7 @@ export default {
|
|||||||
this.details.isbn = this.mediaMetadata.isbn || null
|
this.details.isbn = this.mediaMetadata.isbn || null
|
||||||
this.details.asin = this.mediaMetadata.asin || null
|
this.details.asin = this.mediaMetadata.asin || null
|
||||||
this.details.explicit = !!this.mediaMetadata.explicit
|
this.details.explicit = !!this.mediaMetadata.explicit
|
||||||
|
this.details.abridged = !!this.mediaMetadata.abridged
|
||||||
this.newTags = [...(this.media.tags || [])]
|
this.newTags = [...(this.media.tags || [])]
|
||||||
},
|
},
|
||||||
submitForm() {
|
submitForm() {
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ export default {
|
|||||||
return `/library/${task.data.libraryId}/podcast/download-queue`
|
return `/library/${task.data.libraryId}/podcast/download-queue`
|
||||||
case 'encode-m4b':
|
case 'encode-m4b':
|
||||||
return `/audiobook/${task.data.libraryItemId}/manage?tool=m4b`
|
return `/audiobook/${task.data.libraryItemId}/manage?tool=m4b`
|
||||||
|
case 'embed-metadata':
|
||||||
|
return `/audiobook/${task.data.libraryItemId}/manage?tool=embed`
|
||||||
default:
|
default:
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -278,6 +278,13 @@ export default {
|
|||||||
console.log('Task finished', task)
|
console.log('Task finished', task)
|
||||||
this.$store.commit('tasks/addUpdateTask', task)
|
this.$store.commit('tasks/addUpdateTask', task)
|
||||||
},
|
},
|
||||||
|
metadataEmbedQueueUpdate(data) {
|
||||||
|
if (data.queued) {
|
||||||
|
this.$store.commit('tasks/addQueuedEmbedLId', data.libraryItemId)
|
||||||
|
} else {
|
||||||
|
this.$store.commit('tasks/removeQueuedEmbedLId', data.libraryItemId)
|
||||||
|
}
|
||||||
|
},
|
||||||
userUpdated(user) {
|
userUpdated(user) {
|
||||||
if (this.$store.state.user.user.id === user.id) {
|
if (this.$store.state.user.user.id === user.id) {
|
||||||
this.$store.commit('user/setUser', user)
|
this.$store.commit('user/setUser', user)
|
||||||
@@ -418,6 +425,7 @@ export default {
|
|||||||
// Task Listeners
|
// Task Listeners
|
||||||
this.socket.on('task_started', this.taskStarted)
|
this.socket.on('task_started', this.taskStarted)
|
||||||
this.socket.on('task_finished', this.taskFinished)
|
this.socket.on('task_finished', this.taskFinished)
|
||||||
|
this.socket.on('metadata_embed_queue_update', this.metadataEmbedQueueUpdate)
|
||||||
|
|
||||||
this.socket.on('backup_applied', this.backupApplied)
|
this.socket.on('backup_applied', this.backupApplied)
|
||||||
|
|
||||||
@@ -531,12 +539,18 @@ export default {
|
|||||||
},
|
},
|
||||||
loadTasks() {
|
loadTasks() {
|
||||||
this.$axios
|
this.$axios
|
||||||
.$get('/api/tasks')
|
.$get('/api/tasks?include=queue')
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
console.log('Fetched tasks', payload)
|
console.log('Fetched tasks', payload)
|
||||||
if (payload.tasks) {
|
if (payload.tasks) {
|
||||||
this.$store.commit('tasks/setTasks', payload.tasks)
|
this.$store.commit('tasks/setTasks', payload.tasks)
|
||||||
}
|
}
|
||||||
|
if (payload.queuedTaskData?.embedMetadata?.length) {
|
||||||
|
this.$store.commit(
|
||||||
|
'tasks/setQueuedEmbedLIds',
|
||||||
|
payload.queuedTaskData.embedMetadata.map((td) => td.libraryItemId)
|
||||||
|
)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to load tasks', error)
|
console.error('Failed to load tasks', error)
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.2.17",
|
"version": "2.2.18",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.2.17",
|
"version": "2.2.18",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/axios": "^5.13.6",
|
"@nuxtjs/axios": "^5.13.6",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.2.17",
|
"version": "2.2.18",
|
||||||
"description": "Self-hosted audiobook and podcast client",
|
"description": "Self-hosted audiobook and podcast client",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -62,14 +62,20 @@
|
|||||||
<div class="w-full h-px bg-white bg-opacity-10 my-8" />
|
<div class="w-full h-px bg-white bg-opacity-10 my-8" />
|
||||||
|
|
||||||
<div class="w-full max-w-4xl mx-auto">
|
<div class="w-full max-w-4xl mx-auto">
|
||||||
<div v-if="isEmbedTool" class="w-full flex justify-end items-center mb-4">
|
<!-- queued alert -->
|
||||||
<ui-checkbox v-if="!isFinished" v-model="shouldBackupAudioFiles" label="Backup audio files" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" @input="toggleBackupAudioFiles" />
|
<widgets-alert v-if="isMetadataEmbedQueued" type="warning" class="mb-4">
|
||||||
|
<p class="text-lg">Audiobook is queued for metadata embed ({{ queuedEmbedLIds.length }} in queue)</p>
|
||||||
|
</widgets-alert>
|
||||||
|
<!-- metadata embed action buttons -->
|
||||||
|
<div v-else-if="isEmbedTool" class="w-full flex justify-end items-center mb-4">
|
||||||
|
<ui-checkbox v-if="!isTaskFinished" v-model="shouldBackupAudioFiles" :disabled="processing" label="Backup audio files" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" @input="toggleBackupAudioFiles" />
|
||||||
|
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
|
|
||||||
<ui-btn v-if="!isFinished" color="primary" :loading="processing" @click.stop="embedClick">{{ $strings.ButtonStartMetadataEmbed }}</ui-btn>
|
<ui-btn v-if="!isTaskFinished" color="primary" :loading="processing" @click.stop="embedClick">{{ $strings.ButtonStartMetadataEmbed }}</ui-btn>
|
||||||
<p v-else class="text-success text-lg font-semibold">{{ $strings.MessageEmbedFinished }}</p>
|
<p v-else class="text-success text-lg font-semibold">{{ $strings.MessageEmbedFinished }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 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">
|
<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-icons text-xl">{{ showEncodeOptions ? 'check_box' : 'check_box_outline_blank' }}</span> <span class="pl-1">Use Advanced Options</span>
|
<span class="material-icons text-xl">{{ showEncodeOptions ? 'check_box' : 'check_box_outline_blank' }}</span> <span class="pl-1">Use Advanced Options</span>
|
||||||
@@ -83,6 +89,7 @@
|
|||||||
<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 -->
|
||||||
<div v-if="isM4BTool" class="overflow-hidden">
|
<div v-if="isM4BTool" class="overflow-hidden">
|
||||||
<transition name="slide">
|
<transition name="slide">
|
||||||
<div v-if="showEncodeOptions" class="mb-4 pb-4 border-b border-white/10">
|
<div v-if="showEncodeOptions" class="mb-4 pb-4 border-b border-white/10">
|
||||||
@@ -191,6 +198,7 @@ export default {
|
|||||||
cnosole.error('No audio files')
|
cnosole.error('No audio files')
|
||||||
return redirect('/?error=no audio files')
|
return redirect('/?error=no audio files')
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
libraryItem
|
libraryItem
|
||||||
}
|
}
|
||||||
@@ -200,7 +208,6 @@ export default {
|
|||||||
processing: false,
|
processing: false,
|
||||||
audiofilesEncoding: {},
|
audiofilesEncoding: {},
|
||||||
audiofilesFinished: {},
|
audiofilesFinished: {},
|
||||||
isFinished: false,
|
|
||||||
toneObject: null,
|
toneObject: null,
|
||||||
selectedTool: 'embed',
|
selectedTool: 'embed',
|
||||||
isCancelingEncode: false,
|
isCancelingEncode: false,
|
||||||
@@ -272,11 +279,28 @@ export default {
|
|||||||
isTaskFinished() {
|
isTaskFinished() {
|
||||||
return this.task && this.task.isFinished
|
return this.task && this.task.isFinished
|
||||||
},
|
},
|
||||||
|
tasks() {
|
||||||
|
return this.$store.getters['tasks/getTasksByLibraryItemId'](this.libraryItemId)
|
||||||
|
},
|
||||||
|
embedTask() {
|
||||||
|
return this.tasks.find((t) => t.action === 'embed-metadata')
|
||||||
|
},
|
||||||
|
encodeTask() {
|
||||||
|
return this.tasks.find((t) => t.action === 'encode-m4b')
|
||||||
|
},
|
||||||
task() {
|
task() {
|
||||||
return this.$store.getters['tasks/getTaskByLibraryItemId'](this.libraryItemId)
|
if (this.isEmbedTool) return this.embedTask
|
||||||
|
else if (this.isM4BTool) return this.encodeTask
|
||||||
|
return null
|
||||||
},
|
},
|
||||||
taskRunning() {
|
taskRunning() {
|
||||||
return this.task && !this.task.isFinished
|
return this.task && !this.task.isFinished
|
||||||
|
},
|
||||||
|
queuedEmbedLIds() {
|
||||||
|
return this.$store.state.tasks.queuedEmbedLIds || []
|
||||||
|
},
|
||||||
|
isMetadataEmbedQueued() {
|
||||||
|
return this.queuedEmbedLIds.some((lid) => lid === this.libraryItemId)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -322,7 +346,7 @@ export default {
|
|||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
|
var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
|
||||||
this.$toast.error(errorMsg)
|
this.$toast.error(errorMsg)
|
||||||
this.processing = true
|
this.processing = false
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
embedClick() {
|
embedClick() {
|
||||||
@@ -349,24 +373,6 @@ export default {
|
|||||||
this.processing = false
|
this.processing = false
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
audioMetadataStarted(data) {
|
|
||||||
console.log('audio metadata started', data)
|
|
||||||
if (data.libraryItemId !== this.libraryItemId) return
|
|
||||||
this.audiofilesFinished = {}
|
|
||||||
},
|
|
||||||
audioMetadataFinished(data) {
|
|
||||||
console.log('audio metadata finished', data)
|
|
||||||
if (data.libraryItemId !== this.libraryItemId) return
|
|
||||||
this.processing = false
|
|
||||||
this.audiofilesEncoding = {}
|
|
||||||
|
|
||||||
if (data.failed) {
|
|
||||||
this.$toast.error(data.error)
|
|
||||||
} else {
|
|
||||||
this.isFinished = true
|
|
||||||
this.$toast.success('Audio file metadata updated')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
audiofileMetadataStarted(data) {
|
audiofileMetadataStarted(data) {
|
||||||
if (data.libraryItemId !== this.libraryItemId) return
|
if (data.libraryItemId !== this.libraryItemId) return
|
||||||
this.$set(this.audiofilesEncoding, data.ino, true)
|
this.$set(this.audiofilesEncoding, data.ino, true)
|
||||||
@@ -412,14 +418,10 @@ export default {
|
|||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.init()
|
this.init()
|
||||||
this.$root.socket.on('audio_metadata_started', this.audioMetadataStarted)
|
|
||||||
this.$root.socket.on('audio_metadata_finished', this.audioMetadataFinished)
|
|
||||||
this.$root.socket.on('audiofile_metadata_started', this.audiofileMetadataStarted)
|
this.$root.socket.on('audiofile_metadata_started', this.audiofileMetadataStarted)
|
||||||
this.$root.socket.on('audiofile_metadata_finished', this.audiofileMetadataFinished)
|
this.$root.socket.on('audiofile_metadata_finished', this.audiofileMetadataFinished)
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
this.$root.socket.off('audio_metadata_started', this.audioMetadataStarted)
|
|
||||||
this.$root.socket.off('audio_metadata_finished', this.audioMetadataFinished)
|
|
||||||
this.$root.socket.off('audiofile_metadata_started', this.audiofileMetadataStarted)
|
this.$root.socket.off('audiofile_metadata_started', this.audiofileMetadataStarted)
|
||||||
this.$root.socket.off('audiofile_metadata_finished', this.audiofileMetadataFinished)
|
this.$root.socket.off('audiofile_metadata_finished', this.audiofileMetadataFinished)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,6 +124,14 @@
|
|||||||
{{ sizePretty }}
|
{{ sizePretty }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="isBook" class="flex py-0.5">
|
||||||
|
<div class="w-32">
|
||||||
|
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelAbridged }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ isAbridged ? 'Yes' : 'No' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hidden md:block flex-grow" />
|
<div class="hidden md:block flex-grow" />
|
||||||
</div>
|
</div>
|
||||||
@@ -156,7 +164,7 @@
|
|||||||
<div v-if="!isPodcast && progressPercent > 0" class="px-4 py-2 mt-4 bg-primary text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0" :class="resettingProgress ? 'opacity-25' : ''">
|
<div v-if="!isPodcast && progressPercent > 0" class="px-4 py-2 mt-4 bg-primary text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0" :class="resettingProgress ? 'opacity-25' : ''">
|
||||||
<p v-if="progressPercent < 1" class="leading-6">{{ $strings.LabelYourProgress }}: {{ Math.round(progressPercent * 100) }}%</p>
|
<p v-if="progressPercent < 1" class="leading-6">{{ $strings.LabelYourProgress }}: {{ Math.round(progressPercent * 100) }}%</p>
|
||||||
<p v-else class="text-xs">{{ $strings.LabelFinished }} {{ $formatDate(userProgressFinishedAt, dateFormat) }}</p>
|
<p v-else class="text-xs">{{ $strings.LabelFinished }} {{ $formatDate(userProgressFinishedAt, dateFormat) }}</p>
|
||||||
<p v-if="progressPercent < 1" class="text-gray-200 text-xs">{{ $getString('LabelTimeRemaining', [$elapsedPretty(userTimeRemaining)]) }}</p>
|
<p v-if="progressPercent < 1 && !useEBookProgress" class="text-gray-200 text-xs">{{ $getString('LabelTimeRemaining', [$elapsedPretty(userTimeRemaining)]) }}</p>
|
||||||
<p class="text-gray-400 text-xs pt-1">{{ $strings.LabelStarted }} {{ $formatDate(userProgressStartedAt, dateFormat) }}</p>
|
<p class="text-gray-400 text-xs pt-1">{{ $strings.LabelStarted }} {{ $formatDate(userProgressStartedAt, dateFormat) }}</p>
|
||||||
|
|
||||||
<div v-if="!resettingProgress" class="absolute -top-1.5 -right-1.5 p-1 w-5 h-5 rounded-full bg-bg hover:bg-error border border-primary flex items-center justify-center cursor-pointer" @click.stop="clearProgressClick">
|
<div v-if="!resettingProgress" class="absolute -top-1.5 -right-1.5 p-1 w-5 h-5 rounded-full bg-bg hover:bg-error border border-primary flex items-center justify-center cursor-pointer" @click.stop="clearProgressClick">
|
||||||
@@ -319,7 +327,10 @@ export default {
|
|||||||
return this.libraryItem.isInvalid
|
return this.libraryItem.isInvalid
|
||||||
},
|
},
|
||||||
isExplicit() {
|
isExplicit() {
|
||||||
return this.mediaMetadata.explicit || false
|
return !!this.mediaMetadata.explicit
|
||||||
|
},
|
||||||
|
isAbridged() {
|
||||||
|
return !!this.mediaMetadata.abridged
|
||||||
},
|
},
|
||||||
invalidAudioFiles() {
|
invalidAudioFiles() {
|
||||||
if (!this.isBook) return []
|
if (!this.isBook) return []
|
||||||
@@ -471,7 +482,12 @@ export default {
|
|||||||
const duration = this.userMediaProgress.duration || this.duration
|
const duration = this.userMediaProgress.duration || this.duration
|
||||||
return duration - this.userMediaProgress.currentTime
|
return duration - this.userMediaProgress.currentTime
|
||||||
},
|
},
|
||||||
|
useEBookProgress() {
|
||||||
|
if (!this.userMediaProgress || this.userMediaProgress.progress) return false
|
||||||
|
return this.userMediaProgress.ebookProgress > 0
|
||||||
|
},
|
||||||
progressPercent() {
|
progressPercent() {
|
||||||
|
if (this.useEBookProgress) return Math.max(Math.min(1, this.userMediaProgress.ebookProgress), 0)
|
||||||
return this.userMediaProgress ? Math.max(Math.min(1, this.userMediaProgress.progress), 0) : 0
|
return this.userMediaProgress ? Math.max(Math.min(1, this.userMediaProgress.progress), 0) : 0
|
||||||
},
|
},
|
||||||
userProgressStartedAt() {
|
userProgressStartedAt() {
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ export default class LocalAudioPlayer extends EventEmitter {
|
|||||||
|
|
||||||
setHlsStream() {
|
setHlsStream() {
|
||||||
this.trackStartTime = 0
|
this.trackStartTime = 0
|
||||||
|
this.currentTrackIndex = 0
|
||||||
|
|
||||||
// iOS does not support Media Elements but allows for HLS in the native audio player
|
// iOS does not support Media Elements but allows for HLS in the native audio player
|
||||||
if (!Hls.isSupported()) {
|
if (!Hls.isSupported()) {
|
||||||
|
|||||||
@@ -99,14 +99,14 @@ export const getters = {
|
|||||||
|
|
||||||
return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}&ts=${lastUpdate}`
|
return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}&ts=${lastUpdate}`
|
||||||
},
|
},
|
||||||
getLibraryItemCoverSrcById: (state, getters, rootState, rootGetters) => (libraryItemId, placeholder = null) => {
|
getLibraryItemCoverSrcById: (state, getters, rootState, rootGetters) => (libraryItemId, placeholder = null, raw = false) => {
|
||||||
if (!placeholder) placeholder = `${rootState.routerBasePath}/book_placeholder.jpg`
|
if (!placeholder) placeholder = `${rootState.routerBasePath}/book_placeholder.jpg`
|
||||||
if (!libraryItemId) return placeholder
|
if (!libraryItemId) return placeholder
|
||||||
var userToken = rootGetters['user/getToken']
|
var userToken = rootGetters['user/getToken']
|
||||||
if (process.env.NODE_ENV !== 'production') { // Testing
|
if (process.env.NODE_ENV !== 'production') { // Testing
|
||||||
return `http://localhost:3333${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}`
|
return `http://localhost:3333${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}${raw ? '&raw=1' : ''}`
|
||||||
}
|
}
|
||||||
return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}`
|
return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}${raw ? '&raw=1' : ''}`
|
||||||
},
|
},
|
||||||
getIsBatchSelectingMediaItems: (state) => {
|
getIsBatchSelectingMediaItems: (state) => {
|
||||||
return state.selectedMediaItems.length
|
return state.selectedMediaItems.length
|
||||||
|
|||||||
+22
-4
@@ -1,11 +1,12 @@
|
|||||||
|
|
||||||
export const state = () => ({
|
export const state = () => ({
|
||||||
tasks: []
|
tasks: [],
|
||||||
|
queuedEmbedLIds: []
|
||||||
})
|
})
|
||||||
|
|
||||||
export const getters = {
|
export const getters = {
|
||||||
getTaskByLibraryItemId: (state) => (libraryItemId) => {
|
getTasksByLibraryItemId: (state) => (libraryItemId) => {
|
||||||
return state.tasks.find(t => t.data && t.data.libraryItemId === libraryItemId)
|
return state.tasks.filter(t => t.data && t.data.libraryItemId === libraryItemId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,14 +19,31 @@ export const mutations = {
|
|||||||
state.tasks = tasks
|
state.tasks = tasks
|
||||||
},
|
},
|
||||||
addUpdateTask(state, task) {
|
addUpdateTask(state, task) {
|
||||||
var index = state.tasks.findIndex(d => d.id === task.id)
|
const index = state.tasks.findIndex(d => d.id === task.id)
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
state.tasks.splice(index, 1, task)
|
state.tasks.splice(index, 1, task)
|
||||||
} else {
|
} else {
|
||||||
|
// Remove duplicate (only have one library item per action)
|
||||||
|
state.tasks = state.tasks.filter(_task => {
|
||||||
|
if (!_task.data?.libraryItemId || _task.action !== task.action) return true
|
||||||
|
return _task.data.libraryItemId !== task.data.libraryItemId
|
||||||
|
})
|
||||||
|
|
||||||
state.tasks.push(task)
|
state.tasks.push(task)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
removeTask(state, task) {
|
removeTask(state, task) {
|
||||||
state.tasks = state.tasks.filter(d => d.id !== task.id)
|
state.tasks = state.tasks.filter(d => d.id !== task.id)
|
||||||
|
},
|
||||||
|
setQueuedEmbedLIds(state, libraryItemIds) {
|
||||||
|
state.queuedEmbedLIds = libraryItemIds
|
||||||
|
},
|
||||||
|
addQueuedEmbedLId(state, libraryItemId) {
|
||||||
|
if (!state.queuedEmbedLIds.some(lid => lid === libraryItemId)) {
|
||||||
|
state.queuedEmbedLIds.push(libraryItemId)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
removeQueuedEmbedLId(state, libraryItemId) {
|
||||||
|
state.queuedEmbedLIds = state.queuedEmbedLIds.filter(lid => lid !== libraryItemId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -155,6 +155,7 @@
|
|||||||
"HeaderUpdateLibrary": "Bibliothek aktualisieren",
|
"HeaderUpdateLibrary": "Bibliothek aktualisieren",
|
||||||
"HeaderUsers": "Benutzer",
|
"HeaderUsers": "Benutzer",
|
||||||
"HeaderYourStats": "Eigene Statistiken",
|
"HeaderYourStats": "Eigene Statistiken",
|
||||||
|
"LabelAbridged": "Abridged",
|
||||||
"LabelAccountType": "Kontoart",
|
"LabelAccountType": "Kontoart",
|
||||||
"LabelAccountTypeAdmin": "Admin",
|
"LabelAccountTypeAdmin": "Admin",
|
||||||
"LabelAccountTypeGuest": "Gast",
|
"LabelAccountTypeGuest": "Gast",
|
||||||
@@ -420,6 +421,7 @@
|
|||||||
"LabelTracksMultiTrack": "Mehrfachdatei",
|
"LabelTracksMultiTrack": "Mehrfachdatei",
|
||||||
"LabelTracksSingleTrack": "Einzeldatei",
|
"LabelTracksSingleTrack": "Einzeldatei",
|
||||||
"LabelType": "Typ",
|
"LabelType": "Typ",
|
||||||
|
"LabelUnabridged": "Unabridged",
|
||||||
"LabelUnknown": "Unbekannt",
|
"LabelUnknown": "Unbekannt",
|
||||||
"LabelUpdateCover": "Titelbild aktualisieren",
|
"LabelUpdateCover": "Titelbild aktualisieren",
|
||||||
"LabelUpdateCoverHelp": "Erlaube das Überschreiben bestehender Titelbilder für die ausgewählten Hörbücher wenn eine Übereinstimmung gefunden wird",
|
"LabelUpdateCoverHelp": "Erlaube das Überschreiben bestehender Titelbilder für die ausgewählten Hörbücher wenn eine Übereinstimmung gefunden wird",
|
||||||
|
|||||||
@@ -155,6 +155,7 @@
|
|||||||
"HeaderUpdateLibrary": "Update Library",
|
"HeaderUpdateLibrary": "Update Library",
|
||||||
"HeaderUsers": "Users",
|
"HeaderUsers": "Users",
|
||||||
"HeaderYourStats": "Your Stats",
|
"HeaderYourStats": "Your Stats",
|
||||||
|
"LabelAbridged": "Abridged",
|
||||||
"LabelAccountType": "Account Type",
|
"LabelAccountType": "Account Type",
|
||||||
"LabelAccountTypeAdmin": "Admin",
|
"LabelAccountTypeAdmin": "Admin",
|
||||||
"LabelAccountTypeGuest": "Guest",
|
"LabelAccountTypeGuest": "Guest",
|
||||||
@@ -420,6 +421,7 @@
|
|||||||
"LabelTracksMultiTrack": "Multi-track",
|
"LabelTracksMultiTrack": "Multi-track",
|
||||||
"LabelTracksSingleTrack": "Single-track",
|
"LabelTracksSingleTrack": "Single-track",
|
||||||
"LabelType": "Type",
|
"LabelType": "Type",
|
||||||
|
"LabelUnabridged": "Unabridged",
|
||||||
"LabelUnknown": "Unknown",
|
"LabelUnknown": "Unknown",
|
||||||
"LabelUpdateCover": "Update Cover",
|
"LabelUpdateCover": "Update Cover",
|
||||||
"LabelUpdateCoverHelp": "Allow overwriting of existing covers for the selected books when a match is located",
|
"LabelUpdateCoverHelp": "Allow overwriting of existing covers for the selected books when a match is located",
|
||||||
|
|||||||
@@ -155,6 +155,7 @@
|
|||||||
"HeaderUpdateLibrary": "Actualizar Biblioteca",
|
"HeaderUpdateLibrary": "Actualizar Biblioteca",
|
||||||
"HeaderUsers": "Usuarios",
|
"HeaderUsers": "Usuarios",
|
||||||
"HeaderYourStats": "Tus Estáticas",
|
"HeaderYourStats": "Tus Estáticas",
|
||||||
|
"LabelAbridged": "Abridged",
|
||||||
"LabelAccountType": "Tipo de Cuenta",
|
"LabelAccountType": "Tipo de Cuenta",
|
||||||
"LabelAccountTypeAdmin": "Administrador",
|
"LabelAccountTypeAdmin": "Administrador",
|
||||||
"LabelAccountTypeGuest": "Invitado",
|
"LabelAccountTypeGuest": "Invitado",
|
||||||
@@ -420,6 +421,7 @@
|
|||||||
"LabelTracksMultiTrack": "Multi-track",
|
"LabelTracksMultiTrack": "Multi-track",
|
||||||
"LabelTracksSingleTrack": "Single-track",
|
"LabelTracksSingleTrack": "Single-track",
|
||||||
"LabelType": "Tipo",
|
"LabelType": "Tipo",
|
||||||
|
"LabelUnabridged": "Unabridged",
|
||||||
"LabelUnknown": "Desconocido",
|
"LabelUnknown": "Desconocido",
|
||||||
"LabelUpdateCover": "Actualizar Portada",
|
"LabelUpdateCover": "Actualizar Portada",
|
||||||
"LabelUpdateCoverHelp": "Permitir sobrescribir portadas existentes de los libros seleccionados cuando sean encontrados.",
|
"LabelUpdateCoverHelp": "Permitir sobrescribir portadas existentes de los libros seleccionados cuando sean encontrados.",
|
||||||
|
|||||||
@@ -155,6 +155,7 @@
|
|||||||
"HeaderUpdateLibrary": "Mettre à jour la bibliothèque",
|
"HeaderUpdateLibrary": "Mettre à jour la bibliothèque",
|
||||||
"HeaderUsers": "Utilisateurs",
|
"HeaderUsers": "Utilisateurs",
|
||||||
"HeaderYourStats": "Vos statistiques",
|
"HeaderYourStats": "Vos statistiques",
|
||||||
|
"LabelAbridged": "Abridged",
|
||||||
"LabelAccountType": "Type de compte",
|
"LabelAccountType": "Type de compte",
|
||||||
"LabelAccountTypeAdmin": "Admin",
|
"LabelAccountTypeAdmin": "Admin",
|
||||||
"LabelAccountTypeGuest": "Invité",
|
"LabelAccountTypeGuest": "Invité",
|
||||||
@@ -420,6 +421,7 @@
|
|||||||
"LabelTracksMultiTrack": "Piste multiple",
|
"LabelTracksMultiTrack": "Piste multiple",
|
||||||
"LabelTracksSingleTrack": "Piste simple",
|
"LabelTracksSingleTrack": "Piste simple",
|
||||||
"LabelType": "Type",
|
"LabelType": "Type",
|
||||||
|
"LabelUnabridged": "Unabridged",
|
||||||
"LabelUnknown": "Inconnu",
|
"LabelUnknown": "Inconnu",
|
||||||
"LabelUpdateCover": "Mettre à jour la couverture",
|
"LabelUpdateCover": "Mettre à jour la couverture",
|
||||||
"LabelUpdateCoverHelp": "Autoriser la mise à jour de la couverture existante lorsqu’une correspondance est trouvée",
|
"LabelUpdateCoverHelp": "Autoriser la mise à jour de la couverture existante lorsqu’une correspondance est trouvée",
|
||||||
|
|||||||
@@ -155,6 +155,7 @@
|
|||||||
"HeaderUpdateLibrary": "Aktualiziraj biblioteku",
|
"HeaderUpdateLibrary": "Aktualiziraj biblioteku",
|
||||||
"HeaderUsers": "Korinici",
|
"HeaderUsers": "Korinici",
|
||||||
"HeaderYourStats": "Tvoja statistika",
|
"HeaderYourStats": "Tvoja statistika",
|
||||||
|
"LabelAbridged": "Abridged",
|
||||||
"LabelAccountType": "Vrsta korisničkog računa",
|
"LabelAccountType": "Vrsta korisničkog računa",
|
||||||
"LabelAccountTypeAdmin": "Administrator",
|
"LabelAccountTypeAdmin": "Administrator",
|
||||||
"LabelAccountTypeGuest": "Gost",
|
"LabelAccountTypeGuest": "Gost",
|
||||||
@@ -420,6 +421,7 @@
|
|||||||
"LabelTracksMultiTrack": "Multi-track",
|
"LabelTracksMultiTrack": "Multi-track",
|
||||||
"LabelTracksSingleTrack": "Single-track",
|
"LabelTracksSingleTrack": "Single-track",
|
||||||
"LabelType": "Tip",
|
"LabelType": "Tip",
|
||||||
|
"LabelUnabridged": "Unabridged",
|
||||||
"LabelUnknown": "Nepoznato",
|
"LabelUnknown": "Nepoznato",
|
||||||
"LabelUpdateCover": "Aktualiziraj Cover",
|
"LabelUpdateCover": "Aktualiziraj Cover",
|
||||||
"LabelUpdateCoverHelp": "Dozvoli postavljanje novog covera za odabrane knjige nakon što je match pronađen.",
|
"LabelUpdateCoverHelp": "Dozvoli postavljanje novog covera za odabrane knjige nakon što je match pronađen.",
|
||||||
|
|||||||
+23
-21
@@ -20,8 +20,8 @@
|
|||||||
"ButtonCreate": "Crea",
|
"ButtonCreate": "Crea",
|
||||||
"ButtonCreateBackup": "Crea un Backup",
|
"ButtonCreateBackup": "Crea un Backup",
|
||||||
"ButtonDelete": "Elimina",
|
"ButtonDelete": "Elimina",
|
||||||
"ButtonDownloadQueue": "Queue",
|
"ButtonDownloadQueue": "Coda",
|
||||||
"ButtonEdit": "Edit",
|
"ButtonEdit": "Modifica",
|
||||||
"ButtonEditChapters": "Modifica Capitoli",
|
"ButtonEditChapters": "Modifica Capitoli",
|
||||||
"ButtonEditPodcast": "Modifica Podcast",
|
"ButtonEditPodcast": "Modifica Podcast",
|
||||||
"ButtonForceReScan": "Forza Re-Scan",
|
"ButtonForceReScan": "Forza Re-Scan",
|
||||||
@@ -155,6 +155,7 @@
|
|||||||
"HeaderUpdateLibrary": "Aggiorna Libreria",
|
"HeaderUpdateLibrary": "Aggiorna Libreria",
|
||||||
"HeaderUsers": "Utenti",
|
"HeaderUsers": "Utenti",
|
||||||
"HeaderYourStats": "Statistiche Personali",
|
"HeaderYourStats": "Statistiche Personali",
|
||||||
|
"LabelAbridged": "Abridged",
|
||||||
"LabelAccountType": "Tipo di Account",
|
"LabelAccountType": "Tipo di Account",
|
||||||
"LabelAccountTypeAdmin": "Admin",
|
"LabelAccountTypeAdmin": "Admin",
|
||||||
"LabelAccountTypeGuest": "Ospite",
|
"LabelAccountTypeGuest": "Ospite",
|
||||||
@@ -167,7 +168,7 @@
|
|||||||
"LabelAddToPlaylistBatch": "Aggiungi {0} file alla Playlist",
|
"LabelAddToPlaylistBatch": "Aggiungi {0} file alla Playlist",
|
||||||
"LabelAll": "Tutti",
|
"LabelAll": "Tutti",
|
||||||
"LabelAllUsers": "Tutti gli Utenti",
|
"LabelAllUsers": "Tutti gli Utenti",
|
||||||
"LabelAlreadyInYourLibrary": "Already in your library",
|
"LabelAlreadyInYourLibrary": "Già esistente nella libreria",
|
||||||
"LabelAppend": "Appese",
|
"LabelAppend": "Appese",
|
||||||
"LabelAuthor": "Autore",
|
"LabelAuthor": "Autore",
|
||||||
"LabelAuthorFirstLast": "Autore (Per Nome)",
|
"LabelAuthorFirstLast": "Autore (Per Nome)",
|
||||||
@@ -238,7 +239,7 @@
|
|||||||
"LabelInProgress": "In Corso",
|
"LabelInProgress": "In Corso",
|
||||||
"LabelInterval": "Intervallo",
|
"LabelInterval": "Intervallo",
|
||||||
"LabelIntervalCustomDailyWeekly": "Personalizza giorni/settimane",
|
"LabelIntervalCustomDailyWeekly": "Personalizza giorni/settimane",
|
||||||
"LabelIntervalEvery12Hours": "EOgni 12 Ore",
|
"LabelIntervalEvery12Hours": "Ogni 12 Ore",
|
||||||
"LabelIntervalEvery15Minutes": "Ogni 15 Minuti",
|
"LabelIntervalEvery15Minutes": "Ogni 15 Minuti",
|
||||||
"LabelIntervalEvery2Hours": "Ogni 2 Ore",
|
"LabelIntervalEvery2Hours": "Ogni 2 Ore",
|
||||||
"LabelIntervalEvery30Minutes": "Ogni 30 Minuti",
|
"LabelIntervalEvery30Minutes": "Ogni 30 Minuti",
|
||||||
@@ -278,8 +279,8 @@
|
|||||||
"LabelNewestAuthors": "Autori Recenti",
|
"LabelNewestAuthors": "Autori Recenti",
|
||||||
"LabelNewestEpisodes": "Episodi Recenti",
|
"LabelNewestEpisodes": "Episodi Recenti",
|
||||||
"LabelNewPassword": "Nuova Password",
|
"LabelNewPassword": "Nuova Password",
|
||||||
"LabelNextBackupDate": "Next backup date",
|
"LabelNextBackupDate": "Data Prossimo Backup",
|
||||||
"LabelNextScheduledRun": "Next scheduled run",
|
"LabelNextScheduledRun": "Data prossima esecuzione schedulata",
|
||||||
"LabelNotes": "Note",
|
"LabelNotes": "Note",
|
||||||
"LabelNotFinished": "Da Completare",
|
"LabelNotFinished": "Da Completare",
|
||||||
"LabelNotificationAppriseURL": "Apprendi URL(s)",
|
"LabelNotificationAppriseURL": "Apprendi URL(s)",
|
||||||
@@ -295,7 +296,7 @@
|
|||||||
"LabelNumberOfBooks": "Numero di libri",
|
"LabelNumberOfBooks": "Numero di libri",
|
||||||
"LabelNumberOfEpisodes": "# degli episodi",
|
"LabelNumberOfEpisodes": "# degli episodi",
|
||||||
"LabelOpenRSSFeed": "Apri RSS Feed",
|
"LabelOpenRSSFeed": "Apri RSS Feed",
|
||||||
"LabelOverwrite": "Overwrite",
|
"LabelOverwrite": "Sovrascrivi",
|
||||||
"LabelPassword": "Password",
|
"LabelPassword": "Password",
|
||||||
"LabelPath": "Percorso",
|
"LabelPath": "Percorso",
|
||||||
"LabelPermissionsAccessAllLibraries": "Può accedere a tutte le librerie",
|
"LabelPermissionsAccessAllLibraries": "Può accedere a tutte le librerie",
|
||||||
@@ -310,9 +311,9 @@
|
|||||||
"LabelPlayMethod": "Metodo di riproduzione",
|
"LabelPlayMethod": "Metodo di riproduzione",
|
||||||
"LabelPodcast": "Podcast",
|
"LabelPodcast": "Podcast",
|
||||||
"LabelPodcasts": "Podcasts",
|
"LabelPodcasts": "Podcasts",
|
||||||
"LabelPodcastType": "Podcast Type",
|
"LabelPodcastType": "Timo di Podcast",
|
||||||
"LabelPrefixesToIgnore": "Suffissi da ignorare (specificando maiuscole e minuscole)",
|
"LabelPrefixesToIgnore": "Suffissi da ignorare (specificando maiuscole e minuscole)",
|
||||||
"LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
|
"LabelPreventIndexing": "Impedisci che il tuo feed venga indicizzato da iTunes e dalle directory dei podcast di Google",
|
||||||
"LabelProgress": "Cominciati",
|
"LabelProgress": "Cominciati",
|
||||||
"LabelProvider": "Provider",
|
"LabelProvider": "Provider",
|
||||||
"LabelPubDate": "Data Pubblicazione",
|
"LabelPubDate": "Data Pubblicazione",
|
||||||
@@ -320,14 +321,14 @@
|
|||||||
"LabelPublishYear": "Anno Pubblicazione",
|
"LabelPublishYear": "Anno Pubblicazione",
|
||||||
"LabelRecentlyAdded": "Aggiunti Recentemente",
|
"LabelRecentlyAdded": "Aggiunti Recentemente",
|
||||||
"LabelRecentSeries": "Serie Recenti",
|
"LabelRecentSeries": "Serie Recenti",
|
||||||
"LabelRecommended": "Recommended",
|
"LabelRecommended": "Raccomandati",
|
||||||
"LabelRegion": "Regione",
|
"LabelRegion": "Regione",
|
||||||
"LabelReleaseDate": "Data Release",
|
"LabelReleaseDate": "Data Release",
|
||||||
"LabelRemoveCover": "Remove cover",
|
"LabelRemoveCover": "Rimuovi cover",
|
||||||
"LabelRSSFeedCustomOwnerEmail": "Custom owner Email",
|
"LabelRSSFeedCustomOwnerEmail": "Email del proprietario personalizzato",
|
||||||
"LabelRSSFeedCustomOwnerName": "Custom owner Name",
|
"LabelRSSFeedCustomOwnerName": "Nome del proprietario personalizzato",
|
||||||
"LabelRSSFeedOpen": "RSS Feed Aperto",
|
"LabelRSSFeedOpen": "RSS Feed Aperto",
|
||||||
"LabelRSSFeedPreventIndexing": "Prevent Indexing",
|
"LabelRSSFeedPreventIndexing": "Impedisci l'indicizzazione",
|
||||||
"LabelRSSFeedSlug": "RSS Feed Slug",
|
"LabelRSSFeedSlug": "RSS Feed Slug",
|
||||||
"LabelRSSFeedURL": "RSS Feed URL",
|
"LabelRSSFeedURL": "RSS Feed URL",
|
||||||
"LabelSearchTerm": "Ricerca",
|
"LabelSearchTerm": "Ricerca",
|
||||||
@@ -372,7 +373,7 @@
|
|||||||
"LabelSettingsStoreCoversWithItemHelp": "Di default, le immagini di copertina sono salvate dentro /metadata/items, abilitando questa opzione le copertine saranno archiviate nella cartella della libreria corrispondente. Verrà conservato solo un file denominato \"cover\"",
|
"LabelSettingsStoreCoversWithItemHelp": "Di default, le immagini di copertina sono salvate dentro /metadata/items, abilitando questa opzione le copertine saranno archiviate nella cartella della libreria corrispondente. Verrà conservato solo un file denominato \"cover\"",
|
||||||
"LabelSettingsStoreMetadataWithItem": "Archivia i metadata con il file",
|
"LabelSettingsStoreMetadataWithItem": "Archivia i metadata con il file",
|
||||||
"LabelSettingsStoreMetadataWithItemHelp": "Di default, i metadati sono salvati dentro /metadata/items, abilitando questa opzione si memorizzeranno i metadata nella cartella della libreria. I file avranno estensione .abs",
|
"LabelSettingsStoreMetadataWithItemHelp": "Di default, i metadati sono salvati dentro /metadata/items, abilitando questa opzione si memorizzeranno i metadata nella cartella della libreria. I file avranno estensione .abs",
|
||||||
"LabelSettingsTimeFormat": "Time Format",
|
"LabelSettingsTimeFormat": "Formato Ora",
|
||||||
"LabelShowAll": "Mostra Tutto",
|
"LabelShowAll": "Mostra Tutto",
|
||||||
"LabelSize": "Dimensione",
|
"LabelSize": "Dimensione",
|
||||||
"LabelSleepTimer": "Sleep timer",
|
"LabelSleepTimer": "Sleep timer",
|
||||||
@@ -400,7 +401,7 @@
|
|||||||
"LabelTag": "Tag",
|
"LabelTag": "Tag",
|
||||||
"LabelTags": "Tags",
|
"LabelTags": "Tags",
|
||||||
"LabelTagsAccessibleToUser": "Tags permessi agli Utenti",
|
"LabelTagsAccessibleToUser": "Tags permessi agli Utenti",
|
||||||
"LabelTasks": "Tasks Running",
|
"LabelTasks": "Processi in esecuzione",
|
||||||
"LabelTimeListened": "Tempo di Ascolto",
|
"LabelTimeListened": "Tempo di Ascolto",
|
||||||
"LabelTimeListenedToday": "Tempo di Ascolto Oggi",
|
"LabelTimeListenedToday": "Tempo di Ascolto Oggi",
|
||||||
"LabelTimeRemaining": "{0} rimanente",
|
"LabelTimeRemaining": "{0} rimanente",
|
||||||
@@ -420,6 +421,7 @@
|
|||||||
"LabelTracksMultiTrack": "Multi-traccia",
|
"LabelTracksMultiTrack": "Multi-traccia",
|
||||||
"LabelTracksSingleTrack": "Traccia-singola",
|
"LabelTracksSingleTrack": "Traccia-singola",
|
||||||
"LabelType": "Tipo",
|
"LabelType": "Tipo",
|
||||||
|
"LabelUnabridged": "Unabridged",
|
||||||
"LabelUnknown": "Sconosciuto",
|
"LabelUnknown": "Sconosciuto",
|
||||||
"LabelUpdateCover": "Aggiornamento Cover",
|
"LabelUpdateCover": "Aggiornamento Cover",
|
||||||
"LabelUpdateCoverHelp": "Consenti la sovrascrittura delle copertine esistenti per i libri selezionati quando viene trovata una corrispondenza",
|
"LabelUpdateCoverHelp": "Consenti la sovrascrittura delle copertine esistenti per i libri selezionati quando viene trovata una corrispondenza",
|
||||||
@@ -502,8 +504,8 @@
|
|||||||
"MessageNoCollections": "Nessuna Raccolta",
|
"MessageNoCollections": "Nessuna Raccolta",
|
||||||
"MessageNoCoversFound": "Nessuna Cover Trovata",
|
"MessageNoCoversFound": "Nessuna Cover Trovata",
|
||||||
"MessageNoDescription": "Nessuna descrizione",
|
"MessageNoDescription": "Nessuna descrizione",
|
||||||
"MessageNoDownloadsInProgress": "No downloads currently in progress",
|
"MessageNoDownloadsInProgress": "Nessun download attualmente in corso",
|
||||||
"MessageNoDownloadsQueued": "No downloads queued",
|
"MessageNoDownloadsQueued": "Nessuna coda di download",
|
||||||
"MessageNoEpisodeMatchesFound": "Nessun episodio corrispondente trovato",
|
"MessageNoEpisodeMatchesFound": "Nessun episodio corrispondente trovato",
|
||||||
"MessageNoEpisodes": "Nessun Episodio",
|
"MessageNoEpisodes": "Nessun Episodio",
|
||||||
"MessageNoFoldersAvailable": "Nessuna Cartella disponibile",
|
"MessageNoFoldersAvailable": "Nessuna Cartella disponibile",
|
||||||
@@ -520,7 +522,7 @@
|
|||||||
"MessageNoSearchResultsFor": "Nessun risultato per \"{0}\"",
|
"MessageNoSearchResultsFor": "Nessun risultato per \"{0}\"",
|
||||||
"MessageNoSeries": "Nessuna Serie",
|
"MessageNoSeries": "Nessuna Serie",
|
||||||
"MessageNoTags": "No Tags",
|
"MessageNoTags": "No Tags",
|
||||||
"MessageNoTasksRunning": "No Tasks Running",
|
"MessageNoTasksRunning": "Nessun processo in esecuzione",
|
||||||
"MessageNotYetImplemented": "Non Ancora Implementato",
|
"MessageNotYetImplemented": "Non Ancora Implementato",
|
||||||
"MessageNoUpdateNecessary": "Nessun aggiornamento necessario",
|
"MessageNoUpdateNecessary": "Nessun aggiornamento necessario",
|
||||||
"MessageNoUpdatesWereNecessary": "Nessun aggiornamento necessario",
|
"MessageNoUpdatesWereNecessary": "Nessun aggiornamento necessario",
|
||||||
@@ -566,7 +568,7 @@
|
|||||||
"PlaceholderNewFolderPath": "Nuovo percorso Cartella",
|
"PlaceholderNewFolderPath": "Nuovo percorso Cartella",
|
||||||
"PlaceholderNewPlaylist": "Nome nuova playlist",
|
"PlaceholderNewPlaylist": "Nome nuova playlist",
|
||||||
"PlaceholderSearch": "Cerca..",
|
"PlaceholderSearch": "Cerca..",
|
||||||
"PlaceholderSearchEpisode": "Search episode..",
|
"PlaceholderSearchEpisode": "Cerca Episodio..",
|
||||||
"ToastAccountUpdateFailed": "Aggiornamento Account Fallito",
|
"ToastAccountUpdateFailed": "Aggiornamento Account Fallito",
|
||||||
"ToastAccountUpdateSuccess": "Account Aggiornato",
|
"ToastAccountUpdateSuccess": "Account Aggiornato",
|
||||||
"ToastAuthorImageRemoveFailed": "Rimozione immagine autore Fallita",
|
"ToastAuthorImageRemoveFailed": "Rimozione immagine autore Fallita",
|
||||||
@@ -627,7 +629,7 @@
|
|||||||
"ToastRemoveItemFromCollectionSuccess": "Oggetto rimosso dalla Raccolta",
|
"ToastRemoveItemFromCollectionSuccess": "Oggetto rimosso dalla Raccolta",
|
||||||
"ToastRSSFeedCloseFailed": "Errore chiusura RSS feed",
|
"ToastRSSFeedCloseFailed": "Errore chiusura RSS feed",
|
||||||
"ToastRSSFeedCloseSuccess": "RSS feed chiuso",
|
"ToastRSSFeedCloseSuccess": "RSS feed chiuso",
|
||||||
"ToastSeriesUpdateFailed": "Aggiornaemnto Serie Fallito",
|
"ToastSeriesUpdateFailed": "Aggiornaento Serie Fallito",
|
||||||
"ToastSeriesUpdateSuccess": "Serie Aggornate",
|
"ToastSeriesUpdateSuccess": "Serie Aggornate",
|
||||||
"ToastSessionDeleteFailed": "Errore eliminazione sessione",
|
"ToastSessionDeleteFailed": "Errore eliminazione sessione",
|
||||||
"ToastSessionDeleteSuccess": "Sessione cancellata",
|
"ToastSessionDeleteSuccess": "Sessione cancellata",
|
||||||
|
|||||||
@@ -155,6 +155,7 @@
|
|||||||
"HeaderUpdateLibrary": "Zaktualizuj bibliotekę",
|
"HeaderUpdateLibrary": "Zaktualizuj bibliotekę",
|
||||||
"HeaderUsers": "Użytkownicy",
|
"HeaderUsers": "Użytkownicy",
|
||||||
"HeaderYourStats": "Twoje statystyki",
|
"HeaderYourStats": "Twoje statystyki",
|
||||||
|
"LabelAbridged": "Abridged",
|
||||||
"LabelAccountType": "Typ konta",
|
"LabelAccountType": "Typ konta",
|
||||||
"LabelAccountTypeAdmin": "Administrator",
|
"LabelAccountTypeAdmin": "Administrator",
|
||||||
"LabelAccountTypeGuest": "Gość",
|
"LabelAccountTypeGuest": "Gość",
|
||||||
@@ -420,6 +421,7 @@
|
|||||||
"LabelTracksMultiTrack": "Multi-track",
|
"LabelTracksMultiTrack": "Multi-track",
|
||||||
"LabelTracksSingleTrack": "Single-track",
|
"LabelTracksSingleTrack": "Single-track",
|
||||||
"LabelType": "Typ",
|
"LabelType": "Typ",
|
||||||
|
"LabelUnabridged": "Unabridged",
|
||||||
"LabelUnknown": "Nieznany",
|
"LabelUnknown": "Nieznany",
|
||||||
"LabelUpdateCover": "Zaktalizuj odkładkę",
|
"LabelUpdateCover": "Zaktalizuj odkładkę",
|
||||||
"LabelUpdateCoverHelp": "Umożliwienie nadpisania istniejących okładek dla wybranych książek w przypadku znalezienia dopasowania",
|
"LabelUpdateCoverHelp": "Umożliwienie nadpisania istniejących okładek dla wybranych książek w przypadku znalezienia dopasowania",
|
||||||
|
|||||||
@@ -155,6 +155,7 @@
|
|||||||
"HeaderUpdateLibrary": "Обновить библиотеку",
|
"HeaderUpdateLibrary": "Обновить библиотеку",
|
||||||
"HeaderUsers": "Пользователи",
|
"HeaderUsers": "Пользователи",
|
||||||
"HeaderYourStats": "Ваша статистика",
|
"HeaderYourStats": "Ваша статистика",
|
||||||
|
"LabelAbridged": "Abridged",
|
||||||
"LabelAccountType": "Тип учетной записи",
|
"LabelAccountType": "Тип учетной записи",
|
||||||
"LabelAccountTypeAdmin": "Администратор",
|
"LabelAccountTypeAdmin": "Администратор",
|
||||||
"LabelAccountTypeGuest": "Гость",
|
"LabelAccountTypeGuest": "Гость",
|
||||||
@@ -420,6 +421,7 @@
|
|||||||
"LabelTracksMultiTrack": "Мультитрек",
|
"LabelTracksMultiTrack": "Мультитрек",
|
||||||
"LabelTracksSingleTrack": "Один трек",
|
"LabelTracksSingleTrack": "Один трек",
|
||||||
"LabelType": "Тип",
|
"LabelType": "Тип",
|
||||||
|
"LabelUnabridged": "Unabridged",
|
||||||
"LabelUnknown": "Неизвестно",
|
"LabelUnknown": "Неизвестно",
|
||||||
"LabelUpdateCover": "Обновить обложку",
|
"LabelUpdateCover": "Обновить обложку",
|
||||||
"LabelUpdateCoverHelp": "Позволяет перезаписывать существующие обложки для выбранных книг если будут найдены",
|
"LabelUpdateCoverHelp": "Позволяет перезаписывать существующие обложки для выбранных книг если будут найдены",
|
||||||
|
|||||||
@@ -155,6 +155,7 @@
|
|||||||
"HeaderUpdateLibrary": "更新媒体库",
|
"HeaderUpdateLibrary": "更新媒体库",
|
||||||
"HeaderUsers": "用户",
|
"HeaderUsers": "用户",
|
||||||
"HeaderYourStats": "你的统计数据",
|
"HeaderYourStats": "你的统计数据",
|
||||||
|
"LabelAbridged": "Abridged",
|
||||||
"LabelAccountType": "帐户类型",
|
"LabelAccountType": "帐户类型",
|
||||||
"LabelAccountTypeAdmin": "管理员",
|
"LabelAccountTypeAdmin": "管理员",
|
||||||
"LabelAccountTypeGuest": "来宾",
|
"LabelAccountTypeGuest": "来宾",
|
||||||
@@ -420,6 +421,7 @@
|
|||||||
"LabelTracksMultiTrack": "多轨",
|
"LabelTracksMultiTrack": "多轨",
|
||||||
"LabelTracksSingleTrack": "单轨",
|
"LabelTracksSingleTrack": "单轨",
|
||||||
"LabelType": "类型",
|
"LabelType": "类型",
|
||||||
|
"LabelUnabridged": "Unabridged",
|
||||||
"LabelUnknown": "未知",
|
"LabelUnknown": "未知",
|
||||||
"LabelUpdateCover": "更新封面",
|
"LabelUpdateCover": "更新封面",
|
||||||
"LabelUpdateCoverHelp": "找到匹配项时允许覆盖所选书籍存在的封面",
|
"LabelUpdateCoverHelp": "找到匹配项时允许覆盖所选书籍存在的封面",
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.2.17",
|
"version": "2.2.18",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.2.17",
|
"version": "2.2.18",
|
||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^0.27.2",
|
"axios": "^0.27.2",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.2.17",
|
"version": "2.2.18",
|
||||||
"description": "Self-hosted audiobook and podcast server",
|
"description": "Self-hosted audiobook and podcast server",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -256,13 +256,13 @@ class MeController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GET: api/me/items-in-progress
|
// GET: api/me/items-in-progress
|
||||||
async getAllLibraryItemsInProgress(req, res) {
|
getAllLibraryItemsInProgress(req, res) {
|
||||||
const limit = !isNaN(req.query.limit) ? Number(req.query.limit) || 25 : 25
|
const limit = !isNaN(req.query.limit) ? Number(req.query.limit) || 25 : 25
|
||||||
|
|
||||||
var itemsInProgress = []
|
let itemsInProgress = []
|
||||||
for (const mediaProgress of req.user.mediaProgress) {
|
for (const mediaProgress of req.user.mediaProgress) {
|
||||||
if (!mediaProgress.isFinished && mediaProgress.progress > 0) {
|
if (!mediaProgress.isFinished && (mediaProgress.progress > 0 || mediaProgress.ebookProgress > 0)) {
|
||||||
const libraryItem = await this.db.getLibraryItem(mediaProgress.libraryItemId)
|
const libraryItem = this.db.getLibraryItem(mediaProgress.libraryItemId)
|
||||||
if (libraryItem) {
|
if (libraryItem) {
|
||||||
if (mediaProgress.episodeId && libraryItem.mediaType === 'podcast') {
|
if (mediaProgress.episodeId && libraryItem.mediaType === 'podcast') {
|
||||||
const episode = libraryItem.media.episodes.find(ep => ep.id === mediaProgress.episodeId)
|
const episode = libraryItem.media.episodes.find(ep => ep.id === mediaProgress.episodeId)
|
||||||
|
|||||||
@@ -90,9 +90,19 @@ class MiscController {
|
|||||||
|
|
||||||
// GET: api/tasks
|
// GET: api/tasks
|
||||||
getTasks(req, res) {
|
getTasks(req, res) {
|
||||||
res.json({
|
const includeArray = (req.query.include || '').split(',')
|
||||||
|
|
||||||
|
const data = {
|
||||||
tasks: this.taskManager.tasks.map(t => t.toJSON())
|
tasks: this.taskManager.tasks.map(t => t.toJSON())
|
||||||
})
|
}
|
||||||
|
|
||||||
|
if (includeArray.includes('queue')) {
|
||||||
|
data.queuedTaskData = {
|
||||||
|
embedMetadata: this.audioMetadataManager.getQueuedTaskData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PATCH: api/settings (admin)
|
// PATCH: api/settings (admin)
|
||||||
|
|||||||
@@ -3,14 +3,8 @@ const Logger = require('../Logger')
|
|||||||
class ToolsController {
|
class ToolsController {
|
||||||
constructor() { }
|
constructor() { }
|
||||||
|
|
||||||
|
|
||||||
// POST: api/tools/item/:id/encode-m4b
|
// POST: api/tools/item/:id/encode-m4b
|
||||||
async encodeM4b(req, res) {
|
async encodeM4b(req, res) {
|
||||||
if (!req.user.isAdminOrUp) {
|
|
||||||
Logger.error('[MiscController] encodeM4b: Non-admin user attempting to make m4b', req.user)
|
|
||||||
return res.sendStatus(403)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.libraryItem.isMissing || req.libraryItem.isInvalid) {
|
if (req.libraryItem.isMissing || req.libraryItem.isInvalid) {
|
||||||
Logger.error(`[MiscController] encodeM4b: library item not found or invalid ${req.params.id}`)
|
Logger.error(`[MiscController] encodeM4b: library item not found or invalid ${req.params.id}`)
|
||||||
return res.status(404).send('Audiobook not found')
|
return res.status(404).send('Audiobook not found')
|
||||||
@@ -34,11 +28,6 @@ class ToolsController {
|
|||||||
|
|
||||||
// DELETE: api/tools/item/:id/encode-m4b
|
// DELETE: api/tools/item/:id/encode-m4b
|
||||||
async cancelM4bEncode(req, res) {
|
async cancelM4bEncode(req, res) {
|
||||||
if (!req.user.isAdminOrUp) {
|
|
||||||
Logger.error('[MiscController] cancelM4bEncode: Non-admin user attempting to cancel m4b encode', req.user)
|
|
||||||
return res.sendStatus(403)
|
|
||||||
}
|
|
||||||
|
|
||||||
const workerTask = this.abMergeManager.getPendingTaskByLibraryItemId(req.params.id)
|
const workerTask = this.abMergeManager.getPendingTaskByLibraryItemId(req.params.id)
|
||||||
if (!workerTask) return res.sendStatus(404)
|
if (!workerTask) return res.sendStatus(404)
|
||||||
|
|
||||||
@@ -49,14 +38,14 @@ class ToolsController {
|
|||||||
|
|
||||||
// POST: api/tools/item/:id/embed-metadata
|
// POST: api/tools/item/:id/embed-metadata
|
||||||
async embedAudioFileMetadata(req, res) {
|
async embedAudioFileMetadata(req, res) {
|
||||||
if (!req.user.isAdminOrUp) {
|
if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) {
|
||||||
Logger.error(`[LibraryItemController] Non-root user attempted to update audio metadata`, req.user)
|
Logger.error(`[ToolsController] Invalid library item`)
|
||||||
return res.sendStatus(403)
|
return res.sendStatus(500)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) {
|
if (this.audioMetadataManager.getIsLibraryItemQueuedOrProcessing(req.libraryItem.id)) {
|
||||||
Logger.error(`[LibraryItemController] Invalid library item`)
|
Logger.error(`[ToolsController] Library item (${req.libraryItem.id}) is already in queue or processing`)
|
||||||
return res.sendStatus(500)
|
return res.status(500).send('Library item is already in queue or processing')
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
@@ -67,16 +56,66 @@ class ToolsController {
|
|||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
itemMiddleware(req, res, next) {
|
// POST: api/tools/batch/embed-metadata
|
||||||
var item = this.db.libraryItems.find(li => li.id === req.params.id)
|
async batchEmbedMetadata(req, res) {
|
||||||
if (!item || !item.media) return res.sendStatus(404)
|
const libraryItemIds = req.body.libraryItemIds || []
|
||||||
|
if (!libraryItemIds.length) {
|
||||||
|
return res.status(400).send('Invalid request payload')
|
||||||
|
}
|
||||||
|
|
||||||
// Check user can access this library item
|
const libraryItems = []
|
||||||
if (!req.user.checkCanAccessLibraryItem(item)) {
|
for (const libraryItemId of libraryItemIds) {
|
||||||
|
const libraryItem = this.db.getLibraryItem(libraryItemId)
|
||||||
|
if (!libraryItem) {
|
||||||
|
Logger.error(`[ToolsController] Batch embed metadata library item (${libraryItemId}) not found`)
|
||||||
|
return res.sendStatus(404)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check user can access this library item
|
||||||
|
if (!req.user.checkCanAccessLibraryItem(libraryItem)) {
|
||||||
|
Logger.error(`[ToolsController] Batch embed metadata library item (${libraryItemId}) not accessible to user`, req.user)
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (libraryItem.isMissing || !libraryItem.hasAudioFiles || !libraryItem.isBook) {
|
||||||
|
Logger.error(`[ToolsController] Batch embed invalid library item (${libraryItemId})`)
|
||||||
|
return res.sendStatus(500)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.audioMetadataManager.getIsLibraryItemQueuedOrProcessing(libraryItemId)) {
|
||||||
|
Logger.error(`[ToolsController] Batch embed library item (${libraryItemId}) is already in queue or processing`)
|
||||||
|
return res.status(500).send('Library item is already in queue or processing')
|
||||||
|
}
|
||||||
|
|
||||||
|
libraryItems.push(libraryItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
forceEmbedChapters: req.query.forceEmbedChapters === '1',
|
||||||
|
backup: req.query.backup === '1'
|
||||||
|
}
|
||||||
|
this.audioMetadataManager.handleBatchEmbed(req.user, libraryItems, options)
|
||||||
|
res.sendStatus(200)
|
||||||
|
}
|
||||||
|
|
||||||
|
middleware(req, res, next) {
|
||||||
|
if (!req.user.isAdminOrUp) {
|
||||||
|
Logger.error(`[LibraryItemController] Non-root user attempted to access tools route`, req.user)
|
||||||
return res.sendStatus(403)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
|
|
||||||
req.libraryItem = item
|
if (req.params.id) {
|
||||||
|
const item = this.db.libraryItems.find(li => li.id === req.params.id)
|
||||||
|
if (!item || !item.media) return res.sendStatus(404)
|
||||||
|
|
||||||
|
// Check user can access this library item
|
||||||
|
if (!req.user.checkCanAccessLibraryItem(item)) {
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.libraryItem = item
|
||||||
|
}
|
||||||
|
|
||||||
next()
|
next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ class UserController {
|
|||||||
findAll(req, res) {
|
findAll(req, res) {
|
||||||
if (!req.user.isAdminOrUp) return res.sendStatus(403)
|
if (!req.user.isAdminOrUp) return res.sendStatus(403)
|
||||||
const hideRootToken = !req.user.isRoot
|
const hideRootToken = !req.user.isRoot
|
||||||
const users = this.db.users.map(u => this.userJsonWithItemProgressDetails(u, hideRootToken))
|
|
||||||
res.json({
|
res.json({
|
||||||
users: users
|
// Minimal toJSONForBrowser does not include mediaProgress and bookmarks
|
||||||
|
users: this.db.users.map(u => u.toJSONForBrowser(hideRootToken, true))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,18 +5,42 @@ const Logger = require('../Logger')
|
|||||||
|
|
||||||
const fs = require('../libs/fsExtra')
|
const fs = require('../libs/fsExtra')
|
||||||
|
|
||||||
const { secondsToTimestamp } = require('../utils/index')
|
|
||||||
const toneHelpers = require('../utils/toneHelpers')
|
const toneHelpers = require('../utils/toneHelpers')
|
||||||
const filePerms = require('../utils/filePerms')
|
|
||||||
|
const Task = require('../objects/Task')
|
||||||
|
|
||||||
class AudioMetadataMangaer {
|
class AudioMetadataMangaer {
|
||||||
constructor(db, taskManager) {
|
constructor(db, taskManager) {
|
||||||
this.db = db
|
this.db = db
|
||||||
this.taskManager = taskManager
|
this.taskManager = taskManager
|
||||||
|
|
||||||
|
this.itemsCacheDir = Path.join(global.MetadataPath, 'cache/items')
|
||||||
|
|
||||||
|
this.MAX_CONCURRENT_TASKS = 1
|
||||||
|
this.tasksRunning = []
|
||||||
|
this.tasksQueued = []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get queued task data
|
||||||
|
* @return {Array}
|
||||||
|
*/
|
||||||
|
getQueuedTaskData() {
|
||||||
|
return this.tasksQueued.map(t => t.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
getIsLibraryItemQueuedOrProcessing(libraryItemId) {
|
||||||
|
return this.tasksQueued.some(t => t.data.libraryItemId === libraryItemId) || this.tasksRunning.some(t => t.data.libraryItemId === libraryItemId)
|
||||||
}
|
}
|
||||||
|
|
||||||
getToneMetadataObjectForApi(libraryItem) {
|
getToneMetadataObjectForApi(libraryItem) {
|
||||||
return toneHelpers.getToneMetadataObject(libraryItem)
|
return toneHelpers.getToneMetadataObject(libraryItem, libraryItem.media.chapters, libraryItem.media.tracks.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
handleBatchEmbed(user, libraryItems, options = {}) {
|
||||||
|
libraryItems.forEach((li) => {
|
||||||
|
this.updateMetadataForItem(user, li, options)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateMetadataForItem(user, libraryItem, options = {}) {
|
async updateMetadataForItem(user, libraryItem, options = {}) {
|
||||||
@@ -25,99 +49,144 @@ class AudioMetadataMangaer {
|
|||||||
|
|
||||||
const audioFiles = libraryItem.media.includedAudioFiles
|
const audioFiles = libraryItem.media.includedAudioFiles
|
||||||
|
|
||||||
const itemAudioMetadataPayload = {
|
const task = new Task()
|
||||||
userId: user.id,
|
|
||||||
|
const itemCachePath = Path.join(this.itemsCacheDir, libraryItem.id)
|
||||||
|
|
||||||
|
// Only writing chapters for single file audiobooks
|
||||||
|
const chapters = (audioFiles.length == 1 || forceEmbedChapters) ? libraryItem.media.chapters.map(c => ({ ...c })) : null
|
||||||
|
|
||||||
|
// Create task
|
||||||
|
const taskData = {
|
||||||
libraryItemId: libraryItem.id,
|
libraryItemId: libraryItem.id,
|
||||||
startedAt: Date.now(),
|
libraryItemPath: libraryItem.path,
|
||||||
audioFiles: audioFiles.map(af => ({ index: af.index, ino: af.ino, filename: af.metadata.filename }))
|
userId: user.id,
|
||||||
|
audioFiles: audioFiles.map(af => (
|
||||||
|
{
|
||||||
|
index: af.index,
|
||||||
|
ino: af.ino,
|
||||||
|
filename: af.metadata.filename,
|
||||||
|
path: af.metadata.path,
|
||||||
|
cachePath: Path.join(itemCachePath, af.metadata.filename)
|
||||||
|
}
|
||||||
|
)),
|
||||||
|
coverPath: libraryItem.media.coverPath,
|
||||||
|
metadataObject: toneHelpers.getToneMetadataObject(libraryItem, chapters, audioFiles.length),
|
||||||
|
itemCachePath,
|
||||||
|
chapters,
|
||||||
|
options: {
|
||||||
|
forceEmbedChapters,
|
||||||
|
backupFiles
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
const taskDescription = `Embedding metadata in audiobook "${libraryItem.media.metadata.title}".`
|
||||||
|
task.setData('embed-metadata', 'Embedding Metadata', taskDescription, taskData)
|
||||||
|
|
||||||
SocketAuthority.emitter('audio_metadata_started', itemAudioMetadataPayload)
|
if (this.tasksRunning.length >= this.MAX_CONCURRENT_TASKS) {
|
||||||
|
Logger.info(`[AudioMetadataManager] Queueing embed metadata for audiobook "${libraryItem.media.metadata.title}"`)
|
||||||
|
SocketAuthority.adminEmitter('metadata_embed_queue_update', {
|
||||||
|
libraryItemId: libraryItem.id,
|
||||||
|
queued: true
|
||||||
|
})
|
||||||
|
this.tasksQueued.push(task)
|
||||||
|
} else {
|
||||||
|
this.runMetadataEmbed(task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure folder for backup files
|
async runMetadataEmbed(task) {
|
||||||
const itemCacheDir = Path.join(global.MetadataPath, `cache/items/${libraryItem.id}`)
|
this.tasksRunning.push(task)
|
||||||
|
this.taskManager.addTask(task)
|
||||||
|
|
||||||
|
Logger.info(`[AudioMetadataManager] Starting metadata embed task`, task.description)
|
||||||
|
|
||||||
|
// Ensure item cache dir exists
|
||||||
let cacheDirCreated = false
|
let cacheDirCreated = false
|
||||||
if (!await fs.pathExists(itemCacheDir)) {
|
if (!await fs.pathExists(task.data.itemCachePath)) {
|
||||||
await fs.mkdir(itemCacheDir)
|
await fs.mkdir(task.data.itemCachePath)
|
||||||
await filePerms.setDefault(itemCacheDir, true)
|
|
||||||
cacheDirCreated = true
|
cacheDirCreated = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write chapters file
|
// Create metadata json file
|
||||||
const toneJsonPath = Path.join(itemCacheDir, 'metadata.json')
|
const toneJsonPath = Path.join(task.data.itemCachePath, 'metadata.json')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const chapters = (audioFiles.length == 1 || forceEmbedChapters) ? libraryItem.media.chapters : null
|
await fs.writeFile(toneJsonPath, JSON.stringify({ meta: task.data.metadataObject }, null, 2))
|
||||||
await toneHelpers.writeToneMetadataJsonFile(libraryItem, chapters, toneJsonPath, audioFiles.length)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(`[AudioMetadataManager] Write metadata.json failed`, error)
|
Logger.error(`[AudioMetadataManager] Write metadata.json failed`, error)
|
||||||
|
task.setFailed('Failed to write metadata.json')
|
||||||
itemAudioMetadataPayload.failed = true
|
this.handleTaskFinished(task)
|
||||||
itemAudioMetadataPayload.error = 'Failed to write metadata.json'
|
|
||||||
SocketAuthority.emitter('audio_metadata_finished', itemAudioMetadataPayload)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = []
|
// Tag audio files
|
||||||
for (const af of audioFiles) {
|
for (const af of task.data.audioFiles) {
|
||||||
const result = await this.updateAudioFileMetadataWithTone(libraryItem, af, toneJsonPath, itemCacheDir, backupFiles)
|
SocketAuthority.adminEmitter('audiofile_metadata_started', {
|
||||||
results.push(result)
|
libraryItemId: task.data.libraryItemId,
|
||||||
|
ino: af.ino
|
||||||
|
})
|
||||||
|
|
||||||
|
// Backup audio file
|
||||||
|
if (task.data.options.backupFiles) {
|
||||||
|
try {
|
||||||
|
const backupFilePath = Path.join(task.data.itemCachePath, af.filename)
|
||||||
|
await fs.copy(af.path, backupFilePath)
|
||||||
|
Logger.debug(`[AudioMetadataManager] Backed up audio file at "${backupFilePath}"`)
|
||||||
|
} catch (err) {
|
||||||
|
Logger.error(`[AudioMetadataManager] Failed to backup audio file "${af.path}"`, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const _toneMetadataObject = {
|
||||||
|
'ToneJsonFile': toneJsonPath,
|
||||||
|
'TrackNumber': af.index,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (task.data.coverPath) {
|
||||||
|
_toneMetadataObject['CoverFile'] = task.data.coverPath
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await toneHelpers.tagAudioFile(af.path, _toneMetadataObject)
|
||||||
|
if (success) {
|
||||||
|
Logger.info(`[AudioMetadataManager] Successfully tagged audio file "${af.path}"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
SocketAuthority.adminEmitter('audiofile_metadata_finished', {
|
||||||
|
libraryItemId: task.data.libraryItemId,
|
||||||
|
ino: af.ino
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove temp cache file/folder if not backing up
|
// Remove temp cache file/folder if not backing up
|
||||||
if (!backupFiles) {
|
if (!task.data.options.backupFiles) {
|
||||||
// If cache dir was created from this then remove it
|
// If cache dir was created from this then remove it
|
||||||
if (cacheDirCreated) {
|
if (cacheDirCreated) {
|
||||||
await fs.remove(itemCacheDir)
|
await fs.remove(task.data.itemCachePath)
|
||||||
} else {
|
} else {
|
||||||
await fs.remove(toneJsonPath)
|
await fs.remove(toneJsonPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const elapsed = Date.now() - itemAudioMetadataPayload.startedAt
|
task.setFinished()
|
||||||
Logger.debug(`[AudioMetadataManager] Elapsed ${secondsToTimestamp(elapsed / 1000, true)}`)
|
this.handleTaskFinished(task)
|
||||||
itemAudioMetadataPayload.results = results
|
|
||||||
itemAudioMetadataPayload.elapsed = elapsed
|
|
||||||
itemAudioMetadataPayload.finishedAt = Date.now()
|
|
||||||
SocketAuthority.emitter('audio_metadata_finished', itemAudioMetadataPayload)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateAudioFileMetadataWithTone(libraryItem, audioFile, toneJsonPath, itemCacheDir, backupFiles) {
|
handleTaskFinished(task) {
|
||||||
const resultPayload = {
|
this.taskManager.taskFinished(task)
|
||||||
libraryItemId: libraryItem.id,
|
this.tasksRunning = this.tasksRunning.filter(t => t.id !== task.id)
|
||||||
index: audioFile.index,
|
|
||||||
ino: audioFile.ino,
|
|
||||||
filename: audioFile.metadata.filename
|
|
||||||
}
|
|
||||||
SocketAuthority.emitter('audiofile_metadata_started', resultPayload)
|
|
||||||
|
|
||||||
// Backup audio file
|
if (this.tasksRunning.length < this.MAX_CONCURRENT_TASKS && this.tasksQueued.length) {
|
||||||
if (backupFiles) {
|
Logger.info(`[AudioMetadataManager] Task finished and dequeueing next task. ${this.tasksQueued} tasks queued.`)
|
||||||
try {
|
const nextTask = this.tasksQueued.shift()
|
||||||
const backupFilePath = Path.join(itemCacheDir, audioFile.metadata.filename)
|
SocketAuthority.emitter('metadata_embed_queue_update', {
|
||||||
await fs.copy(audioFile.metadata.path, backupFilePath)
|
libraryItemId: nextTask.data.libraryItemId,
|
||||||
Logger.debug(`[AudioMetadataManager] Backed up audio file at "${backupFilePath}"`)
|
queued: false
|
||||||
} catch (err) {
|
})
|
||||||
Logger.error(`[AudioMetadataManager] Failed to backup audio file "${audioFile.metadata.path}"`, err)
|
this.runMetadataEmbed(nextTask)
|
||||||
}
|
} else if (this.tasksRunning.length > 0) {
|
||||||
|
Logger.debug(`[AudioMetadataManager] Task finished but not dequeueing. Currently running ${this.tasksRunning.length} tasks. ${this.tasksQueued.length} tasks queued.`)
|
||||||
|
} else {
|
||||||
|
Logger.debug(`[AudioMetadataManager] Task finished and no tasks remain in queue`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const _toneMetadataObject = {
|
|
||||||
'ToneJsonFile': toneJsonPath,
|
|
||||||
'TrackNumber': audioFile.index,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (libraryItem.media.coverPath) {
|
|
||||||
_toneMetadataObject['CoverFile'] = libraryItem.media.coverPath
|
|
||||||
}
|
|
||||||
|
|
||||||
resultPayload.success = await toneHelpers.tagAudioFile(audioFile.metadata.path, _toneMetadataObject)
|
|
||||||
if (resultPayload.success) {
|
|
||||||
Logger.info(`[AudioMetadataManager] Successfully tagged audio file "${audioFile.metadata.path}"`)
|
|
||||||
}
|
|
||||||
|
|
||||||
SocketAuthority.emitter('audiofile_metadata_finished', resultPayload)
|
|
||||||
return resultPayload
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
module.exports = AudioMetadataMangaer
|
module.exports = AudioMetadataMangaer
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ const SocketAuthority = require('../SocketAuthority')
|
|||||||
const fs = require('../libs/fsExtra')
|
const fs = require('../libs/fsExtra')
|
||||||
|
|
||||||
const { getPodcastFeed } = require('../utils/podcastUtils')
|
const { getPodcastFeed } = require('../utils/podcastUtils')
|
||||||
const { downloadFile, removeFile } = require('../utils/fileUtils')
|
const { removeFile, downloadFile } = require('../utils/fileUtils')
|
||||||
const filePerms = require('../utils/filePerms')
|
const filePerms = require('../utils/filePerms')
|
||||||
const { levenshteinDistance } = require('../utils/index')
|
const { levenshteinDistance } = require('../utils/index')
|
||||||
const opmlParser = require('../utils/parsers/parseOPML')
|
const opmlParser = require('../utils/parsers/parseOPML')
|
||||||
const prober = require('../utils/prober')
|
const prober = require('../utils/prober')
|
||||||
|
const ffmpegHelpers = require('../utils/ffmpegHelpers')
|
||||||
|
|
||||||
const LibraryFile = require('../objects/files/LibraryFile')
|
const LibraryFile = require('../objects/files/LibraryFile')
|
||||||
const PodcastEpisodeDownload = require('../objects/PodcastEpisodeDownload')
|
const PodcastEpisodeDownload = require('../objects/PodcastEpisodeDownload')
|
||||||
@@ -93,10 +94,22 @@ class PodcastManager {
|
|||||||
await filePerms.setDefault(this.currentDownload.libraryItem.path)
|
await filePerms.setDefault(this.currentDownload.libraryItem.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
let success = await downloadFile(this.currentDownload.url, this.currentDownload.targetPath).then(() => true).catch((error) => {
|
|
||||||
Logger.error(`[PodcastManager] Podcast Episode download failed`, error)
|
let success = false
|
||||||
return false
|
if (this.currentDownload.urlFileExtension === 'mp3') {
|
||||||
})
|
// Download episode and tag it
|
||||||
|
success = await ffmpegHelpers.downloadPodcastEpisode(this.currentDownload).catch((error) => {
|
||||||
|
Logger.error(`[PodcastManager] Podcast Episode download failed`, error)
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Download episode only
|
||||||
|
success = await downloadFile(this.currentDownload.url, this.currentDownload.targetPath).then(() => true).catch((error) => {
|
||||||
|
Logger.error(`[PodcastManager] Podcast Episode download failed`, error)
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
success = await this.scanAddPodcastEpisodeAudioFile()
|
success = await this.scanAddPodcastEpisodeAudioFile()
|
||||||
if (!success) {
|
if (!success) {
|
||||||
@@ -126,22 +139,22 @@ class PodcastManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async scanAddPodcastEpisodeAudioFile() {
|
async scanAddPodcastEpisodeAudioFile() {
|
||||||
var libraryFile = await this.getLibraryFile(this.currentDownload.targetPath, this.currentDownload.targetRelPath)
|
const libraryFile = await this.getLibraryFile(this.currentDownload.targetPath, this.currentDownload.targetRelPath)
|
||||||
|
|
||||||
// TODO: Set meta tags on new audio file
|
// TODO: Set meta tags on new audio file
|
||||||
|
|
||||||
var audioFile = await this.probeAudioFile(libraryFile)
|
const audioFile = await this.probeAudioFile(libraryFile)
|
||||||
if (!audioFile) {
|
if (!audioFile) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
var libraryItem = this.db.libraryItems.find(li => li.id === this.currentDownload.libraryItem.id)
|
const libraryItem = this.db.libraryItems.find(li => li.id === this.currentDownload.libraryItem.id)
|
||||||
if (!libraryItem) {
|
if (!libraryItem) {
|
||||||
Logger.error(`[PodcastManager] Podcast Episode finished but library item was not found ${this.currentDownload.libraryItem.id}`)
|
Logger.error(`[PodcastManager] Podcast Episode finished but library item was not found ${this.currentDownload.libraryItem.id}`)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
var podcastEpisode = this.currentDownload.podcastEpisode
|
const podcastEpisode = this.currentDownload.podcastEpisode
|
||||||
podcastEpisode.audioFile = audioFile
|
podcastEpisode.audioFile = audioFile
|
||||||
libraryItem.media.addPodcastEpisode(podcastEpisode)
|
libraryItem.media.addPodcastEpisode(podcastEpisode)
|
||||||
if (libraryItem.isInvalid) {
|
if (libraryItem.isInvalid) {
|
||||||
|
|||||||
@@ -41,8 +41,12 @@ class PodcastEpisodeDownload {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get urlFileExtension() {
|
||||||
|
const cleanUrl = this.url.split('?')[0] // Remove query string
|
||||||
|
return Path.extname(cleanUrl).substring(1).toLowerCase()
|
||||||
|
}
|
||||||
get fileExtension() {
|
get fileExtension() {
|
||||||
const extname = Path.extname(this.url).substring(1).toLowerCase()
|
const extname = this.urlFileExtension
|
||||||
if (globals.SupportedAudioTypes.includes(extname)) return extname
|
if (globals.SupportedAudioTypes.includes(extname)) return extname
|
||||||
return 'mp3'
|
return 'mp3'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const Path = require('path')
|
const Path = require('path')
|
||||||
|
const Logger = require('../../Logger')
|
||||||
const { getId, cleanStringForSearch } = require('../../utils/index')
|
const { getId, cleanStringForSearch } = require('../../utils/index')
|
||||||
const AudioFile = require('../files/AudioFile')
|
const AudioFile = require('../files/AudioFile')
|
||||||
const AudioTrack = require('../files/AudioTrack')
|
const AudioTrack = require('../files/AudioTrack')
|
||||||
@@ -106,6 +107,10 @@ class PodcastEpisode {
|
|||||||
get enclosureUrl() {
|
get enclosureUrl() {
|
||||||
return this.enclosure ? this.enclosure.url : null
|
return this.enclosure ? this.enclosure.url : null
|
||||||
}
|
}
|
||||||
|
get pubYear() {
|
||||||
|
if (!this.publishedAt) return null
|
||||||
|
return new Date(this.publishedAt).getFullYear()
|
||||||
|
}
|
||||||
|
|
||||||
setData(data, index = 1) {
|
setData(data, index = 1) {
|
||||||
this.id = getId('ep')
|
this.id = getId('ep')
|
||||||
@@ -128,6 +133,9 @@ class PodcastEpisode {
|
|||||||
this.audioFile = audioFile
|
this.audioFile = audioFile
|
||||||
this.title = Path.basename(audioFile.metadata.filename, Path.extname(audioFile.metadata.filename))
|
this.title = Path.basename(audioFile.metadata.filename, Path.extname(audioFile.metadata.filename))
|
||||||
this.index = index
|
this.index = index
|
||||||
|
|
||||||
|
this.setDataFromAudioMetaTags(audioFile.metaTags, true)
|
||||||
|
|
||||||
this.addedAt = Date.now()
|
this.addedAt = Date.now()
|
||||||
this.updatedAt = Date.now()
|
this.updatedAt = Date.now()
|
||||||
}
|
}
|
||||||
@@ -164,5 +172,76 @@ class PodcastEpisode {
|
|||||||
searchQuery(query) {
|
searchQuery(query) {
|
||||||
return cleanStringForSearch(this.title).includes(query)
|
return cleanStringForSearch(this.title).includes(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setDataFromAudioMetaTags(audioFileMetaTags, overrideExistingDetails = false) {
|
||||||
|
if (!audioFileMetaTags) return false
|
||||||
|
|
||||||
|
const MetadataMapArray = [
|
||||||
|
{
|
||||||
|
tag: 'tagComment',
|
||||||
|
altTag: 'tagSubtitle',
|
||||||
|
key: 'description'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: 'tagSubtitle',
|
||||||
|
key: 'subtitle'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: 'tagDate',
|
||||||
|
key: 'pubDate'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: 'tagDisc',
|
||||||
|
key: 'season',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: 'tagTrack',
|
||||||
|
altTag: 'tagSeriesPart',
|
||||||
|
key: 'episode'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: 'tagTitle',
|
||||||
|
key: 'title'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: 'tagEpisodeType',
|
||||||
|
key: 'episodeType'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
MetadataMapArray.forEach((mapping) => {
|
||||||
|
let value = audioFileMetaTags[mapping.tag]
|
||||||
|
let tagToUse = mapping.tag
|
||||||
|
if (!value && mapping.altTag) {
|
||||||
|
tagToUse = mapping.altTag
|
||||||
|
value = audioFileMetaTags[mapping.altTag]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value && typeof value === 'string') {
|
||||||
|
value = value.trim() // Trim whitespace
|
||||||
|
|
||||||
|
if (mapping.key === 'pubDate' && (!this.pubDate || overrideExistingDetails)) {
|
||||||
|
const pubJsDate = new Date(value)
|
||||||
|
if (pubJsDate && !isNaN(pubJsDate)) {
|
||||||
|
this.publishedAt = pubJsDate.valueOf()
|
||||||
|
this.pubDate = value
|
||||||
|
Logger.debug(`[PodcastEpisode] Mapping metadata to key ${tagToUse} => ${mapping.key}: ${this[mapping.key]}`)
|
||||||
|
} else {
|
||||||
|
Logger.warn(`[PodcastEpisode] Mapping pubDate with tag ${tagToUse} has invalid date "${value}"`)
|
||||||
|
}
|
||||||
|
} else if (mapping.key === 'episodeType' && (!this.episodeType || overrideExistingDetails)) {
|
||||||
|
if (['full', 'trailer', 'bonus'].includes(value)) {
|
||||||
|
this.episodeType = value
|
||||||
|
Logger.debug(`[PodcastEpisode] Mapping metadata to key ${tagToUse} => ${mapping.key}: ${this[mapping.key]}`)
|
||||||
|
} else {
|
||||||
|
Logger.warn(`[PodcastEpisode] Mapping episodeType with invalid value "${value}". Must be one of [full, trailer, bonus].`)
|
||||||
|
}
|
||||||
|
} else if (!this[mapping.key] || overrideExistingDetails) {
|
||||||
|
this[mapping.key] = value
|
||||||
|
Logger.debug(`[PodcastEpisode] Mapping metadata to key ${tagToUse} => ${mapping.key}: ${this[mapping.key]}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
module.exports = PodcastEpisode
|
module.exports = PodcastEpisode
|
||||||
|
|||||||
@@ -175,6 +175,10 @@ class Podcast {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
findEpisodeWithInode(inode) {
|
||||||
|
return this.episodes.find(ep => ep.audioFile.ino === inode)
|
||||||
|
}
|
||||||
|
|
||||||
setData(mediaData) {
|
setData(mediaData) {
|
||||||
this.metadata = new PodcastMetadata()
|
this.metadata = new PodcastMetadata()
|
||||||
if (mediaData.metadata) {
|
if (mediaData.metadata) {
|
||||||
@@ -315,5 +319,13 @@ class Podcast {
|
|||||||
getEpisode(episodeId) {
|
getEpisode(episodeId) {
|
||||||
return this.episodes.find(ep => ep.id == episodeId)
|
return this.episodes.find(ep => ep.id == episodeId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Audio file metadata tags map to podcast details
|
||||||
|
setMetadataFromAudioFile(overrideExistingDetails = false) {
|
||||||
|
if (!this.episodes.length) return false
|
||||||
|
const audioFile = this.episodes[0].audioFile
|
||||||
|
if (!audioFile?.metaTags) return false
|
||||||
|
return this.metadata.setDataFromAudioMetaTags(audioFile.metaTags, overrideExistingDetails)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
module.exports = Podcast
|
module.exports = Podcast
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
class AudioMetaTags {
|
class AudioMetaTags {
|
||||||
constructor(metadata) {
|
constructor(metadata) {
|
||||||
this.tagAlbum = null
|
this.tagAlbum = null
|
||||||
|
this.tagAlbumSort = null
|
||||||
this.tagArtist = null
|
this.tagArtist = null
|
||||||
|
this.tagArtistSort = null
|
||||||
this.tagGenre = null
|
this.tagGenre = null
|
||||||
this.tagTitle = null
|
this.tagTitle = null
|
||||||
|
this.tagTitleSort = null
|
||||||
this.tagSeries = null
|
this.tagSeries = null
|
||||||
this.tagSeriesPart = null
|
this.tagSeriesPart = null
|
||||||
this.tagTrack = null
|
this.tagTrack = null
|
||||||
@@ -20,6 +23,9 @@ class AudioMetaTags {
|
|||||||
this.tagIsbn = null
|
this.tagIsbn = null
|
||||||
this.tagLanguage = null
|
this.tagLanguage = null
|
||||||
this.tagASIN = null
|
this.tagASIN = null
|
||||||
|
this.tagItunesId = null
|
||||||
|
this.tagPodcastType = null
|
||||||
|
this.tagEpisodeType = null
|
||||||
this.tagOverdriveMediaMarker = null
|
this.tagOverdriveMediaMarker = null
|
||||||
this.tagOriginalYear = null
|
this.tagOriginalYear = null
|
||||||
this.tagReleaseCountry = null
|
this.tagReleaseCountry = null
|
||||||
@@ -94,9 +100,12 @@ class AudioMetaTags {
|
|||||||
|
|
||||||
construct(metadata) {
|
construct(metadata) {
|
||||||
this.tagAlbum = metadata.tagAlbum || null
|
this.tagAlbum = metadata.tagAlbum || null
|
||||||
|
this.tagAlbumSort = metadata.tagAlbumSort || null
|
||||||
this.tagArtist = metadata.tagArtist || null
|
this.tagArtist = metadata.tagArtist || null
|
||||||
|
this.tagArtistSort = metadata.tagArtistSort || null
|
||||||
this.tagGenre = metadata.tagGenre || null
|
this.tagGenre = metadata.tagGenre || null
|
||||||
this.tagTitle = metadata.tagTitle || null
|
this.tagTitle = metadata.tagTitle || null
|
||||||
|
this.tagTitleSort = metadata.tagTitleSort || null
|
||||||
this.tagSeries = metadata.tagSeries || null
|
this.tagSeries = metadata.tagSeries || null
|
||||||
this.tagSeriesPart = metadata.tagSeriesPart || null
|
this.tagSeriesPart = metadata.tagSeriesPart || null
|
||||||
this.tagTrack = metadata.tagTrack || null
|
this.tagTrack = metadata.tagTrack || null
|
||||||
@@ -113,6 +122,9 @@ class AudioMetaTags {
|
|||||||
this.tagIsbn = metadata.tagIsbn || null
|
this.tagIsbn = metadata.tagIsbn || null
|
||||||
this.tagLanguage = metadata.tagLanguage || null
|
this.tagLanguage = metadata.tagLanguage || null
|
||||||
this.tagASIN = metadata.tagASIN || null
|
this.tagASIN = metadata.tagASIN || null
|
||||||
|
this.tagItunesId = metadata.tagItunesId || null
|
||||||
|
this.tagPodcastType = metadata.tagPodcastType || null
|
||||||
|
this.tagEpisodeType = metadata.tagEpisodeType || null
|
||||||
this.tagOverdriveMediaMarker = metadata.tagOverdriveMediaMarker || null
|
this.tagOverdriveMediaMarker = metadata.tagOverdriveMediaMarker || null
|
||||||
this.tagOriginalYear = metadata.tagOriginalYear || null
|
this.tagOriginalYear = metadata.tagOriginalYear || null
|
||||||
this.tagReleaseCountry = metadata.tagReleaseCountry || null
|
this.tagReleaseCountry = metadata.tagReleaseCountry || null
|
||||||
@@ -128,9 +140,12 @@ class AudioMetaTags {
|
|||||||
// Data parsed in prober.js
|
// Data parsed in prober.js
|
||||||
setData(payload) {
|
setData(payload) {
|
||||||
this.tagAlbum = payload.file_tag_album || null
|
this.tagAlbum = payload.file_tag_album || null
|
||||||
|
this.tagAlbumSort = payload.file_tag_albumsort || null
|
||||||
this.tagArtist = payload.file_tag_artist || null
|
this.tagArtist = payload.file_tag_artist || null
|
||||||
|
this.tagArtistSort = payload.file_tag_artistsort || null
|
||||||
this.tagGenre = payload.file_tag_genre || null
|
this.tagGenre = payload.file_tag_genre || null
|
||||||
this.tagTitle = payload.file_tag_title || null
|
this.tagTitle = payload.file_tag_title || null
|
||||||
|
this.tagTitleSort = payload.file_tag_titlesort || null
|
||||||
this.tagSeries = payload.file_tag_series || null
|
this.tagSeries = payload.file_tag_series || null
|
||||||
this.tagSeriesPart = payload.file_tag_seriespart || null
|
this.tagSeriesPart = payload.file_tag_seriespart || null
|
||||||
this.tagTrack = payload.file_tag_track || null
|
this.tagTrack = payload.file_tag_track || null
|
||||||
@@ -147,6 +162,9 @@ class AudioMetaTags {
|
|||||||
this.tagIsbn = payload.file_tag_isbn || null
|
this.tagIsbn = payload.file_tag_isbn || null
|
||||||
this.tagLanguage = payload.file_tag_language || null
|
this.tagLanguage = payload.file_tag_language || null
|
||||||
this.tagASIN = payload.file_tag_asin || null
|
this.tagASIN = payload.file_tag_asin || null
|
||||||
|
this.tagItunesId = payload.file_tag_itunesid || null
|
||||||
|
this.tagPodcastType = payload.file_tag_podcasttype || null
|
||||||
|
this.tagEpisodeType = payload.file_tag_episodetype || null
|
||||||
this.tagOverdriveMediaMarker = payload.file_tag_overdrive_media_marker || null
|
this.tagOverdriveMediaMarker = payload.file_tag_overdrive_media_marker || null
|
||||||
this.tagOriginalYear = payload.file_tag_originalyear || null
|
this.tagOriginalYear = payload.file_tag_originalyear || null
|
||||||
this.tagReleaseCountry = payload.file_tag_releasecountry || null
|
this.tagReleaseCountry = payload.file_tag_releasecountry || null
|
||||||
@@ -166,9 +184,12 @@ class AudioMetaTags {
|
|||||||
updateData(payload) {
|
updateData(payload) {
|
||||||
const dataMap = {
|
const dataMap = {
|
||||||
tagAlbum: payload.file_tag_album || null,
|
tagAlbum: payload.file_tag_album || null,
|
||||||
|
tagAlbumSort: payload.file_tag_albumsort || null,
|
||||||
tagArtist: payload.file_tag_artist || null,
|
tagArtist: payload.file_tag_artist || null,
|
||||||
|
tagArtistSort: payload.file_tag_artistsort || null,
|
||||||
tagGenre: payload.file_tag_genre || null,
|
tagGenre: payload.file_tag_genre || null,
|
||||||
tagTitle: payload.file_tag_title || null,
|
tagTitle: payload.file_tag_title || null,
|
||||||
|
tagTitleSort: payload.file_tag_titlesort || null,
|
||||||
tagSeries: payload.file_tag_series || null,
|
tagSeries: payload.file_tag_series || null,
|
||||||
tagSeriesPart: payload.file_tag_seriespart || null,
|
tagSeriesPart: payload.file_tag_seriespart || null,
|
||||||
tagTrack: payload.file_tag_track || null,
|
tagTrack: payload.file_tag_track || null,
|
||||||
@@ -185,6 +206,9 @@ class AudioMetaTags {
|
|||||||
tagIsbn: payload.file_tag_isbn || null,
|
tagIsbn: payload.file_tag_isbn || null,
|
||||||
tagLanguage: payload.file_tag_language || null,
|
tagLanguage: payload.file_tag_language || null,
|
||||||
tagASIN: payload.file_tag_asin || null,
|
tagASIN: payload.file_tag_asin || null,
|
||||||
|
tagItunesId: payload.file_tag_itunesid || null,
|
||||||
|
tagPodcastType: payload.file_tag_podcasttype || null,
|
||||||
|
tagEpisodeType: payload.file_tag_episodetype || null,
|
||||||
tagOverdriveMediaMarker: payload.file_tag_overdrive_media_marker || null,
|
tagOverdriveMediaMarker: payload.file_tag_overdrive_media_marker || null,
|
||||||
tagOriginalYear: payload.file_tag_originalyear || null,
|
tagOriginalYear: payload.file_tag_originalyear || null,
|
||||||
tagReleaseCountry: payload.file_tag_releasecountry || null,
|
tagReleaseCountry: payload.file_tag_releasecountry || null,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class BookMetadata {
|
|||||||
this.asin = null
|
this.asin = null
|
||||||
this.language = null
|
this.language = null
|
||||||
this.explicit = false
|
this.explicit = false
|
||||||
|
this.abridged = false
|
||||||
|
|
||||||
if (metadata) {
|
if (metadata) {
|
||||||
this.construct(metadata)
|
this.construct(metadata)
|
||||||
@@ -38,6 +39,7 @@ class BookMetadata {
|
|||||||
this.asin = metadata.asin
|
this.asin = metadata.asin
|
||||||
this.language = metadata.language
|
this.language = metadata.language
|
||||||
this.explicit = !!metadata.explicit
|
this.explicit = !!metadata.explicit
|
||||||
|
this.abridged = !!metadata.abridged
|
||||||
}
|
}
|
||||||
|
|
||||||
toJSON() {
|
toJSON() {
|
||||||
@@ -55,7 +57,8 @@ class BookMetadata {
|
|||||||
isbn: this.isbn,
|
isbn: this.isbn,
|
||||||
asin: this.asin,
|
asin: this.asin,
|
||||||
language: this.language,
|
language: this.language,
|
||||||
explicit: this.explicit
|
explicit: this.explicit,
|
||||||
|
abridged: this.abridged
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +79,8 @@ class BookMetadata {
|
|||||||
isbn: this.isbn,
|
isbn: this.isbn,
|
||||||
asin: this.asin,
|
asin: this.asin,
|
||||||
language: this.language,
|
language: this.language,
|
||||||
explicit: this.explicit
|
explicit: this.explicit,
|
||||||
|
abridged: this.abridged
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +104,8 @@ class BookMetadata {
|
|||||||
authorName: this.authorName,
|
authorName: this.authorName,
|
||||||
authorNameLF: this.authorNameLF,
|
authorNameLF: this.authorNameLF,
|
||||||
narratorName: this.narratorName,
|
narratorName: this.narratorName,
|
||||||
seriesName: this.seriesName
|
seriesName: this.seriesName,
|
||||||
|
abridged: this.abridged
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -136,5 +136,74 @@ class PodcastMetadata {
|
|||||||
}
|
}
|
||||||
return hasUpdates
|
return hasUpdates
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setDataFromAudioMetaTags(audioFileMetaTags, overrideExistingDetails = false) {
|
||||||
|
const MetadataMapArray = [
|
||||||
|
{
|
||||||
|
tag: 'tagAlbum',
|
||||||
|
altTag: 'tagSeries',
|
||||||
|
key: 'title'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: 'tagArtist',
|
||||||
|
key: 'author'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: 'tagGenre',
|
||||||
|
key: 'genres'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: 'tagLanguage',
|
||||||
|
key: 'language'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: 'tagItunesId',
|
||||||
|
key: 'itunesId'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: 'tagPodcastType',
|
||||||
|
key: 'type',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const updatePayload = {}
|
||||||
|
|
||||||
|
MetadataMapArray.forEach((mapping) => {
|
||||||
|
let value = audioFileMetaTags[mapping.tag]
|
||||||
|
let tagToUse = mapping.tag
|
||||||
|
if (!value && mapping.altTag) {
|
||||||
|
value = audioFileMetaTags[mapping.altTag]
|
||||||
|
tagToUse = mapping.altTag
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value && typeof value === 'string') {
|
||||||
|
value = value.trim() // Trim whitespace
|
||||||
|
|
||||||
|
if (mapping.key === 'genres' && (!this.genres.length || overrideExistingDetails)) {
|
||||||
|
updatePayload.genres = this.parseGenresTag(value)
|
||||||
|
Logger.debug(`[Podcast] Mapping metadata to key ${tagToUse} => ${mapping.key}: ${updatePayload.genres.join(', ')}`)
|
||||||
|
} else if (!this[mapping.key] || overrideExistingDetails) {
|
||||||
|
updatePayload[mapping.key] = value
|
||||||
|
Logger.debug(`[Podcast] Mapping metadata to key ${tagToUse} => ${mapping.key}: ${updatePayload[mapping.key]}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (Object.keys(updatePayload).length) {
|
||||||
|
return this.update(updatePayload)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
parseGenresTag(genreTag) {
|
||||||
|
if (!genreTag || !genreTag.length) return []
|
||||||
|
const separators = ['/', '//', ';']
|
||||||
|
for (let i = 0; i < separators.length; i++) {
|
||||||
|
if (genreTag.includes(separators[i])) {
|
||||||
|
return genreTag.split(separators[i]).map(genre => genre.trim()).filter(g => !!g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [genreTag]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
module.exports = PodcastMetadata
|
module.exports = PodcastMetadata
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ class MediaProgress {
|
|||||||
this.isFinished = false
|
this.isFinished = false
|
||||||
this.hideFromContinueListening = false
|
this.hideFromContinueListening = false
|
||||||
|
|
||||||
|
this.ebookLocation = null // current cfi tag
|
||||||
|
this.ebookProgress = null // 0 to 1
|
||||||
|
|
||||||
this.lastUpdate = null
|
this.lastUpdate = null
|
||||||
this.startedAt = null
|
this.startedAt = null
|
||||||
this.finishedAt = null
|
this.finishedAt = null
|
||||||
@@ -29,6 +32,8 @@ class MediaProgress {
|
|||||||
currentTime: this.currentTime,
|
currentTime: this.currentTime,
|
||||||
isFinished: this.isFinished,
|
isFinished: this.isFinished,
|
||||||
hideFromContinueListening: this.hideFromContinueListening,
|
hideFromContinueListening: this.hideFromContinueListening,
|
||||||
|
ebookLocation: this.ebookLocation,
|
||||||
|
ebookProgress: this.ebookProgress,
|
||||||
lastUpdate: this.lastUpdate,
|
lastUpdate: this.lastUpdate,
|
||||||
startedAt: this.startedAt,
|
startedAt: this.startedAt,
|
||||||
finishedAt: this.finishedAt
|
finishedAt: this.finishedAt
|
||||||
@@ -44,13 +49,15 @@ class MediaProgress {
|
|||||||
this.currentTime = progress.currentTime
|
this.currentTime = progress.currentTime
|
||||||
this.isFinished = !!progress.isFinished
|
this.isFinished = !!progress.isFinished
|
||||||
this.hideFromContinueListening = !!progress.hideFromContinueListening
|
this.hideFromContinueListening = !!progress.hideFromContinueListening
|
||||||
|
this.ebookLocation = progress.ebookLocation || null
|
||||||
|
this.ebookProgress = progress.ebookProgress
|
||||||
this.lastUpdate = progress.lastUpdate
|
this.lastUpdate = progress.lastUpdate
|
||||||
this.startedAt = progress.startedAt
|
this.startedAt = progress.startedAt
|
||||||
this.finishedAt = progress.finishedAt || null
|
this.finishedAt = progress.finishedAt || null
|
||||||
}
|
}
|
||||||
|
|
||||||
get inProgress() {
|
get inProgress() {
|
||||||
return !this.isFinished && this.progress > 0
|
return !this.isFinished && (this.progress > 0 || this.ebookLocation != null)
|
||||||
}
|
}
|
||||||
|
|
||||||
setData(libraryItemId, progress, episodeId = null) {
|
setData(libraryItemId, progress, episodeId = null) {
|
||||||
@@ -62,6 +69,8 @@ class MediaProgress {
|
|||||||
this.currentTime = progress.currentTime || 0
|
this.currentTime = progress.currentTime || 0
|
||||||
this.isFinished = !!progress.isFinished || this.progress == 1
|
this.isFinished = !!progress.isFinished || this.progress == 1
|
||||||
this.hideFromContinueListening = !!progress.hideFromContinueListening
|
this.hideFromContinueListening = !!progress.hideFromContinueListening
|
||||||
|
this.ebookLocation = progress.ebookLocation
|
||||||
|
this.ebookProgress = Math.min(1, (progress.ebookProgress || 0))
|
||||||
this.lastUpdate = Date.now()
|
this.lastUpdate = Date.now()
|
||||||
this.finishedAt = null
|
this.finishedAt = null
|
||||||
if (this.isFinished) {
|
if (this.isFinished) {
|
||||||
|
|||||||
@@ -101,12 +101,12 @@ class User {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toJSONForBrowser() {
|
toJSONForBrowser(hideRootToken = false, minimal = false) {
|
||||||
return {
|
const json = {
|
||||||
id: this.id,
|
id: this.id,
|
||||||
username: this.username,
|
username: this.username,
|
||||||
type: this.type,
|
type: this.type,
|
||||||
token: this.token,
|
token: (this.type === 'root' && hideRootToken) ? '' : this.token,
|
||||||
mediaProgress: this.mediaProgress ? this.mediaProgress.map(li => li.toJSON()) : [],
|
mediaProgress: this.mediaProgress ? this.mediaProgress.map(li => li.toJSON()) : [],
|
||||||
seriesHideFromContinueListening: [...this.seriesHideFromContinueListening],
|
seriesHideFromContinueListening: [...this.seriesHideFromContinueListening],
|
||||||
bookmarks: this.bookmarks ? this.bookmarks.map(b => b.toJSON()) : [],
|
bookmarks: this.bookmarks ? this.bookmarks.map(b => b.toJSON()) : [],
|
||||||
@@ -119,6 +119,11 @@ class User {
|
|||||||
librariesAccessible: [...this.librariesAccessible],
|
librariesAccessible: [...this.librariesAccessible],
|
||||||
itemTagsAccessible: [...this.itemTagsAccessible]
|
itemTagsAccessible: [...this.itemTagsAccessible]
|
||||||
}
|
}
|
||||||
|
if (minimal) {
|
||||||
|
delete json.mediaProgress
|
||||||
|
delete json.bookmarks
|
||||||
|
}
|
||||||
|
return json
|
||||||
}
|
}
|
||||||
|
|
||||||
// Data broadcasted
|
// Data broadcasted
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class Audible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cleanResult(item) {
|
cleanResult(item) {
|
||||||
const { title, subtitle, asin, authors, narrators, publisherName, summary, releaseDate, image, genres, seriesPrimary, seriesSecondary, language, runtimeLengthMin } = item
|
const { title, subtitle, asin, authors, narrators, publisherName, summary, releaseDate, image, genres, seriesPrimary, seriesSecondary, language, runtimeLengthMin, formatType } = item
|
||||||
|
|
||||||
const series = []
|
const series = []
|
||||||
if (seriesPrimary) {
|
if (seriesPrimary) {
|
||||||
@@ -54,7 +54,8 @@ class Audible {
|
|||||||
language: language ? language.charAt(0).toUpperCase() + language.slice(1) : null,
|
language: language ? language.charAt(0).toUpperCase() + language.slice(1) : null,
|
||||||
duration: runtimeLengthMin && !isNaN(runtimeLengthMin) ? Number(runtimeLengthMin) : 0,
|
duration: runtimeLengthMin && !isNaN(runtimeLengthMin) ? Number(runtimeLengthMin) : 0,
|
||||||
region: item.region || null,
|
region: item.region || null,
|
||||||
rating: item.rating || null
|
rating: item.rating || null,
|
||||||
|
abridged: formatType === 'abridged'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+21
-13
@@ -271,9 +271,10 @@ class ApiRouter {
|
|||||||
//
|
//
|
||||||
// Tools Routes (Admin and up)
|
// Tools Routes (Admin and up)
|
||||||
//
|
//
|
||||||
this.router.post('/tools/item/:id/encode-m4b', ToolsController.itemMiddleware.bind(this), ToolsController.encodeM4b.bind(this))
|
this.router.post('/tools/item/:id/encode-m4b', ToolsController.middleware.bind(this), ToolsController.encodeM4b.bind(this))
|
||||||
this.router.delete('/tools/item/:id/encode-m4b', ToolsController.itemMiddleware.bind(this), ToolsController.cancelM4bEncode.bind(this))
|
this.router.delete('/tools/item/:id/encode-m4b', ToolsController.middleware.bind(this), ToolsController.cancelM4bEncode.bind(this))
|
||||||
this.router.post('/tools/item/:id/embed-metadata', ToolsController.itemMiddleware.bind(this), ToolsController.embedAudioFileMetadata.bind(this))
|
this.router.post('/tools/item/:id/embed-metadata', ToolsController.middleware.bind(this), ToolsController.embedAudioFileMetadata.bind(this))
|
||||||
|
this.router.post('/tools/batch/embed-metadata', ToolsController.middleware.bind(this), ToolsController.batchEmbedMetadata.bind(this))
|
||||||
|
|
||||||
//
|
//
|
||||||
// RSS Feed Routes (Admin and up)
|
// RSS Feed Routes (Admin and up)
|
||||||
@@ -339,10 +340,7 @@ class ApiRouter {
|
|||||||
// Helper Methods
|
// Helper Methods
|
||||||
//
|
//
|
||||||
userJsonWithItemProgressDetails(user, hideRootToken = false) {
|
userJsonWithItemProgressDetails(user, hideRootToken = false) {
|
||||||
const json = user.toJSONForBrowser()
|
const json = user.toJSONForBrowser(hideRootToken)
|
||||||
if (json.type === 'root' && hideRootToken) {
|
|
||||||
json.token = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
json.mediaProgress = json.mediaProgress.map(lip => {
|
json.mediaProgress = json.mediaProgress.map(lip => {
|
||||||
const libraryItem = this.db.libraryItems.find(li => li.id === lip.libraryItemId)
|
const libraryItem = this.db.libraryItems.find(li => li.id === lip.libraryItemId)
|
||||||
@@ -507,11 +505,16 @@ class ApiRouter {
|
|||||||
|
|
||||||
// Create new authors if in payload
|
// Create new authors if in payload
|
||||||
if (mediaMetadata.authors && mediaMetadata.authors.length) {
|
if (mediaMetadata.authors && mediaMetadata.authors.length) {
|
||||||
// TODO: validate authors
|
|
||||||
const newAuthors = []
|
const newAuthors = []
|
||||||
for (let i = 0; i < mediaMetadata.authors.length; i++) {
|
for (let i = 0; i < mediaMetadata.authors.length; i++) {
|
||||||
if (mediaMetadata.authors[i].id.startsWith('new')) {
|
const authorName = (mediaMetadata.authors[i].name || '').trim()
|
||||||
let author = this.db.authors.find(au => au.checkNameEquals(mediaMetadata.authors[i].name))
|
if (!authorName) {
|
||||||
|
Logger.error(`[ApiRouter] Invalid author object, no name`, mediaMetadata.authors[i])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mediaMetadata.authors[i].id || mediaMetadata.authors[i].id.startsWith('new')) {
|
||||||
|
let author = this.db.authors.find(au => au.checkNameEquals(authorName))
|
||||||
if (!author) {
|
if (!author) {
|
||||||
author = new Author()
|
author = new Author()
|
||||||
author.setData(mediaMetadata.authors[i])
|
author.setData(mediaMetadata.authors[i])
|
||||||
@@ -531,11 +534,16 @@ class ApiRouter {
|
|||||||
|
|
||||||
// Create new series if in payload
|
// Create new series if in payload
|
||||||
if (mediaMetadata.series && mediaMetadata.series.length) {
|
if (mediaMetadata.series && mediaMetadata.series.length) {
|
||||||
// TODO: validate series
|
|
||||||
const newSeries = []
|
const newSeries = []
|
||||||
for (let i = 0; i < mediaMetadata.series.length; i++) {
|
for (let i = 0; i < mediaMetadata.series.length; i++) {
|
||||||
if (mediaMetadata.series[i].id.startsWith('new')) {
|
const seriesName = (mediaMetadata.series[i].name || '').trim()
|
||||||
let seriesItem = this.db.series.find(se => se.checkNameEquals(mediaMetadata.series[i].name))
|
if (!seriesName) {
|
||||||
|
Logger.error(`[ApiRouter] Invalid series object, no name`, mediaMetadata.series[i])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mediaMetadata.series[i].id || mediaMetadata.series[i].id.startsWith('new')) {
|
||||||
|
let seriesItem = this.db.series.find(se => se.checkNameEquals(seriesName))
|
||||||
if (!seriesItem) {
|
if (!seriesItem) {
|
||||||
seriesItem = new Series()
|
seriesItem = new Series()
|
||||||
seriesItem.setData(mediaMetadata.series[i])
|
seriesItem.setData(mediaMetadata.series[i])
|
||||||
|
|||||||
@@ -296,11 +296,17 @@ class MediaFileScanner {
|
|||||||
|
|
||||||
// Update audio file metadata for audio files already there
|
// Update audio file metadata for audio files already there
|
||||||
existingAudioFiles.forEach((af) => {
|
existingAudioFiles.forEach((af) => {
|
||||||
const peAudioFile = libraryItem.media.findFileWithInode(af.ino)
|
const podcastEpisode = libraryItem.media.findEpisodeWithInode(af.ino)
|
||||||
if (peAudioFile.updateFromScan && peAudioFile.updateFromScan(af)) {
|
if (podcastEpisode?.audioFile.updateFromScan(af)) {
|
||||||
hasUpdated = true
|
hasUpdated = true
|
||||||
|
|
||||||
|
podcastEpisode.setDataFromAudioMetaTags(podcastEpisode.audioFile.metaTags, false)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (libraryItem.media.setMetadataFromAudioFile(preferAudioMetadata)) {
|
||||||
|
hasUpdated = true
|
||||||
|
}
|
||||||
} else if (libraryItem.mediaType === 'music') { // Music
|
} else if (libraryItem.mediaType === 'music') { // Music
|
||||||
// Only one audio file in library item
|
// Only one audio file in library item
|
||||||
if (newAudioFiles.length) { // New audio file
|
if (newAudioFiles.length) { // New audio file
|
||||||
|
|||||||
@@ -793,7 +793,7 @@ class Scanner {
|
|||||||
|
|
||||||
async quickMatchBookBuildUpdatePayload(libraryItem, matchData, options) {
|
async quickMatchBookBuildUpdatePayload(libraryItem, matchData, options) {
|
||||||
// Update media metadata if not set OR overrideDetails flag
|
// Update media metadata if not set OR overrideDetails flag
|
||||||
const detailKeysToUpdate = ['title', 'subtitle', 'description', 'narrator', 'publisher', 'publishedYear', 'genres', 'tags', 'language', 'explicit', 'asin', 'isbn']
|
const detailKeysToUpdate = ['title', 'subtitle', 'description', 'narrator', 'publisher', 'publishedYear', 'genres', 'tags', 'language', 'explicit', 'abridged', 'asin', 'isbn']
|
||||||
const updatePayload = {}
|
const updatePayload = {}
|
||||||
updatePayload.metadata = {}
|
updatePayload.metadata = {}
|
||||||
|
|
||||||
|
|||||||
@@ -121,6 +121,10 @@ const bookMetadataMapper = {
|
|||||||
explicit: {
|
explicit: {
|
||||||
to: (m) => m.explicit ? 'Y' : 'N',
|
to: (m) => m.explicit ? 'Y' : 'N',
|
||||||
from: (v) => v && v.toLowerCase() == 'y'
|
from: (v) => v && v.toLowerCase() == 'y'
|
||||||
|
},
|
||||||
|
abridged: {
|
||||||
|
to: (m) => m.abridged ? 'Y' : 'N',
|
||||||
|
from: (v) => v && v.toLowerCase() == 'y'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
const axios = require('axios')
|
||||||
const Ffmpeg = require('../libs/fluentFfmpeg')
|
const Ffmpeg = require('../libs/fluentFfmpeg')
|
||||||
const fs = require('../libs/fsExtra')
|
const fs = require('../libs/fsExtra')
|
||||||
const Path = require('path')
|
const Path = require('path')
|
||||||
@@ -86,3 +87,68 @@ async function resizeImage(filePath, outputPath, width, height) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
module.exports.resizeImage = resizeImage
|
module.exports.resizeImage = resizeImage
|
||||||
|
|
||||||
|
module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => {
|
||||||
|
return new Promise(async (resolve) => {
|
||||||
|
const response = await axios({
|
||||||
|
url: podcastEpisodeDownload.url,
|
||||||
|
method: 'GET',
|
||||||
|
responseType: 'stream',
|
||||||
|
timeout: 30000
|
||||||
|
})
|
||||||
|
|
||||||
|
const ffmpeg = Ffmpeg(response.data)
|
||||||
|
ffmpeg.outputOptions(
|
||||||
|
'-c', 'copy',
|
||||||
|
'-metadata', 'podcast=1'
|
||||||
|
)
|
||||||
|
|
||||||
|
const podcastMetadata = podcastEpisodeDownload.libraryItem.media.metadata
|
||||||
|
const podcastEpisode = podcastEpisodeDownload.podcastEpisode
|
||||||
|
|
||||||
|
const taggings = {
|
||||||
|
'album': podcastMetadata.title,
|
||||||
|
'album-sort': podcastMetadata.title,
|
||||||
|
'artist': podcastMetadata.author,
|
||||||
|
'artist-sort': podcastMetadata.author,
|
||||||
|
'comment': podcastEpisode.description,
|
||||||
|
'subtitle': podcastEpisode.subtitle,
|
||||||
|
'disc': podcastEpisode.season,
|
||||||
|
'genre': podcastMetadata.genres.length ? podcastMetadata.genres.join(';') : null,
|
||||||
|
'language': podcastMetadata.language,
|
||||||
|
'MVNM': podcastMetadata.title,
|
||||||
|
'MVIN': podcastEpisode.episode,
|
||||||
|
'track': podcastEpisode.episode,
|
||||||
|
'series-part': podcastEpisode.episode,
|
||||||
|
'title': podcastEpisode.title,
|
||||||
|
'title-sort': podcastEpisode.title,
|
||||||
|
'year': podcastEpisode.pubYear,
|
||||||
|
'date': podcastEpisode.pubDate,
|
||||||
|
'releasedate': podcastEpisode.pubDate,
|
||||||
|
'itunes-id': podcastMetadata.itunesId,
|
||||||
|
'podcast-type': podcastMetadata.type,
|
||||||
|
'episode-type': podcastMetadata.episodeType
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const tag in taggings) {
|
||||||
|
if (taggings[tag]) {
|
||||||
|
ffmpeg.addOption('-metadata', `${tag}=${taggings[tag]}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ffmpeg.addOutput(podcastEpisodeDownload.targetPath)
|
||||||
|
|
||||||
|
ffmpeg.on('start', (cmd) => {
|
||||||
|
Logger.debug(`[FfmpegHelpers] downloadPodcastEpisode: Cmd: ${cmd}`)
|
||||||
|
})
|
||||||
|
ffmpeg.on('error', (err, stdout, stderr) => {
|
||||||
|
Logger.error(`[FfmpegHelpers] downloadPodcastEpisode: Error ${err} ${stdout} ${stderr}`)
|
||||||
|
resolve(false)
|
||||||
|
})
|
||||||
|
ffmpeg.on('end', () => {
|
||||||
|
Logger.debug(`[FfmpegHelpers] downloadPodcastEpisode: Complete`)
|
||||||
|
resolve(podcastEpisodeDownload.targetPath)
|
||||||
|
})
|
||||||
|
ffmpeg.run()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ module.exports.reqSupportsWebp = (req) => {
|
|||||||
module.exports.areEquivalent = areEquivalent
|
module.exports.areEquivalent = areEquivalent
|
||||||
|
|
||||||
module.exports.copyValue = (val) => {
|
module.exports.copyValue = (val) => {
|
||||||
if (!val) return null
|
if (!val) return val === false ? false : null
|
||||||
if (!this.isObject(val)) return val
|
if (!this.isObject(val)) return val
|
||||||
|
|
||||||
if (Array.isArray(val)) {
|
if (Array.isArray(val)) {
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ module.exports = {
|
|||||||
filtered = filtered.filter(li => li.hasIssues)
|
filtered = filtered.filter(li => li.hasIssues)
|
||||||
} else if (filterBy === 'feed-open') {
|
} else if (filterBy === 'feed-open') {
|
||||||
filtered = filtered.filter(li => feedsArray.some(feed => feed.entityId === li.id))
|
filtered = filtered.filter(li => feedsArray.some(feed => feed.entityId === li.id))
|
||||||
|
} else if (filterBy === 'abridged') {
|
||||||
|
filtered = filtered.filter(li => !!li.media.metadata?.abridged)
|
||||||
}
|
}
|
||||||
|
|
||||||
return filtered
|
return filtered
|
||||||
|
|||||||
+11
-3
@@ -73,7 +73,8 @@ function tryGrabChannelLayout(stream) {
|
|||||||
function tryGrabTags(stream, ...tags) {
|
function tryGrabTags(stream, ...tags) {
|
||||||
if (!stream.tags) return null
|
if (!stream.tags) return null
|
||||||
for (let i = 0; i < tags.length; i++) {
|
for (let i = 0; i < tags.length; i++) {
|
||||||
const value = stream.tags[tags[i]] || stream.tags[tags[i].toUpperCase()]
|
const tagKey = Object.keys(stream.tags).find(t => t.toLowerCase() === tags[i].toLowerCase())
|
||||||
|
const value = stream.tags[tagKey]
|
||||||
if (value && value.trim()) return value.trim()
|
if (value && value.trim()) return value.trim()
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
@@ -161,15 +162,19 @@ function parseTags(format, verbose) {
|
|||||||
if (verbose) {
|
if (verbose) {
|
||||||
Logger.debug('Tags', format.tags)
|
Logger.debug('Tags', format.tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
const tags = {
|
const tags = {
|
||||||
file_tag_encoder: tryGrabTags(format, 'encoder', 'tsse', 'tss'),
|
file_tag_encoder: tryGrabTags(format, 'encoder', 'tsse', 'tss'),
|
||||||
file_tag_encodedby: tryGrabTags(format, 'encoded_by', 'tenc', 'ten'),
|
file_tag_encodedby: tryGrabTags(format, 'encoded_by', 'tenc', 'ten'),
|
||||||
file_tag_title: tryGrabTags(format, 'title', 'tit2', 'tt2'),
|
file_tag_title: tryGrabTags(format, 'title', 'tit2', 'tt2'),
|
||||||
|
file_tag_titlesort: tryGrabTags(format, 'title-sort', 'tsot'),
|
||||||
file_tag_subtitle: tryGrabTags(format, 'subtitle', 'tit3', 'tt3'),
|
file_tag_subtitle: tryGrabTags(format, 'subtitle', 'tit3', 'tt3'),
|
||||||
file_tag_track: tryGrabTags(format, 'track', 'trck', 'trk'),
|
file_tag_track: tryGrabTags(format, 'track', 'trck', 'trk'),
|
||||||
file_tag_disc: tryGrabTags(format, 'discnumber', 'disc', 'disk', 'tpos'),
|
file_tag_disc: tryGrabTags(format, 'discnumber', 'disc', 'disk', 'tpos'),
|
||||||
file_tag_album: tryGrabTags(format, 'album', 'talb', 'tal'),
|
file_tag_album: tryGrabTags(format, 'album', 'talb', 'tal'),
|
||||||
|
file_tag_albumsort: tryGrabTags(format, 'album-sort', 'tsoa'),
|
||||||
file_tag_artist: tryGrabTags(format, 'artist', 'tpe1', 'tp1'),
|
file_tag_artist: tryGrabTags(format, 'artist', 'tpe1', 'tp1'),
|
||||||
|
file_tag_artistsort: tryGrabTags(format, 'artist-sort', 'tsop'),
|
||||||
file_tag_albumartist: tryGrabTags(format, 'albumartist', 'album_artist', 'tpe2'),
|
file_tag_albumartist: tryGrabTags(format, 'albumartist', 'album_artist', 'tpe2'),
|
||||||
file_tag_date: tryGrabTags(format, 'date', 'tyer', 'tye'),
|
file_tag_date: tryGrabTags(format, 'date', 'tyer', 'tye'),
|
||||||
file_tag_composer: tryGrabTags(format, 'composer', 'tcom', 'tcm'),
|
file_tag_composer: tryGrabTags(format, 'composer', 'tcom', 'tcm'),
|
||||||
@@ -179,9 +184,12 @@ function parseTags(format, verbose) {
|
|||||||
file_tag_genre: tryGrabTags(format, 'genre', 'tcon', 'tco'),
|
file_tag_genre: tryGrabTags(format, 'genre', 'tcon', 'tco'),
|
||||||
file_tag_series: tryGrabTags(format, 'series', 'show', 'mvnm'),
|
file_tag_series: tryGrabTags(format, 'series', 'show', 'mvnm'),
|
||||||
file_tag_seriespart: tryGrabTags(format, 'series-part', 'episode_id', 'mvin'),
|
file_tag_seriespart: tryGrabTags(format, 'series-part', 'episode_id', 'mvin'),
|
||||||
file_tag_isbn: tryGrabTags(format, 'isbn'),
|
file_tag_isbn: tryGrabTags(format, 'isbn'), // custom
|
||||||
file_tag_language: tryGrabTags(format, 'language', 'lang'),
|
file_tag_language: tryGrabTags(format, 'language', 'lang'),
|
||||||
file_tag_asin: tryGrabTags(format, 'asin'),
|
file_tag_asin: tryGrabTags(format, 'asin'), // custom
|
||||||
|
file_tag_itunesid: tryGrabTags(format, 'itunes-id'), // custom
|
||||||
|
file_tag_podcasttype: tryGrabTags(format, 'podcast-type'), // custom
|
||||||
|
file_tag_episodetype: tryGrabTags(format, 'episode-type'), // custom
|
||||||
file_tag_originalyear: tryGrabTags(format, 'originalyear'),
|
file_tag_originalyear: tryGrabTags(format, 'originalyear'),
|
||||||
file_tag_releasecountry: tryGrabTags(format, 'MusicBrainz Album Release Country', 'releasecountry'),
|
file_tag_releasecountry: tryGrabTags(format, 'MusicBrainz Album Release Country', 'releasecountry'),
|
||||||
file_tag_releasestatus: tryGrabTags(format, 'MusicBrainz Album Status', 'releasestatus', 'musicbrainz_albumstatus'),
|
file_tag_releasestatus: tryGrabTags(format, 'MusicBrainz Album Status', 'releasestatus', 'musicbrainz_albumstatus'),
|
||||||
|
|||||||
@@ -1,78 +1,8 @@
|
|||||||
const tone = require('node-tone')
|
const tone = require('node-tone')
|
||||||
const fs = require('../libs/fsExtra')
|
const fs = require('../libs/fsExtra')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const { secondsToTimestamp } = require('./index')
|
|
||||||
|
|
||||||
module.exports.writeToneChaptersFile = (chapters, filePath) => {
|
function getToneMetadataObject(libraryItem, chapters, trackTotal) {
|
||||||
var chaptersTxt = ''
|
|
||||||
for (const chapter of chapters) {
|
|
||||||
chaptersTxt += `${secondsToTimestamp(chapter.start, true, true)} ${chapter.title}\n`
|
|
||||||
}
|
|
||||||
return fs.writeFile(filePath, chaptersTxt)
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports.getToneMetadataObject = (libraryItem, chaptersFile) => {
|
|
||||||
const coverPath = libraryItem.media.coverPath
|
|
||||||
const bookMetadata = libraryItem.media.metadata
|
|
||||||
|
|
||||||
const metadataObject = {
|
|
||||||
'Title': bookMetadata.title || '',
|
|
||||||
'Album': bookMetadata.title || '',
|
|
||||||
'TrackTotal': libraryItem.media.tracks.length
|
|
||||||
}
|
|
||||||
const additionalFields = []
|
|
||||||
|
|
||||||
if (bookMetadata.subtitle) {
|
|
||||||
metadataObject['Subtitle'] = bookMetadata.subtitle
|
|
||||||
}
|
|
||||||
if (bookMetadata.authorName) {
|
|
||||||
metadataObject['Artist'] = bookMetadata.authorName
|
|
||||||
metadataObject['AlbumArtist'] = bookMetadata.authorName
|
|
||||||
}
|
|
||||||
if (bookMetadata.description) {
|
|
||||||
metadataObject['Comment'] = bookMetadata.description
|
|
||||||
metadataObject['Description'] = bookMetadata.description
|
|
||||||
}
|
|
||||||
if (bookMetadata.narratorName) {
|
|
||||||
metadataObject['Narrator'] = bookMetadata.narratorName
|
|
||||||
metadataObject['Composer'] = bookMetadata.narratorName
|
|
||||||
}
|
|
||||||
if (bookMetadata.firstSeriesName) {
|
|
||||||
metadataObject['MovementName'] = bookMetadata.firstSeriesName
|
|
||||||
}
|
|
||||||
if (bookMetadata.firstSeriesSequence) {
|
|
||||||
metadataObject['Movement'] = bookMetadata.firstSeriesSequence
|
|
||||||
}
|
|
||||||
if (bookMetadata.genres.length) {
|
|
||||||
metadataObject['Genre'] = bookMetadata.genres.join('/')
|
|
||||||
}
|
|
||||||
if (bookMetadata.publisher) {
|
|
||||||
metadataObject['Publisher'] = bookMetadata.publisher
|
|
||||||
}
|
|
||||||
if (bookMetadata.asin) {
|
|
||||||
additionalFields.push(`ASIN=${bookMetadata.asin}`)
|
|
||||||
}
|
|
||||||
if (bookMetadata.isbn) {
|
|
||||||
additionalFields.push(`ISBN=${bookMetadata.isbn}`)
|
|
||||||
}
|
|
||||||
if (coverPath) {
|
|
||||||
metadataObject['CoverFile'] = coverPath
|
|
||||||
}
|
|
||||||
if (parsePublishedYear(bookMetadata.publishedYear)) {
|
|
||||||
metadataObject['PublishingDate'] = parsePublishedYear(bookMetadata.publishedYear)
|
|
||||||
}
|
|
||||||
if (chaptersFile) {
|
|
||||||
metadataObject['ChaptersFile'] = chaptersFile
|
|
||||||
}
|
|
||||||
|
|
||||||
if (additionalFields.length) {
|
|
||||||
metadataObject['AdditionalFields'] = additionalFields
|
|
||||||
}
|
|
||||||
|
|
||||||
return metadataObject
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports.writeToneMetadataJsonFile = (libraryItem, chapters, filePath, trackTotal) => {
|
|
||||||
const bookMetadata = libraryItem.media.metadata
|
const bookMetadata = libraryItem.media.metadata
|
||||||
const coverPath = libraryItem.media.coverPath
|
const coverPath = libraryItem.media.coverPath
|
||||||
|
|
||||||
@@ -133,6 +63,12 @@ module.exports.writeToneMetadataJsonFile = (libraryItem, chapters, filePath, tra
|
|||||||
metadataObject['chapters'] = metadataChapters
|
metadataObject['chapters'] = metadataChapters
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return metadataObject
|
||||||
|
}
|
||||||
|
module.exports.getToneMetadataObject = getToneMetadataObject
|
||||||
|
|
||||||
|
module.exports.writeToneMetadataJsonFile = (libraryItem, chapters, filePath, trackTotal) => {
|
||||||
|
const metadataObject = getToneMetadataObject(libraryItem, chapters, trackTotal)
|
||||||
return fs.writeFile(filePath, JSON.stringify({ meta: metadataObject }, null, 2))
|
return fs.writeFile(filePath, JSON.stringify({ meta: metadataObject }, null, 2))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user