Merge branch 'advplyr:master' into book-preview-on-hover

This commit is contained in:
Greg Lorenzen 2024-07-29 14:24:13 -07:00 committed by GitHub
commit e6da9a1945
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
79 changed files with 2246 additions and 1780 deletions

View file

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

3
.gitignore vendored
View file

@ -15,8 +15,9 @@
/.nyc_output/ /.nyc_output/
/ffmpeg* /ffmpeg*
/ffprobe* /ffprobe*
/unicode*
sw.* sw.*
.DS_STORE .DS_STORE
.idea/* .idea/*
tailwind.compiled.css tailwind.compiled.css

View file

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

View file

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

View file

@ -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,9 +37,9 @@ 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;
} }
</style> </style>

View file

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

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

View file

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

View file

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

View file

@ -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,9 +28,9 @@ 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;
} }
</style> </style>

View file

@ -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,9 +28,9 @@ 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;
} }
</style> </style>

View file

@ -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
@ -203,4 +215,4 @@ export default {
.globalSearchMenu { .globalSearchMenu {
max-height: calc(100vh - 75px); max-height: calc(100vh - 75px);
} }
</style> </style>

View file

@ -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'
} }
] ]
}, },
@ -215,4 +223,4 @@ export default {
} }
} }
} }
</script> </script>

View file

@ -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>
@ -87,4 +87,4 @@ export default {
max-width: calc(100% - 150px); max-width: calc(100% - 150px);
} }
} }
</style> </style>

View file

@ -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 {
path: `${this.selectedFolderPath}/${this.$sanitizeFilename(metadata.title)}`,
folderId: this.selectedFolderId,
libraryId: this.currentLibrary.id,
media: {
metadata: {
...metadata
},
autoDownloadEpisodes: this.autoDownloadEpisodes
}
}
})
console.log('New feed payloads', newFeedPayloads)
for (const podcastPayload of newFeedPayloads) { const payload = {
await this.$axios feeds: this.feeds.map((f) => f.feedUrl),
.$post('/api/podcasts', podcastPayload) folderId: this.selectedFolderId,
.then(() => { libraryId: this.currentLibrary.id,
this.$toast.success(`${podcastPayload.media.metadata.title}: ${this.$strings.ToastPodcastCreateSuccess}`) autoDownloadEpisodes: this.autoDownloadEpisodes
})
.catch((error) => {
var errorMsg = error.response && error.response.data ? error.response.data : this.$strings.ToastPodcastCreateFailed
console.error('Failed to create podcast', podcastPayload, error)
this.$toast.error(`${podcastPayload.media.metadata.title}: ${errorMsg}`)
})
} }
this.processing = false this.$axios
this.show = false .$post('/api/podcasts/opml/create', payload)
.then(() => {
this.show = false
})
.catch((error) => {
const errorMsg = error.response?.data || this.$strings.ToastPodcastCreateFailed
console.error('Failed to create podcast', payload, error)
this.$toast.error(errorMsg)
})
.finally(() => {
this.processing = false
})
} }
}, },
mounted() {} mounted() {}
} }
</script> </script>
<style scoped>
#podcast-wrapper {
min-height: 400px;
max-height: 80vh;
}
#episodes-scroll {
max-height: calc(80vh - 200px);
}
</style>

View file

@ -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)
@ -153,4 +153,4 @@ export default {
}, },
mounted() {} mounted() {}
} }
</script> </script>

View file

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

View file

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

View file

@ -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 />
@ -116,4 +116,4 @@ export default {
}, },
mounted() {} mounted() {}
} }
</script> </script>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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>
@ -138,4 +138,4 @@ export default {
this.init() this.init()
} }
} }
</script> </script>

View file

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

View file

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

View file

@ -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.opmlFeeds = data.feeds || [] this.$toast.error('No feeds found in OPML file')
this.showOPMLFeedsModal = true } else {
this.opmlFeeds = data.feeds || []
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')
}) })
this.processing = false .finally(() => {
this.processing = false
})
}, },
submit() { submit() {
if (!this.searchInput) return if (!this.searchInput) return

View file

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

View file

@ -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,17 +347,20 @@ 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
this.failedProgressSyncs = 0 .$post(`/api/session/${this.currentSessionId}/sync`, syncData, { timeout: 9000, progress: false })
}).catch((error) => { .then(() => {
console.error('Failed to update session progress', error)
// After 4 failed sync attempts show an alert toast
this.failedProgressSyncs++
if (this.failedProgressSyncs >= 4) {
this.ctx.showFailedProgressSyncs()
this.failedProgressSyncs = 0 this.failedProgressSyncs = 0
} })
}) .catch((error) => {
console.error('Failed to update session progress', error)
// After 4 failed sync attempts show an alert toast
this.failedProgressSyncs++
if (this.failedProgressSyncs >= 4) {
this.ctx.showFailedProgressSyncs()
this.failedProgressSyncs = 0
}
})
} }
stopPlayInterval() { stopPlayInterval() {
@ -419,4 +423,4 @@ export default class PlayerHandler {
this.sendProgressSync(time) this.sendProgressSync(time)
} }
} }
} }

View file

@ -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') () => {
resolve(true) if (ctx) ctx.$toast.success('Copied to clipboard')
}, (err) => { resolve(true)
console.error('Clipboard copy failed', str, err) },
resolve(false) (err) => {
}) console.error('Clipboard copy failed', str, err)
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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 limage à partir du web", "LabelImageURLFromTheWeb": "URL de limage à 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 dheure", "LabelSettingsTimeFormat": "Format dheure",
"LabelShare": "Partager",
"LabelShareOpen": "Ouvrir le partage",
"LabelShareURL": "Partager lURL",
"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 />LURL de lAPI 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 />LURL de lAPI 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>nincluent 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>nincluent 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: lemplacement de sauvegarde est défini via une variable denvironnement 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 dajouter 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 dajouter 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 navez pas encore de collections", "MessageBookshelfNoCollections": "Vous navez 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": "Ladresse 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 : lhorodatage 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 : lhorodatage 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.",

View file

@ -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": "קצה תקשורת חובר",

View file

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

View file

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

View file

@ -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: array type: object
items: properties:
$ref: '../objects/mediaTypes/Podcast.yaml#/components/schemas/Podcast' feeds:
400: type: array
description: Bad request items:
403: type: object
description: Forbidden properties:
title:
type: string
feedUrl:
type: string
'400':
description: Bad request, OPML text not provided
'403':
description: Forbidden, user is not admin
/api/podcasts/opml/create:
post:
summary: Bulk create podcasts from OPML feed URLs
operationId: bulkCreatePodcastsFromOpmlFeedUrls
tags:
- Podcasts
requestBody:
content:
application/json:
schema:
type: object
properties:
feeds:
type: array
items:
type: string
libraryId:
$ref: '../objects/Library.yaml#/components/schemas/libraryId'
folderId:
$ref: '../objects/Folder.yaml#/components/schemas/folderId'
autoDownloadEpisodes:
$ref: '../objects/mediaTypes/Podcast.yaml#/components/schemas/autoDownloadEpisodes'
responses:
'200':
description: Successfully created podcasts from feed URLs
'400':
description: Bad request, invalid request body
'403':
description: Forbidden, user is not admin
'404':
description: Folder not found
/api/podcasts/{id}/checknew: /api/podcasts/{id}/checknew:
parameters: parameters:

View file

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

View file

@ -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": "array", "type": "object",
"items": { "properties": {
"$ref": "#/components/schemas/Podcast" "feeds": {
"type": "array",
"items": {
"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",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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] }),
this.removeTask(task, true) (trackIndex, progressInTrack, taskProgress) => {
return SocketAuthority.adminEmitter('track_progress', { libraryItemId: libraryItem.id, ino: task.data.inos[trackIndex], progress: progressInTrack })
} SocketAuthority.adminEmitter('task_progress', { libraryItemId: libraryItem.id, progress: taskProgress * encodeFraction })
},
if (!result.success) { (trackIndex) => SocketAuthority.adminEmitter('track_finished', { libraryItemId: libraryItem.id, ino: task.data.inos[trackIndex] })
task.setFailed('Encoding failed') )
this.removeTask(task, true) task.data.ffmpeg = new Ffmpeg()
await ffmpegHelpers.mergeAudioFiles(libraryItem.media.tracks, task.data.duration, task.data.itemCachePath, task.data.tempFilepath, encodingOptions, (progress) => trackProgressMonitor.update(progress), task.data.ffmpeg)
delete task.data.ffmpeg
trackProgressMonitor.finish()
} catch (error) {
if (error.message === 'FFMPEG_CANCELED') {
Logger.info(`[AbMergeManager] Task cancelled ${task.id}`)
} else {
Logger.error(`[AbMergeManager] mergeAudioFiles failed`, error)
task.setFailed('Failed to merge audio files')
this.removeTask(task, true)
}
return 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()
Logger.error(`[AbMergeManager] Failed to write metadata to file "${task.data.tempFilepath}"`) await ffmpegHelpers.addCoverAndMetadataToFile(
task.setFailed('Failed to write metadata to m4b file') task.data.tempFilepath,
this.removeTask(task, true) task.data.coverPath,
task.data.ffmetadataPath,
1,
'audio/mp4',
(progress) => {
Logger.debug(`[AbMergeManager] Embedding metadata progress: ${100 * encodeFraction + progress * embedFraction}`)
SocketAuthority.adminEmitter('task_progress', { libraryItemId: libraryItem.id, progress: 100 * encodeFraction + progress * embedFraction })
},
task.data.ffmpeg
)
delete task.data.ffmpeg
} catch (error) {
if (error.message === 'FFMPEG_CANCELED') {
Logger.info(`[AbMergeManager] Task cancelled ${task.id}`)
} else {
Logger.error(`[AbMergeManager] Failed to write metadata to file "${task.data.tempFilepath}"`)
task.setFailed('Failed to write metadata to m4b file')
this.removeTask(task, true)
}
return 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`)
} }
} }

View file

@ -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))) {
await fs.mkdir(task.data.itemCachePath) try {
cacheDirCreated = true await fs.mkdir(task.data.itemCachePath)
cacheDirCreated = true
} catch (err) {
Logger.error(`[AudioMetadataManager] Failed to create cache directory ${task.data.itemCachePath}`, err)
task.setFailed('Failed to create cache directory')
this.handleTaskFinished(task)
return
}
} }
// Create ffmetadata file // 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

View file

@ -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,14 +421,16 @@ class BackupManager {
reject(err) reject(err)
}) })
archive.on('progress', ({ fs: fsobj }) => { archive.on('progress', ({ fs: fsobj }) => {
const maxBackupSizeInBytes = this.maxBackupSize * 1000 * 1000 * 1000 if (this.maxBackupSize !== Infinity) {
if (fsobj.processedBytes > maxBackupSizeInBytes) { const maxBackupSizeInBytes = this.maxBackupSize * 1000 * 1000 * 1000
Logger.error(`[BackupManager] Archiver is too large - aborting to prevent endless loop, Bytes Processed: ${fsobj.processedBytes}`) if (fsobj.processedBytes > maxBackupSizeInBytes) {
archive.abort() Logger.error(`[BackupManager] Archiver is too large - aborting to prevent endless loop, Bytes Processed: ${fsobj.processedBytes}`)
setTimeout(() => { archive.abort()
this.removeBackup(backup) setTimeout(() => {
output.destroy('Backup too large') // Promise is reject in write stream error evt this.removeBackup(backup)
}, 500) output.destroy('Backup too large') // Promise is reject in write stream error evt
}, 500)
}
} }
}) })

View 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,36 +293,30 @@ 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) async removeOldBinaries(binaries) {
if (await fs.pathExists(mainInstallPath)) { for (const binary of binaries) {
Logger.debug(`[BinaryManager] Removing old binary: ${mainInstallPath}`) await this.removeBinary(this.mainInstallDir, binary)
await fs.remove(mainInstallPath) await this.removeBinary(this.altInstallDir, binary)
}
const altInstallPath = path.join(this.altInstallPath, executable)
if (await fs.pathExists(altInstallPath)) {
Logger.debug(`[BinaryManager] Removing old binary: ${altInstallPath}`)
await fs.remove(altInstallPath)
}
} }
} }
/** /**
* Find required binaries and return array of binary names that are missing * Find required binaries and return array of binary names that are missing
* *
* @returns {Promise<string[]>} * @returns {Promise<string[]>}
*/ */
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) {
await binary.download(destination)
}
Logger.info(`[BinaryManager] Binaries installed to ${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

View file

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

View file

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

View file

@ -9,8 +9,8 @@ class TaskManager {
/** /**
* Add task and emit socket task_started event * Add task and emit socket task_started event
* *
* @param {Task} task * @param {Task} task
*/ */
addTask(task) { addTask(task) {
this.tasks.push(task) this.tasks.push(task)
@ -19,24 +19,24 @@ class TaskManager {
/** /**
* Remove task and emit task_finished event * Remove task and emit task_finished event
* *
* @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())
} }
} }
/** /**
* Create new task and add * Create new task and add
* *
* @param {string} action * @param {string} action
* @param {string} title * @param {string} title
* @param {string} description * @param {string} description
* @param {boolean} showSuccess * @param {boolean} showSuccess
* @param {Object} [data] * @param {Object} [data]
*/ */
createAndAddTask(action, title, description, showSuccess, data = {}) { createAndAddTask(action, title, description, showSuccess, data = {}) {
const task = new Task() const task = new Task()
@ -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()

View file

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

View file

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

View 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

View file

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

View file

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

View file

@ -2,24 +2,26 @@ 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
* *
* @param {import('../models/LibraryItem').LibraryFileObject} nfoLibraryFileObj * @param {import('../models/LibraryItem').LibraryFileObject} nfoLibraryFileObj
* @param {Object} bookMetadata * @param {Object} bookMetadata
*/ */
async scanBookNfoFile(nfoLibraryFileObj, bookMetadata) { async scanBookNfoFile(nfoLibraryFileObj, bookMetadata) {
const nfoText = await readTextFile(nfoLibraryFileObj.metadata.path) const nfoText = await readTextFile(nfoLibraryFileObj.metadata.path)
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, {
sequence: nfoMetadata.sequence || null name: nfoMetadata.series,
}] sequence: nfoMetadata.sequence || null
}
]
} }
} else if (nfoMetadata[key] && key !== 'sequence') { } else if (nfoMetadata[key] && key !== 'sequence') {
bookMetadata[key] = nfoMetadata[key] bookMetadata[key] = nfoMetadata[key]
@ -45,4 +49,4 @@ class NfoFileScanner {
} }
} }
} }
module.exports = new NfoFileScanner() module.exports = new NfoFileScanner()

View file

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

View file

@ -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) => {
Logger.error('Error adding cover image and metadata:', err) if (err.message && err.message.includes('SIGKILL')) {
Logger.error('ffmpeg stdout:', stdout) Logger.info(`[ffmpegHelpers] addCoverAndMetadataToFile Killed by User`)
Logger.error('ffmpeg stderr:', stderr) reject(new Error('FFMPEG_CANCELED'))
resolve(false) } else {
Logger.error('Error adding cover image and metadata:', err)
Logger.error('ffmpeg stdout:', stdout)
Logger.error('ffmpeg stderr:', stderr)
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

View file

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

View 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

View file

@ -4,12 +4,11 @@ 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
* *
* @param {string} epubPath * @param {string} epubPath
* @param {string} filepath * @param {string} filepath
* @returns {Promise<string>} * @returns {Promise<string>}
*/ */
async function extractFileFromEpub(epubPath, filepath) { async function extractFileFromEpub(epubPath, filepath) {
@ -27,9 +26,9 @@ async function extractFileFromEpub(epubPath, filepath) {
/** /**
* Extract an XML file from epub and return JSON * Extract an XML file from epub and return JSON
* *
* @param {string} epubPath * @param {string} epubPath
* @param {string} xmlFilepath * @param {string} xmlFilepath
* @returns {Promise<Object>} * @returns {Promise<Object>}
*/ */
async function extractXmlToJson(epubPath, xmlFilepath) { async function extractXmlToJson(epubPath, xmlFilepath) {
@ -40,19 +39,22 @@ async function extractXmlToJson(epubPath, xmlFilepath) {
/** /**
* Extract cover image from epub return true if success * Extract cover image from epub return true if success
* *
* @param {string} epubPath * @param {string} epubPath
* @param {string} epubImageFilepath * @param {string} epubImageFilepath
* @param {string} outputCoverPath * @param {string} outputCoverPath
* @returns {Promise<boolean>} * @returns {Promise<boolean>}
*/ */
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
Logger.error(`[parseEpubMetadata] Failed to extract image ${epubImageFilepath} from epub at "${epubPath}"`, error) .extract(epubImageFilepath, outputCoverPath)
return false .then(() => true)
}) .catch((error) => {
Logger.error(`[parseEpubMetadata] Failed to extract image ${epubImageFilepath} from epub at "${epubPath}"`, error)
return false
})
await zip.close() await zip.close()
@ -62,8 +64,8 @@ module.exports.extractCoverImage = extractCoverImage
/** /**
* Parse metadata from epub * Parse metadata from epub
* *
* @param {import('../../models/Book').EBookFileObject} ebookFile * @param {import('../../models/Book').EBookFileObject} ebookFile
* @returns {Promise<import('./parseEbookMetadata').EBookFileScanData>} * @returns {Promise<import('./parseEbookMetadata').EBookFileScanData>}
*/ */
async function parse(ebookFile) { async function parse(ebookFile) {
@ -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)
@ -113,4 +130,4 @@ async function parse(ebookFile) {
return payload return payload
} }
module.exports.parse = parse module.exports.parse = parse

View file

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

View file

@ -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
}) })
} }
@ -21,4 +25,4 @@ function parse(opmlText) {
parser.write(opmlText) parser.write(opmlText)
return feeds return feeds
} }
module.exports.parse = parse module.exports.parse = parse

View file

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

View file

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

View file

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

View file

@ -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
itemMatches.push({
let matchText = null libraryItem: Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSONExpanded()
let matchKey = null })
for (const key of ['title', 'subtitle', 'asin', 'isbn']) {
const valueToLower = asciiOnlyToLowerCase(book[key])
if (valueToLower.includes(query)) {
matchText = book[key]
matchKey = key
break
}
}
if (matchKey) {
itemMatches.push({
matchText,
matchKey,
libraryItem: Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSONExpanded()
})
}
} }
const matchJsonValue = Database.matchExpression('json_each.value', normalizedQuery)
// Search narrators // 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
} }

View file

@ -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')
@ -7,7 +6,7 @@ const { asciiOnlyToLowerCase } = require('../index')
module.exports = { module.exports = {
/** /**
* User permissions to restrict podcasts for explicit content & tags * User permissions to restrict podcasts for explicit content & tags
* @param {import('../../objects/user/User')} user * @param {import('../../objects/user/User')} user
* @returns {{ podcastWhere:Sequelize.WhereOptions, replacements:object }} * @returns {{ podcastWhere:Sequelize.WhereOptions, replacements:object }}
*/ */
getUserPermissionPodcastWhereQuery(user) { getUserPermissionPodcastWhereQuery(user) {
@ -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.Op.gte]: 1 Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:userTagsSelected))`), {
})) [Sequelize.Op.gte]: 1
})
)
} }
} }
return { return {
@ -36,8 +37,8 @@ module.exports = {
/** /**
* Get where options for Podcast model * Get where options for Podcast model
* @param {string} group * @param {string} group
* @param {[string]} value * @param {[string]} value
* @returns {object} { Sequelize.WhereOptions, string[] } * @returns {object} { Sequelize.WhereOptions, string[] }
*/ */
getMediaGroupQuery(group, value) { getMediaGroupQuery(group, value) {
@ -63,8 +64,8 @@ module.exports = {
/** /**
* Get sequelize order * Get sequelize order
* @param {string} sortBy * @param {string} sortBy
* @param {boolean} sortDesc * @param {boolean} sortDesc
* @returns {Sequelize.order} * @returns {Sequelize.order}
*/ */
getOrder(sortBy, sortDesc) { getOrder(sortBy, sortDesc) {
@ -88,21 +89,23 @@ 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 []
}, },
/** /**
* Get library items for podcast media type using filter and sort * Get library items for podcast media type using filter and sort
* @param {string} libraryId * @param {string} libraryId
* @param {oldUser} user * @param {oldUser} user
* @param {[string]} filterGroup * @param {[string]} filterGroup
* @param {[string]} filterValue * @param {[string]} filterValue
* @param {string} sortBy * @param {string} sortBy
* @param {string} sortDesc * @param {string} sortDesc
* @param {string[]} include * @param {string[]} include
* @param {number} limit * @param {number} limit
* @param {number} offset * @param {number} offset
* @returns {object} { libraryItems:LibraryItem[], count:number } * @returns {object} { libraryItems:LibraryItem[], count:number }
*/ */
async getFilteredLibraryItems(libraryId, user, filterGroup, filterValue, sortBy, sortDesc, include, limit, offset) { async getFilteredLibraryItems(libraryId, user, filterGroup, filterValue, sortBy, sortDesc, include, limit, offset) {
@ -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: [
{ {
@ -199,14 +199,14 @@ module.exports = {
/** /**
* Get podcast episodes filtered and sorted * Get podcast episodes filtered and sorted
* @param {string} libraryId * @param {string} libraryId
* @param {oldUser} user * @param {oldUser} user
* @param {[string]} filterGroup * @param {[string]} filterGroup
* @param {[string]} filterValue * @param {[string]} filterValue
* @param {string} sortBy * @param {string} sortBy
* @param {string} sortDesc * @param {string} sortDesc
* @param {number} limit * @param {number} limit
* @param {number} offset * @param {number} offset
* @param {boolean} isHomePage for home page shelves * @param {boolean} isHomePage for home page shelves
* @returns {object} {libraryItems:LibraryItem[], count:number} * @returns {object} {libraryItems:LibraryItem[], count:number}
*/ */
@ -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
} }
} }
@ -305,29 +305,26 @@ module.exports = {
/** /**
* Search podcasts * Search podcasts
* @param {import('../../objects/user/User')} oldUser * @param {import('../../objects/user/User')} oldUser
* @param {import('../../objects/Library')} oldLibrary * @param {import('../../objects/Library')} oldLibrary
* @param {string} query * @param {string} query
* @param {number} limit * @param {number} limit
* @param {number} offset * @param {number} offset
* @returns {{podcast:object[], tags:object[]}} * @returns {{podcast:object[], tags:object[]}}
*/ */
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
itemMatches.push({
let matchText = null libraryItem: Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSONExpanded()
let matchKey = null })
for (const key of ['title', 'author', 'itunesId', 'itunesArtistId']) {
const valueToLower = asciiOnlyToLowerCase(podcast[key])
if (valueToLower.includes(query)) {
matchText = podcast[key]
matchKey = key
break
}
}
if (matchKey) {
itemMatches.push({
matchText,
matchKey,
libraryItem: Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSONExpanded()
})
}
} }
const matchJsonValue = Database.matchExpression('json_each.value', normalizedQuery)
// Search tags // 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,18 +384,36 @@ 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
} }
}, },
/** /**
* Most recent podcast episodes not finished * Most recent podcast episodes not finished
* @param {import('../../objects/user/User')} oldUser * @param {import('../../objects/user/User')} oldUser
* @param {import('../../objects/Library')} oldLibrary * @param {import('../../objects/Library')} oldLibrary
* @param {number} limit * @param {number} limit
* @param {number} offset * @param {number} offset
* @returns {Promise<object[]>} * @returns {Promise<object[]>}
*/ */
async getRecentEpisodes(oldUser, oldLibrary, limit, offset) { async getRecentEpisodes(oldUser, oldLibrary, limit, offset) {
@ -446,9 +446,7 @@ module.exports = {
required: false required: false
} }
], ],
order: [ order: [['publishedAt', 'DESC']],
['publishedAt', 'DESC']
],
subQuery: false, subQuery: false,
limit, limit,
offset offset
@ -469,7 +467,7 @@ module.exports = {
/** /**
* Get stats for podcast library * Get stats for podcast library
* @param {string} libraryId * @param {string} libraryId
* @returns {Promise<{ totalSize:number, totalDuration:number, numAudioFiles:number, totalItems:number}>} * @returns {Promise<{ totalSize:number, totalDuration:number, numAudioFiles:number, totalItems:number}>}
*/ */
async getPodcastLibraryStats(libraryId) { async getPodcastLibraryStats(libraryId) {
@ -491,7 +489,7 @@ module.exports = {
/** /**
* Genres with num podcasts * Genres with num podcasts
* @param {string} libraryId * @param {string} libraryId
* @returns {{genre:string, count:number}[]} * @returns {{genre:string, count:number}[]}
*/ */
async getGenresWithCount(libraryId) { async getGenresWithCount(libraryId) {
@ -513,17 +511,13 @@ module.exports = {
/** /**
* Get longest podcasts in library * Get longest podcasts in library
* @param {string} libraryId * @param {string} libraryId
* @param {number} limit * @param {number} limit
* @returns {Promise<{ id:string, title:string, duration:number }[]>} * @returns {Promise<{ id:string, title:string, duration:number }[]>}
*/ */
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,
@ -544,4 +536,4 @@ module.exports = {
} }
}) })
} }
} }

View file

@ -10,15 +10,15 @@ module.exports = {
/** /**
* Get series filtered and sorted * Get series filtered and sorted
* *
* @param {import('../../objects/Library')} library * @param {import('../../objects/Library')} library
* @param {import('../../objects/user/User')} user * @param {import('../../objects/user/User')} user
* @param {string} filterBy * @param {string} filterBy
* @param {string} sortBy * @param {string} sortBy
* @param {boolean} sortDesc * @param {boolean} sortDesc
* @param {string[]} include * @param {string[]} include
* @param {number} limit * @param {number} limit
* @param {number} offset * @param {number} offset
* @returns {Promise<{ series:object[], count:number }>} * @returns {Promise<{ series:object[], count:number }>}
*/ */
async getFilteredSeries(library, user, filterBy, sortBy, sortDesc, include, limit, offset) { async getFilteredSeries(library, user, filterBy, sortBy, sortDesc, include, limit, offset) {
@ -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.Op.gt]: 1 Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND bs.bookId = b.id)`), {
})) [Sequelize.Op.gt]: 1
})
)
} }
// Handle filters // Handle filters
@ -101,9 +103,11 @@ module.exports = {
} }
if (attrQuery) { if (attrQuery) {
seriesWhere.push(Sequelize.where(Sequelize.literal(`(${attrQuery})`), { seriesWhere.push(
[Sequelize.Op.gt]: 0 Sequelize.where(Sequelize.literal(`(${attrQuery})`), {
})) [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

View file

@ -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
@ -38,7 +38,7 @@ describe('BinaryManager', () => {
it('should not install binaries if they are already found', async () => { it('should not install binaries if they are already found', async () => {
findStub.resolves([]) findStub.resolves([])
await binaryManager.init() await binaryManager.init()
expect(installStub.called).to.be.false expect(installStub.called).to.be.false
@ -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,8 +116,8 @@ 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)
const result = await binaryManager.findRequiredBinaries() const result = await binaryManager.findRequiredBinaries()
@ -119,22 +127,25 @@ describe('BinaryManager', () => {
expect(process.env.FFMPEG_PATH).to.be.undefined expect(process.env.FFMPEG_PATH).to.be.undefined
}) })
}) })
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,240 +154,302 @@ 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 whichSyncStub let isGoodStub
let mainInstallPath let whichSyncStub
let altInstallPath let mainInstallPath
let altInstallPath
const name = 'ffmpeg' const name = 'ffmpeg'
const envVariable = 'FFMPEG_PATH' const envVariable = 'FFMPEG_PATH'
const defaultPath = '/path/to/ffmpeg' const defaultPath = '/path/to/ffmpeg'
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(() => {
binary = new Binary(name, 'executable', envVariable, ['5.1'], ffbinaries)
isGoodStub = sinon.stub(binary, 'isGood')
whichSyncStub = sinon.stub(which, 'sync')
binary.mainInstallDir = '/path/to/main/install'
mainInstallPath = path.join(binary.mainInstallDir, executable)
binary.altInstallDir = '/path/to/alt/install'
altInstallPath = path.join(binary.altInstallDir, executable)
})
beforeEach(() => { afterEach(() => {
binaryManager = new BinaryManager() isGoodStub.restore()
isBinaryGoodStub = sinon.stub(binaryManager, 'isBinaryGood') whichSyncStub.restore()
whichSyncStub = sinon.stub(which, 'sync') })
binaryManager.mainInstallPath = '/path/to/main/install'
mainInstallPath = path.join(binaryManager.mainInstallPath, executable) it('should return the defaultPath if it exists and is a good binary', async () => {
binaryManager.altInstallPath = '/path/to/alt/install' process.env[envVariable] = defaultPath
altInstallPath = path.join(binaryManager.altInstallPath, executable) isGoodStub.withArgs(defaultPath).resolves(true)
const result = await binary.find(binary.mainInstallDir, binary.altInstallDir)
expect(result).to.equal(defaultPath)
expect(isGoodStub.calledOnce).to.be.true
expect(isGoodStub.calledWith(defaultPath)).to.be.true
})
it('should return the whichPath if it exists and is a good binary', async () => {
delete process.env[envVariable]
isGoodStub.withArgs(undefined).resolves(false)
whichSyncStub.returns(whichPath)
isGoodStub.withArgs(whichPath).resolves(true)
const result = await binary.find(binary.mainInstallDir, binary.altInstallDir)
expect(result).to.equal(whichPath)
expect(isGoodStub.calledTwice).to.be.true
expect(isGoodStub.calledWith(undefined)).to.be.true
expect(isGoodStub.calledWith(whichPath)).to.be.true
})
it('should return the mainInstallPath if it exists and is a good binary', async () => {
delete process.env[envVariable]
isGoodStub.withArgs(undefined).resolves(false)
whichSyncStub.returns(null)
isGoodStub.withArgs(null).resolves(false)
isGoodStub.withArgs(mainInstallPath).resolves(true)
const result = await binary.find(binary.mainInstallDir, binary.altInstallDir)
expect(result).to.equal(mainInstallPath)
expect(isGoodStub.callCount).to.be.equal(3)
expect(isGoodStub.calledWith(undefined)).to.be.true
expect(isGoodStub.calledWith(null)).to.be.true
expect(isGoodStub.calledWith(mainInstallPath)).to.be.true
})
it('should return the altInstallPath if it exists and is a good binary', async () => {
delete process.env[envVariable]
isGoodStub.withArgs(undefined).resolves(false)
whichSyncStub.returns(null)
isGoodStub.withArgs(null).resolves(false)
isGoodStub.withArgs(mainInstallPath).resolves(false)
isGoodStub.withArgs(altInstallPath).resolves(true)
const result = await binary.find(binary.mainInstallDir, binary.altInstallDir)
expect(result).to.equal(altInstallPath)
expect(isGoodStub.callCount).to.be.equal(4)
expect(isGoodStub.calledWith(undefined)).to.be.true
expect(isGoodStub.calledWith(null)).to.be.true
expect(isGoodStub.calledWith(mainInstallPath)).to.be.true
expect(isGoodStub.calledWith(altInstallPath)).to.be.true
})
it('should return null if no good binary is found', async () => {
delete process.env[envVariable]
isGoodStub.withArgs(undefined).resolves(false)
whichSyncStub.returns(null)
isGoodStub.withArgs(null).resolves(false)
isGoodStub.withArgs(mainInstallPath).resolves(false)
isGoodStub.withArgs(altInstallPath).resolves(false)
const result = await binary.find(binary.mainInstallDir, binary.altInstallDir)
expect(result).to.be.null
expect(isGoodStub.callCount).to.be.equal(4)
expect(isGoodStub.calledWith(undefined)).to.be.true
expect(isGoodStub.calledWith(null)).to.be.true
expect(isGoodStub.calledWith(mainInstallPath)).to.be.true
expect(isGoodStub.calledWith(altInstallPath)).to.be.true
})
}) })
afterEach(() => { describe('isGood', () => {
isBinaryGoodStub.restore() let binary
whichSyncStub.restore() let fsPathExistsStub
let execStub
const binaryPath = '/path/to/binary'
const execCommand = '"' + binaryPath + '"' + ' -version'
const goodVersions = ['5.1', '6']
beforeEach(() => {
binary = new Binary('ffmpeg', 'executable', 'FFMPEG_PATH', goodVersions, ffbinaries)
fsPathExistsStub = sinon.stub(fs, 'pathExists')
execStub = sinon.stub(binary, 'exec')
})
afterEach(() => {
fsPathExistsStub.restore()
execStub.restore()
})
it('should return false if binaryPath is falsy', async () => {
fsPathExistsStub.resolves(true)
const result = await binary.isGood(null)
expect(result).to.be.false
expect(fsPathExistsStub.called).to.be.false
expect(execStub.called).to.be.false
})
it('should return false if binaryPath does not exist', async () => {
fsPathExistsStub.resolves(false)
const result = await binary.isGood(binaryPath)
expect(result).to.be.false
expect(fsPathExistsStub.calledOnce).to.be.true
expect(fsPathExistsStub.calledWith(binaryPath)).to.be.true
expect(execStub.called).to.be.false
})
it('should return false if failed to check version of binary', async () => {
fsPathExistsStub.resolves(true)
execStub.rejects(new Error('Failed to execute command'))
const result = await binary.isGood(binaryPath)
expect(result).to.be.false
expect(fsPathExistsStub.calledOnce).to.be.true
expect(fsPathExistsStub.calledWith(binaryPath)).to.be.true
expect(execStub.calledOnce).to.be.true
expect(execStub.calledWith(execCommand)).to.be.true
})
it('should return false if version is not found', async () => {
const stdout = 'Some output without version'
fsPathExistsStub.resolves(true)
execStub.resolves({ stdout })
const result = await binary.isGood(binaryPath)
expect(result).to.be.false
expect(fsPathExistsStub.calledOnce).to.be.true
expect(fsPathExistsStub.calledWith(binaryPath)).to.be.true
expect(execStub.calledOnce).to.be.true
expect(execStub.calledWith(execCommand)).to.be.true
})
it('should return false if version is found but does not match a good version', async () => {
const stdout = 'version 1.2.3'
fsPathExistsStub.resolves(true)
execStub.resolves({ stdout })
const result = await binary.isGood(binaryPath)
expect(result).to.be.false
expect(fsPathExistsStub.calledOnce).to.be.true
expect(fsPathExistsStub.calledWith(binaryPath)).to.be.true
expect(execStub.calledOnce).to.be.true
expect(execStub.calledWith(execCommand)).to.be.true
})
it('should return true if version is found and matches a good version', async () => {
const stdout = 'version 6.1.2'
fsPathExistsStub.resolves(true)
execStub.resolves({ stdout })
const result = await binary.isGood(binaryPath)
expect(result).to.be.true
expect(fsPathExistsStub.calledOnce).to.be.true
expect(fsPathExistsStub.calledWith(binaryPath)).to.be.true
expect(execStub.calledOnce).to.be.true
expect(execStub.calledWith(execCommand)).to.be.true
})
}) })
it('should return the defaultPath if it exists and is a good binary', async () => { describe('getFileName', () => {
process.env[envVariable] = defaultPath let originalPlatform
isBinaryGoodStub.withArgs(defaultPath).resolves(true)
const mockPlatform = (platform) => {
const result = await binaryManager.findBinary(name, envVariable) Object.defineProperty(process, 'platform', { value: platform })
}
expect(result).to.equal(defaultPath)
expect(isBinaryGoodStub.calledOnce).to.be.true beforeEach(() => {
expect(isBinaryGoodStub.calledWith(defaultPath)).to.be.true // 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')
})
}) })
it('should return the whichPath if it exists and is a good binary', async () => {
delete process.env[envVariable]
isBinaryGoodStub.withArgs(undefined).resolves(false)
isBinaryGoodStub.withArgs(whichPath).resolves(true)
whichSyncStub.returns(whichPath)
const result = await binaryManager.findBinary(name, envVariable)
expect(result).to.equal(whichPath)
expect(isBinaryGoodStub.calledTwice).to.be.true
expect(isBinaryGoodStub.calledWith(undefined)).to.be.true
expect(isBinaryGoodStub.calledWith(whichPath)).to.be.true
})
it('should return the mainInstallPath if it exists and is a good binary', async () => {
delete process.env[envVariable]
isBinaryGoodStub.withArgs(undefined).resolves(false)
isBinaryGoodStub.withArgs(null).resolves(false)
isBinaryGoodStub.withArgs(mainInstallPath).resolves(true)
whichSyncStub.returns(null)
const result = await binaryManager.findBinary(name, envVariable)
expect(result).to.equal(mainInstallPath)
expect(isBinaryGoodStub.callCount).to.be.equal(3)
expect(isBinaryGoodStub.calledWith(undefined)).to.be.true
expect(isBinaryGoodStub.calledWith(null)).to.be.true
expect(isBinaryGoodStub.calledWith(mainInstallPath)).to.be.true
})
it('should return the altInstallPath if it exists and is a good binary', async () => {
delete process.env[envVariable]
isBinaryGoodStub.withArgs(undefined).resolves(false)
isBinaryGoodStub.withArgs(null).resolves(false)
isBinaryGoodStub.withArgs(mainInstallPath).resolves(false)
isBinaryGoodStub.withArgs(altInstallPath).resolves(true)
whichSyncStub.returns(null)
const result = await binaryManager.findBinary(name, envVariable)
expect(result).to.equal(altInstallPath)
expect(isBinaryGoodStub.callCount).to.be.equal(4)
expect(isBinaryGoodStub.calledWith(undefined)).to.be.true
expect(isBinaryGoodStub.calledWith(null)).to.be.true
expect(isBinaryGoodStub.calledWith(mainInstallPath)).to.be.true
expect(isBinaryGoodStub.calledWith(altInstallPath)).to.be.true
})
it('should return null if no good binary is found', async () => {
delete process.env[envVariable]
isBinaryGoodStub.withArgs(undefined).resolves(false)
isBinaryGoodStub.withArgs(null).resolves(false)
isBinaryGoodStub.withArgs(mainInstallPath).resolves(false)
isBinaryGoodStub.withArgs(altInstallPath).resolves(false)
whichSyncStub.returns(null)
const result = await binaryManager.findBinary(name, envVariable)
expect(result).to.be.null
expect(isBinaryGoodStub.callCount).to.be.equal(4)
expect(isBinaryGoodStub.calledWith(undefined)).to.be.true
expect(isBinaryGoodStub.calledWith(null)).to.be.true
expect(isBinaryGoodStub.calledWith(mainInstallPath)).to.be.true
expect(isBinaryGoodStub.calledWith(altInstallPath)).to.be.true
})
}) })
describe('isBinaryGood', () => {
let binaryManager
let fsPathExistsStub
let execStub
let loggerInfoStub
let loggerErrorStub
const binaryPath = '/path/to/binary'
const execCommand = '"' + binaryPath + '"' + ' -version'
const goodVersions = ['5.1', '6']
beforeEach(() => {
binaryManager = new BinaryManager()
fsPathExistsStub = sinon.stub(fs, 'pathExists')
execStub = sinon.stub(binaryManager, 'exec')
})
afterEach(() => {
fsPathExistsStub.restore()
execStub.restore()
})
it('should return false if binaryPath is falsy', async () => {
fsPathExistsStub.resolves(true)
const result = await binaryManager.isBinaryGood(null, goodVersions)
expect(result).to.be.false
expect(fsPathExistsStub.called).to.be.false
expect(execStub.called).to.be.false
})
it('should return false if binaryPath does not exist', async () => {
fsPathExistsStub.resolves(false)
const result = await binaryManager.isBinaryGood(binaryPath, goodVersions)
expect(result).to.be.false
expect(fsPathExistsStub.calledOnce).to.be.true
expect(fsPathExistsStub.calledWith(binaryPath)).to.be.true
expect(execStub.called).to.be.false
})
it('should return false if failed to check version of binary', async () => {
fsPathExistsStub.resolves(true)
execStub.rejects(new Error('Failed to execute command'))
const result = await binaryManager.isBinaryGood(binaryPath, goodVersions)
expect(result).to.be.false
expect(fsPathExistsStub.calledOnce).to.be.true
expect(fsPathExistsStub.calledWith(binaryPath)).to.be.true
expect(execStub.calledOnce).to.be.true
expect(execStub.calledWith(execCommand)).to.be.true
})
it('should return false if version is not found', async () => {
const stdout = 'Some output without version'
fsPathExistsStub.resolves(true)
execStub.resolves({ stdout })
const result = await binaryManager.isBinaryGood(binaryPath, goodVersions)
expect(result).to.be.false
expect(fsPathExistsStub.calledOnce).to.be.true
expect(fsPathExistsStub.calledWith(binaryPath)).to.be.true
expect(execStub.calledOnce).to.be.true
expect(execStub.calledWith(execCommand)).to.be.true
})
it('should return false if version is found but does not match a good version', async () => {
const stdout = 'version 1.2.3'
fsPathExistsStub.resolves(true)
execStub.resolves({ stdout })
const result = await binaryManager.isBinaryGood(binaryPath, goodVersions)
expect(result).to.be.false
expect(fsPathExistsStub.calledOnce).to.be.true
expect(fsPathExistsStub.calledWith(binaryPath)).to.be.true
expect(execStub.calledOnce).to.be.true
expect(execStub.calledWith(execCommand)).to.be.true
})
it('should return true if version is found and matches a good version', async () => {
const stdout = 'version 6.1.2'
fsPathExistsStub.resolves(true)
execStub.resolves({ stdout })
const result = await binaryManager.isBinaryGood(binaryPath, goodVersions)
expect(result).to.be.true
expect(fsPathExistsStub.calledOnce).to.be.true
expect(fsPathExistsStub.calledWith(binaryPath)).to.be.true
expect(execStub.calledOnce).to.be.true
expect(execStub.calledWith(execCommand)).to.be.true
})
})

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

View file

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

View file

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