mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-10 03:11:34 +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
|
- name: Discord
|
||||||
url: https://discord.gg/HQgCbd6E75
|
url: https://discord.gg/HQgCbd6E75
|
||||||
about: Ask questions, get help troubleshooting, and join the Abs community here.
|
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/
|
/.nyc_output/
|
||||||
/ffmpeg*
|
/ffmpeg*
|
||||||
/ffprobe*
|
/ffprobe*
|
||||||
|
/unicode*
|
||||||
|
|
||||||
sw.*
|
sw.*
|
||||||
.DS_STORE
|
.DS_STORE
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
set -e
|
set -e
|
||||||
set -o pipefail
|
set -o pipefail
|
||||||
|
|
||||||
FFMPEG_INSTALL_DIR="/usr/lib/audiobookshelf-ffmpeg"
|
|
||||||
DEFAULT_DATA_DIR="/usr/share/audiobookshelf"
|
DEFAULT_DATA_DIR="/usr/share/audiobookshelf"
|
||||||
CONFIG_PATH="/etc/default/audiobookshelf"
|
CONFIG_PATH="/etc/default/audiobookshelf"
|
||||||
DEFAULT_PORT=13378
|
DEFAULT_PORT=13378
|
||||||
|
|
@ -46,25 +45,6 @@ add_group() {
|
||||||
fi
|
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() {
|
setup_config() {
|
||||||
if [ -f "$CONFIG_PATH" ]; then
|
if [ -f "$CONFIG_PATH" ]; then
|
||||||
echo "Existing config found."
|
echo "Existing config found."
|
||||||
|
|
@ -83,8 +63,6 @@ setup_config() {
|
||||||
|
|
||||||
config_text="METADATA_PATH=$DEFAULT_DATA_DIR/metadata
|
config_text="METADATA_PATH=$DEFAULT_DATA_DIR/metadata
|
||||||
CONFIG_PATH=$DEFAULT_DATA_DIR/config
|
CONFIG_PATH=$DEFAULT_DATA_DIR/config
|
||||||
FFMPEG_PATH=$FFMPEG_INSTALL_DIR/ffmpeg
|
|
||||||
FFPROBE_PATH=$FFMPEG_INSTALL_DIR/ffprobe
|
|
||||||
PORT=$DEFAULT_PORT
|
PORT=$DEFAULT_PORT
|
||||||
HOST=$DEFAULT_HOST"
|
HOST=$DEFAULT_HOST"
|
||||||
|
|
||||||
|
|
@ -101,5 +79,3 @@ add_group 'audiobookshelf' ''
|
||||||
add_user 'audiobookshelf' '' 'audiobookshelf' 'audiobookshelf user-daemon' '/bin/false'
|
add_user 'audiobookshelf' '' 'audiobookshelf' 'audiobookshelf user-daemon' '/bin/false'
|
||||||
|
|
||||||
setup_config
|
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" />
|
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="110" class="ml-2" @action="contextMenuAction" />
|
||||||
</template>
|
</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 -->
|
<!-- search page -->
|
||||||
<template v-else-if="page === 'search'">
|
<template v-else-if="page === 'search'">
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
|
|
@ -99,10 +94,15 @@
|
||||||
<!-- authors page -->
|
<!-- authors page -->
|
||||||
<template v-else-if="page === 'authors'">
|
<template v-else-if="page === 'authors'">
|
||||||
<div class="flex-grow" />
|
<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 -->
|
<!-- 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>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -188,6 +188,10 @@ export default {
|
||||||
{
|
{
|
||||||
text: this.$strings.LabelTotalDuration,
|
text: this.$strings.LabelTotalDuration,
|
||||||
value: 'totalDuration'
|
value: 'totalDuration'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelRandomly,
|
||||||
|
value: 'random'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow px-2 authorSearchCardContent h-full">
|
<div class="flex-grow px-2 authorSearchCardContent h-full">
|
||||||
<p class="truncate text-sm">{{ name }}</p>
|
<p class="truncate text-sm">{{ name }}</p>
|
||||||
|
<p class="text-xs text-gray-400">{{ $getString('LabelXBooks', [numBooks]) }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -23,6 +24,9 @@ export default {
|
||||||
computed: {
|
computed: {
|
||||||
name() {
|
name() {
|
||||||
return this.author.name
|
return this.author.name
|
||||||
|
},
|
||||||
|
numBooks() {
|
||||||
|
return this.author.numBooks
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {},
|
methods: {},
|
||||||
|
|
@ -33,7 +37,7 @@ export default {
|
||||||
<style>
|
<style>
|
||||||
.authorSearchCardContent {
|
.authorSearchCardContent {
|
||||||
width: calc(100% - 80px);
|
width: calc(100% - 80px);
|
||||||
height: 40px;
|
height: 44px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
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">
|
<div class="flex items-center h-full px-1 overflow-hidden">
|
||||||
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
<div class="flex-grow px-2 audiobookSearchCardContent">
|
<div class="flex-grow px-2 audiobookSearchCardContent">
|
||||||
<p v-if="matchKey !== 'title'" class="truncate text-sm">{{ title }}</p>
|
<p class="truncate text-sm">{{ title }}</p>
|
||||||
<p v-else class="truncate text-sm" v-html="matchHtml" />
|
<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>
|
||||||
<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" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -21,10 +15,7 @@ export default {
|
||||||
libraryItem: {
|
libraryItem: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => {}
|
default: () => {}
|
||||||
},
|
}
|
||||||
search: String,
|
|
||||||
matchKey: String,
|
|
||||||
matchText: String
|
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
|
|
@ -58,23 +49,6 @@ export default {
|
||||||
authorName() {
|
authorName() {
|
||||||
if (this.isPodcast) return this.mediaMetadata.author || 'Unknown'
|
if (this.isPodcast) return this.mediaMetadata.author || 'Unknown'
|
||||||
return this.mediaMetadata.authorName || '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: {},
|
methods: {},
|
||||||
|
|
|
||||||
|
|
@ -81,16 +81,16 @@ export default {
|
||||||
return this.store.getters['user/getSizeMultiplier']
|
return this.store.getters['user/getSizeMultiplier']
|
||||||
},
|
},
|
||||||
seriesId() {
|
seriesId() {
|
||||||
return this.series ? this.series.id : ''
|
return this.series?.id || ''
|
||||||
},
|
},
|
||||||
title() {
|
title() {
|
||||||
return this.series ? this.series.name : ''
|
return this.series?.name || ''
|
||||||
},
|
},
|
||||||
nameIgnorePrefix() {
|
nameIgnorePrefix() {
|
||||||
return this.series ? this.series.nameIgnorePrefix : ''
|
return this.series?.nameIgnorePrefix || ''
|
||||||
},
|
},
|
||||||
displayTitle() {
|
displayTitle() {
|
||||||
if (this.sortingIgnorePrefix) return this.nameIgnorePrefix || this.title
|
if (this.sortingIgnorePrefix) return this.nameIgnorePrefix || this.title || '\u00A0'
|
||||||
return this.title || '\u00A0'
|
return this.title || '\u00A0'
|
||||||
},
|
},
|
||||||
displaySortLine() {
|
displaySortLine() {
|
||||||
|
|
@ -110,13 +110,13 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
books() {
|
books() {
|
||||||
return this.series ? this.series.books || [] : []
|
return this.series?.books || []
|
||||||
},
|
},
|
||||||
addedAt() {
|
addedAt() {
|
||||||
return this.series ? this.series.addedAt : 0
|
return this.series?.addedAt || 0
|
||||||
},
|
},
|
||||||
totalDuration() {
|
totalDuration() {
|
||||||
return this.series ? this.series.totalDuration : 0
|
return this.series?.totalDuration || 0
|
||||||
},
|
},
|
||||||
seriesBookProgress() {
|
seriesBookProgress() {
|
||||||
return this.books
|
return this.books
|
||||||
|
|
@ -161,7 +161,7 @@ export default {
|
||||||
return this.bookshelfView == constants.BookshelfView.DETAIL
|
return this.bookshelfView == constants.BookshelfView.DETAIL
|
||||||
},
|
},
|
||||||
rssFeed() {
|
rssFeed() {
|
||||||
return this.series ? this.series.rssFeed : null
|
return this.series?.rssFeed
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow px-2 narratorSearchCardContent h-full">
|
<div class="flex-grow px-2 narratorSearchCardContent h-full">
|
||||||
<p class="truncate text-sm">{{ narrator }}</p>
|
<p class="truncate text-sm">{{ narrator }}</p>
|
||||||
|
<p class="text-xs text-gray-400">{{ $getString('LabelXBooks', [numBooks]) }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -12,7 +13,8 @@
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
narrator: String
|
narrator: String,
|
||||||
|
numBooks: Number
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
|
|
@ -26,7 +28,7 @@ export default {
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.narratorSearchCardContent {
|
.narratorSearchCardContent {
|
||||||
width: calc(100% - 40px);
|
width: calc(100% - 40px);
|
||||||
height: 40px;
|
height: 44px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow px-2 tagSearchCardContent h-full">
|
<div class="flex-grow px-2 tagSearchCardContent h-full">
|
||||||
<p class="truncate text-sm">{{ tag }}</p>
|
<p class="truncate text-sm">{{ tag }}</p>
|
||||||
|
<p class="text-xs text-gray-400">{{ $getString('LabelXItems', [numItems]) }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -12,7 +13,8 @@
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
tag: String
|
tag: String,
|
||||||
|
numItems: Number
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
|
|
@ -26,7 +28,7 @@ export default {
|
||||||
<style>
|
<style>
|
||||||
.tagSearchCardContent {
|
.tagSearchCardContent {
|
||||||
width: calc(100% - 40px);
|
width: calc(100% - 40px);
|
||||||
height: 40px;
|
height: 44px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
<template v-for="item in bookResults">
|
<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">
|
<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}`">
|
<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>
|
</nuxt-link>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -34,7 +34,7 @@
|
||||||
<template v-for="item in podcastResults">
|
<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">
|
<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}`">
|
<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>
|
</nuxt-link>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</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>
|
<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">
|
<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)}`">
|
<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>
|
</nuxt-link>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -70,7 +79,7 @@
|
||||||
<template v-for="narrator in narratorResults">
|
<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">
|
<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)}`">
|
<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>
|
</nuxt-link>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -95,6 +104,7 @@ export default {
|
||||||
authorResults: [],
|
authorResults: [],
|
||||||
seriesResults: [],
|
seriesResults: [],
|
||||||
tagResults: [],
|
tagResults: [],
|
||||||
|
genreResults: [],
|
||||||
narratorResults: [],
|
narratorResults: [],
|
||||||
searchTimeout: null,
|
searchTimeout: null,
|
||||||
lastSearch: null
|
lastSearch: null
|
||||||
|
|
@ -105,7 +115,7 @@ export default {
|
||||||
return this.$store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
},
|
},
|
||||||
totalResults() {
|
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: {
|
methods: {
|
||||||
|
|
@ -116,7 +126,7 @@ export default {
|
||||||
if (!this.search) return
|
if (!this.search) return
|
||||||
var search = this.search
|
var search = this.search
|
||||||
this.clearResults()
|
this.clearResults()
|
||||||
this.$router.push(`/library/${this.currentLibraryId}/search?q=${search}`)
|
this.$router.push(`/library/${this.currentLibraryId}/search?q=${encodeURIComponent(search)}`)
|
||||||
},
|
},
|
||||||
clearResults() {
|
clearResults() {
|
||||||
this.search = null
|
this.search = null
|
||||||
|
|
@ -126,6 +136,7 @@ export default {
|
||||||
this.authorResults = []
|
this.authorResults = []
|
||||||
this.seriesResults = []
|
this.seriesResults = []
|
||||||
this.tagResults = []
|
this.tagResults = []
|
||||||
|
this.genreResults = []
|
||||||
this.narratorResults = []
|
this.narratorResults = []
|
||||||
this.showMenu = false
|
this.showMenu = false
|
||||||
this.isFetching = false
|
this.isFetching = false
|
||||||
|
|
@ -155,7 +166,7 @@ export default {
|
||||||
}
|
}
|
||||||
this.isFetching = true
|
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)
|
console.error('Search error', error)
|
||||||
return []
|
return []
|
||||||
})
|
})
|
||||||
|
|
@ -168,6 +179,7 @@ export default {
|
||||||
this.authorResults = searchResults.authors || []
|
this.authorResults = searchResults.authors || []
|
||||||
this.seriesResults = searchResults.series || []
|
this.seriesResults = searchResults.series || []
|
||||||
this.tagResults = searchResults.tags || []
|
this.tagResults = searchResults.tags || []
|
||||||
|
this.genreResults = searchResults.genres || []
|
||||||
this.narratorResults = searchResults.narrators || []
|
this.narratorResults = searchResults.narrators || []
|
||||||
|
|
||||||
this.isFetching = false
|
this.isFetching = false
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,10 @@ export default {
|
||||||
{
|
{
|
||||||
text: this.$strings.LabelFileModified,
|
text: this.$strings.LabelFileModified,
|
||||||
value: 'mtimeMs'
|
value: 'mtimeMs'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelRandomly,
|
||||||
|
value: 'random'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
@ -128,6 +132,10 @@ export default {
|
||||||
{
|
{
|
||||||
text: this.$strings.LabelFileModified,
|
text: this.$strings.LabelFileModified,
|
||||||
value: 'mtimeMs'
|
value: 'mtimeMs'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelRandomly,
|
||||||
|
value: 'random'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
<template>
|
<template>
|
||||||
<modals-modal v-model="show" name="chapters" :width="600" :height="'unset'">
|
<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">
|
<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">
|
<p class="chapter-title truncate text-sm md:text-base">
|
||||||
{{ chap.title }}
|
{{ chap.title }}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,18 @@
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="w-full overflow-y-auto" style="max-height: 50vh">
|
||||||
<template v-for="(feed, index) in feedMetadata">
|
<template v-for="(feed, index) in feeds">
|
||||||
<cards-podcast-feed-summary-card :key="index" :feed="feed" :library-folder-path="selectedFolderPath" class="my-1" />
|
<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>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -45,9 +52,7 @@ export default {
|
||||||
return {
|
return {
|
||||||
processing: false,
|
processing: false,
|
||||||
selectedFolderId: null,
|
selectedFolderId: null,
|
||||||
fullPath: null,
|
autoDownloadEpisodes: false
|
||||||
autoDownloadEpisodes: false,
|
|
||||||
feedMetadata: []
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
|
|
@ -96,73 +101,36 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
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() {
|
init() {
|
||||||
this.feedMetadata = this.feeds.map(this.toFeedMetadata)
|
|
||||||
|
|
||||||
if (this.folderItems[0]) {
|
if (this.folderItems[0]) {
|
||||||
this.selectedFolderId = this.folderItems[0].value
|
this.selectedFolderId = this.folderItems[0].value
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async submit() {
|
async submit() {
|
||||||
this.processing = true
|
this.processing = true
|
||||||
const newFeedPayloads = this.feedMetadata.map((metadata) => {
|
|
||||||
return {
|
const payload = {
|
||||||
path: `${this.selectedFolderPath}/${this.$sanitizeFilename(metadata.title)}`,
|
feeds: this.feeds.map((f) => f.feedUrl),
|
||||||
folderId: this.selectedFolderId,
|
folderId: this.selectedFolderId,
|
||||||
libraryId: this.currentLibrary.id,
|
libraryId: this.currentLibrary.id,
|
||||||
media: {
|
|
||||||
metadata: {
|
|
||||||
...metadata
|
|
||||||
},
|
|
||||||
autoDownloadEpisodes: this.autoDownloadEpisodes
|
autoDownloadEpisodes: this.autoDownloadEpisodes
|
||||||
}
|
}
|
||||||
}
|
this.$axios
|
||||||
})
|
.$post('/api/podcasts/opml/create', payload)
|
||||||
console.log('New feed payloads', newFeedPayloads)
|
|
||||||
|
|
||||||
for (const podcastPayload of newFeedPayloads) {
|
|
||||||
await this.$axios
|
|
||||||
.$post('/api/podcasts', podcastPayload)
|
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.$toast.success(`${podcastPayload.media.metadata.title}: ${this.$strings.ToastPodcastCreateSuccess}`)
|
this.show = false
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
var errorMsg = error.response && error.response.data ? error.response.data : this.$strings.ToastPodcastCreateFailed
|
const errorMsg = error.response?.data || this.$strings.ToastPodcastCreateFailed
|
||||||
console.error('Failed to create podcast', podcastPayload, error)
|
console.error('Failed to create podcast', payload, error)
|
||||||
this.$toast.error(`${podcastPayload.media.metadata.title}: ${errorMsg}`)
|
this.$toast.error(errorMsg)
|
||||||
})
|
})
|
||||||
}
|
.finally(() => {
|
||||||
this.processing = false
|
this.processing = false
|
||||||
this.show = false
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</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.searchedTitle = this.episodeTitle
|
||||||
this.isProcessing = true
|
this.isProcessing = true
|
||||||
this.$axios
|
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) => {
|
.then((results) => {
|
||||||
this.episodesFound = results.episodes.map((ep) => ep.episode)
|
this.episodesFound = results.episodes.map((ep) => ep.episode)
|
||||||
console.log('Episodes found', this.episodesFound)
|
console.log('Episodes found', this.episodesFound)
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<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">
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div class="flex-grow" />
|
<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">
|
<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 />
|
<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">
|
<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" />
|
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -33,7 +34,8 @@ export default {
|
||||||
paddingY: Number,
|
paddingY: Number,
|
||||||
small: Boolean,
|
small: Boolean,
|
||||||
loading: Boolean,
|
loading: Boolean,
|
||||||
disabled: Boolean
|
disabled: Boolean,
|
||||||
|
progress: String
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
<div class="relative h-9 w-9" v-click-outside="clickOutsideObj">
|
<div class="relative h-9 w-9" v-click-outside="clickOutsideObj">
|
||||||
<slot :disabled="disabled" :showMenu="showMenu" :clickShowMenu="clickShowMenu" :processing="processing">
|
<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">
|
<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>
|
</button>
|
||||||
<div v-else class="h-full w-full flex items-center justify-center">
|
<div v-else class="h-full w-full flex items-center justify-center">
|
||||||
<widgets-loading-spinner />
|
<widgets-loading-spinner />
|
||||||
|
|
|
||||||
|
|
@ -92,12 +92,13 @@ export default {
|
||||||
|
|
||||||
if (this.$route.name.startsWith('config')) {
|
if (this.$route.name.startsWith('config')) {
|
||||||
// No need to refresh
|
// 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') {
|
} else if (this.$route.name === 'library-library-series-id' && library.mediaType === 'book') {
|
||||||
// For series item page redirect to root series page
|
// For series item page redirect to root series page
|
||||||
this.$router.push(`/library/${library.id}/bookshelf/series`)
|
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 {
|
} else {
|
||||||
this.$router.push(`/library/${library.id}`)
|
this.$router.push(`/library/${library.id}`)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -212,6 +212,16 @@ export default {
|
||||||
this.libraryItemAdded(ab)
|
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) {
|
taskStarted(task) {
|
||||||
console.log('Task started', task)
|
console.log('Task started', task)
|
||||||
this.$store.commit('tasks/addUpdateTask', task)
|
this.$store.commit('tasks/addUpdateTask', task)
|
||||||
|
|
@ -220,6 +230,9 @@ export default {
|
||||||
console.log('Task finished', task)
|
console.log('Task finished', task)
|
||||||
this.$store.commit('tasks/addUpdateTask', task)
|
this.$store.commit('tasks/addUpdateTask', task)
|
||||||
},
|
},
|
||||||
|
taskProgress(data) {
|
||||||
|
this.$store.commit('tasks/updateTaskProgress', { libraryItemId: data.libraryItemId, progress: `${Math.round(data.progress)}%` })
|
||||||
|
},
|
||||||
metadataEmbedQueueUpdate(data) {
|
metadataEmbedQueueUpdate(data) {
|
||||||
if (data.queued) {
|
if (data.queued) {
|
||||||
this.$store.commit('tasks/addQueuedEmbedLId', data.libraryItemId)
|
this.$store.commit('tasks/addQueuedEmbedLId', data.libraryItemId)
|
||||||
|
|
@ -406,6 +419,10 @@ export default {
|
||||||
this.socket.on('task_started', this.taskStarted)
|
this.socket.on('task_started', this.taskStarted)
|
||||||
this.socket.on('task_finished', this.taskFinished)
|
this.socket.on('task_finished', this.taskFinished)
|
||||||
this.socket.on('metadata_embed_queue_update', this.metadataEmbedQueueUpdate)
|
this.socket.on('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
|
// EReader Device Listeners
|
||||||
this.socket.on('ereader-devices-updated', this.ereaderDevicesUpdated)
|
this.socket.on('ereader-devices-updated', this.ereaderDevicesUpdated)
|
||||||
|
|
|
||||||
|
|
@ -37,8 +37,10 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nuxtjs/pwa": "^3.3.5",
|
"@nuxtjs/pwa": "^3.3.5",
|
||||||
"autoprefixer": "^10.4.7",
|
"autoprefixer": "^10.4.7",
|
||||||
"cypress": "^13.7.3",
|
|
||||||
"postcss": "^8.3.6",
|
"postcss": "^8.3.6",
|
||||||
"tailwindcss": "^3.4.1"
|
"tailwindcss": "^3.4.1"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"cypress": "^13.7.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,8 @@
|
||||||
|
|
||||||
<div class="flex-grow" />
|
<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>
|
<p v-else class="text-success text-lg font-semibold">{{ $strings.MessageEmbedFinished }}</p>
|
||||||
</div>
|
</div>
|
||||||
<!-- m4b embed action buttons -->
|
<!-- m4b embed action buttons -->
|
||||||
|
|
@ -83,7 +84,7 @@
|
||||||
<div class="flex-grow" />
|
<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 && 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-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>
|
<p v-else class="text-success text-lg font-semibold">{{ $strings.MessageM4BFinished }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -159,9 +160,9 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="w-24">
|
<div class="w-24">
|
||||||
<div class="flex justify-center">
|
<div class="flex justify-center">
|
||||||
<span v-if="audiofilesFinished[file.ino]" class="material-symbols text-xl text-success leading-none">check_circle</span>
|
<span v-if="audioFilesFinished[file.ino]" class="material-symbols text-xl text-success leading-none">check_circle</span>
|
||||||
<div v-else-if="audiofilesEncoding[file.ino]">
|
<div v-else-if="audioFilesEncoding[file.ino]">
|
||||||
<widgets-loading-spinner />
|
<span class="font-mono text-success leading-none">{{ audioFilesEncoding[file.ino] }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -205,8 +206,6 @@ export default {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
processing: false,
|
processing: false,
|
||||||
audiofilesEncoding: {},
|
|
||||||
audiofilesFinished: {},
|
|
||||||
metadataObject: null,
|
metadataObject: null,
|
||||||
selectedTool: 'embed',
|
selectedTool: 'embed',
|
||||||
isCancelingEncode: false,
|
isCancelingEncode: false,
|
||||||
|
|
@ -229,6 +228,15 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
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() {
|
isEmbedTool() {
|
||||||
return this.selectedTool === 'embed'
|
return this.selectedTool === 'embed'
|
||||||
},
|
},
|
||||||
|
|
@ -372,15 +380,6 @@ export default {
|
||||||
this.processing = false
|
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() {
|
selectedToolUpdated() {
|
||||||
let newurl = window.location.protocol + '//' + window.location.host + window.location.pathname + `?tool=${this.selectedTool}`
|
let newurl = window.location.protocol + '//' + window.location.host + window.location.pathname + `?tool=${this.selectedTool}`
|
||||||
window.history.replaceState({ path: newurl }, '', newurl)
|
window.history.replaceState({ path: newurl }, '', newurl)
|
||||||
|
|
@ -416,12 +415,6 @@ export default {
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.init()
|
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>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -170,7 +170,7 @@ export default {
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
updateBackupsSettings() {
|
updateBackupsSettings() {
|
||||||
if (isNaN(this.maxBackupSize) || this.maxBackupSize <= 0) {
|
if (isNaN(this.maxBackupSize) || this.maxBackupSize < 0) {
|
||||||
this.$toast.error('Invalid maximum backup size')
|
this.$toast.error('Invalid maximum backup size')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -200,10 +200,9 @@ export default {
|
||||||
},
|
},
|
||||||
initServerSettings() {
|
initServerSettings() {
|
||||||
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
||||||
|
|
||||||
this.backupsToKeep = this.newServerSettings.backupsToKeep || 2
|
this.backupsToKeep = this.newServerSettings.backupsToKeep || 2
|
||||||
this.enableBackups = !!this.newServerSettings.backupSchedule
|
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 * * *'
|
this.cronExpression = this.newServerSettings.backupSchedule || '30 1 * * *'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<div class="px-3">
|
<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>
|
<p class="text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsItemsFinished }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
<span class="hidden sm:block material-symbols-outlined text-5xl lg:text-6xl">event</span>
|
<span class="hidden sm:block material-symbols-outlined text-5xl lg:text-6xl">event</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-1">
|
<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>
|
<p class="text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsDaysListened }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -33,7 +33,7 @@
|
||||||
<span class="material-symbols-outlined text-5xl lg:text-6xl">watch_later</span>
|
<span class="material-symbols-outlined text-5xl lg:text-6xl">watch_later</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-1">
|
<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>
|
<p class="text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsMinutesListening }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -121,7 +121,7 @@
|
||||||
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="148" @action="contextMenuAction">
|
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="148" @action="contextMenuAction">
|
||||||
<template #default="{ showMenu, clickShowMenu, disabled }">
|
<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">
|
<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>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</ui-context-menu-dropdown>
|
</ui-context-menu-dropdown>
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ export default {
|
||||||
if (typeof a[sortProp] === 'number' && typeof b[sortProp] === 'number') {
|
if (typeof a[sortProp] === 'number' && typeof b[sortProp] === 'number') {
|
||||||
return a[sortProp] > b[sortProp] ? bDesc : -bDesc
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.$axios
|
this.$axios
|
||||||
.$post(`/api/podcasts/opml`, { opmlText: txt })
|
.$post(`/api/podcasts/opml/parse`, { opmlText: txt })
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
console.log(data)
|
if (!data.feeds?.length) {
|
||||||
|
this.$toast.error('No feeds found in OPML file')
|
||||||
|
} else {
|
||||||
this.opmlFeeds = data.feeds || []
|
this.opmlFeeds = data.feeds || []
|
||||||
this.showOPMLFeedsModal = true
|
this.showOPMLFeedsModal = true
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
this.$toast.error('Failed to parse OPML file')
|
this.$toast.error('Failed to parse OPML file')
|
||||||
})
|
})
|
||||||
|
.finally(() => {
|
||||||
this.processing = false
|
this.processing = false
|
||||||
|
})
|
||||||
},
|
},
|
||||||
submit() {
|
submit() {
|
||||||
if (!this.searchInput) return
|
if (!this.searchInput) return
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ export default {
|
||||||
if (!library) {
|
if (!library) {
|
||||||
return redirect('/oops?message=Library not found')
|
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)
|
console.error('Failed to search library', error)
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
|
|
@ -55,7 +55,7 @@ export default {
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async search() {
|
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)
|
console.error('Failed to search library', error)
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -36,10 +36,10 @@ export default class PlayerHandler {
|
||||||
return this.libraryItem ? this.libraryItem.id : null
|
return this.libraryItem ? this.libraryItem.id : null
|
||||||
}
|
}
|
||||||
get isPlayingCastedItem() {
|
get isPlayingCastedItem() {
|
||||||
return this.libraryItem && (this.player instanceof CastPlayer)
|
return this.libraryItem && this.player instanceof CastPlayer
|
||||||
}
|
}
|
||||||
get isPlayingLocalItem() {
|
get isPlayingLocalItem() {
|
||||||
return this.libraryItem && (this.player instanceof LocalAudioPlayer)
|
return this.libraryItem && this.player instanceof LocalAudioPlayer
|
||||||
}
|
}
|
||||||
get userToken() {
|
get userToken() {
|
||||||
return this.ctx.$store.getters['user/getToken']
|
return this.ctx.$store.getters['user/getToken']
|
||||||
|
|
@ -49,7 +49,7 @@ export default class PlayerHandler {
|
||||||
}
|
}
|
||||||
get episode() {
|
get episode() {
|
||||||
if (!this.episodeId) return null
|
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() {
|
get jumpForwardAmount() {
|
||||||
return this.ctx.$store.getters['user/getUserSetting']('jumpForwardAmount')
|
return this.ctx.$store.getters['user/getUserSetting']('jumpForwardAmount')
|
||||||
|
|
@ -72,7 +72,7 @@ export default class PlayerHandler {
|
||||||
this.playWhenReady = playWhenReady
|
this.playWhenReady = playWhenReady
|
||||||
this.initialPlaybackRate = this.isMusic ? 1 : playbackRate
|
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)
|
if (!this.player) this.switchPlayer(playWhenReady)
|
||||||
else this.prepare()
|
else this.prepare()
|
||||||
|
|
@ -133,7 +133,7 @@ export default class PlayerHandler {
|
||||||
|
|
||||||
playerError() {
|
playerError() {
|
||||||
// Switch to HLS stream on error
|
// 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`)
|
console.log(`[PlayerHandler] Audio player error switching to HLS stream`)
|
||||||
this.prepare(true)
|
this.prepare(true)
|
||||||
}
|
}
|
||||||
|
|
@ -213,7 +213,8 @@ export default class PlayerHandler {
|
||||||
this.prepareSession(session)
|
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
|
if (!this.player) this.switchPlayer() // Must set player first for open sessions
|
||||||
|
|
||||||
this.libraryItem = session.libraryItem
|
this.libraryItem = session.libraryItem
|
||||||
|
|
@ -247,7 +248,7 @@ export default class PlayerHandler {
|
||||||
|
|
||||||
this.player.set(this.libraryItem, videoTrack, this.isHlsTranscode, this.startTime, this.playWhenReady)
|
this.player.set(this.libraryItem, videoTrack, this.isHlsTranscode, this.startTime, this.playWhenReady)
|
||||||
} else {
|
} 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.ctx.playerLoading = true
|
||||||
this.isHlsTranscode = true
|
this.isHlsTranscode = true
|
||||||
|
|
@ -301,7 +302,7 @@ export default class PlayerHandler {
|
||||||
const currentTime = this.player.getCurrentTime()
|
const currentTime = this.player.getCurrentTime()
|
||||||
this.ctx.setCurrentTime(currentTime)
|
this.ctx.setCurrentTime(currentTime)
|
||||||
|
|
||||||
const exactTimeElapsed = ((Date.now() - lastTick) / 1000)
|
const exactTimeElapsed = (Date.now() - lastTick) / 1000
|
||||||
lastTick = Date.now()
|
lastTick = Date.now()
|
||||||
this.listeningTimeSinceSync += exactTimeElapsed
|
this.listeningTimeSinceSync += exactTimeElapsed
|
||||||
const TimeToWaitBeforeSync = this.lastSyncTime > 0 ? 10 : 20
|
const TimeToWaitBeforeSync = this.lastSyncTime > 0 ? 10 : 20
|
||||||
|
|
@ -326,7 +327,7 @@ export default class PlayerHandler {
|
||||||
}
|
}
|
||||||
this.listeningTimeSinceSync = 0
|
this.listeningTimeSinceSync = 0
|
||||||
this.lastSyncTime = 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)
|
console.error('Failed to close session', error)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -346,9 +347,12 @@ export default class PlayerHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.listeningTimeSinceSync = 0
|
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
|
this.failedProgressSyncs = 0
|
||||||
}).catch((error) => {
|
})
|
||||||
|
.catch((error) => {
|
||||||
console.error('Failed to update session progress', error)
|
console.error('Failed to update session progress', error)
|
||||||
// After 4 failed sync attempts show an alert toast
|
// After 4 failed sync attempts show an alert toast
|
||||||
this.failedProgressSyncs++
|
this.failedProgressSyncs++
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import * as locale from 'date-fns/locale'
|
||||||
|
|
||||||
Vue.directive('click-outside', vClickOutside.directive)
|
Vue.directive('click-outside', vClickOutside.directive)
|
||||||
|
|
||||||
|
|
||||||
Vue.prototype.$setDateFnsLocale = (localeString) => {
|
Vue.prototype.$setDateFnsLocale = (localeString) => {
|
||||||
if (!locale[localeString]) return 0
|
if (!locale[localeString]) return 0
|
||||||
return setDefaultOptions({ locale: locale[localeString] })
|
return setDefaultOptions({ locale: locale[localeString] })
|
||||||
|
|
@ -112,14 +111,15 @@ Vue.prototype.$sanitizeSlug = (str) => {
|
||||||
str = str.toLowerCase()
|
str = str.toLowerCase()
|
||||||
|
|
||||||
// remove accents, swap ñ for n, etc
|
// remove accents, swap ñ for n, etc
|
||||||
var from = "àáäâèéëêìíïîòóöôùúüûñçěščřžýúůďťň·/,:;"
|
var from = 'àáäâèéëêìíïîòóöôùúüûñçěščřžýúůďťň·/,:;'
|
||||||
var to = "aaaaeeeeiiiioooouuuuncescrzyuudtn-----"
|
var to = 'aaaaeeeeiiiioooouuuuncescrzyuudtn-----'
|
||||||
|
|
||||||
for (var i = 0, l = from.length; i < l; i++) {
|
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(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(/[^a-z0-9 -_]/g, '') // remove invalid chars
|
||||||
.replace(/\s+/g, '-') // collapse whitespace and replace by a dash
|
.replace(/\s+/g, '-') // collapse whitespace and replace by a dash
|
||||||
.replace(/-+/g, '-') // collapse dashes
|
.replace(/-+/g, '-') // collapse dashes
|
||||||
|
|
@ -131,13 +131,16 @@ Vue.prototype.$sanitizeSlug = (str) => {
|
||||||
Vue.prototype.$copyToClipboard = (str, ctx) => {
|
Vue.prototype.$copyToClipboard = (str, ctx) => {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
if (navigator.clipboard) {
|
if (navigator.clipboard) {
|
||||||
navigator.clipboard.writeText(str).then(() => {
|
navigator.clipboard.writeText(str).then(
|
||||||
|
() => {
|
||||||
if (ctx) ctx.$toast.success('Copied to clipboard')
|
if (ctx) ctx.$toast.success('Copied to clipboard')
|
||||||
resolve(true)
|
resolve(true)
|
||||||
}, (err) => {
|
},
|
||||||
|
(err) => {
|
||||||
console.error('Clipboard copy failed', str, err)
|
console.error('Clipboard copy failed', str, err)
|
||||||
resolve(false)
|
resolve(false)
|
||||||
})
|
}
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
const el = document.createElement('textarea')
|
const el = document.createElement('textarea')
|
||||||
el.value = str
|
el.value = str
|
||||||
|
|
@ -160,26 +163,18 @@ function xmlToJson(xml) {
|
||||||
for (const res of xml.matchAll(/(?:<(\w*)(?:\s[^>]*)*>)((?:(?!<\1).)*)(?:<\/\1>)|<(\w*)(?:\s*)*\/>/gm)) {
|
for (const res of xml.matchAll(/(?:<(\w*)(?:\s[^>]*)*>)((?:(?!<\1).)*)(?:<\/\1>)|<(\w*)(?:\s*)*\/>/gm)) {
|
||||||
const key = res[1] || res[3]
|
const key = res[1] || res[3]
|
||||||
const value = res[2] && xmlToJson(res[2])
|
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
|
return json
|
||||||
}
|
}
|
||||||
Vue.prototype.$xmlToJson = xmlToJson
|
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'))
|
const encode = (text) => encodeURIComponent(Buffer.from(text).toString('base64'))
|
||||||
Vue.prototype.$encode = encode
|
Vue.prototype.$encode = encode
|
||||||
const decode = (text) => Buffer.from(decodeURIComponent(text), 'base64').toString()
|
const decode = (text) => Buffer.from(decodeURIComponent(text), 'base64').toString()
|
||||||
Vue.prototype.$decode = decode
|
Vue.prototype.$decode = decode
|
||||||
|
|
||||||
export {
|
export { encode, decode }
|
||||||
encode,
|
|
||||||
decode
|
|
||||||
}
|
|
||||||
export default ({ app, store }, inject) => {
|
export default ({ app, store }, inject) => {
|
||||||
app.$decode = decode
|
app.$decode = decode
|
||||||
app.$encode = encode
|
app.$encode = encode
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,60 @@
|
||||||
|
import Vue from 'vue'
|
||||||
|
|
||||||
export const state = () => ({
|
export const state = () => ({
|
||||||
tasks: [],
|
tasks: [],
|
||||||
queuedEmbedLIds: []
|
queuedEmbedLIds: [],
|
||||||
|
audioFilesEncoding: {},
|
||||||
|
audioFilesFinished: {},
|
||||||
|
taskProgress: {}
|
||||||
})
|
})
|
||||||
|
|
||||||
export const getters = {
|
export const getters = {
|
||||||
getTasksByLibraryItemId: (state) => (libraryItemId) => {
|
getTasksByLibraryItemId: (state) => (libraryItemId) => {
|
||||||
return state.tasks.filter(t => t.data?.libraryItemId === libraryItemId)
|
return state.tasks.filter((t) => t.data?.libraryItemId === libraryItemId)
|
||||||
},
|
},
|
||||||
getRunningLibraryScanTask: (state) => (libraryId) => {
|
getRunningLibraryScanTask: (state) => (libraryId) => {
|
||||||
const libraryScanActions = ['library-scan', 'library-match-all']
|
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 = {
|
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) {
|
setTasks(state, tasks) {
|
||||||
state.tasks = tasks
|
state.tasks = tasks
|
||||||
},
|
},
|
||||||
addUpdateTask(state, task) {
|
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) {
|
if (index >= 0) {
|
||||||
state.tasks.splice(index, 1, task)
|
state.tasks.splice(index, 1, task)
|
||||||
} else {
|
} else {
|
||||||
// Remove duplicate (only have one library item per action)
|
// 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
|
if (!_task.data?.libraryItemId || _task.action !== task.action) return true
|
||||||
return _task.data.libraryItemId !== task.data.libraryItemId
|
return _task.data.libraryItemId !== task.data.libraryItemId
|
||||||
})
|
})
|
||||||
|
|
@ -37,17 +63,17 @@ export const mutations = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
removeTask(state, task) {
|
removeTask(state, task) {
|
||||||
state.tasks = state.tasks.filter(d => d.id !== task.id)
|
state.tasks = state.tasks.filter((d) => d.id !== task.id)
|
||||||
},
|
},
|
||||||
setQueuedEmbedLIds(state, libraryItemIds) {
|
setQueuedEmbedLIds(state, libraryItemIds) {
|
||||||
state.queuedEmbedLIds = libraryItemIds
|
state.queuedEmbedLIds = libraryItemIds
|
||||||
},
|
},
|
||||||
addQueuedEmbedLId(state, libraryItemId) {
|
addQueuedEmbedLId(state, libraryItemId) {
|
||||||
if (!state.queuedEmbedLIds.some(lid => lid === libraryItemId)) {
|
if (!state.queuedEmbedLIds.some((lid) => lid === libraryItemId)) {
|
||||||
state.queuedEmbedLIds.push(libraryItemId)
|
state.queuedEmbedLIds.push(libraryItemId)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
removeQueuedEmbedLId(state, 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",
|
"ButtonPurgeItemsCache": "Lösche Medien-Cache",
|
||||||
"ButtonQueueAddItem": "Zur Warteschlange hinzufügen",
|
"ButtonQueueAddItem": "Zur Warteschlange hinzufügen",
|
||||||
"ButtonQueueRemoveItem": "Aus der Warteschlange entfernen",
|
"ButtonQueueRemoveItem": "Aus der Warteschlange entfernen",
|
||||||
|
"ButtonQuickEmbedMetadata": "Schnelles Hinzufügen von Metadaten",
|
||||||
"ButtonQuickMatch": "Schnellabgleich",
|
"ButtonQuickMatch": "Schnellabgleich",
|
||||||
"ButtonReScan": "Neu scannen",
|
"ButtonReScan": "Neu scannen",
|
||||||
"ButtonRead": "Lesen",
|
"ButtonRead": "Lesen",
|
||||||
|
|
@ -66,11 +67,11 @@
|
||||||
"ButtonReadMore": "Mehr anzeigen",
|
"ButtonReadMore": "Mehr anzeigen",
|
||||||
"ButtonRefresh": "Neu Laden",
|
"ButtonRefresh": "Neu Laden",
|
||||||
"ButtonRemove": "Entfernen",
|
"ButtonRemove": "Entfernen",
|
||||||
"ButtonRemoveAll": "Alles löschen",
|
"ButtonRemoveAll": "Alles entfernen",
|
||||||
"ButtonRemoveAllLibraryItems": "Lösche alle Bibliothekseinträge",
|
"ButtonRemoveAllLibraryItems": "Entferne alle Bibliothekseinträge",
|
||||||
"ButtonRemoveFromContinueListening": "Lösche den Eintrag aus der Fortsetzungsliste",
|
"ButtonRemoveFromContinueListening": "Entferne den Eintrag aus der Fortsetzungsliste",
|
||||||
"ButtonRemoveFromContinueReading": "Lösche die Serie aus der Lesefortsetzungsliste",
|
"ButtonRemoveFromContinueReading": "Entferne die Serie aus der Lesefortsetzungsliste",
|
||||||
"ButtonRemoveSeriesFromContinueSeries": "Lösche die Serie aus der Serienfortsetzungsliste",
|
"ButtonRemoveSeriesFromContinueSeries": "Entferne die Serie aus der Serienfortsetzungsliste",
|
||||||
"ButtonReset": "Zurücksetzen",
|
"ButtonReset": "Zurücksetzen",
|
||||||
"ButtonResetToDefault": "Zurücksetzen auf Standard",
|
"ButtonResetToDefault": "Zurücksetzen auf Standard",
|
||||||
"ButtonRestore": "Wiederherstellen",
|
"ButtonRestore": "Wiederherstellen",
|
||||||
|
|
@ -88,6 +89,7 @@
|
||||||
"ButtonShow": "Anzeigen",
|
"ButtonShow": "Anzeigen",
|
||||||
"ButtonStartM4BEncode": "M4B-Kodierung starten",
|
"ButtonStartM4BEncode": "M4B-Kodierung starten",
|
||||||
"ButtonStartMetadataEmbed": "Metadateneinbettung starten",
|
"ButtonStartMetadataEmbed": "Metadateneinbettung starten",
|
||||||
|
"ButtonStats": "Statistiken",
|
||||||
"ButtonSubmit": "Ok",
|
"ButtonSubmit": "Ok",
|
||||||
"ButtonTest": "Test",
|
"ButtonTest": "Test",
|
||||||
"ButtonUpload": "Hochladen",
|
"ButtonUpload": "Hochladen",
|
||||||
|
|
@ -154,6 +156,7 @@
|
||||||
"HeaderPasswordAuthentication": "Passwort Authentifizierung",
|
"HeaderPasswordAuthentication": "Passwort Authentifizierung",
|
||||||
"HeaderPermissions": "Berechtigungen",
|
"HeaderPermissions": "Berechtigungen",
|
||||||
"HeaderPlayerQueue": "Player Warteschlange",
|
"HeaderPlayerQueue": "Player Warteschlange",
|
||||||
|
"HeaderPlayerSettings": "Player Einstellungen",
|
||||||
"HeaderPlaylist": "Wiedergabeliste",
|
"HeaderPlaylist": "Wiedergabeliste",
|
||||||
"HeaderPlaylistItems": "Einträge in der Wiedergabeliste",
|
"HeaderPlaylistItems": "Einträge in der Wiedergabeliste",
|
||||||
"HeaderPodcastsToAdd": "Podcasts zum Hinzufügen",
|
"HeaderPodcastsToAdd": "Podcasts zum Hinzufügen",
|
||||||
|
|
@ -161,8 +164,8 @@
|
||||||
"HeaderRSSFeedGeneral": "RSS Details",
|
"HeaderRSSFeedGeneral": "RSS Details",
|
||||||
"HeaderRSSFeedIsOpen": "RSS-Feed ist geöffnet",
|
"HeaderRSSFeedIsOpen": "RSS-Feed ist geöffnet",
|
||||||
"HeaderRSSFeeds": "RSS-Feeds",
|
"HeaderRSSFeeds": "RSS-Feeds",
|
||||||
"HeaderRemoveEpisode": "Episode löschen",
|
"HeaderRemoveEpisode": "Episode entfernen",
|
||||||
"HeaderRemoveEpisodes": "Lösche {0} Episoden",
|
"HeaderRemoveEpisodes": "Entferne {0} Episoden",
|
||||||
"HeaderSavedMediaProgress": "Gespeicherte Hörfortschritte",
|
"HeaderSavedMediaProgress": "Gespeicherte Hörfortschritte",
|
||||||
"HeaderSchedule": "Zeitplan",
|
"HeaderSchedule": "Zeitplan",
|
||||||
"HeaderScheduleLibraryScans": "Automatische Bibliotheksscans",
|
"HeaderScheduleLibraryScans": "Automatische Bibliotheksscans",
|
||||||
|
|
@ -259,7 +262,7 @@
|
||||||
"LabelCustomCronExpression": "Benutzerdefinierter Cron-Ausdruck:",
|
"LabelCustomCronExpression": "Benutzerdefinierter Cron-Ausdruck:",
|
||||||
"LabelDatetime": "Datum & Uhrzeit",
|
"LabelDatetime": "Datum & Uhrzeit",
|
||||||
"LabelDays": "Tage",
|
"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",
|
"LabelDescription": "Beschreibung",
|
||||||
"LabelDeselectAll": "Alles abwählen",
|
"LabelDeselectAll": "Alles abwählen",
|
||||||
"LabelDevice": "Gerät",
|
"LabelDevice": "Gerät",
|
||||||
|
|
@ -289,13 +292,16 @@
|
||||||
"LabelEmbeddedCover": "Eingebettetes Cover",
|
"LabelEmbeddedCover": "Eingebettetes Cover",
|
||||||
"LabelEnable": "Aktivieren",
|
"LabelEnable": "Aktivieren",
|
||||||
"LabelEnd": "Ende",
|
"LabelEnd": "Ende",
|
||||||
|
"LabelEndOfChapter": "Ende des Kapitels",
|
||||||
"LabelEpisode": "Episode",
|
"LabelEpisode": "Episode",
|
||||||
"LabelEpisodeTitle": "Episodentitel",
|
"LabelEpisodeTitle": "Episodentitel",
|
||||||
"LabelEpisodeType": "Episodentyp",
|
"LabelEpisodeType": "Episodentyp",
|
||||||
"LabelExample": "Beispiel",
|
"LabelExample": "Beispiel",
|
||||||
|
"LabelExpandSeries": "Serie erweitern",
|
||||||
"LabelExplicit": "Explizit (Altersbeschränkung)",
|
"LabelExplicit": "Explizit (Altersbeschränkung)",
|
||||||
"LabelExplicitChecked": "Explicit (Altersbeschränkung) (angehakt)",
|
"LabelExplicitChecked": "Explicit (Altersbeschränkung) (angehakt)",
|
||||||
"LabelExplicitUnchecked": "Not Explicit (Altersbeschränkung) (nicht angehakt)",
|
"LabelExplicitUnchecked": "Not Explicit (Altersbeschränkung) (nicht angehakt)",
|
||||||
|
"LabelExportOPML": "OPML exportieren",
|
||||||
"LabelFeedURL": "Feed URL",
|
"LabelFeedURL": "Feed URL",
|
||||||
"LabelFetchingMetadata": "Abholen der Metadaten",
|
"LabelFetchingMetadata": "Abholen der Metadaten",
|
||||||
"LabelFile": "Datei",
|
"LabelFile": "Datei",
|
||||||
|
|
@ -319,6 +325,7 @@
|
||||||
"LabelHardDeleteFile": "Datei dauerhaft löschen",
|
"LabelHardDeleteFile": "Datei dauerhaft löschen",
|
||||||
"LabelHasEbook": "E-Book verfügbar",
|
"LabelHasEbook": "E-Book verfügbar",
|
||||||
"LabelHasSupplementaryEbook": "Ergänzendes E-Book verfügbar",
|
"LabelHasSupplementaryEbook": "Ergänzendes E-Book verfügbar",
|
||||||
|
"LabelHideSubtitles": "Untertitel ausblenden",
|
||||||
"LabelHighestPriority": "Höchste Priorität",
|
"LabelHighestPriority": "Höchste Priorität",
|
||||||
"LabelHost": "Host",
|
"LabelHost": "Host",
|
||||||
"LabelHour": "Stunde",
|
"LabelHour": "Stunde",
|
||||||
|
|
@ -339,6 +346,8 @@
|
||||||
"LabelIntervalEveryHour": "Jede Stunde",
|
"LabelIntervalEveryHour": "Jede Stunde",
|
||||||
"LabelInvert": "Umkehren",
|
"LabelInvert": "Umkehren",
|
||||||
"LabelItem": "Medium",
|
"LabelItem": "Medium",
|
||||||
|
"LabelJumpBackwardAmount": "Zurückspringen Zeit",
|
||||||
|
"LabelJumpForwardAmount": "Vorwärtsspringn Zeit",
|
||||||
"LabelLanguage": "Sprache",
|
"LabelLanguage": "Sprache",
|
||||||
"LabelLanguageDefaultServer": "Standard-Server-Sprache",
|
"LabelLanguageDefaultServer": "Standard-Server-Sprache",
|
||||||
"LabelLanguages": "Sprachen",
|
"LabelLanguages": "Sprachen",
|
||||||
|
|
@ -446,6 +455,7 @@
|
||||||
"LabelRSSFeedPreventIndexing": "Indizierung verhindern",
|
"LabelRSSFeedPreventIndexing": "Indizierung verhindern",
|
||||||
"LabelRSSFeedSlug": "RSS-Feed-Schlagwort",
|
"LabelRSSFeedSlug": "RSS-Feed-Schlagwort",
|
||||||
"LabelRSSFeedURL": "RSS Feed URL",
|
"LabelRSSFeedURL": "RSS Feed URL",
|
||||||
|
"LabelReAddSeriesToContinueListening": "Serien erneut zur Fortsetzungsliste hinzufügen",
|
||||||
"LabelRead": "Lesen",
|
"LabelRead": "Lesen",
|
||||||
"LabelReadAgain": "Noch einmal Lesen",
|
"LabelReadAgain": "Noch einmal Lesen",
|
||||||
"LabelReadEbookWithoutProgress": "E-Book lesen und Fortschritt verwerfen",
|
"LabelReadEbookWithoutProgress": "E-Book lesen und Fortschritt verwerfen",
|
||||||
|
|
@ -455,7 +465,7 @@
|
||||||
"LabelRedo": "Wiederholen",
|
"LabelRedo": "Wiederholen",
|
||||||
"LabelRegion": "Region",
|
"LabelRegion": "Region",
|
||||||
"LabelReleaseDate": "Veröffentlichungsdatum",
|
"LabelReleaseDate": "Veröffentlichungsdatum",
|
||||||
"LabelRemoveCover": "Lösche Titelbild",
|
"LabelRemoveCover": "Entferne Titelbild",
|
||||||
"LabelRowsPerPage": "Zeilen pro Seite",
|
"LabelRowsPerPage": "Zeilen pro Seite",
|
||||||
"LabelSearchTerm": "Begriff suchen",
|
"LabelSearchTerm": "Begriff suchen",
|
||||||
"LabelSearchTitle": "Titel 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",
|
"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",
|
"LabelSettingsTimeFormat": "Zeitformat",
|
||||||
"LabelShare": "Teilen",
|
"LabelShare": "Teilen",
|
||||||
"LabelShareOpen": "Teilen Offen",
|
"LabelShareOpen": "Teilen öffnen",
|
||||||
"LabelShareURL": "URL teilen",
|
"LabelShareURL": "URL teilen",
|
||||||
"LabelShowAll": "Alles anzeigen",
|
"LabelShowAll": "Alles anzeigen",
|
||||||
"LabelShowSeconds": "Zeige Sekunden",
|
"LabelShowSeconds": "Zeige Sekunden",
|
||||||
|
"LabelShowSubtitles": "Untertitel anzeigen",
|
||||||
"LabelSize": "Größe",
|
"LabelSize": "Größe",
|
||||||
"LabelSleepTimer": "Schlummerfunktion",
|
"LabelSleepTimer": "Schlummerfunktion",
|
||||||
"LabelSlug": "URL Teil",
|
"LabelSlug": "URL Teil",
|
||||||
|
|
@ -553,6 +564,10 @@
|
||||||
"LabelThemeDark": "Dunkel",
|
"LabelThemeDark": "Dunkel",
|
||||||
"LabelThemeLight": "Hell",
|
"LabelThemeLight": "Hell",
|
||||||
"LabelTimeBase": "Basiszeit",
|
"LabelTimeBase": "Basiszeit",
|
||||||
|
"LabelTimeDurationXHours": "{0} Stunden",
|
||||||
|
"LabelTimeDurationXMinutes": "{0} Minuten",
|
||||||
|
"LabelTimeDurationXSeconds": "{0} Sekunden",
|
||||||
|
"LabelTimeInMinutes": "Zeit in Minuten",
|
||||||
"LabelTimeListened": "Gehörte Zeit",
|
"LabelTimeListened": "Gehörte Zeit",
|
||||||
"LabelTimeListenedToday": "Heute gehörte Zeit",
|
"LabelTimeListenedToday": "Heute gehörte Zeit",
|
||||||
"LabelTimeRemaining": "{0} verbleibend",
|
"LabelTimeRemaining": "{0} verbleibend",
|
||||||
|
|
@ -592,6 +607,7 @@
|
||||||
"LabelVersion": "Version",
|
"LabelVersion": "Version",
|
||||||
"LabelViewBookmarks": "Lesezeichen anzeigen",
|
"LabelViewBookmarks": "Lesezeichen anzeigen",
|
||||||
"LabelViewChapters": "Kapitel anzeigen",
|
"LabelViewChapters": "Kapitel anzeigen",
|
||||||
|
"LabelViewPlayerSettings": "Zeige player Einstellungen",
|
||||||
"LabelViewQueue": "Player-Warteschlange anzeigen",
|
"LabelViewQueue": "Player-Warteschlange anzeigen",
|
||||||
"LabelVolume": "Lautstärke",
|
"LabelVolume": "Lautstärke",
|
||||||
"LabelWeekdaysToRun": "Wochentage für die Ausführung",
|
"LabelWeekdaysToRun": "Wochentage für die Ausführung",
|
||||||
|
|
@ -637,11 +653,11 @@
|
||||||
"MessageConfirmReScanLibraryItems": "{0} Elemente werden erneut gescannt! Bist du dir sicher?",
|
"MessageConfirmReScanLibraryItems": "{0} Elemente werden erneut gescannt! Bist du dir sicher?",
|
||||||
"MessageConfirmRemoveAllChapters": "Alle Kapitel werden entfernt! Bist du dir sicher?",
|
"MessageConfirmRemoveAllChapters": "Alle Kapitel werden entfernt! Bist du dir sicher?",
|
||||||
"MessageConfirmRemoveAuthor": "Autor \"{0}\" wird enfernt! Bist du dir sicher?",
|
"MessageConfirmRemoveAuthor": "Autor \"{0}\" wird enfernt! Bist du dir sicher?",
|
||||||
"MessageConfirmRemoveCollection": "Sammlung \"{0}\" wird gelöscht! Bist du dir sicher?",
|
"MessageConfirmRemoveCollection": "Sammlung \"{0}\" wird entfernt! Bist du dir sicher?",
|
||||||
"MessageConfirmRemoveEpisode": "Episode \"{0}\" wird geloscht! Bist du dir sicher?",
|
"MessageConfirmRemoveEpisode": "Episode \"{0}\" wird entfernt! Bist du dir sicher?",
|
||||||
"MessageConfirmRemoveEpisodes": "{0} Episoden werden gelöscht! 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?",
|
"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?",
|
"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?",
|
"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.",
|
"MessageConfirmRenameGenreMergeNote": "Hinweis: Kategorie existiert bereits -> Kategorien werden zusammengelegt.",
|
||||||
|
|
@ -712,9 +728,9 @@
|
||||||
"MessagePlaylistCreateFromCollection": "Erstelle eine Wiedergabeliste aus der Sammlung",
|
"MessagePlaylistCreateFromCollection": "Erstelle eine Wiedergabeliste aus der Sammlung",
|
||||||
"MessagePodcastHasNoRSSFeedForMatching": "Der Podcast hat keine RSS-Feed-Url welche für den Online-Abgleich verwendet werden kann",
|
"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.",
|
"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)",
|
"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?",
|
"MessageRemoveUserWarning": "Benutzer \"{0}\" wird dauerhaft gelöscht! Bist du dir sicher?",
|
||||||
"MessageReportBugsAndContribute": "Fehler melden, Funktionen anfordern und mitwirken",
|
"MessageReportBugsAndContribute": "Fehler melden, Funktionen anfordern und mitwirken",
|
||||||
"MessageResetChaptersConfirm": "Kapitel und vorgenommenen Änderungen werden zurückgesetzt und rückgängig gemacht! Bist du dir sicher?",
|
"MessageResetChaptersConfirm": "Kapitel und vorgenommenen Änderungen werden zurückgesetzt und rückgängig gemacht! Bist du dir sicher?",
|
||||||
|
|
@ -769,8 +785,8 @@
|
||||||
"ToastBatchUpdateSuccess": "Stapelaktualisierung erfolgreich",
|
"ToastBatchUpdateSuccess": "Stapelaktualisierung erfolgreich",
|
||||||
"ToastBookmarkCreateFailed": "Lesezeichen konnte nicht erstellt werden",
|
"ToastBookmarkCreateFailed": "Lesezeichen konnte nicht erstellt werden",
|
||||||
"ToastBookmarkCreateSuccess": "Lesezeichen hinzugefügt",
|
"ToastBookmarkCreateSuccess": "Lesezeichen hinzugefügt",
|
||||||
"ToastBookmarkRemoveFailed": "Lesezeichen konnte nicht gelöscht werden",
|
"ToastBookmarkRemoveFailed": "Lesezeichen konnte nicht entfernt werden",
|
||||||
"ToastBookmarkRemoveSuccess": "Lesezeichen gelöscht",
|
"ToastBookmarkRemoveSuccess": "Lesezeichen entfernt",
|
||||||
"ToastBookmarkUpdateFailed": "Lesezeichenaktualisierung fehlgeschlagen",
|
"ToastBookmarkUpdateFailed": "Lesezeichenaktualisierung fehlgeschlagen",
|
||||||
"ToastBookmarkUpdateSuccess": "Lesezeichen aktualisiert",
|
"ToastBookmarkUpdateSuccess": "Lesezeichen aktualisiert",
|
||||||
"ToastCachePurgeFailed": "Cache leeren fehlgeschlagen",
|
"ToastCachePurgeFailed": "Cache leeren fehlgeschlagen",
|
||||||
|
|
@ -780,7 +796,7 @@
|
||||||
"ToastCollectionItemsRemoveFailed": "Fehler beim Entfernen der Medien aus der Sammlung",
|
"ToastCollectionItemsRemoveFailed": "Fehler beim Entfernen der Medien aus der Sammlung",
|
||||||
"ToastCollectionItemsRemoveSuccess": "Medien aus der Sammlung entfernt",
|
"ToastCollectionItemsRemoveSuccess": "Medien aus der Sammlung entfernt",
|
||||||
"ToastCollectionRemoveFailed": "Sammlung konnte nicht entfernt werden",
|
"ToastCollectionRemoveFailed": "Sammlung konnte nicht entfernt werden",
|
||||||
"ToastCollectionRemoveSuccess": "Sammlung gelöscht",
|
"ToastCollectionRemoveSuccess": "Sammlung entfernt",
|
||||||
"ToastCollectionUpdateFailed": "Sammlung konnte nicht aktualisiert werden",
|
"ToastCollectionUpdateFailed": "Sammlung konnte nicht aktualisiert werden",
|
||||||
"ToastCollectionUpdateSuccess": "Sammlung aktualisiert",
|
"ToastCollectionUpdateSuccess": "Sammlung aktualisiert",
|
||||||
"ToastDeleteFileFailed": "Die Datei konnte nicht gelöscht werden",
|
"ToastDeleteFileFailed": "Die Datei konnte nicht gelöscht werden",
|
||||||
|
|
|
||||||
|
|
@ -229,7 +229,7 @@
|
||||||
"LabelBackupLocation": "Backup Location",
|
"LabelBackupLocation": "Backup Location",
|
||||||
"LabelBackupsEnableAutomaticBackups": "Enable automatic backups",
|
"LabelBackupsEnableAutomaticBackups": "Enable automatic backups",
|
||||||
"LabelBackupsEnableAutomaticBackupsHelp": "Backups saved in /metadata/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.",
|
"LabelBackupsMaxBackupSizeHelp": "As a safeguard against misconfiguration, backups will fail if they exceed the configured size.",
|
||||||
"LabelBackupsNumberToKeep": "Number of backups to keep",
|
"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.",
|
"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",
|
"LabelRSSFeedPreventIndexing": "Prevent Indexing",
|
||||||
"LabelRSSFeedSlug": "RSS Feed Slug",
|
"LabelRSSFeedSlug": "RSS Feed Slug",
|
||||||
"LabelRSSFeedURL": "RSS Feed URL",
|
"LabelRSSFeedURL": "RSS Feed URL",
|
||||||
|
"LabelRandomly": "Randomly",
|
||||||
"LabelReAddSeriesToContinueListening": "Re-add series to Continue Listening",
|
"LabelReAddSeriesToContinueListening": "Re-add series to Continue Listening",
|
||||||
"LabelRead": "Read",
|
"LabelRead": "Read",
|
||||||
"LabelReadAgain": "Read Again",
|
"LabelReadAgain": "Read Again",
|
||||||
|
|
@ -613,6 +614,8 @@
|
||||||
"LabelViewQueue": "View player queue",
|
"LabelViewQueue": "View player queue",
|
||||||
"LabelVolume": "Volume",
|
"LabelVolume": "Volume",
|
||||||
"LabelWeekdaysToRun": "Weekdays to run",
|
"LabelWeekdaysToRun": "Weekdays to run",
|
||||||
|
"LabelXBooks": "{0} books",
|
||||||
|
"LabelXItems": "{0} items",
|
||||||
"LabelYearReviewHide": "Hide Year in Review",
|
"LabelYearReviewHide": "Hide Year in Review",
|
||||||
"LabelYearReviewShow": "See Year in Review",
|
"LabelYearReviewShow": "See Year in Review",
|
||||||
"LabelYourAudiobookDuration": "Your audiobook duration",
|
"LabelYourAudiobookDuration": "Your audiobook duration",
|
||||||
|
|
@ -670,6 +673,7 @@
|
||||||
"MessageConfirmSendEbookToDevice": "Are you sure you want to send {0} ebook \"{1}\" to device \"{2}\"?",
|
"MessageConfirmSendEbookToDevice": "Are you sure you want to send {0} ebook \"{1}\" to device \"{2}\"?",
|
||||||
"MessageDownloadingEpisode": "Downloading episode",
|
"MessageDownloadingEpisode": "Downloading episode",
|
||||||
"MessageDragFilesIntoTrackOrder": "Drag files into correct track order",
|
"MessageDragFilesIntoTrackOrder": "Drag files into correct track order",
|
||||||
|
"MessageEmbedFailed": "Embed Failed!",
|
||||||
"MessageEmbedFinished": "Embed Finished!",
|
"MessageEmbedFinished": "Embed Finished!",
|
||||||
"MessageEpisodesQueuedForDownload": "{0} Episode(s) queued for download",
|
"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.",
|
"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",
|
"MessageNoUpdatesWereNecessary": "No updates were necessary",
|
||||||
"MessageNoUserPlaylists": "You have no playlists",
|
"MessageNoUserPlaylists": "You have no playlists",
|
||||||
"MessageNotYetImplemented": "Not yet implemented",
|
"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",
|
"MessageOr": "or",
|
||||||
"MessagePauseChapter": "Pause chapter playback",
|
"MessagePauseChapter": "Pause chapter playback",
|
||||||
"MessagePlayChapter": "Listen to beginning of chapter",
|
"MessagePlayChapter": "Listen to beginning of chapter",
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@
|
||||||
"ButtonPurgeItemsCache": "Purgar Elementos de Cache",
|
"ButtonPurgeItemsCache": "Purgar Elementos de Cache",
|
||||||
"ButtonQueueAddItem": "Agregar a la Fila",
|
"ButtonQueueAddItem": "Agregar a la Fila",
|
||||||
"ButtonQueueRemoveItem": "Remover de la Fila",
|
"ButtonQueueRemoveItem": "Remover de la Fila",
|
||||||
|
"ButtonQuickEmbedMetadata": "Agregue metadatos rápidamente",
|
||||||
"ButtonQuickMatch": "Encontrar Rápido",
|
"ButtonQuickMatch": "Encontrar Rápido",
|
||||||
"ButtonReScan": "Re-Escanear",
|
"ButtonReScan": "Re-Escanear",
|
||||||
"ButtonRead": "Leer",
|
"ButtonRead": "Leer",
|
||||||
|
|
@ -88,6 +89,7 @@
|
||||||
"ButtonShow": "Mostrar",
|
"ButtonShow": "Mostrar",
|
||||||
"ButtonStartM4BEncode": "Iniciar Codificación M4B",
|
"ButtonStartM4BEncode": "Iniciar Codificación M4B",
|
||||||
"ButtonStartMetadataEmbed": "Iniciar la Inserción de Metadata",
|
"ButtonStartMetadataEmbed": "Iniciar la Inserción de Metadata",
|
||||||
|
"ButtonStats": "Estadísticas",
|
||||||
"ButtonSubmit": "Enviar",
|
"ButtonSubmit": "Enviar",
|
||||||
"ButtonTest": "Prueba",
|
"ButtonTest": "Prueba",
|
||||||
"ButtonUpload": "Subir",
|
"ButtonUpload": "Subir",
|
||||||
|
|
@ -154,6 +156,7 @@
|
||||||
"HeaderPasswordAuthentication": "Autenticación por contraseña",
|
"HeaderPasswordAuthentication": "Autenticación por contraseña",
|
||||||
"HeaderPermissions": "Permisos",
|
"HeaderPermissions": "Permisos",
|
||||||
"HeaderPlayerQueue": "Fila del Reproductor",
|
"HeaderPlayerQueue": "Fila del Reproductor",
|
||||||
|
"HeaderPlayerSettings": "Ajustes del reproductor",
|
||||||
"HeaderPlaylist": "Lista de reproducción",
|
"HeaderPlaylist": "Lista de reproducción",
|
||||||
"HeaderPlaylistItems": "Elementos de lista de reproducción",
|
"HeaderPlaylistItems": "Elementos de lista de reproducción",
|
||||||
"HeaderPodcastsToAdd": "Podcasts para agregar",
|
"HeaderPodcastsToAdd": "Podcasts para agregar",
|
||||||
|
|
@ -289,13 +292,16 @@
|
||||||
"LabelEmbeddedCover": "Portada Integrada",
|
"LabelEmbeddedCover": "Portada Integrada",
|
||||||
"LabelEnable": "Habilitar",
|
"LabelEnable": "Habilitar",
|
||||||
"LabelEnd": "Fin",
|
"LabelEnd": "Fin",
|
||||||
|
"LabelEndOfChapter": "Fin del capítulo",
|
||||||
"LabelEpisode": "Episodio",
|
"LabelEpisode": "Episodio",
|
||||||
"LabelEpisodeTitle": "Titulo de Episodio",
|
"LabelEpisodeTitle": "Titulo de Episodio",
|
||||||
"LabelEpisodeType": "Tipo de Episodio",
|
"LabelEpisodeType": "Tipo de Episodio",
|
||||||
"LabelExample": "Ejemplo",
|
"LabelExample": "Ejemplo",
|
||||||
|
"LabelExpandSeries": "Ampliar serie",
|
||||||
"LabelExplicit": "Explicito",
|
"LabelExplicit": "Explicito",
|
||||||
"LabelExplicitChecked": "Explícito (marcado)",
|
"LabelExplicitChecked": "Explícito (marcado)",
|
||||||
"LabelExplicitUnchecked": "No Explícito (sin marcar)",
|
"LabelExplicitUnchecked": "No Explícito (sin marcar)",
|
||||||
|
"LabelExportOPML": "Exportar OPML",
|
||||||
"LabelFeedURL": "Fuente de URL",
|
"LabelFeedURL": "Fuente de URL",
|
||||||
"LabelFetchingMetadata": "Obteniendo metadatos",
|
"LabelFetchingMetadata": "Obteniendo metadatos",
|
||||||
"LabelFile": "Archivo",
|
"LabelFile": "Archivo",
|
||||||
|
|
@ -319,6 +325,7 @@
|
||||||
"LabelHardDeleteFile": "Eliminar Definitivamente",
|
"LabelHardDeleteFile": "Eliminar Definitivamente",
|
||||||
"LabelHasEbook": "Tiene un libro",
|
"LabelHasEbook": "Tiene un libro",
|
||||||
"LabelHasSupplementaryEbook": "Tiene un libro complementario",
|
"LabelHasSupplementaryEbook": "Tiene un libro complementario",
|
||||||
|
"LabelHideSubtitles": "Ocultar subtítulos",
|
||||||
"LabelHighestPriority": "Mayor prioridad",
|
"LabelHighestPriority": "Mayor prioridad",
|
||||||
"LabelHost": "Host",
|
"LabelHost": "Host",
|
||||||
"LabelHour": "Hora",
|
"LabelHour": "Hora",
|
||||||
|
|
@ -339,6 +346,8 @@
|
||||||
"LabelIntervalEveryHour": "Cada Hora",
|
"LabelIntervalEveryHour": "Cada Hora",
|
||||||
"LabelInvert": "Invertir",
|
"LabelInvert": "Invertir",
|
||||||
"LabelItem": "Elemento",
|
"LabelItem": "Elemento",
|
||||||
|
"LabelJumpBackwardAmount": "Cantidad de saltos hacia atrás",
|
||||||
|
"LabelJumpForwardAmount": "Cantidad de saltos hacia adelante",
|
||||||
"LabelLanguage": "Idioma",
|
"LabelLanguage": "Idioma",
|
||||||
"LabelLanguageDefaultServer": "Lenguaje Predeterminado del Servidor",
|
"LabelLanguageDefaultServer": "Lenguaje Predeterminado del Servidor",
|
||||||
"LabelLanguages": "Idiomas",
|
"LabelLanguages": "Idiomas",
|
||||||
|
|
@ -446,6 +455,7 @@
|
||||||
"LabelRSSFeedPreventIndexing": "Prevenir indexado",
|
"LabelRSSFeedPreventIndexing": "Prevenir indexado",
|
||||||
"LabelRSSFeedSlug": "Fuente RSS Slug",
|
"LabelRSSFeedSlug": "Fuente RSS Slug",
|
||||||
"LabelRSSFeedURL": "URL de Fuente RSS",
|
"LabelRSSFeedURL": "URL de Fuente RSS",
|
||||||
|
"LabelReAddSeriesToContinueListening": "Volver a agregar la serie para continuar escuchándola",
|
||||||
"LabelRead": "Leído",
|
"LabelRead": "Leído",
|
||||||
"LabelReadAgain": "Volver a leer",
|
"LabelReadAgain": "Volver a leer",
|
||||||
"LabelReadEbookWithoutProgress": "Leer Ebook sin guardar progreso",
|
"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",
|
"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",
|
"LabelSettingsTimeFormat": "Formato de Tiempo",
|
||||||
"LabelShare": "Compartir",
|
"LabelShare": "Compartir",
|
||||||
|
"LabelShareOpen": "abrir un recurso compartido",
|
||||||
"LabelShareURL": "Compartir la URL",
|
"LabelShareURL": "Compartir la URL",
|
||||||
"LabelShowAll": "Mostrar Todos",
|
"LabelShowAll": "Mostrar Todos",
|
||||||
"LabelShowSeconds": "Mostrar segundos",
|
"LabelShowSeconds": "Mostrar segundos",
|
||||||
|
"LabelShowSubtitles": "Mostrar subtítulos",
|
||||||
"LabelSize": "Tamaño",
|
"LabelSize": "Tamaño",
|
||||||
"LabelSleepTimer": "Temporizador de apagado",
|
"LabelSleepTimer": "Temporizador de apagado",
|
||||||
"LabelSlug": "Slug",
|
"LabelSlug": "Slug",
|
||||||
|
|
@ -552,6 +564,10 @@
|
||||||
"LabelThemeDark": "Oscuro",
|
"LabelThemeDark": "Oscuro",
|
||||||
"LabelThemeLight": "Claro",
|
"LabelThemeLight": "Claro",
|
||||||
"LabelTimeBase": "Tiempo Base",
|
"LabelTimeBase": "Tiempo Base",
|
||||||
|
"LabelTimeDurationXHours": "{0} horas",
|
||||||
|
"LabelTimeDurationXMinutes": "{0} minutos",
|
||||||
|
"LabelTimeDurationXSeconds": "{0} segundos",
|
||||||
|
"LabelTimeInMinutes": "Tiempo en minutos",
|
||||||
"LabelTimeListened": "Tiempo Escuchando",
|
"LabelTimeListened": "Tiempo Escuchando",
|
||||||
"LabelTimeListenedToday": "Tiempo Escuchando Hoy",
|
"LabelTimeListenedToday": "Tiempo Escuchando Hoy",
|
||||||
"LabelTimeRemaining": "{0} restante",
|
"LabelTimeRemaining": "{0} restante",
|
||||||
|
|
@ -591,6 +607,7 @@
|
||||||
"LabelVersion": "Versión",
|
"LabelVersion": "Versión",
|
||||||
"LabelViewBookmarks": "Ver Marcadores",
|
"LabelViewBookmarks": "Ver Marcadores",
|
||||||
"LabelViewChapters": "Ver Capítulos",
|
"LabelViewChapters": "Ver Capítulos",
|
||||||
|
"LabelViewPlayerSettings": "Ver los ajustes del reproductor",
|
||||||
"LabelViewQueue": "Ver Fila del Reproductor",
|
"LabelViewQueue": "Ver Fila del Reproductor",
|
||||||
"LabelVolume": "Volumen",
|
"LabelVolume": "Volumen",
|
||||||
"LabelWeekdaysToRun": "Correr en Días de la Semana",
|
"LabelWeekdaysToRun": "Correr en Días de la Semana",
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,7 @@
|
||||||
"HeaderYourStats": "Tilastosi",
|
"HeaderYourStats": "Tilastosi",
|
||||||
"LabelAddToPlaylist": "Lisää soittolistaan",
|
"LabelAddToPlaylist": "Lisää soittolistaan",
|
||||||
"LabelAdded": "Lisätty",
|
"LabelAdded": "Lisätty",
|
||||||
"LabelAddedAt": "Lisätty",
|
"LabelAddedAt": "Lisätty listalle",
|
||||||
"LabelAll": "Kaikki",
|
"LabelAll": "Kaikki",
|
||||||
"LabelAuthor": "Tekijä",
|
"LabelAuthor": "Tekijä",
|
||||||
"LabelAuthorFirstLast": "Tekijä (Etunimi Sukunimi)",
|
"LabelAuthorFirstLast": "Tekijä (Etunimi Sukunimi)",
|
||||||
|
|
@ -152,11 +152,34 @@
|
||||||
"LabelContinueReading": "Jatka lukemista",
|
"LabelContinueReading": "Jatka lukemista",
|
||||||
"LabelContinueSeries": "Jatka sarjoja",
|
"LabelContinueSeries": "Jatka sarjoja",
|
||||||
"LabelDescription": "Kuvaus",
|
"LabelDescription": "Kuvaus",
|
||||||
|
"LabelDownload": "Lataa",
|
||||||
"LabelDuration": "Kesto",
|
"LabelDuration": "Kesto",
|
||||||
"LabelEbook": "E-kirja",
|
"LabelEbook": "E-kirja",
|
||||||
"LabelEbooks": "E-kirjat",
|
"LabelEbooks": "E-kirjat",
|
||||||
|
"LabelEnable": "Ota käyttöön",
|
||||||
"LabelFile": "Tiedosto",
|
"LabelFile": "Tiedosto",
|
||||||
"LabelFileBirthtime": "Tiedoston syntymäaika",
|
"LabelFileBirthtime": "Tiedoston syntymäaika",
|
||||||
"LabelFileModified": "Muutettu tiedosto",
|
"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 :",
|
"LabelCurrently": "Actuellement :",
|
||||||
"LabelCustomCronExpression": "Expression cron personnalisée :",
|
"LabelCustomCronExpression": "Expression cron personnalisée :",
|
||||||
"LabelDatetime": "Date",
|
"LabelDatetime": "Date",
|
||||||
|
"LabelDays": "Jours",
|
||||||
"LabelDeleteFromFileSystemCheckbox": "Supprimer du système de fichiers (décocher pour ne supprimer que de la base de données)",
|
"LabelDeleteFromFileSystemCheckbox": "Supprimer du système de fichiers (décocher pour ne supprimer que de la base de données)",
|
||||||
"LabelDescription": "Description",
|
"LabelDescription": "Description",
|
||||||
"LabelDeselectAll": "Tout déselectionner",
|
"LabelDeselectAll": "Tout déselectionner",
|
||||||
|
|
@ -321,6 +322,7 @@
|
||||||
"LabelHighestPriority": "Priorité la plus élevée",
|
"LabelHighestPriority": "Priorité la plus élevée",
|
||||||
"LabelHost": "Hôte",
|
"LabelHost": "Hôte",
|
||||||
"LabelHour": "Heure",
|
"LabelHour": "Heure",
|
||||||
|
"LabelHours": "Heures",
|
||||||
"LabelIcon": "Icône",
|
"LabelIcon": "Icône",
|
||||||
"LabelImageURLFromTheWeb": "URL de l’image à partir du web",
|
"LabelImageURLFromTheWeb": "URL de l’image à partir du web",
|
||||||
"LabelInProgress": "En cours",
|
"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",
|
"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",
|
"LabelMetadataProvider": "Fournisseur de métadonnées",
|
||||||
"LabelMinute": "Minute",
|
"LabelMinute": "Minute",
|
||||||
|
"LabelMinutes": "Minutes",
|
||||||
"LabelMissing": "Manquant",
|
"LabelMissing": "Manquant",
|
||||||
"LabelMissingEbook": "Ne possède aucun livre numérique",
|
"LabelMissingEbook": "Ne possède aucun livre numérique",
|
||||||
"LabelMissingSupplementaryEbook": "Ne possède aucun livre numérique supplémentaire",
|
"LabelMissingSupplementaryEbook": "Ne possède aucun livre numérique supplémentaire",
|
||||||
|
|
@ -410,6 +413,7 @@
|
||||||
"LabelOverwrite": "Écraser",
|
"LabelOverwrite": "Écraser",
|
||||||
"LabelPassword": "Mot de passe",
|
"LabelPassword": "Mot de passe",
|
||||||
"LabelPath": "Chemin",
|
"LabelPath": "Chemin",
|
||||||
|
"LabelPermanent": "Permanent",
|
||||||
"LabelPermissionsAccessAllLibraries": "Peut accéder à toutes les bibliothèque",
|
"LabelPermissionsAccessAllLibraries": "Peut accéder à toutes les bibliothèque",
|
||||||
"LabelPermissionsAccessAllTags": "Peut accéder à toutes les étiquettes",
|
"LabelPermissionsAccessAllTags": "Peut accéder à toutes les étiquettes",
|
||||||
"LabelPermissionsAccessExplicitContent": "Peut accéder au contenu restreint",
|
"LabelPermissionsAccessExplicitContent": "Peut accéder au contenu restreint",
|
||||||
|
|
@ -507,6 +511,9 @@
|
||||||
"LabelSettingsStoreMetadataWithItem": "Enregistrer les métadonnées avec l’élément",
|
"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",
|
"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",
|
"LabelSettingsTimeFormat": "Format d’heure",
|
||||||
|
"LabelShare": "Partager",
|
||||||
|
"LabelShareOpen": "Ouvrir le partage",
|
||||||
|
"LabelShareURL": "Partager l’URL",
|
||||||
"LabelShowAll": "Tout afficher",
|
"LabelShowAll": "Tout afficher",
|
||||||
"LabelShowSeconds": "Afficher les seondes",
|
"LabelShowSeconds": "Afficher les seondes",
|
||||||
"LabelSize": "Taille",
|
"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>.",
|
"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.",
|
"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",
|
"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",
|
"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.",
|
"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",
|
"MessageBookshelfNoCollections": "Vous n’avez pas encore de collections",
|
||||||
|
|
@ -716,6 +724,9 @@
|
||||||
"MessageSelected": "{0} sélectionnés",
|
"MessageSelected": "{0} sélectionnés",
|
||||||
"MessageServerCouldNotBeReached": "Serveur inaccessible",
|
"MessageServerCouldNotBeReached": "Serveur inaccessible",
|
||||||
"MessageSetChaptersFromTracksDescription": "Positionne un chapitre par fichier audio, avec le titre du fichier comme titre de chapitre",
|
"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} ?",
|
"MessageStartPlaybackAtTime": "Démarrer la lecture pour « {0} » à {1} ?",
|
||||||
"MessageThinking": "Je cherche…",
|
"MessageThinking": "Je cherche…",
|
||||||
"MessageUploaderItemFailed": "Échec du téléversement",
|
"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.",
|
"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",
|
"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",
|
"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.",
|
"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.",
|
"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.",
|
"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": "החל",
|
"ButtonApply": "החל",
|
||||||
"ButtonApplyChapters": "החל פרקים",
|
"ButtonApplyChapters": "החל פרקים",
|
||||||
"ButtonAuthors": "יוצרים",
|
"ButtonAuthors": "יוצרים",
|
||||||
"ButtonBack": "Back",
|
"ButtonBack": "חזור",
|
||||||
"ButtonBrowseForFolder": "עיין בתיקייה",
|
"ButtonBrowseForFolder": "עיין בתיקייה",
|
||||||
"ButtonCancel": "בטל",
|
"ButtonCancel": "בטל",
|
||||||
"ButtonCancelEncode": "בטל קידוד",
|
"ButtonCancelEncode": "בטל קידוד",
|
||||||
|
|
@ -62,8 +62,8 @@
|
||||||
"ButtonQuickMatch": "התאמה מהירה",
|
"ButtonQuickMatch": "התאמה מהירה",
|
||||||
"ButtonReScan": "סרוק מחדש",
|
"ButtonReScan": "סרוק מחדש",
|
||||||
"ButtonRead": "קרא",
|
"ButtonRead": "קרא",
|
||||||
"ButtonReadLess": "Read less",
|
"ButtonReadLess": "קרא פחות",
|
||||||
"ButtonReadMore": "Read more",
|
"ButtonReadMore": "קרא יותר",
|
||||||
"ButtonRefresh": "רענן",
|
"ButtonRefresh": "רענן",
|
||||||
"ButtonRemove": "הסר",
|
"ButtonRemove": "הסר",
|
||||||
"ButtonRemoveAll": "הסר הכל",
|
"ButtonRemoveAll": "הסר הכל",
|
||||||
|
|
@ -115,7 +115,7 @@
|
||||||
"HeaderCollectionItems": "פריטי אוסף",
|
"HeaderCollectionItems": "פריטי אוסף",
|
||||||
"HeaderCover": "כריכה",
|
"HeaderCover": "כריכה",
|
||||||
"HeaderCurrentDownloads": "הורדות נוכחיות",
|
"HeaderCurrentDownloads": "הורדות נוכחיות",
|
||||||
"HeaderCustomMessageOnLogin": "Custom Message on Login",
|
"HeaderCustomMessageOnLogin": "הודעה מותאמת אישית בהתחברות",
|
||||||
"HeaderCustomMetadataProviders": "ספקי מטא-נתונים מותאמים אישית",
|
"HeaderCustomMetadataProviders": "ספקי מטא-נתונים מותאמים אישית",
|
||||||
"HeaderDetails": "פרטים",
|
"HeaderDetails": "פרטים",
|
||||||
"HeaderDownloadQueue": "תור הורדה",
|
"HeaderDownloadQueue": "תור הורדה",
|
||||||
|
|
@ -806,8 +806,8 @@
|
||||||
"ToastSendEbookToDeviceSuccess": "הספר נשלח אל המכשיר \"{0}\"",
|
"ToastSendEbookToDeviceSuccess": "הספר נשלח אל המכשיר \"{0}\"",
|
||||||
"ToastSeriesUpdateFailed": "עדכון הסדרה נכשל",
|
"ToastSeriesUpdateFailed": "עדכון הסדרה נכשל",
|
||||||
"ToastSeriesUpdateSuccess": "הסדרה עודכנה בהצלחה",
|
"ToastSeriesUpdateSuccess": "הסדרה עודכנה בהצלחה",
|
||||||
"ToastServerSettingsUpdateFailed": "Failed to update server settings",
|
"ToastServerSettingsUpdateFailed": "כשל בעדכון הגדרות שרת",
|
||||||
"ToastServerSettingsUpdateSuccess": "Server settings updated",
|
"ToastServerSettingsUpdateSuccess": "הגדרות שרת עודכנו בהצלחה",
|
||||||
"ToastSessionDeleteFailed": "מחיקת הפעולה נכשלה",
|
"ToastSessionDeleteFailed": "מחיקת הפעולה נכשלה",
|
||||||
"ToastSessionDeleteSuccess": "הפעולה נמחקה בהצלחה",
|
"ToastSessionDeleteSuccess": "הפעולה נמחקה בהצלחה",
|
||||||
"ToastSocketConnected": "קצה תקשורת חובר",
|
"ToastSocketConnected": "קצה תקשורת חובר",
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
{
|
{
|
||||||
"ButtonAdd": "Toevoegen",
|
"ButtonAdd": "Toevoegen",
|
||||||
"ButtonAddChapters": "Hoofdstukken toevoegen",
|
"ButtonAddChapters": "Hoofdstukken toevoegen",
|
||||||
"ButtonAddDevice": "Add Device",
|
"ButtonAddDevice": "Toestel toevoegen",
|
||||||
"ButtonAddLibrary": "Add Library",
|
"ButtonAddLibrary": "Bibliotheek toevoegen",
|
||||||
"ButtonAddPodcasts": "Podcasts toevoegen",
|
"ButtonAddPodcasts": "Podcasts toevoegen",
|
||||||
"ButtonAddUser": "Add User",
|
"ButtonAddUser": "Gebruiker toevoegen",
|
||||||
"ButtonAddYourFirstLibrary": "Voeg je eerste bibliotheek toe",
|
"ButtonAddYourFirstLibrary": "Voeg je eerste bibliotheek toe",
|
||||||
"ButtonApply": "Pas toe",
|
"ButtonApply": "Pas toe",
|
||||||
"ButtonApplyChapters": "Hoofdstukken toepassen",
|
"ButtonApplyChapters": "Hoofdstukken toepassen",
|
||||||
"ButtonAuthors": "Auteurs",
|
"ButtonAuthors": "Auteurs",
|
||||||
"ButtonBack": "Back",
|
"ButtonBack": "Terug",
|
||||||
"ButtonBrowseForFolder": "Bladeren naar map",
|
"ButtonBrowseForFolder": "Bladeren naar map",
|
||||||
"ButtonCancel": "Annuleren",
|
"ButtonCancel": "Annuleren",
|
||||||
"ButtonCancelEncode": "Encoding annuleren",
|
"ButtonCancelEncode": "Encoding annuleren",
|
||||||
|
|
@ -32,9 +32,9 @@
|
||||||
"ButtonFullPath": "Volledig pad",
|
"ButtonFullPath": "Volledig pad",
|
||||||
"ButtonHide": "Verberg",
|
"ButtonHide": "Verberg",
|
||||||
"ButtonHome": "Home",
|
"ButtonHome": "Home",
|
||||||
"ButtonIssues": "Issues",
|
"ButtonIssues": "Problemen",
|
||||||
"ButtonJumpBackward": "Jump Backward",
|
"ButtonJumpBackward": "Spring achteruit",
|
||||||
"ButtonJumpForward": "Jump Forward",
|
"ButtonJumpForward": "Spring vooruit",
|
||||||
"ButtonLatest": "Meest recent",
|
"ButtonLatest": "Meest recent",
|
||||||
"ButtonLibrary": "Bibliotheek",
|
"ButtonLibrary": "Bibliotheek",
|
||||||
"ButtonLogout": "Log uit",
|
"ButtonLogout": "Log uit",
|
||||||
|
|
@ -44,17 +44,17 @@
|
||||||
"ButtonMatchAllAuthors": "Alle auteurs matchen",
|
"ButtonMatchAllAuthors": "Alle auteurs matchen",
|
||||||
"ButtonMatchBooks": "Alle boeken matchen",
|
"ButtonMatchBooks": "Alle boeken matchen",
|
||||||
"ButtonNevermind": "Laat maar",
|
"ButtonNevermind": "Laat maar",
|
||||||
"ButtonNext": "Next",
|
"ButtonNext": "Volgende",
|
||||||
"ButtonNextChapter": "Next Chapter",
|
"ButtonNextChapter": "Volgend hoofdstuk",
|
||||||
"ButtonOk": "Ok",
|
"ButtonOk": "Ok",
|
||||||
"ButtonOpenFeed": "Feed openen",
|
"ButtonOpenFeed": "Feed openen",
|
||||||
"ButtonOpenManager": "Manager openen",
|
"ButtonOpenManager": "Manager openen",
|
||||||
"ButtonPause": "Pause",
|
"ButtonPause": "Pauze",
|
||||||
"ButtonPlay": "Afspelen",
|
"ButtonPlay": "Afspelen",
|
||||||
"ButtonPlaying": "Speelt",
|
"ButtonPlaying": "Speelt",
|
||||||
"ButtonPlaylists": "Afspeellijsten",
|
"ButtonPlaylists": "Afspeellijsten",
|
||||||
"ButtonPrevious": "Previous",
|
"ButtonPrevious": "Vorige",
|
||||||
"ButtonPreviousChapter": "Previous Chapter",
|
"ButtonPreviousChapter": "Vorig hoofdstuk",
|
||||||
"ButtonPurgeAllCache": "Volledige cache legen",
|
"ButtonPurgeAllCache": "Volledige cache legen",
|
||||||
"ButtonPurgeItemsCache": "Onderdelen-cache legen",
|
"ButtonPurgeItemsCache": "Onderdelen-cache legen",
|
||||||
"ButtonQueueAddItem": "In wachtrij zetten",
|
"ButtonQueueAddItem": "In wachtrij zetten",
|
||||||
|
|
@ -62,14 +62,14 @@
|
||||||
"ButtonQuickMatch": "Snelle match",
|
"ButtonQuickMatch": "Snelle match",
|
||||||
"ButtonReScan": "Nieuwe scan",
|
"ButtonReScan": "Nieuwe scan",
|
||||||
"ButtonRead": "Lees",
|
"ButtonRead": "Lees",
|
||||||
"ButtonReadLess": "Read less",
|
"ButtonReadLess": "Lees minder",
|
||||||
"ButtonReadMore": "Read more",
|
"ButtonReadMore": "Lees meer",
|
||||||
"ButtonRefresh": "Refresh",
|
"ButtonRefresh": "Verversen",
|
||||||
"ButtonRemove": "Verwijder",
|
"ButtonRemove": "Verwijder",
|
||||||
"ButtonRemoveAll": "Alles verwijderen",
|
"ButtonRemoveAll": "Alles verwijderen",
|
||||||
"ButtonRemoveAllLibraryItems": "Verwijder volledige bibliotheekinhoud",
|
"ButtonRemoveAllLibraryItems": "Verwijder volledige bibliotheekinhoud",
|
||||||
"ButtonRemoveFromContinueListening": "Vewijder uit Verder luisteren",
|
"ButtonRemoveFromContinueListening": "Vewijder uit Verder luisteren",
|
||||||
"ButtonRemoveFromContinueReading": "Remove from Continue Reading",
|
"ButtonRemoveFromContinueReading": "Verwijder van Verder luisteren",
|
||||||
"ButtonRemoveSeriesFromContinueSeries": "Verwijder serie uit Serie vervolgen",
|
"ButtonRemoveSeriesFromContinueSeries": "Verwijder serie uit Serie vervolgen",
|
||||||
"ButtonReset": "Reset",
|
"ButtonReset": "Reset",
|
||||||
"ButtonResetToDefault": "Reset to default",
|
"ButtonResetToDefault": "Reset to default",
|
||||||
|
|
@ -83,7 +83,7 @@
|
||||||
"ButtonSelectFolderPath": "Maplocatie selecteren",
|
"ButtonSelectFolderPath": "Maplocatie selecteren",
|
||||||
"ButtonSeries": "Series",
|
"ButtonSeries": "Series",
|
||||||
"ButtonSetChaptersFromTracks": "Maak hoofdstukken op basis van tracks",
|
"ButtonSetChaptersFromTracks": "Maak hoofdstukken op basis van tracks",
|
||||||
"ButtonShare": "Share",
|
"ButtonShare": "Deel",
|
||||||
"ButtonShiftTimes": "Tijden verschuiven",
|
"ButtonShiftTimes": "Tijden verschuiven",
|
||||||
"ButtonShow": "Toon",
|
"ButtonShow": "Toon",
|
||||||
"ButtonStartM4BEncode": "Start M4B-encoding",
|
"ButtonStartM4BEncode": "Start M4B-encoding",
|
||||||
|
|
@ -98,9 +98,9 @@
|
||||||
"ButtonUserEdit": "Wijzig gebruiker {0}",
|
"ButtonUserEdit": "Wijzig gebruiker {0}",
|
||||||
"ButtonViewAll": "Toon alle",
|
"ButtonViewAll": "Toon alle",
|
||||||
"ButtonYes": "Ja",
|
"ButtonYes": "Ja",
|
||||||
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
|
"ErrorUploadFetchMetadataAPI": "Error metadata ophalen",
|
||||||
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
|
"ErrorUploadFetchMetadataNoResults": "Kan metadata niet ophalen - probeer de titel en/of auteur te updaten",
|
||||||
"ErrorUploadLacksTitle": "Must have a title",
|
"ErrorUploadLacksTitle": "Moet een titel hebben",
|
||||||
"HeaderAccount": "Account",
|
"HeaderAccount": "Account",
|
||||||
"HeaderAdvanced": "Geavanceerd",
|
"HeaderAdvanced": "Geavanceerd",
|
||||||
"HeaderAppriseNotificationSettings": "Apprise-notificatie instellingen",
|
"HeaderAppriseNotificationSettings": "Apprise-notificatie instellingen",
|
||||||
|
|
@ -113,13 +113,13 @@
|
||||||
"HeaderChooseAFolder": "Map kiezen",
|
"HeaderChooseAFolder": "Map kiezen",
|
||||||
"HeaderCollection": "Collectie",
|
"HeaderCollection": "Collectie",
|
||||||
"HeaderCollectionItems": "Collectie-objecten",
|
"HeaderCollectionItems": "Collectie-objecten",
|
||||||
"HeaderCover": "Cover",
|
"HeaderCover": "Omslag",
|
||||||
"HeaderCurrentDownloads": "Huidige downloads",
|
"HeaderCurrentDownloads": "Huidige downloads",
|
||||||
"HeaderCustomMessageOnLogin": "Custom Message on Login",
|
"HeaderCustomMessageOnLogin": "Custom Message on Login",
|
||||||
"HeaderCustomMetadataProviders": "Custom Metadata Providers",
|
"HeaderCustomMetadataProviders": "Custom Metadata Providers",
|
||||||
"HeaderDetails": "Details",
|
"HeaderDetails": "Details",
|
||||||
"HeaderDownloadQueue": "Download-wachtrij",
|
"HeaderDownloadQueue": "Download-wachtrij",
|
||||||
"HeaderEbookFiles": "Ebook Files",
|
"HeaderEbookFiles": "Ebook bestanden",
|
||||||
"HeaderEmail": "E-mail",
|
"HeaderEmail": "E-mail",
|
||||||
"HeaderEmailSettings": "E-mail instellingen",
|
"HeaderEmailSettings": "E-mail instellingen",
|
||||||
"HeaderEpisodes": "Afleveringen",
|
"HeaderEpisodes": "Afleveringen",
|
||||||
|
|
@ -239,11 +239,11 @@
|
||||||
"LabelChapterTitle": "Hoofdstuktitel",
|
"LabelChapterTitle": "Hoofdstuktitel",
|
||||||
"LabelChapters": "Hoofdstukken",
|
"LabelChapters": "Hoofdstukken",
|
||||||
"LabelChaptersFound": "Hoofdstukken gevonden",
|
"LabelChaptersFound": "Hoofdstukken gevonden",
|
||||||
"LabelClickForMoreInfo": "Click for more info",
|
"LabelClickForMoreInfo": "Klik voor meer informatie",
|
||||||
"LabelClosePlayer": "Sluit speler",
|
"LabelClosePlayer": "Sluit speler",
|
||||||
"LabelCodec": "Codec",
|
"LabelCodec": "Codec",
|
||||||
"LabelCollapseSeries": "Series inklappen",
|
"LabelCollapseSeries": "Series inklappen",
|
||||||
"LabelCollection": "Collection",
|
"LabelCollection": "Collectie",
|
||||||
"LabelCollections": "Collecties",
|
"LabelCollections": "Collecties",
|
||||||
"LabelComplete": "Compleet",
|
"LabelComplete": "Compleet",
|
||||||
"LabelConfirmPassword": "Bevestig wachtwoord",
|
"LabelConfirmPassword": "Bevestig wachtwoord",
|
||||||
|
|
@ -258,6 +258,7 @@
|
||||||
"LabelCurrently": "Op dit moment:",
|
"LabelCurrently": "Op dit moment:",
|
||||||
"LabelCustomCronExpression": "Aangepaste Cron-uitdrukking:",
|
"LabelCustomCronExpression": "Aangepaste Cron-uitdrukking:",
|
||||||
"LabelDatetime": "Datum-tijd",
|
"LabelDatetime": "Datum-tijd",
|
||||||
|
"LabelDays": "Dagen",
|
||||||
"LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)",
|
"LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)",
|
||||||
"LabelDescription": "Beschrijving",
|
"LabelDescription": "Beschrijving",
|
||||||
"LabelDeselectAll": "Deselecteer alle",
|
"LabelDeselectAll": "Deselecteer alle",
|
||||||
|
|
@ -296,7 +297,7 @@
|
||||||
"LabelExplicitChecked": "Explicit (checked)",
|
"LabelExplicitChecked": "Explicit (checked)",
|
||||||
"LabelExplicitUnchecked": "Not Explicit (unchecked)",
|
"LabelExplicitUnchecked": "Not Explicit (unchecked)",
|
||||||
"LabelFeedURL": "Feed URL",
|
"LabelFeedURL": "Feed URL",
|
||||||
"LabelFetchingMetadata": "Fetching Metadata",
|
"LabelFetchingMetadata": "Metadata ophalen",
|
||||||
"LabelFile": "Bestand",
|
"LabelFile": "Bestand",
|
||||||
"LabelFileBirthtime": "Aanmaaktijd bestand",
|
"LabelFileBirthtime": "Aanmaaktijd bestand",
|
||||||
"LabelFileModified": "Bestand gewijzigd",
|
"LabelFileModified": "Bestand gewijzigd",
|
||||||
|
|
@ -306,7 +307,7 @@
|
||||||
"LabelFinished": "Voltooid",
|
"LabelFinished": "Voltooid",
|
||||||
"LabelFolder": "Map",
|
"LabelFolder": "Map",
|
||||||
"LabelFolders": "Mappen",
|
"LabelFolders": "Mappen",
|
||||||
"LabelFontBold": "Bold",
|
"LabelFontBold": "Vetgedrukt",
|
||||||
"LabelFontBoldness": "Font Boldness",
|
"LabelFontBoldness": "Font Boldness",
|
||||||
"LabelFontFamily": "Lettertypefamilie",
|
"LabelFontFamily": "Lettertypefamilie",
|
||||||
"LabelFontItalic": "Italic",
|
"LabelFontItalic": "Italic",
|
||||||
|
|
@ -321,6 +322,7 @@
|
||||||
"LabelHighestPriority": "Highest priority",
|
"LabelHighestPriority": "Highest priority",
|
||||||
"LabelHost": "Host",
|
"LabelHost": "Host",
|
||||||
"LabelHour": "Uur",
|
"LabelHour": "Uur",
|
||||||
|
"LabelHours": "Uren",
|
||||||
"LabelIcon": "Icoon",
|
"LabelIcon": "Icoon",
|
||||||
"LabelImageURLFromTheWeb": "Image URL from the web",
|
"LabelImageURLFromTheWeb": "Image URL from the web",
|
||||||
"LabelInProgress": "Bezig",
|
"LabelInProgress": "Bezig",
|
||||||
|
|
@ -567,7 +569,7 @@
|
||||||
"LabelTracksSingleTrack": "Enkele track",
|
"LabelTracksSingleTrack": "Enkele track",
|
||||||
"LabelType": "Type",
|
"LabelType": "Type",
|
||||||
"LabelUnabridged": "Onverkort",
|
"LabelUnabridged": "Onverkort",
|
||||||
"LabelUndo": "Undo",
|
"LabelUndo": "Ongedaan maken",
|
||||||
"LabelUnknown": "Onbekend",
|
"LabelUnknown": "Onbekend",
|
||||||
"LabelUpdateCover": "Cover bijwerken",
|
"LabelUpdateCover": "Cover bijwerken",
|
||||||
"LabelUpdateCoverHelp": "Sta overschrijven van bestaande covers toe voor de geselecteerde boeken wanneer een match is gevonden",
|
"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?",
|
"MessageConfirmRemoveCollection": "Weet je zeker dat je de collectie \"{0}\" wil verwijderen?",
|
||||||
"MessageConfirmRemoveEpisode": "Weet je zeker dat je de aflevering \"{0}\" wil verwijderen?",
|
"MessageConfirmRemoveEpisode": "Weet je zeker dat je de aflevering \"{0}\" wil verwijderen?",
|
||||||
"MessageConfirmRemoveEpisodes": "Weet je zeker dat je {0} afleveringen 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?",
|
"MessageConfirmRemoveNarrator": "Weet je zeker dat je verteller \"{0}\" wil verwijderen?",
|
||||||
"MessageConfirmRemovePlaylist": "Weet je zeker dat je je afspeellijst \"{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?",
|
"MessageConfirmRenameGenre": "Weet je zeker dat je genre \"{0}\" wil hernoemen naar \"{1}\" voor alle onderdelen?",
|
||||||
|
|
@ -714,6 +716,7 @@
|
||||||
"MessageSelected": "{0} selected",
|
"MessageSelected": "{0} selected",
|
||||||
"MessageServerCouldNotBeReached": "Server niet bereikbaar",
|
"MessageServerCouldNotBeReached": "Server niet bereikbaar",
|
||||||
"MessageSetChaptersFromTracksDescription": "Stel hoofdstukken in met ieder audiobestand als een hoofdstuk en de audiobestandsnaam als hoofdstuktitel",
|
"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}?",
|
"MessageStartPlaybackAtTime": "Afspelen van \"{0}\" beginnen op {1}?",
|
||||||
"MessageThinking": "Aan het denken...",
|
"MessageThinking": "Aan het denken...",
|
||||||
"MessageUploaderItemFailed": "Uploaden mislukt",
|
"MessageUploaderItemFailed": "Uploaden mislukt",
|
||||||
|
|
|
||||||
|
|
@ -62,8 +62,8 @@
|
||||||
"ButtonQuickMatch": "Szybkie dopasowanie",
|
"ButtonQuickMatch": "Szybkie dopasowanie",
|
||||||
"ButtonReScan": "Ponowne skanowanie",
|
"ButtonReScan": "Ponowne skanowanie",
|
||||||
"ButtonRead": "Czytaj",
|
"ButtonRead": "Czytaj",
|
||||||
"ButtonReadLess": "Read less",
|
"ButtonReadLess": "Pokaż mniej",
|
||||||
"ButtonReadMore": "Read more",
|
"ButtonReadMore": "Pokaż więcej",
|
||||||
"ButtonRefresh": "Odśwież",
|
"ButtonRefresh": "Odśwież",
|
||||||
"ButtonRemove": "Usuń",
|
"ButtonRemove": "Usuń",
|
||||||
"ButtonRemoveAll": "Usuń wszystko",
|
"ButtonRemoveAll": "Usuń wszystko",
|
||||||
|
|
@ -88,6 +88,7 @@
|
||||||
"ButtonShow": "Pokaż",
|
"ButtonShow": "Pokaż",
|
||||||
"ButtonStartM4BEncode": "Eksportuj jako plik M4B",
|
"ButtonStartM4BEncode": "Eksportuj jako plik M4B",
|
||||||
"ButtonStartMetadataEmbed": "Osadź metadane",
|
"ButtonStartMetadataEmbed": "Osadź metadane",
|
||||||
|
"ButtonStats": "Statystyki",
|
||||||
"ButtonSubmit": "Zaloguj",
|
"ButtonSubmit": "Zaloguj",
|
||||||
"ButtonTest": "Test",
|
"ButtonTest": "Test",
|
||||||
"ButtonUpload": "Wgraj",
|
"ButtonUpload": "Wgraj",
|
||||||
|
|
@ -130,13 +131,13 @@
|
||||||
"HeaderIgnoredFiles": "Zignoruj pliki",
|
"HeaderIgnoredFiles": "Zignoruj pliki",
|
||||||
"HeaderItemFiles": "Pliki",
|
"HeaderItemFiles": "Pliki",
|
||||||
"HeaderItemMetadataUtils": "Item Metadata Utils",
|
"HeaderItemMetadataUtils": "Item Metadata Utils",
|
||||||
"HeaderLastListeningSession": "Ostatnio odtwarzana sesja",
|
"HeaderLastListeningSession": "Ostatnia sesja słuchania",
|
||||||
"HeaderLatestEpisodes": "Najnowsze odcinki",
|
"HeaderLatestEpisodes": "Najnowsze odcinki",
|
||||||
"HeaderLibraries": "Biblioteki",
|
"HeaderLibraries": "Biblioteki",
|
||||||
"HeaderLibraryFiles": "Pliki w bibliotece",
|
"HeaderLibraryFiles": "Pliki w bibliotece",
|
||||||
"HeaderLibraryStats": "Statystyki biblioteki",
|
"HeaderLibraryStats": "Statystyki biblioteki",
|
||||||
"HeaderListeningSessions": "Sesje słuchania",
|
"HeaderListeningSessions": "Sesje słuchania",
|
||||||
"HeaderListeningStats": "Statystyki odtwarzania",
|
"HeaderListeningStats": "Statystyki słuchania",
|
||||||
"HeaderLogin": "Zaloguj się",
|
"HeaderLogin": "Zaloguj się",
|
||||||
"HeaderLogs": "Logi",
|
"HeaderLogs": "Logi",
|
||||||
"HeaderManageGenres": "Zarządzaj gatunkami",
|
"HeaderManageGenres": "Zarządzaj gatunkami",
|
||||||
|
|
@ -148,12 +149,13 @@
|
||||||
"HeaderNewAccount": "Nowe konto",
|
"HeaderNewAccount": "Nowe konto",
|
||||||
"HeaderNewLibrary": "Nowa biblioteka",
|
"HeaderNewLibrary": "Nowa biblioteka",
|
||||||
"HeaderNotifications": "Powiadomienia",
|
"HeaderNotifications": "Powiadomienia",
|
||||||
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication",
|
"HeaderOpenIDConnectAuthentication": "Uwierzytelnianie OpenID Connect",
|
||||||
"HeaderOpenRSSFeed": "Utwórz kanał RSS",
|
"HeaderOpenRSSFeed": "Utwórz kanał RSS",
|
||||||
"HeaderOtherFiles": "Inne pliki",
|
"HeaderOtherFiles": "Inne pliki",
|
||||||
"HeaderPasswordAuthentication": "Uwierzytelnianie hasłem",
|
"HeaderPasswordAuthentication": "Uwierzytelnianie hasłem",
|
||||||
"HeaderPermissions": "Uprawnienia",
|
"HeaderPermissions": "Uprawnienia",
|
||||||
"HeaderPlayerQueue": "Kolejka odtwarzania",
|
"HeaderPlayerQueue": "Kolejka odtwarzania",
|
||||||
|
"HeaderPlayerSettings": "Ustawienia Odtwarzania",
|
||||||
"HeaderPlaylist": "Playlista",
|
"HeaderPlaylist": "Playlista",
|
||||||
"HeaderPlaylistItems": "Pozycje listy odtwarzania",
|
"HeaderPlaylistItems": "Pozycje listy odtwarzania",
|
||||||
"HeaderPodcastsToAdd": "Podcasty do dodania",
|
"HeaderPodcastsToAdd": "Podcasty do dodania",
|
||||||
|
|
@ -175,7 +177,7 @@
|
||||||
"HeaderSettingsScanner": "Skanowanie",
|
"HeaderSettingsScanner": "Skanowanie",
|
||||||
"HeaderSleepTimer": "Wyłącznik czasowy",
|
"HeaderSleepTimer": "Wyłącznik czasowy",
|
||||||
"HeaderStatsLargestItems": "Największe pozycje",
|
"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)",
|
"HeaderStatsMinutesListeningChart": "Czas słuchania w minutach (ostatnie 7 dni)",
|
||||||
"HeaderStatsRecentSessions": "Ostatnie sesje",
|
"HeaderStatsRecentSessions": "Ostatnie sesje",
|
||||||
"HeaderStatsTop10Authors": "Top 10 Autorów",
|
"HeaderStatsTop10Authors": "Top 10 Autorów",
|
||||||
|
|
@ -200,8 +202,8 @@
|
||||||
"LabelActivity": "Aktywność",
|
"LabelActivity": "Aktywność",
|
||||||
"LabelAddToCollection": "Dodaj do kolekcji",
|
"LabelAddToCollection": "Dodaj do kolekcji",
|
||||||
"LabelAddToCollectionBatch": "Dodaj {0} książki do kolekcji",
|
"LabelAddToCollectionBatch": "Dodaj {0} książki do kolekcji",
|
||||||
"LabelAddToPlaylist": "Add to Playlist",
|
"LabelAddToPlaylist": "Dodaj do playlisty",
|
||||||
"LabelAddToPlaylistBatch": "Add {0} Items to Playlist",
|
"LabelAddToPlaylistBatch": "Dodaj {0} pozycji do playlisty",
|
||||||
"LabelAdded": "Dodane",
|
"LabelAdded": "Dodane",
|
||||||
"LabelAddedAt": "Dodano",
|
"LabelAddedAt": "Dodano",
|
||||||
"LabelAdminUsersOnly": "Tylko użytkownicy administracyjni",
|
"LabelAdminUsersOnly": "Tylko użytkownicy administracyjni",
|
||||||
|
|
@ -226,14 +228,14 @@
|
||||||
"LabelBackupLocation": "Lokalizacja kopii zapasowej",
|
"LabelBackupLocation": "Lokalizacja kopii zapasowej",
|
||||||
"LabelBackupsEnableAutomaticBackups": "Włącz automatyczne kopie zapasowe",
|
"LabelBackupsEnableAutomaticBackups": "Włącz automatyczne kopie zapasowe",
|
||||||
"LabelBackupsEnableAutomaticBackupsHelp": "Kopie zapasowe są zapisywane w folderze /metadata/backups",
|
"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.",
|
"LabelBackupsMaxBackupSizeHelp": "Jako zabezpieczenie przed błędną konfiguracją, kopie zapasowe nie będą wykonywane, jeśli przekroczą skonfigurowany rozmiar.",
|
||||||
"LabelBackupsNumberToKeep": "Liczba kopii zapasowych do przechowywania",
|
"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ąć.",
|
"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",
|
"LabelBitrate": "Bitrate",
|
||||||
"LabelBooks": "Książki",
|
"LabelBooks": "Książki",
|
||||||
"LabelButtonText": "Button Text",
|
"LabelButtonText": "Button Text",
|
||||||
"LabelByAuthor": "by {0}",
|
"LabelByAuthor": "autorstwa {0}",
|
||||||
"LabelChangePassword": "Zmień hasło",
|
"LabelChangePassword": "Zmień hasło",
|
||||||
"LabelChannels": "Kanały",
|
"LabelChannels": "Kanały",
|
||||||
"LabelChapterTitle": "Tytuł rozdziału",
|
"LabelChapterTitle": "Tytuł rozdziału",
|
||||||
|
|
@ -247,7 +249,7 @@
|
||||||
"LabelCollections": "Kolekcje",
|
"LabelCollections": "Kolekcje",
|
||||||
"LabelComplete": "Ukończone",
|
"LabelComplete": "Ukończone",
|
||||||
"LabelConfirmPassword": "Potwierdź hasło",
|
"LabelConfirmPassword": "Potwierdź hasło",
|
||||||
"LabelContinueListening": "Kontynuuj odtwarzanie",
|
"LabelContinueListening": "Kontynuuj słuchanie",
|
||||||
"LabelContinueReading": "Kontynuuj czytanie",
|
"LabelContinueReading": "Kontynuuj czytanie",
|
||||||
"LabelContinueSeries": "Kontynuuj serię",
|
"LabelContinueSeries": "Kontynuuj serię",
|
||||||
"LabelCover": "Okładka",
|
"LabelCover": "Okładka",
|
||||||
|
|
@ -319,6 +321,7 @@
|
||||||
"LabelHardDeleteFile": "Usuń trwale plik",
|
"LabelHardDeleteFile": "Usuń trwale plik",
|
||||||
"LabelHasEbook": "Ma ebooka",
|
"LabelHasEbook": "Ma ebooka",
|
||||||
"LabelHasSupplementaryEbook": "Posiada dodatkowy ebook",
|
"LabelHasSupplementaryEbook": "Posiada dodatkowy ebook",
|
||||||
|
"LabelHideSubtitles": "Ukryj napisy",
|
||||||
"LabelHighestPriority": "Najwyższy priorytet",
|
"LabelHighestPriority": "Najwyższy priorytet",
|
||||||
"LabelHost": "Host",
|
"LabelHost": "Host",
|
||||||
"LabelHour": "Godzina",
|
"LabelHour": "Godzina",
|
||||||
|
|
@ -413,7 +416,7 @@
|
||||||
"LabelOverwrite": "Nadpisz",
|
"LabelOverwrite": "Nadpisz",
|
||||||
"LabelPassword": "Hasło",
|
"LabelPassword": "Hasło",
|
||||||
"LabelPath": "Ścieżka",
|
"LabelPath": "Ścieżka",
|
||||||
"LabelPermanent": "Trwały",
|
"LabelPermanent": "Stałe",
|
||||||
"LabelPermissionsAccessAllLibraries": "Ma dostęp do wszystkich bibliotek",
|
"LabelPermissionsAccessAllLibraries": "Ma dostęp do wszystkich bibliotek",
|
||||||
"LabelPermissionsAccessAllTags": "Ma dostęp do wszystkich tagów",
|
"LabelPermissionsAccessAllTags": "Ma dostęp do wszystkich tagów",
|
||||||
"LabelPermissionsAccessExplicitContent": "Ma dostęp do treści oznacznych jako nieprzyzwoite",
|
"LabelPermissionsAccessExplicitContent": "Ma dostęp do treści oznacznych jako nieprzyzwoite",
|
||||||
|
|
@ -446,6 +449,7 @@
|
||||||
"LabelRSSFeedPreventIndexing": "Zapobiegaj indeksowaniu",
|
"LabelRSSFeedPreventIndexing": "Zapobiegaj indeksowaniu",
|
||||||
"LabelRSSFeedSlug": "RSS Feed Slug",
|
"LabelRSSFeedSlug": "RSS Feed Slug",
|
||||||
"LabelRSSFeedURL": "URL kanały RSS",
|
"LabelRSSFeedURL": "URL kanały RSS",
|
||||||
|
"LabelReAddSeriesToContinueListening": "Ponownie Dodaj Serię do sekcji Kontunuuj Odtwarzanie",
|
||||||
"LabelRead": "Czytaj",
|
"LabelRead": "Czytaj",
|
||||||
"LabelReadAgain": "Czytaj ponownie",
|
"LabelReadAgain": "Czytaj ponownie",
|
||||||
"LabelReadEbookWithoutProgress": "Czytaj książkę bez zapamiętywania postępu",
|
"LabelReadEbookWithoutProgress": "Czytaj książkę bez zapamiętywania postępu",
|
||||||
|
|
@ -516,6 +520,7 @@
|
||||||
"LabelShareURL": "Link do udziału",
|
"LabelShareURL": "Link do udziału",
|
||||||
"LabelShowAll": "Pokaż wszystko",
|
"LabelShowAll": "Pokaż wszystko",
|
||||||
"LabelShowSeconds": "Pokaż sekundy",
|
"LabelShowSeconds": "Pokaż sekundy",
|
||||||
|
"LabelShowSubtitles": "Pokaż Napisy",
|
||||||
"LabelSize": "Rozmiar",
|
"LabelSize": "Rozmiar",
|
||||||
"LabelSleepTimer": "Wyłącznik czasowy",
|
"LabelSleepTimer": "Wyłącznik czasowy",
|
||||||
"LabelSlug": "Slug",
|
"LabelSlug": "Slug",
|
||||||
|
|
@ -534,10 +539,10 @@
|
||||||
"LabelStatsItemsFinished": "Pozycje zakończone",
|
"LabelStatsItemsFinished": "Pozycje zakończone",
|
||||||
"LabelStatsItemsInLibrary": "Pozycje w bibliotece",
|
"LabelStatsItemsInLibrary": "Pozycje w bibliotece",
|
||||||
"LabelStatsMinutes": "Minuty",
|
"LabelStatsMinutes": "Minuty",
|
||||||
"LabelStatsMinutesListening": "Minuty odtwarzania",
|
"LabelStatsMinutesListening": "Minuty słuchania",
|
||||||
"LabelStatsOverallDays": "Całkowity czas (dni)",
|
"LabelStatsOverallDays": "Całkowity czas (dni)",
|
||||||
"LabelStatsOverallHours": "Całkowity czas (godziny)",
|
"LabelStatsOverallHours": "Całkowity czas (godziny)",
|
||||||
"LabelStatsWeekListening": "Tydzień odtwarzania",
|
"LabelStatsWeekListening": "Tydzień słuchania",
|
||||||
"LabelSubtitle": "Podtytuł",
|
"LabelSubtitle": "Podtytuł",
|
||||||
"LabelSupportedFileTypes": "Obsługiwane typy plików",
|
"LabelSupportedFileTypes": "Obsługiwane typy plików",
|
||||||
"LabelTag": "Tag",
|
"LabelTag": "Tag",
|
||||||
|
|
@ -592,6 +597,7 @@
|
||||||
"LabelVersion": "Wersja",
|
"LabelVersion": "Wersja",
|
||||||
"LabelViewBookmarks": "Wyświetlaj zakładki",
|
"LabelViewBookmarks": "Wyświetlaj zakładki",
|
||||||
"LabelViewChapters": "Wyświetlaj rozdziały",
|
"LabelViewChapters": "Wyświetlaj rozdziały",
|
||||||
|
"LabelViewPlayerSettings": "Zobacz ustawienia odtwarzacza",
|
||||||
"LabelViewQueue": "Wyświetlaj kolejkę odtwarzania",
|
"LabelViewQueue": "Wyświetlaj kolejkę odtwarzania",
|
||||||
"LabelVolume": "Głośność",
|
"LabelVolume": "Głośność",
|
||||||
"LabelWeekdaysToRun": "Dni tygodnia",
|
"LabelWeekdaysToRun": "Dni tygodnia",
|
||||||
|
|
@ -642,7 +648,7 @@
|
||||||
"MessageConfirmRemoveEpisodes": "Czy na pewno chcesz usunąć {0} odcinki?",
|
"MessageConfirmRemoveEpisodes": "Czy na pewno chcesz usunąć {0} odcinki?",
|
||||||
"MessageConfirmRemoveListeningSessions": "Czy na pewno chcesz usunąć {0} sesji słuchania?",
|
"MessageConfirmRemoveListeningSessions": "Czy na pewno chcesz usunąć {0} sesji słuchania?",
|
||||||
"MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?",
|
"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?",
|
"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.",
|
"MessageConfirmRenameGenreMergeNote": "Note: This genre already exists so they will be merged.",
|
||||||
"MessageConfirmRenameGenreWarning": "Warning! A similar genre with a different casing already exists \"{0}\".",
|
"MessageConfirmRenameGenreWarning": "Warning! A similar genre with a different casing already exists \"{0}\".",
|
||||||
|
|
@ -663,7 +669,7 @@
|
||||||
"MessageItemsSelected": "{0} zaznaczone elementy",
|
"MessageItemsSelected": "{0} zaznaczone elementy",
|
||||||
"MessageItemsUpdated": "{0} Items Updated",
|
"MessageItemsUpdated": "{0} Items Updated",
|
||||||
"MessageJoinUsOn": "Dołącz do nas na",
|
"MessageJoinUsOn": "Dołącz do nas na",
|
||||||
"MessageListeningSessionsInTheLastYear": "{0} sesje odsłuchowe w ostatnim roku",
|
"MessageListeningSessionsInTheLastYear": "Sesje słuchania w ostatnim roku: {0}",
|
||||||
"MessageLoading": "Ładowanie...",
|
"MessageLoading": "Ładowanie...",
|
||||||
"MessageLoadingFolders": "Ładowanie folderów...",
|
"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>.",
|
"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",
|
"MessageNoIssues": "Brak problemów",
|
||||||
"MessageNoItems": "Brak elementów",
|
"MessageNoItems": "Brak elementów",
|
||||||
"MessageNoItemsFound": "Nie znaleziono żadnych elementów",
|
"MessageNoItemsFound": "Nie znaleziono żadnych elementów",
|
||||||
"MessageNoListeningSessions": "Brak sesji odtwarzania",
|
"MessageNoListeningSessions": "Brak sesji słuchania",
|
||||||
"MessageNoLogs": "Brak logów",
|
"MessageNoLogs": "Brak logów",
|
||||||
"MessageNoMediaProgress": "Brak postępu",
|
"MessageNoMediaProgress": "Brak postępu",
|
||||||
"MessageNoNotifications": "Brak powiadomień",
|
"MessageNoNotifications": "Brak powiadomień",
|
||||||
|
|
@ -709,7 +715,7 @@
|
||||||
"MessageOr": "lub",
|
"MessageOr": "lub",
|
||||||
"MessagePauseChapter": "Zatrzymaj odtwarzanie rozdziały",
|
"MessagePauseChapter": "Zatrzymaj odtwarzanie rozdziały",
|
||||||
"MessagePlayChapter": "Rozpocznij odtwarzanie od początku rozdziału",
|
"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",
|
"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'.",
|
"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ł",
|
"MessageRemoveChapter": "Usuń rozdział",
|
||||||
|
|
@ -724,8 +730,9 @@
|
||||||
"MessageSelected": "{0} wybranych",
|
"MessageSelected": "{0} wybranych",
|
||||||
"MessageServerCouldNotBeReached": "Nie udało się uzyskać połączenia z serwerem",
|
"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",
|
"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}",
|
"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}?",
|
"MessageStartPlaybackAtTime": "Rozpoczęcie odtwarzania \"{0}\" od {1}?",
|
||||||
"MessageThinking": "Myślę...",
|
"MessageThinking": "Myślę...",
|
||||||
"MessageUploaderItemFailed": "Nie udało się przesłać",
|
"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.",
|
"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",
|
"PlaceholderNewCollection": "Nowa nazwa kolekcji",
|
||||||
"PlaceholderNewFolderPath": "Nowa ścieżka folderu",
|
"PlaceholderNewFolderPath": "Nowa ścieżka folderu",
|
||||||
"PlaceholderNewPlaylist": "New playlist name",
|
"PlaceholderNewPlaylist": "Nowa nazwa playlisty",
|
||||||
"PlaceholderSearch": "Szukanie..",
|
"PlaceholderSearch": "Szukanie..",
|
||||||
"PlaceholderSearchEpisode": "Szukanie odcinka..",
|
"PlaceholderSearchEpisode": "Szukanie odcinka..",
|
||||||
"ToastAccountUpdateFailed": "Nie udało się zaktualizować konta",
|
"ToastAccountUpdateFailed": "Nie udało się zaktualizować konta",
|
||||||
|
|
@ -802,12 +809,12 @@
|
||||||
"ToastLibraryScanStarted": "Rozpoczęto skanowanie biblioteki",
|
"ToastLibraryScanStarted": "Rozpoczęto skanowanie biblioteki",
|
||||||
"ToastLibraryUpdateFailed": "Nie udało się zaktualizować biblioteki",
|
"ToastLibraryUpdateFailed": "Nie udało się zaktualizować biblioteki",
|
||||||
"ToastLibraryUpdateSuccess": "Zaktualizowano \"{0}\" pozycji",
|
"ToastLibraryUpdateSuccess": "Zaktualizowano \"{0}\" pozycji",
|
||||||
"ToastPlaylistCreateFailed": "Failed to create playlist",
|
"ToastPlaylistCreateFailed": "Nie udało się utworzyć playlisty",
|
||||||
"ToastPlaylistCreateSuccess": "Playlist created",
|
"ToastPlaylistCreateSuccess": "Playlista utworzona",
|
||||||
"ToastPlaylistRemoveFailed": "Failed to remove playlist",
|
"ToastPlaylistRemoveFailed": "Nie udało się usunąć playlisty",
|
||||||
"ToastPlaylistRemoveSuccess": "Playlist removed",
|
"ToastPlaylistRemoveSuccess": "Playlista usunięta",
|
||||||
"ToastPlaylistUpdateFailed": "Failed to update playlist",
|
"ToastPlaylistUpdateFailed": "Nie udało się zaktualizować playlisty",
|
||||||
"ToastPlaylistUpdateSuccess": "Playlist updated",
|
"ToastPlaylistUpdateSuccess": "Playlista zaktualizowana",
|
||||||
"ToastPodcastCreateFailed": "Nie udało się utworzyć podcastu",
|
"ToastPodcastCreateFailed": "Nie udało się utworzyć podcastu",
|
||||||
"ToastPodcastCreateSuccess": "Podcast został pomyślnie utworzony",
|
"ToastPodcastCreateSuccess": "Podcast został pomyślnie utworzony",
|
||||||
"ToastRSSFeedCloseFailed": "Zamknięcie kanału RSS nie powiodło się",
|
"ToastRSSFeedCloseFailed": "Zamknięcie kanału RSS nie powiodło się",
|
||||||
|
|
|
||||||
|
|
@ -58,14 +58,14 @@ paths:
|
||||||
404:
|
404:
|
||||||
description: Not found
|
description: Not found
|
||||||
|
|
||||||
/api/podcasts/opml:
|
/api/podcasts/opml/parse:
|
||||||
post:
|
post:
|
||||||
summary: Get feeds from OPML text
|
summary: Get feeds from OPML text
|
||||||
|
description: Parse OPML text and return an array of feeds
|
||||||
operationId: getFeedsFromOPMLText
|
operationId: getFeedsFromOPMLText
|
||||||
tags:
|
tags:
|
||||||
- Podcasts
|
- Podcasts
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
|
|
@ -73,20 +73,58 @@ paths:
|
||||||
properties:
|
properties:
|
||||||
opmlText:
|
opmlText:
|
||||||
type: string
|
type: string
|
||||||
description: The OPML text containing podcast feeds
|
|
||||||
responses:
|
responses:
|
||||||
200:
|
'200':
|
||||||
description: Successfully retrieved feeds from OPML text
|
description: Successfully parsed OPML text and returned feeds
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
feeds:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: '../objects/mediaTypes/Podcast.yaml#/components/schemas/Podcast'
|
type: object
|
||||||
400:
|
properties:
|
||||||
description: Bad request
|
title:
|
||||||
403:
|
type: string
|
||||||
description: Forbidden
|
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:
|
/api/podcasts/{id}/checknew:
|
||||||
parameters:
|
parameters:
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ components:
|
||||||
nullable: true
|
nullable: true
|
||||||
format: 'pod_[a-z0-9]{18}'
|
format: 'pod_[a-z0-9]{18}'
|
||||||
example: pod_o78uaoeuh78h6aoeif
|
example: pod_o78uaoeuh78h6aoeif
|
||||||
|
autoDownloadEpisodes:
|
||||||
|
type: boolean
|
||||||
|
description: Whether episodes are automatically downloaded.
|
||||||
|
|
||||||
Podcast:
|
Podcast:
|
||||||
type: object
|
type: object
|
||||||
|
|
@ -37,8 +40,7 @@ components:
|
||||||
items:
|
items:
|
||||||
$ref: '../entities/PodcastEpisode.yaml#/components/schemas/PodcastEpisode'
|
$ref: '../entities/PodcastEpisode.yaml#/components/schemas/PodcastEpisode'
|
||||||
autoDownloadEpisodes:
|
autoDownloadEpisodes:
|
||||||
type: boolean
|
$ref: '#/components/schemas/autoDownloadEpisodes'
|
||||||
description: Whether episodes are automatically downloaded.
|
|
||||||
autoDownloadSchedule:
|
autoDownloadSchedule:
|
||||||
type: string
|
type: string
|
||||||
description: The schedule for automatic episode downloads, in cron format.
|
description: The schedule for automatic episode downloads, in cron format.
|
||||||
|
|
|
||||||
|
|
@ -1589,23 +1589,22 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/podcasts/opml": {
|
"/api/podcasts/opml/parse": {
|
||||||
"post": {
|
"post": {
|
||||||
"summary": "Get feeds from OPML text",
|
"summary": "Get feeds from OPML text",
|
||||||
|
"description": "Parse OPML text and return an array of feeds",
|
||||||
"operationId": "getFeedsFromOPMLText",
|
"operationId": "getFeedsFromOPMLText",
|
||||||
"tags": [
|
"tags": [
|
||||||
"Podcasts"
|
"Podcasts"
|
||||||
],
|
],
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"required": true,
|
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"opmlText": {
|
"opmlText": {
|
||||||
"type": "string",
|
"type": "string"
|
||||||
"description": "The OPML text containing podcast feeds"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1614,23 +1613,85 @@
|
||||||
},
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successfully retrieved feeds from OPML text",
|
"description": "Successfully parsed OPML text and returned feeds",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"feeds": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/components/schemas/Podcast"
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"feedUrl": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"400": {
|
"400": {
|
||||||
"description": "Bad request"
|
"description": "Bad request, OPML text not provided"
|
||||||
},
|
},
|
||||||
"403": {
|
"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": {
|
"Podcast": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "A podcast containing multiple episodes.",
|
"description": "A podcast containing multiple episodes.",
|
||||||
|
|
@ -3889,8 +3954,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"autoDownloadEpisodes": {
|
"autoDownloadEpisodes": {
|
||||||
"type": "boolean",
|
"$ref": "#/components/schemas/autoDownloadEpisodes"
|
||||||
"description": "Whether episodes are automatically downloaded."
|
|
||||||
},
|
},
|
||||||
"autoDownloadSchedule": {
|
"autoDownloadSchedule": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|
|
||||||
|
|
@ -57,8 +57,10 @@ paths:
|
||||||
$ref: './controllers/PodcastController.yaml#/paths/~1api~1podcasts'
|
$ref: './controllers/PodcastController.yaml#/paths/~1api~1podcasts'
|
||||||
/api/podcasts/feed:
|
/api/podcasts/feed:
|
||||||
$ref: './controllers/PodcastController.yaml#/paths/~1api~1podcasts~1feed'
|
$ref: './controllers/PodcastController.yaml#/paths/~1api~1podcasts~1feed'
|
||||||
/api/podcasts/opml:
|
/api/podcasts/opml/parse:
|
||||||
$ref: './controllers/PodcastController.yaml#/paths/~1api~1podcasts~1opml'
|
$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:
|
/api/podcasts/{id}/checknew:
|
||||||
$ref: './controllers/PodcastController.yaml#/paths/~1api~1podcasts~1{id}~1checknew'
|
$ref: './controllers/PodcastController.yaml#/paths/~1api~1podcasts~1{id}~1checknew'
|
||||||
/api/podcasts/{id}/clear-queue:
|
/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)
|
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)
|
### 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)
|
### 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)_**
|
Using Test Flight: https://testflight.apple.com/join/wiic7QIW **_(beta is full)_**
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -207,6 +207,7 @@ class Database {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.sequelize.authenticate()
|
await this.sequelize.authenticate()
|
||||||
|
await this.loadExtensions([process.env.SQLEAN_UNICODE_PATH])
|
||||||
Logger.info(`[Database] Db connection was successful`)
|
Logger.info(`[Database] Db connection was successful`)
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} 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
|
* Disconnect from db
|
||||||
*/
|
*/
|
||||||
|
|
@ -801,6 +830,39 @@ class Database {
|
||||||
Logger.warn(`Removed ${badSessionsRemoved} sessions that were 3 seconds or less`)
|
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()
|
module.exports = new Database()
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,8 @@ class Server {
|
||||||
|
|
||||||
await this.playbackSessionManager.removeOrphanStreams()
|
await this.playbackSessionManager.removeOrphanStreams()
|
||||||
|
|
||||||
|
await this.binaryManager.init()
|
||||||
|
|
||||||
await Database.init(false)
|
await Database.init(false)
|
||||||
|
|
||||||
await Logger.logManager.init()
|
await Logger.logManager.init()
|
||||||
|
|
@ -128,11 +130,6 @@ class Server {
|
||||||
await this.cronManager.init(libraries)
|
await this.cronManager.init(libraries)
|
||||||
this.apiCacheManager.init()
|
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) {
|
if (Database.serverSettings.scannerDisableWatcher) {
|
||||||
Logger.info(`[Server] Watcher is disabled`)
|
Logger.info(`[Server] Watcher is disabled`)
|
||||||
this.watcher.disabled = true
|
this.watcher.disabled = true
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,15 @@ const CoverManager = require('../managers/CoverManager')
|
||||||
const LibraryItem = require('../objects/LibraryItem')
|
const LibraryItem = require('../objects/LibraryItem')
|
||||||
|
|
||||||
class PodcastController {
|
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) {
|
async create(req, res) {
|
||||||
if (!req.user.isAdminOrUp) {
|
if (!req.user.isAdminOrUp) {
|
||||||
Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to create podcast`)
|
Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to create podcast`)
|
||||||
|
|
@ -133,6 +142,14 @@ class PodcastController {
|
||||||
res.json({ podcast })
|
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) {
|
async getFeedsFromOPMLText(req, res) {
|
||||||
if (!req.user.isAdminOrUp) {
|
if (!req.user.isAdminOrUp) {
|
||||||
Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to get feeds from opml`)
|
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)
|
return res.sendStatus(400)
|
||||||
}
|
}
|
||||||
|
|
||||||
const rssFeedsData = await this.podcastManager.getOPMLFeeds(req.body.opmlText)
|
res.json({
|
||||||
res.json(rssFeedsData)
|
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) {
|
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 Path = require('path')
|
||||||
const fs = require('../libs/fsExtra')
|
const fs = require('../libs/fsExtra')
|
||||||
|
|
||||||
const workerThreads = require('worker_threads')
|
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const TaskManager = require('./TaskManager')
|
const TaskManager = require('./TaskManager')
|
||||||
const Task = require('../objects/Task')
|
const Task = require('../objects/Task')
|
||||||
const { writeConcatFile } = require('../utils/ffmpegHelpers')
|
const { writeConcatFile } = require('../utils/ffmpegHelpers')
|
||||||
const ffmpegHelpers = 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 {
|
class AbMergeManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|
@ -20,6 +22,7 @@ class AbMergeManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelEncode(task) {
|
cancelEncode(task) {
|
||||||
|
task.setFailed('Task canceled by user')
|
||||||
return this.removeTask(task, true)
|
return this.removeTask(task, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -36,6 +39,7 @@ class AbMergeManager {
|
||||||
libraryItemPath: libraryItem.path,
|
libraryItemPath: libraryItem.path,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
originalTrackPaths: libraryItem.media.tracks.map((t) => t.metadata.path),
|
originalTrackPaths: libraryItem.media.tracks.map((t) => t.metadata.path),
|
||||||
|
inos: libraryItem.media.includedAudioFiles.map((f) => f.ino),
|
||||||
tempFilepath,
|
tempFilepath,
|
||||||
targetFilename,
|
targetFilename,
|
||||||
targetFilepath: Path.join(libraryItem.path, targetFilename),
|
targetFilepath: Path.join(libraryItem.path, targetFilename),
|
||||||
|
|
@ -43,7 +47,8 @@ class AbMergeManager {
|
||||||
ffmetadataObject: ffmpegHelpers.getFFMetadataObject(libraryItem, 1),
|
ffmetadataObject: ffmpegHelpers.getFFMetadataObject(libraryItem, 1),
|
||||||
chapters: libraryItem.media.chapters?.map((c) => ({ ...c })),
|
chapters: libraryItem.media.chapters?.map((c) => ({ ...c })),
|
||||||
coverPath: libraryItem.media.coverPath,
|
coverPath: libraryItem.media.coverPath,
|
||||||
ffmetadataPath
|
ffmetadataPath,
|
||||||
|
duration: libraryItem.media.duration
|
||||||
}
|
}
|
||||||
const taskDescription = `Encoding audiobook "${libraryItem.media.metadata.title}" into a single m4b file.`
|
const taskDescription = `Encoding audiobook "${libraryItem.media.metadata.title}" into a single m4b file.`
|
||||||
task.setData('encode-m4b', 'Encoding M4b', taskDescription, false, taskData)
|
task.setData('encode-m4b', 'Encoding M4b', taskDescription, false, taskData)
|
||||||
|
|
@ -58,119 +63,78 @@ class AbMergeManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
async runAudiobookMerge(libraryItem, task, encodingOptions) {
|
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
|
// Create ffmetadata file
|
||||||
const success = await ffmpegHelpers.writeFFMetadataFile(task.data.metadataObject, task.data.chapters, task.data.ffmetadataPath)
|
if (!(await ffmpegHelpers.writeFFMetadataFile(task.data.ffmetadataObject, task.data.chapters, task.data.ffmetadataPath))) {
|
||||||
if (!success) {
|
|
||||||
Logger.error(`[AudioMetadataManager] Failed to write ffmetadata file for audiobook "${task.data.libraryItemId}"`)
|
Logger.error(`[AudioMetadataManager] Failed to write ffmetadata file for audiobook "${task.data.libraryItemId}"`)
|
||||||
task.setFailed('Failed to write metadata file.')
|
task.setFailed('Failed to write metadata file.')
|
||||||
this.removeTask(task, true)
|
this.removeTask(task, true)
|
||||||
return
|
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({
|
this.pendingTasks.push({
|
||||||
id: task.id,
|
id: task.id,
|
||||||
task,
|
task
|
||||||
worker
|
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
async sendResult(task, result) {
|
const encodeFraction = 0.95
|
||||||
// Remove pending task
|
const embedFraction = 1 - encodeFraction
|
||||||
this.pendingTasks = this.pendingTasks.filter((d) => d.id !== task.id)
|
try {
|
||||||
|
const trackProgressMonitor = new TrackProgressMonitor(
|
||||||
if (result.isKilled) {
|
libraryItem.media.tracks.map((t) => t.duration),
|
||||||
task.setFailed('Ffmpeg task killed')
|
(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)
|
this.removeTask(task, true)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
task.setFailed('Encoding failed')
|
|
||||||
this.removeTask(task, true)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write metadata to merged file
|
// Write metadata to merged file
|
||||||
const success = await ffmpegHelpers.addCoverAndMetadataToFile(task.data.tempFilepath, task.data.coverPath, task.data.ffmetadataPath, 1, 'audio/mp4')
|
try {
|
||||||
if (!success) {
|
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}"`)
|
Logger.error(`[AbMergeManager] Failed to write metadata to file "${task.data.tempFilepath}"`)
|
||||||
task.setFailed('Failed to write metadata to m4b file')
|
task.setFailed('Failed to write metadata to m4b file')
|
||||||
this.removeTask(task, true)
|
this.removeTask(task, true)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -199,19 +163,14 @@ class AbMergeManager {
|
||||||
async removeTask(task, removeTempFilepath = false) {
|
async removeTask(task, removeTempFilepath = false) {
|
||||||
Logger.info('[AbMergeManager] Removing task ' + task.id)
|
Logger.info('[AbMergeManager] Removing task ' + task.id)
|
||||||
|
|
||||||
const pendingDl = this.pendingTasks.find((d) => d.id === task.id)
|
const pendingTask = this.pendingTasks.find((d) => d.id === task.id)
|
||||||
if (pendingDl) {
|
if (pendingTask) {
|
||||||
this.pendingTasks = this.pendingTasks.filter((d) => d.id !== task.id)
|
this.pendingTasks = this.pendingTasks.filter((d) => d.id !== task.id)
|
||||||
if (pendingDl.worker) {
|
if (task.data.ffmpeg) {
|
||||||
Logger.warn(`[AbMergeManager] Removing download in progress - stopping worker`)
|
Logger.warn(`[AbMergeManager] Killing ffmpeg process for task ${task.id}`)
|
||||||
try {
|
task.data.ffmpeg.kill()
|
||||||
pendingDl.worker.postMessage('STOP')
|
// wait for ffmpeg to exit, so that the output file is unlocked
|
||||||
return
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
} catch (error) {
|
|
||||||
Logger.error('[AbMergeManager] Error posting stop message to worker', error)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Logger.debug(`[AbMergeManager] Removing download in progress - no worker`)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,11 @@
|
||||||
const Path = require('path')
|
const Path = require('path')
|
||||||
|
|
||||||
const SocketAuthority = require('../SocketAuthority')
|
const SocketAuthority = require('../SocketAuthority')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
|
|
||||||
const fs = require('../libs/fsExtra')
|
const fs = require('../libs/fsExtra')
|
||||||
|
|
||||||
const ffmpegHelpers = require('../utils/ffmpegHelpers')
|
const ffmpegHelpers = require('../utils/ffmpegHelpers')
|
||||||
|
|
||||||
const TaskManager = require('./TaskManager')
|
const TaskManager = require('./TaskManager')
|
||||||
|
|
||||||
const Task = require('../objects/Task')
|
const Task = require('../objects/Task')
|
||||||
|
const fileUtils = require('../utils/fileUtils')
|
||||||
|
|
||||||
class AudioMetadataMangaer {
|
class AudioMetadataMangaer {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|
@ -68,7 +64,8 @@ class AudioMetadataMangaer {
|
||||||
ino: af.ino,
|
ino: af.ino,
|
||||||
filename: af.metadata.filename,
|
filename: af.metadata.filename,
|
||||||
path: af.metadata.path,
|
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,
|
coverPath: libraryItem.media.coverPath,
|
||||||
metadataObject: ffmpegHelpers.getFFMetadataObject(libraryItem, audioFiles.length),
|
metadataObject: ffmpegHelpers.getFFMetadataObject(libraryItem, audioFiles.length),
|
||||||
|
|
@ -78,7 +75,8 @@ class AudioMetadataMangaer {
|
||||||
options: {
|
options: {
|
||||||
forceEmbedChapters,
|
forceEmbedChapters,
|
||||||
backupFiles
|
backupFiles
|
||||||
}
|
},
|
||||||
|
duration: libraryItem.media.duration
|
||||||
}
|
}
|
||||||
const taskDescription = `Embedding metadata in audiobook "${libraryItem.media.metadata.title}".`
|
const taskDescription = `Embedding metadata in audiobook "${libraryItem.media.metadata.title}".`
|
||||||
task.setData('embed-metadata', 'Embedding Metadata', taskDescription, false, taskData)
|
task.setData('embed-metadata', 'Embedding Metadata', taskDescription, false, taskData)
|
||||||
|
|
@ -101,11 +99,40 @@ class AudioMetadataMangaer {
|
||||||
|
|
||||||
Logger.info(`[AudioMetadataManager] Starting metadata embed task`, task.description)
|
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
|
// Ensure item cache dir exists
|
||||||
let cacheDirCreated = false
|
let cacheDirCreated = false
|
||||||
if (!(await fs.pathExists(task.data.itemCachePath))) {
|
if (!(await fs.pathExists(task.data.itemCachePath))) {
|
||||||
|
try {
|
||||||
await fs.mkdir(task.data.itemCachePath)
|
await fs.mkdir(task.data.itemCachePath)
|
||||||
cacheDirCreated = true
|
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
|
// Create ffmetadata file
|
||||||
|
|
@ -119,8 +146,10 @@ class AudioMetadataMangaer {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tag audio files
|
// Tag audio files
|
||||||
|
let cummulativeProgress = 0
|
||||||
for (const af of task.data.audioFiles) {
|
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,
|
libraryItemId: task.data.libraryItemId,
|
||||||
ino: af.ino
|
ino: af.ino
|
||||||
})
|
})
|
||||||
|
|
@ -133,18 +162,31 @@ class AudioMetadataMangaer {
|
||||||
Logger.debug(`[AudioMetadataManager] Backed up audio file at "${backupFilePath}"`)
|
Logger.debug(`[AudioMetadataManager] Backed up audio file at "${backupFilePath}"`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Logger.error(`[AudioMetadataManager] Failed to backup audio file "${af.path}"`, 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)
|
try {
|
||||||
if (success) {
|
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}"`)
|
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,
|
libraryItemId: task.data.libraryItemId,
|
||||||
ino: af.ino
|
ino: af.ino
|
||||||
})
|
})
|
||||||
|
|
||||||
|
cummulativeProgress += audioFileRelativeDuration * 100
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove temp cache file/folder if not backing up
|
// Remove temp cache file/folder if not backing up
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ class BackupManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
get maxBackupSize() {
|
get maxBackupSize() {
|
||||||
return global.ServerSettings.maxBackupSize || 1
|
return global.ServerSettings.maxBackupSize || Infinity
|
||||||
}
|
}
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
|
|
@ -216,7 +216,9 @@ class BackupManager {
|
||||||
Logger.info(`[BackupManager] Saved backup sqlite file at "${dbPath}"`)
|
Logger.info(`[BackupManager] Saved backup sqlite file at "${dbPath}"`)
|
||||||
|
|
||||||
// Extract /metadata/items and /metadata/authors folders
|
// Extract /metadata/items and /metadata/authors folders
|
||||||
|
await fs.ensureDir(this.ItemsMetadataPath)
|
||||||
await zip.extract('metadata-items/', this.ItemsMetadataPath)
|
await zip.extract('metadata-items/', this.ItemsMetadataPath)
|
||||||
|
await fs.ensureDir(this.AuthorsMetadataPath)
|
||||||
await zip.extract('metadata-authors/', this.AuthorsMetadataPath)
|
await zip.extract('metadata-authors/', this.AuthorsMetadataPath)
|
||||||
await zip.close()
|
await zip.close()
|
||||||
|
|
||||||
|
|
@ -419,6 +421,7 @@ class BackupManager {
|
||||||
reject(err)
|
reject(err)
|
||||||
})
|
})
|
||||||
archive.on('progress', ({ fs: fsobj }) => {
|
archive.on('progress', ({ fs: fsobj }) => {
|
||||||
|
if (this.maxBackupSize !== Infinity) {
|
||||||
const maxBackupSizeInBytes = this.maxBackupSize * 1000 * 1000 * 1000
|
const maxBackupSizeInBytes = this.maxBackupSize * 1000 * 1000 * 1000
|
||||||
if (fsobj.processedBytes > maxBackupSizeInBytes) {
|
if (fsobj.processedBytes > maxBackupSizeInBytes) {
|
||||||
Logger.error(`[BackupManager] Archiver is too large - aborting to prevent endless loop, Bytes Processed: ${fsobj.processedBytes}`)
|
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
|
output.destroy('Backup too large') // Promise is reject in write stream error evt
|
||||||
}, 500)
|
}, 500)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// pipe archive data to the file
|
// pipe archive data to the file
|
||||||
|
|
|
||||||
|
|
@ -2,25 +2,274 @@ const child_process = require('child_process')
|
||||||
const { promisify } = require('util')
|
const { promisify } = require('util')
|
||||||
const exec = promisify(child_process.exec)
|
const exec = promisify(child_process.exec)
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
|
const axios = require('axios')
|
||||||
const which = require('../libs/which')
|
const which = require('../libs/which')
|
||||||
const fs = require('../libs/fsExtra')
|
const fs = require('../libs/fsExtra')
|
||||||
const ffbinaries = require('../libs/ffbinaries')
|
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const fileUtils = require('../utils/fileUtils')
|
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 {
|
class BinaryManager {
|
||||||
|
|
||||||
defaultRequiredBinaries = [
|
defaultRequiredBinaries = [
|
||||||
{ name: 'ffmpeg', envVariable: 'FFMPEG_PATH', validVersions: ['5.1'] },
|
new Binary('ffmpeg', 'executable', 'FFMPEG_PATH', ['5.1'], ffbinaries), // ffmpeg executable
|
||||||
{ name: 'ffprobe', envVariable: 'FFPROBE_PATH', validVersions: ['5.1'] }
|
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) {
|
constructor(requiredBinaries = this.defaultRequiredBinaries) {
|
||||||
this.requiredBinaries = requiredBinaries
|
this.requiredBinaries = requiredBinaries
|
||||||
this.mainInstallPath = process.pkg ? path.dirname(process.execPath) : global.appRoot
|
this.mainInstallDir = process.pkg ? path.dirname(process.execPath) : global.appRoot
|
||||||
this.altInstallPath = global.ConfigPath
|
this.altInstallDir = global.ConfigPath
|
||||||
this.initialized = false
|
this.initialized = false
|
||||||
this.exec = exec
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
|
|
@ -44,24 +293,18 @@ class BinaryManager {
|
||||||
this.initialized = true
|
this.initialized = true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async removeBinary(destination, binary) {
|
||||||
* Remove old/invalid binaries in main or alt install path
|
const binaryPath = path.join(destination, binary.fileName)
|
||||||
*
|
if (await fs.pathExists(binaryPath)) {
|
||||||
* @param {string[]} binaryNames
|
Logger.debug(`[BinaryManager] Removing binary: ${binaryPath}`)
|
||||||
*/
|
await fs.remove(binaryPath)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
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() {
|
async findRequiredBinaries() {
|
||||||
const missingBinaries = []
|
const missingBinaries = []
|
||||||
for (const binary of this.requiredBinaries) {
|
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) {
|
if (binaryPath) {
|
||||||
Logger.info(`[BinaryManager] Found valid binary ${binary.name} at ${binaryPath}`)
|
Logger.info(`[BinaryManager] Found valid binary ${binary.name} at ${binaryPath}`)
|
||||||
if (process.env[binary.envVariable] !== binaryPath) {
|
if (process.env[binary.envVariable] !== binaryPath) {
|
||||||
|
|
@ -82,79 +325,22 @@ class BinaryManager {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Logger.info(`[BinaryManager] ${binary.name} not found or version too old`)
|
Logger.info(`[BinaryManager] ${binary.name} not found or version too old`)
|
||||||
missingBinaries.push(binary.name)
|
missingBinaries.push(binary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return missingBinaries
|
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) {
|
async install(binaries) {
|
||||||
if (!binaries.length) return
|
if (!binaries.length) return
|
||||||
Logger.info(`[BinaryManager] Installing binaries: ${binaries.join(', ')}`)
|
Logger.info(`[BinaryManager] Installing binaries: ${binaries.map((binary) => binary.name).join(', ')}`)
|
||||||
let destination = await fileUtils.isWritable(this.mainInstallPath) ? this.mainInstallPath : this.altInstallPath
|
let destination = (await fileUtils.isWritable(this.mainInstallDir)) ? this.mainInstallDir : this.altInstallDir
|
||||||
await ffbinaries.downloadBinaries(binaries, { destination, version: '5.1', force: true })
|
for (const binary of binaries) {
|
||||||
Logger.info(`[BinaryManager] Binaries installed to ${destination}`)
|
await binary.download(destination)
|
||||||
}
|
}
|
||||||
|
Logger.info(`[BinaryManager] Binaries installed to ${destination}`)
|
||||||
/**
|
|
||||||
* Append .exe to binary name for Windows
|
|
||||||
*
|
|
||||||
* @param {string} name
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
getExecutableFileName(name) {
|
|
||||||
return name + (process.platform == 'win32' ? '.exe' : '')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = BinaryManager
|
module.exports = BinaryManager
|
||||||
|
module.exports.Binary = Binary // for testing
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ const Database = require('../Database')
|
||||||
const fs = require('../libs/fsExtra')
|
const fs = require('../libs/fsExtra')
|
||||||
|
|
||||||
const { getPodcastFeed } = require('../utils/podcastUtils')
|
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 { levenshteinDistance } = require('../utils/index')
|
||||||
const opmlParser = require('../utils/parsers/parseOPML')
|
const opmlParser = require('../utils/parsers/parseOPML')
|
||||||
const opmlGenerator = require('../utils/generators/opmlGenerator')
|
const opmlGenerator = require('../utils/generators/opmlGenerator')
|
||||||
|
|
@ -13,11 +13,13 @@ const prober = require('../utils/prober')
|
||||||
const ffmpegHelpers = require('../utils/ffmpegHelpers')
|
const ffmpegHelpers = require('../utils/ffmpegHelpers')
|
||||||
|
|
||||||
const TaskManager = require('./TaskManager')
|
const TaskManager = require('./TaskManager')
|
||||||
|
const CoverManager = require('../managers/CoverManager')
|
||||||
|
|
||||||
const LibraryFile = require('../objects/files/LibraryFile')
|
const LibraryFile = require('../objects/files/LibraryFile')
|
||||||
const PodcastEpisodeDownload = require('../objects/PodcastEpisodeDownload')
|
const PodcastEpisodeDownload = require('../objects/PodcastEpisodeDownload')
|
||||||
const PodcastEpisode = require('../objects/entities/PodcastEpisode')
|
const PodcastEpisode = require('../objects/entities/PodcastEpisode')
|
||||||
const AudioFile = require('../objects/files/AudioFile')
|
const AudioFile = require('../objects/files/AudioFile')
|
||||||
|
const LibraryItem = require('../objects/LibraryItem')
|
||||||
|
|
||||||
class PodcastManager {
|
class PodcastManager {
|
||||||
constructor(watcher, notificationManager) {
|
constructor(watcher, notificationManager) {
|
||||||
|
|
@ -350,19 +352,23 @@ class PodcastManager {
|
||||||
return matches.sort((a, b) => a.levenshtein - b.levenshtein)
|
return matches.sort((a, b) => a.levenshtein - b.levenshtein)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getParsedOPMLFileFeeds(opmlText) {
|
||||||
|
return opmlParser.parse(opmlText)
|
||||||
|
}
|
||||||
|
|
||||||
async getOPMLFeeds(opmlText) {
|
async getOPMLFeeds(opmlText) {
|
||||||
var extractedFeeds = opmlParser.parse(opmlText)
|
const extractedFeeds = opmlParser.parse(opmlText)
|
||||||
if (!extractedFeeds || !extractedFeeds.length) {
|
if (!extractedFeeds?.length) {
|
||||||
Logger.error('[PodcastManager] getOPMLFeeds: No RSS feeds found in OPML')
|
Logger.error('[PodcastManager] getOPMLFeeds: No RSS feeds found in OPML')
|
||||||
return {
|
return {
|
||||||
error: 'No RSS feeds found in OPML'
|
error: 'No RSS feeds found in OPML'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var rssFeedData = []
|
const rssFeedData = []
|
||||||
|
|
||||||
for (let feed of extractedFeeds) {
|
for (let feed of extractedFeeds) {
|
||||||
var feedData = await getPodcastFeed(feed.feedUrl, true)
|
const feedData = await getPodcastFeed(feed.feedUrl, true)
|
||||||
if (feedData) {
|
if (feedData) {
|
||||||
feedData.metadata.feedUrl = feed.feedUrl
|
feedData.metadata.feedUrl = feed.feedUrl
|
||||||
rssFeedData.push(feedData)
|
rssFeedData.push(feedData)
|
||||||
|
|
@ -392,5 +398,115 @@ class PodcastManager {
|
||||||
queue: this.downloadQueue.filter((item) => !libraryId || item.libraryId === libraryId).map((item) => item.toJSONForClient())
|
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
|
module.exports = PodcastManager
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
const Database = require('../Database')
|
const Database = require('../Database')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const SocketAuthority = require('../SocketAuthority')
|
const SocketAuthority = require('../SocketAuthority')
|
||||||
|
const LongTimeout = require('../utils/longTimeout')
|
||||||
|
const { elapsedPretty } = require('../utils/index')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef OpenMediaItemShareObject
|
* @typedef OpenMediaItemShareObject
|
||||||
* @property {string} id
|
* @property {string} id
|
||||||
* @property {import('../models/MediaItemShare').MediaItemShareObject} mediaItemShare
|
* @property {import('../models/MediaItemShare').MediaItemShareObject} mediaItemShare
|
||||||
* @property {NodeJS.Timeout} timeout
|
* @property {LongTimeout} timeout
|
||||||
*/
|
*/
|
||||||
|
|
||||||
class ShareManager {
|
class ShareManager {
|
||||||
|
|
@ -118,13 +120,13 @@ class ShareManager {
|
||||||
this.destroyMediaItemShare(mediaItemShare.id)
|
this.destroyMediaItemShare(mediaItemShare.id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const timeout = new LongTimeout()
|
||||||
const timeout = setTimeout(() => {
|
timeout.set(() => {
|
||||||
Logger.info(`[ShareManager] Removing expired media item share "${mediaItemShare.id}"`)
|
Logger.info(`[ShareManager] Removing expired media item share "${mediaItemShare.id}"`)
|
||||||
this.removeMediaItemShare(mediaItemShare.id)
|
this.removeMediaItemShare(mediaItemShare.id)
|
||||||
}, expiresAtDuration)
|
}, expiresAtDuration)
|
||||||
this.openMediaItemShares.push({ id: mediaItemShare.id, mediaItemShare: mediaItemShare.toJSON(), timeout })
|
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) return
|
||||||
|
|
||||||
if (mediaItemShare.timeout) {
|
if (mediaItemShare.timeout) {
|
||||||
clearTimeout(mediaItemShare.timeout)
|
mediaItemShare.timeout.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
this.openMediaItemShares = this.openMediaItemShares.filter((s) => s.id !== mediaItemShareId)
|
this.openMediaItemShares = this.openMediaItemShares.filter((s) => s.id !== mediaItemShareId)
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,8 @@ class TaskManager {
|
||||||
* @param {Task} task
|
* @param {Task} task
|
||||||
*/
|
*/
|
||||||
taskFinished(task) {
|
taskFinished(task) {
|
||||||
if (this.tasks.some(t => t.id === task.id)) {
|
if (this.tasks.some((t) => t.id === task.id)) {
|
||||||
this.tasks = this.tasks.filter(t => t.id !== task.id)
|
this.tasks = this.tasks.filter((t) => t.id !== task.id)
|
||||||
SocketAuthority.emitter('task_finished', task.toJSON())
|
SocketAuthority.emitter('task_finished', task.toJSON())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -44,5 +44,21 @@ class TaskManager {
|
||||||
this.addTask(task)
|
this.addTask(task)
|
||||||
return 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()
|
module.exports = new TaskManager()
|
||||||
|
|
@ -60,7 +60,7 @@ class Library extends Model {
|
||||||
/**
|
/**
|
||||||
* Convert expanded Library to oldLibrary
|
* Convert expanded Library to oldLibrary
|
||||||
* @param {Library} libraryExpanded
|
* @param {Library} libraryExpanded
|
||||||
* @returns {Promise<oldLibrary>}
|
* @returns {oldLibrary}
|
||||||
*/
|
*/
|
||||||
static getOldLibrary(libraryExpanded) {
|
static getOldLibrary(libraryExpanded) {
|
||||||
const folders = libraryExpanded.libraryFolders.map((folder) => {
|
const folders = libraryExpanded.libraryFolders.map((folder) => {
|
||||||
|
|
|
||||||
|
|
@ -217,11 +217,11 @@ class Feed {
|
||||||
this.entityType = 'collection'
|
this.entityType = 'collection'
|
||||||
this.entityId = collectionExpanded.id
|
this.entityId = collectionExpanded.id
|
||||||
this.entityUpdatedAt = collectionExpanded.lastUpdate // This will be set to the most recently updated library item
|
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.serverAddress = serverAddress
|
||||||
this.feedUrl = feedUrl
|
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 = new FeedMeta()
|
||||||
this.meta.title = collectionExpanded.name
|
this.meta.title = collectionExpanded.name
|
||||||
|
|
@ -265,9 +265,9 @@ class Feed {
|
||||||
const firstItemWithCover = itemsWithTracks.find((item) => item.media.coverPath)
|
const firstItemWithCover = itemsWithTracks.find((item) => item.media.coverPath)
|
||||||
|
|
||||||
this.entityUpdatedAt = collectionExpanded.lastUpdate
|
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.title = collectionExpanded.name
|
||||||
this.meta.description = collectionExpanded.description || ''
|
this.meta.description = collectionExpanded.description || ''
|
||||||
|
|
@ -316,11 +316,11 @@ class Feed {
|
||||||
this.entityType = 'series'
|
this.entityType = 'series'
|
||||||
this.entityId = seriesExpanded.id
|
this.entityId = seriesExpanded.id
|
||||||
this.entityUpdatedAt = seriesExpanded.updatedAt // This will be set to the most recently updated library item
|
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.serverAddress = serverAddress
|
||||||
this.feedUrl = feedUrl
|
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 = new FeedMeta()
|
||||||
this.meta.title = seriesExpanded.name
|
this.meta.title = seriesExpanded.name
|
||||||
|
|
@ -367,9 +367,9 @@ class Feed {
|
||||||
const firstItemWithCover = itemsWithTracks.find((item) => item.media.coverPath)
|
const firstItemWithCover = itemsWithTracks.find((item) => item.media.coverPath)
|
||||||
|
|
||||||
this.entityUpdatedAt = seriesExpanded.updatedAt
|
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.title = seriesExpanded.name
|
||||||
this.meta.description = seriesExpanded.description || ''
|
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.backupPath = settings.backupPath || Path.join(global.MetadataPath, 'backups')
|
||||||
this.backupSchedule = settings.backupSchedule || false
|
this.backupSchedule = settings.backupSchedule || false
|
||||||
this.backupsToKeep = settings.backupsToKeep || 2
|
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.loggerDailyLogsToKeep = settings.loggerDailyLogsToKeep || 7
|
||||||
this.loggerScannerLogsToKeep = settings.loggerScannerLogsToKeep || 2
|
this.loggerScannerLogsToKeep = settings.loggerScannerLogsToKeep || 2
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ class ApiRouter {
|
||||||
this.backupManager = Server.backupManager
|
this.backupManager = Server.backupManager
|
||||||
/** @type {import('../Watcher')} */
|
/** @type {import('../Watcher')} */
|
||||||
this.watcher = Server.watcher
|
this.watcher = Server.watcher
|
||||||
|
/** @type {import('../managers/PodcastManager')} */
|
||||||
this.podcastManager = Server.podcastManager
|
this.podcastManager = Server.podcastManager
|
||||||
this.audioMetadataManager = Server.audioMetadataManager
|
this.audioMetadataManager = Server.audioMetadataManager
|
||||||
this.rssFeedManager = Server.rssFeedManager
|
this.rssFeedManager = Server.rssFeedManager
|
||||||
|
|
@ -239,7 +240,8 @@ class ApiRouter {
|
||||||
//
|
//
|
||||||
this.router.post('/podcasts', PodcastController.create.bind(this))
|
this.router.post('/podcasts', PodcastController.create.bind(this))
|
||||||
this.router.post('/podcasts/feed', PodcastController.getPodcastFeed.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/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/downloads', PodcastController.middleware.bind(this), PodcastController.getEpisodeDownloads.bind(this))
|
||||||
this.router.get('/podcasts/:id/clear-queue', PodcastController.middleware.bind(this), PodcastController.clearEpisodeDownloadQueue.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')
|
const { readTextFile } = require('../utils/fileUtils')
|
||||||
|
|
||||||
class NfoFileScanner {
|
class NfoFileScanner {
|
||||||
constructor() { }
|
constructor() {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse metadata from .nfo file found in library scan and update bookMetadata
|
* 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
|
const nfoMetadata = nfoText ? await parseNfoMetadata(nfoText) : null
|
||||||
if (nfoMetadata) {
|
if (nfoMetadata) {
|
||||||
for (const key in 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) {
|
if (nfoMetadata.tags.length) {
|
||||||
bookMetadata.tags = nfoMetadata.tags
|
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) {
|
if (nfoMetadata.genres.length) {
|
||||||
bookMetadata.genres = nfoMetadata.genres
|
bookMetadata.genres = nfoMetadata.genres
|
||||||
}
|
}
|
||||||
|
|
@ -33,10 +35,12 @@ class NfoFileScanner {
|
||||||
}
|
}
|
||||||
} else if (key === 'series') {
|
} else if (key === 'series') {
|
||||||
if (nfoMetadata.series) {
|
if (nfoMetadata.series) {
|
||||||
bookMetadata.series = [{
|
bookMetadata.series = [
|
||||||
|
{
|
||||||
name: nfoMetadata.series,
|
name: nfoMetadata.series,
|
||||||
sequence: nfoMetadata.sequence || null
|
sequence: nfoMetadata.sequence || null
|
||||||
}]
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
} else if (nfoMetadata[key] && key !== 'sequence') {
|
} else if (nfoMetadata[key] && key !== 'sequence') {
|
||||||
bookMetadata[key] = nfoMetadata[key]
|
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 axios = require('axios')
|
||||||
const Ffmpeg = require('../libs/fluentFfmpeg')
|
const Ffmpeg = require('../libs/fluentFfmpeg')
|
||||||
|
const ffmpgegUtils = require('../libs/fluentFfmpeg/utils')
|
||||||
const fs = require('../libs/fsExtra')
|
const fs = require('../libs/fsExtra')
|
||||||
const os = require('os')
|
|
||||||
const Path = require('path')
|
const Path = require('path')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const { filePathToPOSIX } = require('./fileUtils')
|
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 {number} track - The track number to embed in the audio file.
|
||||||
* @param {string} mimeType - The MIME type of 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.
|
* @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 isMp4 = mimeType === 'audio/mp4'
|
||||||
const isMp3 = mimeType === 'audio/mpeg'
|
const isMp3 = mimeType === 'audio/mpeg'
|
||||||
|
|
||||||
|
|
@ -262,7 +263,7 @@ async function addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataF
|
||||||
const audioFileBaseName = Path.basename(audioFilePath, audioFileExt)
|
const audioFileBaseName = Path.basename(audioFilePath, audioFileExt)
|
||||||
const tempFilePath = filePathToPOSIX(Path.join(audioFileDir, `${audioFileBaseName}.tmp${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([
|
ffmpeg.input(audioFilePath).input(metadataFilePath).outputOptions([
|
||||||
'-map 0:a', // map audio stream from input file
|
'-map 0:a', // map audio stream from input file
|
||||||
'-map_metadata 1', // map metadata tags from metadata file first
|
'-map_metadata 1', // map metadata tags from metadata file first
|
||||||
|
|
@ -302,21 +303,36 @@ async function addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataF
|
||||||
|
|
||||||
ffmpeg
|
ffmpeg
|
||||||
.output(tempFilePath)
|
.output(tempFilePath)
|
||||||
.on('start', function (commandLine) {
|
.on('start', (commandLine) => {
|
||||||
Logger.debug('[ffmpegHelpers] Spawned Ffmpeg with command: ' + 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 stdout:', stdout)
|
||||||
Logger.debug('[ffmpegHelpers] ffmpeg stderr:', stderr)
|
Logger.debug('[ffmpegHelpers] ffmpeg stderr:', stderr)
|
||||||
fs.copyFileSync(tempFilePath, audioFilePath)
|
Logger.debug('[ffmpegHelpers] Moving temp file to audio file path:', `"${tempFilePath}"`, '->', `"${audioFilePath}"`)
|
||||||
fs.unlinkSync(tempFilePath)
|
try {
|
||||||
resolve(true)
|
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) => {
|
.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('Error adding cover image and metadata:', err)
|
||||||
Logger.error('ffmpeg stdout:', stdout)
|
Logger.error('ffmpeg stdout:', stdout)
|
||||||
Logger.error('ffmpeg stderr:', stderr)
|
Logger.error('ffmpeg stderr:', stderr)
|
||||||
resolve(false)
|
reject(err)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
ffmpeg.run()
|
ffmpeg.run()
|
||||||
|
|
@ -366,3 +382,92 @@ function getFFMetadataObject(libraryItem, audioFilesLength) {
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports.getFFMetadataObject = getFFMetadataObject
|
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
|
return _id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {number} seconds
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
function elapsedPretty(seconds) {
|
function elapsedPretty(seconds) {
|
||||||
if (seconds > 0 && seconds < 1) {
|
if (seconds > 0 && seconds < 1) {
|
||||||
return `${Math.floor(seconds * 1000)} ms`
|
return `${Math.floor(seconds * 1000)} ms`
|
||||||
|
|
@ -73,16 +78,27 @@ function elapsedPretty(seconds) {
|
||||||
if (seconds < 60) {
|
if (seconds < 60) {
|
||||||
return `${Math.floor(seconds)} sec`
|
return `${Math.floor(seconds)} sec`
|
||||||
}
|
}
|
||||||
var minutes = Math.floor(seconds / 60)
|
let minutes = Math.floor(seconds / 60)
|
||||||
if (minutes < 70) {
|
if (minutes < 70) {
|
||||||
return `${minutes} min`
|
return `${minutes} min`
|
||||||
}
|
}
|
||||||
var hours = Math.floor(minutes / 60)
|
let hours = Math.floor(minutes / 60)
|
||||||
minutes -= hours * 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
|
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 parseOpfMetadata = require('./parseOpfMetadata')
|
||||||
const { xmlToJSON } = require('../index')
|
const { xmlToJSON } = require('../index')
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract file from epub and return string content
|
* Extract file from epub and return string content
|
||||||
*
|
*
|
||||||
|
|
@ -49,7 +48,10 @@ async function extractXmlToJson(epubPath, xmlFilepath) {
|
||||||
async function extractCoverImage(epubPath, epubImageFilepath, outputCoverPath) {
|
async function extractCoverImage(epubPath, epubImageFilepath, outputCoverPath) {
|
||||||
const zip = new StreamZip.async({ file: epubPath })
|
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)
|
Logger.error(`[parseEpubMetadata] Failed to extract image ${epubImageFilepath} from epub at "${epubPath}"`, error)
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|
@ -89,7 +91,7 @@ async function parse(ebookFile) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse metadata from package document opf file
|
// Parse metadata from package document opf file
|
||||||
const opfMetadata = parseOpfMetadata.parseOpfMetadataJson(packageJson)
|
const opfMetadata = parseOpfMetadata.parseOpfMetadataJson(structuredClone(packageJson))
|
||||||
if (!opfMetadata) {
|
if (!opfMetadata) {
|
||||||
Logger.error(`Unable to parse metadata in package doc with json`, JSON.stringify(packageJson, null, 2))
|
Logger.error(`Unable to parse metadata in package doc with json`, JSON.stringify(packageJson, null, 2))
|
||||||
return null
|
return null
|
||||||
|
|
@ -101,8 +103,23 @@ async function parse(ebookFile) {
|
||||||
metadata: opfMetadata
|
metadata: opfMetadata
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempt to find filepath to cover image
|
// Attempt to find filepath to cover image:
|
||||||
const manifestFirstImage = packageJson.package?.manifest?.[0]?.item?.find(item => item.$?.['media-type']?.startsWith('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
|
let coverImagePath = manifestFirstImage?.$?.href
|
||||||
if (coverImagePath) {
|
if (coverImagePath) {
|
||||||
const packageDirname = Path.dirname(packageDocPath)
|
const packageDirname = Path.dirname(packageDocPath)
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,10 @@ function parseNfoMetadata(nfoText) {
|
||||||
case 'isbn-13':
|
case 'isbn-13':
|
||||||
metadata.isbn = value
|
metadata.isbn = value
|
||||||
break
|
break
|
||||||
|
case 'language':
|
||||||
|
case 'lang':
|
||||||
|
metadata.language = value
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,21 @@
|
||||||
const h = require('htmlparser2')
|
const h = require('htmlparser2')
|
||||||
const Logger = require('../../Logger')
|
const Logger = require('../../Logger')
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} opmlText
|
||||||
|
* @returns {Array<{title: string, feedUrl: string}>
|
||||||
|
*/
|
||||||
function parse(opmlText) {
|
function parse(opmlText) {
|
||||||
var feeds = []
|
var feeds = []
|
||||||
var parser = new h.Parser({
|
var parser = new h.Parser({
|
||||||
onopentag: (name, attribs) => {
|
onopentag: (name, attribs) => {
|
||||||
if (name === "outline" && attribs.type === 'rss') {
|
if (name === 'outline' && attribs.type === 'rss') {
|
||||||
if (!attribs.xmlurl) {
|
if (!attribs.xmlurl) {
|
||||||
Logger.error('[parseOPML] Invalid opml outline tag has no xmlurl attribute')
|
Logger.error('[parseOPML] Invalid opml outline tag has no xmlurl attribute')
|
||||||
} else {
|
} else {
|
||||||
feeds.push({
|
feeds.push({
|
||||||
title: attribs.title || 'No Title',
|
title: attribs.title || attribs.text || '',
|
||||||
text: attribs.text || '',
|
|
||||||
feedUrl: attribs.xmlurl
|
feedUrl: attribs.xmlurl
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,31 @@
|
||||||
const { xmlToJSON } = require('../index')
|
const { xmlToJSON } = require('../index')
|
||||||
const htmlSanitizer = require('../htmlSanitizer')
|
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) {
|
function parseCreators(metadata) {
|
||||||
if (!metadata['dc:creator']) return null
|
if (!metadata['dc:creator']?.length) return null
|
||||||
const creators = metadata['dc:creator']
|
return metadata['dc:creator'].map((c) => {
|
||||||
if (!creators.length) return null
|
|
||||||
return creators.map((c) => {
|
|
||||||
if (typeof c !== 'object' || !c['$'] || !c['_']) return false
|
if (typeof c !== 'object' || !c['$'] || !c['_']) return false
|
||||||
|
const namespace =
|
||||||
|
Object.keys(c['$'])
|
||||||
|
.find((key) => key.startsWith('xmlns:'))
|
||||||
|
?.split(':')[1] || 'opf'
|
||||||
return {
|
return {
|
||||||
value: c['_'],
|
value: c['_'],
|
||||||
role: c['$']['opf:role'] || null,
|
role: c['$'][`${namespace}:role`] || null,
|
||||||
fileAs: c['$']['opf:file-as'] || null
|
fileAs: c['$'][`${namespace}:file-as`] || null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -59,18 +74,34 @@ function fetchPublisher(metadata) {
|
||||||
return fetchTagString(metadata, 'dc:publisher')
|
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) {
|
function fetchISBN(metadata) {
|
||||||
if (!metadata['dc:identifier'] || !metadata['dc:identifier'].length) return null
|
return fetchIdentifier(metadata, 'ISBN')
|
||||||
const identifiers = metadata['dc:identifier']
|
|
||||||
const isbnObj = identifiers.find((i) => i['$'] && i['$']['opf:scheme'] === 'ISBN')
|
|
||||||
return isbnObj ? isbnObj['_'] || null : null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchASIN(metadata) {
|
function fetchASIN(metadata) {
|
||||||
if (!metadata['dc:identifier'] || !metadata['dc:identifier'].length) return null
|
return fetchIdentifier(metadata, 'ASIN')
|
||||||
const identifiers = metadata['dc:identifier']
|
|
||||||
const asinObj = identifiers.find((i) => i['$'] && i['$']['opf:scheme'] === 'ASIN')
|
|
||||||
return asinObj ? asinObj['_'] || null : null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchTitle(metadata) {
|
function fetchTitle(metadata) {
|
||||||
|
|
|
||||||
|
|
@ -289,7 +289,6 @@ module.exports.findMatchingEpisodesInFeed = (feed, searchTitle) => {
|
||||||
const matches = []
|
const matches = []
|
||||||
feed.episodes.forEach((ep) => {
|
feed.episodes.forEach((ep) => {
|
||||||
if (!ep.title) return
|
if (!ep.title) return
|
||||||
|
|
||||||
const epTitle = ep.title.toLowerCase().trim()
|
const epTitle = ep.title.toLowerCase().trim()
|
||||||
if (epTitle === searchTitle) {
|
if (epTitle === searchTitle) {
|
||||||
matches.push({
|
matches.push({
|
||||||
|
|
|
||||||
|
|
@ -60,12 +60,10 @@ module.exports = {
|
||||||
* @returns {Promise<Object[]>} oldAuthor with numBooks
|
* @returns {Promise<Object[]>} oldAuthor with numBooks
|
||||||
*/
|
*/
|
||||||
async search(libraryId, query, limit, offset) {
|
async search(libraryId, query, limit, offset) {
|
||||||
|
const matchAuthor = Database.matchExpression('name', query)
|
||||||
const authors = await Database.authorModel.findAll({
|
const authors = await Database.authorModel.findAll({
|
||||||
where: {
|
where: {
|
||||||
name: {
|
[Sequelize.Op.and]: [Sequelize.literal(matchAuthor), { libraryId }]
|
||||||
[Sequelize.Op.substring]: query
|
|
||||||
},
|
|
||||||
libraryId
|
|
||||||
},
|
},
|
||||||
attributes: {
|
attributes: {
|
||||||
include: [[Sequelize.literal('(SELECT count(*) FROM bookAuthors ba WHERE ba.authorId = author.id)'), 'numBooks']]
|
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 Database = require('../../Database')
|
||||||
const Logger = require('../../Logger')
|
const Logger = require('../../Logger')
|
||||||
const authorFilters = require('./authorFilters')
|
const authorFilters = require('./authorFilters')
|
||||||
const { asciiOnlyToLowerCase } = require('../index')
|
|
||||||
|
|
||||||
const ShareManager = require('../../managers/ShareManager')
|
const ShareManager = require('../../managers/ShareManager')
|
||||||
|
|
||||||
|
|
@ -274,6 +273,8 @@ module.exports = {
|
||||||
return [[Sequelize.literal(`CAST(\`series.bookSeries.sequence\` AS FLOAT) ${nullDir}`)]]
|
return [[Sequelize.literal(`CAST(\`series.bookSeries.sequence\` AS FLOAT) ${nullDir}`)]]
|
||||||
} else if (sortBy === 'progress') {
|
} else if (sortBy === 'progress') {
|
||||||
return [[Sequelize.literal('mediaProgresses.updatedAt'), dir]]
|
return [[Sequelize.literal('mediaProgresses.updatedAt'), dir]]
|
||||||
|
} else if (sortBy === 'random') {
|
||||||
|
return [Database.sequelize.random()]
|
||||||
}
|
}
|
||||||
return []
|
return []
|
||||||
},
|
},
|
||||||
|
|
@ -974,21 +975,18 @@ module.exports = {
|
||||||
async search(oldUser, oldLibrary, query, limit, offset) {
|
async search(oldUser, oldLibrary, query, limit, offset) {
|
||||||
const userPermissionBookWhere = this.getUserPermissionBookWhereQuery(oldUser)
|
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
|
// Search title, subtitle, asin, isbn
|
||||||
const books = await Database.bookModel.findAll({
|
const books = await Database.bookModel.findAll({
|
||||||
where: [
|
where: [
|
||||||
{
|
{
|
||||||
[Sequelize.Op.or]: [
|
[Sequelize.Op.or]: [
|
||||||
{
|
Sequelize.literal(matchTitle),
|
||||||
title: {
|
Sequelize.literal(matchSubtitle),
|
||||||
[Sequelize.Op.substring]: query
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
subtitle: {
|
|
||||||
[Sequelize.Op.substring]: query
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
asin: {
|
asin: {
|
||||||
[Sequelize.Op.substring]: query
|
[Sequelize.Op.substring]: query
|
||||||
|
|
@ -1038,32 +1036,17 @@ module.exports = {
|
||||||
const libraryItem = book.libraryItem
|
const libraryItem = book.libraryItem
|
||||||
delete book.libraryItem
|
delete book.libraryItem
|
||||||
libraryItem.media = book
|
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({
|
itemMatches.push({
|
||||||
matchText,
|
|
||||||
matchKey,
|
|
||||||
libraryItem: Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSONExpanded()
|
libraryItem: Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSONExpanded()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
const matchJsonValue = Database.matchExpression('json_each.value', normalizedQuery)
|
||||||
|
|
||||||
// Search narrators
|
// Search narrators
|
||||||
const narratorMatches = []
|
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: {
|
replacements: {
|
||||||
query: `%${query}%`,
|
|
||||||
libraryId: oldLibrary.id,
|
libraryId: oldLibrary.id,
|
||||||
limit,
|
limit,
|
||||||
offset
|
offset
|
||||||
|
|
@ -1079,9 +1062,8 @@ module.exports = {
|
||||||
|
|
||||||
// Search tags
|
// Search tags
|
||||||
const tagMatches = []
|
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: {
|
replacements: {
|
||||||
query: `%${query}%`,
|
|
||||||
libraryId: oldLibrary.id,
|
libraryId: oldLibrary.id,
|
||||||
limit,
|
limit,
|
||||||
offset
|
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
|
// Search series
|
||||||
|
const matchName = Database.matchExpression('name', normalizedQuery)
|
||||||
const allSeries = await Database.seriesModel.findAll({
|
const allSeries = await Database.seriesModel.findAll({
|
||||||
where: {
|
where: {
|
||||||
name: {
|
[Sequelize.Op.and]: [
|
||||||
[Sequelize.Op.substring]: query
|
Sequelize.literal(matchName),
|
||||||
},
|
{
|
||||||
libraryId: oldLibrary.id
|
libraryId: oldLibrary.id
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
replacements: userPermissionBookWhere.replacements,
|
replacements: userPermissionBookWhere.replacements,
|
||||||
include: {
|
include: {
|
||||||
|
|
@ -1134,12 +1136,13 @@ module.exports = {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search authors
|
// Search authors
|
||||||
const authorMatches = await authorFilters.search(oldLibrary.id, query, limit, offset)
|
const authorMatches = await authorFilters.search(oldLibrary.id, normalizedQuery, limit, offset)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
book: itemMatches,
|
book: itemMatches,
|
||||||
narrators: narratorMatches,
|
narrators: narratorMatches,
|
||||||
tags: tagMatches,
|
tags: tagMatches,
|
||||||
|
genres: genreMatches,
|
||||||
series: seriesMatches,
|
series: seriesMatches,
|
||||||
authors: authorMatches
|
authors: authorMatches
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
|
|
||||||
const Sequelize = require('sequelize')
|
const Sequelize = require('sequelize')
|
||||||
const Database = require('../../Database')
|
const Database = require('../../Database')
|
||||||
const Logger = require('../../Logger')
|
const Logger = require('../../Logger')
|
||||||
|
|
@ -23,9 +22,11 @@ module.exports = {
|
||||||
if (user.permissions.selectedTagsNotAccessible) {
|
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))
|
podcastWhere.push(Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:userTagsSelected))`), 0))
|
||||||
} else {
|
} 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
|
[Sequelize.Op.gte]: 1
|
||||||
}))
|
})
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|
@ -88,6 +89,8 @@ module.exports = {
|
||||||
}
|
}
|
||||||
} else if (sortBy === 'media.numTracks') {
|
} else if (sortBy === 'media.numTracks') {
|
||||||
return [['numEpisodes', dir]]
|
return [['numEpisodes', dir]]
|
||||||
|
} else if (sortBy === 'random') {
|
||||||
|
return [Database.sequelize.random()]
|
||||||
}
|
}
|
||||||
return []
|
return []
|
||||||
},
|
},
|
||||||
|
|
@ -130,7 +133,7 @@ module.exports = {
|
||||||
]
|
]
|
||||||
} else if (filterGroup === 'recent') {
|
} else if (filterGroup === 'recent') {
|
||||||
libraryItemWhere['createdAt'] = {
|
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,
|
replacements,
|
||||||
distinct: true,
|
distinct: true,
|
||||||
attributes: {
|
attributes: {
|
||||||
include: [
|
include: [[Sequelize.literal(`(SELECT count(*) FROM podcastEpisodes pe WHERE pe.podcastId = podcast.id)`), 'numEpisodes'], ...podcastIncludes]
|
||||||
[Sequelize.literal(`(SELECT count(*) FROM podcastEpisodes pe WHERE pe.podcastId = podcast.id)`), 'numEpisodes'],
|
|
||||||
...podcastIncludes
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
|
|
@ -251,7 +251,7 @@ module.exports = {
|
||||||
}
|
}
|
||||||
} else if (filterGroup === 'recent') {
|
} else if (filterGroup === 'recent') {
|
||||||
podcastEpisodeWhere['createdAt'] = {
|
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) {
|
async search(oldUser, oldLibrary, query, limit, offset) {
|
||||||
const userPermissionPodcastWhere = this.getUserPermissionPodcastWhereQuery(oldUser)
|
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
|
// Search title, author, itunesId, itunesArtistId
|
||||||
const podcasts = await Database.podcastModel.findAll({
|
const podcasts = await Database.podcastModel.findAll({
|
||||||
where: [
|
where: [
|
||||||
{
|
{
|
||||||
[Sequelize.Op.or]: [
|
[Sequelize.Op.or]: [
|
||||||
{
|
Sequelize.literal(matchTitle),
|
||||||
title: {
|
Sequelize.literal(matchAuthor),
|
||||||
[Sequelize.Op.substring]: query
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
author: {
|
|
||||||
[Sequelize.Op.substring]: query
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
itunesId: {
|
itunesId: {
|
||||||
[Sequelize.Op.substring]: query
|
[Sequelize.Op.substring]: query
|
||||||
|
|
@ -363,32 +360,17 @@ module.exports = {
|
||||||
const libraryItem = podcast.libraryItem
|
const libraryItem = podcast.libraryItem
|
||||||
delete podcast.libraryItem
|
delete podcast.libraryItem
|
||||||
libraryItem.media = podcast
|
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({
|
itemMatches.push({
|
||||||
matchText,
|
|
||||||
matchKey,
|
|
||||||
libraryItem: Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSONExpanded()
|
libraryItem: Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSONExpanded()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
const matchJsonValue = Database.matchExpression('json_each.value', normalizedQuery)
|
||||||
|
|
||||||
// Search tags
|
// Search tags
|
||||||
const tagMatches = []
|
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: {
|
replacements: {
|
||||||
query: `%${query}%`,
|
|
||||||
libraryId: oldLibrary.id,
|
libraryId: oldLibrary.id,
|
||||||
limit,
|
limit,
|
||||||
offset
|
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 {
|
return {
|
||||||
podcast: itemMatches,
|
podcast: itemMatches,
|
||||||
tags: tagMatches
|
tags: tagMatches,
|
||||||
|
genres: genreMatches
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -446,9 +446,7 @@ module.exports = {
|
||||||
required: false
|
required: false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
order: [
|
order: [['publishedAt', 'DESC']],
|
||||||
['publishedAt', 'DESC']
|
|
||||||
],
|
|
||||||
subQuery: false,
|
subQuery: false,
|
||||||
limit,
|
limit,
|
||||||
offset
|
offset
|
||||||
|
|
@ -519,11 +517,7 @@ module.exports = {
|
||||||
*/
|
*/
|
||||||
async getLongestPodcasts(libraryId, limit) {
|
async getLongestPodcasts(libraryId, limit) {
|
||||||
const podcasts = await Database.podcastModel.findAll({
|
const podcasts = await Database.podcastModel.findAll({
|
||||||
attributes: [
|
attributes: ['id', 'title', [Sequelize.literal(`(SELECT SUM(json_extract(pe.audioFile, '$.duration')) FROM podcastEpisodes pe WHERE pe.podcastId = podcast.id)`), 'duration']],
|
||||||
'id',
|
|
||||||
'title',
|
|
||||||
[Sequelize.literal(`(SELECT SUM(json_extract(pe.audioFile, '$.duration')) FROM podcastEpisodes pe WHERE pe.podcastId = podcast.id)`), 'duration']
|
|
||||||
],
|
|
||||||
include: {
|
include: {
|
||||||
model: Database.libraryItemModel,
|
model: Database.libraryItemModel,
|
||||||
attributes: ['id', 'libraryId'],
|
attributes: ['id', 'libraryId'],
|
||||||
|
|
@ -531,12 +525,10 @@ module.exports = {
|
||||||
libraryId
|
libraryId
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
order: [
|
order: [['duration', 'DESC']],
|
||||||
['duration', 'DESC']
|
|
||||||
],
|
|
||||||
limit
|
limit
|
||||||
})
|
})
|
||||||
return podcasts.map(podcast => {
|
return podcasts.map((podcast) => {
|
||||||
return {
|
return {
|
||||||
id: podcast.libraryItem.id,
|
id: podcast.libraryItem.id,
|
||||||
title: podcast.title,
|
title: podcast.title,
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ module.exports = {
|
||||||
let filterGroup = null
|
let filterGroup = null
|
||||||
if (filterBy) {
|
if (filterBy) {
|
||||||
const searchGroups = ['genres', 'tags', 'authors', 'progress', 'narrators', 'publishers', 'languages']
|
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
|
filterGroup = group || filterBy
|
||||||
filterValue = group ? this.decode(filterBy.replace(`${group}.`, '')) : null
|
filterValue = group ? this.decode(filterBy.replace(`${group}.`, '')) : null
|
||||||
}
|
}
|
||||||
|
|
@ -49,9 +49,11 @@ module.exports = {
|
||||||
// Handle library setting to hide single book series
|
// Handle library setting to hide single book series
|
||||||
// TODO: Merge with existing query
|
// TODO: Merge with existing query
|
||||||
if (library.settings.hideSingleBookSeries) {
|
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
|
[Sequelize.Op.gt]: 1
|
||||||
}))
|
})
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle filters
|
// Handle filters
|
||||||
|
|
@ -101,9 +103,11 @@ module.exports = {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attrQuery) {
|
if (attrQuery) {
|
||||||
seriesWhere.push(Sequelize.where(Sequelize.literal(`(${attrQuery})`), {
|
seriesWhere.push(
|
||||||
|
Sequelize.where(Sequelize.literal(`(${attrQuery})`), {
|
||||||
[Sequelize.Op.gt]: 0
|
[Sequelize.Op.gt]: 0
|
||||||
}))
|
})
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const order = []
|
const order = []
|
||||||
|
|
@ -133,6 +137,8 @@ module.exports = {
|
||||||
} else if (sortBy === 'lastBookUpdated') {
|
} 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'])
|
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])
|
order.push(['mostRecentBookUpdated', dir])
|
||||||
|
} else if (sortBy === 'random') {
|
||||||
|
order.push(Database.sequelize.random())
|
||||||
}
|
}
|
||||||
|
|
||||||
const { rows: series, count } = await Database.seriesModel.findAndCountAll({
|
const { rows: series, count } = await Database.seriesModel.findAndCountAll({
|
||||||
|
|
@ -184,7 +190,7 @@ module.exports = {
|
||||||
sensitivity: 'base'
|
sensitivity: 'base'
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
oldSeries.books = s.bookSeries.map(bs => {
|
oldSeries.books = s.bookSeries.map((bs) => {
|
||||||
const libraryItem = bs.book.libraryItem.toJSON()
|
const libraryItem = bs.book.libraryItem.toJSON()
|
||||||
delete bs.book.libraryItem
|
delete bs.book.libraryItem
|
||||||
libraryItem.media = bs.book
|
libraryItem.media = bs.book
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@ const sinon = require('sinon')
|
||||||
const fs = require('../../../server/libs/fsExtra')
|
const fs = require('../../../server/libs/fsExtra')
|
||||||
const fileUtils = require('../../../server/utils/fileUtils')
|
const fileUtils = require('../../../server/utils/fileUtils')
|
||||||
const which = require('../../../server/libs/which')
|
const which = require('../../../server/libs/which')
|
||||||
const ffbinaries = require('../../../server/libs/ffbinaries')
|
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
const BinaryManager = require('../../../server/managers/BinaryManager')
|
const BinaryManager = require('../../../server/managers/BinaryManager')
|
||||||
|
const { Binary, ffbinaries } = require('../../../server/managers/BinaryManager')
|
||||||
|
|
||||||
const expect = chai.expect
|
const expect = chai.expect
|
||||||
|
|
||||||
|
|
@ -49,10 +49,14 @@ describe('BinaryManager', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should install missing binaries', async () => {
|
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 = []
|
const missingBinariesAfterInstall = []
|
||||||
findStub.onFirstCall().resolves(missingBinaries)
|
findStub.onFirstCall().resolves(missingBinaries)
|
||||||
findStub.onSecondCall().resolves(missingBinariesAfterInstall)
|
findStub.onSecondCall().resolves(missingBinariesAfterInstall)
|
||||||
|
binaryManager.requiredBinaries = requiredBinaries
|
||||||
|
|
||||||
await binaryManager.init()
|
await binaryManager.init()
|
||||||
|
|
||||||
|
|
@ -64,8 +68,11 @@ describe('BinaryManager', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('exit if binaries are not found after installation', async () => {
|
it('exit if binaries are not found after installation', async () => {
|
||||||
const missingBinaries = ['ffmpeg', 'ffprobe']
|
const ffmpegBinary = new Binary('ffmpeg', 'executable', 'FFMPEG_PATH', ['5.1'], ffbinaries)
|
||||||
const missingBinariesAfterInstall = ['ffmpeg', 'ffprobe']
|
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.onFirstCall().resolves(missingBinaries)
|
||||||
findStub.onSecondCall().resolves(missingBinariesAfterInstall)
|
findStub.onSecondCall().resolves(missingBinariesAfterInstall)
|
||||||
|
|
||||||
|
|
@ -80,14 +87,15 @@ describe('BinaryManager', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
describe('findRequiredBinaries', () => {
|
describe('findRequiredBinaries', () => {
|
||||||
let findBinaryStub
|
let findBinaryStub
|
||||||
|
let ffmpegBinary
|
||||||
|
|
||||||
beforeEach(() => {
|
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)
|
binaryManager = new BinaryManager(requiredBinaries)
|
||||||
findBinaryStub = sinon.stub(binaryManager, 'findBinary')
|
findBinaryStub = sinon.stub(ffmpegBinary, 'find')
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
@ -108,7 +116,7 @@ describe('BinaryManager', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should add missing binaries to result', async () => {
|
it('should add missing binaries to result', async () => {
|
||||||
const missingBinaries = ['ffmpeg']
|
const missingBinaries = [ffmpegBinary]
|
||||||
delete process.env.FFMPEG_PATH
|
delete process.env.FFMPEG_PATH
|
||||||
findBinaryStub.resolves(null)
|
findBinaryStub.resolves(null)
|
||||||
|
|
||||||
|
|
@ -122,19 +130,22 @@ describe('BinaryManager', () => {
|
||||||
|
|
||||||
describe('install', () => {
|
describe('install', () => {
|
||||||
let isWritableStub
|
let isWritableStub
|
||||||
let downloadBinariesStub
|
let downloadBinaryStub
|
||||||
|
let ffmpegBinary
|
||||||
|
|
||||||
beforeEach(() => {
|
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')
|
isWritableStub = sinon.stub(fileUtils, 'isWritable')
|
||||||
downloadBinariesStub = sinon.stub(ffbinaries, 'downloadBinaries')
|
downloadBinaryStub = sinon.stub(ffmpegBinary, 'download')
|
||||||
binaryManager.mainInstallPath = '/path/to/main/install'
|
binaryManager.mainInstallDir = '/path/to/main/install'
|
||||||
binaryManager.altInstallPath = '/path/to/alt/install'
|
binaryManager.altInstallDir = '/path/to/alt/install'
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
isWritableStub.restore()
|
isWritableStub.restore()
|
||||||
downloadBinariesStub.restore()
|
downloadBinaryStub.restore()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should not install binaries if no binaries are passed', async () => {
|
it('should not install binaries if no binaries are passed', async () => {
|
||||||
|
|
@ -143,41 +154,42 @@ describe('BinaryManager', () => {
|
||||||
await binaryManager.install(binaries)
|
await binaryManager.install(binaries)
|
||||||
|
|
||||||
expect(isWritableStub.called).to.be.false
|
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 () => {
|
it('should install binaries in main install path if has access', async () => {
|
||||||
const binaries = ['ffmpeg']
|
const binaries = [ffmpegBinary]
|
||||||
const destination = binaryManager.mainInstallPath
|
const destination = binaryManager.mainInstallDir
|
||||||
isWritableStub.withArgs(destination).resolves(true)
|
isWritableStub.withArgs(destination).resolves(true)
|
||||||
downloadBinariesStub.resolves()
|
downloadBinaryStub.resolves()
|
||||||
|
|
||||||
await binaryManager.install(binaries)
|
await binaryManager.install(binaries)
|
||||||
|
|
||||||
expect(isWritableStub.calledOnce).to.be.true
|
expect(isWritableStub.calledOnce).to.be.true
|
||||||
expect(downloadBinariesStub.calledOnce).to.be.true
|
expect(downloadBinaryStub.calledOnce).to.be.true
|
||||||
expect(downloadBinariesStub.calledWith(binaries, sinon.match({ destination: destination }))).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 () => {
|
it('should install binaries in alt install path if has no access to main', async () => {
|
||||||
const binaries = ['ffmpeg']
|
const binaries = [ffmpegBinary]
|
||||||
const mainDestination = binaryManager.mainInstallPath
|
const mainDestination = binaryManager.mainInstallDir
|
||||||
const destination = binaryManager.altInstallPath
|
const destination = binaryManager.altInstallDir
|
||||||
isWritableStub.withArgs(mainDestination).resolves(false)
|
isWritableStub.withArgs(mainDestination).resolves(false)
|
||||||
downloadBinariesStub.resolves()
|
downloadBinaryStub.resolves()
|
||||||
|
|
||||||
await binaryManager.install(binaries)
|
await binaryManager.install(binaries)
|
||||||
|
|
||||||
expect(isWritableStub.calledOnce).to.be.true
|
expect(isWritableStub.calledOnce).to.be.true
|
||||||
expect(downloadBinariesStub.calledOnce).to.be.true
|
expect(downloadBinaryStub.calledOnce).to.be.true
|
||||||
expect(downloadBinariesStub.calledWith(binaries, sinon.match({ destination: destination }))).to.be.true
|
expect(downloadBinaryStub.calledWith(destination)).to.be.true
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('findBinary', () => {
|
describe('Binary', () => {
|
||||||
let binaryManager
|
describe('find', () => {
|
||||||
let isBinaryGoodStub
|
let binary
|
||||||
|
let isGoodStub
|
||||||
let whichSyncStub
|
let whichSyncStub
|
||||||
let mainInstallPath
|
let mainInstallPath
|
||||||
let altInstallPath
|
let altInstallPath
|
||||||
|
|
@ -188,115 +200,112 @@ describe('findBinary', () => {
|
||||||
const executable = name + (process.platform == 'win32' ? '.exe' : '')
|
const executable = name + (process.platform == 'win32' ? '.exe' : '')
|
||||||
const whichPath = '/usr/bin/ffmpeg'
|
const whichPath = '/usr/bin/ffmpeg'
|
||||||
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
binaryManager = new BinaryManager()
|
binary = new Binary(name, 'executable', envVariable, ['5.1'], ffbinaries)
|
||||||
isBinaryGoodStub = sinon.stub(binaryManager, 'isBinaryGood')
|
isGoodStub = sinon.stub(binary, 'isGood')
|
||||||
whichSyncStub = sinon.stub(which, 'sync')
|
whichSyncStub = sinon.stub(which, 'sync')
|
||||||
binaryManager.mainInstallPath = '/path/to/main/install'
|
binary.mainInstallDir = '/path/to/main/install'
|
||||||
mainInstallPath = path.join(binaryManager.mainInstallPath, executable)
|
mainInstallPath = path.join(binary.mainInstallDir, executable)
|
||||||
binaryManager.altInstallPath = '/path/to/alt/install'
|
binary.altInstallDir = '/path/to/alt/install'
|
||||||
altInstallPath = path.join(binaryManager.altInstallPath, executable)
|
altInstallPath = path.join(binary.altInstallDir, executable)
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
isBinaryGoodStub.restore()
|
isGoodStub.restore()
|
||||||
whichSyncStub.restore()
|
whichSyncStub.restore()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return the defaultPath if it exists and is a good binary', async () => {
|
it('should return the defaultPath if it exists and is a good binary', async () => {
|
||||||
process.env[envVariable] = defaultPath
|
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(result).to.equal(defaultPath)
|
||||||
expect(isBinaryGoodStub.calledOnce).to.be.true
|
expect(isGoodStub.calledOnce).to.be.true
|
||||||
expect(isBinaryGoodStub.calledWith(defaultPath)).to.be.true
|
expect(isGoodStub.calledWith(defaultPath)).to.be.true
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return the whichPath if it exists and is a good binary', async () => {
|
it('should return the whichPath if it exists and is a good binary', async () => {
|
||||||
delete process.env[envVariable]
|
delete process.env[envVariable]
|
||||||
isBinaryGoodStub.withArgs(undefined).resolves(false)
|
isGoodStub.withArgs(undefined).resolves(false)
|
||||||
isBinaryGoodStub.withArgs(whichPath).resolves(true)
|
|
||||||
whichSyncStub.returns(whichPath)
|
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(result).to.equal(whichPath)
|
||||||
expect(isBinaryGoodStub.calledTwice).to.be.true
|
expect(isGoodStub.calledTwice).to.be.true
|
||||||
expect(isBinaryGoodStub.calledWith(undefined)).to.be.true
|
expect(isGoodStub.calledWith(undefined)).to.be.true
|
||||||
expect(isBinaryGoodStub.calledWith(whichPath)).to.be.true
|
expect(isGoodStub.calledWith(whichPath)).to.be.true
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return the mainInstallPath if it exists and is a good binary', async () => {
|
it('should return the mainInstallPath if it exists and is a good binary', async () => {
|
||||||
delete process.env[envVariable]
|
delete process.env[envVariable]
|
||||||
isBinaryGoodStub.withArgs(undefined).resolves(false)
|
isGoodStub.withArgs(undefined).resolves(false)
|
||||||
isBinaryGoodStub.withArgs(null).resolves(false)
|
|
||||||
isBinaryGoodStub.withArgs(mainInstallPath).resolves(true)
|
|
||||||
whichSyncStub.returns(null)
|
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(result).to.equal(mainInstallPath)
|
||||||
expect(isBinaryGoodStub.callCount).to.be.equal(3)
|
expect(isGoodStub.callCount).to.be.equal(3)
|
||||||
expect(isBinaryGoodStub.calledWith(undefined)).to.be.true
|
expect(isGoodStub.calledWith(undefined)).to.be.true
|
||||||
expect(isBinaryGoodStub.calledWith(null)).to.be.true
|
expect(isGoodStub.calledWith(null)).to.be.true
|
||||||
expect(isBinaryGoodStub.calledWith(mainInstallPath)).to.be.true
|
expect(isGoodStub.calledWith(mainInstallPath)).to.be.true
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return the altInstallPath if it exists and is a good binary', async () => {
|
it('should return the altInstallPath if it exists and is a good binary', async () => {
|
||||||
delete process.env[envVariable]
|
delete process.env[envVariable]
|
||||||
isBinaryGoodStub.withArgs(undefined).resolves(false)
|
isGoodStub.withArgs(undefined).resolves(false)
|
||||||
isBinaryGoodStub.withArgs(null).resolves(false)
|
|
||||||
isBinaryGoodStub.withArgs(mainInstallPath).resolves(false)
|
|
||||||
isBinaryGoodStub.withArgs(altInstallPath).resolves(true)
|
|
||||||
whichSyncStub.returns(null)
|
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(result).to.equal(altInstallPath)
|
||||||
expect(isBinaryGoodStub.callCount).to.be.equal(4)
|
expect(isGoodStub.callCount).to.be.equal(4)
|
||||||
expect(isBinaryGoodStub.calledWith(undefined)).to.be.true
|
expect(isGoodStub.calledWith(undefined)).to.be.true
|
||||||
expect(isBinaryGoodStub.calledWith(null)).to.be.true
|
expect(isGoodStub.calledWith(null)).to.be.true
|
||||||
expect(isBinaryGoodStub.calledWith(mainInstallPath)).to.be.true
|
expect(isGoodStub.calledWith(mainInstallPath)).to.be.true
|
||||||
expect(isBinaryGoodStub.calledWith(altInstallPath)).to.be.true
|
expect(isGoodStub.calledWith(altInstallPath)).to.be.true
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return null if no good binary is found', async () => {
|
it('should return null if no good binary is found', async () => {
|
||||||
delete process.env[envVariable]
|
delete process.env[envVariable]
|
||||||
isBinaryGoodStub.withArgs(undefined).resolves(false)
|
isGoodStub.withArgs(undefined).resolves(false)
|
||||||
isBinaryGoodStub.withArgs(null).resolves(false)
|
|
||||||
isBinaryGoodStub.withArgs(mainInstallPath).resolves(false)
|
|
||||||
isBinaryGoodStub.withArgs(altInstallPath).resolves(false)
|
|
||||||
whichSyncStub.returns(null)
|
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(result).to.be.null
|
||||||
expect(isBinaryGoodStub.callCount).to.be.equal(4)
|
expect(isGoodStub.callCount).to.be.equal(4)
|
||||||
expect(isBinaryGoodStub.calledWith(undefined)).to.be.true
|
expect(isGoodStub.calledWith(undefined)).to.be.true
|
||||||
expect(isBinaryGoodStub.calledWith(null)).to.be.true
|
expect(isGoodStub.calledWith(null)).to.be.true
|
||||||
expect(isBinaryGoodStub.calledWith(mainInstallPath)).to.be.true
|
expect(isGoodStub.calledWith(mainInstallPath)).to.be.true
|
||||||
expect(isBinaryGoodStub.calledWith(altInstallPath)).to.be.true
|
expect(isGoodStub.calledWith(altInstallPath)).to.be.true
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
describe('isBinaryGood', () => {
|
describe('isGood', () => {
|
||||||
let binaryManager
|
let binary
|
||||||
let fsPathExistsStub
|
let fsPathExistsStub
|
||||||
let execStub
|
let execStub
|
||||||
let loggerInfoStub
|
|
||||||
let loggerErrorStub
|
|
||||||
|
|
||||||
const binaryPath = '/path/to/binary'
|
const binaryPath = '/path/to/binary'
|
||||||
const execCommand = '"' + binaryPath + '"' + ' -version'
|
const execCommand = '"' + binaryPath + '"' + ' -version'
|
||||||
const goodVersions = ['5.1', '6']
|
const goodVersions = ['5.1', '6']
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
binaryManager = new BinaryManager()
|
binary = new Binary('ffmpeg', 'executable', 'FFMPEG_PATH', goodVersions, ffbinaries)
|
||||||
fsPathExistsStub = sinon.stub(fs, 'pathExists')
|
fsPathExistsStub = sinon.stub(fs, 'pathExists')
|
||||||
execStub = sinon.stub(binaryManager, 'exec')
|
execStub = sinon.stub(binary, 'exec')
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
@ -307,7 +316,7 @@ describe('isBinaryGood', () => {
|
||||||
it('should return false if binaryPath is falsy', async () => {
|
it('should return false if binaryPath is falsy', async () => {
|
||||||
fsPathExistsStub.resolves(true)
|
fsPathExistsStub.resolves(true)
|
||||||
|
|
||||||
const result = await binaryManager.isBinaryGood(null, goodVersions)
|
const result = await binary.isGood(null)
|
||||||
|
|
||||||
expect(result).to.be.false
|
expect(result).to.be.false
|
||||||
expect(fsPathExistsStub.called).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 () => {
|
it('should return false if binaryPath does not exist', async () => {
|
||||||
fsPathExistsStub.resolves(false)
|
fsPathExistsStub.resolves(false)
|
||||||
|
|
||||||
const result = await binaryManager.isBinaryGood(binaryPath, goodVersions)
|
const result = await binary.isGood(binaryPath)
|
||||||
|
|
||||||
expect(result).to.be.false
|
expect(result).to.be.false
|
||||||
expect(fsPathExistsStub.calledOnce).to.be.true
|
expect(fsPathExistsStub.calledOnce).to.be.true
|
||||||
|
|
@ -329,7 +338,7 @@ describe('isBinaryGood', () => {
|
||||||
fsPathExistsStub.resolves(true)
|
fsPathExistsStub.resolves(true)
|
||||||
execStub.rejects(new Error('Failed to execute command'))
|
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(result).to.be.false
|
||||||
expect(fsPathExistsStub.calledOnce).to.be.true
|
expect(fsPathExistsStub.calledOnce).to.be.true
|
||||||
|
|
@ -343,7 +352,7 @@ describe('isBinaryGood', () => {
|
||||||
fsPathExistsStub.resolves(true)
|
fsPathExistsStub.resolves(true)
|
||||||
execStub.resolves({ stdout })
|
execStub.resolves({ stdout })
|
||||||
|
|
||||||
const result = await binaryManager.isBinaryGood(binaryPath, goodVersions)
|
const result = await binary.isGood(binaryPath)
|
||||||
|
|
||||||
expect(result).to.be.false
|
expect(result).to.be.false
|
||||||
expect(fsPathExistsStub.calledOnce).to.be.true
|
expect(fsPathExistsStub.calledOnce).to.be.true
|
||||||
|
|
@ -357,7 +366,7 @@ describe('isBinaryGood', () => {
|
||||||
fsPathExistsStub.resolves(true)
|
fsPathExistsStub.resolves(true)
|
||||||
execStub.resolves({ stdout })
|
execStub.resolves({ stdout })
|
||||||
|
|
||||||
const result = await binaryManager.isBinaryGood(binaryPath, goodVersions)
|
const result = await binary.isGood(binaryPath)
|
||||||
|
|
||||||
expect(result).to.be.false
|
expect(result).to.be.false
|
||||||
expect(fsPathExistsStub.calledOnce).to.be.true
|
expect(fsPathExistsStub.calledOnce).to.be.true
|
||||||
|
|
@ -371,7 +380,7 @@ describe('isBinaryGood', () => {
|
||||||
fsPathExistsStub.resolves(true)
|
fsPathExistsStub.resolves(true)
|
||||||
execStub.resolves({ stdout })
|
execStub.resolves({ stdout })
|
||||||
|
|
||||||
const result = await binaryManager.isBinaryGood(binaryPath, goodVersions)
|
const result = await binary.isGood(binaryPath)
|
||||||
|
|
||||||
expect(result).to.be.true
|
expect(result).to.be.true
|
||||||
expect(fsPathExistsStub.calledOnce).to.be.true
|
expect(fsPathExistsStub.calledOnce).to.be.true
|
||||||
|
|
@ -379,4 +388,68 @@ describe('isBinaryGood', () => {
|
||||||
expect(execStub.calledOnce).to.be.true
|
expect(execStub.calledOnce).to.be.true
|
||||||
expect(execStub.calledWith(execCommand)).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.run = sinon.stub().callsFake(() => {
|
||||||
ffmpegStub.emit('end')
|
ffmpegStub.emit('end')
|
||||||
})
|
})
|
||||||
const fsCopyFileSyncStub = sinon.stub(fs, 'copyFileSync')
|
const fsMove = sinon.stub(fs, 'move').resolves()
|
||||||
const fsUnlinkSyncStub = sinon.stub(fs, 'unlinkSync')
|
|
||||||
|
|
||||||
return { audioFilePath, coverFilePath, metadataFilePath, track, mimeType, ffmpegStub, fsCopyFileSyncStub, fsUnlinkSyncStub }
|
return { audioFilePath, coverFilePath, metadataFilePath, track, mimeType, ffmpegStub, fsMove }
|
||||||
}
|
}
|
||||||
|
|
||||||
let audioFilePath = null
|
let audioFilePath = null
|
||||||
|
|
@ -93,8 +92,7 @@ describe('addCoverAndMetadataToFile', () => {
|
||||||
let track = null
|
let track = null
|
||||||
let mimeType = null
|
let mimeType = null
|
||||||
let ffmpegStub = null
|
let ffmpegStub = null
|
||||||
let fsCopyFileSyncStub = null
|
let fsMove = null
|
||||||
let fsUnlinkSyncStub = null
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const input = createTestSetup()
|
const input = createTestSetup()
|
||||||
audioFilePath = input.audioFilePath
|
audioFilePath = input.audioFilePath
|
||||||
|
|
@ -103,16 +101,14 @@ describe('addCoverAndMetadataToFile', () => {
|
||||||
track = input.track
|
track = input.track
|
||||||
mimeType = input.mimeType
|
mimeType = input.mimeType
|
||||||
ffmpegStub = input.ffmpegStub
|
ffmpegStub = input.ffmpegStub
|
||||||
fsCopyFileSyncStub = input.fsCopyFileSyncStub
|
fsMove = input.fsMove
|
||||||
fsUnlinkSyncStub = input.fsUnlinkSyncStub
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should add cover image and metadata to audio file', async () => {
|
it('should add cover image and metadata to audio file', async () => {
|
||||||
// Act
|
// Act
|
||||||
const result = await addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, ffmpegStub)
|
await addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, null, ffmpegStub)
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(result).to.be.true
|
|
||||||
expect(ffmpegStub.input.calledThrice).to.be.true
|
expect(ffmpegStub.input.calledThrice).to.be.true
|
||||||
expect(ffmpegStub.input.getCall(0).args[0]).to.equal(audioFilePath)
|
expect(ffmpegStub.input.getCall(0).args[0]).to.equal(audioFilePath)
|
||||||
expect(ffmpegStub.input.getCall(1).args[0]).to.equal(metadataFilePath)
|
expect(ffmpegStub.input.getCall(1).args[0]).to.equal(metadataFilePath)
|
||||||
|
|
@ -129,12 +125,10 @@ describe('addCoverAndMetadataToFile', () => {
|
||||||
|
|
||||||
expect(ffmpegStub.run.calledOnce).to.be.true
|
expect(ffmpegStub.run.calledOnce).to.be.true
|
||||||
|
|
||||||
expect(fsCopyFileSyncStub.calledOnce).to.be.true
|
expect(fsMove.calledOnce).to.be.true
|
||||||
expect(fsCopyFileSyncStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.mp3')
|
expect(fsMove.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.mp3')
|
||||||
expect(fsCopyFileSyncStub.firstCall.args[1]).to.equal('/path/to/audio/file.mp3')
|
expect(fsMove.firstCall.args[1]).to.equal('/path/to/audio/file.mp3')
|
||||||
|
expect(fsMove.firstCall.args[2]).to.deep.equal({ overwrite: true })
|
||||||
expect(fsUnlinkSyncStub.calledOnce).to.be.true
|
|
||||||
expect(fsUnlinkSyncStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.mp3')
|
|
||||||
|
|
||||||
// Restore the stub
|
// Restore the stub
|
||||||
sinon.restore()
|
sinon.restore()
|
||||||
|
|
@ -145,10 +139,9 @@ describe('addCoverAndMetadataToFile', () => {
|
||||||
coverFilePath = null
|
coverFilePath = null
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
const result = await addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, ffmpegStub)
|
await addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, null, ffmpegStub)
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(result).to.be.true
|
|
||||||
expect(ffmpegStub.input.calledTwice).to.be.true
|
expect(ffmpegStub.input.calledTwice).to.be.true
|
||||||
expect(ffmpegStub.input.getCall(0).args[0]).to.equal(audioFilePath)
|
expect(ffmpegStub.input.getCall(0).args[0]).to.equal(audioFilePath)
|
||||||
expect(ffmpegStub.input.getCall(1).args[0]).to.equal(metadataFilePath)
|
expect(ffmpegStub.input.getCall(1).args[0]).to.equal(metadataFilePath)
|
||||||
|
|
@ -164,12 +157,10 @@ describe('addCoverAndMetadataToFile', () => {
|
||||||
|
|
||||||
expect(ffmpegStub.run.calledOnce).to.be.true
|
expect(ffmpegStub.run.calledOnce).to.be.true
|
||||||
|
|
||||||
expect(fsCopyFileSyncStub.calledOnce).to.be.true
|
expect(fsMove.calledOnce).to.be.true
|
||||||
expect(fsCopyFileSyncStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.mp3')
|
expect(fsMove.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.mp3')
|
||||||
expect(fsCopyFileSyncStub.firstCall.args[1]).to.equal('/path/to/audio/file.mp3')
|
expect(fsMove.firstCall.args[1]).to.equal('/path/to/audio/file.mp3')
|
||||||
|
expect(fsMove.firstCall.args[2]).to.deep.equal({ overwrite: true })
|
||||||
expect(fsUnlinkSyncStub.calledOnce).to.be.true
|
|
||||||
expect(fsUnlinkSyncStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.mp3')
|
|
||||||
|
|
||||||
// Restore the stub
|
// Restore the stub
|
||||||
sinon.restore()
|
sinon.restore()
|
||||||
|
|
@ -182,10 +173,15 @@ describe('addCoverAndMetadataToFile', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
// Act
|
// 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
|
// Assert
|
||||||
expect(result).to.be.false
|
|
||||||
expect(ffmpegStub.input.calledThrice).to.be.true
|
expect(ffmpegStub.input.calledThrice).to.be.true
|
||||||
expect(ffmpegStub.input.getCall(0).args[0]).to.equal(audioFilePath)
|
expect(ffmpegStub.input.getCall(0).args[0]).to.equal(audioFilePath)
|
||||||
expect(ffmpegStub.input.getCall(1).args[0]).to.equal(metadataFilePath)
|
expect(ffmpegStub.input.getCall(1).args[0]).to.equal(metadataFilePath)
|
||||||
|
|
@ -202,9 +198,7 @@ describe('addCoverAndMetadataToFile', () => {
|
||||||
|
|
||||||
expect(ffmpegStub.run.calledOnce).to.be.true
|
expect(ffmpegStub.run.calledOnce).to.be.true
|
||||||
|
|
||||||
expect(fsCopyFileSyncStub.called).to.be.false
|
expect(fsMove.called).to.be.false
|
||||||
|
|
||||||
expect(fsUnlinkSyncStub.called).to.be.false
|
|
||||||
|
|
||||||
// Restore the stub
|
// Restore the stub
|
||||||
sinon.restore()
|
sinon.restore()
|
||||||
|
|
@ -216,10 +210,9 @@ describe('addCoverAndMetadataToFile', () => {
|
||||||
audioFilePath = '/path/to/audio/file.m4b'
|
audioFilePath = '/path/to/audio/file.m4b'
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
const result = await addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, ffmpegStub)
|
await addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, null, ffmpegStub)
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(result).to.be.true
|
|
||||||
expect(ffmpegStub.input.calledThrice).to.be.true
|
expect(ffmpegStub.input.calledThrice).to.be.true
|
||||||
expect(ffmpegStub.input.getCall(0).args[0]).to.equal(audioFilePath)
|
expect(ffmpegStub.input.getCall(0).args[0]).to.equal(audioFilePath)
|
||||||
expect(ffmpegStub.input.getCall(1).args[0]).to.equal(metadataFilePath)
|
expect(ffmpegStub.input.getCall(1).args[0]).to.equal(metadataFilePath)
|
||||||
|
|
@ -236,12 +229,10 @@ describe('addCoverAndMetadataToFile', () => {
|
||||||
|
|
||||||
expect(ffmpegStub.run.calledOnce).to.be.true
|
expect(ffmpegStub.run.calledOnce).to.be.true
|
||||||
|
|
||||||
expect(fsCopyFileSyncStub.calledOnce).to.be.true
|
expect(fsMove.calledOnce).to.be.true
|
||||||
expect(fsCopyFileSyncStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.m4b')
|
expect(fsMove.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.m4b')
|
||||||
expect(fsCopyFileSyncStub.firstCall.args[1]).to.equal('/path/to/audio/file.m4b')
|
expect(fsMove.firstCall.args[1]).to.equal('/path/to/audio/file.m4b')
|
||||||
|
expect(fsMove.firstCall.args[2]).to.deep.equal({ overwrite: true })
|
||||||
expect(fsUnlinkSyncStub.calledOnce).to.be.true
|
|
||||||
expect(fsUnlinkSyncStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.m4b')
|
|
||||||
|
|
||||||
// Restore the stub
|
// Restore the stub
|
||||||
sinon.restore()
|
sinon.restore()
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,16 @@ describe('parseNfoMetadata', () => {
|
||||||
expect(result.asin).to.equal('B08X5JZJLH')
|
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', () => {
|
it('parses description', () => {
|
||||||
const nfoText = 'Book Description\n=========\nThis is a book.\n It\'s good'
|
const nfoText = 'Book Description\n=========\nThis is a book.\n It\'s good'
|
||||||
const result = parseNfoMetadata(nfoText)
|
const result = parseNfoMetadata(nfoText)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue