mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-10 11:21:36 +00:00
Merge branch 'advplyr:master' into book-preview-on-hover
This commit is contained in:
commit
e6da9a1945
79 changed files with 2246 additions and 1780 deletions
3
.github/ISSUE_TEMPLATE/config.yml
vendored
3
.github/ISSUE_TEMPLATE/config.yml
vendored
|
|
@ -3,6 +3,3 @@ contact_links:
|
|||
- name: Discord
|
||||
url: https://discord.gg/HQgCbd6E75
|
||||
about: Ask questions, get help troubleshooting, and join the Abs community here.
|
||||
- name: Matrix
|
||||
url: https://matrix.to/#/#audiobookshelf:matrix.org
|
||||
about: Ask questions, get help troubleshooting, and join the Abs community here.
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -15,6 +15,7 @@
|
|||
/.nyc_output/
|
||||
/ffmpeg*
|
||||
/ffprobe*
|
||||
/unicode*
|
||||
|
||||
sw.*
|
||||
.DS_STORE
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
set -e
|
||||
set -o pipefail
|
||||
|
||||
FFMPEG_INSTALL_DIR="/usr/lib/audiobookshelf-ffmpeg"
|
||||
DEFAULT_DATA_DIR="/usr/share/audiobookshelf"
|
||||
CONFIG_PATH="/etc/default/audiobookshelf"
|
||||
DEFAULT_PORT=13378
|
||||
|
|
@ -46,25 +45,6 @@ add_group() {
|
|||
fi
|
||||
}
|
||||
|
||||
install_ffmpeg() {
|
||||
echo "Starting FFMPEG Install"
|
||||
|
||||
WGET="wget https://johnvansickle.com/ffmpeg/builds/ffmpeg-git-amd64-static.tar.xz --output-document=ffmpeg-git-amd64-static.tar.xz"
|
||||
|
||||
if ! cd "$FFMPEG_INSTALL_DIR"; then
|
||||
echo "Creating ffmpeg install dir at $FFMPEG_INSTALL_DIR"
|
||||
mkdir "$FFMPEG_INSTALL_DIR"
|
||||
chown -R 'audiobookshelf:audiobookshelf' "$FFMPEG_INSTALL_DIR"
|
||||
cd "$FFMPEG_INSTALL_DIR"
|
||||
fi
|
||||
|
||||
$WGET
|
||||
tar xvf ffmpeg-git-amd64-static.tar.xz --strip-components=1 --no-same-owner
|
||||
rm ffmpeg-git-amd64-static.tar.xz
|
||||
|
||||
echo "Good to go on Ffmpeg... hopefully"
|
||||
}
|
||||
|
||||
setup_config() {
|
||||
if [ -f "$CONFIG_PATH" ]; then
|
||||
echo "Existing config found."
|
||||
|
|
@ -83,8 +63,6 @@ setup_config() {
|
|||
|
||||
config_text="METADATA_PATH=$DEFAULT_DATA_DIR/metadata
|
||||
CONFIG_PATH=$DEFAULT_DATA_DIR/config
|
||||
FFMPEG_PATH=$FFMPEG_INSTALL_DIR/ffmpeg
|
||||
FFPROBE_PATH=$FFMPEG_INSTALL_DIR/ffprobe
|
||||
PORT=$DEFAULT_PORT
|
||||
HOST=$DEFAULT_HOST"
|
||||
|
||||
|
|
@ -101,5 +79,3 @@ add_group 'audiobookshelf' ''
|
|||
add_user 'audiobookshelf' '' 'audiobookshelf' 'audiobookshelf user-daemon' '/bin/false'
|
||||
|
||||
setup_config
|
||||
|
||||
install_ffmpeg
|
||||
|
|
|
|||
|
|
@ -84,11 +84,6 @@
|
|||
|
||||
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="110" class="ml-2" @action="contextMenuAction" />
|
||||
</template>
|
||||
<!-- home page -->
|
||||
<template v-else-if="isHome">
|
||||
<div class="flex-grow" />
|
||||
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="110" class="ml-2" @action="contextMenuAction" />
|
||||
</template>
|
||||
<!-- search page -->
|
||||
<template v-else-if="page === 'search'">
|
||||
<div class="flex-grow" />
|
||||
|
|
@ -99,10 +94,15 @@
|
|||
<!-- authors page -->
|
||||
<template v-else-if="page === 'authors'">
|
||||
<div class="flex-grow" />
|
||||
<ui-btn v-if="userCanUpdate && authors && authors.length && !isBatchSelecting" :loading="processingAuthors" color="primary" small @click="matchAllAuthors">{{ $strings.ButtonMatchAllAuthors }}</ui-btn>
|
||||
<ui-btn v-if="userCanUpdate && authors?.length && !isBatchSelecting" :loading="processingAuthors" color="primary" small @click="matchAllAuthors">{{ $strings.ButtonMatchAllAuthors }}</ui-btn>
|
||||
|
||||
<!-- author sort select -->
|
||||
<controls-sort-select v-if="authors && authors.length" v-model="settings.authorSortBy" :descending.sync="settings.authorSortDesc" :items="authorSortItems" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateAuthorSort" />
|
||||
<controls-sort-select v-if="authors?.length" v-model="settings.authorSortBy" :descending.sync="settings.authorSortDesc" :items="authorSortItems" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateAuthorSort" />
|
||||
</template>
|
||||
<!-- home page -->
|
||||
<template v-else-if="isHome">
|
||||
<div class="flex-grow" />
|
||||
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="110" class="ml-2" @action="contextMenuAction" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -188,6 +188,10 @@ export default {
|
|||
{
|
||||
text: this.$strings.LabelTotalDuration,
|
||||
value: 'totalDuration'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelRandomly,
|
||||
value: 'random'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
</div>
|
||||
<div class="flex-grow px-2 authorSearchCardContent h-full">
|
||||
<p class="truncate text-sm">{{ name }}</p>
|
||||
<p class="text-xs text-gray-400">{{ $getString('LabelXBooks', [numBooks]) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -23,6 +24,9 @@ export default {
|
|||
computed: {
|
||||
name() {
|
||||
return this.author.name
|
||||
},
|
||||
numBooks() {
|
||||
return this.author.numBooks
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
|
|
@ -33,7 +37,7 @@ export default {
|
|||
<style>
|
||||
.authorSearchCardContent {
|
||||
width: calc(100% - 80px);
|
||||
height: 40px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
|
|
|||
|
|
@ -1,254 +0,0 @@
|
|||
<template>
|
||||
<div ref="wrapper" class="relative pointer-events-none" :style="{ width: standardWidth * 0.8 * 1.1 * scale + 'px', height: standardHeight * 1.1 * scale + 'px', marginBottom: 20 + 'px', marginTop: 15 + 'px' }">
|
||||
<div ref="card" class="wrap absolute origin-center transform duration-200" :style="{ transform: `scale(${scale * scaleMultiplier}) translateY(${hover2 ? '-40%' : '-50%'})` }">
|
||||
<div class="perspective">
|
||||
<div class="book-wrap transform duration-100 pointer-events-auto" :class="hover2 ? 'z-80' : 'rotate'" @mouseover="hover = true" @mouseout="hover = false">
|
||||
<div class="book book-1 box-shadow-book3d" ref="front"></div>
|
||||
<div class="title book-1 pointer-events-none" ref="left"></div>
|
||||
<div class="bottom book-1 pointer-events-none" ref="bottom"></div>
|
||||
<div class="book-back book-1 pointer-events-none">
|
||||
<div class="text pointer-events-none">
|
||||
<h3 class="mb-4">Book Back</h3>
|
||||
<p>
|
||||
<span>Lorem ipsum dolor sit amet, consectetur adipisicing elit. Sunt earum doloremque aliquam culpa dolor nostrum consequatur quas dicta? Molestias repellendus minima pariatur libero vel, reiciendis optio magnam rerum, labore corporis.</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
src: String,
|
||||
width: {
|
||||
type: Number,
|
||||
default: 200
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
hover: false,
|
||||
hover2: false,
|
||||
standardWidth: 200,
|
||||
standardHeight: 320,
|
||||
isAttached: true,
|
||||
pageX: 0,
|
||||
pageY: 0
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
src(newVal) {
|
||||
this.setCover()
|
||||
},
|
||||
width(newVal) {
|
||||
this.init()
|
||||
},
|
||||
hover(newVal) {
|
||||
if (newVal) {
|
||||
this.unattach()
|
||||
} else {
|
||||
this.attach()
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.hover2 = newVal
|
||||
}, 100)
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
scaleMultiplier() {
|
||||
return this.hover2 ? 1.25 : 1
|
||||
},
|
||||
scale() {
|
||||
var scale = this.width / this.standardWidth
|
||||
return scale
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
unattach() {
|
||||
if (this.$refs.card && this.isAttached) {
|
||||
var bookshelf = document.getElementById('bookshelf')
|
||||
if (bookshelf) {
|
||||
var pos = this.$refs.wrapper.getBoundingClientRect()
|
||||
|
||||
this.pageX = pos.x
|
||||
this.pageY = pos.y
|
||||
document.body.appendChild(this.$refs.card)
|
||||
this.$refs.card.style.left = this.pageX + 'px'
|
||||
this.$refs.card.style.top = this.pageY + 'px'
|
||||
this.$refs.card.style.zIndex = 50
|
||||
this.isAttached = false
|
||||
} else if (bookshelf) {
|
||||
console.log(this.pageX, this.pageY)
|
||||
this.isAttached = false
|
||||
}
|
||||
}
|
||||
},
|
||||
attach() {
|
||||
if (this.$refs.card && !this.isAttached) {
|
||||
if (this.$refs.wrapper) {
|
||||
this.isAttached = true
|
||||
|
||||
this.$refs.wrapper.appendChild(this.$refs.card)
|
||||
this.$refs.card.style.left = '0px'
|
||||
this.$refs.card.style.top = '0px'
|
||||
}
|
||||
} else {
|
||||
console.log('Is attached already', this.isAttached)
|
||||
}
|
||||
},
|
||||
init() {
|
||||
var standardWidth = this.standardWidth
|
||||
document.documentElement.style.setProperty('--book-w', standardWidth + 'px')
|
||||
document.documentElement.style.setProperty('--book-wx', standardWidth + 1 + 'px')
|
||||
document.documentElement.style.setProperty('--book-h', standardWidth * 1.6 + 'px')
|
||||
document.documentElement.style.setProperty('--book-d', 40 + 'px')
|
||||
},
|
||||
setElBg(el) {
|
||||
el.style.backgroundImage = `url("${this.src}")`
|
||||
el.style.backgroundSize = 'cover'
|
||||
el.style.backgroundPosition = 'center center'
|
||||
el.style.backgroundRepeat = 'no-repeat'
|
||||
},
|
||||
setCover() {
|
||||
if (this.$refs.front) {
|
||||
this.setElBg(this.$refs.front)
|
||||
}
|
||||
if (this.$refs.bottom) {
|
||||
this.setElBg(this.$refs.bottom)
|
||||
this.$refs.bottom.style.backgroundSize = '2000%'
|
||||
this.$refs.bottom.style.filter = 'blur(1px)'
|
||||
}
|
||||
if (this.$refs.left) {
|
||||
this.setElBg(this.$refs.left)
|
||||
this.$refs.left.style.backgroundSize = '2000%'
|
||||
this.$refs.left.style.filter = 'blur(1px)'
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.setCover()
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* :root {
|
||||
--book-w: 200px;
|
||||
--book-h: 320px;
|
||||
--book-d: 30px;
|
||||
--book-wx: 201px;
|
||||
} */
|
||||
/*
|
||||
.wrap {
|
||||
width: calc(1.1 * var(--book-w));
|
||||
height: calc(1.1 * var(--book-h));
|
||||
margin: 0 auto;
|
||||
}
|
||||
.perspective {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
perspective: 600px;
|
||||
transform-style: preserve-3d;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.book-wrap {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
transform-style: preserve-3d;
|
||||
transition: 'all ease-out 0.6s';
|
||||
}
|
||||
|
||||
.book {
|
||||
width: var(--book-w);
|
||||
height: var(--book-h);
|
||||
background: url(https://covers.openlibrary.org/b/id/8303020-L.jpg) no-repeat center center;
|
||||
background-size: cover;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
margin: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
.title {
|
||||
content: '';
|
||||
height: var(--book-h);
|
||||
width: var(--book-d);
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: calc(var(--book-wx) * -1);
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
margin: auto;
|
||||
background: #444;
|
||||
transform: rotateY(-80deg) translateX(-14px);
|
||||
|
||||
background: url(https://covers.openlibrary.org/b/id/8303020-L.jpg) no-repeat center center;
|
||||
background-size: 5000%;
|
||||
filter: blur(1px);
|
||||
}
|
||||
|
||||
.bottom {
|
||||
content: '';
|
||||
height: var(--book-d);
|
||||
width: var(--book-w);
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: var(--book-h);
|
||||
top: 0;
|
||||
left: 0;
|
||||
margin: auto;
|
||||
background: #444;
|
||||
transform: rotateY(0deg) rotateX(90deg) translateY(-15px) translateX(-2.5px) skewX(10deg);
|
||||
|
||||
background: url(https://covers.openlibrary.org/b/id/8303020-L.jpg) no-repeat center center;
|
||||
background-size: 5000%;
|
||||
filter: blur(1px);
|
||||
}
|
||||
|
||||
.book-back {
|
||||
width: var(--book-w);
|
||||
height: var(--book-h);
|
||||
background-color: #444;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
margin: auto;
|
||||
cursor: pointer;
|
||||
transform: rotate(180deg) translateZ(-30px) translateX(5px);
|
||||
}
|
||||
.book-back .text {
|
||||
transform: rotateX(180deg);
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
padding: 20px;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
}
|
||||
.book-back .text h3 {
|
||||
color: #fff;
|
||||
}
|
||||
.book-back .text span {
|
||||
display: block;
|
||||
margin-bottom: 20px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.book-wrap.rotate {
|
||||
transform: rotateY(30deg) rotateX(0deg);
|
||||
}
|
||||
.book-wrap.flip {
|
||||
transform: rotateY(180deg);
|
||||
} */
|
||||
</style>
|
||||
36
client/components/cards/GenreSearchCard.vue
Normal file
36
client/components/cards/GenreSearchCard.vue
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<template>
|
||||
<div class="flex h-full px-1 overflow-hidden">
|
||||
<div class="w-10 h-10 flex items-center justify-center">
|
||||
<span class="material-symbols text-2xl text-gray-200">category</span>
|
||||
</div>
|
||||
<div class="flex-grow px-2 tagSearchCardContent h-full">
|
||||
<p class="truncate text-sm">{{ genre }}</p>
|
||||
<p class="text-xs text-gray-400">{{ $getString('LabelXItems', [numItems]) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
genre: String,
|
||||
numItems: Number
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.tagSearchCardContent {
|
||||
width: calc(100% - 40px);
|
||||
height: 44px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -2,15 +2,9 @@
|
|||
<div class="flex items-center h-full px-1 overflow-hidden">
|
||||
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<div class="flex-grow px-2 audiobookSearchCardContent">
|
||||
<p v-if="matchKey !== 'title'" class="truncate text-sm">{{ title }}</p>
|
||||
<p v-else class="truncate text-sm" v-html="matchHtml" />
|
||||
|
||||
<p v-if="matchKey === 'subtitle'" class="truncate text-xs text-gray-300" v-html="matchHtml" />
|
||||
|
||||
<p v-if="matchKey !== 'authors'" class="text-xs text-gray-200 truncate">{{ $getString('LabelByAuthor', [authorName]) }}</p>
|
||||
<p v-else class="truncate text-xs text-gray-200" v-html="matchHtml" />
|
||||
|
||||
<div v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin' || matchKey === 'episode' || matchKey === 'narrators'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" />
|
||||
<p class="truncate text-sm">{{ title }}</p>
|
||||
<p v-if="subtitle" class="truncate text-xs text-gray-300">{{ subtitle }}</p>
|
||||
<p class="text-xs text-gray-200 truncate">{{ $getString('LabelByAuthor', [authorName]) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -21,10 +15,7 @@ export default {
|
|||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
search: String,
|
||||
matchKey: String,
|
||||
matchText: String
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
|
|
@ -58,23 +49,6 @@ export default {
|
|||
authorName() {
|
||||
if (this.isPodcast) return this.mediaMetadata.author || 'Unknown'
|
||||
return this.mediaMetadata.authorName || 'Unknown'
|
||||
},
|
||||
matchHtml() {
|
||||
if (!this.matchText || !this.search) return ''
|
||||
|
||||
// This used to highlight the part of the search found
|
||||
// but with removing commas periods etc this is no longer plausible
|
||||
const html = this.matchText
|
||||
|
||||
if (this.matchKey === 'episode') return `<p class="truncate">${this.$strings.LabelEpisode}: ${html}</p>`
|
||||
if (this.matchKey === 'tags') return `<p class="truncate">${this.$strings.LabelTags}: ${html}</p>`
|
||||
if (this.matchKey === 'subtitle') return `<p class="truncate">${html}</p>`
|
||||
if (this.matchKey === 'authors') this.$getString('LabelByAuthor', [html])
|
||||
if (this.matchKey === 'isbn') return `<p class="truncate">ISBN: ${html}</p>`
|
||||
if (this.matchKey === 'asin') return `<p class="truncate">ASIN: ${html}</p>`
|
||||
if (this.matchKey === 'series') return `<p class="truncate">${this.$strings.LabelSeries}: ${html}</p>`
|
||||
if (this.matchKey === 'narrators') return `<p class="truncate">${this.$strings.LabelNarrator}: ${html}</p>`
|
||||
return `${html}`
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
|
|
|
|||
|
|
@ -81,16 +81,16 @@ export default {
|
|||
return this.store.getters['user/getSizeMultiplier']
|
||||
},
|
||||
seriesId() {
|
||||
return this.series ? this.series.id : ''
|
||||
return this.series?.id || ''
|
||||
},
|
||||
title() {
|
||||
return this.series ? this.series.name : ''
|
||||
return this.series?.name || ''
|
||||
},
|
||||
nameIgnorePrefix() {
|
||||
return this.series ? this.series.nameIgnorePrefix : ''
|
||||
return this.series?.nameIgnorePrefix || ''
|
||||
},
|
||||
displayTitle() {
|
||||
if (this.sortingIgnorePrefix) return this.nameIgnorePrefix || this.title
|
||||
if (this.sortingIgnorePrefix) return this.nameIgnorePrefix || this.title || '\u00A0'
|
||||
return this.title || '\u00A0'
|
||||
},
|
||||
displaySortLine() {
|
||||
|
|
@ -110,13 +110,13 @@ export default {
|
|||
}
|
||||
},
|
||||
books() {
|
||||
return this.series ? this.series.books || [] : []
|
||||
return this.series?.books || []
|
||||
},
|
||||
addedAt() {
|
||||
return this.series ? this.series.addedAt : 0
|
||||
return this.series?.addedAt || 0
|
||||
},
|
||||
totalDuration() {
|
||||
return this.series ? this.series.totalDuration : 0
|
||||
return this.series?.totalDuration || 0
|
||||
},
|
||||
seriesBookProgress() {
|
||||
return this.books
|
||||
|
|
@ -161,7 +161,7 @@ export default {
|
|||
return this.bookshelfView == constants.BookshelfView.DETAIL
|
||||
},
|
||||
rssFeed() {
|
||||
return this.series ? this.series.rssFeed : null
|
||||
return this.series?.rssFeed
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
</div>
|
||||
<div class="flex-grow px-2 narratorSearchCardContent h-full">
|
||||
<p class="truncate text-sm">{{ narrator }}</p>
|
||||
<p class="text-xs text-gray-400">{{ $getString('LabelXBooks', [numBooks]) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -12,7 +13,8 @@
|
|||
<script>
|
||||
export default {
|
||||
props: {
|
||||
narrator: String
|
||||
narrator: String,
|
||||
numBooks: Number
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
|
|
@ -26,7 +28,7 @@ export default {
|
|||
<style scoped>
|
||||
.narratorSearchCardContent {
|
||||
width: calc(100% - 40px);
|
||||
height: 40px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
</div>
|
||||
<div class="flex-grow px-2 tagSearchCardContent h-full">
|
||||
<p class="truncate text-sm">{{ tag }}</p>
|
||||
<p class="text-xs text-gray-400">{{ $getString('LabelXItems', [numItems]) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -12,7 +13,8 @@
|
|||
<script>
|
||||
export default {
|
||||
props: {
|
||||
tag: String
|
||||
tag: String,
|
||||
numItems: Number
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
|
|
@ -26,7 +28,7 @@ export default {
|
|||
<style>
|
||||
.tagSearchCardContent {
|
||||
width: calc(100% - 40px);
|
||||
height: 40px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@
|
|||
<template v-for="item in bookResults">
|
||||
<li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
||||
<nuxt-link :to="`/item/${item.libraryItem.id}`">
|
||||
<cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
|
||||
<cards-item-search-card :library-item="item.libraryItem" />
|
||||
</nuxt-link>
|
||||
</li>
|
||||
</template>
|
||||
|
|
@ -34,7 +34,7 @@
|
|||
<template v-for="item in podcastResults">
|
||||
<li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
||||
<nuxt-link :to="`/item/${item.libraryItem.id}`">
|
||||
<cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
|
||||
<cards-item-search-card :library-item="item.libraryItem" />
|
||||
</nuxt-link>
|
||||
</li>
|
||||
</template>
|
||||
|
|
@ -59,9 +59,18 @@
|
|||
|
||||
<p v-if="tagResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">{{ $strings.LabelTags }}</p>
|
||||
<template v-for="item in tagResults">
|
||||
<li :key="item.name" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
||||
<li :key="`tag.${item.name}`" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=tags.${$encode(item.name)}`">
|
||||
<cards-tag-search-card :tag="item.name" />
|
||||
<cards-tag-search-card :tag="item.name" :num-items="item.numItems" />
|
||||
</nuxt-link>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<p v-if="genreResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">{{ $strings.LabelGenres }}</p>
|
||||
<template v-for="item in genreResults">
|
||||
<li :key="`genre.${item.name}`" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=genres.${$encode(item.name)}`">
|
||||
<cards-genre-search-card :genre="item.name" :num-items="item.numItems" />
|
||||
</nuxt-link>
|
||||
</li>
|
||||
</template>
|
||||
|
|
@ -70,7 +79,7 @@
|
|||
<template v-for="narrator in narratorResults">
|
||||
<li :key="narrator.name" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=narrators.${$encode(narrator.name)}`">
|
||||
<cards-narrator-search-card :narrator="narrator.name" />
|
||||
<cards-narrator-search-card :narrator="narrator.name" :num-books="narrator.numBooks" />
|
||||
</nuxt-link>
|
||||
</li>
|
||||
</template>
|
||||
|
|
@ -95,6 +104,7 @@ export default {
|
|||
authorResults: [],
|
||||
seriesResults: [],
|
||||
tagResults: [],
|
||||
genreResults: [],
|
||||
narratorResults: [],
|
||||
searchTimeout: null,
|
||||
lastSearch: null
|
||||
|
|
@ -105,7 +115,7 @@ export default {
|
|||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
totalResults() {
|
||||
return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length + this.podcastResults.length + this.narratorResults.length
|
||||
return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length + this.genreResults.length + this.podcastResults.length + this.narratorResults.length
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
|
@ -116,7 +126,7 @@ export default {
|
|||
if (!this.search) return
|
||||
var search = this.search
|
||||
this.clearResults()
|
||||
this.$router.push(`/library/${this.currentLibraryId}/search?q=${search}`)
|
||||
this.$router.push(`/library/${this.currentLibraryId}/search?q=${encodeURIComponent(search)}`)
|
||||
},
|
||||
clearResults() {
|
||||
this.search = null
|
||||
|
|
@ -126,6 +136,7 @@ export default {
|
|||
this.authorResults = []
|
||||
this.seriesResults = []
|
||||
this.tagResults = []
|
||||
this.genreResults = []
|
||||
this.narratorResults = []
|
||||
this.showMenu = false
|
||||
this.isFetching = false
|
||||
|
|
@ -155,7 +166,7 @@ export default {
|
|||
}
|
||||
this.isFetching = true
|
||||
|
||||
const searchResults = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/search?q=${value}&limit=3`).catch((error) => {
|
||||
const searchResults = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/search?q=${encodeURIComponent(value)}&limit=3`).catch((error) => {
|
||||
console.error('Search error', error)
|
||||
return []
|
||||
})
|
||||
|
|
@ -168,6 +179,7 @@ export default {
|
|||
this.authorResults = searchResults.authors || []
|
||||
this.seriesResults = searchResults.series || []
|
||||
this.tagResults = searchResults.tags || []
|
||||
this.genreResults = searchResults.genres || []
|
||||
this.narratorResults = searchResults.narrators || []
|
||||
|
||||
this.isFetching = false
|
||||
|
|
|
|||
|
|
@ -88,6 +88,10 @@ export default {
|
|||
{
|
||||
text: this.$strings.LabelFileModified,
|
||||
value: 'mtimeMs'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelRandomly,
|
||||
value: 'random'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
@ -128,6 +132,10 @@ export default {
|
|||
{
|
||||
text: this.$strings.LabelFileModified,
|
||||
value: 'mtimeMs'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelRandomly,
|
||||
value: 'random'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
<template>
|
||||
<modals-modal v-model="show" name="chapters" :width="600" :height="'unset'">
|
||||
<div id="chapter-modal-wrapper" ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||
<div id="chapter-modal-wrapper" ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||
<template v-for="chap in chapters">
|
||||
<div :key="chap.id" :id="`chapter-row-${chap.id}`" class="flex items-center px-6 py-3 justify-start cursor-pointer hover:bg-bg relative" :class="chap.id === currentChapterId ? 'bg-yellow-400 bg-opacity-10' : chap.end / _playbackRate <= currentChapterStart ? 'bg-success bg-opacity-5' : 'bg-opacity-20'" @click="clickChapter(chap)">
|
||||
<div :key="chap.id" :id="`chapter-row-${chap.id}`" class="flex items-center px-6 py-3 justify-start cursor-pointer relative" :class="chap.id === currentChapterId ? 'bg-yellow-400/20 hover:bg-yellow-400/10' : chap.end / _playbackRate <= currentChapterStart ? 'bg-success/10 hover:bg-success/5' : 'hover:bg-primary/10'" @click="clickChapter(chap)">
|
||||
<p class="chapter-title truncate text-sm md:text-base">
|
||||
{{ chap.title }}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -16,11 +16,18 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-lg font-semibold mb-2">{{ $strings.HeaderPodcastsToAdd }}</p>
|
||||
<p class="text-lg font-semibold mb-1">{{ $strings.HeaderPodcastsToAdd }}</p>
|
||||
<p class="text-sm text-gray-300 mb-4">{{ $strings.MessageOpmlPreviewNote }}</p>
|
||||
|
||||
<div class="w-full overflow-y-auto" style="max-height: 50vh">
|
||||
<template v-for="(feed, index) in feedMetadata">
|
||||
<cards-podcast-feed-summary-card :key="index" :feed="feed" :library-folder-path="selectedFolderPath" class="my-1" />
|
||||
<template v-for="(feed, index) in feeds">
|
||||
<div :key="index" class="py-1 flex items-center">
|
||||
<p class="text-lg font-semibold">{{ index + 1 }}.</p>
|
||||
<div class="pl-2">
|
||||
<p v-if="feed.title" class="text-sm font-semibold">{{ feed.title }}</p>
|
||||
<p class="text-xs text-gray-400">{{ feed.feedUrl }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -45,9 +52,7 @@ export default {
|
|||
return {
|
||||
processing: false,
|
||||
selectedFolderId: null,
|
||||
fullPath: null,
|
||||
autoDownloadEpisodes: false,
|
||||
feedMetadata: []
|
||||
autoDownloadEpisodes: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
|
|
@ -96,73 +101,36 @@ export default {
|
|||
}
|
||||
},
|
||||
methods: {
|
||||
toFeedMetadata(feed) {
|
||||
const metadata = feed.metadata
|
||||
return {
|
||||
title: metadata.title,
|
||||
author: metadata.author,
|
||||
description: metadata.description,
|
||||
releaseDate: '',
|
||||
genres: [...metadata.categories],
|
||||
feedUrl: metadata.feedUrl,
|
||||
imageUrl: metadata.image,
|
||||
itunesPageUrl: '',
|
||||
itunesId: '',
|
||||
itunesArtistId: '',
|
||||
language: '',
|
||||
numEpisodes: feed.numEpisodes
|
||||
}
|
||||
},
|
||||
init() {
|
||||
this.feedMetadata = this.feeds.map(this.toFeedMetadata)
|
||||
|
||||
if (this.folderItems[0]) {
|
||||
this.selectedFolderId = this.folderItems[0].value
|
||||
}
|
||||
},
|
||||
async submit() {
|
||||
this.processing = true
|
||||
const newFeedPayloads = this.feedMetadata.map((metadata) => {
|
||||
return {
|
||||
path: `${this.selectedFolderPath}/${this.$sanitizeFilename(metadata.title)}`,
|
||||
|
||||
const payload = {
|
||||
feeds: this.feeds.map((f) => f.feedUrl),
|
||||
folderId: this.selectedFolderId,
|
||||
libraryId: this.currentLibrary.id,
|
||||
media: {
|
||||
metadata: {
|
||||
...metadata
|
||||
},
|
||||
autoDownloadEpisodes: this.autoDownloadEpisodes
|
||||
}
|
||||
}
|
||||
})
|
||||
console.log('New feed payloads', newFeedPayloads)
|
||||
|
||||
for (const podcastPayload of newFeedPayloads) {
|
||||
await this.$axios
|
||||
.$post('/api/podcasts', podcastPayload)
|
||||
this.$axios
|
||||
.$post('/api/podcasts/opml/create', payload)
|
||||
.then(() => {
|
||||
this.$toast.success(`${podcastPayload.media.metadata.title}: ${this.$strings.ToastPodcastCreateSuccess}`)
|
||||
this.show = false
|
||||
})
|
||||
.catch((error) => {
|
||||
var errorMsg = error.response && error.response.data ? error.response.data : this.$strings.ToastPodcastCreateFailed
|
||||
console.error('Failed to create podcast', podcastPayload, error)
|
||||
this.$toast.error(`${podcastPayload.media.metadata.title}: ${errorMsg}`)
|
||||
const errorMsg = error.response?.data || this.$strings.ToastPodcastCreateFailed
|
||||
console.error('Failed to create podcast', payload, error)
|
||||
this.$toast.error(errorMsg)
|
||||
})
|
||||
}
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
this.show = false
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#podcast-wrapper {
|
||||
min-height: 400px;
|
||||
max-height: 80vh;
|
||||
}
|
||||
#episodes-scroll {
|
||||
max-height: calc(80vh - 200px);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -132,7 +132,7 @@ export default {
|
|||
this.searchedTitle = this.episodeTitle
|
||||
this.isProcessing = true
|
||||
this.$axios
|
||||
.$get(`/api/podcasts/${this.libraryItem.id}/search-episode?title=${this.$encodeUriPath(this.episodeTitle)}`)
|
||||
.$get(`/api/podcasts/${this.libraryItem.id}/search-episode?title=${encodeURIComponent(this.episodeTitle)}`)
|
||||
.then((results) => {
|
||||
this.episodesFound = results.episodes.map((ep) => ep.episode)
|
||||
console.log('Episodes found', this.episodesFound)
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
</template>
|
||||
<template v-else>
|
||||
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8 animate-spin">
|
||||
<span class="material-symbols">autorenew</span>
|
||||
<span class="material-symbols text-2xl">autorenew</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex-grow" />
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@
|
|||
<button v-else class="abs-btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :type="type" :class="classList" @mousedown.prevent @click="click">
|
||||
<slot />
|
||||
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
|
||||
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
|
||||
<span v-if="progress">{{ progress }}</span>
|
||||
<svg v-else class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
|
@ -33,7 +34,8 @@ export default {
|
|||
paddingY: Number,
|
||||
small: Boolean,
|
||||
loading: Boolean,
|
||||
disabled: Boolean
|
||||
disabled: Boolean,
|
||||
progress: String
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
<div class="relative h-9 w-9" v-click-outside="clickOutsideObj">
|
||||
<slot :disabled="disabled" :showMenu="showMenu" :clickShowMenu="clickShowMenu" :processing="processing">
|
||||
<button v-if="!processing" type="button" :disabled="disabled" class="relative h-full w-full flex items-center justify-center shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
|
||||
<span class="material-symbols" :class="iconClass">more_vert</span>
|
||||
<span class="material-symbols text-2xl" :class="iconClass">more_vert</span>
|
||||
</button>
|
||||
<div v-else class="h-full w-full flex items-center justify-center">
|
||||
<widgets-loading-spinner />
|
||||
|
|
|
|||
|
|
@ -92,12 +92,13 @@ export default {
|
|||
|
||||
if (this.$route.name.startsWith('config')) {
|
||||
// No need to refresh
|
||||
} else if (this.$route.name.startsWith('library') && this.$route.name !== 'library-library-series-id') {
|
||||
const newRoute = this.$route.path.replace(currLibraryId, library.id)
|
||||
this.$router.push(newRoute)
|
||||
} else if (this.$route.name === 'library-library-series-id' && library.mediaType === 'book') {
|
||||
// For series item page redirect to root series page
|
||||
this.$router.push(`/library/${library.id}/bookshelf/series`)
|
||||
} else if (this.$route.name === 'library-library-search') {
|
||||
this.$router.push(this.$route.fullPath.replace(currLibraryId, library.id))
|
||||
} else if (this.$route.name.startsWith('library')) {
|
||||
this.$router.push(this.$route.path.replace(currLibraryId, library.id))
|
||||
} else {
|
||||
this.$router.push(`/library/${library.id}`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -212,6 +212,16 @@ export default {
|
|||
this.libraryItemAdded(ab)
|
||||
})
|
||||
},
|
||||
trackStarted(data) {
|
||||
this.$store.commit('tasks/updateAudioFilesEncoding', { libraryItemId: data.libraryItemId, ino: data.ino, progress: '0%' })
|
||||
},
|
||||
trackProgress(data) {
|
||||
this.$store.commit('tasks/updateAudioFilesEncoding', { libraryItemId: data.libraryItemId, ino: data.ino, progress: `${Math.round(data.progress)}%` })
|
||||
},
|
||||
trackFinished(data) {
|
||||
this.$store.commit('tasks/updateAudioFilesEncoding', { libraryItemId: data.libraryItemId, ino: data.ino, progress: '100%' })
|
||||
this.$store.commit('tasks/updateAudioFilesFinished', { libraryItemId: data.libraryItemId, ino: data.ino, finished: true })
|
||||
},
|
||||
taskStarted(task) {
|
||||
console.log('Task started', task)
|
||||
this.$store.commit('tasks/addUpdateTask', task)
|
||||
|
|
@ -220,6 +230,9 @@ export default {
|
|||
console.log('Task finished', task)
|
||||
this.$store.commit('tasks/addUpdateTask', task)
|
||||
},
|
||||
taskProgress(data) {
|
||||
this.$store.commit('tasks/updateTaskProgress', { libraryItemId: data.libraryItemId, progress: `${Math.round(data.progress)}%` })
|
||||
},
|
||||
metadataEmbedQueueUpdate(data) {
|
||||
if (data.queued) {
|
||||
this.$store.commit('tasks/addQueuedEmbedLId', data.libraryItemId)
|
||||
|
|
@ -406,6 +419,10 @@ export default {
|
|||
this.socket.on('task_started', this.taskStarted)
|
||||
this.socket.on('task_finished', this.taskFinished)
|
||||
this.socket.on('metadata_embed_queue_update', this.metadataEmbedQueueUpdate)
|
||||
this.socket.on('track_started', this.trackStarted)
|
||||
this.socket.on('track_finished', this.trackFinished)
|
||||
this.socket.on('track_progress', this.trackProgress)
|
||||
this.socket.on('task_progress', this.taskProgress)
|
||||
|
||||
// EReader Device Listeners
|
||||
this.socket.on('ereader-devices-updated', this.ereaderDevicesUpdated)
|
||||
|
|
|
|||
|
|
@ -37,8 +37,10 @@
|
|||
"devDependencies": {
|
||||
"@nuxtjs/pwa": "^3.3.5",
|
||||
"autoprefixer": "^10.4.7",
|
||||
"cypress": "^13.7.3",
|
||||
"postcss": "^8.3.6",
|
||||
"tailwindcss": "^3.4.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"cypress": "^13.7.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,7 +71,8 @@
|
|||
|
||||
<div class="flex-grow" />
|
||||
|
||||
<ui-btn v-if="!isTaskFinished" color="primary" :loading="processing" @click.stop="embedClick">{{ $strings.ButtonStartMetadataEmbed }}</ui-btn>
|
||||
<ui-btn v-if="!isTaskFinished" color="primary" :loading="processing" :progress="progress" @click.stop="embedClick">{{ $strings.ButtonStartMetadataEmbed }}</ui-btn>
|
||||
<p v-else-if="taskFailed" class="text-error text-lg font-semibold">{{ $strings.MessageEmbedFailed }} {{ taskError }}</p>
|
||||
<p v-else class="text-success text-lg font-semibold">{{ $strings.MessageEmbedFinished }}</p>
|
||||
</div>
|
||||
<!-- m4b embed action buttons -->
|
||||
|
|
@ -83,7 +84,7 @@
|
|||
<div class="flex-grow" />
|
||||
|
||||
<ui-btn v-if="!isTaskFinished && processing" color="error" :loading="isCancelingEncode" class="mr-2" @click.stop="cancelEncodeClick">{{ $strings.ButtonCancelEncode }}</ui-btn>
|
||||
<ui-btn v-if="!isTaskFinished" color="primary" :loading="processing" @click.stop="encodeM4bClick">{{ $strings.ButtonStartM4BEncode }}</ui-btn>
|
||||
<ui-btn v-if="!isTaskFinished" color="primary" :loading="processing" :progress="progress" @click.stop="encodeM4bClick">{{ $strings.ButtonStartM4BEncode }}</ui-btn>
|
||||
<p v-else-if="taskFailed" class="text-error text-lg font-semibold">{{ $strings.MessageM4BFailed }} {{ taskError }}</p>
|
||||
<p v-else class="text-success text-lg font-semibold">{{ $strings.MessageM4BFinished }}</p>
|
||||
</div>
|
||||
|
|
@ -159,9 +160,9 @@
|
|||
</div>
|
||||
<div class="w-24">
|
||||
<div class="flex justify-center">
|
||||
<span v-if="audiofilesFinished[file.ino]" class="material-symbols text-xl text-success leading-none">check_circle</span>
|
||||
<div v-else-if="audiofilesEncoding[file.ino]">
|
||||
<widgets-loading-spinner />
|
||||
<span v-if="audioFilesFinished[file.ino]" class="material-symbols text-xl text-success leading-none">check_circle</span>
|
||||
<div v-else-if="audioFilesEncoding[file.ino]">
|
||||
<span class="font-mono text-success leading-none">{{ audioFilesEncoding[file.ino] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -205,8 +206,6 @@ export default {
|
|||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
audiofilesEncoding: {},
|
||||
audiofilesFinished: {},
|
||||
metadataObject: null,
|
||||
selectedTool: 'embed',
|
||||
isCancelingEncode: false,
|
||||
|
|
@ -229,6 +228,15 @@ export default {
|
|||
}
|
||||
},
|
||||
computed: {
|
||||
audioFilesEncoding() {
|
||||
return this.$store.getters['tasks/getAudioFilesEncoding'](this.libraryItemId) || {}
|
||||
},
|
||||
audioFilesFinished() {
|
||||
return this.$store.getters['tasks/getAudioFilesFinished'](this.libraryItemId) || {}
|
||||
},
|
||||
progress() {
|
||||
return this.$store.getters['tasks/getTaskProgress'](this.libraryItemId) || '0%'
|
||||
},
|
||||
isEmbedTool() {
|
||||
return this.selectedTool === 'embed'
|
||||
},
|
||||
|
|
@ -372,15 +380,6 @@ export default {
|
|||
this.processing = false
|
||||
})
|
||||
},
|
||||
audiofileMetadataStarted(data) {
|
||||
if (data.libraryItemId !== this.libraryItemId) return
|
||||
this.$set(this.audiofilesEncoding, data.ino, true)
|
||||
},
|
||||
audiofileMetadataFinished(data) {
|
||||
if (data.libraryItemId !== this.libraryItemId) return
|
||||
this.$set(this.audiofilesEncoding, data.ino, false)
|
||||
this.$set(this.audiofilesFinished, data.ino, true)
|
||||
},
|
||||
selectedToolUpdated() {
|
||||
let newurl = window.location.protocol + '//' + window.location.host + window.location.pathname + `?tool=${this.selectedTool}`
|
||||
window.history.replaceState({ path: newurl }, '', newurl)
|
||||
|
|
@ -416,12 +415,6 @@ export default {
|
|||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
this.$root.socket.on('audiofile_metadata_started', this.audiofileMetadataStarted)
|
||||
this.$root.socket.on('audiofile_metadata_finished', this.audiofileMetadataFinished)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$root.socket.off('audiofile_metadata_started', this.audiofileMetadataStarted)
|
||||
this.$root.socket.off('audiofile_metadata_finished', this.audiofileMetadataFinished)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -170,7 +170,7 @@ export default {
|
|||
})
|
||||
},
|
||||
updateBackupsSettings() {
|
||||
if (isNaN(this.maxBackupSize) || this.maxBackupSize <= 0) {
|
||||
if (isNaN(this.maxBackupSize) || this.maxBackupSize < 0) {
|
||||
this.$toast.error('Invalid maximum backup size')
|
||||
return
|
||||
}
|
||||
|
|
@ -200,10 +200,9 @@ export default {
|
|||
},
|
||||
initServerSettings() {
|
||||
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
||||
|
||||
this.backupsToKeep = this.newServerSettings.backupsToKeep || 2
|
||||
this.enableBackups = !!this.newServerSettings.backupSchedule
|
||||
this.maxBackupSize = this.newServerSettings.maxBackupSize || 1
|
||||
this.maxBackupSize = this.newServerSettings.maxBackupSize === 0 ? 0 : this.newServerSettings.maxBackupSize || 1
|
||||
this.cronExpression = this.newServerSettings.backupSchedule || '30 1 * * *'
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
/>
|
||||
</svg>
|
||||
<div class="px-3">
|
||||
<p class="text-4xl md:text-5xl font-bold">{{ userItemsFinished.length }}</p>
|
||||
<p class="text-4xl md:text-5xl font-bold">{{ $formatNumber(userItemsFinished.length) }}</p>
|
||||
<p class="text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsItemsFinished }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -23,7 +23,7 @@
|
|||
<span class="hidden sm:block material-symbols-outlined text-5xl lg:text-6xl">event</span>
|
||||
</div>
|
||||
<div class="px-1">
|
||||
<p class="text-4xl md:text-5xl font-bold">{{ totalDaysListened }}</p>
|
||||
<p class="text-4xl md:text-5xl font-bold">{{ $formatNumber(totalDaysListened) }}</p>
|
||||
<p class="text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsDaysListened }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -33,7 +33,7 @@
|
|||
<span class="material-symbols-outlined text-5xl lg:text-6xl">watch_later</span>
|
||||
</div>
|
||||
<div class="px-1">
|
||||
<p class="text-4xl md:text-5xl font-bold">{{ totalMinutesListening }}</p>
|
||||
<p class="text-4xl md:text-5xl font-bold">{{ $formatNumber(totalMinutesListening) }}</p>
|
||||
<p class="text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsMinutesListening }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@
|
|||
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="148" @action="contextMenuAction">
|
||||
<template #default="{ showMenu, clickShowMenu, disabled }">
|
||||
<button type="button" :disabled="disabled" class="mx-0.5 icon-btn bg-primary border border-gray-600 w-9 h-9 rounded-md flex items-center justify-center relative" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
|
||||
<span class="material-symbols">more_horiz</span>
|
||||
<span class="material-symbols text-2xl">more_horiz</span>
|
||||
</button>
|
||||
</template>
|
||||
</ui-context-menu-dropdown>
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ export default {
|
|||
if (typeof a[sortProp] === 'number' && typeof b[sortProp] === 'number') {
|
||||
return a[sortProp] > b[sortProp] ? bDesc : -bDesc
|
||||
}
|
||||
return a[sortProp].localeCompare(b[sortProp], undefined, { sensitivity: 'base' }) * bDesc
|
||||
return a[sortProp]?.localeCompare(b[sortProp], undefined, { sensitivity: 'base' }) * bDesc
|
||||
})
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -113,18 +113,23 @@ export default {
|
|||
return
|
||||
}
|
||||
|
||||
await this.$axios
|
||||
.$post(`/api/podcasts/opml`, { opmlText: txt })
|
||||
this.$axios
|
||||
.$post(`/api/podcasts/opml/parse`, { opmlText: txt })
|
||||
.then((data) => {
|
||||
console.log(data)
|
||||
if (!data.feeds?.length) {
|
||||
this.$toast.error('No feeds found in OPML file')
|
||||
} else {
|
||||
this.opmlFeeds = data.feeds || []
|
||||
this.showOPMLFeedsModal = true
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.$toast.error('Failed to parse OPML file')
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
submit() {
|
||||
if (!this.searchInput) return
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export default {
|
|||
if (!library) {
|
||||
return redirect('/oops?message=Library not found')
|
||||
}
|
||||
let results = await app.$axios.$get(`/api/libraries/${libraryId}/search?q=${query.q}`).catch((error) => {
|
||||
let results = await app.$axios.$get(`/api/libraries/${libraryId}/search?q=${encodeURIComponent(query.q)}`).catch((error) => {
|
||||
console.error('Failed to search library', error)
|
||||
return null
|
||||
})
|
||||
|
|
@ -55,7 +55,7 @@ export default {
|
|||
},
|
||||
methods: {
|
||||
async search() {
|
||||
const results = await this.$axios.$get(`/api/libraries/${this.libraryId}/search?q=${this.query}`).catch((error) => {
|
||||
const results = await this.$axios.$get(`/api/libraries/${this.libraryId}/search?q=${encodeURIComponent(this.query)}`).catch((error) => {
|
||||
console.error('Failed to search library', error)
|
||||
return null
|
||||
})
|
||||
|
|
|
|||
|
|
@ -36,10 +36,10 @@ export default class PlayerHandler {
|
|||
return this.libraryItem ? this.libraryItem.id : null
|
||||
}
|
||||
get isPlayingCastedItem() {
|
||||
return this.libraryItem && (this.player instanceof CastPlayer)
|
||||
return this.libraryItem && this.player instanceof CastPlayer
|
||||
}
|
||||
get isPlayingLocalItem() {
|
||||
return this.libraryItem && (this.player instanceof LocalAudioPlayer)
|
||||
return this.libraryItem && this.player instanceof LocalAudioPlayer
|
||||
}
|
||||
get userToken() {
|
||||
return this.ctx.$store.getters['user/getToken']
|
||||
|
|
@ -49,7 +49,7 @@ export default class PlayerHandler {
|
|||
}
|
||||
get episode() {
|
||||
if (!this.episodeId) return null
|
||||
return this.libraryItem.media.episodes.find(ep => ep.id === this.episodeId)
|
||||
return this.libraryItem.media.episodes.find((ep) => ep.id === this.episodeId)
|
||||
}
|
||||
get jumpForwardAmount() {
|
||||
return this.ctx.$store.getters['user/getUserSetting']('jumpForwardAmount')
|
||||
|
|
@ -72,7 +72,7 @@ export default class PlayerHandler {
|
|||
this.playWhenReady = playWhenReady
|
||||
this.initialPlaybackRate = this.isMusic ? 1 : playbackRate
|
||||
|
||||
this.startTimeOverride = (startTimeOverride == null || isNaN(startTimeOverride)) ? undefined : Number(startTimeOverride)
|
||||
this.startTimeOverride = startTimeOverride == null || isNaN(startTimeOverride) ? undefined : Number(startTimeOverride)
|
||||
|
||||
if (!this.player) this.switchPlayer(playWhenReady)
|
||||
else this.prepare()
|
||||
|
|
@ -133,7 +133,7 @@ export default class PlayerHandler {
|
|||
|
||||
playerError() {
|
||||
// Switch to HLS stream on error
|
||||
if (!this.isCasting && (this.player instanceof LocalAudioPlayer)) {
|
||||
if (!this.isCasting && this.player instanceof LocalAudioPlayer) {
|
||||
console.log(`[PlayerHandler] Audio player error switching to HLS stream`)
|
||||
this.prepare(true)
|
||||
}
|
||||
|
|
@ -213,7 +213,8 @@ export default class PlayerHandler {
|
|||
this.prepareSession(session)
|
||||
}
|
||||
|
||||
prepareOpenSession(session, playbackRate) { // Session opened on init socket
|
||||
prepareOpenSession(session, playbackRate) {
|
||||
// Session opened on init socket
|
||||
if (!this.player) this.switchPlayer() // Must set player first for open sessions
|
||||
|
||||
this.libraryItem = session.libraryItem
|
||||
|
|
@ -247,7 +248,7 @@ export default class PlayerHandler {
|
|||
|
||||
this.player.set(this.libraryItem, videoTrack, this.isHlsTranscode, this.startTime, this.playWhenReady)
|
||||
} else {
|
||||
var audioTracks = session.audioTracks.map(at => new AudioTrack(at, this.userToken))
|
||||
var audioTracks = session.audioTracks.map((at) => new AudioTrack(at, this.userToken))
|
||||
|
||||
this.ctx.playerLoading = true
|
||||
this.isHlsTranscode = true
|
||||
|
|
@ -301,7 +302,7 @@ export default class PlayerHandler {
|
|||
const currentTime = this.player.getCurrentTime()
|
||||
this.ctx.setCurrentTime(currentTime)
|
||||
|
||||
const exactTimeElapsed = ((Date.now() - lastTick) / 1000)
|
||||
const exactTimeElapsed = (Date.now() - lastTick) / 1000
|
||||
lastTick = Date.now()
|
||||
this.listeningTimeSinceSync += exactTimeElapsed
|
||||
const TimeToWaitBeforeSync = this.lastSyncTime > 0 ? 10 : 20
|
||||
|
|
@ -326,7 +327,7 @@ export default class PlayerHandler {
|
|||
}
|
||||
this.listeningTimeSinceSync = 0
|
||||
this.lastSyncTime = 0
|
||||
return this.ctx.$axios.$post(`/api/session/${this.currentSessionId}/close`, syncData, { timeout: 6000 }).catch((error) => {
|
||||
return this.ctx.$axios.$post(`/api/session/${this.currentSessionId}/close`, syncData, { timeout: 6000, progress: false }).catch((error) => {
|
||||
console.error('Failed to close session', error)
|
||||
})
|
||||
}
|
||||
|
|
@ -346,9 +347,12 @@ export default class PlayerHandler {
|
|||
}
|
||||
|
||||
this.listeningTimeSinceSync = 0
|
||||
this.ctx.$axios.$post(`/api/session/${this.currentSessionId}/sync`, syncData, { timeout: 9000 }).then(() => {
|
||||
this.ctx.$axios
|
||||
.$post(`/api/session/${this.currentSessionId}/sync`, syncData, { timeout: 9000, progress: false })
|
||||
.then(() => {
|
||||
this.failedProgressSyncs = 0
|
||||
}).catch((error) => {
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update session progress', error)
|
||||
// After 4 failed sync attempts show an alert toast
|
||||
this.failedProgressSyncs++
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import * as locale from 'date-fns/locale'
|
|||
|
||||
Vue.directive('click-outside', vClickOutside.directive)
|
||||
|
||||
|
||||
Vue.prototype.$setDateFnsLocale = (localeString) => {
|
||||
if (!locale[localeString]) return 0
|
||||
return setDefaultOptions({ locale: locale[localeString] })
|
||||
|
|
@ -112,14 +111,15 @@ Vue.prototype.$sanitizeSlug = (str) => {
|
|||
str = str.toLowerCase()
|
||||
|
||||
// remove accents, swap ñ for n, etc
|
||||
var from = "àáäâèéëêìíïîòóöôùúüûñçěščřžýúůďťň·/,:;"
|
||||
var to = "aaaaeeeeiiiioooouuuuncescrzyuudtn-----"
|
||||
var from = 'àáäâèéëêìíïîòóöôùúüûñçěščřžýúůďťň·/,:;'
|
||||
var to = 'aaaaeeeeiiiioooouuuuncescrzyuudtn-----'
|
||||
|
||||
for (var i = 0, l = from.length; i < l; i++) {
|
||||
str = str.replace(new RegExp(from.charAt(i), 'g'), to.charAt(i))
|
||||
}
|
||||
|
||||
str = str.replace('.', '-') // replace a dot by a dash
|
||||
str = str
|
||||
.replace('.', '-') // replace a dot by a dash
|
||||
.replace(/[^a-z0-9 -_]/g, '') // remove invalid chars
|
||||
.replace(/\s+/g, '-') // collapse whitespace and replace by a dash
|
||||
.replace(/-+/g, '-') // collapse dashes
|
||||
|
|
@ -131,13 +131,16 @@ Vue.prototype.$sanitizeSlug = (str) => {
|
|||
Vue.prototype.$copyToClipboard = (str, ctx) => {
|
||||
return new Promise((resolve) => {
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(str).then(() => {
|
||||
navigator.clipboard.writeText(str).then(
|
||||
() => {
|
||||
if (ctx) ctx.$toast.success('Copied to clipboard')
|
||||
resolve(true)
|
||||
}, (err) => {
|
||||
},
|
||||
(err) => {
|
||||
console.error('Clipboard copy failed', str, err)
|
||||
resolve(false)
|
||||
})
|
||||
}
|
||||
)
|
||||
} else {
|
||||
const el = document.createElement('textarea')
|
||||
el.value = str
|
||||
|
|
@ -160,26 +163,18 @@ function xmlToJson(xml) {
|
|||
for (const res of xml.matchAll(/(?:<(\w*)(?:\s[^>]*)*>)((?:(?!<\1).)*)(?:<\/\1>)|<(\w*)(?:\s*)*\/>/gm)) {
|
||||
const key = res[1] || res[3]
|
||||
const value = res[2] && xmlToJson(res[2])
|
||||
json[key] = ((value && Object.keys(value).length) ? value : res[2]) || null
|
||||
|
||||
json[key] = (value && Object.keys(value).length ? value : res[2]) || null
|
||||
}
|
||||
return json
|
||||
}
|
||||
Vue.prototype.$xmlToJson = xmlToJson
|
||||
|
||||
Vue.prototype.$encodeUriPath = (path) => {
|
||||
return path.replace(/\\/g, '/').replace(/%/g, '%25').replace(/#/g, '%23')
|
||||
}
|
||||
|
||||
const encode = (text) => encodeURIComponent(Buffer.from(text).toString('base64'))
|
||||
Vue.prototype.$encode = encode
|
||||
const decode = (text) => Buffer.from(decodeURIComponent(text), 'base64').toString()
|
||||
Vue.prototype.$decode = decode
|
||||
|
||||
export {
|
||||
encode,
|
||||
decode
|
||||
}
|
||||
export { encode, decode }
|
||||
export default ({ app, store }, inject) => {
|
||||
app.$decode = decode
|
||||
app.$encode = encode
|
||||
|
|
|
|||
|
|
@ -1,34 +1,60 @@
|
|||
import Vue from 'vue'
|
||||
|
||||
export const state = () => ({
|
||||
tasks: [],
|
||||
queuedEmbedLIds: []
|
||||
queuedEmbedLIds: [],
|
||||
audioFilesEncoding: {},
|
||||
audioFilesFinished: {},
|
||||
taskProgress: {}
|
||||
})
|
||||
|
||||
export const getters = {
|
||||
getTasksByLibraryItemId: (state) => (libraryItemId) => {
|
||||
return state.tasks.filter(t => t.data?.libraryItemId === libraryItemId)
|
||||
return state.tasks.filter((t) => t.data?.libraryItemId === libraryItemId)
|
||||
},
|
||||
getRunningLibraryScanTask: (state) => (libraryId) => {
|
||||
const libraryScanActions = ['library-scan', 'library-match-all']
|
||||
return state.tasks.find(t => libraryScanActions.includes(t.action) && t.data?.libraryId === libraryId && !t.isFinished)
|
||||
return state.tasks.find((t) => libraryScanActions.includes(t.action) && t.data?.libraryId === libraryId && !t.isFinished)
|
||||
},
|
||||
getAudioFilesEncoding: (state) => (libraryItemId) => {
|
||||
return state.audioFilesEncoding[libraryItemId]
|
||||
},
|
||||
getAudioFilesFinished: (state) => (libraryItemId) => {
|
||||
return state.audioFilesFinished[libraryItemId]
|
||||
},
|
||||
getTaskProgress: (state) => (libraryItemId) => {
|
||||
return state.taskProgress[libraryItemId]
|
||||
}
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
|
||||
}
|
||||
export const actions = {}
|
||||
|
||||
export const mutations = {
|
||||
updateAudioFilesEncoding(state, payload) {
|
||||
if (!state.audioFilesEncoding[payload.libraryItemId]) {
|
||||
Vue.set(state.audioFilesEncoding, payload.libraryItemId, {})
|
||||
}
|
||||
Vue.set(state.audioFilesEncoding[payload.libraryItemId], payload.ino, payload.progress)
|
||||
},
|
||||
updateAudioFilesFinished(state, payload) {
|
||||
if (!state.audioFilesFinished[payload.libraryItemId]) {
|
||||
Vue.set(state.audioFilesFinished, payload.libraryItemId, {})
|
||||
}
|
||||
Vue.set(state.audioFilesFinished[payload.libraryItemId], payload.ino, payload.finished)
|
||||
},
|
||||
updateTaskProgress(state, payload) {
|
||||
Vue.set(state.taskProgress, payload.libraryItemId, payload.progress)
|
||||
},
|
||||
setTasks(state, tasks) {
|
||||
state.tasks = tasks
|
||||
},
|
||||
addUpdateTask(state, task) {
|
||||
const index = state.tasks.findIndex(d => d.id === task.id)
|
||||
const index = state.tasks.findIndex((d) => d.id === task.id)
|
||||
if (index >= 0) {
|
||||
state.tasks.splice(index, 1, task)
|
||||
} else {
|
||||
// Remove duplicate (only have one library item per action)
|
||||
state.tasks = state.tasks.filter(_task => {
|
||||
state.tasks = state.tasks.filter((_task) => {
|
||||
if (!_task.data?.libraryItemId || _task.action !== task.action) return true
|
||||
return _task.data.libraryItemId !== task.data.libraryItemId
|
||||
})
|
||||
|
|
@ -37,17 +63,17 @@ export const mutations = {
|
|||
}
|
||||
},
|
||||
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)) {
|
||||
if (!state.queuedEmbedLIds.some((lid) => lid === libraryItemId)) {
|
||||
state.queuedEmbedLIds.push(libraryItemId)
|
||||
}
|
||||
},
|
||||
removeQueuedEmbedLId(state, libraryItemId) {
|
||||
state.queuedEmbedLIds = state.queuedEmbedLIds.filter(lid => lid !== libraryItemId)
|
||||
state.queuedEmbedLIds = state.queuedEmbedLIds.filter((lid) => lid !== libraryItemId)
|
||||
}
|
||||
}
|
||||
|
|
@ -59,6 +59,7 @@
|
|||
"ButtonPurgeItemsCache": "Lösche Medien-Cache",
|
||||
"ButtonQueueAddItem": "Zur Warteschlange hinzufügen",
|
||||
"ButtonQueueRemoveItem": "Aus der Warteschlange entfernen",
|
||||
"ButtonQuickEmbedMetadata": "Schnelles Hinzufügen von Metadaten",
|
||||
"ButtonQuickMatch": "Schnellabgleich",
|
||||
"ButtonReScan": "Neu scannen",
|
||||
"ButtonRead": "Lesen",
|
||||
|
|
@ -66,11 +67,11 @@
|
|||
"ButtonReadMore": "Mehr anzeigen",
|
||||
"ButtonRefresh": "Neu Laden",
|
||||
"ButtonRemove": "Entfernen",
|
||||
"ButtonRemoveAll": "Alles löschen",
|
||||
"ButtonRemoveAllLibraryItems": "Lösche alle Bibliothekseinträge",
|
||||
"ButtonRemoveFromContinueListening": "Lösche den Eintrag aus der Fortsetzungsliste",
|
||||
"ButtonRemoveFromContinueReading": "Lösche die Serie aus der Lesefortsetzungsliste",
|
||||
"ButtonRemoveSeriesFromContinueSeries": "Lösche die Serie aus der Serienfortsetzungsliste",
|
||||
"ButtonRemoveAll": "Alles entfernen",
|
||||
"ButtonRemoveAllLibraryItems": "Entferne alle Bibliothekseinträge",
|
||||
"ButtonRemoveFromContinueListening": "Entferne den Eintrag aus der Fortsetzungsliste",
|
||||
"ButtonRemoveFromContinueReading": "Entferne die Serie aus der Lesefortsetzungsliste",
|
||||
"ButtonRemoveSeriesFromContinueSeries": "Entferne die Serie aus der Serienfortsetzungsliste",
|
||||
"ButtonReset": "Zurücksetzen",
|
||||
"ButtonResetToDefault": "Zurücksetzen auf Standard",
|
||||
"ButtonRestore": "Wiederherstellen",
|
||||
|
|
@ -88,6 +89,7 @@
|
|||
"ButtonShow": "Anzeigen",
|
||||
"ButtonStartM4BEncode": "M4B-Kodierung starten",
|
||||
"ButtonStartMetadataEmbed": "Metadateneinbettung starten",
|
||||
"ButtonStats": "Statistiken",
|
||||
"ButtonSubmit": "Ok",
|
||||
"ButtonTest": "Test",
|
||||
"ButtonUpload": "Hochladen",
|
||||
|
|
@ -154,6 +156,7 @@
|
|||
"HeaderPasswordAuthentication": "Passwort Authentifizierung",
|
||||
"HeaderPermissions": "Berechtigungen",
|
||||
"HeaderPlayerQueue": "Player Warteschlange",
|
||||
"HeaderPlayerSettings": "Player Einstellungen",
|
||||
"HeaderPlaylist": "Wiedergabeliste",
|
||||
"HeaderPlaylistItems": "Einträge in der Wiedergabeliste",
|
||||
"HeaderPodcastsToAdd": "Podcasts zum Hinzufügen",
|
||||
|
|
@ -161,8 +164,8 @@
|
|||
"HeaderRSSFeedGeneral": "RSS Details",
|
||||
"HeaderRSSFeedIsOpen": "RSS-Feed ist geöffnet",
|
||||
"HeaderRSSFeeds": "RSS-Feeds",
|
||||
"HeaderRemoveEpisode": "Episode löschen",
|
||||
"HeaderRemoveEpisodes": "Lösche {0} Episoden",
|
||||
"HeaderRemoveEpisode": "Episode entfernen",
|
||||
"HeaderRemoveEpisodes": "Entferne {0} Episoden",
|
||||
"HeaderSavedMediaProgress": "Gespeicherte Hörfortschritte",
|
||||
"HeaderSchedule": "Zeitplan",
|
||||
"HeaderScheduleLibraryScans": "Automatische Bibliotheksscans",
|
||||
|
|
@ -259,7 +262,7 @@
|
|||
"LabelCustomCronExpression": "Benutzerdefinierter Cron-Ausdruck:",
|
||||
"LabelDatetime": "Datum & Uhrzeit",
|
||||
"LabelDays": "Tage",
|
||||
"LabelDeleteFromFileSystemCheckbox": "Löschen von der Festplatte + Datenbank (deaktivieren um nur aus der Datenbank zu löschen)",
|
||||
"LabelDeleteFromFileSystemCheckbox": "Löschen von der Festplatte + Datenbank (deaktivieren um nur aus der Datenbank zu entfernen)",
|
||||
"LabelDescription": "Beschreibung",
|
||||
"LabelDeselectAll": "Alles abwählen",
|
||||
"LabelDevice": "Gerät",
|
||||
|
|
@ -289,13 +292,16 @@
|
|||
"LabelEmbeddedCover": "Eingebettetes Cover",
|
||||
"LabelEnable": "Aktivieren",
|
||||
"LabelEnd": "Ende",
|
||||
"LabelEndOfChapter": "Ende des Kapitels",
|
||||
"LabelEpisode": "Episode",
|
||||
"LabelEpisodeTitle": "Episodentitel",
|
||||
"LabelEpisodeType": "Episodentyp",
|
||||
"LabelExample": "Beispiel",
|
||||
"LabelExpandSeries": "Serie erweitern",
|
||||
"LabelExplicit": "Explizit (Altersbeschränkung)",
|
||||
"LabelExplicitChecked": "Explicit (Altersbeschränkung) (angehakt)",
|
||||
"LabelExplicitUnchecked": "Not Explicit (Altersbeschränkung) (nicht angehakt)",
|
||||
"LabelExportOPML": "OPML exportieren",
|
||||
"LabelFeedURL": "Feed URL",
|
||||
"LabelFetchingMetadata": "Abholen der Metadaten",
|
||||
"LabelFile": "Datei",
|
||||
|
|
@ -319,6 +325,7 @@
|
|||
"LabelHardDeleteFile": "Datei dauerhaft löschen",
|
||||
"LabelHasEbook": "E-Book verfügbar",
|
||||
"LabelHasSupplementaryEbook": "Ergänzendes E-Book verfügbar",
|
||||
"LabelHideSubtitles": "Untertitel ausblenden",
|
||||
"LabelHighestPriority": "Höchste Priorität",
|
||||
"LabelHost": "Host",
|
||||
"LabelHour": "Stunde",
|
||||
|
|
@ -339,6 +346,8 @@
|
|||
"LabelIntervalEveryHour": "Jede Stunde",
|
||||
"LabelInvert": "Umkehren",
|
||||
"LabelItem": "Medium",
|
||||
"LabelJumpBackwardAmount": "Zurückspringen Zeit",
|
||||
"LabelJumpForwardAmount": "Vorwärtsspringn Zeit",
|
||||
"LabelLanguage": "Sprache",
|
||||
"LabelLanguageDefaultServer": "Standard-Server-Sprache",
|
||||
"LabelLanguages": "Sprachen",
|
||||
|
|
@ -446,6 +455,7 @@
|
|||
"LabelRSSFeedPreventIndexing": "Indizierung verhindern",
|
||||
"LabelRSSFeedSlug": "RSS-Feed-Schlagwort",
|
||||
"LabelRSSFeedURL": "RSS Feed URL",
|
||||
"LabelReAddSeriesToContinueListening": "Serien erneut zur Fortsetzungsliste hinzufügen",
|
||||
"LabelRead": "Lesen",
|
||||
"LabelReadAgain": "Noch einmal Lesen",
|
||||
"LabelReadEbookWithoutProgress": "E-Book lesen und Fortschritt verwerfen",
|
||||
|
|
@ -455,7 +465,7 @@
|
|||
"LabelRedo": "Wiederholen",
|
||||
"LabelRegion": "Region",
|
||||
"LabelReleaseDate": "Veröffentlichungsdatum",
|
||||
"LabelRemoveCover": "Lösche Titelbild",
|
||||
"LabelRemoveCover": "Entferne Titelbild",
|
||||
"LabelRowsPerPage": "Zeilen pro Seite",
|
||||
"LabelSearchTerm": "Begriff suchen",
|
||||
"LabelSearchTitle": "Titel suchen",
|
||||
|
|
@ -512,10 +522,11 @@
|
|||
"LabelSettingsStoreMetadataWithItemHelp": "Standardmäßig werden die Metadaten in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Metadaten als OPF-Datei (Textdatei) in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet",
|
||||
"LabelSettingsTimeFormat": "Zeitformat",
|
||||
"LabelShare": "Teilen",
|
||||
"LabelShareOpen": "Teilen Offen",
|
||||
"LabelShareOpen": "Teilen öffnen",
|
||||
"LabelShareURL": "URL teilen",
|
||||
"LabelShowAll": "Alles anzeigen",
|
||||
"LabelShowSeconds": "Zeige Sekunden",
|
||||
"LabelShowSubtitles": "Untertitel anzeigen",
|
||||
"LabelSize": "Größe",
|
||||
"LabelSleepTimer": "Schlummerfunktion",
|
||||
"LabelSlug": "URL Teil",
|
||||
|
|
@ -553,6 +564,10 @@
|
|||
"LabelThemeDark": "Dunkel",
|
||||
"LabelThemeLight": "Hell",
|
||||
"LabelTimeBase": "Basiszeit",
|
||||
"LabelTimeDurationXHours": "{0} Stunden",
|
||||
"LabelTimeDurationXMinutes": "{0} Minuten",
|
||||
"LabelTimeDurationXSeconds": "{0} Sekunden",
|
||||
"LabelTimeInMinutes": "Zeit in Minuten",
|
||||
"LabelTimeListened": "Gehörte Zeit",
|
||||
"LabelTimeListenedToday": "Heute gehörte Zeit",
|
||||
"LabelTimeRemaining": "{0} verbleibend",
|
||||
|
|
@ -592,6 +607,7 @@
|
|||
"LabelVersion": "Version",
|
||||
"LabelViewBookmarks": "Lesezeichen anzeigen",
|
||||
"LabelViewChapters": "Kapitel anzeigen",
|
||||
"LabelViewPlayerSettings": "Zeige player Einstellungen",
|
||||
"LabelViewQueue": "Player-Warteschlange anzeigen",
|
||||
"LabelVolume": "Lautstärke",
|
||||
"LabelWeekdaysToRun": "Wochentage für die Ausführung",
|
||||
|
|
@ -637,11 +653,11 @@
|
|||
"MessageConfirmReScanLibraryItems": "{0} Elemente werden erneut gescannt! Bist du dir sicher?",
|
||||
"MessageConfirmRemoveAllChapters": "Alle Kapitel werden entfernt! Bist du dir sicher?",
|
||||
"MessageConfirmRemoveAuthor": "Autor \"{0}\" wird enfernt! Bist du dir sicher?",
|
||||
"MessageConfirmRemoveCollection": "Sammlung \"{0}\" wird gelöscht! Bist du dir sicher?",
|
||||
"MessageConfirmRemoveEpisode": "Episode \"{0}\" wird geloscht! Bist du dir sicher?",
|
||||
"MessageConfirmRemoveEpisodes": "{0} Episoden werden gelöscht! Bist du dir sicher?",
|
||||
"MessageConfirmRemoveCollection": "Sammlung \"{0}\" wird entfernt! Bist du dir sicher?",
|
||||
"MessageConfirmRemoveEpisode": "Episode \"{0}\" wird entfernt! Bist du dir sicher?",
|
||||
"MessageConfirmRemoveEpisodes": "{0} Episoden werden entfernt! Bist du dir sicher?",
|
||||
"MessageConfirmRemoveListeningSessions": "Bist du dir sicher, dass du {0} Hörsitzungen enfernen möchtest?",
|
||||
"MessageConfirmRemoveNarrator": "Erzähler \"{0}\" wird gelöscht! Bist du dir sicher?",
|
||||
"MessageConfirmRemoveNarrator": "Erzähler \"{0}\" wird entfernt! Bist du dir sicher?",
|
||||
"MessageConfirmRemovePlaylist": "Wiedergabeliste \"{0}\" wird entfernt! Bist du dir sicher?",
|
||||
"MessageConfirmRenameGenre": "Kategorie \"{0}\" in \"{1}\" für alle Hörbücher/Podcasts werden umbenannt! Bist du dir sicher?",
|
||||
"MessageConfirmRenameGenreMergeNote": "Hinweis: Kategorie existiert bereits -> Kategorien werden zusammengelegt.",
|
||||
|
|
@ -712,9 +728,9 @@
|
|||
"MessagePlaylistCreateFromCollection": "Erstelle eine Wiedergabeliste aus der Sammlung",
|
||||
"MessagePodcastHasNoRSSFeedForMatching": "Der Podcast hat keine RSS-Feed-Url welche für den Online-Abgleich verwendet werden kann",
|
||||
"MessageQuickMatchDescription": "Füllt leere Details und Titelbilder mit dem ersten Treffer aus '{0}'. Überschreibt keine Details, es sei denn, die Server-Einstellung \"Passende Metadaten bevorzugen\" ist aktiviert.",
|
||||
"MessageRemoveChapter": "Kapitel löschen",
|
||||
"MessageRemoveChapter": "Kapitel entfernen",
|
||||
"MessageRemoveEpisodes": "Entferne {0} Episode(n)",
|
||||
"MessageRemoveFromPlayerQueue": "Aus der Abspielwarteliste löschen",
|
||||
"MessageRemoveFromPlayerQueue": "Aus der Abspielwarteliste entfernen",
|
||||
"MessageRemoveUserWarning": "Benutzer \"{0}\" wird dauerhaft gelöscht! Bist du dir sicher?",
|
||||
"MessageReportBugsAndContribute": "Fehler melden, Funktionen anfordern und mitwirken",
|
||||
"MessageResetChaptersConfirm": "Kapitel und vorgenommenen Änderungen werden zurückgesetzt und rückgängig gemacht! Bist du dir sicher?",
|
||||
|
|
@ -769,8 +785,8 @@
|
|||
"ToastBatchUpdateSuccess": "Stapelaktualisierung erfolgreich",
|
||||
"ToastBookmarkCreateFailed": "Lesezeichen konnte nicht erstellt werden",
|
||||
"ToastBookmarkCreateSuccess": "Lesezeichen hinzugefügt",
|
||||
"ToastBookmarkRemoveFailed": "Lesezeichen konnte nicht gelöscht werden",
|
||||
"ToastBookmarkRemoveSuccess": "Lesezeichen gelöscht",
|
||||
"ToastBookmarkRemoveFailed": "Lesezeichen konnte nicht entfernt werden",
|
||||
"ToastBookmarkRemoveSuccess": "Lesezeichen entfernt",
|
||||
"ToastBookmarkUpdateFailed": "Lesezeichenaktualisierung fehlgeschlagen",
|
||||
"ToastBookmarkUpdateSuccess": "Lesezeichen aktualisiert",
|
||||
"ToastCachePurgeFailed": "Cache leeren fehlgeschlagen",
|
||||
|
|
@ -780,7 +796,7 @@
|
|||
"ToastCollectionItemsRemoveFailed": "Fehler beim Entfernen der Medien aus der Sammlung",
|
||||
"ToastCollectionItemsRemoveSuccess": "Medien aus der Sammlung entfernt",
|
||||
"ToastCollectionRemoveFailed": "Sammlung konnte nicht entfernt werden",
|
||||
"ToastCollectionRemoveSuccess": "Sammlung gelöscht",
|
||||
"ToastCollectionRemoveSuccess": "Sammlung entfernt",
|
||||
"ToastCollectionUpdateFailed": "Sammlung konnte nicht aktualisiert werden",
|
||||
"ToastCollectionUpdateSuccess": "Sammlung aktualisiert",
|
||||
"ToastDeleteFileFailed": "Die Datei konnte nicht gelöscht werden",
|
||||
|
|
|
|||
|
|
@ -229,7 +229,7 @@
|
|||
"LabelBackupLocation": "Backup Location",
|
||||
"LabelBackupsEnableAutomaticBackups": "Enable automatic backups",
|
||||
"LabelBackupsEnableAutomaticBackupsHelp": "Backups saved in /metadata/backups",
|
||||
"LabelBackupsMaxBackupSize": "Maximum backup size (in GB)",
|
||||
"LabelBackupsMaxBackupSize": "Maximum backup size (in GB) (0 for unlimited)",
|
||||
"LabelBackupsMaxBackupSizeHelp": "As a safeguard against misconfiguration, backups will fail if they exceed the configured size.",
|
||||
"LabelBackupsNumberToKeep": "Number of backups to keep",
|
||||
"LabelBackupsNumberToKeepHelp": "Only 1 backup will be removed at a time so if you already have more backups than this you should manually remove them.",
|
||||
|
|
@ -456,6 +456,7 @@
|
|||
"LabelRSSFeedPreventIndexing": "Prevent Indexing",
|
||||
"LabelRSSFeedSlug": "RSS Feed Slug",
|
||||
"LabelRSSFeedURL": "RSS Feed URL",
|
||||
"LabelRandomly": "Randomly",
|
||||
"LabelReAddSeriesToContinueListening": "Re-add series to Continue Listening",
|
||||
"LabelRead": "Read",
|
||||
"LabelReadAgain": "Read Again",
|
||||
|
|
@ -613,6 +614,8 @@
|
|||
"LabelViewQueue": "View player queue",
|
||||
"LabelVolume": "Volume",
|
||||
"LabelWeekdaysToRun": "Weekdays to run",
|
||||
"LabelXBooks": "{0} books",
|
||||
"LabelXItems": "{0} items",
|
||||
"LabelYearReviewHide": "Hide Year in Review",
|
||||
"LabelYearReviewShow": "See Year in Review",
|
||||
"LabelYourAudiobookDuration": "Your audiobook duration",
|
||||
|
|
@ -670,6 +673,7 @@
|
|||
"MessageConfirmSendEbookToDevice": "Are you sure you want to send {0} ebook \"{1}\" to device \"{2}\"?",
|
||||
"MessageDownloadingEpisode": "Downloading episode",
|
||||
"MessageDragFilesIntoTrackOrder": "Drag files into correct track order",
|
||||
"MessageEmbedFailed": "Embed Failed!",
|
||||
"MessageEmbedFinished": "Embed Finished!",
|
||||
"MessageEpisodesQueuedForDownload": "{0} Episode(s) queued for download",
|
||||
"MessageEreaderDevices": "To ensure delivery of ebooks, you may need to add the above email address as a valid sender for each device listed below.",
|
||||
|
|
@ -724,6 +728,7 @@
|
|||
"MessageNoUpdatesWereNecessary": "No updates were necessary",
|
||||
"MessageNoUserPlaylists": "You have no playlists",
|
||||
"MessageNotYetImplemented": "Not yet implemented",
|
||||
"MessageOpmlPreviewNote": "Note: This is a preview of the parsed OPML file. The actual podcast title will be taken from the RSS feed.",
|
||||
"MessageOr": "or",
|
||||
"MessagePauseChapter": "Pause chapter playback",
|
||||
"MessagePlayChapter": "Listen to beginning of chapter",
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@
|
|||
"ButtonPurgeItemsCache": "Purgar Elementos de Cache",
|
||||
"ButtonQueueAddItem": "Agregar a la Fila",
|
||||
"ButtonQueueRemoveItem": "Remover de la Fila",
|
||||
"ButtonQuickEmbedMetadata": "Agregue metadatos rápidamente",
|
||||
"ButtonQuickMatch": "Encontrar Rápido",
|
||||
"ButtonReScan": "Re-Escanear",
|
||||
"ButtonRead": "Leer",
|
||||
|
|
@ -88,6 +89,7 @@
|
|||
"ButtonShow": "Mostrar",
|
||||
"ButtonStartM4BEncode": "Iniciar Codificación M4B",
|
||||
"ButtonStartMetadataEmbed": "Iniciar la Inserción de Metadata",
|
||||
"ButtonStats": "Estadísticas",
|
||||
"ButtonSubmit": "Enviar",
|
||||
"ButtonTest": "Prueba",
|
||||
"ButtonUpload": "Subir",
|
||||
|
|
@ -154,6 +156,7 @@
|
|||
"HeaderPasswordAuthentication": "Autenticación por contraseña",
|
||||
"HeaderPermissions": "Permisos",
|
||||
"HeaderPlayerQueue": "Fila del Reproductor",
|
||||
"HeaderPlayerSettings": "Ajustes del reproductor",
|
||||
"HeaderPlaylist": "Lista de reproducción",
|
||||
"HeaderPlaylistItems": "Elementos de lista de reproducción",
|
||||
"HeaderPodcastsToAdd": "Podcasts para agregar",
|
||||
|
|
@ -289,13 +292,16 @@
|
|||
"LabelEmbeddedCover": "Portada Integrada",
|
||||
"LabelEnable": "Habilitar",
|
||||
"LabelEnd": "Fin",
|
||||
"LabelEndOfChapter": "Fin del capítulo",
|
||||
"LabelEpisode": "Episodio",
|
||||
"LabelEpisodeTitle": "Titulo de Episodio",
|
||||
"LabelEpisodeType": "Tipo de Episodio",
|
||||
"LabelExample": "Ejemplo",
|
||||
"LabelExpandSeries": "Ampliar serie",
|
||||
"LabelExplicit": "Explicito",
|
||||
"LabelExplicitChecked": "Explícito (marcado)",
|
||||
"LabelExplicitUnchecked": "No Explícito (sin marcar)",
|
||||
"LabelExportOPML": "Exportar OPML",
|
||||
"LabelFeedURL": "Fuente de URL",
|
||||
"LabelFetchingMetadata": "Obteniendo metadatos",
|
||||
"LabelFile": "Archivo",
|
||||
|
|
@ -319,6 +325,7 @@
|
|||
"LabelHardDeleteFile": "Eliminar Definitivamente",
|
||||
"LabelHasEbook": "Tiene un libro",
|
||||
"LabelHasSupplementaryEbook": "Tiene un libro complementario",
|
||||
"LabelHideSubtitles": "Ocultar subtítulos",
|
||||
"LabelHighestPriority": "Mayor prioridad",
|
||||
"LabelHost": "Host",
|
||||
"LabelHour": "Hora",
|
||||
|
|
@ -339,6 +346,8 @@
|
|||
"LabelIntervalEveryHour": "Cada Hora",
|
||||
"LabelInvert": "Invertir",
|
||||
"LabelItem": "Elemento",
|
||||
"LabelJumpBackwardAmount": "Cantidad de saltos hacia atrás",
|
||||
"LabelJumpForwardAmount": "Cantidad de saltos hacia adelante",
|
||||
"LabelLanguage": "Idioma",
|
||||
"LabelLanguageDefaultServer": "Lenguaje Predeterminado del Servidor",
|
||||
"LabelLanguages": "Idiomas",
|
||||
|
|
@ -446,6 +455,7 @@
|
|||
"LabelRSSFeedPreventIndexing": "Prevenir indexado",
|
||||
"LabelRSSFeedSlug": "Fuente RSS Slug",
|
||||
"LabelRSSFeedURL": "URL de Fuente RSS",
|
||||
"LabelReAddSeriesToContinueListening": "Volver a agregar la serie para continuar escuchándola",
|
||||
"LabelRead": "Leído",
|
||||
"LabelReadAgain": "Volver a leer",
|
||||
"LabelReadEbookWithoutProgress": "Leer Ebook sin guardar progreso",
|
||||
|
|
@ -512,9 +522,11 @@
|
|||
"LabelSettingsStoreMetadataWithItemHelp": "Por defecto, los archivos de metadatos se almacenan en /metadata/items. Si habilita esta opción, los archivos de metadatos se guardarán en la carpeta de elementos de su biblioteca",
|
||||
"LabelSettingsTimeFormat": "Formato de Tiempo",
|
||||
"LabelShare": "Compartir",
|
||||
"LabelShareOpen": "abrir un recurso compartido",
|
||||
"LabelShareURL": "Compartir la URL",
|
||||
"LabelShowAll": "Mostrar Todos",
|
||||
"LabelShowSeconds": "Mostrar segundos",
|
||||
"LabelShowSubtitles": "Mostrar subtítulos",
|
||||
"LabelSize": "Tamaño",
|
||||
"LabelSleepTimer": "Temporizador de apagado",
|
||||
"LabelSlug": "Slug",
|
||||
|
|
@ -552,6 +564,10 @@
|
|||
"LabelThemeDark": "Oscuro",
|
||||
"LabelThemeLight": "Claro",
|
||||
"LabelTimeBase": "Tiempo Base",
|
||||
"LabelTimeDurationXHours": "{0} horas",
|
||||
"LabelTimeDurationXMinutes": "{0} minutos",
|
||||
"LabelTimeDurationXSeconds": "{0} segundos",
|
||||
"LabelTimeInMinutes": "Tiempo en minutos",
|
||||
"LabelTimeListened": "Tiempo Escuchando",
|
||||
"LabelTimeListenedToday": "Tiempo Escuchando Hoy",
|
||||
"LabelTimeRemaining": "{0} restante",
|
||||
|
|
@ -591,6 +607,7 @@
|
|||
"LabelVersion": "Versión",
|
||||
"LabelViewBookmarks": "Ver Marcadores",
|
||||
"LabelViewChapters": "Ver Capítulos",
|
||||
"LabelViewPlayerSettings": "Ver los ajustes del reproductor",
|
||||
"LabelViewQueue": "Ver Fila del Reproductor",
|
||||
"LabelVolume": "Volumen",
|
||||
"LabelWeekdaysToRun": "Correr en Días de la Semana",
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@
|
|||
"HeaderYourStats": "Tilastosi",
|
||||
"LabelAddToPlaylist": "Lisää soittolistaan",
|
||||
"LabelAdded": "Lisätty",
|
||||
"LabelAddedAt": "Lisätty",
|
||||
"LabelAddedAt": "Lisätty listalle",
|
||||
"LabelAll": "Kaikki",
|
||||
"LabelAuthor": "Tekijä",
|
||||
"LabelAuthorFirstLast": "Tekijä (Etunimi Sukunimi)",
|
||||
|
|
@ -152,11 +152,34 @@
|
|||
"LabelContinueReading": "Jatka lukemista",
|
||||
"LabelContinueSeries": "Jatka sarjoja",
|
||||
"LabelDescription": "Kuvaus",
|
||||
"LabelDownload": "Lataa",
|
||||
"LabelDuration": "Kesto",
|
||||
"LabelEbook": "E-kirja",
|
||||
"LabelEbooks": "E-kirjat",
|
||||
"LabelEnable": "Ota käyttöön",
|
||||
"LabelFile": "Tiedosto",
|
||||
"LabelFileBirthtime": "Tiedoston syntymäaika",
|
||||
"LabelFileModified": "Muutettu tiedosto",
|
||||
"LabelFilename": "Tiedostonimi"
|
||||
"LabelFilename": "Tiedostonimi",
|
||||
"LabelFolder": "Kansio",
|
||||
"LabelLanguage": "Kieli",
|
||||
"LabelMore": "Lisää",
|
||||
"LabelNarrator": "Lukija",
|
||||
"LabelNarrators": "Lukijat",
|
||||
"LabelNewestAuthors": "Uusimmat kirjailijat",
|
||||
"LabelNewestEpisodes": "Uusimmat jaksot",
|
||||
"LabelPassword": "Salasana",
|
||||
"LabelPath": "Polku",
|
||||
"LabelRead": "Lue",
|
||||
"LabelReadAgain": "Lue uudelleen",
|
||||
"LabelSeason": "Kausi",
|
||||
"LabelShowAll": "Näytä kaikki",
|
||||
"LabelSize": "Koko",
|
||||
"LabelSleepTimer": "Uniajastin",
|
||||
"LabelTheme": "Teema",
|
||||
"LabelThemeDark": "Tumma",
|
||||
"LabelThemeLight": "Kirkas",
|
||||
"LabelUser": "Käyttäjä",
|
||||
"LabelUsername": "Käyttäjätunnus",
|
||||
"MessageDownloadingEpisode": "Ladataan jaksoa"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -258,6 +258,7 @@
|
|||
"LabelCurrently": "Actuellement :",
|
||||
"LabelCustomCronExpression": "Expression cron personnalisée :",
|
||||
"LabelDatetime": "Date",
|
||||
"LabelDays": "Jours",
|
||||
"LabelDeleteFromFileSystemCheckbox": "Supprimer du système de fichiers (décocher pour ne supprimer que de la base de données)",
|
||||
"LabelDescription": "Description",
|
||||
"LabelDeselectAll": "Tout déselectionner",
|
||||
|
|
@ -321,6 +322,7 @@
|
|||
"LabelHighestPriority": "Priorité la plus élevée",
|
||||
"LabelHost": "Hôte",
|
||||
"LabelHour": "Heure",
|
||||
"LabelHours": "Heures",
|
||||
"LabelIcon": "Icône",
|
||||
"LabelImageURLFromTheWeb": "URL de l’image à partir du web",
|
||||
"LabelInProgress": "En cours",
|
||||
|
|
@ -371,6 +373,7 @@
|
|||
"LabelMetadataOrderOfPrecedenceDescription": "Les sources de métadonnées ayant une priorité plus élevée auront la priorité sur celles ayant une priorité moins élevée",
|
||||
"LabelMetadataProvider": "Fournisseur de métadonnées",
|
||||
"LabelMinute": "Minute",
|
||||
"LabelMinutes": "Minutes",
|
||||
"LabelMissing": "Manquant",
|
||||
"LabelMissingEbook": "Ne possède aucun livre numérique",
|
||||
"LabelMissingSupplementaryEbook": "Ne possède aucun livre numérique supplémentaire",
|
||||
|
|
@ -410,6 +413,7 @@
|
|||
"LabelOverwrite": "Écraser",
|
||||
"LabelPassword": "Mot de passe",
|
||||
"LabelPath": "Chemin",
|
||||
"LabelPermanent": "Permanent",
|
||||
"LabelPermissionsAccessAllLibraries": "Peut accéder à toutes les bibliothèque",
|
||||
"LabelPermissionsAccessAllTags": "Peut accéder à toutes les étiquettes",
|
||||
"LabelPermissionsAccessExplicitContent": "Peut accéder au contenu restreint",
|
||||
|
|
@ -507,6 +511,9 @@
|
|||
"LabelSettingsStoreMetadataWithItem": "Enregistrer les métadonnées avec l’élément",
|
||||
"LabelSettingsStoreMetadataWithItemHelp": "Par défaut, les fichiers de métadonnées sont stockés dans /metadata/items. En activant ce paramètre, les fichiers de métadonnées seront stockés dans les dossiers des éléments de votre bibliothèque",
|
||||
"LabelSettingsTimeFormat": "Format d’heure",
|
||||
"LabelShare": "Partager",
|
||||
"LabelShareOpen": "Ouvrir le partage",
|
||||
"LabelShareURL": "Partager l’URL",
|
||||
"LabelShowAll": "Tout afficher",
|
||||
"LabelShowSeconds": "Afficher les seondes",
|
||||
"LabelSize": "Taille",
|
||||
|
|
@ -598,6 +605,7 @@
|
|||
"MessageAppriseDescription": "Nécessite une instance d’<a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">API Apprise</a> pour utiliser cette fonctionnalité ou une api qui prend en charge les mêmes requêtes.<br />L’URL de l’API Apprise doit comprendre le chemin complet pour envoyer la notification. Par exemple, si votre instance écoute sur <code>http://192.168.1.1:8337</code> alors vous devez mettre <code>http://192.168.1.1:8337/notify</code>.",
|
||||
"MessageBackupsDescription": "Les sauvegardes incluent les utilisateurs, la progression des utilisateurs, les détails des éléments de la bibliothèque, les paramètres du serveur et les images stockées dans <code>/metadata/items</code> & <code>/metadata/authors</code>. Les sauvegardes <strong>n’incluent pas</strong> les fichiers stockés dans les dossiers de votre bibliothèque.",
|
||||
"MessageBackupsLocationEditNote": "Remarque : Mettre à jour l'emplacement de sauvegarde ne déplacera pas ou ne modifiera pas les sauvegardes existantes",
|
||||
"MessageBackupsLocationNoEditNote": "Remarque : l’emplacement de sauvegarde est défini via une variable d’environnement et ne peut pas être modifié ici.",
|
||||
"MessageBackupsLocationPathEmpty": "L'emplacement de secours ne peut pas être vide",
|
||||
"MessageBatchQuickMatchDescription": "La recherche par correspondance rapide tentera d’ajouter les couvertures et métadonnées manquantes pour les éléments sélectionnés. Activez les options ci-dessous pour permettre la Recherche par correspondance d’écraser les couvertures et/ou métadonnées existantes.",
|
||||
"MessageBookshelfNoCollections": "Vous n’avez pas encore de collections",
|
||||
|
|
@ -716,6 +724,9 @@
|
|||
"MessageSelected": "{0} sélectionnés",
|
||||
"MessageServerCouldNotBeReached": "Serveur inaccessible",
|
||||
"MessageSetChaptersFromTracksDescription": "Positionne un chapitre par fichier audio, avec le titre du fichier comme titre de chapitre",
|
||||
"MessageShareExpirationWillBe": "Expire le <strong>{0}</strong>",
|
||||
"MessageShareExpiresIn": "Expire dans {0}",
|
||||
"MessageShareURLWillBe": "L’adresse de partage sera <strong>{0}</strong>",
|
||||
"MessageStartPlaybackAtTime": "Démarrer la lecture pour « {0} » à {1} ?",
|
||||
"MessageThinking": "Je cherche…",
|
||||
"MessageUploaderItemFailed": "Échec du téléversement",
|
||||
|
|
@ -730,7 +741,7 @@
|
|||
"NoteChapterEditorTimes": "Information : l’horodatage du premier chapitre doit être à 0:00 et celui du dernier chapitre ne peut se situer au-delà de la durée du livre audio.",
|
||||
"NoteFolderPicker": "Information : les dossiers déjà surveillés ne sont pas affichés",
|
||||
"NoteRSSFeedPodcastAppsHttps": "Attention : la majorité des application de podcast nécessite une adresse de flux HTTPS",
|
||||
"NoteRSSFeedPodcastAppsPubDate": "Attention : un ou plusieurs de vos épisodes ne possèdent pas de date de publication. Certaines applications de podcast le requièrent.",
|
||||
"NoteRSSFeedPodcastAppsPubDate": "Attention : un ou plusieurs de vos épisodes ne possèdent pas de date de publication. Certaines applications de podcast le requièrent.",
|
||||
"NoteUploaderFoldersWithMediaFiles": "Les dossiers contenant des fichiers multimédias seront traités comme des éléments distincts de la bibliothèque.",
|
||||
"NoteUploaderOnlyAudioFiles": "Si vous téléversez uniquement des fichiers audio, chaque fichier audio sera traité comme un livre audio distinct.",
|
||||
"NoteUploaderUnsupportedFiles": "Les fichiers non pris en charge sont ignorés. Lorsque vous choisissez ou déposez un dossier, les autres fichiers qui ne sont pas dans un dossier d’élément sont ignorés.",
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
"ButtonApply": "החל",
|
||||
"ButtonApplyChapters": "החל פרקים",
|
||||
"ButtonAuthors": "יוצרים",
|
||||
"ButtonBack": "Back",
|
||||
"ButtonBack": "חזור",
|
||||
"ButtonBrowseForFolder": "עיין בתיקייה",
|
||||
"ButtonCancel": "בטל",
|
||||
"ButtonCancelEncode": "בטל קידוד",
|
||||
|
|
@ -62,8 +62,8 @@
|
|||
"ButtonQuickMatch": "התאמה מהירה",
|
||||
"ButtonReScan": "סרוק מחדש",
|
||||
"ButtonRead": "קרא",
|
||||
"ButtonReadLess": "Read less",
|
||||
"ButtonReadMore": "Read more",
|
||||
"ButtonReadLess": "קרא פחות",
|
||||
"ButtonReadMore": "קרא יותר",
|
||||
"ButtonRefresh": "רענן",
|
||||
"ButtonRemove": "הסר",
|
||||
"ButtonRemoveAll": "הסר הכל",
|
||||
|
|
@ -115,7 +115,7 @@
|
|||
"HeaderCollectionItems": "פריטי אוסף",
|
||||
"HeaderCover": "כריכה",
|
||||
"HeaderCurrentDownloads": "הורדות נוכחיות",
|
||||
"HeaderCustomMessageOnLogin": "Custom Message on Login",
|
||||
"HeaderCustomMessageOnLogin": "הודעה מותאמת אישית בהתחברות",
|
||||
"HeaderCustomMetadataProviders": "ספקי מטא-נתונים מותאמים אישית",
|
||||
"HeaderDetails": "פרטים",
|
||||
"HeaderDownloadQueue": "תור הורדה",
|
||||
|
|
@ -806,8 +806,8 @@
|
|||
"ToastSendEbookToDeviceSuccess": "הספר נשלח אל המכשיר \"{0}\"",
|
||||
"ToastSeriesUpdateFailed": "עדכון הסדרה נכשל",
|
||||
"ToastSeriesUpdateSuccess": "הסדרה עודכנה בהצלחה",
|
||||
"ToastServerSettingsUpdateFailed": "Failed to update server settings",
|
||||
"ToastServerSettingsUpdateSuccess": "Server settings updated",
|
||||
"ToastServerSettingsUpdateFailed": "כשל בעדכון הגדרות שרת",
|
||||
"ToastServerSettingsUpdateSuccess": "הגדרות שרת עודכנו בהצלחה",
|
||||
"ToastSessionDeleteFailed": "מחיקת הפעולה נכשלה",
|
||||
"ToastSessionDeleteSuccess": "הפעולה נמחקה בהצלחה",
|
||||
"ToastSocketConnected": "קצה תקשורת חובר",
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
{
|
||||
"ButtonAdd": "Toevoegen",
|
||||
"ButtonAddChapters": "Hoofdstukken toevoegen",
|
||||
"ButtonAddDevice": "Add Device",
|
||||
"ButtonAddLibrary": "Add Library",
|
||||
"ButtonAddDevice": "Toestel toevoegen",
|
||||
"ButtonAddLibrary": "Bibliotheek toevoegen",
|
||||
"ButtonAddPodcasts": "Podcasts toevoegen",
|
||||
"ButtonAddUser": "Add User",
|
||||
"ButtonAddUser": "Gebruiker toevoegen",
|
||||
"ButtonAddYourFirstLibrary": "Voeg je eerste bibliotheek toe",
|
||||
"ButtonApply": "Pas toe",
|
||||
"ButtonApplyChapters": "Hoofdstukken toepassen",
|
||||
"ButtonAuthors": "Auteurs",
|
||||
"ButtonBack": "Back",
|
||||
"ButtonBack": "Terug",
|
||||
"ButtonBrowseForFolder": "Bladeren naar map",
|
||||
"ButtonCancel": "Annuleren",
|
||||
"ButtonCancelEncode": "Encoding annuleren",
|
||||
|
|
@ -32,9 +32,9 @@
|
|||
"ButtonFullPath": "Volledig pad",
|
||||
"ButtonHide": "Verberg",
|
||||
"ButtonHome": "Home",
|
||||
"ButtonIssues": "Issues",
|
||||
"ButtonJumpBackward": "Jump Backward",
|
||||
"ButtonJumpForward": "Jump Forward",
|
||||
"ButtonIssues": "Problemen",
|
||||
"ButtonJumpBackward": "Spring achteruit",
|
||||
"ButtonJumpForward": "Spring vooruit",
|
||||
"ButtonLatest": "Meest recent",
|
||||
"ButtonLibrary": "Bibliotheek",
|
||||
"ButtonLogout": "Log uit",
|
||||
|
|
@ -44,17 +44,17 @@
|
|||
"ButtonMatchAllAuthors": "Alle auteurs matchen",
|
||||
"ButtonMatchBooks": "Alle boeken matchen",
|
||||
"ButtonNevermind": "Laat maar",
|
||||
"ButtonNext": "Next",
|
||||
"ButtonNextChapter": "Next Chapter",
|
||||
"ButtonNext": "Volgende",
|
||||
"ButtonNextChapter": "Volgend hoofdstuk",
|
||||
"ButtonOk": "Ok",
|
||||
"ButtonOpenFeed": "Feed openen",
|
||||
"ButtonOpenManager": "Manager openen",
|
||||
"ButtonPause": "Pause",
|
||||
"ButtonPause": "Pauze",
|
||||
"ButtonPlay": "Afspelen",
|
||||
"ButtonPlaying": "Speelt",
|
||||
"ButtonPlaylists": "Afspeellijsten",
|
||||
"ButtonPrevious": "Previous",
|
||||
"ButtonPreviousChapter": "Previous Chapter",
|
||||
"ButtonPrevious": "Vorige",
|
||||
"ButtonPreviousChapter": "Vorig hoofdstuk",
|
||||
"ButtonPurgeAllCache": "Volledige cache legen",
|
||||
"ButtonPurgeItemsCache": "Onderdelen-cache legen",
|
||||
"ButtonQueueAddItem": "In wachtrij zetten",
|
||||
|
|
@ -62,14 +62,14 @@
|
|||
"ButtonQuickMatch": "Snelle match",
|
||||
"ButtonReScan": "Nieuwe scan",
|
||||
"ButtonRead": "Lees",
|
||||
"ButtonReadLess": "Read less",
|
||||
"ButtonReadMore": "Read more",
|
||||
"ButtonRefresh": "Refresh",
|
||||
"ButtonReadLess": "Lees minder",
|
||||
"ButtonReadMore": "Lees meer",
|
||||
"ButtonRefresh": "Verversen",
|
||||
"ButtonRemove": "Verwijder",
|
||||
"ButtonRemoveAll": "Alles verwijderen",
|
||||
"ButtonRemoveAllLibraryItems": "Verwijder volledige bibliotheekinhoud",
|
||||
"ButtonRemoveFromContinueListening": "Vewijder uit Verder luisteren",
|
||||
"ButtonRemoveFromContinueReading": "Remove from Continue Reading",
|
||||
"ButtonRemoveFromContinueReading": "Verwijder van Verder luisteren",
|
||||
"ButtonRemoveSeriesFromContinueSeries": "Verwijder serie uit Serie vervolgen",
|
||||
"ButtonReset": "Reset",
|
||||
"ButtonResetToDefault": "Reset to default",
|
||||
|
|
@ -83,7 +83,7 @@
|
|||
"ButtonSelectFolderPath": "Maplocatie selecteren",
|
||||
"ButtonSeries": "Series",
|
||||
"ButtonSetChaptersFromTracks": "Maak hoofdstukken op basis van tracks",
|
||||
"ButtonShare": "Share",
|
||||
"ButtonShare": "Deel",
|
||||
"ButtonShiftTimes": "Tijden verschuiven",
|
||||
"ButtonShow": "Toon",
|
||||
"ButtonStartM4BEncode": "Start M4B-encoding",
|
||||
|
|
@ -98,9 +98,9 @@
|
|||
"ButtonUserEdit": "Wijzig gebruiker {0}",
|
||||
"ButtonViewAll": "Toon alle",
|
||||
"ButtonYes": "Ja",
|
||||
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
|
||||
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
|
||||
"ErrorUploadLacksTitle": "Must have a title",
|
||||
"ErrorUploadFetchMetadataAPI": "Error metadata ophalen",
|
||||
"ErrorUploadFetchMetadataNoResults": "Kan metadata niet ophalen - probeer de titel en/of auteur te updaten",
|
||||
"ErrorUploadLacksTitle": "Moet een titel hebben",
|
||||
"HeaderAccount": "Account",
|
||||
"HeaderAdvanced": "Geavanceerd",
|
||||
"HeaderAppriseNotificationSettings": "Apprise-notificatie instellingen",
|
||||
|
|
@ -113,13 +113,13 @@
|
|||
"HeaderChooseAFolder": "Map kiezen",
|
||||
"HeaderCollection": "Collectie",
|
||||
"HeaderCollectionItems": "Collectie-objecten",
|
||||
"HeaderCover": "Cover",
|
||||
"HeaderCover": "Omslag",
|
||||
"HeaderCurrentDownloads": "Huidige downloads",
|
||||
"HeaderCustomMessageOnLogin": "Custom Message on Login",
|
||||
"HeaderCustomMetadataProviders": "Custom Metadata Providers",
|
||||
"HeaderDetails": "Details",
|
||||
"HeaderDownloadQueue": "Download-wachtrij",
|
||||
"HeaderEbookFiles": "Ebook Files",
|
||||
"HeaderEbookFiles": "Ebook bestanden",
|
||||
"HeaderEmail": "E-mail",
|
||||
"HeaderEmailSettings": "E-mail instellingen",
|
||||
"HeaderEpisodes": "Afleveringen",
|
||||
|
|
@ -239,11 +239,11 @@
|
|||
"LabelChapterTitle": "Hoofdstuktitel",
|
||||
"LabelChapters": "Hoofdstukken",
|
||||
"LabelChaptersFound": "Hoofdstukken gevonden",
|
||||
"LabelClickForMoreInfo": "Click for more info",
|
||||
"LabelClickForMoreInfo": "Klik voor meer informatie",
|
||||
"LabelClosePlayer": "Sluit speler",
|
||||
"LabelCodec": "Codec",
|
||||
"LabelCollapseSeries": "Series inklappen",
|
||||
"LabelCollection": "Collection",
|
||||
"LabelCollection": "Collectie",
|
||||
"LabelCollections": "Collecties",
|
||||
"LabelComplete": "Compleet",
|
||||
"LabelConfirmPassword": "Bevestig wachtwoord",
|
||||
|
|
@ -258,6 +258,7 @@
|
|||
"LabelCurrently": "Op dit moment:",
|
||||
"LabelCustomCronExpression": "Aangepaste Cron-uitdrukking:",
|
||||
"LabelDatetime": "Datum-tijd",
|
||||
"LabelDays": "Dagen",
|
||||
"LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)",
|
||||
"LabelDescription": "Beschrijving",
|
||||
"LabelDeselectAll": "Deselecteer alle",
|
||||
|
|
@ -296,7 +297,7 @@
|
|||
"LabelExplicitChecked": "Explicit (checked)",
|
||||
"LabelExplicitUnchecked": "Not Explicit (unchecked)",
|
||||
"LabelFeedURL": "Feed URL",
|
||||
"LabelFetchingMetadata": "Fetching Metadata",
|
||||
"LabelFetchingMetadata": "Metadata ophalen",
|
||||
"LabelFile": "Bestand",
|
||||
"LabelFileBirthtime": "Aanmaaktijd bestand",
|
||||
"LabelFileModified": "Bestand gewijzigd",
|
||||
|
|
@ -306,7 +307,7 @@
|
|||
"LabelFinished": "Voltooid",
|
||||
"LabelFolder": "Map",
|
||||
"LabelFolders": "Mappen",
|
||||
"LabelFontBold": "Bold",
|
||||
"LabelFontBold": "Vetgedrukt",
|
||||
"LabelFontBoldness": "Font Boldness",
|
||||
"LabelFontFamily": "Lettertypefamilie",
|
||||
"LabelFontItalic": "Italic",
|
||||
|
|
@ -321,6 +322,7 @@
|
|||
"LabelHighestPriority": "Highest priority",
|
||||
"LabelHost": "Host",
|
||||
"LabelHour": "Uur",
|
||||
"LabelHours": "Uren",
|
||||
"LabelIcon": "Icoon",
|
||||
"LabelImageURLFromTheWeb": "Image URL from the web",
|
||||
"LabelInProgress": "Bezig",
|
||||
|
|
@ -567,7 +569,7 @@
|
|||
"LabelTracksSingleTrack": "Enkele track",
|
||||
"LabelType": "Type",
|
||||
"LabelUnabridged": "Onverkort",
|
||||
"LabelUndo": "Undo",
|
||||
"LabelUndo": "Ongedaan maken",
|
||||
"LabelUnknown": "Onbekend",
|
||||
"LabelUpdateCover": "Cover bijwerken",
|
||||
"LabelUpdateCoverHelp": "Sta overschrijven van bestaande covers toe voor de geselecteerde boeken wanneer een match is gevonden",
|
||||
|
|
@ -630,7 +632,7 @@
|
|||
"MessageConfirmRemoveCollection": "Weet je zeker dat je de collectie \"{0}\" wil verwijderen?",
|
||||
"MessageConfirmRemoveEpisode": "Weet je zeker dat je de aflevering \"{0}\" wil verwijderen?",
|
||||
"MessageConfirmRemoveEpisodes": "Weet je zeker dat je {0} afleveringen wil verwijderen?",
|
||||
"MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?",
|
||||
"MessageConfirmRemoveListeningSessions": "Weet je zeker dat je {0} luistersessies wilt verwijderen?",
|
||||
"MessageConfirmRemoveNarrator": "Weet je zeker dat je verteller \"{0}\" wil verwijderen?",
|
||||
"MessageConfirmRemovePlaylist": "Weet je zeker dat je je afspeellijst \"{0}\" wil verwijderen?",
|
||||
"MessageConfirmRenameGenre": "Weet je zeker dat je genre \"{0}\" wil hernoemen naar \"{1}\" voor alle onderdelen?",
|
||||
|
|
@ -714,6 +716,7 @@
|
|||
"MessageSelected": "{0} selected",
|
||||
"MessageServerCouldNotBeReached": "Server niet bereikbaar",
|
||||
"MessageSetChaptersFromTracksDescription": "Stel hoofdstukken in met ieder audiobestand als een hoofdstuk en de audiobestandsnaam als hoofdstuktitel",
|
||||
"MessageShareExpiresIn": "Vervalt in {0}",
|
||||
"MessageStartPlaybackAtTime": "Afspelen van \"{0}\" beginnen op {1}?",
|
||||
"MessageThinking": "Aan het denken...",
|
||||
"MessageUploaderItemFailed": "Uploaden mislukt",
|
||||
|
|
|
|||
|
|
@ -62,8 +62,8 @@
|
|||
"ButtonQuickMatch": "Szybkie dopasowanie",
|
||||
"ButtonReScan": "Ponowne skanowanie",
|
||||
"ButtonRead": "Czytaj",
|
||||
"ButtonReadLess": "Read less",
|
||||
"ButtonReadMore": "Read more",
|
||||
"ButtonReadLess": "Pokaż mniej",
|
||||
"ButtonReadMore": "Pokaż więcej",
|
||||
"ButtonRefresh": "Odśwież",
|
||||
"ButtonRemove": "Usuń",
|
||||
"ButtonRemoveAll": "Usuń wszystko",
|
||||
|
|
@ -88,6 +88,7 @@
|
|||
"ButtonShow": "Pokaż",
|
||||
"ButtonStartM4BEncode": "Eksportuj jako plik M4B",
|
||||
"ButtonStartMetadataEmbed": "Osadź metadane",
|
||||
"ButtonStats": "Statystyki",
|
||||
"ButtonSubmit": "Zaloguj",
|
||||
"ButtonTest": "Test",
|
||||
"ButtonUpload": "Wgraj",
|
||||
|
|
@ -130,13 +131,13 @@
|
|||
"HeaderIgnoredFiles": "Zignoruj pliki",
|
||||
"HeaderItemFiles": "Pliki",
|
||||
"HeaderItemMetadataUtils": "Item Metadata Utils",
|
||||
"HeaderLastListeningSession": "Ostatnio odtwarzana sesja",
|
||||
"HeaderLastListeningSession": "Ostatnia sesja słuchania",
|
||||
"HeaderLatestEpisodes": "Najnowsze odcinki",
|
||||
"HeaderLibraries": "Biblioteki",
|
||||
"HeaderLibraryFiles": "Pliki w bibliotece",
|
||||
"HeaderLibraryStats": "Statystyki biblioteki",
|
||||
"HeaderListeningSessions": "Sesje słuchania",
|
||||
"HeaderListeningStats": "Statystyki odtwarzania",
|
||||
"HeaderListeningStats": "Statystyki słuchania",
|
||||
"HeaderLogin": "Zaloguj się",
|
||||
"HeaderLogs": "Logi",
|
||||
"HeaderManageGenres": "Zarządzaj gatunkami",
|
||||
|
|
@ -148,12 +149,13 @@
|
|||
"HeaderNewAccount": "Nowe konto",
|
||||
"HeaderNewLibrary": "Nowa biblioteka",
|
||||
"HeaderNotifications": "Powiadomienia",
|
||||
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication",
|
||||
"HeaderOpenIDConnectAuthentication": "Uwierzytelnianie OpenID Connect",
|
||||
"HeaderOpenRSSFeed": "Utwórz kanał RSS",
|
||||
"HeaderOtherFiles": "Inne pliki",
|
||||
"HeaderPasswordAuthentication": "Uwierzytelnianie hasłem",
|
||||
"HeaderPermissions": "Uprawnienia",
|
||||
"HeaderPlayerQueue": "Kolejka odtwarzania",
|
||||
"HeaderPlayerSettings": "Ustawienia Odtwarzania",
|
||||
"HeaderPlaylist": "Playlista",
|
||||
"HeaderPlaylistItems": "Pozycje listy odtwarzania",
|
||||
"HeaderPodcastsToAdd": "Podcasty do dodania",
|
||||
|
|
@ -175,7 +177,7 @@
|
|||
"HeaderSettingsScanner": "Skanowanie",
|
||||
"HeaderSleepTimer": "Wyłącznik czasowy",
|
||||
"HeaderStatsLargestItems": "Największe pozycje",
|
||||
"HeaderStatsLongestItems": "Najdłuższe pozycje (hrs)",
|
||||
"HeaderStatsLongestItems": "Najdłuższe pozycje (godziny)",
|
||||
"HeaderStatsMinutesListeningChart": "Czas słuchania w minutach (ostatnie 7 dni)",
|
||||
"HeaderStatsRecentSessions": "Ostatnie sesje",
|
||||
"HeaderStatsTop10Authors": "Top 10 Autorów",
|
||||
|
|
@ -200,8 +202,8 @@
|
|||
"LabelActivity": "Aktywność",
|
||||
"LabelAddToCollection": "Dodaj do kolekcji",
|
||||
"LabelAddToCollectionBatch": "Dodaj {0} książki do kolekcji",
|
||||
"LabelAddToPlaylist": "Add to Playlist",
|
||||
"LabelAddToPlaylistBatch": "Add {0} Items to Playlist",
|
||||
"LabelAddToPlaylist": "Dodaj do playlisty",
|
||||
"LabelAddToPlaylistBatch": "Dodaj {0} pozycji do playlisty",
|
||||
"LabelAdded": "Dodane",
|
||||
"LabelAddedAt": "Dodano",
|
||||
"LabelAdminUsersOnly": "Tylko użytkownicy administracyjni",
|
||||
|
|
@ -226,14 +228,14 @@
|
|||
"LabelBackupLocation": "Lokalizacja kopii zapasowej",
|
||||
"LabelBackupsEnableAutomaticBackups": "Włącz automatyczne kopie zapasowe",
|
||||
"LabelBackupsEnableAutomaticBackupsHelp": "Kopie zapasowe są zapisywane w folderze /metadata/backups",
|
||||
"LabelBackupsMaxBackupSize": "Maksymalny łączny rozmiar backupów (w GB)",
|
||||
"LabelBackupsMaxBackupSize": "Maksymalny rozmiar kopii zapasowej (w GB)",
|
||||
"LabelBackupsMaxBackupSizeHelp": "Jako zabezpieczenie przed błędną konfiguracją, kopie zapasowe nie będą wykonywane, jeśli przekroczą skonfigurowany rozmiar.",
|
||||
"LabelBackupsNumberToKeep": "Liczba kopii zapasowych do przechowywania",
|
||||
"LabelBackupsNumberToKeepHelp": "Tylko 1 kopia zapasowa zostanie usunięta, więc jeśli masz już więcej kopii zapasowych, powinieneś je ręcznie usunąć.",
|
||||
"LabelBitrate": "Bitrate",
|
||||
"LabelBooks": "Książki",
|
||||
"LabelButtonText": "Button Text",
|
||||
"LabelByAuthor": "by {0}",
|
||||
"LabelByAuthor": "autorstwa {0}",
|
||||
"LabelChangePassword": "Zmień hasło",
|
||||
"LabelChannels": "Kanały",
|
||||
"LabelChapterTitle": "Tytuł rozdziału",
|
||||
|
|
@ -247,7 +249,7 @@
|
|||
"LabelCollections": "Kolekcje",
|
||||
"LabelComplete": "Ukończone",
|
||||
"LabelConfirmPassword": "Potwierdź hasło",
|
||||
"LabelContinueListening": "Kontynuuj odtwarzanie",
|
||||
"LabelContinueListening": "Kontynuuj słuchanie",
|
||||
"LabelContinueReading": "Kontynuuj czytanie",
|
||||
"LabelContinueSeries": "Kontynuuj serię",
|
||||
"LabelCover": "Okładka",
|
||||
|
|
@ -319,6 +321,7 @@
|
|||
"LabelHardDeleteFile": "Usuń trwale plik",
|
||||
"LabelHasEbook": "Ma ebooka",
|
||||
"LabelHasSupplementaryEbook": "Posiada dodatkowy ebook",
|
||||
"LabelHideSubtitles": "Ukryj napisy",
|
||||
"LabelHighestPriority": "Najwyższy priorytet",
|
||||
"LabelHost": "Host",
|
||||
"LabelHour": "Godzina",
|
||||
|
|
@ -413,7 +416,7 @@
|
|||
"LabelOverwrite": "Nadpisz",
|
||||
"LabelPassword": "Hasło",
|
||||
"LabelPath": "Ścieżka",
|
||||
"LabelPermanent": "Trwały",
|
||||
"LabelPermanent": "Stałe",
|
||||
"LabelPermissionsAccessAllLibraries": "Ma dostęp do wszystkich bibliotek",
|
||||
"LabelPermissionsAccessAllTags": "Ma dostęp do wszystkich tagów",
|
||||
"LabelPermissionsAccessExplicitContent": "Ma dostęp do treści oznacznych jako nieprzyzwoite",
|
||||
|
|
@ -446,6 +449,7 @@
|
|||
"LabelRSSFeedPreventIndexing": "Zapobiegaj indeksowaniu",
|
||||
"LabelRSSFeedSlug": "RSS Feed Slug",
|
||||
"LabelRSSFeedURL": "URL kanały RSS",
|
||||
"LabelReAddSeriesToContinueListening": "Ponownie Dodaj Serię do sekcji Kontunuuj Odtwarzanie",
|
||||
"LabelRead": "Czytaj",
|
||||
"LabelReadAgain": "Czytaj ponownie",
|
||||
"LabelReadEbookWithoutProgress": "Czytaj książkę bez zapamiętywania postępu",
|
||||
|
|
@ -516,6 +520,7 @@
|
|||
"LabelShareURL": "Link do udziału",
|
||||
"LabelShowAll": "Pokaż wszystko",
|
||||
"LabelShowSeconds": "Pokaż sekundy",
|
||||
"LabelShowSubtitles": "Pokaż Napisy",
|
||||
"LabelSize": "Rozmiar",
|
||||
"LabelSleepTimer": "Wyłącznik czasowy",
|
||||
"LabelSlug": "Slug",
|
||||
|
|
@ -534,10 +539,10 @@
|
|||
"LabelStatsItemsFinished": "Pozycje zakończone",
|
||||
"LabelStatsItemsInLibrary": "Pozycje w bibliotece",
|
||||
"LabelStatsMinutes": "Minuty",
|
||||
"LabelStatsMinutesListening": "Minuty odtwarzania",
|
||||
"LabelStatsMinutesListening": "Minuty słuchania",
|
||||
"LabelStatsOverallDays": "Całkowity czas (dni)",
|
||||
"LabelStatsOverallHours": "Całkowity czas (godziny)",
|
||||
"LabelStatsWeekListening": "Tydzień odtwarzania",
|
||||
"LabelStatsWeekListening": "Tydzień słuchania",
|
||||
"LabelSubtitle": "Podtytuł",
|
||||
"LabelSupportedFileTypes": "Obsługiwane typy plików",
|
||||
"LabelTag": "Tag",
|
||||
|
|
@ -592,6 +597,7 @@
|
|||
"LabelVersion": "Wersja",
|
||||
"LabelViewBookmarks": "Wyświetlaj zakładki",
|
||||
"LabelViewChapters": "Wyświetlaj rozdziały",
|
||||
"LabelViewPlayerSettings": "Zobacz ustawienia odtwarzacza",
|
||||
"LabelViewQueue": "Wyświetlaj kolejkę odtwarzania",
|
||||
"LabelVolume": "Głośność",
|
||||
"LabelWeekdaysToRun": "Dni tygodnia",
|
||||
|
|
@ -642,7 +648,7 @@
|
|||
"MessageConfirmRemoveEpisodes": "Czy na pewno chcesz usunąć {0} odcinki?",
|
||||
"MessageConfirmRemoveListeningSessions": "Czy na pewno chcesz usunąć {0} sesji słuchania?",
|
||||
"MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?",
|
||||
"MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?",
|
||||
"MessageConfirmRemovePlaylist": "Czy jesteś pewien, że chcesz usunąć twoją playlistę \"{0}\"?",
|
||||
"MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?",
|
||||
"MessageConfirmRenameGenreMergeNote": "Note: This genre already exists so they will be merged.",
|
||||
"MessageConfirmRenameGenreWarning": "Warning! A similar genre with a different casing already exists \"{0}\".",
|
||||
|
|
@ -663,7 +669,7 @@
|
|||
"MessageItemsSelected": "{0} zaznaczone elementy",
|
||||
"MessageItemsUpdated": "{0} Items Updated",
|
||||
"MessageJoinUsOn": "Dołącz do nas na",
|
||||
"MessageListeningSessionsInTheLastYear": "{0} sesje odsłuchowe w ostatnim roku",
|
||||
"MessageListeningSessionsInTheLastYear": "Sesje słuchania w ostatnim roku: {0}",
|
||||
"MessageLoading": "Ładowanie...",
|
||||
"MessageLoadingFolders": "Ładowanie folderów...",
|
||||
"MessageLogsDescription": "Logi zapisane są w <code>/metadata/logs</code> jako pliki JSON. Logi awaryjne są zapisane w <code>/metadata/logs/crash_logs.txt</code>.",
|
||||
|
|
@ -692,7 +698,7 @@
|
|||
"MessageNoIssues": "Brak problemów",
|
||||
"MessageNoItems": "Brak elementów",
|
||||
"MessageNoItemsFound": "Nie znaleziono żadnych elementów",
|
||||
"MessageNoListeningSessions": "Brak sesji odtwarzania",
|
||||
"MessageNoListeningSessions": "Brak sesji słuchania",
|
||||
"MessageNoLogs": "Brak logów",
|
||||
"MessageNoMediaProgress": "Brak postępu",
|
||||
"MessageNoNotifications": "Brak powiadomień",
|
||||
|
|
@ -709,7 +715,7 @@
|
|||
"MessageOr": "lub",
|
||||
"MessagePauseChapter": "Zatrzymaj odtwarzanie rozdziały",
|
||||
"MessagePlayChapter": "Rozpocznij odtwarzanie od początku rozdziału",
|
||||
"MessagePlaylistCreateFromCollection": "Utwórz listę odtwarznia na podstawie kolekcji",
|
||||
"MessagePlaylistCreateFromCollection": "Utwórz listę odtwarzania na podstawie kolekcji",
|
||||
"MessagePodcastHasNoRSSFeedForMatching": "Podcast nie ma adresu url kanału RSS, który mógłby zostać użyty do dopasowania",
|
||||
"MessageQuickMatchDescription": "Wypełnij puste informacje i okładkę pierwszym wynikiem dopasowania z '{0}'. Nie nadpisuje szczegółów, chyba że włączone jest ustawienie serwera 'Preferuj dopasowane metadane'.",
|
||||
"MessageRemoveChapter": "Usuń rozdział",
|
||||
|
|
@ -724,8 +730,9 @@
|
|||
"MessageSelected": "{0} wybranych",
|
||||
"MessageServerCouldNotBeReached": "Nie udało się uzyskać połączenia z serwerem",
|
||||
"MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name",
|
||||
"MessageShareExpirationWillBe": "Czas udostępniania <strong>{0}</strong>",
|
||||
"MessageShareExpiresIn": "Wygaśnie za {0}",
|
||||
"MessageShareURLWillBe": "URL udziału będzie <strong>{0}</strong>",
|
||||
"MessageShareURLWillBe": "Udostępnione pod linkiem <strong>{0}</strong>",
|
||||
"MessageStartPlaybackAtTime": "Rozpoczęcie odtwarzania \"{0}\" od {1}?",
|
||||
"MessageThinking": "Myślę...",
|
||||
"MessageUploaderItemFailed": "Nie udało się przesłać",
|
||||
|
|
@ -746,7 +753,7 @@
|
|||
"NoteUploaderUnsupportedFiles": "Nieobsługiwane pliki są ignorowane. Podczas dodawania folderu, inne pliki, które nie znajdują się w folderze elementu, są ignorowane.",
|
||||
"PlaceholderNewCollection": "Nowa nazwa kolekcji",
|
||||
"PlaceholderNewFolderPath": "Nowa ścieżka folderu",
|
||||
"PlaceholderNewPlaylist": "New playlist name",
|
||||
"PlaceholderNewPlaylist": "Nowa nazwa playlisty",
|
||||
"PlaceholderSearch": "Szukanie..",
|
||||
"PlaceholderSearchEpisode": "Szukanie odcinka..",
|
||||
"ToastAccountUpdateFailed": "Nie udało się zaktualizować konta",
|
||||
|
|
@ -802,12 +809,12 @@
|
|||
"ToastLibraryScanStarted": "Rozpoczęto skanowanie biblioteki",
|
||||
"ToastLibraryUpdateFailed": "Nie udało się zaktualizować biblioteki",
|
||||
"ToastLibraryUpdateSuccess": "Zaktualizowano \"{0}\" pozycji",
|
||||
"ToastPlaylistCreateFailed": "Failed to create playlist",
|
||||
"ToastPlaylistCreateSuccess": "Playlist created",
|
||||
"ToastPlaylistRemoveFailed": "Failed to remove playlist",
|
||||
"ToastPlaylistRemoveSuccess": "Playlist removed",
|
||||
"ToastPlaylistUpdateFailed": "Failed to update playlist",
|
||||
"ToastPlaylistUpdateSuccess": "Playlist updated",
|
||||
"ToastPlaylistCreateFailed": "Nie udało się utworzyć playlisty",
|
||||
"ToastPlaylistCreateSuccess": "Playlista utworzona",
|
||||
"ToastPlaylistRemoveFailed": "Nie udało się usunąć playlisty",
|
||||
"ToastPlaylistRemoveSuccess": "Playlista usunięta",
|
||||
"ToastPlaylistUpdateFailed": "Nie udało się zaktualizować playlisty",
|
||||
"ToastPlaylistUpdateSuccess": "Playlista zaktualizowana",
|
||||
"ToastPodcastCreateFailed": "Nie udało się utworzyć podcastu",
|
||||
"ToastPodcastCreateSuccess": "Podcast został pomyślnie utworzony",
|
||||
"ToastRSSFeedCloseFailed": "Zamknięcie kanału RSS nie powiodło się",
|
||||
|
|
|
|||
|
|
@ -58,14 +58,14 @@ paths:
|
|||
404:
|
||||
description: Not found
|
||||
|
||||
/api/podcasts/opml:
|
||||
/api/podcasts/opml/parse:
|
||||
post:
|
||||
summary: Get feeds from OPML text
|
||||
description: Parse OPML text and return an array of feeds
|
||||
operationId: getFeedsFromOPMLText
|
||||
tags:
|
||||
- Podcasts
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
|
|
@ -73,20 +73,58 @@ paths:
|
|||
properties:
|
||||
opmlText:
|
||||
type: string
|
||||
description: The OPML text containing podcast feeds
|
||||
responses:
|
||||
200:
|
||||
description: Successfully retrieved feeds from OPML text
|
||||
'200':
|
||||
description: Successfully parsed OPML text and returned feeds
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
feeds:
|
||||
type: array
|
||||
items:
|
||||
$ref: '../objects/mediaTypes/Podcast.yaml#/components/schemas/Podcast'
|
||||
400:
|
||||
description: Bad request
|
||||
403:
|
||||
description: Forbidden
|
||||
type: object
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
feedUrl:
|
||||
type: string
|
||||
'400':
|
||||
description: Bad request, OPML text not provided
|
||||
'403':
|
||||
description: Forbidden, user is not admin
|
||||
/api/podcasts/opml/create:
|
||||
post:
|
||||
summary: Bulk create podcasts from OPML feed URLs
|
||||
operationId: bulkCreatePodcastsFromOpmlFeedUrls
|
||||
tags:
|
||||
- Podcasts
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
feeds:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
libraryId:
|
||||
$ref: '../objects/Library.yaml#/components/schemas/libraryId'
|
||||
folderId:
|
||||
$ref: '../objects/Folder.yaml#/components/schemas/folderId'
|
||||
autoDownloadEpisodes:
|
||||
$ref: '../objects/mediaTypes/Podcast.yaml#/components/schemas/autoDownloadEpisodes'
|
||||
responses:
|
||||
'200':
|
||||
description: Successfully created podcasts from feed URLs
|
||||
'400':
|
||||
description: Bad request, invalid request body
|
||||
'403':
|
||||
description: Forbidden, user is not admin
|
||||
'404':
|
||||
description: Folder not found
|
||||
|
||||
/api/podcasts/{id}/checknew:
|
||||
parameters:
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ components:
|
|||
nullable: true
|
||||
format: 'pod_[a-z0-9]{18}'
|
||||
example: pod_o78uaoeuh78h6aoeif
|
||||
autoDownloadEpisodes:
|
||||
type: boolean
|
||||
description: Whether episodes are automatically downloaded.
|
||||
|
||||
Podcast:
|
||||
type: object
|
||||
|
|
@ -37,8 +40,7 @@ components:
|
|||
items:
|
||||
$ref: '../entities/PodcastEpisode.yaml#/components/schemas/PodcastEpisode'
|
||||
autoDownloadEpisodes:
|
||||
type: boolean
|
||||
description: Whether episodes are automatically downloaded.
|
||||
$ref: '#/components/schemas/autoDownloadEpisodes'
|
||||
autoDownloadSchedule:
|
||||
type: string
|
||||
description: The schedule for automatic episode downloads, in cron format.
|
||||
|
|
|
|||
|
|
@ -1589,23 +1589,22 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/api/podcasts/opml": {
|
||||
"/api/podcasts/opml/parse": {
|
||||
"post": {
|
||||
"summary": "Get feeds from OPML text",
|
||||
"description": "Parse OPML text and return an array of feeds",
|
||||
"operationId": "getFeedsFromOPMLText",
|
||||
"tags": [
|
||||
"Podcasts"
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"opmlText": {
|
||||
"type": "string",
|
||||
"description": "The OPML text containing podcast feeds"
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1614,23 +1613,85 @@
|
|||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successfully retrieved feeds from OPML text",
|
||||
"description": "Successfully parsed OPML text and returned feeds",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"feeds": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Podcast"
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"feedUrl": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad request"
|
||||
"description": "Bad request, OPML text not provided"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden"
|
||||
"description": "Forbidden, user is not admin"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/podcasts/opml/create": {
|
||||
"post": {
|
||||
"summary": "Bulk create podcasts from OPML feed URLs",
|
||||
"operationId": "bulkCreatePodcastsFromOpmlFeedUrls",
|
||||
"tags": [
|
||||
"Podcasts"
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"feeds": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"libraryId": {
|
||||
"$ref": "#/components/schemas/libraryId"
|
||||
},
|
||||
"folderId": {
|
||||
"$ref": "#/components/schemas/folderId"
|
||||
},
|
||||
"autoDownloadEpisodes": {
|
||||
"$ref": "#/components/schemas/autoDownloadEpisodes"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successfully created podcasts from feed URLs"
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad request, invalid request body"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden, user is not admin"
|
||||
},
|
||||
"404": {
|
||||
"description": "Folder not found"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3856,6 +3917,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"autoDownloadEpisodes": {
|
||||
"type": "boolean",
|
||||
"description": "Whether episodes are automatically downloaded."
|
||||
},
|
||||
"Podcast": {
|
||||
"type": "object",
|
||||
"description": "A podcast containing multiple episodes.",
|
||||
|
|
@ -3889,8 +3954,7 @@
|
|||
}
|
||||
},
|
||||
"autoDownloadEpisodes": {
|
||||
"type": "boolean",
|
||||
"description": "Whether episodes are automatically downloaded."
|
||||
"$ref": "#/components/schemas/autoDownloadEpisodes"
|
||||
},
|
||||
"autoDownloadSchedule": {
|
||||
"type": "string",
|
||||
|
|
|
|||
|
|
@ -57,8 +57,10 @@ paths:
|
|||
$ref: './controllers/PodcastController.yaml#/paths/~1api~1podcasts'
|
||||
/api/podcasts/feed:
|
||||
$ref: './controllers/PodcastController.yaml#/paths/~1api~1podcasts~1feed'
|
||||
/api/podcasts/opml:
|
||||
$ref: './controllers/PodcastController.yaml#/paths/~1api~1podcasts~1opml'
|
||||
/api/podcasts/opml/parse:
|
||||
$ref: './controllers/PodcastController.yaml#/paths/~1api~1podcasts~1opml~1parse'
|
||||
/api/podcasts/opml/create:
|
||||
$ref: './controllers/PodcastController.yaml#/paths/~1api~1podcasts~1opml~1create'
|
||||
/api/podcasts/{id}/checknew:
|
||||
$ref: './controllers/PodcastController.yaml#/paths/~1api~1podcasts~1{id}~1checknew'
|
||||
/api/podcasts/{id}/clear-queue:
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ Audiobookshelf is a self-hosted audiobook and podcast server.
|
|||
|
||||
Is there a feature you are looking for? [Suggest it](https://github.com/advplyr/audiobookshelf/issues/new/choose)
|
||||
|
||||
Join us on [Discord](https://discord.gg/HQgCbd6E75) or [Matrix](https://matrix.to/#/#audiobookshelf:matrix.org)
|
||||
Join us on [Discord](https://discord.gg/HQgCbd6E75)
|
||||
|
||||
### Android App (beta)
|
||||
|
||||
|
|
@ -47,7 +47,7 @@ Try it out on the [Google Play Store](https://play.google.com/store/apps/details
|
|||
|
||||
### iOS App (beta)
|
||||
|
||||
**Beta is currently full. Apple has a hard limit of 10k beta testers. Updates will be posted in Discord/Matrix.**
|
||||
**Beta is currently full. Apple has a hard limit of 10k beta testers. Updates will be posted in Discord.**
|
||||
|
||||
Using Test Flight: https://testflight.apple.com/join/wiic7QIW **_(beta is full)_**
|
||||
|
||||
|
|
|
|||
|
|
@ -207,6 +207,7 @@ class Database {
|
|||
|
||||
try {
|
||||
await this.sequelize.authenticate()
|
||||
await this.loadExtensions([process.env.SQLEAN_UNICODE_PATH])
|
||||
Logger.info(`[Database] Db connection was successful`)
|
||||
return true
|
||||
} catch (error) {
|
||||
|
|
@ -215,6 +216,34 @@ class Database {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string[]} extensions paths to extension binaries
|
||||
*/
|
||||
async loadExtensions(extensions) {
|
||||
// This is a hack to get the db connection for loading extensions.
|
||||
// The proper way would be to use the 'afterConnect' hook, but that hook is never called for sqlite due to a bug in sequelize.
|
||||
// See https://github.com/sequelize/sequelize/issues/12487
|
||||
// This is not a public API and may break in the future.
|
||||
const db = await this.sequelize.dialect.connectionManager.getConnection()
|
||||
if (typeof db?.loadExtension !== 'function') throw new Error('Failed to get db connection for loading extensions')
|
||||
|
||||
for (const ext of extensions) {
|
||||
Logger.info(`[Database] Loading extension ${ext}`)
|
||||
await new Promise((resolve, reject) => {
|
||||
db.loadExtension(ext, (err) => {
|
||||
if (err) {
|
||||
Logger.error(`[Database] Failed to load extension ${ext}`, err)
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
Logger.info(`[Database] Successfully loaded extension ${ext}`)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from db
|
||||
*/
|
||||
|
|
@ -801,6 +830,39 @@ class Database {
|
|||
Logger.warn(`Removed ${badSessionsRemoved} sessions that were 3 seconds or less`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} value
|
||||
* @returns {string}
|
||||
*/
|
||||
normalize(value) {
|
||||
return `lower(unaccent(${value}))`
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} query
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async getNormalizedQuery(query) {
|
||||
const escapedQuery = this.sequelize.escape(query)
|
||||
const normalizedQuery = this.normalize(escapedQuery)
|
||||
const normalizedQueryResult = await this.sequelize.query(`SELECT ${normalizedQuery} as normalized_query`)
|
||||
return normalizedQueryResult[0][0].normalized_query
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} column
|
||||
* @param {string} normalizedQuery
|
||||
* @returns {string}
|
||||
*/
|
||||
matchExpression(column, normalizedQuery) {
|
||||
const normalizedPattern = this.sequelize.escape(`%${normalizedQuery}%`)
|
||||
const normalizedColumn = this.normalize(column)
|
||||
return `${normalizedColumn} LIKE ${normalizedPattern}`
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new Database()
|
||||
|
|
|
|||
|
|
@ -108,6 +108,8 @@ class Server {
|
|||
|
||||
await this.playbackSessionManager.removeOrphanStreams()
|
||||
|
||||
await this.binaryManager.init()
|
||||
|
||||
await Database.init(false)
|
||||
|
||||
await Logger.logManager.init()
|
||||
|
|
@ -128,11 +130,6 @@ class Server {
|
|||
await this.cronManager.init(libraries)
|
||||
this.apiCacheManager.init()
|
||||
|
||||
// Download ffmpeg & ffprobe if not found (Currently only in use for Windows installs)
|
||||
if (global.isWin || Logger.isDev) {
|
||||
await this.binaryManager.init()
|
||||
}
|
||||
|
||||
if (Database.serverSettings.scannerDisableWatcher) {
|
||||
Logger.info(`[Server] Watcher is disabled`)
|
||||
this.watcher.disabled = true
|
||||
|
|
|
|||
|
|
@ -14,6 +14,15 @@ const CoverManager = require('../managers/CoverManager')
|
|||
const LibraryItem = require('../objects/LibraryItem')
|
||||
|
||||
class PodcastController {
|
||||
/**
|
||||
* POST /api/podcasts
|
||||
* Create podcast
|
||||
*
|
||||
* @this import('../routers/ApiRouter')
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
*/
|
||||
async create(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to create podcast`)
|
||||
|
|
@ -133,6 +142,14 @@ class PodcastController {
|
|||
res.json({ podcast })
|
||||
}
|
||||
|
||||
/**
|
||||
* POST: /api/podcasts/opml
|
||||
*
|
||||
* @this import('../routers/ApiRouter')
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
*/
|
||||
async getFeedsFromOPMLText(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to get feeds from opml`)
|
||||
|
|
@ -143,8 +160,44 @@ class PodcastController {
|
|||
return res.sendStatus(400)
|
||||
}
|
||||
|
||||
const rssFeedsData = await this.podcastManager.getOPMLFeeds(req.body.opmlText)
|
||||
res.json(rssFeedsData)
|
||||
res.json({
|
||||
feeds: this.podcastManager.getParsedOPMLFileFeeds(req.body.opmlText)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* POST: /api/podcasts/opml/create
|
||||
*
|
||||
* @this import('../routers/ApiRouter')
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
*/
|
||||
async bulkCreatePodcastsFromOpmlFeedUrls(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to bulk create podcasts`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
const rssFeeds = req.body.feeds
|
||||
if (!Array.isArray(rssFeeds) || !rssFeeds.length || rssFeeds.some((feed) => !validateUrl(feed))) {
|
||||
return res.status(400).send('Invalid request body. "feeds" must be an array of RSS feed URLs')
|
||||
}
|
||||
|
||||
const libraryId = req.body.libraryId
|
||||
const folderId = req.body.folderId
|
||||
if (!libraryId || !folderId) {
|
||||
return res.status(400).send('Invalid request body. "libraryId" and "folderId" are required')
|
||||
}
|
||||
|
||||
const folder = await Database.libraryFolderModel.findByPk(folderId)
|
||||
if (!folder || folder.libraryId !== libraryId) {
|
||||
return res.status(404).send('Folder not found')
|
||||
}
|
||||
const autoDownloadEpisodes = !!req.body.autoDownloadEpisodes
|
||||
this.podcastManager.createPodcastsFromFeedUrls(rssFeeds, folder, autoDownloadEpisodes, this.cronManager)
|
||||
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
async checkNewEpisodes(req, res) {
|
||||
|
|
|
|||
|
|
@ -1,315 +0,0 @@
|
|||
const os = require('os')
|
||||
const path = require('path')
|
||||
const axios = require('axios')
|
||||
const fse = require('../fsExtra')
|
||||
const async = require('../async')
|
||||
const StreamZip = require('../nodeStreamZip')
|
||||
const { finished } = require('stream/promises')
|
||||
|
||||
var API_URL = 'https://ffbinaries.com/api/v1'
|
||||
|
||||
var RUNTIME_CACHE = {}
|
||||
var errorMsgs = {
|
||||
connectionIssues: 'Couldn\'t connect to ffbinaries.com API. Check your Internet connection.',
|
||||
parsingVersionData: 'Couldn\'t parse retrieved version data.',
|
||||
parsingVersionList: 'Couldn\'t parse the list of available versions.',
|
||||
notFound: 'Requested data not found.',
|
||||
incorrectVersionParam: '"version" parameter must be a string.'
|
||||
}
|
||||
|
||||
function ensureDirSync(dir) {
|
||||
try {
|
||||
fse.accessSync(dir)
|
||||
} catch (e) {
|
||||
fse.mkdirSync(dir)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the platform key based on input string
|
||||
*/
|
||||
function resolvePlatform(input) {
|
||||
var rtn = null
|
||||
|
||||
switch (input) {
|
||||
case 'mac':
|
||||
case 'osx':
|
||||
case 'mac-64':
|
||||
case 'osx-64':
|
||||
rtn = 'osx-64'
|
||||
break
|
||||
|
||||
case 'linux':
|
||||
case 'linux-32':
|
||||
rtn = 'linux-32'
|
||||
break
|
||||
|
||||
case 'linux-64':
|
||||
rtn = 'linux-64'
|
||||
break
|
||||
|
||||
case 'linux-arm':
|
||||
case 'linux-armel':
|
||||
rtn = 'linux-armel'
|
||||
break
|
||||
|
||||
case 'linux-armhf':
|
||||
rtn = 'linux-armhf'
|
||||
break
|
||||
|
||||
case 'win':
|
||||
case 'win-32':
|
||||
case 'windows':
|
||||
case 'windows-32':
|
||||
rtn = 'windows-32'
|
||||
break
|
||||
|
||||
case 'win-64':
|
||||
case 'windows-64':
|
||||
rtn = 'windows-64'
|
||||
break
|
||||
|
||||
default:
|
||||
rtn = null
|
||||
}
|
||||
|
||||
return rtn
|
||||
}
|
||||
/**
|
||||
* Detects the platform of the machine the script is executed on.
|
||||
* Object can be provided to detect platform from info derived elsewhere.
|
||||
*
|
||||
* @param {object} osinfo Contains "type" and "arch" properties
|
||||
*/
|
||||
function detectPlatform(osinfo) {
|
||||
var inputIsValid = typeof osinfo === 'object' && typeof osinfo.type === 'string' && typeof osinfo.arch === 'string'
|
||||
var type = (inputIsValid ? osinfo.type : os.type()).toLowerCase()
|
||||
var arch = (inputIsValid ? osinfo.arch : os.arch()).toLowerCase()
|
||||
|
||||
if (type === 'darwin') {
|
||||
return 'osx-64'
|
||||
}
|
||||
|
||||
if (type === 'windows_nt') {
|
||||
return arch === 'x64' ? 'windows-64' : 'windows-32'
|
||||
}
|
||||
|
||||
if (type === 'linux') {
|
||||
if (arch === 'arm' || arch === 'arm64') {
|
||||
return 'linux-armel'
|
||||
}
|
||||
return arch === 'x64' ? 'linux-64' : 'linux-32'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
/**
|
||||
* Gets the binary filename (appends exe in Windows)
|
||||
*
|
||||
* @param {string} component "ffmpeg", "ffplay", "ffprobe" or "ffserver"
|
||||
* @param {platform} platform "ffmpeg", "ffplay", "ffprobe" or "ffserver"
|
||||
*/
|
||||
function getBinaryFilename(component, platform) {
|
||||
var platformCode = resolvePlatform(platform)
|
||||
if (platformCode === 'windows-32' || platformCode === 'windows-64') {
|
||||
return component + '.exe'
|
||||
}
|
||||
return component
|
||||
}
|
||||
|
||||
function listPlatforms() {
|
||||
return ['osx-64', 'linux-32', 'linux-64', 'linux-armel', 'linux-armhf', 'windows-32', 'windows-64']
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {Promise<string[]>} array of version strings
|
||||
*/
|
||||
function listVersions() {
|
||||
if (RUNTIME_CACHE.versionsAll) {
|
||||
return RUNTIME_CACHE.versionsAll
|
||||
}
|
||||
return axios.get(API_URL).then((res) => {
|
||||
if (!res.data?.versions || !Object.keys(res.data.versions)?.length) {
|
||||
throw new Error(errorMsgs.parsingVersionList)
|
||||
}
|
||||
const versionKeys = Object.keys(res.data.versions)
|
||||
RUNTIME_CACHE.versionsAll = versionKeys
|
||||
return versionKeys
|
||||
})
|
||||
}
|
||||
/**
|
||||
* Gets full data set from ffbinaries.com
|
||||
*/
|
||||
function getVersionData(version) {
|
||||
if (RUNTIME_CACHE[version]) {
|
||||
return RUNTIME_CACHE[version]
|
||||
}
|
||||
|
||||
if (version && typeof version !== 'string') {
|
||||
throw new Error(errorMsgs.incorrectVersionParam)
|
||||
}
|
||||
|
||||
var url = version ? '/version/' + version : '/latest'
|
||||
|
||||
return axios.get(`${API_URL}${url}`).then((res) => {
|
||||
RUNTIME_CACHE[version] = res.data
|
||||
return res.data
|
||||
}).catch((error) => {
|
||||
if (error.response?.status == 404) {
|
||||
throw new Error(errorMsgs.notFound)
|
||||
} else {
|
||||
throw new Error(errorMsgs.connectionIssues)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Download file(s) and save them in the specified directory
|
||||
*/
|
||||
async function downloadUrls(components, urls, opts) {
|
||||
const destinationDir = opts.destination
|
||||
const results = []
|
||||
const remappedUrls = []
|
||||
|
||||
if (components && !Array.isArray(components)) {
|
||||
components = [components]
|
||||
} else if (!components || !Array.isArray(components)) {
|
||||
components = []
|
||||
}
|
||||
|
||||
// returns an array of objects like this: {component: 'ffmpeg', url: 'https://...'}
|
||||
if (typeof urls === 'object') {
|
||||
for (const key in urls) {
|
||||
if (components.includes(key) && urls[key]) {
|
||||
remappedUrls.push({
|
||||
component: key,
|
||||
url: urls[key]
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function extractZipToDestination(zipFilename) {
|
||||
const oldpath = path.join(destinationDir, zipFilename)
|
||||
const zip = new StreamZip.async({ file: oldpath })
|
||||
const count = await zip.extract(null, destinationDir)
|
||||
await zip.close()
|
||||
}
|
||||
|
||||
|
||||
await async.each(remappedUrls, async function (urlObject) {
|
||||
try {
|
||||
const url = urlObject.url
|
||||
|
||||
const zipFilename = url.split('/').pop()
|
||||
const binFilenameBase = urlObject.component
|
||||
const binFilename = getBinaryFilename(binFilenameBase, opts.platform || detectPlatform())
|
||||
|
||||
let runningTotal = 0
|
||||
let totalFilesize
|
||||
let interval
|
||||
|
||||
|
||||
if (typeof opts.tickerFn === 'function') {
|
||||
opts.tickerInterval = parseInt(opts.tickerInterval, 10)
|
||||
const tickerInterval = (!Number.isNaN(opts.tickerInterval)) ? opts.tickerInterval : 1000
|
||||
const tickData = { filename: zipFilename, progress: 0 }
|
||||
|
||||
// Schedule next ticks
|
||||
interval = setInterval(function () {
|
||||
if (totalFilesize && runningTotal == totalFilesize) {
|
||||
return clearInterval(interval)
|
||||
}
|
||||
tickData.progress = totalFilesize > -1 ? runningTotal / totalFilesize : 0
|
||||
|
||||
opts.tickerFn(tickData)
|
||||
}, tickerInterval)
|
||||
}
|
||||
|
||||
|
||||
// Check if file already exists in target directory
|
||||
const binPath = path.join(destinationDir, binFilename)
|
||||
if (!opts.force && await fse.pathExists(binPath)) {
|
||||
// if the accessSync method doesn't throw we know the binary already exists
|
||||
results.push({
|
||||
filename: binFilename,
|
||||
path: destinationDir,
|
||||
status: 'File exists',
|
||||
code: 'FILE_EXISTS'
|
||||
})
|
||||
clearInterval(interval)
|
||||
return
|
||||
}
|
||||
|
||||
if (opts.quiet) clearInterval(interval)
|
||||
|
||||
const zipPath = path.join(destinationDir, zipFilename)
|
||||
const zipFileTempName = zipPath + '.part'
|
||||
const zipFileFinalName = zipPath
|
||||
|
||||
const response = await axios({
|
||||
url,
|
||||
method: 'GET',
|
||||
responseType: 'stream'
|
||||
})
|
||||
totalFilesize = response.headers?.['content-length'] || []
|
||||
|
||||
const writer = fse.createWriteStream(zipFileTempName)
|
||||
response.data.on('data', (chunk) => {
|
||||
runningTotal += chunk.length
|
||||
})
|
||||
response.data.pipe(writer)
|
||||
await finished(writer)
|
||||
await fse.rename(zipFileTempName, zipFileFinalName)
|
||||
await extractZipToDestination(zipFilename)
|
||||
await fse.remove(zipFileFinalName)
|
||||
|
||||
results.push({
|
||||
filename: binFilename,
|
||||
path: destinationDir,
|
||||
size: Math.floor(totalFilesize / 1024 / 1024 * 1000) / 1000 + 'MB',
|
||||
status: 'File extracted to destination (downloaded from "' + url + '")',
|
||||
code: 'DONE_CLEAN'
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(`Failed to download or extract file for component: ${urlObject.component}`, err)
|
||||
}
|
||||
})
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets binaries for the platform
|
||||
* It will get the data from ffbinaries, pick the correct files
|
||||
* and save it to the specified directory
|
||||
*
|
||||
* @param {Array} components
|
||||
* @param {Object} [opts]
|
||||
*/
|
||||
async function downloadBinaries(components, opts = {}) {
|
||||
var platform = resolvePlatform(opts.platform) || detectPlatform()
|
||||
|
||||
opts.destination = path.resolve(opts.destination || '.')
|
||||
ensureDirSync(opts.destination)
|
||||
|
||||
const versionData = await getVersionData(opts.version)
|
||||
const urls = versionData?.bin?.[platform]
|
||||
if (!urls) {
|
||||
throw new Error('No URLs!')
|
||||
}
|
||||
|
||||
return await downloadUrls(components, urls, opts)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
downloadBinaries: downloadBinaries,
|
||||
getVersionData: getVersionData,
|
||||
listVersions: listVersions,
|
||||
listPlatforms: listPlatforms,
|
||||
detectPlatform: detectPlatform,
|
||||
resolvePlatform: resolvePlatform,
|
||||
getBinaryFilename: getBinaryFilename
|
||||
}
|
||||
|
|
@ -1,12 +1,14 @@
|
|||
const Path = require('path')
|
||||
const fs = require('../libs/fsExtra')
|
||||
|
||||
const workerThreads = require('worker_threads')
|
||||
const Logger = require('../Logger')
|
||||
const TaskManager = require('./TaskManager')
|
||||
const Task = require('../objects/Task')
|
||||
const { writeConcatFile } = require('../utils/ffmpegHelpers')
|
||||
const ffmpegHelpers = require('../utils/ffmpegHelpers')
|
||||
const Ffmpeg = require('../libs/fluentFfmpeg')
|
||||
const SocketAuthority = require('../SocketAuthority')
|
||||
const fileUtils = require('../utils/fileUtils')
|
||||
const TrackProgressMonitor = require('../objects/TrackProgressMonitor')
|
||||
|
||||
class AbMergeManager {
|
||||
constructor() {
|
||||
|
|
@ -20,6 +22,7 @@ class AbMergeManager {
|
|||
}
|
||||
|
||||
cancelEncode(task) {
|
||||
task.setFailed('Task canceled by user')
|
||||
return this.removeTask(task, true)
|
||||
}
|
||||
|
||||
|
|
@ -36,6 +39,7 @@ class AbMergeManager {
|
|||
libraryItemPath: libraryItem.path,
|
||||
userId: user.id,
|
||||
originalTrackPaths: libraryItem.media.tracks.map((t) => t.metadata.path),
|
||||
inos: libraryItem.media.includedAudioFiles.map((f) => f.ino),
|
||||
tempFilepath,
|
||||
targetFilename,
|
||||
targetFilepath: Path.join(libraryItem.path, targetFilename),
|
||||
|
|
@ -43,7 +47,8 @@ class AbMergeManager {
|
|||
ffmetadataObject: ffmpegHelpers.getFFMetadataObject(libraryItem, 1),
|
||||
chapters: libraryItem.media.chapters?.map((c) => ({ ...c })),
|
||||
coverPath: libraryItem.media.coverPath,
|
||||
ffmetadataPath
|
||||
ffmetadataPath,
|
||||
duration: libraryItem.media.duration
|
||||
}
|
||||
const taskDescription = `Encoding audiobook "${libraryItem.media.metadata.title}" into a single m4b file.`
|
||||
task.setData('encode-m4b', 'Encoding M4b', taskDescription, false, taskData)
|
||||
|
|
@ -58,119 +63,78 @@ class AbMergeManager {
|
|||
}
|
||||
|
||||
async runAudiobookMerge(libraryItem, task, encodingOptions) {
|
||||
// Make sure the target directory is writable
|
||||
if (!(await fileUtils.isWritable(libraryItem.path))) {
|
||||
Logger.error(`[AbMergeManager] Target directory is not writable: ${libraryItem.path}`)
|
||||
task.setFailed('Target directory is not writable')
|
||||
this.removeTask(task, true)
|
||||
return
|
||||
}
|
||||
|
||||
// Create ffmetadata file
|
||||
const success = await ffmpegHelpers.writeFFMetadataFile(task.data.metadataObject, task.data.chapters, task.data.ffmetadataPath)
|
||||
if (!success) {
|
||||
if (!(await ffmpegHelpers.writeFFMetadataFile(task.data.ffmetadataObject, task.data.chapters, task.data.ffmetadataPath))) {
|
||||
Logger.error(`[AudioMetadataManager] Failed to write ffmetadata file for audiobook "${task.data.libraryItemId}"`)
|
||||
task.setFailed('Failed to write metadata file.')
|
||||
this.removeTask(task, true)
|
||||
return
|
||||
}
|
||||
|
||||
const audioBitrate = encodingOptions.bitrate || '128k'
|
||||
const audioCodec = encodingOptions.codec || 'aac'
|
||||
const audioChannels = encodingOptions.channels || 2
|
||||
|
||||
// If changing audio file type then encoding is needed
|
||||
const audioTracks = libraryItem.media.tracks
|
||||
|
||||
// TODO: Updated in 2.2.11 to always encode even if merging multiple m4b. This is because just using the file extension as was being done before is not enough. This can be an option or do more to check if a concat is possible.
|
||||
// const audioRequiresEncode = audioTracks[0].metadata.ext !== '.m4b'
|
||||
const audioRequiresEncode = true
|
||||
|
||||
const firstTrackIsM4b = audioTracks[0].metadata.ext.toLowerCase() === '.m4b'
|
||||
const isOneTrack = audioTracks.length === 1
|
||||
|
||||
const ffmpegInputs = []
|
||||
|
||||
if (!isOneTrack) {
|
||||
const concatFilePath = Path.join(task.data.itemCachePath, 'files.txt')
|
||||
await writeConcatFile(audioTracks, concatFilePath)
|
||||
ffmpegInputs.push({
|
||||
input: concatFilePath,
|
||||
options: ['-safe 0', '-f concat']
|
||||
})
|
||||
} else {
|
||||
ffmpegInputs.push({
|
||||
input: audioTracks[0].metadata.path,
|
||||
options: firstTrackIsM4b ? ['-f mp4'] : []
|
||||
})
|
||||
}
|
||||
|
||||
const logLevel = process.env.NODE_ENV === 'production' ? 'error' : 'warning'
|
||||
let ffmpegOptions = [`-loglevel ${logLevel}`]
|
||||
const ffmpegOutputOptions = ['-f mp4']
|
||||
|
||||
if (audioRequiresEncode) {
|
||||
ffmpegOptions = ffmpegOptions.concat(['-map 0:a', `-acodec ${audioCodec}`, `-ac ${audioChannels}`, `-b:a ${audioBitrate}`])
|
||||
} else {
|
||||
ffmpegOptions.push('-max_muxing_queue_size 1000')
|
||||
|
||||
if (isOneTrack && firstTrackIsM4b) {
|
||||
ffmpegOptions.push('-c copy')
|
||||
} else {
|
||||
ffmpegOptions.push('-c:a copy')
|
||||
}
|
||||
}
|
||||
|
||||
const workerData = {
|
||||
inputs: ffmpegInputs,
|
||||
options: ffmpegOptions,
|
||||
outputOptions: ffmpegOutputOptions,
|
||||
output: task.data.tempFilepath
|
||||
}
|
||||
|
||||
let worker = null
|
||||
try {
|
||||
const workerPath = Path.join(global.appRoot, 'server/utils/downloadWorker.js')
|
||||
worker = new workerThreads.Worker(workerPath, { workerData })
|
||||
} catch (error) {
|
||||
Logger.error(`[AbMergeManager] Start worker thread failed`, error)
|
||||
task.setFailed('Failed to start worker thread')
|
||||
this.removeTask(task, true)
|
||||
return
|
||||
}
|
||||
|
||||
worker.on('message', (message) => {
|
||||
if (message != null && typeof message === 'object') {
|
||||
if (message.type === 'RESULT') {
|
||||
this.sendResult(task, message)
|
||||
} else if (message.type === 'FFMPEG') {
|
||||
if (Logger[message.level]) {
|
||||
Logger[message.level](message.log)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
this.pendingTasks.push({
|
||||
id: task.id,
|
||||
task,
|
||||
worker
|
||||
task
|
||||
})
|
||||
}
|
||||
|
||||
async sendResult(task, result) {
|
||||
// Remove pending task
|
||||
this.pendingTasks = this.pendingTasks.filter((d) => d.id !== task.id)
|
||||
|
||||
if (result.isKilled) {
|
||||
task.setFailed('Ffmpeg task killed')
|
||||
const encodeFraction = 0.95
|
||||
const embedFraction = 1 - encodeFraction
|
||||
try {
|
||||
const trackProgressMonitor = new TrackProgressMonitor(
|
||||
libraryItem.media.tracks.map((t) => t.duration),
|
||||
(trackIndex) => SocketAuthority.adminEmitter('track_started', { libraryItemId: libraryItem.id, ino: task.data.inos[trackIndex] }),
|
||||
(trackIndex, progressInTrack, taskProgress) => {
|
||||
SocketAuthority.adminEmitter('track_progress', { libraryItemId: libraryItem.id, ino: task.data.inos[trackIndex], progress: progressInTrack })
|
||||
SocketAuthority.adminEmitter('task_progress', { libraryItemId: libraryItem.id, progress: taskProgress * encodeFraction })
|
||||
},
|
||||
(trackIndex) => SocketAuthority.adminEmitter('track_finished', { libraryItemId: libraryItem.id, ino: task.data.inos[trackIndex] })
|
||||
)
|
||||
task.data.ffmpeg = new Ffmpeg()
|
||||
await ffmpegHelpers.mergeAudioFiles(libraryItem.media.tracks, task.data.duration, task.data.itemCachePath, task.data.tempFilepath, encodingOptions, (progress) => trackProgressMonitor.update(progress), task.data.ffmpeg)
|
||||
delete task.data.ffmpeg
|
||||
trackProgressMonitor.finish()
|
||||
} catch (error) {
|
||||
if (error.message === 'FFMPEG_CANCELED') {
|
||||
Logger.info(`[AbMergeManager] Task cancelled ${task.id}`)
|
||||
} else {
|
||||
Logger.error(`[AbMergeManager] mergeAudioFiles failed`, error)
|
||||
task.setFailed('Failed to merge audio files')
|
||||
this.removeTask(task, true)
|
||||
return
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
task.setFailed('Encoding failed')
|
||||
this.removeTask(task, true)
|
||||
return
|
||||
}
|
||||
|
||||
// Write metadata to merged file
|
||||
const success = await ffmpegHelpers.addCoverAndMetadataToFile(task.data.tempFilepath, task.data.coverPath, task.data.ffmetadataPath, 1, 'audio/mp4')
|
||||
if (!success) {
|
||||
try {
|
||||
task.data.ffmpeg = new Ffmpeg()
|
||||
await ffmpegHelpers.addCoverAndMetadataToFile(
|
||||
task.data.tempFilepath,
|
||||
task.data.coverPath,
|
||||
task.data.ffmetadataPath,
|
||||
1,
|
||||
'audio/mp4',
|
||||
(progress) => {
|
||||
Logger.debug(`[AbMergeManager] Embedding metadata progress: ${100 * encodeFraction + progress * embedFraction}`)
|
||||
SocketAuthority.adminEmitter('task_progress', { libraryItemId: libraryItem.id, progress: 100 * encodeFraction + progress * embedFraction })
|
||||
},
|
||||
task.data.ffmpeg
|
||||
)
|
||||
delete task.data.ffmpeg
|
||||
} catch (error) {
|
||||
if (error.message === 'FFMPEG_CANCELED') {
|
||||
Logger.info(`[AbMergeManager] Task cancelled ${task.id}`)
|
||||
} else {
|
||||
Logger.error(`[AbMergeManager] Failed to write metadata to file "${task.data.tempFilepath}"`)
|
||||
task.setFailed('Failed to write metadata to m4b file')
|
||||
this.removeTask(task, true)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -199,19 +163,14 @@ class AbMergeManager {
|
|||
async removeTask(task, removeTempFilepath = false) {
|
||||
Logger.info('[AbMergeManager] Removing task ' + task.id)
|
||||
|
||||
const pendingDl = this.pendingTasks.find((d) => d.id === task.id)
|
||||
if (pendingDl) {
|
||||
const pendingTask = this.pendingTasks.find((d) => d.id === task.id)
|
||||
if (pendingTask) {
|
||||
this.pendingTasks = this.pendingTasks.filter((d) => d.id !== task.id)
|
||||
if (pendingDl.worker) {
|
||||
Logger.warn(`[AbMergeManager] Removing download in progress - stopping worker`)
|
||||
try {
|
||||
pendingDl.worker.postMessage('STOP')
|
||||
return
|
||||
} catch (error) {
|
||||
Logger.error('[AbMergeManager] Error posting stop message to worker', error)
|
||||
}
|
||||
} else {
|
||||
Logger.debug(`[AbMergeManager] Removing download in progress - no worker`)
|
||||
if (task.data.ffmpeg) {
|
||||
Logger.warn(`[AbMergeManager] Killing ffmpeg process for task ${task.id}`)
|
||||
task.data.ffmpeg.kill()
|
||||
// wait for ffmpeg to exit, so that the output file is unlocked
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,11 @@
|
|||
const Path = require('path')
|
||||
|
||||
const SocketAuthority = require('../SocketAuthority')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
const fs = require('../libs/fsExtra')
|
||||
|
||||
const ffmpegHelpers = require('../utils/ffmpegHelpers')
|
||||
|
||||
const TaskManager = require('./TaskManager')
|
||||
|
||||
const Task = require('../objects/Task')
|
||||
const fileUtils = require('../utils/fileUtils')
|
||||
|
||||
class AudioMetadataMangaer {
|
||||
constructor() {
|
||||
|
|
@ -68,7 +64,8 @@ class AudioMetadataMangaer {
|
|||
ino: af.ino,
|
||||
filename: af.metadata.filename,
|
||||
path: af.metadata.path,
|
||||
cachePath: Path.join(itemCachePath, af.metadata.filename)
|
||||
cachePath: Path.join(itemCachePath, af.metadata.filename),
|
||||
duration: af.duration
|
||||
})),
|
||||
coverPath: libraryItem.media.coverPath,
|
||||
metadataObject: ffmpegHelpers.getFFMetadataObject(libraryItem, audioFiles.length),
|
||||
|
|
@ -78,7 +75,8 @@ class AudioMetadataMangaer {
|
|||
options: {
|
||||
forceEmbedChapters,
|
||||
backupFiles
|
||||
}
|
||||
},
|
||||
duration: libraryItem.media.duration
|
||||
}
|
||||
const taskDescription = `Embedding metadata in audiobook "${libraryItem.media.metadata.title}".`
|
||||
task.setData('embed-metadata', 'Embedding Metadata', taskDescription, false, taskData)
|
||||
|
|
@ -101,11 +99,40 @@ class AudioMetadataMangaer {
|
|||
|
||||
Logger.info(`[AudioMetadataManager] Starting metadata embed task`, task.description)
|
||||
|
||||
// Ensure target directory is writable
|
||||
const targetDirWritable = await fileUtils.isWritable(task.data.libraryItemPath)
|
||||
Logger.debug(`[AudioMetadataManager] Target directory ${task.data.libraryItemPath} writable: ${targetDirWritable}`)
|
||||
if (!targetDirWritable) {
|
||||
Logger.error(`[AudioMetadataManager] Target directory is not writable: ${task.data.libraryItemPath}`)
|
||||
task.setFailed('Target directory is not writable')
|
||||
this.handleTaskFinished(task)
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure target audio files are writable
|
||||
for (const af of task.data.audioFiles) {
|
||||
try {
|
||||
await fs.access(af.path, fs.constants.W_OK)
|
||||
} catch (err) {
|
||||
Logger.error(`[AudioMetadataManager] Audio file is not writable: ${af.path}`)
|
||||
task.setFailed(`Audio file "${Path.basename(af.path)}" is not writable`)
|
||||
this.handleTaskFinished(task)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure item cache dir exists
|
||||
let cacheDirCreated = false
|
||||
if (!(await fs.pathExists(task.data.itemCachePath))) {
|
||||
try {
|
||||
await fs.mkdir(task.data.itemCachePath)
|
||||
cacheDirCreated = true
|
||||
} catch (err) {
|
||||
Logger.error(`[AudioMetadataManager] Failed to create cache directory ${task.data.itemCachePath}`, err)
|
||||
task.setFailed('Failed to create cache directory')
|
||||
this.handleTaskFinished(task)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Create ffmetadata file
|
||||
|
|
@ -119,8 +146,10 @@ class AudioMetadataMangaer {
|
|||
}
|
||||
|
||||
// Tag audio files
|
||||
let cummulativeProgress = 0
|
||||
for (const af of task.data.audioFiles) {
|
||||
SocketAuthority.adminEmitter('audiofile_metadata_started', {
|
||||
const audioFileRelativeDuration = af.duration / task.data.duration
|
||||
SocketAuthority.adminEmitter('track_started', {
|
||||
libraryItemId: task.data.libraryItemId,
|
||||
ino: af.ino
|
||||
})
|
||||
|
|
@ -133,18 +162,31 @@ class AudioMetadataMangaer {
|
|||
Logger.debug(`[AudioMetadataManager] Backed up audio file at "${backupFilePath}"`)
|
||||
} catch (err) {
|
||||
Logger.error(`[AudioMetadataManager] Failed to backup audio file "${af.path}"`, err)
|
||||
task.setFailed(`Failed to backup audio file "${Path.basename(af.path)}"`)
|
||||
this.handleTaskFinished(task)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const success = await ffmpegHelpers.addCoverAndMetadataToFile(af.path, task.data.coverPath, ffmetadataPath, af.index, task.data.mimeType)
|
||||
if (success) {
|
||||
try {
|
||||
await ffmpegHelpers.addCoverAndMetadataToFile(af.path, task.data.coverPath, ffmetadataPath, af.index, task.data.mimeType, (progress) => {
|
||||
SocketAuthority.adminEmitter('task_progress', { libraryItemId: task.data.libraryItemId, progress: cummulativeProgress + progress * audioFileRelativeDuration })
|
||||
SocketAuthority.adminEmitter('track_progress', { libraryItemId: task.data.libraryItemId, ino: af.ino, progress })
|
||||
})
|
||||
Logger.info(`[AudioMetadataManager] Successfully tagged audio file "${af.path}"`)
|
||||
} catch (err) {
|
||||
Logger.error(`[AudioMetadataManager] Failed to tag audio file "${af.path}"`, err)
|
||||
task.setFailed(`Failed to tag audio file "${Path.basename(af.path)}"`)
|
||||
this.handleTaskFinished(task)
|
||||
return
|
||||
}
|
||||
|
||||
SocketAuthority.adminEmitter('audiofile_metadata_finished', {
|
||||
SocketAuthority.adminEmitter('track_finished', {
|
||||
libraryItemId: task.data.libraryItemId,
|
||||
ino: af.ino
|
||||
})
|
||||
|
||||
cummulativeProgress += audioFileRelativeDuration * 100
|
||||
}
|
||||
|
||||
// Remove temp cache file/folder if not backing up
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ class BackupManager {
|
|||
}
|
||||
|
||||
get maxBackupSize() {
|
||||
return global.ServerSettings.maxBackupSize || 1
|
||||
return global.ServerSettings.maxBackupSize || Infinity
|
||||
}
|
||||
|
||||
async init() {
|
||||
|
|
@ -216,7 +216,9 @@ class BackupManager {
|
|||
Logger.info(`[BackupManager] Saved backup sqlite file at "${dbPath}"`)
|
||||
|
||||
// Extract /metadata/items and /metadata/authors folders
|
||||
await fs.ensureDir(this.ItemsMetadataPath)
|
||||
await zip.extract('metadata-items/', this.ItemsMetadataPath)
|
||||
await fs.ensureDir(this.AuthorsMetadataPath)
|
||||
await zip.extract('metadata-authors/', this.AuthorsMetadataPath)
|
||||
await zip.close()
|
||||
|
||||
|
|
@ -419,6 +421,7 @@ class BackupManager {
|
|||
reject(err)
|
||||
})
|
||||
archive.on('progress', ({ fs: fsobj }) => {
|
||||
if (this.maxBackupSize !== Infinity) {
|
||||
const maxBackupSizeInBytes = this.maxBackupSize * 1000 * 1000 * 1000
|
||||
if (fsobj.processedBytes > maxBackupSizeInBytes) {
|
||||
Logger.error(`[BackupManager] Archiver is too large - aborting to prevent endless loop, Bytes Processed: ${fsobj.processedBytes}`)
|
||||
|
|
@ -428,6 +431,7 @@ class BackupManager {
|
|||
output.destroy('Backup too large') // Promise is reject in write stream error evt
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// pipe archive data to the file
|
||||
|
|
|
|||
|
|
@ -2,25 +2,274 @@ const child_process = require('child_process')
|
|||
const { promisify } = require('util')
|
||||
const exec = promisify(child_process.exec)
|
||||
const path = require('path')
|
||||
const axios = require('axios')
|
||||
const which = require('../libs/which')
|
||||
const fs = require('../libs/fsExtra')
|
||||
const ffbinaries = require('../libs/ffbinaries')
|
||||
const Logger = require('../Logger')
|
||||
const fileUtils = require('../utils/fileUtils')
|
||||
const StreamZip = require('../libs/nodeStreamZip')
|
||||
|
||||
class GithubAssetDownloader {
|
||||
constructor(owner, repo) {
|
||||
this.owner = owner
|
||||
this.repo = repo
|
||||
this.assetCache = {}
|
||||
}
|
||||
|
||||
async getAssetUrl(releaseTag, assetName) {
|
||||
// Check if the assets information is already cached for the release tag
|
||||
if (this.assetCache[releaseTag]) {
|
||||
Logger.debug(`[GithubAssetDownloader] Repo ${this.repo} release ${releaseTag}: assets found in cache.`)
|
||||
} else {
|
||||
// Get the release information
|
||||
const releaseUrl = `https://api.github.com/repos/${this.owner}/${this.repo}/releases/tags/${releaseTag}`
|
||||
const releaseResponse = await axios.get(releaseUrl, {
|
||||
headers: {
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
'User-Agent': 'axios'
|
||||
}
|
||||
})
|
||||
|
||||
// Cache the assets information for the release tag
|
||||
this.assetCache[releaseTag] = releaseResponse.data.assets
|
||||
Logger.debug(`[GithubAssetDownloader] Repo ${this.repo} release ${releaseTag}: assets fetched from API.`)
|
||||
}
|
||||
|
||||
// Find the asset URL
|
||||
const assets = this.assetCache[releaseTag]
|
||||
const asset = assets.find((asset) => asset.name === assetName)
|
||||
if (!asset) {
|
||||
throw new Error(`[GithubAssetDownloader] Repo ${this.repo} release ${releaseTag}: asset ${assetName} not found`)
|
||||
}
|
||||
|
||||
return asset.browser_download_url
|
||||
}
|
||||
|
||||
async downloadAsset(assetUrl, destDir) {
|
||||
const zipPath = path.join(destDir, 'temp.zip')
|
||||
const writer = fs.createWriteStream(zipPath)
|
||||
|
||||
const assetResponse = await axios({
|
||||
url: assetUrl,
|
||||
method: 'GET',
|
||||
responseType: 'stream'
|
||||
})
|
||||
|
||||
assetResponse.data.pipe(writer)
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
writer.on('finish', () => {
|
||||
Logger.debug(`[GithubAssetDownloader] Downloaded asset ${assetUrl} to ${zipPath}`)
|
||||
resolve()
|
||||
})
|
||||
writer.on('error', (err) => {
|
||||
Logger.error(`[GithubAssetDownloader] Error downloading asset ${assetUrl}: ${err.message}`)
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
|
||||
return zipPath
|
||||
}
|
||||
|
||||
async extractFiles(zipPath, filesToExtract, destDir) {
|
||||
const zip = new StreamZip.async({ file: zipPath })
|
||||
|
||||
for (const file of filesToExtract) {
|
||||
const outputPath = path.join(destDir, file.outputFileName)
|
||||
await zip.extract(file.pathInsideZip, outputPath)
|
||||
Logger.debug(`[GithubAssetDownloader] Extracted file ${file.pathInsideZip} to ${outputPath}`)
|
||||
|
||||
// Set executable permission for Linux
|
||||
if (process.platform !== 'win32') {
|
||||
await fs.chmod(outputPath, 0o755)
|
||||
}
|
||||
}
|
||||
|
||||
await zip.close()
|
||||
}
|
||||
|
||||
async downloadAndExtractFiles(releaseTag, assetName, filesToExtract, destDir) {
|
||||
let zipPath
|
||||
try {
|
||||
await fs.ensureDir(destDir)
|
||||
const assetUrl = await this.getAssetUrl(releaseTag, assetName)
|
||||
zipPath = await this.downloadAsset(assetUrl, destDir)
|
||||
await this.extractFiles(zipPath, filesToExtract, destDir)
|
||||
} catch (error) {
|
||||
Logger.error(`[GithubAssetDownloader] Error downloading or extracting files: ${error.message}`)
|
||||
throw error
|
||||
} finally {
|
||||
if (zipPath) await fs.remove(zipPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FFBinariesDownloader extends GithubAssetDownloader {
|
||||
constructor() {
|
||||
super('ffbinaries', 'ffbinaries-prebuilt')
|
||||
}
|
||||
|
||||
getPlatformSuffix() {
|
||||
const platform = process.platform
|
||||
const arch = process.arch
|
||||
|
||||
switch (platform) {
|
||||
case 'win32':
|
||||
return 'win-64'
|
||||
case 'darwin':
|
||||
return 'macos-64'
|
||||
case 'linux':
|
||||
switch (arch) {
|
||||
case 'x64':
|
||||
return 'linux-64'
|
||||
case 'x32':
|
||||
case 'ia32':
|
||||
return 'linux-32'
|
||||
case 'arm64':
|
||||
return 'linux-arm-64'
|
||||
case 'arm':
|
||||
return 'linux-armhf-32'
|
||||
default:
|
||||
throw new Error(`Unsupported architecture: ${arch}`)
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unsupported platform: ${platform}`)
|
||||
}
|
||||
}
|
||||
|
||||
async downloadBinary(binaryName, releaseTag, destDir) {
|
||||
const platformSuffix = this.getPlatformSuffix()
|
||||
const assetName = `${binaryName}-${releaseTag}-${platformSuffix}.zip`
|
||||
const fileName = process.platform === 'win32' ? `${binaryName}.exe` : binaryName
|
||||
const filesToExtract = [{ pathInsideZip: fileName, outputFileName: fileName }]
|
||||
releaseTag = `v${releaseTag}`
|
||||
|
||||
await this.downloadAndExtractFiles(releaseTag, assetName, filesToExtract, destDir)
|
||||
}
|
||||
}
|
||||
|
||||
class SQLeanDownloader extends GithubAssetDownloader {
|
||||
constructor() {
|
||||
super('nalgeon', 'sqlean')
|
||||
}
|
||||
|
||||
getPlatformSuffix() {
|
||||
const platform = process.platform
|
||||
const arch = process.arch
|
||||
|
||||
switch (platform) {
|
||||
case 'win32':
|
||||
return arch === 'x64' ? 'win-x64' : 'win-x86'
|
||||
case 'darwin':
|
||||
return arch === 'arm64' ? 'macos-arm64' : 'macos-x86'
|
||||
case 'linux':
|
||||
return arch === 'arm64' ? 'linux-arm64' : 'linux-x86'
|
||||
default:
|
||||
throw new Error(`Unsupported platform or architecture: ${platform}, ${arch}`)
|
||||
}
|
||||
}
|
||||
|
||||
getLibraryName(binaryName) {
|
||||
const platform = process.platform
|
||||
|
||||
switch (platform) {
|
||||
case 'win32':
|
||||
return `${binaryName}.dll`
|
||||
case 'darwin':
|
||||
return `${binaryName}.dylib`
|
||||
case 'linux':
|
||||
return `${binaryName}.so`
|
||||
default:
|
||||
throw new Error(`Unsupported platform: ${platform}`)
|
||||
}
|
||||
}
|
||||
|
||||
async downloadBinary(binaryName, releaseTag, destDir) {
|
||||
const platformSuffix = this.getPlatformSuffix()
|
||||
const assetName = `sqlean-${platformSuffix}.zip`
|
||||
const fileName = this.getLibraryName(binaryName)
|
||||
const filesToExtract = [{ pathInsideZip: fileName, outputFileName: fileName }]
|
||||
|
||||
await this.downloadAndExtractFiles(releaseTag, assetName, filesToExtract, destDir)
|
||||
}
|
||||
}
|
||||
|
||||
class Binary {
|
||||
constructor(name, type, envVariable, validVersions, source) {
|
||||
this.name = name
|
||||
this.type = type
|
||||
this.envVariable = envVariable
|
||||
this.validVersions = validVersions
|
||||
this.source = source
|
||||
this.fileName = this.getFileName()
|
||||
this.exec = exec
|
||||
}
|
||||
|
||||
async find(mainInstallDir, altInstallDir) {
|
||||
// 1. check path specified in environment variable
|
||||
const defaultPath = process.env[this.envVariable]
|
||||
if (await this.isGood(defaultPath)) return defaultPath
|
||||
// 2. find the first instance of the binary in the PATH environment variable
|
||||
if (this.type === 'executable') {
|
||||
const whichPath = which.sync(this.fileName, { nothrow: true })
|
||||
if (await this.isGood(whichPath)) return whichPath
|
||||
}
|
||||
// 3. check main install path (binary root dir)
|
||||
const mainInstallPath = path.join(mainInstallDir, this.fileName)
|
||||
if (await this.isGood(mainInstallPath)) return mainInstallPath
|
||||
// 4. check alt install path (/config)
|
||||
const altInstallPath = path.join(altInstallDir, this.fileName)
|
||||
if (await this.isGood(altInstallPath)) return altInstallPath
|
||||
return null
|
||||
}
|
||||
|
||||
getFileName() {
|
||||
if (this.type === 'executable') {
|
||||
return this.name + (process.platform == 'win32' ? '.exe' : '')
|
||||
} else if (this.type === 'library') {
|
||||
return this.name + (process.platform == 'win32' ? '.dll' : '.so')
|
||||
} else {
|
||||
return this.name
|
||||
}
|
||||
}
|
||||
|
||||
async isGood(binaryPath) {
|
||||
if (!binaryPath || !(await fs.pathExists(binaryPath))) return false
|
||||
if (!this.validVersions.length) return true
|
||||
if (this.type === 'library') return true
|
||||
try {
|
||||
const { stdout } = await this.exec('"' + binaryPath + '"' + ' -version')
|
||||
const version = stdout.match(/version\s([\d\.]+)/)?.[1]
|
||||
if (!version) return false
|
||||
return this.validVersions.some((validVersion) => version.startsWith(validVersion))
|
||||
} catch (err) {
|
||||
Logger.error(`[Binary] Failed to check version of ${binaryPath}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async download(destination) {
|
||||
await this.source.downloadBinary(this.name, this.validVersions[0], destination)
|
||||
}
|
||||
}
|
||||
|
||||
const ffbinaries = new FFBinariesDownloader()
|
||||
module.exports.ffbinaries = ffbinaries // for testing
|
||||
const sqlean = new SQLeanDownloader()
|
||||
module.exports.sqlean = sqlean // for testing
|
||||
|
||||
class BinaryManager {
|
||||
|
||||
defaultRequiredBinaries = [
|
||||
{ name: 'ffmpeg', envVariable: 'FFMPEG_PATH', validVersions: ['5.1'] },
|
||||
{ name: 'ffprobe', envVariable: 'FFPROBE_PATH', validVersions: ['5.1'] }
|
||||
new Binary('ffmpeg', 'executable', 'FFMPEG_PATH', ['5.1'], ffbinaries), // ffmpeg executable
|
||||
new Binary('ffprobe', 'executable', 'FFPROBE_PATH', ['5.1'], ffbinaries), // ffprobe executable
|
||||
new Binary('unicode', 'library', 'SQLEAN_UNICODE_PATH', ['0.24.2'], sqlean) // sqlean unicode extension
|
||||
]
|
||||
|
||||
constructor(requiredBinaries = this.defaultRequiredBinaries) {
|
||||
this.requiredBinaries = requiredBinaries
|
||||
this.mainInstallPath = process.pkg ? path.dirname(process.execPath) : global.appRoot
|
||||
this.altInstallPath = global.ConfigPath
|
||||
this.mainInstallDir = process.pkg ? path.dirname(process.execPath) : global.appRoot
|
||||
this.altInstallDir = global.ConfigPath
|
||||
this.initialized = false
|
||||
this.exec = exec
|
||||
}
|
||||
|
||||
async init() {
|
||||
|
|
@ -44,24 +293,18 @@ class BinaryManager {
|
|||
this.initialized = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove old/invalid binaries in main or alt install path
|
||||
*
|
||||
* @param {string[]} binaryNames
|
||||
*/
|
||||
async removeOldBinaries(binaryNames) {
|
||||
for (const binaryName of binaryNames) {
|
||||
const executable = this.getExecutableFileName(binaryName)
|
||||
const mainInstallPath = path.join(this.mainInstallPath, executable)
|
||||
if (await fs.pathExists(mainInstallPath)) {
|
||||
Logger.debug(`[BinaryManager] Removing old binary: ${mainInstallPath}`)
|
||||
await fs.remove(mainInstallPath)
|
||||
async removeBinary(destination, binary) {
|
||||
const binaryPath = path.join(destination, binary.fileName)
|
||||
if (await fs.pathExists(binaryPath)) {
|
||||
Logger.debug(`[BinaryManager] Removing binary: ${binaryPath}`)
|
||||
await fs.remove(binaryPath)
|
||||
}
|
||||
const altInstallPath = path.join(this.altInstallPath, executable)
|
||||
if (await fs.pathExists(altInstallPath)) {
|
||||
Logger.debug(`[BinaryManager] Removing old binary: ${altInstallPath}`)
|
||||
await fs.remove(altInstallPath)
|
||||
}
|
||||
|
||||
async removeOldBinaries(binaries) {
|
||||
for (const binary of binaries) {
|
||||
await this.removeBinary(this.mainInstallDir, binary)
|
||||
await this.removeBinary(this.altInstallDir, binary)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -73,7 +316,7 @@ class BinaryManager {
|
|||
async findRequiredBinaries() {
|
||||
const missingBinaries = []
|
||||
for (const binary of this.requiredBinaries) {
|
||||
const binaryPath = await this.findBinary(binary.name, binary.envVariable, binary.validVersions)
|
||||
const binaryPath = await binary.find(this.mainInstallDir, this.altInstallDir)
|
||||
if (binaryPath) {
|
||||
Logger.info(`[BinaryManager] Found valid binary ${binary.name} at ${binaryPath}`)
|
||||
if (process.env[binary.envVariable] !== binaryPath) {
|
||||
|
|
@ -82,79 +325,22 @@ class BinaryManager {
|
|||
}
|
||||
} else {
|
||||
Logger.info(`[BinaryManager] ${binary.name} not found or version too old`)
|
||||
missingBinaries.push(binary.name)
|
||||
missingBinaries.push(binary)
|
||||
}
|
||||
}
|
||||
return missingBinaries
|
||||
}
|
||||
|
||||
/**
|
||||
* Find absolute path for binary
|
||||
*
|
||||
* @param {string} name
|
||||
* @param {string} envVariable
|
||||
* @param {string[]} [validVersions]
|
||||
* @returns {Promise<string>} Path to binary
|
||||
*/
|
||||
async findBinary(name, envVariable, validVersions = []) {
|
||||
const executable = this.getExecutableFileName(name)
|
||||
// 1. check path specified in environment variable
|
||||
const defaultPath = process.env[envVariable]
|
||||
if (await this.isBinaryGood(defaultPath, validVersions)) return defaultPath
|
||||
// 2. find the first instance of the binary in the PATH environment variable
|
||||
const whichPath = which.sync(executable, { nothrow: true })
|
||||
if (await this.isBinaryGood(whichPath, validVersions)) return whichPath
|
||||
// 3. check main install path (binary root dir)
|
||||
const mainInstallPath = path.join(this.mainInstallPath, executable)
|
||||
if (await this.isBinaryGood(mainInstallPath, validVersions)) return mainInstallPath
|
||||
// 4. check alt install path (/config)
|
||||
const altInstallPath = path.join(this.altInstallPath, executable)
|
||||
if (await this.isBinaryGood(altInstallPath, validVersions)) return altInstallPath
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check binary path exists and optionally check version is valid
|
||||
*
|
||||
* @param {string} binaryPath
|
||||
* @param {string[]} [validVersions]
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async isBinaryGood(binaryPath, validVersions = []) {
|
||||
if (!binaryPath || !await fs.pathExists(binaryPath)) return false
|
||||
if (!validVersions.length) return true
|
||||
try {
|
||||
const { stdout } = await this.exec('"' + binaryPath + '"' + ' -version')
|
||||
const version = stdout.match(/version\s([\d\.]+)/)?.[1]
|
||||
if (!version) return false
|
||||
return validVersions.some(validVersion => version.startsWith(validVersion))
|
||||
} catch (err) {
|
||||
Logger.error(`[BinaryManager] Failed to check version of ${binaryPath}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string[]} binaries
|
||||
*/
|
||||
async install(binaries) {
|
||||
if (!binaries.length) return
|
||||
Logger.info(`[BinaryManager] Installing binaries: ${binaries.join(', ')}`)
|
||||
let destination = await fileUtils.isWritable(this.mainInstallPath) ? this.mainInstallPath : this.altInstallPath
|
||||
await ffbinaries.downloadBinaries(binaries, { destination, version: '5.1', force: true })
|
||||
Logger.info(`[BinaryManager] Binaries installed to ${destination}`)
|
||||
Logger.info(`[BinaryManager] Installing binaries: ${binaries.map((binary) => binary.name).join(', ')}`)
|
||||
let destination = (await fileUtils.isWritable(this.mainInstallDir)) ? this.mainInstallDir : this.altInstallDir
|
||||
for (const binary of binaries) {
|
||||
await binary.download(destination)
|
||||
}
|
||||
|
||||
/**
|
||||
* Append .exe to binary name for Windows
|
||||
*
|
||||
* @param {string} name
|
||||
* @returns {string}
|
||||
*/
|
||||
getExecutableFileName(name) {
|
||||
return name + (process.platform == 'win32' ? '.exe' : '')
|
||||
Logger.info(`[BinaryManager] Binaries installed to ${destination}`)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BinaryManager
|
||||
module.exports.Binary = Binary // for testing
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ const Database = require('../Database')
|
|||
const fs = require('../libs/fsExtra')
|
||||
|
||||
const { getPodcastFeed } = require('../utils/podcastUtils')
|
||||
const { removeFile, downloadFile } = require('../utils/fileUtils')
|
||||
const { removeFile, downloadFile, sanitizeFilename, filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils')
|
||||
const { levenshteinDistance } = require('../utils/index')
|
||||
const opmlParser = require('../utils/parsers/parseOPML')
|
||||
const opmlGenerator = require('../utils/generators/opmlGenerator')
|
||||
|
|
@ -13,11 +13,13 @@ const prober = require('../utils/prober')
|
|||
const ffmpegHelpers = require('../utils/ffmpegHelpers')
|
||||
|
||||
const TaskManager = require('./TaskManager')
|
||||
const CoverManager = require('../managers/CoverManager')
|
||||
|
||||
const LibraryFile = require('../objects/files/LibraryFile')
|
||||
const PodcastEpisodeDownload = require('../objects/PodcastEpisodeDownload')
|
||||
const PodcastEpisode = require('../objects/entities/PodcastEpisode')
|
||||
const AudioFile = require('../objects/files/AudioFile')
|
||||
const LibraryItem = require('../objects/LibraryItem')
|
||||
|
||||
class PodcastManager {
|
||||
constructor(watcher, notificationManager) {
|
||||
|
|
@ -350,19 +352,23 @@ class PodcastManager {
|
|||
return matches.sort((a, b) => a.levenshtein - b.levenshtein)
|
||||
}
|
||||
|
||||
getParsedOPMLFileFeeds(opmlText) {
|
||||
return opmlParser.parse(opmlText)
|
||||
}
|
||||
|
||||
async getOPMLFeeds(opmlText) {
|
||||
var extractedFeeds = opmlParser.parse(opmlText)
|
||||
if (!extractedFeeds || !extractedFeeds.length) {
|
||||
const extractedFeeds = opmlParser.parse(opmlText)
|
||||
if (!extractedFeeds?.length) {
|
||||
Logger.error('[PodcastManager] getOPMLFeeds: No RSS feeds found in OPML')
|
||||
return {
|
||||
error: 'No RSS feeds found in OPML'
|
||||
}
|
||||
}
|
||||
|
||||
var rssFeedData = []
|
||||
const rssFeedData = []
|
||||
|
||||
for (let feed of extractedFeeds) {
|
||||
var feedData = await getPodcastFeed(feed.feedUrl, true)
|
||||
const feedData = await getPodcastFeed(feed.feedUrl, true)
|
||||
if (feedData) {
|
||||
feedData.metadata.feedUrl = feed.feedUrl
|
||||
rssFeedData.push(feedData)
|
||||
|
|
@ -392,5 +398,115 @@ class PodcastManager {
|
|||
queue: this.downloadQueue.filter((item) => !libraryId || item.libraryId === libraryId).map((item) => item.toJSONForClient())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string[]} rssFeedUrls
|
||||
* @param {import('../models/LibraryFolder')} folder
|
||||
* @param {boolean} autoDownloadEpisodes
|
||||
* @param {import('../managers/CronManager')} cronManager
|
||||
*/
|
||||
async createPodcastsFromFeedUrls(rssFeedUrls, folder, autoDownloadEpisodes, cronManager) {
|
||||
const task = TaskManager.createAndAddTask('opml-import', 'OPML import', `Creating podcasts from ${rssFeedUrls.length} RSS feeds`, true, null)
|
||||
let numPodcastsAdded = 0
|
||||
Logger.info(`[PodcastManager] createPodcastsFromFeedUrls: Importing ${rssFeedUrls.length} RSS feeds to folder "${folder.path}"`)
|
||||
for (const feedUrl of rssFeedUrls) {
|
||||
const feed = await getPodcastFeed(feedUrl).catch(() => null)
|
||||
if (!feed?.episodes) {
|
||||
TaskManager.createAndEmitFailedTask('opml-import-feed', 'OPML import feed', `Importing RSS feed "${feedUrl}"`, 'Failed to get podcast feed')
|
||||
Logger.error(`[PodcastManager] createPodcastsFromFeedUrls: Failed to get podcast feed for "${feedUrl}"`)
|
||||
continue
|
||||
}
|
||||
|
||||
const podcastFilename = sanitizeFilename(feed.metadata.title)
|
||||
const podcastPath = filePathToPOSIX(`${folder.path}/${podcastFilename}`)
|
||||
// Check if a library item with this podcast folder exists already
|
||||
const existingLibraryItem =
|
||||
(await Database.libraryItemModel.count({
|
||||
where: {
|
||||
path: podcastPath
|
||||
}
|
||||
})) > 0
|
||||
if (existingLibraryItem) {
|
||||
Logger.error(`[PodcastManager] createPodcastsFromFeedUrls: Podcast already exists at path "${podcastPath}"`)
|
||||
TaskManager.createAndEmitFailedTask('opml-import-feed', 'OPML import feed', `Creating podcast "${feed.metadata.title}"`, 'Podcast already exists at path')
|
||||
continue
|
||||
}
|
||||
|
||||
const successCreatingPath = await fs
|
||||
.ensureDir(podcastPath)
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
Logger.error(`[PodcastManager] Failed to ensure podcast dir "${podcastPath}"`, error)
|
||||
return false
|
||||
})
|
||||
if (!successCreatingPath) {
|
||||
Logger.error(`[PodcastManager] createPodcastsFromFeedUrls: Failed to create podcast folder at "${podcastPath}"`)
|
||||
TaskManager.createAndEmitFailedTask('opml-import-feed', 'OPML import feed', `Creating podcast "${feed.metadata.title}"`, 'Failed to create podcast folder')
|
||||
continue
|
||||
}
|
||||
|
||||
const newPodcastMetadata = {
|
||||
title: feed.metadata.title,
|
||||
author: feed.metadata.author,
|
||||
description: feed.metadata.description,
|
||||
releaseDate: '',
|
||||
genres: [...feed.metadata.categories],
|
||||
feedUrl: feed.metadata.feedUrl,
|
||||
imageUrl: feed.metadata.image,
|
||||
itunesPageUrl: '',
|
||||
itunesId: '',
|
||||
itunesArtistId: '',
|
||||
language: '',
|
||||
numEpisodes: feed.numEpisodes
|
||||
}
|
||||
|
||||
const libraryItemFolderStats = await getFileTimestampsWithIno(podcastPath)
|
||||
const libraryItemPayload = {
|
||||
path: podcastPath,
|
||||
relPath: podcastFilename,
|
||||
folderId: folder.id,
|
||||
libraryId: folder.libraryId,
|
||||
ino: libraryItemFolderStats.ino,
|
||||
mtimeMs: libraryItemFolderStats.mtimeMs || 0,
|
||||
ctimeMs: libraryItemFolderStats.ctimeMs || 0,
|
||||
birthtimeMs: libraryItemFolderStats.birthtimeMs || 0,
|
||||
media: {
|
||||
metadata: newPodcastMetadata,
|
||||
autoDownloadEpisodes
|
||||
}
|
||||
}
|
||||
|
||||
const libraryItem = new LibraryItem()
|
||||
libraryItem.setData('podcast', libraryItemPayload)
|
||||
|
||||
// Download and save cover image
|
||||
if (newPodcastMetadata.imageUrl) {
|
||||
// TODO: Scan cover image to library files
|
||||
// Podcast cover will always go into library item folder
|
||||
const coverResponse = await CoverManager.downloadCoverFromUrl(libraryItem, newPodcastMetadata.imageUrl, true)
|
||||
if (coverResponse) {
|
||||
if (coverResponse.error) {
|
||||
Logger.error(`[PodcastManager] createPodcastsFromFeedUrls: Download cover error from "${newPodcastMetadata.imageUrl}": ${coverResponse.error}`)
|
||||
} else if (coverResponse.cover) {
|
||||
libraryItem.media.coverPath = coverResponse.cover
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Database.createLibraryItem(libraryItem)
|
||||
SocketAuthority.emitter('item_added', libraryItem.toJSONExpanded())
|
||||
|
||||
// Turn on podcast auto download cron if not already on
|
||||
if (libraryItem.media.autoDownloadEpisodes) {
|
||||
cronManager.checkUpdatePodcastCron(libraryItem)
|
||||
}
|
||||
|
||||
numPodcastsAdded++
|
||||
}
|
||||
task.setFinished(`Added ${numPodcastsAdded} podcasts`)
|
||||
TaskManager.taskFinished(task)
|
||||
Logger.info(`[PodcastManager] createPodcastsFromFeedUrls: Finished OPML import. Created ${numPodcastsAdded} podcasts out of ${rssFeedUrls.length} RSS feed URLs`)
|
||||
}
|
||||
}
|
||||
module.exports = PodcastManager
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
const Database = require('../Database')
|
||||
const Logger = require('../Logger')
|
||||
const SocketAuthority = require('../SocketAuthority')
|
||||
const LongTimeout = require('../utils/longTimeout')
|
||||
const { elapsedPretty } = require('../utils/index')
|
||||
|
||||
/**
|
||||
* @typedef OpenMediaItemShareObject
|
||||
* @property {string} id
|
||||
* @property {import('../models/MediaItemShare').MediaItemShareObject} mediaItemShare
|
||||
* @property {NodeJS.Timeout} timeout
|
||||
* @property {LongTimeout} timeout
|
||||
*/
|
||||
|
||||
class ShareManager {
|
||||
|
|
@ -118,13 +120,13 @@ class ShareManager {
|
|||
this.destroyMediaItemShare(mediaItemShare.id)
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
const timeout = new LongTimeout()
|
||||
timeout.set(() => {
|
||||
Logger.info(`[ShareManager] Removing expired media item share "${mediaItemShare.id}"`)
|
||||
this.removeMediaItemShare(mediaItemShare.id)
|
||||
}, expiresAtDuration)
|
||||
this.openMediaItemShares.push({ id: mediaItemShare.id, mediaItemShare: mediaItemShare.toJSON(), timeout })
|
||||
Logger.info(`[ShareManager] Scheduled media item share "${mediaItemShare.id}" to expire in ${expiresAtDuration}ms`)
|
||||
Logger.info(`[ShareManager] Scheduled media item share "${mediaItemShare.id}" to expire in ${elapsedPretty(expiresAtDuration / 1000)}`)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -149,7 +151,7 @@ class ShareManager {
|
|||
if (!mediaItemShare) return
|
||||
|
||||
if (mediaItemShare.timeout) {
|
||||
clearTimeout(mediaItemShare.timeout)
|
||||
mediaItemShare.timeout.clear()
|
||||
}
|
||||
|
||||
this.openMediaItemShares = this.openMediaItemShares.filter((s) => s.id !== mediaItemShareId)
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@ class TaskManager {
|
|||
* @param {Task} task
|
||||
*/
|
||||
taskFinished(task) {
|
||||
if (this.tasks.some(t => t.id === task.id)) {
|
||||
this.tasks = this.tasks.filter(t => t.id !== task.id)
|
||||
if (this.tasks.some((t) => t.id === task.id)) {
|
||||
this.tasks = this.tasks.filter((t) => t.id !== task.id)
|
||||
SocketAuthority.emitter('task_finished', task.toJSON())
|
||||
}
|
||||
}
|
||||
|
|
@ -44,5 +44,21 @@ class TaskManager {
|
|||
this.addTask(task)
|
||||
return task
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new failed task and add
|
||||
*
|
||||
* @param {string} action
|
||||
* @param {string} title
|
||||
* @param {string} description
|
||||
* @param {string} errorMessage
|
||||
*/
|
||||
createAndEmitFailedTask(action, title, description, errorMessage) {
|
||||
const task = new Task()
|
||||
task.setData(action, title, description, false)
|
||||
task.setFailed(errorMessage)
|
||||
SocketAuthority.emitter('task_started', task.toJSON())
|
||||
return task
|
||||
}
|
||||
}
|
||||
module.exports = new TaskManager()
|
||||
|
|
@ -60,7 +60,7 @@ class Library extends Model {
|
|||
/**
|
||||
* Convert expanded Library to oldLibrary
|
||||
* @param {Library} libraryExpanded
|
||||
* @returns {Promise<oldLibrary>}
|
||||
* @returns {oldLibrary}
|
||||
*/
|
||||
static getOldLibrary(libraryExpanded) {
|
||||
const folders = libraryExpanded.libraryFolders.map((folder) => {
|
||||
|
|
|
|||
|
|
@ -217,11 +217,11 @@ class Feed {
|
|||
this.entityType = 'collection'
|
||||
this.entityId = collectionExpanded.id
|
||||
this.entityUpdatedAt = collectionExpanded.lastUpdate // This will be set to the most recently updated library item
|
||||
this.coverPath = firstItemWithCover?.coverPath || null
|
||||
this.coverPath = firstItemWithCover?.media.coverPath || null
|
||||
this.serverAddress = serverAddress
|
||||
this.feedUrl = feedUrl
|
||||
|
||||
const coverFileExtension = this.coverPath ? Path.extname(media.coverPath) : null
|
||||
const coverFileExtension = this.coverPath ? Path.extname(this.coverPath) : null
|
||||
|
||||
this.meta = new FeedMeta()
|
||||
this.meta.title = collectionExpanded.name
|
||||
|
|
@ -265,9 +265,9 @@ class Feed {
|
|||
const firstItemWithCover = itemsWithTracks.find((item) => item.media.coverPath)
|
||||
|
||||
this.entityUpdatedAt = collectionExpanded.lastUpdate
|
||||
this.coverPath = firstItemWithCover?.coverPath || null
|
||||
this.coverPath = firstItemWithCover?.media.coverPath || null
|
||||
|
||||
const coverFileExtension = this.coverPath ? Path.extname(media.coverPath) : null
|
||||
const coverFileExtension = this.coverPath ? Path.extname(this.coverPath) : null
|
||||
|
||||
this.meta.title = collectionExpanded.name
|
||||
this.meta.description = collectionExpanded.description || ''
|
||||
|
|
@ -316,11 +316,11 @@ class Feed {
|
|||
this.entityType = 'series'
|
||||
this.entityId = seriesExpanded.id
|
||||
this.entityUpdatedAt = seriesExpanded.updatedAt // This will be set to the most recently updated library item
|
||||
this.coverPath = firstItemWithCover?.coverPath || null
|
||||
this.coverPath = firstItemWithCover?.media.coverPath || null
|
||||
this.serverAddress = serverAddress
|
||||
this.feedUrl = feedUrl
|
||||
|
||||
const coverFileExtension = this.coverPath ? Path.extname(media.coverPath) : null
|
||||
const coverFileExtension = this.coverPath ? Path.extname(this.coverPath) : null
|
||||
|
||||
this.meta = new FeedMeta()
|
||||
this.meta.title = seriesExpanded.name
|
||||
|
|
@ -367,9 +367,9 @@ class Feed {
|
|||
const firstItemWithCover = itemsWithTracks.find((item) => item.media.coverPath)
|
||||
|
||||
this.entityUpdatedAt = seriesExpanded.updatedAt
|
||||
this.coverPath = firstItemWithCover?.coverPath || null
|
||||
this.coverPath = firstItemWithCover?.media.coverPath || null
|
||||
|
||||
const coverFileExtension = this.coverPath ? Path.extname(media.coverPath) : null
|
||||
const coverFileExtension = this.coverPath ? Path.extname(this.coverPath) : null
|
||||
|
||||
this.meta.title = seriesExpanded.name
|
||||
this.meta.description = seriesExpanded.description || ''
|
||||
|
|
|
|||
88
server/objects/TrackProgressMonitor.js
Normal file
88
server/objects/TrackProgressMonitor.js
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
class TrackProgressMonitor {
|
||||
/**
|
||||
* @callback TrackStartedCallback
|
||||
* @param {number} trackIndex - The index of the track that started.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @callback ProgressCallback
|
||||
* @param {number} trackIndex - The index of the current track.
|
||||
* @param {number} progressInTrack - The current track progress in percent.
|
||||
* @param {number} totalProgress - The total progress in percent.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @callback TrackFinishedCallback
|
||||
* @param {number} trackIndex - The index of the track that finished.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Creates a new TrackProgressMonitor.
|
||||
* @constructor
|
||||
* @param {number[]} trackDurations - The durations of the tracks in seconds.
|
||||
* @param {TrackStartedCallback} trackStartedCallback - The callback to call when a track starts.
|
||||
* @param {ProgressCallback} progressCallback - The callback to call when progress is updated.
|
||||
* @param {TrackFinishedCallback} trackFinishedCallback - The callback to call when a track finishes.
|
||||
*/
|
||||
constructor(trackDurations, trackStartedCallback, progressCallback, trackFinishedCallback) {
|
||||
this.trackDurations = trackDurations
|
||||
this.totalDuration = trackDurations.reduce((total, duration) => total + duration, 0)
|
||||
this.trackStartedCallback = trackStartedCallback
|
||||
this.progressCallback = progressCallback
|
||||
this.trackFinishedCallback = trackFinishedCallback
|
||||
this.currentTrackIndex = -1
|
||||
this.cummulativeProgress = 0
|
||||
this.currentTrackPercentage = 0
|
||||
this.numTracks = this.trackDurations.length
|
||||
this.allTracksFinished = false
|
||||
this.#moveToNextTrack()
|
||||
}
|
||||
|
||||
#outsideCurrentTrack(progress) {
|
||||
this.currentTrackProgress = progress - this.cummulativeProgress
|
||||
return this.currentTrackProgress >= this.currentTrackPercentage
|
||||
}
|
||||
|
||||
#moveToNextTrack() {
|
||||
if (this.currentTrackIndex >= 0) this.#trackFinished()
|
||||
this.currentTrackIndex++
|
||||
this.cummulativeProgress += this.currentTrackPercentage
|
||||
if (this.currentTrackIndex >= this.numTracks) {
|
||||
this.allTracksFinished = true
|
||||
return
|
||||
}
|
||||
this.currentTrackPercentage = (this.trackDurations[this.currentTrackIndex] / this.totalDuration) * 100
|
||||
this.#trackStarted()
|
||||
}
|
||||
|
||||
#trackStarted() {
|
||||
this.trackStartedCallback(this.currentTrackIndex)
|
||||
}
|
||||
|
||||
#progressUpdated(totalProgress) {
|
||||
const progressInTrack = (this.currentTrackProgress / this.currentTrackPercentage) * 100
|
||||
this.progressCallback(this.currentTrackIndex, progressInTrack, totalProgress)
|
||||
}
|
||||
|
||||
#trackFinished() {
|
||||
this.trackFinishedCallback(this.currentTrackIndex)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the track progress based on the total progress.
|
||||
* @param {number} totalProgress - The total progress in percent.
|
||||
*/
|
||||
update(totalProgress) {
|
||||
while (this.#outsideCurrentTrack(totalProgress) && !this.allTracksFinished) this.#moveToNextTrack()
|
||||
if (!this.allTracksFinished) this.#progressUpdated(totalProgress)
|
||||
}
|
||||
|
||||
/**
|
||||
* Finish the track progress monitoring.
|
||||
* Forces all remaining tracks to finish.
|
||||
*/
|
||||
finish() {
|
||||
this.update(101)
|
||||
}
|
||||
}
|
||||
module.exports = TrackProgressMonitor
|
||||
|
|
@ -102,7 +102,7 @@ class ServerSettings {
|
|||
this.backupPath = settings.backupPath || Path.join(global.MetadataPath, 'backups')
|
||||
this.backupSchedule = settings.backupSchedule || false
|
||||
this.backupsToKeep = settings.backupsToKeep || 2
|
||||
this.maxBackupSize = settings.maxBackupSize || 1
|
||||
this.maxBackupSize = settings.maxBackupSize === 0 ? 0 : settings.maxBackupSize || 1
|
||||
|
||||
this.loggerDailyLogsToKeep = settings.loggerDailyLogsToKeep || 7
|
||||
this.loggerScannerLogsToKeep = settings.loggerScannerLogsToKeep || 2
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ class ApiRouter {
|
|||
this.backupManager = Server.backupManager
|
||||
/** @type {import('../Watcher')} */
|
||||
this.watcher = Server.watcher
|
||||
/** @type {import('../managers/PodcastManager')} */
|
||||
this.podcastManager = Server.podcastManager
|
||||
this.audioMetadataManager = Server.audioMetadataManager
|
||||
this.rssFeedManager = Server.rssFeedManager
|
||||
|
|
@ -239,7 +240,8 @@ class ApiRouter {
|
|||
//
|
||||
this.router.post('/podcasts', PodcastController.create.bind(this))
|
||||
this.router.post('/podcasts/feed', PodcastController.getPodcastFeed.bind(this))
|
||||
this.router.post('/podcasts/opml', PodcastController.getFeedsFromOPMLText.bind(this))
|
||||
this.router.post('/podcasts/opml/parse', PodcastController.getFeedsFromOPMLText.bind(this))
|
||||
this.router.post('/podcasts/opml/create', PodcastController.bulkCreatePodcastsFromOpmlFeedUrls.bind(this))
|
||||
this.router.get('/podcasts/:id/checknew', PodcastController.middleware.bind(this), PodcastController.checkNewEpisodes.bind(this))
|
||||
this.router.get('/podcasts/:id/downloads', PodcastController.middleware.bind(this), PodcastController.getEpisodeDownloads.bind(this))
|
||||
this.router.get('/podcasts/:id/clear-queue', PodcastController.middleware.bind(this), PodcastController.clearEpisodeDownloadQueue.bind(this))
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ const { parseNfoMetadata } = require('../utils/parsers/parseNfoMetadata')
|
|||
const { readTextFile } = require('../utils/fileUtils')
|
||||
|
||||
class NfoFileScanner {
|
||||
constructor() { }
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* Parse metadata from .nfo file found in library scan and update bookMetadata
|
||||
|
|
@ -15,11 +15,13 @@ class NfoFileScanner {
|
|||
const nfoMetadata = nfoText ? await parseNfoMetadata(nfoText) : null
|
||||
if (nfoMetadata) {
|
||||
for (const key in nfoMetadata) {
|
||||
if (key === 'tags') { // Add tags only if tags are empty
|
||||
if (key === 'tags') {
|
||||
// Add tags only if tags are empty
|
||||
if (nfoMetadata.tags.length) {
|
||||
bookMetadata.tags = nfoMetadata.tags
|
||||
}
|
||||
} else if (key === 'genres') { // Add genres only if genres are empty
|
||||
} else if (key === 'genres') {
|
||||
// Add genres only if genres are empty
|
||||
if (nfoMetadata.genres.length) {
|
||||
bookMetadata.genres = nfoMetadata.genres
|
||||
}
|
||||
|
|
@ -33,10 +35,12 @@ class NfoFileScanner {
|
|||
}
|
||||
} else if (key === 'series') {
|
||||
if (nfoMetadata.series) {
|
||||
bookMetadata.series = [{
|
||||
bookMetadata.series = [
|
||||
{
|
||||
name: nfoMetadata.series,
|
||||
sequence: nfoMetadata.sequence || null
|
||||
}]
|
||||
}
|
||||
]
|
||||
}
|
||||
} else if (nfoMetadata[key] && key !== 'sequence') {
|
||||
bookMetadata[key] = nfoMetadata[key]
|
||||
|
|
|
|||
|
|
@ -1,92 +0,0 @@
|
|||
const Ffmpeg = require('../libs/fluentFfmpeg')
|
||||
|
||||
if (process.env.FFMPEG_PATH) {
|
||||
Ffmpeg.setFfmpegPath(process.env.FFMPEG_PATH)
|
||||
}
|
||||
|
||||
const { parentPort, workerData } = require("worker_threads")
|
||||
|
||||
parentPort.postMessage({
|
||||
type: 'FFMPEG',
|
||||
level: 'debug',
|
||||
log: '[DownloadWorker] Starting Worker...'
|
||||
})
|
||||
|
||||
const ffmpegCommand = Ffmpeg()
|
||||
const startTime = Date.now()
|
||||
|
||||
workerData.inputs.forEach((inputData) => {
|
||||
ffmpegCommand.input(inputData.input)
|
||||
if (inputData.options) ffmpegCommand.inputOption(inputData.options)
|
||||
})
|
||||
|
||||
if (workerData.options) ffmpegCommand.addOption(workerData.options)
|
||||
if (workerData.outputOptions && workerData.outputOptions.length) ffmpegCommand.addOutputOption(workerData.outputOptions)
|
||||
ffmpegCommand.output(workerData.output)
|
||||
|
||||
var isKilled = false
|
||||
|
||||
async function runFfmpeg() {
|
||||
var success = await new Promise((resolve) => {
|
||||
ffmpegCommand.on('start', (command) => {
|
||||
parentPort.postMessage({
|
||||
type: 'FFMPEG',
|
||||
level: 'info',
|
||||
log: '[DownloadWorker] FFMPEG concat started with command: ' + command
|
||||
})
|
||||
})
|
||||
|
||||
ffmpegCommand.on('stderr', (stdErrline) => {
|
||||
parentPort.postMessage({
|
||||
type: 'FFMPEG',
|
||||
level: 'debug',
|
||||
log: '[DownloadWorker] Ffmpeg Stderr: ' + stdErrline
|
||||
})
|
||||
})
|
||||
|
||||
ffmpegCommand.on('error', (err, stdout, stderr) => {
|
||||
if (err.message && err.message.includes('SIGKILL')) {
|
||||
// This is an intentional SIGKILL
|
||||
parentPort.postMessage({
|
||||
type: 'FFMPEG',
|
||||
level: 'info',
|
||||
log: '[DownloadWorker] User Killed worker'
|
||||
})
|
||||
} else {
|
||||
parentPort.postMessage({
|
||||
type: 'FFMPEG',
|
||||
level: 'error',
|
||||
log: '[DownloadWorker] Ffmpeg Err: ' + err.message
|
||||
})
|
||||
}
|
||||
resolve(false)
|
||||
})
|
||||
|
||||
ffmpegCommand.on('end', (stdout, stderr) => {
|
||||
parentPort.postMessage({
|
||||
type: 'FFMPEG',
|
||||
level: 'info',
|
||||
log: '[DownloadWorker] worker ended'
|
||||
})
|
||||
resolve(true)
|
||||
})
|
||||
ffmpegCommand.run()
|
||||
})
|
||||
|
||||
var resultMessage = {
|
||||
type: 'RESULT',
|
||||
isKilled,
|
||||
elapsed: Date.now() - startTime,
|
||||
success
|
||||
}
|
||||
parentPort.postMessage(resultMessage)
|
||||
}
|
||||
|
||||
parentPort.on('message', (message) => {
|
||||
if (message === 'STOP') {
|
||||
isKilled = true
|
||||
ffmpegCommand.kill()
|
||||
}
|
||||
})
|
||||
|
||||
runFfmpeg()
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
const axios = require('axios')
|
||||
const Ffmpeg = require('../libs/fluentFfmpeg')
|
||||
const ffmpgegUtils = require('../libs/fluentFfmpeg/utils')
|
||||
const fs = require('../libs/fsExtra')
|
||||
const os = require('os')
|
||||
const Path = require('path')
|
||||
const Logger = require('../Logger')
|
||||
const { filePathToPOSIX } = require('./fileUtils')
|
||||
|
|
@ -251,9 +251,10 @@ module.exports.writeFFMetadataFile = writeFFMetadataFile
|
|||
* @param {number} track - The track number to embed in the audio file.
|
||||
* @param {string} mimeType - The MIME type of the audio file.
|
||||
* @param {Ffmpeg} ffmpeg - The Ffmpeg instance to use (optional). Used for dependency injection in tests.
|
||||
* @returns {Promise<boolean>} A promise that resolves to true if the operation is successful, false otherwise.
|
||||
* @param {function(number): void|null} progressCB - A callback function to report progress.
|
||||
* @returns {Promise<void>} A promise that resolves if the operation is successful, rejects otherwise.
|
||||
*/
|
||||
async function addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, ffmpeg = Ffmpeg()) {
|
||||
async function addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, progressCB = null, ffmpeg = Ffmpeg()) {
|
||||
const isMp4 = mimeType === 'audio/mp4'
|
||||
const isMp3 = mimeType === 'audio/mpeg'
|
||||
|
||||
|
|
@ -262,7 +263,7 @@ async function addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataF
|
|||
const audioFileBaseName = Path.basename(audioFilePath, audioFileExt)
|
||||
const tempFilePath = filePathToPOSIX(Path.join(audioFileDir, `${audioFileBaseName}.tmp${audioFileExt}`))
|
||||
|
||||
return new Promise((resolve) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
ffmpeg.input(audioFilePath).input(metadataFilePath).outputOptions([
|
||||
'-map 0:a', // map audio stream from input file
|
||||
'-map_metadata 1', // map metadata tags from metadata file first
|
||||
|
|
@ -302,21 +303,36 @@ async function addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataF
|
|||
|
||||
ffmpeg
|
||||
.output(tempFilePath)
|
||||
.on('start', function (commandLine) {
|
||||
.on('start', (commandLine) => {
|
||||
Logger.debug('[ffmpegHelpers] Spawned Ffmpeg with command: ' + commandLine)
|
||||
})
|
||||
.on('end', (stdout, stderr) => {
|
||||
.on('progress', (progress) => {
|
||||
if (!progressCB || !progress.percent) return
|
||||
Logger.debug(`[ffmpegHelpers] Progress: ${progress.percent}%`)
|
||||
progressCB(progress.percent)
|
||||
})
|
||||
.on('end', async (stdout, stderr) => {
|
||||
Logger.debug('[ffmpegHelpers] ffmpeg stdout:', stdout)
|
||||
Logger.debug('[ffmpegHelpers] ffmpeg stderr:', stderr)
|
||||
fs.copyFileSync(tempFilePath, audioFilePath)
|
||||
fs.unlinkSync(tempFilePath)
|
||||
resolve(true)
|
||||
Logger.debug('[ffmpegHelpers] Moving temp file to audio file path:', `"${tempFilePath}"`, '->', `"${audioFilePath}"`)
|
||||
try {
|
||||
await fs.move(tempFilePath, audioFilePath, { overwrite: true })
|
||||
resolve()
|
||||
} catch (error) {
|
||||
Logger.error(`[ffmpegHelpers] Failed to move temp file to audio file path: "${tempFilePath}" -> "${audioFilePath}"`, error)
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
.on('error', (err, stdout, stderr) => {
|
||||
if (err.message && err.message.includes('SIGKILL')) {
|
||||
Logger.info(`[ffmpegHelpers] addCoverAndMetadataToFile Killed by User`)
|
||||
reject(new Error('FFMPEG_CANCELED'))
|
||||
} else {
|
||||
Logger.error('Error adding cover image and metadata:', err)
|
||||
Logger.error('ffmpeg stdout:', stdout)
|
||||
Logger.error('ffmpeg stderr:', stderr)
|
||||
resolve(false)
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
|
||||
ffmpeg.run()
|
||||
|
|
@ -366,3 +382,92 @@ function getFFMetadataObject(libraryItem, audioFilesLength) {
|
|||
}
|
||||
|
||||
module.exports.getFFMetadataObject = getFFMetadataObject
|
||||
|
||||
/**
|
||||
* Merges audio files into a single output file using FFmpeg.
|
||||
*
|
||||
* @param {Array} audioTracks - The audio tracks to merge.
|
||||
* @param {number} duration - The total duration of the audio tracks.
|
||||
* @param {string} itemCachePath - The path to the item cache.
|
||||
* @param {string} outputFilePath - The path to the output file.
|
||||
* @param {Object} encodingOptions - The options for encoding the audio.
|
||||
* @param {Function} [progressCB=null] - The callback function to track the progress of the merge.
|
||||
* @param {Object} [ffmpeg=Ffmpeg()] - The FFmpeg instance to use for merging.
|
||||
* @returns {Promise<void>} A promise that resolves when the audio files are merged successfully.
|
||||
*/
|
||||
async function mergeAudioFiles(audioTracks, duration, itemCachePath, outputFilePath, encodingOptions, progressCB = null, ffmpeg = Ffmpeg()) {
|
||||
const audioBitrate = encodingOptions.bitrate || '128k'
|
||||
const audioCodec = encodingOptions.codec || 'aac'
|
||||
const audioChannels = encodingOptions.channels || 2
|
||||
|
||||
// TODO: Updated in 2.2.11 to always encode even if merging multiple m4b. This is because just using the file extension as was being done before is not enough. This can be an option or do more to check if a concat is possible.
|
||||
// const audioRequiresEncode = audioTracks[0].metadata.ext !== '.m4b'
|
||||
const audioRequiresEncode = true
|
||||
|
||||
const firstTrackIsM4b = audioTracks[0].metadata.ext.toLowerCase() === '.m4b'
|
||||
const isOneTrack = audioTracks.length === 1
|
||||
|
||||
let concatFilePath = null
|
||||
if (!isOneTrack) {
|
||||
concatFilePath = Path.join(itemCachePath, 'files.txt')
|
||||
if ((await writeConcatFile(audioTracks, concatFilePath)) == null) {
|
||||
throw new Error('Failed to write concat file')
|
||||
}
|
||||
ffmpeg.input(concatFilePath).inputOptions(['-safe 0', '-f concat'])
|
||||
} else {
|
||||
ffmpeg.input(audioTracks[0].metadata.path).inputOptions(firstTrackIsM4b ? ['-f mp4'] : [])
|
||||
}
|
||||
|
||||
//const logLevel = process.env.NODE_ENV === 'production' ? 'error' : 'warning'
|
||||
ffmpeg.outputOptions(['-f mp4'])
|
||||
|
||||
if (audioRequiresEncode) {
|
||||
ffmpeg.outputOptions(['-map 0:a', `-acodec ${audioCodec}`, `-ac ${audioChannels}`, `-b:a ${audioBitrate}`])
|
||||
} else {
|
||||
ffmpeg.outputOptions(['-max_muxing_queue_size 1000'])
|
||||
|
||||
if (isOneTrack && firstTrackIsM4b) {
|
||||
ffmpeg.outputOptions(['-c copy'])
|
||||
} else {
|
||||
ffmpeg.outputOptions(['-c:a copy'])
|
||||
}
|
||||
}
|
||||
|
||||
ffmpeg.output(outputFilePath)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
ffmpeg
|
||||
.on('start', (cmd) => {
|
||||
Logger.debug(`[ffmpegHelpers] Merge Audio Files ffmpeg command: ${cmd}`)
|
||||
})
|
||||
.on('progress', (progress) => {
|
||||
if (!progressCB || !progress.timemark || !duration) return
|
||||
// Cannot rely on progress.percent as it is not accurate for concat
|
||||
const percent = (ffmpgegUtils.timemarkToSeconds(progress.timemark) / duration) * 100
|
||||
progressCB(percent)
|
||||
})
|
||||
.on('end', async (stdout, stderr) => {
|
||||
if (concatFilePath) await fs.remove(concatFilePath)
|
||||
Logger.debug('[ffmpegHelpers] ffmpeg stdout:', stdout)
|
||||
Logger.debug('[ffmpegHelpers] ffmpeg stderr:', stderr)
|
||||
Logger.debug(`[ffmpegHelpers] Audio Files Merged Successfully`)
|
||||
resolve()
|
||||
})
|
||||
.on('error', async (err, stdout, stderr) => {
|
||||
if (concatFilePath) await fs.remove(concatFilePath)
|
||||
if (err.message && err.message.includes('SIGKILL')) {
|
||||
Logger.info(`[ffmpegHelpers] Merge Audio Files Killed by User`)
|
||||
reject(new Error('FFMPEG_CANCELED'))
|
||||
} else {
|
||||
Logger.error(`[ffmpegHelpers] Merge Audio Files Error ${err}`)
|
||||
Logger.error('ffmpeg stdout:', stdout)
|
||||
Logger.error('ffmpeg stderr:', stderr)
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
|
||||
ffmpeg.run()
|
||||
})
|
||||
}
|
||||
|
||||
module.exports.mergeAudioFiles = mergeAudioFiles
|
||||
|
|
|
|||
|
|
@ -66,6 +66,11 @@ module.exports.getId = (prepend = '') => {
|
|||
return _id
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number} seconds
|
||||
* @returns {string}
|
||||
*/
|
||||
function elapsedPretty(seconds) {
|
||||
if (seconds > 0 && seconds < 1) {
|
||||
return `${Math.floor(seconds * 1000)} ms`
|
||||
|
|
@ -73,16 +78,27 @@ function elapsedPretty(seconds) {
|
|||
if (seconds < 60) {
|
||||
return `${Math.floor(seconds)} sec`
|
||||
}
|
||||
var minutes = Math.floor(seconds / 60)
|
||||
let minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 70) {
|
||||
return `${minutes} min`
|
||||
}
|
||||
var hours = Math.floor(minutes / 60)
|
||||
let hours = Math.floor(minutes / 60)
|
||||
minutes -= hours * 60
|
||||
if (!minutes) {
|
||||
return `${hours} hr`
|
||||
|
||||
let days = Math.floor(hours / 24)
|
||||
hours -= days * 24
|
||||
|
||||
const timeParts = []
|
||||
if (days) {
|
||||
timeParts.push(`${days} d`)
|
||||
}
|
||||
return `${hours} hr ${minutes} min`
|
||||
if (hours || (days && minutes)) {
|
||||
timeParts.push(`${hours} hr`)
|
||||
}
|
||||
if (minutes) {
|
||||
timeParts.push(`${minutes} min`)
|
||||
}
|
||||
return timeParts.join(' ')
|
||||
}
|
||||
module.exports.elapsedPretty = elapsedPretty
|
||||
|
||||
|
|
|
|||
36
server/utils/longTimeout.js
Normal file
36
server/utils/longTimeout.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* Handle timeouts greater than 32-bit signed integer
|
||||
*/
|
||||
class LongTimeout {
|
||||
constructor() {
|
||||
this.timeout = 0
|
||||
this.timer = null
|
||||
}
|
||||
|
||||
clear() {
|
||||
clearTimeout(this.timer)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Function} fn
|
||||
* @param {number} timeout
|
||||
*/
|
||||
set(fn, timeout) {
|
||||
const maxValue = 2147483647
|
||||
|
||||
const handleTimeout = () => {
|
||||
if (this.timeout > 0) {
|
||||
let delay = Math.min(this.timeout, maxValue)
|
||||
this.timeout = this.timeout - delay
|
||||
this.timer = setTimeout(handleTimeout, delay)
|
||||
return
|
||||
}
|
||||
fn()
|
||||
}
|
||||
|
||||
this.timeout = timeout
|
||||
handleTimeout()
|
||||
}
|
||||
}
|
||||
module.exports = LongTimeout
|
||||
|
|
@ -4,7 +4,6 @@ const StreamZip = require('../../libs/nodeStreamZip')
|
|||
const parseOpfMetadata = require('./parseOpfMetadata')
|
||||
const { xmlToJSON } = require('../index')
|
||||
|
||||
|
||||
/**
|
||||
* Extract file from epub and return string content
|
||||
*
|
||||
|
|
@ -49,7 +48,10 @@ async function extractXmlToJson(epubPath, xmlFilepath) {
|
|||
async function extractCoverImage(epubPath, epubImageFilepath, outputCoverPath) {
|
||||
const zip = new StreamZip.async({ file: epubPath })
|
||||
|
||||
const success = await zip.extract(epubImageFilepath, outputCoverPath).then(() => true).catch((error) => {
|
||||
const success = await zip
|
||||
.extract(epubImageFilepath, outputCoverPath)
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
Logger.error(`[parseEpubMetadata] Failed to extract image ${epubImageFilepath} from epub at "${epubPath}"`, error)
|
||||
return false
|
||||
})
|
||||
|
|
@ -89,7 +91,7 @@ async function parse(ebookFile) {
|
|||
}
|
||||
|
||||
// Parse metadata from package document opf file
|
||||
const opfMetadata = parseOpfMetadata.parseOpfMetadataJson(packageJson)
|
||||
const opfMetadata = parseOpfMetadata.parseOpfMetadataJson(structuredClone(packageJson))
|
||||
if (!opfMetadata) {
|
||||
Logger.error(`Unable to parse metadata in package doc with json`, JSON.stringify(packageJson, null, 2))
|
||||
return null
|
||||
|
|
@ -101,8 +103,23 @@ async function parse(ebookFile) {
|
|||
metadata: opfMetadata
|
||||
}
|
||||
|
||||
// Attempt to find filepath to cover image
|
||||
const manifestFirstImage = packageJson.package?.manifest?.[0]?.item?.find(item => item.$?.['media-type']?.startsWith('image/'))
|
||||
// Attempt to find filepath to cover image:
|
||||
// Metadata may include <meta name="cover" content="id"/> where content is the id of the cover image in the manifest
|
||||
// Otherwise the first image in the manifest is used as the cover image
|
||||
let packageMetadata = packageJson.package?.metadata
|
||||
if (Array.isArray(packageMetadata)) {
|
||||
packageMetadata = packageMetadata[0]
|
||||
}
|
||||
const metaCoverId = packageMetadata?.meta?.find?.((meta) => meta.$?.name === 'cover')?.$?.content
|
||||
|
||||
let manifestFirstImage = null
|
||||
if (metaCoverId) {
|
||||
manifestFirstImage = packageJson.package?.manifest?.[0]?.item?.find((item) => item.$?.id === metaCoverId)
|
||||
}
|
||||
if (!manifestFirstImage) {
|
||||
manifestFirstImage = packageJson.package?.manifest?.[0]?.item?.find((item) => item.$?.['media-type']?.startsWith('image/'))
|
||||
}
|
||||
|
||||
let coverImagePath = manifestFirstImage?.$?.href
|
||||
if (coverImagePath) {
|
||||
const packageDirname = Path.dirname(packageDocPath)
|
||||
|
|
|
|||
|
|
@ -81,6 +81,10 @@ function parseNfoMetadata(nfoText) {
|
|||
case 'isbn-13':
|
||||
metadata.isbn = value
|
||||
break
|
||||
case 'language':
|
||||
case 'lang':
|
||||
metadata.language = value
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,17 +1,21 @@
|
|||
const h = require('htmlparser2')
|
||||
const Logger = require('../../Logger')
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} opmlText
|
||||
* @returns {Array<{title: string, feedUrl: string}>
|
||||
*/
|
||||
function parse(opmlText) {
|
||||
var feeds = []
|
||||
var parser = new h.Parser({
|
||||
onopentag: (name, attribs) => {
|
||||
if (name === "outline" && attribs.type === 'rss') {
|
||||
if (name === 'outline' && attribs.type === 'rss') {
|
||||
if (!attribs.xmlurl) {
|
||||
Logger.error('[parseOPML] Invalid opml outline tag has no xmlurl attribute')
|
||||
} else {
|
||||
feeds.push({
|
||||
title: attribs.title || 'No Title',
|
||||
text: attribs.text || '',
|
||||
title: attribs.title || attribs.text || '',
|
||||
feedUrl: attribs.xmlurl
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,31 @@
|
|||
const { xmlToJSON } = require('../index')
|
||||
const htmlSanitizer = require('../htmlSanitizer')
|
||||
|
||||
/**
|
||||
* @typedef MetadataCreatorObject
|
||||
* @property {string} value
|
||||
* @property {string} role
|
||||
* @property {string} fileAs
|
||||
*
|
||||
* @example
|
||||
* <dc:creator xmlns:ns0="http://www.idpf.org/2007/opf" ns0:role="aut" ns0:file-as="Steinbeck, John">John Steinbeck</dc:creator>
|
||||
* <dc:creator opf:role="aut" opf:file-as="Orwell, George">George Orwell</dc:creator>
|
||||
*
|
||||
* @param {Object} metadata
|
||||
* @returns {MetadataCreatorObject[]}
|
||||
*/
|
||||
function parseCreators(metadata) {
|
||||
if (!metadata['dc:creator']) return null
|
||||
const creators = metadata['dc:creator']
|
||||
if (!creators.length) return null
|
||||
return creators.map((c) => {
|
||||
if (!metadata['dc:creator']?.length) return null
|
||||
return metadata['dc:creator'].map((c) => {
|
||||
if (typeof c !== 'object' || !c['$'] || !c['_']) return false
|
||||
const namespace =
|
||||
Object.keys(c['$'])
|
||||
.find((key) => key.startsWith('xmlns:'))
|
||||
?.split(':')[1] || 'opf'
|
||||
return {
|
||||
value: c['_'],
|
||||
role: c['$']['opf:role'] || null,
|
||||
fileAs: c['$']['opf:file-as'] || null
|
||||
role: c['$'][`${namespace}:role`] || null,
|
||||
fileAs: c['$'][`${namespace}:file-as`] || null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -59,18 +74,34 @@ function fetchPublisher(metadata) {
|
|||
return fetchTagString(metadata, 'dc:publisher')
|
||||
}
|
||||
|
||||
/**
|
||||
* @example
|
||||
* <dc:identifier xmlns:ns4="http://www.idpf.org/2007/opf" ns4:scheme="ISBN">9781440633904</dc:identifier>
|
||||
* <dc:identifier opf:scheme="ISBN">9780141187761</dc:identifier>
|
||||
*
|
||||
* @param {Object} metadata
|
||||
* @param {string} scheme
|
||||
* @returns {string}
|
||||
*/
|
||||
function fetchIdentifier(metadata, scheme) {
|
||||
if (!metadata['dc:identifier']?.length) return null
|
||||
const identifierObj = metadata['dc:identifier'].find((i) => {
|
||||
if (!i['$']) return false
|
||||
const namespace =
|
||||
Object.keys(i['$'])
|
||||
.find((key) => key.startsWith('xmlns:'))
|
||||
?.split(':')[1] || 'opf'
|
||||
return i['$'][`${namespace}:scheme`] === scheme
|
||||
})
|
||||
return identifierObj?.['_'] || null
|
||||
}
|
||||
|
||||
function fetchISBN(metadata) {
|
||||
if (!metadata['dc:identifier'] || !metadata['dc:identifier'].length) return null
|
||||
const identifiers = metadata['dc:identifier']
|
||||
const isbnObj = identifiers.find((i) => i['$'] && i['$']['opf:scheme'] === 'ISBN')
|
||||
return isbnObj ? isbnObj['_'] || null : null
|
||||
return fetchIdentifier(metadata, 'ISBN')
|
||||
}
|
||||
|
||||
function fetchASIN(metadata) {
|
||||
if (!metadata['dc:identifier'] || !metadata['dc:identifier'].length) return null
|
||||
const identifiers = metadata['dc:identifier']
|
||||
const asinObj = identifiers.find((i) => i['$'] && i['$']['opf:scheme'] === 'ASIN')
|
||||
return asinObj ? asinObj['_'] || null : null
|
||||
return fetchIdentifier(metadata, 'ASIN')
|
||||
}
|
||||
|
||||
function fetchTitle(metadata) {
|
||||
|
|
|
|||
|
|
@ -289,7 +289,6 @@ module.exports.findMatchingEpisodesInFeed = (feed, searchTitle) => {
|
|||
const matches = []
|
||||
feed.episodes.forEach((ep) => {
|
||||
if (!ep.title) return
|
||||
|
||||
const epTitle = ep.title.toLowerCase().trim()
|
||||
if (epTitle === searchTitle) {
|
||||
matches.push({
|
||||
|
|
|
|||
|
|
@ -60,12 +60,10 @@ module.exports = {
|
|||
* @returns {Promise<Object[]>} oldAuthor with numBooks
|
||||
*/
|
||||
async search(libraryId, query, limit, offset) {
|
||||
const matchAuthor = Database.matchExpression('name', query)
|
||||
const authors = await Database.authorModel.findAll({
|
||||
where: {
|
||||
name: {
|
||||
[Sequelize.Op.substring]: query
|
||||
},
|
||||
libraryId
|
||||
[Sequelize.Op.and]: [Sequelize.literal(matchAuthor), { libraryId }]
|
||||
},
|
||||
attributes: {
|
||||
include: [[Sequelize.literal('(SELECT count(*) FROM bookAuthors ba WHERE ba.authorId = author.id)'), 'numBooks']]
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ const Sequelize = require('sequelize')
|
|||
const Database = require('../../Database')
|
||||
const Logger = require('../../Logger')
|
||||
const authorFilters = require('./authorFilters')
|
||||
const { asciiOnlyToLowerCase } = require('../index')
|
||||
|
||||
const ShareManager = require('../../managers/ShareManager')
|
||||
|
||||
|
|
@ -274,6 +273,8 @@ module.exports = {
|
|||
return [[Sequelize.literal(`CAST(\`series.bookSeries.sequence\` AS FLOAT) ${nullDir}`)]]
|
||||
} else if (sortBy === 'progress') {
|
||||
return [[Sequelize.literal('mediaProgresses.updatedAt'), dir]]
|
||||
} else if (sortBy === 'random') {
|
||||
return [Database.sequelize.random()]
|
||||
}
|
||||
return []
|
||||
},
|
||||
|
|
@ -974,21 +975,18 @@ module.exports = {
|
|||
async search(oldUser, oldLibrary, query, limit, offset) {
|
||||
const userPermissionBookWhere = this.getUserPermissionBookWhereQuery(oldUser)
|
||||
|
||||
const normalizedQuery = await Database.getNormalizedQuery(query)
|
||||
|
||||
const matchTitle = Database.matchExpression('title', normalizedQuery)
|
||||
const matchSubtitle = Database.matchExpression('subtitle', normalizedQuery)
|
||||
|
||||
// Search title, subtitle, asin, isbn
|
||||
const books = await Database.bookModel.findAll({
|
||||
where: [
|
||||
{
|
||||
[Sequelize.Op.or]: [
|
||||
{
|
||||
title: {
|
||||
[Sequelize.Op.substring]: query
|
||||
}
|
||||
},
|
||||
{
|
||||
subtitle: {
|
||||
[Sequelize.Op.substring]: query
|
||||
}
|
||||
},
|
||||
Sequelize.literal(matchTitle),
|
||||
Sequelize.literal(matchSubtitle),
|
||||
{
|
||||
asin: {
|
||||
[Sequelize.Op.substring]: query
|
||||
|
|
@ -1038,32 +1036,17 @@ module.exports = {
|
|||
const libraryItem = book.libraryItem
|
||||
delete book.libraryItem
|
||||
libraryItem.media = book
|
||||
|
||||
let matchText = null
|
||||
let matchKey = null
|
||||
for (const key of ['title', 'subtitle', 'asin', 'isbn']) {
|
||||
const valueToLower = asciiOnlyToLowerCase(book[key])
|
||||
if (valueToLower.includes(query)) {
|
||||
matchText = book[key]
|
||||
matchKey = key
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (matchKey) {
|
||||
itemMatches.push({
|
||||
matchText,
|
||||
matchKey,
|
||||
libraryItem: Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSONExpanded()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const matchJsonValue = Database.matchExpression('json_each.value', normalizedQuery)
|
||||
|
||||
// Search narrators
|
||||
const narratorMatches = []
|
||||
const [narratorResults] = await Database.sequelize.query(`SELECT value, count(*) AS numBooks FROM books b, libraryItems li, json_each(b.narrators) WHERE json_valid(b.narrators) AND json_each.value LIKE :query AND b.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value LIMIT :limit OFFSET :offset;`, {
|
||||
const [narratorResults] = await Database.sequelize.query(`SELECT value, count(*) AS numBooks FROM books b, libraryItems li, json_each(b.narrators) WHERE json_valid(b.narrators) AND ${matchJsonValue} AND b.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value LIMIT :limit OFFSET :offset;`, {
|
||||
replacements: {
|
||||
query: `%${query}%`,
|
||||
libraryId: oldLibrary.id,
|
||||
limit,
|
||||
offset
|
||||
|
|
@ -1079,9 +1062,8 @@ module.exports = {
|
|||
|
||||
// Search tags
|
||||
const tagMatches = []
|
||||
const [tagResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM books b, libraryItems li, json_each(b.tags) WHERE json_valid(b.tags) AND json_each.value LIKE :query AND b.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value LIMIT :limit OFFSET :offset;`, {
|
||||
const [tagResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM books b, libraryItems li, json_each(b.tags) WHERE json_valid(b.tags) AND ${matchJsonValue} AND b.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value ORDER BY numItems DESC LIMIT :limit OFFSET :offset;`, {
|
||||
replacements: {
|
||||
query: `%${query}%`,
|
||||
libraryId: oldLibrary.id,
|
||||
limit,
|
||||
offset
|
||||
|
|
@ -1095,13 +1077,33 @@ module.exports = {
|
|||
})
|
||||
}
|
||||
|
||||
// Search genres
|
||||
const genreMatches = []
|
||||
const [genreResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM books b, libraryItems li, json_each(b.genres) WHERE json_valid(b.genres) AND ${matchJsonValue} AND b.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value ORDER BY numItems DESC LIMIT :limit OFFSET :offset;`, {
|
||||
replacements: {
|
||||
libraryId: oldLibrary.id,
|
||||
limit,
|
||||
offset
|
||||
},
|
||||
raw: true
|
||||
})
|
||||
for (const row of genreResults) {
|
||||
genreMatches.push({
|
||||
name: row.value,
|
||||
numItems: row.numItems
|
||||
})
|
||||
}
|
||||
|
||||
// Search series
|
||||
const matchName = Database.matchExpression('name', normalizedQuery)
|
||||
const allSeries = await Database.seriesModel.findAll({
|
||||
where: {
|
||||
name: {
|
||||
[Sequelize.Op.substring]: query
|
||||
},
|
||||
[Sequelize.Op.and]: [
|
||||
Sequelize.literal(matchName),
|
||||
{
|
||||
libraryId: oldLibrary.id
|
||||
}
|
||||
]
|
||||
},
|
||||
replacements: userPermissionBookWhere.replacements,
|
||||
include: {
|
||||
|
|
@ -1134,12 +1136,13 @@ module.exports = {
|
|||
}
|
||||
|
||||
// Search authors
|
||||
const authorMatches = await authorFilters.search(oldLibrary.id, query, limit, offset)
|
||||
const authorMatches = await authorFilters.search(oldLibrary.id, normalizedQuery, limit, offset)
|
||||
|
||||
return {
|
||||
book: itemMatches,
|
||||
narrators: narratorMatches,
|
||||
tags: tagMatches,
|
||||
genres: genreMatches,
|
||||
series: seriesMatches,
|
||||
authors: authorMatches
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
|
||||
const Sequelize = require('sequelize')
|
||||
const Database = require('../../Database')
|
||||
const Logger = require('../../Logger')
|
||||
|
|
@ -23,9 +22,11 @@ module.exports = {
|
|||
if (user.permissions.selectedTagsNotAccessible) {
|
||||
podcastWhere.push(Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:userTagsSelected))`), 0))
|
||||
} else {
|
||||
podcastWhere.push(Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:userTagsSelected))`), {
|
||||
podcastWhere.push(
|
||||
Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:userTagsSelected))`), {
|
||||
[Sequelize.Op.gte]: 1
|
||||
}))
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
return {
|
||||
|
|
@ -88,6 +89,8 @@ module.exports = {
|
|||
}
|
||||
} else if (sortBy === 'media.numTracks') {
|
||||
return [['numEpisodes', dir]]
|
||||
} else if (sortBy === 'random') {
|
||||
return [Database.sequelize.random()]
|
||||
}
|
||||
return []
|
||||
},
|
||||
|
|
@ -130,7 +133,7 @@ module.exports = {
|
|||
]
|
||||
} else if (filterGroup === 'recent') {
|
||||
libraryItemWhere['createdAt'] = {
|
||||
[Sequelize.Op.gte]: new Date(new Date() - (60 * 24 * 60 * 60 * 1000)) // 60 days ago
|
||||
[Sequelize.Op.gte]: new Date(new Date() - 60 * 24 * 60 * 60 * 1000) // 60 days ago
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -154,10 +157,7 @@ module.exports = {
|
|||
replacements,
|
||||
distinct: true,
|
||||
attributes: {
|
||||
include: [
|
||||
[Sequelize.literal(`(SELECT count(*) FROM podcastEpisodes pe WHERE pe.podcastId = podcast.id)`), 'numEpisodes'],
|
||||
...podcastIncludes
|
||||
]
|
||||
include: [[Sequelize.literal(`(SELECT count(*) FROM podcastEpisodes pe WHERE pe.podcastId = podcast.id)`), 'numEpisodes'], ...podcastIncludes]
|
||||
},
|
||||
include: [
|
||||
{
|
||||
|
|
@ -251,7 +251,7 @@ module.exports = {
|
|||
}
|
||||
} else if (filterGroup === 'recent') {
|
||||
podcastEpisodeWhere['createdAt'] = {
|
||||
[Sequelize.Op.gte]: new Date(new Date() - (60 * 24 * 60 * 60 * 1000)) // 60 days ago
|
||||
[Sequelize.Op.gte]: new Date(new Date() - 60 * 24 * 60 * 60 * 1000) // 60 days ago
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -313,21 +313,18 @@ module.exports = {
|
|||
*/
|
||||
async search(oldUser, oldLibrary, query, limit, offset) {
|
||||
const userPermissionPodcastWhere = this.getUserPermissionPodcastWhereQuery(oldUser)
|
||||
|
||||
const normalizedQuery = await Database.getNormalizedQuery(query)
|
||||
const matchTitle = Database.matchExpression('title', normalizedQuery)
|
||||
const matchAuthor = Database.matchExpression('author', normalizedQuery)
|
||||
|
||||
// Search title, author, itunesId, itunesArtistId
|
||||
const podcasts = await Database.podcastModel.findAll({
|
||||
where: [
|
||||
{
|
||||
[Sequelize.Op.or]: [
|
||||
{
|
||||
title: {
|
||||
[Sequelize.Op.substring]: query
|
||||
}
|
||||
},
|
||||
{
|
||||
author: {
|
||||
[Sequelize.Op.substring]: query
|
||||
}
|
||||
},
|
||||
Sequelize.literal(matchTitle),
|
||||
Sequelize.literal(matchAuthor),
|
||||
{
|
||||
itunesId: {
|
||||
[Sequelize.Op.substring]: query
|
||||
|
|
@ -363,32 +360,17 @@ module.exports = {
|
|||
const libraryItem = podcast.libraryItem
|
||||
delete podcast.libraryItem
|
||||
libraryItem.media = podcast
|
||||
|
||||
let matchText = null
|
||||
let matchKey = null
|
||||
for (const key of ['title', 'author', 'itunesId', 'itunesArtistId']) {
|
||||
const valueToLower = asciiOnlyToLowerCase(podcast[key])
|
||||
if (valueToLower.includes(query)) {
|
||||
matchText = podcast[key]
|
||||
matchKey = key
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (matchKey) {
|
||||
itemMatches.push({
|
||||
matchText,
|
||||
matchKey,
|
||||
libraryItem: Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSONExpanded()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const matchJsonValue = Database.matchExpression('json_each.value', normalizedQuery)
|
||||
|
||||
// Search tags
|
||||
const tagMatches = []
|
||||
const [tagResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM podcasts p, libraryItems li, json_each(p.tags) WHERE json_valid(p.tags) AND json_each.value LIKE :query AND p.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value LIMIT :limit OFFSET :offset;`, {
|
||||
const [tagResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM podcasts p, libraryItems li, json_each(p.tags) WHERE json_valid(p.tags) AND ${matchJsonValue} AND p.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value ORDER BY numItems DESC LIMIT :limit OFFSET :offset;`, {
|
||||
replacements: {
|
||||
query: `%${query}%`,
|
||||
libraryId: oldLibrary.id,
|
||||
limit,
|
||||
offset
|
||||
|
|
@ -402,9 +384,27 @@ module.exports = {
|
|||
})
|
||||
}
|
||||
|
||||
// Search genres
|
||||
const genreMatches = []
|
||||
const [genreResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM podcasts p, libraryItems li, json_each(p.genres) WHERE json_valid(p.genres) AND ${matchJsonValue} AND p.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value ORDER BY numItems DESC LIMIT :limit OFFSET :offset;`, {
|
||||
replacements: {
|
||||
libraryId: oldLibrary.id,
|
||||
limit,
|
||||
offset
|
||||
},
|
||||
raw: true
|
||||
})
|
||||
for (const row of genreResults) {
|
||||
genreMatches.push({
|
||||
name: row.value,
|
||||
numItems: row.numItems
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
podcast: itemMatches,
|
||||
tags: tagMatches
|
||||
tags: tagMatches,
|
||||
genres: genreMatches
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -446,9 +446,7 @@ module.exports = {
|
|||
required: false
|
||||
}
|
||||
],
|
||||
order: [
|
||||
['publishedAt', 'DESC']
|
||||
],
|
||||
order: [['publishedAt', 'DESC']],
|
||||
subQuery: false,
|
||||
limit,
|
||||
offset
|
||||
|
|
@ -519,11 +517,7 @@ module.exports = {
|
|||
*/
|
||||
async getLongestPodcasts(libraryId, limit) {
|
||||
const podcasts = await Database.podcastModel.findAll({
|
||||
attributes: [
|
||||
'id',
|
||||
'title',
|
||||
[Sequelize.literal(`(SELECT SUM(json_extract(pe.audioFile, '$.duration')) FROM podcastEpisodes pe WHERE pe.podcastId = podcast.id)`), 'duration']
|
||||
],
|
||||
attributes: ['id', 'title', [Sequelize.literal(`(SELECT SUM(json_extract(pe.audioFile, '$.duration')) FROM podcastEpisodes pe WHERE pe.podcastId = podcast.id)`), 'duration']],
|
||||
include: {
|
||||
model: Database.libraryItemModel,
|
||||
attributes: ['id', 'libraryId'],
|
||||
|
|
@ -531,12 +525,10 @@ module.exports = {
|
|||
libraryId
|
||||
}
|
||||
},
|
||||
order: [
|
||||
['duration', 'DESC']
|
||||
],
|
||||
order: [['duration', 'DESC']],
|
||||
limit
|
||||
})
|
||||
return podcasts.map(podcast => {
|
||||
return podcasts.map((podcast) => {
|
||||
return {
|
||||
id: podcast.libraryItem.id,
|
||||
title: podcast.title,
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ module.exports = {
|
|||
let filterGroup = null
|
||||
if (filterBy) {
|
||||
const searchGroups = ['genres', 'tags', 'authors', 'progress', 'narrators', 'publishers', 'languages']
|
||||
const group = searchGroups.find(_group => filterBy.startsWith(_group + '.'))
|
||||
const group = searchGroups.find((_group) => filterBy.startsWith(_group + '.'))
|
||||
filterGroup = group || filterBy
|
||||
filterValue = group ? this.decode(filterBy.replace(`${group}.`, '')) : null
|
||||
}
|
||||
|
|
@ -49,9 +49,11 @@ module.exports = {
|
|||
// Handle library setting to hide single book series
|
||||
// TODO: Merge with existing query
|
||||
if (library.settings.hideSingleBookSeries) {
|
||||
seriesWhere.push(Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND bs.bookId = b.id)`), {
|
||||
seriesWhere.push(
|
||||
Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND bs.bookId = b.id)`), {
|
||||
[Sequelize.Op.gt]: 1
|
||||
}))
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Handle filters
|
||||
|
|
@ -101,9 +103,11 @@ module.exports = {
|
|||
}
|
||||
|
||||
if (attrQuery) {
|
||||
seriesWhere.push(Sequelize.where(Sequelize.literal(`(${attrQuery})`), {
|
||||
seriesWhere.push(
|
||||
Sequelize.where(Sequelize.literal(`(${attrQuery})`), {
|
||||
[Sequelize.Op.gt]: 0
|
||||
}))
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const order = []
|
||||
|
|
@ -133,6 +137,8 @@ module.exports = {
|
|||
} else if (sortBy === 'lastBookUpdated') {
|
||||
seriesAttributes.include.push([Sequelize.literal('(SELECT MAX(b.updatedAt) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND b.id = bs.bookId)'), 'mostRecentBookUpdated'])
|
||||
order.push(['mostRecentBookUpdated', dir])
|
||||
} else if (sortBy === 'random') {
|
||||
order.push(Database.sequelize.random())
|
||||
}
|
||||
|
||||
const { rows: series, count } = await Database.seriesModel.findAndCountAll({
|
||||
|
|
@ -184,7 +190,7 @@ module.exports = {
|
|||
sensitivity: 'base'
|
||||
})
|
||||
})
|
||||
oldSeries.books = s.bookSeries.map(bs => {
|
||||
oldSeries.books = s.bookSeries.map((bs) => {
|
||||
const libraryItem = bs.book.libraryItem.toJSON()
|
||||
delete bs.book.libraryItem
|
||||
libraryItem.media = bs.book
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ const sinon = require('sinon')
|
|||
const fs = require('../../../server/libs/fsExtra')
|
||||
const fileUtils = require('../../../server/utils/fileUtils')
|
||||
const which = require('../../../server/libs/which')
|
||||
const ffbinaries = require('../../../server/libs/ffbinaries')
|
||||
const path = require('path')
|
||||
const BinaryManager = require('../../../server/managers/BinaryManager')
|
||||
const { Binary, ffbinaries } = require('../../../server/managers/BinaryManager')
|
||||
|
||||
const expect = chai.expect
|
||||
|
||||
|
|
@ -49,10 +49,14 @@ describe('BinaryManager', () => {
|
|||
})
|
||||
|
||||
it('should install missing binaries', async () => {
|
||||
const missingBinaries = ['ffmpeg', 'ffprobe']
|
||||
const ffmpegBinary = new Binary('ffmpeg', 'executable', 'FFMPEG_PATH', ['5.1'], ffbinaries)
|
||||
const ffprobeBinary = new Binary('ffprobe', 'executable', 'FFPROBE_PATH', ['5.1'], ffbinaries)
|
||||
const requiredBinaries = [ffmpegBinary, ffprobeBinary]
|
||||
const missingBinaries = [ffprobeBinary]
|
||||
const missingBinariesAfterInstall = []
|
||||
findStub.onFirstCall().resolves(missingBinaries)
|
||||
findStub.onSecondCall().resolves(missingBinariesAfterInstall)
|
||||
binaryManager.requiredBinaries = requiredBinaries
|
||||
|
||||
await binaryManager.init()
|
||||
|
||||
|
|
@ -64,8 +68,11 @@ describe('BinaryManager', () => {
|
|||
})
|
||||
|
||||
it('exit if binaries are not found after installation', async () => {
|
||||
const missingBinaries = ['ffmpeg', 'ffprobe']
|
||||
const missingBinariesAfterInstall = ['ffmpeg', 'ffprobe']
|
||||
const ffmpegBinary = new Binary('ffmpeg', 'executable', 'FFMPEG_PATH', ['5.1'], ffbinaries)
|
||||
const ffprobeBinary = new Binary('ffprobe', 'executable', 'FFPROBE_PATH', ['5.1'], ffbinaries)
|
||||
const requiredBinaries = [ffmpegBinary, ffprobeBinary]
|
||||
const missingBinaries = [ffprobeBinary]
|
||||
const missingBinariesAfterInstall = [ffprobeBinary]
|
||||
findStub.onFirstCall().resolves(missingBinaries)
|
||||
findStub.onSecondCall().resolves(missingBinariesAfterInstall)
|
||||
|
||||
|
|
@ -80,14 +87,15 @@ describe('BinaryManager', () => {
|
|||
})
|
||||
})
|
||||
|
||||
|
||||
describe('findRequiredBinaries', () => {
|
||||
let findBinaryStub
|
||||
let ffmpegBinary
|
||||
|
||||
beforeEach(() => {
|
||||
const requiredBinaries = [{ name: 'ffmpeg', envVariable: 'FFMPEG_PATH' }]
|
||||
ffmpegBinary = new Binary('ffmpeg', 'executable', 'FFMPEG_PATH', ['5.1'], ffbinaries)
|
||||
const requiredBinaries = [ffmpegBinary]
|
||||
binaryManager = new BinaryManager(requiredBinaries)
|
||||
findBinaryStub = sinon.stub(binaryManager, 'findBinary')
|
||||
findBinaryStub = sinon.stub(ffmpegBinary, 'find')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -108,7 +116,7 @@ describe('BinaryManager', () => {
|
|||
})
|
||||
|
||||
it('should add missing binaries to result', async () => {
|
||||
const missingBinaries = ['ffmpeg']
|
||||
const missingBinaries = [ffmpegBinary]
|
||||
delete process.env.FFMPEG_PATH
|
||||
findBinaryStub.resolves(null)
|
||||
|
||||
|
|
@ -122,19 +130,22 @@ describe('BinaryManager', () => {
|
|||
|
||||
describe('install', () => {
|
||||
let isWritableStub
|
||||
let downloadBinariesStub
|
||||
let downloadBinaryStub
|
||||
let ffmpegBinary
|
||||
|
||||
beforeEach(() => {
|
||||
binaryManager = new BinaryManager()
|
||||
ffmpegBinary = new Binary('ffmpeg', 'executable', 'FFMPEG_PATH', ['5.1'], ffbinaries)
|
||||
const requiredBinaries = [ffmpegBinary]
|
||||
binaryManager = new BinaryManager(requiredBinaries)
|
||||
isWritableStub = sinon.stub(fileUtils, 'isWritable')
|
||||
downloadBinariesStub = sinon.stub(ffbinaries, 'downloadBinaries')
|
||||
binaryManager.mainInstallPath = '/path/to/main/install'
|
||||
binaryManager.altInstallPath = '/path/to/alt/install'
|
||||
downloadBinaryStub = sinon.stub(ffmpegBinary, 'download')
|
||||
binaryManager.mainInstallDir = '/path/to/main/install'
|
||||
binaryManager.altInstallDir = '/path/to/alt/install'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
isWritableStub.restore()
|
||||
downloadBinariesStub.restore()
|
||||
downloadBinaryStub.restore()
|
||||
})
|
||||
|
||||
it('should not install binaries if no binaries are passed', async () => {
|
||||
|
|
@ -143,41 +154,42 @@ describe('BinaryManager', () => {
|
|||
await binaryManager.install(binaries)
|
||||
|
||||
expect(isWritableStub.called).to.be.false
|
||||
expect(downloadBinariesStub.called).to.be.false
|
||||
expect(downloadBinaryStub.called).to.be.false
|
||||
})
|
||||
|
||||
it('should install binaries in main install path if has access', async () => {
|
||||
const binaries = ['ffmpeg']
|
||||
const destination = binaryManager.mainInstallPath
|
||||
const binaries = [ffmpegBinary]
|
||||
const destination = binaryManager.mainInstallDir
|
||||
isWritableStub.withArgs(destination).resolves(true)
|
||||
downloadBinariesStub.resolves()
|
||||
downloadBinaryStub.resolves()
|
||||
|
||||
await binaryManager.install(binaries)
|
||||
|
||||
expect(isWritableStub.calledOnce).to.be.true
|
||||
expect(downloadBinariesStub.calledOnce).to.be.true
|
||||
expect(downloadBinariesStub.calledWith(binaries, sinon.match({ destination: destination }))).to.be.true
|
||||
expect(downloadBinaryStub.calledOnce).to.be.true
|
||||
expect(downloadBinaryStub.calledWith(destination)).to.be.true
|
||||
})
|
||||
|
||||
it('should install binaries in alt install path if has no access to main', async () => {
|
||||
const binaries = ['ffmpeg']
|
||||
const mainDestination = binaryManager.mainInstallPath
|
||||
const destination = binaryManager.altInstallPath
|
||||
const binaries = [ffmpegBinary]
|
||||
const mainDestination = binaryManager.mainInstallDir
|
||||
const destination = binaryManager.altInstallDir
|
||||
isWritableStub.withArgs(mainDestination).resolves(false)
|
||||
downloadBinariesStub.resolves()
|
||||
downloadBinaryStub.resolves()
|
||||
|
||||
await binaryManager.install(binaries)
|
||||
|
||||
expect(isWritableStub.calledOnce).to.be.true
|
||||
expect(downloadBinariesStub.calledOnce).to.be.true
|
||||
expect(downloadBinariesStub.calledWith(binaries, sinon.match({ destination: destination }))).to.be.true
|
||||
expect(downloadBinaryStub.calledOnce).to.be.true
|
||||
expect(downloadBinaryStub.calledWith(destination)).to.be.true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('findBinary', () => {
|
||||
let binaryManager
|
||||
let isBinaryGoodStub
|
||||
describe('Binary', () => {
|
||||
describe('find', () => {
|
||||
let binary
|
||||
let isGoodStub
|
||||
let whichSyncStub
|
||||
let mainInstallPath
|
||||
let altInstallPath
|
||||
|
|
@ -188,115 +200,112 @@ describe('findBinary', () => {
|
|||
const executable = name + (process.platform == 'win32' ? '.exe' : '')
|
||||
const whichPath = '/usr/bin/ffmpeg'
|
||||
|
||||
|
||||
beforeEach(() => {
|
||||
binaryManager = new BinaryManager()
|
||||
isBinaryGoodStub = sinon.stub(binaryManager, 'isBinaryGood')
|
||||
binary = new Binary(name, 'executable', envVariable, ['5.1'], ffbinaries)
|
||||
isGoodStub = sinon.stub(binary, 'isGood')
|
||||
whichSyncStub = sinon.stub(which, 'sync')
|
||||
binaryManager.mainInstallPath = '/path/to/main/install'
|
||||
mainInstallPath = path.join(binaryManager.mainInstallPath, executable)
|
||||
binaryManager.altInstallPath = '/path/to/alt/install'
|
||||
altInstallPath = path.join(binaryManager.altInstallPath, executable)
|
||||
binary.mainInstallDir = '/path/to/main/install'
|
||||
mainInstallPath = path.join(binary.mainInstallDir, executable)
|
||||
binary.altInstallDir = '/path/to/alt/install'
|
||||
altInstallPath = path.join(binary.altInstallDir, executable)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
isBinaryGoodStub.restore()
|
||||
isGoodStub.restore()
|
||||
whichSyncStub.restore()
|
||||
})
|
||||
|
||||
it('should return the defaultPath if it exists and is a good binary', async () => {
|
||||
process.env[envVariable] = defaultPath
|
||||
isBinaryGoodStub.withArgs(defaultPath).resolves(true)
|
||||
isGoodStub.withArgs(defaultPath).resolves(true)
|
||||
|
||||
const result = await binaryManager.findBinary(name, envVariable)
|
||||
const result = await binary.find(binary.mainInstallDir, binary.altInstallDir)
|
||||
|
||||
expect(result).to.equal(defaultPath)
|
||||
expect(isBinaryGoodStub.calledOnce).to.be.true
|
||||
expect(isBinaryGoodStub.calledWith(defaultPath)).to.be.true
|
||||
expect(isGoodStub.calledOnce).to.be.true
|
||||
expect(isGoodStub.calledWith(defaultPath)).to.be.true
|
||||
})
|
||||
|
||||
it('should return the whichPath if it exists and is a good binary', async () => {
|
||||
delete process.env[envVariable]
|
||||
isBinaryGoodStub.withArgs(undefined).resolves(false)
|
||||
isBinaryGoodStub.withArgs(whichPath).resolves(true)
|
||||
isGoodStub.withArgs(undefined).resolves(false)
|
||||
whichSyncStub.returns(whichPath)
|
||||
isGoodStub.withArgs(whichPath).resolves(true)
|
||||
|
||||
const result = await binaryManager.findBinary(name, envVariable)
|
||||
const result = await binary.find(binary.mainInstallDir, binary.altInstallDir)
|
||||
|
||||
expect(result).to.equal(whichPath)
|
||||
expect(isBinaryGoodStub.calledTwice).to.be.true
|
||||
expect(isBinaryGoodStub.calledWith(undefined)).to.be.true
|
||||
expect(isBinaryGoodStub.calledWith(whichPath)).to.be.true
|
||||
expect(isGoodStub.calledTwice).to.be.true
|
||||
expect(isGoodStub.calledWith(undefined)).to.be.true
|
||||
expect(isGoodStub.calledWith(whichPath)).to.be.true
|
||||
})
|
||||
|
||||
it('should return the mainInstallPath if it exists and is a good binary', async () => {
|
||||
delete process.env[envVariable]
|
||||
isBinaryGoodStub.withArgs(undefined).resolves(false)
|
||||
isBinaryGoodStub.withArgs(null).resolves(false)
|
||||
isBinaryGoodStub.withArgs(mainInstallPath).resolves(true)
|
||||
isGoodStub.withArgs(undefined).resolves(false)
|
||||
whichSyncStub.returns(null)
|
||||
isGoodStub.withArgs(null).resolves(false)
|
||||
isGoodStub.withArgs(mainInstallPath).resolves(true)
|
||||
|
||||
const result = await binaryManager.findBinary(name, envVariable)
|
||||
const result = await binary.find(binary.mainInstallDir, binary.altInstallDir)
|
||||
|
||||
expect(result).to.equal(mainInstallPath)
|
||||
expect(isBinaryGoodStub.callCount).to.be.equal(3)
|
||||
expect(isBinaryGoodStub.calledWith(undefined)).to.be.true
|
||||
expect(isBinaryGoodStub.calledWith(null)).to.be.true
|
||||
expect(isBinaryGoodStub.calledWith(mainInstallPath)).to.be.true
|
||||
expect(isGoodStub.callCount).to.be.equal(3)
|
||||
expect(isGoodStub.calledWith(undefined)).to.be.true
|
||||
expect(isGoodStub.calledWith(null)).to.be.true
|
||||
expect(isGoodStub.calledWith(mainInstallPath)).to.be.true
|
||||
})
|
||||
|
||||
it('should return the altInstallPath if it exists and is a good binary', async () => {
|
||||
delete process.env[envVariable]
|
||||
isBinaryGoodStub.withArgs(undefined).resolves(false)
|
||||
isBinaryGoodStub.withArgs(null).resolves(false)
|
||||
isBinaryGoodStub.withArgs(mainInstallPath).resolves(false)
|
||||
isBinaryGoodStub.withArgs(altInstallPath).resolves(true)
|
||||
isGoodStub.withArgs(undefined).resolves(false)
|
||||
whichSyncStub.returns(null)
|
||||
isGoodStub.withArgs(null).resolves(false)
|
||||
isGoodStub.withArgs(mainInstallPath).resolves(false)
|
||||
isGoodStub.withArgs(altInstallPath).resolves(true)
|
||||
|
||||
const result = await binaryManager.findBinary(name, envVariable)
|
||||
const result = await binary.find(binary.mainInstallDir, binary.altInstallDir)
|
||||
|
||||
expect(result).to.equal(altInstallPath)
|
||||
expect(isBinaryGoodStub.callCount).to.be.equal(4)
|
||||
expect(isBinaryGoodStub.calledWith(undefined)).to.be.true
|
||||
expect(isBinaryGoodStub.calledWith(null)).to.be.true
|
||||
expect(isBinaryGoodStub.calledWith(mainInstallPath)).to.be.true
|
||||
expect(isBinaryGoodStub.calledWith(altInstallPath)).to.be.true
|
||||
expect(isGoodStub.callCount).to.be.equal(4)
|
||||
expect(isGoodStub.calledWith(undefined)).to.be.true
|
||||
expect(isGoodStub.calledWith(null)).to.be.true
|
||||
expect(isGoodStub.calledWith(mainInstallPath)).to.be.true
|
||||
expect(isGoodStub.calledWith(altInstallPath)).to.be.true
|
||||
})
|
||||
|
||||
it('should return null if no good binary is found', async () => {
|
||||
delete process.env[envVariable]
|
||||
isBinaryGoodStub.withArgs(undefined).resolves(false)
|
||||
isBinaryGoodStub.withArgs(null).resolves(false)
|
||||
isBinaryGoodStub.withArgs(mainInstallPath).resolves(false)
|
||||
isBinaryGoodStub.withArgs(altInstallPath).resolves(false)
|
||||
isGoodStub.withArgs(undefined).resolves(false)
|
||||
whichSyncStub.returns(null)
|
||||
isGoodStub.withArgs(null).resolves(false)
|
||||
isGoodStub.withArgs(mainInstallPath).resolves(false)
|
||||
isGoodStub.withArgs(altInstallPath).resolves(false)
|
||||
|
||||
const result = await binaryManager.findBinary(name, envVariable)
|
||||
const result = await binary.find(binary.mainInstallDir, binary.altInstallDir)
|
||||
|
||||
expect(result).to.be.null
|
||||
expect(isBinaryGoodStub.callCount).to.be.equal(4)
|
||||
expect(isBinaryGoodStub.calledWith(undefined)).to.be.true
|
||||
expect(isBinaryGoodStub.calledWith(null)).to.be.true
|
||||
expect(isBinaryGoodStub.calledWith(mainInstallPath)).to.be.true
|
||||
expect(isBinaryGoodStub.calledWith(altInstallPath)).to.be.true
|
||||
expect(isGoodStub.callCount).to.be.equal(4)
|
||||
expect(isGoodStub.calledWith(undefined)).to.be.true
|
||||
expect(isGoodStub.calledWith(null)).to.be.true
|
||||
expect(isGoodStub.calledWith(mainInstallPath)).to.be.true
|
||||
expect(isGoodStub.calledWith(altInstallPath)).to.be.true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('isBinaryGood', () => {
|
||||
let binaryManager
|
||||
describe('isGood', () => {
|
||||
let binary
|
||||
let fsPathExistsStub
|
||||
let execStub
|
||||
let loggerInfoStub
|
||||
let loggerErrorStub
|
||||
|
||||
const binaryPath = '/path/to/binary'
|
||||
const execCommand = '"' + binaryPath + '"' + ' -version'
|
||||
const goodVersions = ['5.1', '6']
|
||||
|
||||
beforeEach(() => {
|
||||
binaryManager = new BinaryManager()
|
||||
binary = new Binary('ffmpeg', 'executable', 'FFMPEG_PATH', goodVersions, ffbinaries)
|
||||
fsPathExistsStub = sinon.stub(fs, 'pathExists')
|
||||
execStub = sinon.stub(binaryManager, 'exec')
|
||||
execStub = sinon.stub(binary, 'exec')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -307,7 +316,7 @@ describe('isBinaryGood', () => {
|
|||
it('should return false if binaryPath is falsy', async () => {
|
||||
fsPathExistsStub.resolves(true)
|
||||
|
||||
const result = await binaryManager.isBinaryGood(null, goodVersions)
|
||||
const result = await binary.isGood(null)
|
||||
|
||||
expect(result).to.be.false
|
||||
expect(fsPathExistsStub.called).to.be.false
|
||||
|
|
@ -317,7 +326,7 @@ describe('isBinaryGood', () => {
|
|||
it('should return false if binaryPath does not exist', async () => {
|
||||
fsPathExistsStub.resolves(false)
|
||||
|
||||
const result = await binaryManager.isBinaryGood(binaryPath, goodVersions)
|
||||
const result = await binary.isGood(binaryPath)
|
||||
|
||||
expect(result).to.be.false
|
||||
expect(fsPathExistsStub.calledOnce).to.be.true
|
||||
|
|
@ -329,7 +338,7 @@ describe('isBinaryGood', () => {
|
|||
fsPathExistsStub.resolves(true)
|
||||
execStub.rejects(new Error('Failed to execute command'))
|
||||
|
||||
const result = await binaryManager.isBinaryGood(binaryPath, goodVersions)
|
||||
const result = await binary.isGood(binaryPath)
|
||||
|
||||
expect(result).to.be.false
|
||||
expect(fsPathExistsStub.calledOnce).to.be.true
|
||||
|
|
@ -343,7 +352,7 @@ describe('isBinaryGood', () => {
|
|||
fsPathExistsStub.resolves(true)
|
||||
execStub.resolves({ stdout })
|
||||
|
||||
const result = await binaryManager.isBinaryGood(binaryPath, goodVersions)
|
||||
const result = await binary.isGood(binaryPath)
|
||||
|
||||
expect(result).to.be.false
|
||||
expect(fsPathExistsStub.calledOnce).to.be.true
|
||||
|
|
@ -357,7 +366,7 @@ describe('isBinaryGood', () => {
|
|||
fsPathExistsStub.resolves(true)
|
||||
execStub.resolves({ stdout })
|
||||
|
||||
const result = await binaryManager.isBinaryGood(binaryPath, goodVersions)
|
||||
const result = await binary.isGood(binaryPath)
|
||||
|
||||
expect(result).to.be.false
|
||||
expect(fsPathExistsStub.calledOnce).to.be.true
|
||||
|
|
@ -371,7 +380,7 @@ describe('isBinaryGood', () => {
|
|||
fsPathExistsStub.resolves(true)
|
||||
execStub.resolves({ stdout })
|
||||
|
||||
const result = await binaryManager.isBinaryGood(binaryPath, goodVersions)
|
||||
const result = await binary.isGood(binaryPath)
|
||||
|
||||
expect(result).to.be.true
|
||||
expect(fsPathExistsStub.calledOnce).to.be.true
|
||||
|
|
@ -379,4 +388,68 @@ describe('isBinaryGood', () => {
|
|||
expect(execStub.calledOnce).to.be.true
|
||||
expect(execStub.calledWith(execCommand)).to.be.true
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFileName', () => {
|
||||
let originalPlatform
|
||||
|
||||
const mockPlatform = (platform) => {
|
||||
Object.defineProperty(process, 'platform', { value: platform })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Save the original process.platform descriptor
|
||||
originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Restore the original process.platform descriptor
|
||||
Object.defineProperty(process, 'platform', originalPlatform)
|
||||
})
|
||||
|
||||
it('should return the executable file name with .exe extension on Windows', () => {
|
||||
const binary = new Binary('ffmpeg', 'executable', 'FFMPEG_PATH', ['5.1'], ffbinaries)
|
||||
mockPlatform('win32')
|
||||
|
||||
const result = binary.getFileName()
|
||||
|
||||
expect(result).to.equal('ffmpeg.exe')
|
||||
})
|
||||
|
||||
it('should return the executable file name without extension on linux', () => {
|
||||
const binary = new Binary('ffmpeg', 'executable', 'FFMPEG_PATH', ['5.1'], ffbinaries)
|
||||
mockPlatform('linux')
|
||||
|
||||
const result = binary.getFileName()
|
||||
|
||||
expect(result).to.equal('ffmpeg')
|
||||
})
|
||||
|
||||
it('should return the library file name with .dll extension on Windows', () => {
|
||||
const binary = new Binary('ffmpeg', 'library', 'FFMPEG_PATH', ['5.1'], ffbinaries)
|
||||
mockPlatform('win32')
|
||||
|
||||
const result = binary.getFileName()
|
||||
|
||||
expect(result).to.equal('ffmpeg.dll')
|
||||
})
|
||||
|
||||
it('should return the library file name with .so extension on linux', () => {
|
||||
const binary = new Binary('ffmpeg', 'library', 'FFMPEG_PATH', ['5.1'], ffbinaries)
|
||||
mockPlatform('linux')
|
||||
|
||||
const result = binary.getFileName()
|
||||
|
||||
expect(result).to.equal('ffmpeg.so')
|
||||
})
|
||||
|
||||
it('should return the file name without extension for other types', () => {
|
||||
const binary = new Binary('ffmpeg', 'other', 'FFMPEG_PATH', ['5.1'], ffbinaries)
|
||||
mockPlatform('win32')
|
||||
|
||||
const result = binary.getFileName()
|
||||
|
||||
expect(result).to.equal('ffmpeg')
|
||||
})
|
||||
})
|
||||
})
|
||||
95
test/server/objects/TrackProgressMonitor.test.js
Normal file
95
test/server/objects/TrackProgressMonitor.test.js
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
const chai = require('chai')
|
||||
const sinon = require('sinon')
|
||||
const TrackProgressMonitor = require('../../../server/objects/TrackProgressMonitor')
|
||||
|
||||
const expect = chai.expect
|
||||
|
||||
describe('TrackProgressMonitor', () => {
|
||||
let trackDurations
|
||||
let trackStartedCallback
|
||||
let progressCallback
|
||||
let trackFinishedCallback
|
||||
let monitor
|
||||
|
||||
beforeEach(() => {
|
||||
trackDurations = [10, 40, 50]
|
||||
trackStartedCallback = sinon.spy()
|
||||
progressCallback = sinon.spy()
|
||||
trackFinishedCallback = sinon.spy()
|
||||
})
|
||||
|
||||
it('should initialize correctly', () => {
|
||||
monitor = new TrackProgressMonitor(trackDurations, trackStartedCallback, progressCallback, trackFinishedCallback)
|
||||
|
||||
expect(monitor.trackDurations).to.deep.equal(trackDurations)
|
||||
expect(monitor.totalDuration).to.equal(100)
|
||||
expect(monitor.trackStartedCallback).to.equal(trackStartedCallback)
|
||||
expect(monitor.progressCallback).to.equal(progressCallback)
|
||||
expect(monitor.trackFinishedCallback).to.equal(trackFinishedCallback)
|
||||
expect(monitor.currentTrackIndex).to.equal(0)
|
||||
expect(monitor.cummulativeProgress).to.equal(0)
|
||||
expect(monitor.currentTrackPercentage).to.equal(10)
|
||||
expect(monitor.numTracks).to.equal(trackDurations.length)
|
||||
expect(monitor.allTracksFinished).to.be.false
|
||||
})
|
||||
|
||||
it('should update the progress', () => {
|
||||
monitor = new TrackProgressMonitor(trackDurations, trackStartedCallback, progressCallback, trackFinishedCallback)
|
||||
monitor.update(5)
|
||||
|
||||
expect(monitor.currentTrackIndex).to.equal(0)
|
||||
expect(monitor.cummulativeProgress).to.equal(0)
|
||||
expect(monitor.currentTrackPercentage).to.equal(10)
|
||||
expect(trackStartedCallback.calledOnceWithExactly(0)).to.be.true
|
||||
expect(progressCallback.calledOnceWithExactly(0, 50, 5)).to.be.true
|
||||
expect(trackFinishedCallback.notCalled).to.be.true
|
||||
})
|
||||
|
||||
it('should update the progress multiple times on the same track', () => {
|
||||
monitor = new TrackProgressMonitor(trackDurations, trackStartedCallback, progressCallback, trackFinishedCallback)
|
||||
monitor.update(5)
|
||||
monitor.update(7)
|
||||
|
||||
expect(monitor.currentTrackIndex).to.equal(0)
|
||||
expect(monitor.cummulativeProgress).to.equal(0)
|
||||
expect(monitor.currentTrackPercentage).to.equal(10)
|
||||
expect(trackStartedCallback.calledOnceWithExactly(0)).to.be.true
|
||||
expect(progressCallback.calledTwice).to.be.true
|
||||
expect(progressCallback.calledWithExactly(0, 50, 5)).to.be.true
|
||||
expect(progressCallback.calledWithExactly(0, 70, 7)).to.be.true
|
||||
expect(trackFinishedCallback.notCalled).to.be.true
|
||||
})
|
||||
|
||||
it('should update the progress multiple times on different tracks', () => {
|
||||
monitor = new TrackProgressMonitor(trackDurations, trackStartedCallback, progressCallback, trackFinishedCallback)
|
||||
monitor.update(5)
|
||||
monitor.update(20)
|
||||
|
||||
expect(monitor.currentTrackIndex).to.equal(1)
|
||||
expect(monitor.cummulativeProgress).to.equal(10)
|
||||
expect(monitor.currentTrackPercentage).to.equal(40)
|
||||
expect(trackStartedCallback.calledTwice).to.be.true
|
||||
expect(trackStartedCallback.calledWithExactly(0)).to.be.true
|
||||
expect(trackStartedCallback.calledWithExactly(1)).to.be.true
|
||||
expect(progressCallback.calledTwice).to.be.true
|
||||
expect(progressCallback.calledWithExactly(0, 50, 5)).to.be.true
|
||||
expect(progressCallback.calledWithExactly(1, 25, 20)).to.be.true
|
||||
expect(trackFinishedCallback.calledOnceWithExactly(0)).to.be.true
|
||||
})
|
||||
|
||||
it('should finish all tracks', () => {
|
||||
monitor = new TrackProgressMonitor(trackDurations, trackStartedCallback, progressCallback, trackFinishedCallback)
|
||||
monitor.finish()
|
||||
|
||||
expect(monitor.allTracksFinished).to.be.true
|
||||
expect(trackStartedCallback.calledThrice).to.be.true
|
||||
expect(trackFinishedCallback.calledThrice).to.be.true
|
||||
expect(progressCallback.notCalled).to.be.true
|
||||
expect(trackStartedCallback.calledWithExactly(0)).to.be.true
|
||||
expect(trackFinishedCallback.calledWithExactly(0)).to.be.true
|
||||
expect(trackStartedCallback.calledWithExactly(1)).to.be.true
|
||||
expect(trackFinishedCallback.calledWithExactly(1)).to.be.true
|
||||
expect(trackStartedCallback.calledWithExactly(2)).to.be.true
|
||||
expect(trackFinishedCallback.calledWithExactly(2)).to.be.true
|
||||
})
|
||||
})
|
||||
|
|
@ -81,10 +81,9 @@ describe('addCoverAndMetadataToFile', () => {
|
|||
ffmpegStub.run = sinon.stub().callsFake(() => {
|
||||
ffmpegStub.emit('end')
|
||||
})
|
||||
const fsCopyFileSyncStub = sinon.stub(fs, 'copyFileSync')
|
||||
const fsUnlinkSyncStub = sinon.stub(fs, 'unlinkSync')
|
||||
const fsMove = sinon.stub(fs, 'move').resolves()
|
||||
|
||||
return { audioFilePath, coverFilePath, metadataFilePath, track, mimeType, ffmpegStub, fsCopyFileSyncStub, fsUnlinkSyncStub }
|
||||
return { audioFilePath, coverFilePath, metadataFilePath, track, mimeType, ffmpegStub, fsMove }
|
||||
}
|
||||
|
||||
let audioFilePath = null
|
||||
|
|
@ -93,8 +92,7 @@ describe('addCoverAndMetadataToFile', () => {
|
|||
let track = null
|
||||
let mimeType = null
|
||||
let ffmpegStub = null
|
||||
let fsCopyFileSyncStub = null
|
||||
let fsUnlinkSyncStub = null
|
||||
let fsMove = null
|
||||
beforeEach(() => {
|
||||
const input = createTestSetup()
|
||||
audioFilePath = input.audioFilePath
|
||||
|
|
@ -103,16 +101,14 @@ describe('addCoverAndMetadataToFile', () => {
|
|||
track = input.track
|
||||
mimeType = input.mimeType
|
||||
ffmpegStub = input.ffmpegStub
|
||||
fsCopyFileSyncStub = input.fsCopyFileSyncStub
|
||||
fsUnlinkSyncStub = input.fsUnlinkSyncStub
|
||||
fsMove = input.fsMove
|
||||
})
|
||||
|
||||
it('should add cover image and metadata to audio file', async () => {
|
||||
// Act
|
||||
const result = await addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, ffmpegStub)
|
||||
await addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, null, ffmpegStub)
|
||||
|
||||
// Assert
|
||||
expect(result).to.be.true
|
||||
expect(ffmpegStub.input.calledThrice).to.be.true
|
||||
expect(ffmpegStub.input.getCall(0).args[0]).to.equal(audioFilePath)
|
||||
expect(ffmpegStub.input.getCall(1).args[0]).to.equal(metadataFilePath)
|
||||
|
|
@ -129,12 +125,10 @@ describe('addCoverAndMetadataToFile', () => {
|
|||
|
||||
expect(ffmpegStub.run.calledOnce).to.be.true
|
||||
|
||||
expect(fsCopyFileSyncStub.calledOnce).to.be.true
|
||||
expect(fsCopyFileSyncStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.mp3')
|
||||
expect(fsCopyFileSyncStub.firstCall.args[1]).to.equal('/path/to/audio/file.mp3')
|
||||
|
||||
expect(fsUnlinkSyncStub.calledOnce).to.be.true
|
||||
expect(fsUnlinkSyncStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.mp3')
|
||||
expect(fsMove.calledOnce).to.be.true
|
||||
expect(fsMove.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.mp3')
|
||||
expect(fsMove.firstCall.args[1]).to.equal('/path/to/audio/file.mp3')
|
||||
expect(fsMove.firstCall.args[2]).to.deep.equal({ overwrite: true })
|
||||
|
||||
// Restore the stub
|
||||
sinon.restore()
|
||||
|
|
@ -145,10 +139,9 @@ describe('addCoverAndMetadataToFile', () => {
|
|||
coverFilePath = null
|
||||
|
||||
// Act
|
||||
const result = await addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, ffmpegStub)
|
||||
await addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, null, ffmpegStub)
|
||||
|
||||
// Assert
|
||||
expect(result).to.be.true
|
||||
expect(ffmpegStub.input.calledTwice).to.be.true
|
||||
expect(ffmpegStub.input.getCall(0).args[0]).to.equal(audioFilePath)
|
||||
expect(ffmpegStub.input.getCall(1).args[0]).to.equal(metadataFilePath)
|
||||
|
|
@ -164,12 +157,10 @@ describe('addCoverAndMetadataToFile', () => {
|
|||
|
||||
expect(ffmpegStub.run.calledOnce).to.be.true
|
||||
|
||||
expect(fsCopyFileSyncStub.calledOnce).to.be.true
|
||||
expect(fsCopyFileSyncStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.mp3')
|
||||
expect(fsCopyFileSyncStub.firstCall.args[1]).to.equal('/path/to/audio/file.mp3')
|
||||
|
||||
expect(fsUnlinkSyncStub.calledOnce).to.be.true
|
||||
expect(fsUnlinkSyncStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.mp3')
|
||||
expect(fsMove.calledOnce).to.be.true
|
||||
expect(fsMove.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.mp3')
|
||||
expect(fsMove.firstCall.args[1]).to.equal('/path/to/audio/file.mp3')
|
||||
expect(fsMove.firstCall.args[2]).to.deep.equal({ overwrite: true })
|
||||
|
||||
// Restore the stub
|
||||
sinon.restore()
|
||||
|
|
@ -182,10 +173,15 @@ describe('addCoverAndMetadataToFile', () => {
|
|||
})
|
||||
|
||||
// Act
|
||||
const result = await addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, ffmpegStub)
|
||||
try {
|
||||
await addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, null, ffmpegStub)
|
||||
expect.fail('Expected an error to be thrown')
|
||||
} catch (error) {
|
||||
// Assert
|
||||
expect(error.message).to.equal('FFmpeg error')
|
||||
}
|
||||
|
||||
// Assert
|
||||
expect(result).to.be.false
|
||||
expect(ffmpegStub.input.calledThrice).to.be.true
|
||||
expect(ffmpegStub.input.getCall(0).args[0]).to.equal(audioFilePath)
|
||||
expect(ffmpegStub.input.getCall(1).args[0]).to.equal(metadataFilePath)
|
||||
|
|
@ -202,9 +198,7 @@ describe('addCoverAndMetadataToFile', () => {
|
|||
|
||||
expect(ffmpegStub.run.calledOnce).to.be.true
|
||||
|
||||
expect(fsCopyFileSyncStub.called).to.be.false
|
||||
|
||||
expect(fsUnlinkSyncStub.called).to.be.false
|
||||
expect(fsMove.called).to.be.false
|
||||
|
||||
// Restore the stub
|
||||
sinon.restore()
|
||||
|
|
@ -216,10 +210,9 @@ describe('addCoverAndMetadataToFile', () => {
|
|||
audioFilePath = '/path/to/audio/file.m4b'
|
||||
|
||||
// Act
|
||||
const result = await addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, ffmpegStub)
|
||||
await addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, null, ffmpegStub)
|
||||
|
||||
// Assert
|
||||
expect(result).to.be.true
|
||||
expect(ffmpegStub.input.calledThrice).to.be.true
|
||||
expect(ffmpegStub.input.getCall(0).args[0]).to.equal(audioFilePath)
|
||||
expect(ffmpegStub.input.getCall(1).args[0]).to.equal(metadataFilePath)
|
||||
|
|
@ -236,12 +229,10 @@ describe('addCoverAndMetadataToFile', () => {
|
|||
|
||||
expect(ffmpegStub.run.calledOnce).to.be.true
|
||||
|
||||
expect(fsCopyFileSyncStub.calledOnce).to.be.true
|
||||
expect(fsCopyFileSyncStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.m4b')
|
||||
expect(fsCopyFileSyncStub.firstCall.args[1]).to.equal('/path/to/audio/file.m4b')
|
||||
|
||||
expect(fsUnlinkSyncStub.calledOnce).to.be.true
|
||||
expect(fsUnlinkSyncStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.m4b')
|
||||
expect(fsMove.calledOnce).to.be.true
|
||||
expect(fsMove.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.m4b')
|
||||
expect(fsMove.firstCall.args[1]).to.equal('/path/to/audio/file.m4b')
|
||||
expect(fsMove.firstCall.args[2]).to.deep.equal({ overwrite: true })
|
||||
|
||||
// Restore the stub
|
||||
sinon.restore()
|
||||
|
|
|
|||
|
|
@ -103,6 +103,16 @@ describe('parseNfoMetadata', () => {
|
|||
expect(result.asin).to.equal('B08X5JZJLH')
|
||||
})
|
||||
|
||||
it('parses language', () => {
|
||||
const nfoText = 'Language: eng'
|
||||
const result = parseNfoMetadata(nfoText)
|
||||
expect(result.language).to.equal('eng')
|
||||
|
||||
const nfoText2 = 'lang: deu'
|
||||
const result2 = parseNfoMetadata(nfoText2)
|
||||
expect(result2.language).to.equal('deu')
|
||||
})
|
||||
|
||||
it('parses description', () => {
|
||||
const nfoText = 'Book Description\n=========\nThis is a book.\n It\'s good'
|
||||
const result = parseNfoMetadata(nfoText)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue