Merge branch 'master' into feat/metadataForPlaybackSessions

This commit is contained in:
Vito0912 2025-01-07 17:01:01 +01:00 committed by GitHub
commit 121805ba39
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
167 changed files with 7751 additions and 5880 deletions

1
.gitignore vendored
View File

@ -7,6 +7,7 @@
/podcasts/ /podcasts/
/media/ /media/
/metadata/ /metadata/
/plugins/
/client/.nuxt/ /client/.nuxt/
/client/dist/ /client/dist/
/dist/ /dist/

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="w-full h-16 bg-primary relative"> <div class="w-full h-16 bg-primary relative">
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-60"> <div id="appbar" role="toolbar" aria-label="Appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-60">
<div class="flex h-full items-center"> <div class="flex h-full items-center">
<nuxt-link to="/"> <nuxt-link to="/">
<img src="~static/icon.svg" :alt="$strings.ButtonHome" class="w-8 min-w-8 h-8 mr-2 sm:w-10 sm:min-w-10 sm:h-10 sm:mr-4" /> <img src="~static/icon.svg" :alt="$strings.ButtonHome" class="w-8 min-w-8 h-8 mr-2 sm:w-10 sm:min-w-10 sm:h-10 sm:mr-4" />

View File

@ -17,7 +17,7 @@
<div v-else-if="isAlternativeBookshelfView" class="w-full mb-24e"> <div v-else-if="isAlternativeBookshelfView" class="w-full mb-24e">
<template v-for="(shelf, index) in supportedShelves"> <template v-for="(shelf, index) in supportedShelves">
<widgets-item-slider :shelf-id="shelf.id" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening' || shelf.id === 'continue-reading'" :type="shelf.type" class="bookshelf-row pl-8e my-6e" @selectEntity="(payload) => selectEntity(payload, index)"> <widgets-item-slider :shelf-id="shelf.id" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening' || shelf.id === 'continue-reading'" :type="shelf.type" class="bookshelf-row pl-8e my-6e" @selectEntity="(payload) => selectEntity(payload, index)">
<p class="font-semibold text-gray-100">{{ $strings[shelf.labelStringKey] }}</p> <h2 class="font-semibold text-gray-100">{{ $strings[shelf.labelStringKey] }}</h2>
</widgets-item-slider> </widgets-item-slider>
</template> </template>
</div> </div>

View File

@ -37,18 +37,18 @@
<div class="relative"> <div class="relative">
<div class="relative text-center categoryPlacard transform z-30 top-0 left-4e md:left-8e w-44e rounded-md"> <div class="relative text-center categoryPlacard transform z-30 top-0 left-4e md:left-8e w-44e rounded-md">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0em 0.5em` }"> <div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0em 0.5em` }">
<p :style="{ fontSize: 0.9 + 'em' }">{{ $strings[shelf.labelStringKey] }}</p> <h2 :style="{ fontSize: 0.9 + 'em' }">{{ $strings[shelf.labelStringKey] }}</h2>
</div> </div>
</div> </div>
<div class="bookshelfDividerCategorized h-6e w-full absolute top-0 left-0 right-0 z-20"></div> <div class="bookshelfDividerCategorized h-6e w-full absolute top-0 left-0 right-0 z-20"></div>
</div> </div>
<div v-show="canScrollLeft && !isScrolling" class="hidden sm:flex absolute top-0 left-0 w-32 pr-8 bg-black book-shelf-arrow-left items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-40" @click="scrollLeft"> <button v-show="canScrollLeft && !isScrolling" :aria-label="$strings.ButtonScrollLeft" class="hidden sm:flex absolute top-0 left-0 w-32 pr-8 bg-black book-shelf-arrow-left items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-40" @click="scrollLeft">
<span class="material-symbols text-white" :style="{ fontSize: 3.75 + 'em' }">chevron_left</span> <span class="material-symbols text-white" :style="{ fontSize: 3.75 + 'em' }">chevron_left</span>
</div> </button>
<div v-show="canScrollRight && !isScrolling" class="hidden sm:flex absolute top-0 right-0 w-32 pl-8 bg-black book-shelf-arrow-right items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-40" @click="scrollRight"> <button v-show="canScrollRight && !isScrolling" :aria-label="$strings.ButtonScrollRight" class="hidden sm:flex absolute top-0 right-0 w-32 pl-8 bg-black book-shelf-arrow-right items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-40" @click="scrollRight">
<span class="material-symbols text-white" :style="{ fontSize: 3.75 + 'em' }">chevron_right</span> <span class="material-symbols text-white" :style="{ fontSize: 3.75 + 'em' }">chevron_right</span>
</div> </button>
</div> </div>
</template> </template>

View File

@ -42,8 +42,11 @@
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="flex-grow h-full flex justify-center items-center" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'"> <nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="flex-grow h-full flex justify-center items-center" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<p class="text-sm">{{ $strings.ButtonAdd }}</p> <p class="text-sm">{{ $strings.ButtonAdd }}</p>
</nuxt-link> </nuxt-link>
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/download-queue`" class="flex-grow h-full flex justify-center items-center" :class="isPodcastDownloadQueuePage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<p class="text-sm">{{ $strings.ButtonDownloadQueue }}</p>
</nuxt-link>
</div> </div>
<div id="toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-40 flex items-center justify-end md:justify-start px-2 md:px-8"> <div id="toolbar" role="toolbar" aria-label="Library Toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-40 flex items-center justify-end md:justify-start px-2 md:px-8">
<!-- Series books page --> <!-- Series books page -->
<template v-if="selectedSeries"> <template v-if="selectedSeries">
<p class="pl-2 text-base md:text-lg"> <p class="pl-2 text-base md:text-lg">
@ -265,6 +268,9 @@ export default {
isPodcastLatestPage() { isPodcastLatestPage() {
return this.$route.name === 'library-library-podcast-latest' return this.$route.name === 'library-library-podcast-latest'
}, },
isPodcastDownloadQueuePage() {
return this.$route.name === 'library-library-podcast-download-queue'
},
isAuthorsPage() { isAuthorsPage() {
return this.page === 'authors' return this.page === 'authors'
}, },

View File

@ -1,6 +1,6 @@
<template> <template>
<div> <div role="toolbar" aria-orientation="vertical" aria-label="Config Sidebar">
<div class="w-44 fixed left-0 top-16 bg-bg bg-opacity-100 md:bg-opacity-70 shadow-lg border-r border-white border-opacity-5 py-3 transform transition-transform mb-12 overflow-y-auto" :class="wrapperClass + ' ' + (streamLibraryItem ? 'h-[calc(100%-270px)]' : 'h-[calc(100%-110px)]')" v-click-outside="clickOutside"> <div role="navigation" aria-label="Config Navigation" class="w-44 fixed left-0 top-16 bg-bg bg-opacity-100 md:bg-opacity-70 shadow-lg border-r border-white border-opacity-5 py-3 transform transition-transform mb-12 overflow-y-auto" :class="wrapperClass + ' ' + (streamLibraryItem ? 'h-[calc(100%-270px)]' : 'h-[calc(100%-110px)]')" v-click-outside="clickOutside">
<div v-show="isMobilePortrait" class="flex items-center justify-end pb-2 px-4 mb-1" @click="closeDrawer"> <div v-show="isMobilePortrait" class="flex items-center justify-end pb-2 px-4 mb-1" @click="closeDrawer">
<span class="material-symbols text-2xl">arrow_back</span> <span class="material-symbols text-2xl">arrow_back</span>
</div> </div>

View File

@ -2,6 +2,10 @@
<div id="bookshelf" ref="bookshelf" class="w-full overflow-y-auto" :style="{ fontSize: sizeMultiplier + 'rem' }"> <div id="bookshelf" ref="bookshelf" class="w-full overflow-y-auto" :style="{ fontSize: sizeMultiplier + 'rem' }">
<template v-for="shelf in totalShelves"> <template v-for="shelf in totalShelves">
<div :key="shelf" :id="`shelf-${shelf - 1}`" class="w-full px-4e sm:px-8e relative" :class="{ bookshelfRow: !isAlternativeBookshelfView }" :style="{ height: shelfHeight + 'px' }"> <div :key="shelf" :id="`shelf-${shelf - 1}`" class="w-full px-4e sm:px-8e relative" :class="{ bookshelfRow: !isAlternativeBookshelfView }" :style="{ height: shelfHeight + 'px' }">
<!-- Card skeletons -->
<template v-for="entityIndex in entitiesInShelf(shelf)">
<div :key="entityIndex" class="w-full h-full absolute rounded z-5 top-0 left-0 bg-primary box-shadow-book" :style="{ transform: entityTransform(entityIndex), width: cardWidth + 'px', height: coverHeight + 'px' }" />
</template>
<div v-if="!isAlternativeBookshelfView" class="bookshelfDivider w-full absolute bottom-0 left-0 right-0 z-20 h-6e" /> <div v-if="!isAlternativeBookshelfView" class="bookshelfDivider w-full absolute bottom-0 left-0 right-0 z-20 h-6e" />
</div> </div>
</template> </template>
@ -65,7 +69,13 @@ export default {
tempIsScanning: false, tempIsScanning: false,
cardWidth: 0, cardWidth: 0,
cardHeight: 0, cardHeight: 0,
resizeObserver: null coverHeight: 0,
resizeObserver: null,
lastScrollTop: 0,
lastTimestamp: 0,
postScrollTimeout: null,
currFirstEntityIndex: -1,
currLastEntityIndex: -1
} }
}, },
watch: { watch: {
@ -171,9 +181,6 @@ export default {
bookWidth() { bookWidth() {
return this.cardWidth return this.cardWidth
}, },
bookHeight() {
return this.cardHeight
},
shelfPadding() { shelfPadding() {
if (this.bookshelfWidth < 640) return 32 * this.sizeMultiplier if (this.bookshelfWidth < 640) return 32 * this.sizeMultiplier
return 64 * this.sizeMultiplier return 64 * this.sizeMultiplier
@ -184,9 +191,6 @@ export default {
entityWidth() { entityWidth() {
return this.cardWidth return this.cardWidth
}, },
entityHeight() {
return this.cardHeight
},
shelfPaddingHeight() { shelfPaddingHeight() {
return 16 return 16
}, },
@ -354,50 +358,53 @@ export default {
} }
}, },
loadPage(page) { loadPage(page) {
this.pagesLoaded[page] = true if (!this.pagesLoaded[page]) this.pagesLoaded[page] = this.fetchEntites(page)
this.fetchEntites(page) return this.pagesLoaded[page]
}, },
showHideBookPlaceholder(index, show) { showHideBookPlaceholder(index, show) {
var el = document.getElementById(`book-${index}-placeholder`) var el = document.getElementById(`book-${index}-placeholder`)
if (el) el.style.display = show ? 'flex' : 'none' if (el) el.style.display = show ? 'flex' : 'none'
}, },
mountEntites(fromIndex, toIndex) { mountEntities(fromIndex, toIndex) {
for (let i = fromIndex; i < toIndex; i++) { for (let i = fromIndex; i < toIndex; i++) {
if (!this.entityIndexesMounted.includes(i)) { if (!this.entityIndexesMounted.includes(i)) {
this.cardsHelpers.mountEntityCard(i) this.cardsHelpers.mountEntityCard(i)
} }
} }
}, },
handleScroll(scrollTop) { getVisibleIndices(scrollTop) {
this.currScrollTop = scrollTop const firstShelfIndex = Math.floor(scrollTop / this.shelfHeight)
var firstShelfIndex = Math.floor(scrollTop / this.shelfHeight) const lastShelfIndex = Math.min(Math.ceil((scrollTop + this.bookshelfHeight) / this.shelfHeight), this.totalShelves - 1)
var lastShelfIndex = Math.ceil((scrollTop + this.bookshelfHeight) / this.shelfHeight) const firstEntityIndex = firstShelfIndex * this.entitiesPerShelf
lastShelfIndex = Math.min(this.totalShelves - 1, lastShelfIndex) const lastEntityIndex = Math.min(lastShelfIndex * this.entitiesPerShelf + this.entitiesPerShelf, this.totalEntities)
return { firstEntityIndex, lastEntityIndex }
var firstBookIndex = firstShelfIndex * this.entitiesPerShelf },
var lastBookIndex = lastShelfIndex * this.entitiesPerShelf + this.entitiesPerShelf postScroll() {
lastBookIndex = Math.min(this.totalEntities, lastBookIndex) const { firstEntityIndex, lastEntityIndex } = this.getVisibleIndices(this.currScrollTop)
var firstBookPage = Math.floor(firstBookIndex / this.booksPerFetch)
var lastBookPage = Math.floor(lastBookIndex / this.booksPerFetch)
if (!this.pagesLoaded[firstBookPage]) {
// console.log('Must load next batch', firstBookPage, 'book index', firstBookIndex)
this.loadPage(firstBookPage)
}
if (!this.pagesLoaded[lastBookPage]) {
// console.log('Must load last next batch', lastBookPage, 'book index', lastBookIndex)
this.loadPage(lastBookPage)
}
this.entityIndexesMounted = this.entityIndexesMounted.filter((_index) => { this.entityIndexesMounted = this.entityIndexesMounted.filter((_index) => {
if (_index < firstBookIndex || _index >= lastBookIndex) { if (_index < firstEntityIndex || _index >= lastEntityIndex) {
var el = document.getElementById(`book-card-${_index}`) var el = this.entityComponentRefs[_index]
if (el) el.remove() if (el && el.$el) el.$el.remove()
return false return false
} }
return true return true
}) })
this.mountEntites(firstBookIndex, lastBookIndex) },
handleScroll(scrollTop) {
this.currScrollTop = scrollTop
const { firstEntityIndex, lastEntityIndex } = this.getVisibleIndices(scrollTop)
if (firstEntityIndex === this.currFirstEntityIndex && lastEntityIndex === this.currLastEntityIndex) return
this.currFirstEntityIndex = firstEntityIndex
this.currLastEntityIndex = lastEntityIndex
clearTimeout(this.postScrollTimeout)
const firstPage = Math.floor(firstEntityIndex / this.booksPerFetch)
const lastPage = Math.floor(lastEntityIndex / this.booksPerFetch)
Promise.all([this.loadPage(firstPage), this.loadPage(lastPage)])
.then(() => this.mountEntities(firstEntityIndex, lastEntityIndex))
.catch((error) => console.error('Failed to load page', error))
this.postScrollTimeout = setTimeout(this.postScroll, 500)
}, },
async resetEntities() { async resetEntities() {
if (this.isFetchingEntities) { if (this.isFetchingEntities) {
@ -405,8 +412,6 @@ export default {
return return
} }
this.destroyEntityComponents() this.destroyEntityComponents()
this.entityIndexesMounted = []
this.entityComponentRefs = {}
this.pagesLoaded = {} this.pagesLoaded = {}
this.entities = [] this.entities = []
this.totalShelves = 0 this.totalShelves = 0
@ -416,40 +421,21 @@ export default {
this.initialized = false this.initialized = false
this.initSizeData() this.initSizeData()
this.pagesLoaded[0] = true await this.loadPage(0)
await this.fetchEntites(0)
var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf) var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf)
this.mountEntites(0, lastBookIndex) this.mountEntities(0, lastBookIndex)
}, },
remountEntities() { async rebuild() {
for (const key in this.entityComponentRefs) {
if (this.entityComponentRefs[key]) {
this.entityComponentRefs[key].destroy()
}
}
this.entityComponentRefs = {}
this.entityIndexesMounted.forEach((i) => {
this.cardsHelpers.mountEntityCard(i)
})
},
rebuild() {
this.initSizeData() this.initSizeData()
var lastBookIndex = Math.min(this.totalEntities, this.booksPerFetch) var lastBookIndex = Math.min(this.totalEntities, this.booksPerFetch)
this.entityIndexesMounted = [] this.destroyEntityComponents()
for (let i = 0; i < lastBookIndex; i++) { await this.loadPage(0)
this.entityIndexesMounted.push(i)
if (!this.entities[i]) {
const page = Math.floor(i / this.booksPerFetch)
this.loadPage(page)
}
}
var bookshelfEl = document.getElementById('bookshelf') var bookshelfEl = document.getElementById('bookshelf')
if (bookshelfEl) { if (bookshelfEl) {
bookshelfEl.scrollTop = 0 bookshelfEl.scrollTop = 0
} }
this.mountEntities(0, lastBookIndex)
this.$nextTick(this.remountEntities)
}, },
buildSearchParams() { buildSearchParams() {
if (this.page === 'search' || this.page === 'collections') { if (this.page === 'search' || this.page === 'collections') {
@ -513,12 +499,29 @@ export default {
if (wasUpdated) { if (wasUpdated) {
this.resetEntities() this.resetEntities()
} else if (settings.bookshelfCoverSize !== this.currentBookWidth) { } else if (settings.bookshelfCoverSize !== this.currentBookWidth) {
this.executeRebuild() this.rebuild()
} }
}, },
getScrollRate() {
const currentTimestamp = Date.now()
const timeDelta = currentTimestamp - this.lastTimestamp
const scrollDelta = this.currScrollTop - this.lastScrollTop
const scrollRate = Math.abs(scrollDelta) / (timeDelta || 1)
this.lastScrollTop = this.currScrollTop
this.lastTimestamp = currentTimestamp
return scrollRate
},
scroll(e) { scroll(e) {
if (!e || !e.target) return if (!e || !e.target) return
var { scrollTop } = e.target clearTimeout(this.scrollTimeout)
const { scrollTop } = e.target
const scrollRate = this.getScrollRate()
if (scrollRate > 5) {
this.scrollTimeout = setTimeout(() => {
this.handleScroll(scrollTop)
}, 25)
return
}
this.handleScroll(scrollTop) this.handleScroll(scrollTop)
}, },
libraryItemAdded(libraryItem) { libraryItemAdded(libraryItem) {
@ -667,13 +670,14 @@ export default {
}, },
updatePagesLoaded() { updatePagesLoaded() {
let numPages = Math.ceil(this.totalEntities / this.booksPerFetch) let numPages = Math.ceil(this.totalEntities / this.booksPerFetch)
this.pagesLoaded = {}
for (let page = 0; page < numPages; page++) { for (let page = 0; page < numPages; page++) {
let numEntities = Math.min(this.totalEntities - page * this.booksPerFetch, this.booksPerFetch) let numEntities = Math.min(this.totalEntities - page * this.booksPerFetch, this.booksPerFetch)
this.pagesLoaded[page] = true this.pagesLoaded[page] = Promise.resolve()
for (let i = 0; i < numEntities; i++) { for (let i = 0; i < numEntities; i++) {
const index = page * this.booksPerFetch + i const index = page * this.booksPerFetch + i
if (!this.entities[index]) { if (!this.entities[index]) {
this.pagesLoaded[page] = false if (this.pagesLoaded[page]) delete this.pagesLoaded[page]
break break
} }
} }
@ -688,7 +692,6 @@ export default {
var entitiesPerShelfBefore = this.entitiesPerShelf var entitiesPerShelfBefore = this.entitiesPerShelf
var { clientHeight, clientWidth } = bookshelf var { clientHeight, clientWidth } = bookshelf
// console.log('Init bookshelf width', clientWidth, 'window width', window.innerWidth)
this.mountWindowWidth = window.innerWidth this.mountWindowWidth = window.innerWidth
this.bookshelfHeight = clientHeight this.bookshelfHeight = clientHeight
this.bookshelfWidth = clientWidth this.bookshelfWidth = clientWidth
@ -713,10 +716,9 @@ export default {
this.initSizeData(bookshelf) this.initSizeData(bookshelf)
this.checkUpdateSearchParams() this.checkUpdateSearchParams()
this.pagesLoaded[0] = true await this.loadPage(0)
await this.fetchEntites(0)
var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf) var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf)
this.mountEntites(0, lastBookIndex) this.mountEntities(0, lastBookIndex)
// Set last scroll position for this bookshelf page // Set last scroll position for this bookshelf page
if (this.$store.state.lastBookshelfScrollData[this.page] && window.bookshelf) { if (this.$store.state.lastBookshelfScrollData[this.page] && window.bookshelf) {
@ -747,7 +749,7 @@ export default {
var bookshelf = document.getElementById('bookshelf') var bookshelf = document.getElementById('bookshelf')
if (bookshelf) { if (bookshelf) {
this.init(bookshelf) this.init(bookshelf)
bookshelf.addEventListener('scroll', this.scroll) bookshelf.addEventListener('scroll', this.scroll, { passive: true })
} }
}) })
@ -810,10 +812,14 @@ export default {
}, },
destroyEntityComponents() { destroyEntityComponents() {
for (const key in this.entityComponentRefs) { for (const key in this.entityComponentRefs) {
if (this.entityComponentRefs[key] && this.entityComponentRefs[key].destroy) { const ref = this.entityComponentRefs[key]
this.entityComponentRefs[key].destroy() if (ref && ref.destroy) {
if (ref.$el) ref.$el.remove()
ref.destroy()
} }
} }
this.entityComponentRefs = {}
this.entityIndexesMounted = []
}, },
scan() { scan() {
this.tempIsScanning = true this.tempIsScanning = true
@ -826,6 +832,14 @@ export default {
.finally(() => { .finally(() => {
this.tempIsScanning = false this.tempIsScanning = false
}) })
},
entitiesInShelf(shelf) {
return shelf == this.totalShelves ? this.totalEntities % this.entitiesPerShelf || this.entitiesPerShelf : this.entitiesPerShelf
},
entityTransform(entityIndex) {
const shelfOffsetY = this.shelfPaddingHeight * this.sizeMultiplier
const shelfOffsetX = (entityIndex - 1) * this.totalEntityCardWidth + this.bookshelfMarginLeft
return `translate3d(${shelfOffsetX}px, ${shelfOffsetY}px, 0px)`
} }
}, },
async mounted() { async mounted() {

View File

@ -374,19 +374,27 @@ export default {
return return
} }
// https://developer.mozilla.org/en-US/docs/Web/API/Media_Session_API
if ('mediaSession' in navigator) { if ('mediaSession' in navigator) {
var coverImageSrc = this.$store.getters['globals/getLibraryItemCoverSrc'](this.streamLibraryItem, '/Logo.png', true) const chapterInfo = []
const artwork = [ if (this.chapters.length) {
{ this.chapters.forEach((chapter) => {
src: coverImageSrc chapterInfo.push({
} title: chapter.title,
] startTime: chapter.start
})
})
}
navigator.mediaSession.metadata = new MediaMetadata({ navigator.mediaSession.metadata = new MediaMetadata({
title: this.title, title: this.title,
artist: this.playerHandler.displayAuthor || this.mediaMetadata.authorName || 'Unknown', artist: this.playerHandler.displayAuthor || this.mediaMetadata.authorName || 'Unknown',
album: this.mediaMetadata.seriesName || '', album: this.mediaMetadata.seriesName || '',
artwork artwork: [
{
src: this.$store.getters['globals/getLibraryItemCoverSrc'](this.streamLibraryItem, '/Logo.png', true)
}
]
}) })
console.log('Set media session metadata', navigator.mediaSession.metadata) console.log('Set media session metadata', navigator.mediaSession.metadata)

View File

@ -1,9 +1,9 @@
<template> <template>
<div class="w-20 bg-bg h-full fixed left-0 box-shadow-side z-50" style="min-width: 80px" :style="{ top: offsetTop + 'px' }"> <div role="toolbar" aria-orientation="vertical" aria-label="Library Sidebar" class="w-20 bg-bg h-full fixed left-0 box-shadow-side z-50" style="min-width: 80px" :style="{ top: offsetTop + 'px' }">
<!-- ugly little workaround to cover up the shadow overlapping the bookshelf toolbar --> <!-- ugly little workaround to cover up the shadow overlapping the bookshelf toolbar -->
<div v-if="isShowingBookshelfToolbar" class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" /> <div v-if="isShowingBookshelfToolbar" class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" />
<div id="siderail-buttons-container" :class="{ 'player-open': streamLibraryItem }" class="w-full overflow-y-auto overflow-x-hidden"> <div id="siderail-buttons-container" role="navigation" aria-label="Library Navigation" :class="{ 'player-open': streamLibraryItem }" class="w-full overflow-y-auto overflow-x-hidden">
<nuxt-link :to="`/library/${currentLibraryId}`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> <nuxt-link :to="`/library/${currentLibraryId}`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />

View File

@ -68,6 +68,9 @@ export default {
cardHeight() { cardHeight() {
return this.height * this.sizeMultiplier return this.height * this.sizeMultiplier
}, },
coverHeight() {
return this.cardHeight
},
userToken() { userToken() {
return this.store.getters['user/getToken'] return this.store.getters['user/getToken']
}, },

View File

@ -1,5 +1,5 @@
<template> <template>
<div ref="card" :id="`book-card-${index}`" :style="{ minWidth: coverWidth + 'px', maxWidth: coverWidth + 'px' }" class="absolute rounded-sm z-10 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard"> <div ref="card" :id="`book-card-${index}`" tabindex="0" :style="{ minWidth: coverWidth + 'px', maxWidth: coverWidth + 'px' }" class="absolute rounded-sm z-10 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
<div :id="`cover-area-${index}`" class="relative w-full top-0 left-0 rounded overflow-hidden z-10 bg-primary box-shadow-book" :style="{ height: coverHeight + 'px ' }"> <div :id="`cover-area-${index}`" class="relative w-full top-0 left-0 rounded overflow-hidden z-10 bg-primary box-shadow-book" :style="{ height: coverHeight + 'px ' }">
<!-- When cover image does not fill --> <!-- When cover image does not fill -->
<div cy-id="coverBg" v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary"> <div cy-id="coverBg" v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary">
@ -14,21 +14,21 @@
</div> </div>
<div class="w-full h-full absolute top-0 left-0 rounded overflow-hidden z-10"> <div class="w-full h-full absolute top-0 left-0 rounded overflow-hidden z-10">
<div cy-id="titleImageNotReady" v-show="libraryItem && !imageReady" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: 0.5 + 'em' }"> <div cy-id="titleImageNotReady" v-show="libraryItem && !imageReady" aria-hidden="true" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: 0.5 + 'em' }">
<p :style="{ fontSize: 0.8 + 'em' }" class="text-gray-300 text-center">{{ title }}</p> <p :style="{ fontSize: 0.8 + 'em' }" class="text-gray-300 text-center">{{ title }}</p>
</div> </div>
<!-- Cover Image --> <!-- Cover Image -->
<img cy-id="coverImage" v-show="libraryItem" ref="cover" :src="bookCoverSrc" class="relative w-full h-full transition-opacity duration-300" :class="showCoverBg ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" /> <img cy-id="coverImage" v-if="libraryItem" :alt="`${displayTitle}, ${$strings.LabelCover}`" ref="cover" aria-hidden="true" :src="bookCoverSrc" class="relative w-full h-full transition-opacity duration-300" :class="showCoverBg ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" />
<!-- Placeholder Cover Title & Author --> <!-- Placeholder Cover Title & Author -->
<div cy-id="placeholderTitle" v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'em' }"> <div cy-id="placeholderTitle" v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'em' }">
<div> <div>
<p cy-id="placeholderTitleText" class="text-center" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'em' }">{{ titleCleaned }}</p> <p cy-id="placeholderTitleText" aria-hidden="true" class="text-center" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'em' }">{{ titleCleaned }}</p>
</div> </div>
</div> </div>
<div cy-id="placeholderAuthor" v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'em', bottom: authorBottom + 'em' }"> <div cy-id="placeholderAuthor" v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'em', bottom: authorBottom + 'em' }">
<p cy-id="placeholderAuthorText" class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'em' }">{{ authorCleaned }}</p> <p cy-id="placeholderAuthorText" aria-hidden="true" class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'em' }">{{ authorCleaned }}</p>
</div> </div>
<div v-if="seriesSequenceList" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20 text-right" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `${0.1}em ${0.25}em` }" style="background-color: #78350f"> <div v-if="seriesSequenceList" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20 text-right" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `${0.1}em ${0.25}em` }" style="background-color: #78350f">
@ -93,11 +93,11 @@
<!-- rss feed icon --> <!-- rss feed icon -->
<div cy-id="rssFeed" v-if="rssFeed && !isSelectionMode && !isHovering" class="absolute text-success top-0 left-0 z-10" :style="{ padding: 0.375 + 'em' }"> <div cy-id="rssFeed" v-if="rssFeed && !isSelectionMode && !isHovering" class="absolute text-success top-0 left-0 z-10" :style="{ padding: 0.375 + 'em' }">
<span class="material-symbols" :style="{ fontSize: 1.5 + 'em' }">rss_feed</span> <span class="material-symbols" aria-hidden="true" :style="{ fontSize: 1.5 + 'em' }">rss_feed</span>
</div> </div>
<!-- media item shared icon --> <!-- media item shared icon -->
<div cy-id="mediaItemShare" v-if="mediaItemShare && !isSelectionMode && !isHovering" class="absolute text-success left-0 z-10" :style="{ padding: 0.375 + 'em', top: rssFeed ? '2em' : '0px' }"> <div cy-id="mediaItemShare" v-if="mediaItemShare && !isSelectionMode && !isHovering" class="absolute text-success left-0 z-10" :style="{ padding: 0.375 + 'em', top: rssFeed ? '2em' : '0px' }">
<span class="material-symbols" :style="{ fontSize: 1.5 + 'em' }">public</span> <span class="material-symbols" aria-hidden="true" :style="{ fontSize: 1.5 + 'em' }">public</span>
</div> </div>
<!-- Series sequence --> <!-- Series sequence -->
@ -114,7 +114,7 @@
<!-- Podcast Num Episodes --> <!-- Podcast Num Episodes -->
<div cy-id="numEpisodes" v-else-if="!numEpisodesIncomplete && numEpisodes && !isHovering && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', width: 1.25 + 'em', height: 1.25 + 'em' }"> <div cy-id="numEpisodes" v-else-if="!numEpisodesIncomplete && numEpisodes && !isHovering && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', width: 1.25 + 'em', height: 1.25 + 'em' }">
<p :style="{ fontSize: 0.8 + 'em' }">{{ numEpisodes }}</p> <p :style="{ fontSize: 0.8 + 'em' }" role="status" :aria-label="$strings.LabelNumberOfEpisodes">{{ numEpisodes }}</p>
</div> </div>
<!-- Podcast Num Episodes --> <!-- Podcast Num Episodes -->

View File

@ -1,5 +1,5 @@
<template> <template>
<div ref="card" :id="`collection-card-${index}`" :style="{ width: cardWidth + 'px' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard"> <div ref="card" :id="`collection-card-${index}`" role="button" :style="{ width: cardWidth + 'px' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
<div class="relative" :style="{ height: coverHeight + 'px' }"> <div class="relative" :style="{ height: coverHeight + 'px' }">
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" /> <div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
<div class="w-full h-full bg-primary relative rounded overflow-hidden"> <div class="w-full h-full bg-primary relative rounded overflow-hidden">

View File

@ -1,5 +1,5 @@
<template> <template>
<div ref="card" :id="`playlist-card-${index}`" :style="{ width: cardWidth + 'px', fontSize: sizeMultiplier + 'rem' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard"> <div ref="card" :id="`playlist-card-${index}`" role="button" :style="{ width: cardWidth + 'px', fontSize: sizeMultiplier + 'rem' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
<div class="relative" :style="{ height: coverHeight + 'px' }"> <div class="relative" :style="{ height: coverHeight + 'px' }">
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" /> <div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
<div class="w-full h-full bg-primary relative rounded overflow-hidden"> <div class="w-full h-full bg-primary relative rounded overflow-hidden">

View File

@ -1,5 +1,5 @@
<template> <template>
<div cy-id="card" ref="card" :id="`series-card-${index}`" :style="{ width: cardWidth + 'px' }" class="absolute rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard"> <div cy-id="card" ref="card" :id="`series-card-${index}`" tabindex="0" :style="{ width: cardWidth + 'px' }" class="absolute rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
<div cy-id="covers-area" class="relative" :style="{ height: coverHeight + 'px' }"> <div cy-id="covers-area" class="relative" :style="{ height: coverHeight + 'px' }">
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" /> <div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
<div class="w-full h-full bg-primary relative rounded overflow-hidden z-0"> <div class="w-full h-full bg-primary relative rounded overflow-hidden z-0">
@ -7,12 +7,12 @@
</div> </div>
<div cy-id="seriesLengthMarker" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `0.1em 0.25em` }" style="background-color: #cd9d49dd"> <div cy-id="seriesLengthMarker" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `0.1em 0.25em` }" style="background-color: #cd9d49dd">
<p :style="{ fontSize: 0.8 + 'em' }">{{ books.length }}</p> <p :style="{ fontSize: 0.8 + 'em' }" role="status" :aria-label="$strings.LabelNumberOfBooks">{{ books.length }}</p>
</div> </div>
<div cy-id="seriesProgressBar" v-if="seriesPercentInProgress > 0" class="absolute bottom-0 left-0 h-1e shadow-sm max-w-full z-10 rounded-b w-full" :class="isSeriesFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: seriesPercentInProgress * 100 + '%' }" /> <div cy-id="seriesProgressBar" v-if="seriesPercentInProgress > 0" class="absolute bottom-0 left-0 h-1e shadow-sm max-w-full z-10 rounded-b w-full" :class="isSeriesFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: seriesPercentInProgress * 100 + '%' }" />
<div cy-id="hoveringDisplayTitle" v-if="hasValidCovers" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: '1em' }"> <div cy-id="hoveringDisplayTitle" v-if="hasValidCovers" aria-hidden="true" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: '1em' }">
<p :style="{ fontSize: 1.2 + 'em' }">{{ displayTitle }}</p> <p :style="{ fontSize: 1.2 + 'em' }">{{ displayTitle }}</p>
</div> </div>

View File

@ -1,13 +1,13 @@
<template> <template>
<div class=""> <div class="">
<div class="w-full relative sm:w-80"> <div class="w-full relative sm:w-80">
<form @submit.prevent="submitSearch"> <form role="search" @submit.prevent="submitSearch">
<ui-text-input ref="input" v-model="search" :placeholder="$strings.PlaceholderSearch" @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" /> <ui-text-input ref="input" v-model="search" :placeholder="$strings.PlaceholderSearch" @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
</form> </form>
<div class="absolute top-0 right-0 bottom-0 h-full flex items-center px-2 text-gray-400 cursor-pointer" @click="clickClear"> <button :aria-hidden="!search" class="absolute top-0 right-0 bottom-0 h-full flex items-center px-2 text-gray-400 cursor-pointer" @click="clickClear">
<span v-if="!search" class="material-symbols" style="font-size: 1.2rem">&#xe8b6;</span> <span v-if="!search" class="material-symbols" style="font-size: 1.2rem">&#xe8b6;</span>
<span v-else class="material-symbols" style="font-size: 1.2rem">close</span> <span v-else class="material-symbols" style="font-size: 1.2rem">close</span>
</div> </button>
</div> </div>
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-full max-w-64 sm:max-w-80 sm:w-80 bg-bg border border-black-200 shadow-lg rounded-md py-1 px-2 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalSearchMenu" @mousedown.stop.prevent> <div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-full max-w-64 sm:max-w-80 sm:w-80 bg-bg border border-black-200 shadow-lg rounded-md py-1 px-2 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalSearchMenu" @mousedown.stop.prevent>
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label"> <ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">

View File

@ -1,28 +1,30 @@
<template> <template>
<div ref="wrapper" class="relative" v-click-outside="clickOutside"> <div ref="wrapper" class="relative" v-click-outside="clickOutside">
<button type="button" class="relative w-full h-full bg-bg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu"> <div class="relative h-7">
<span class="flex items-center justify-between"> <button type="button" class="relative w-full h-full bg-bg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="menu" :aria-expanded="showMenu" @click.prevent="showMenu = !showMenu">
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span> <span class="flex items-center justify-between">
</span> <span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
</span>
</button>
<span v-if="selected === 'all'" class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none"> <span v-if="selected === 'all'" class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" /> <path fill-rule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg> </svg>
</span> </span>
<div v-else class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 cursor-pointer text-gray-400 hover:text-gray-200" @mousedown.stop @mouseup.stop @click.stop.prevent="clearSelected"> <button v-else :aria-label="$strings.ButtonClearFilter" class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 cursor-pointer text-gray-400 hover:text-gray-200" @mousedown.stop @mouseup.stop @click.stop.prevent="clearSelected">
<span class="material-symbols" style="font-size: 1.1rem">close</span> <span class="material-symbols" style="font-size: 1.1rem">close</span>
</div> </button>
</button> </div>
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm libraryFilterMenu"> <div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm libraryFilterMenu">
<ul v-show="!sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label"> <ul v-show="!sublist" class="h-full w-full" role="menu">
<template v-for="item in selectItems"> <template v-for="item in selectItems">
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item)"> <li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="menuitem" :aria-haspopup="item.sublist ? '' : 'menu'" @click="clickedOption(item)">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="font-normal ml-3 block truncate text-sm">{{ item.text }}</span> <span class="font-normal ml-3 block truncate text-sm">{{ item.text }}</span>
</div> </div>
<div v-if="item.sublist" class="absolute right-1 top-0 bottom-0 h-full flex items-center"> <div v-if="item.sublist" class="absolute right-1 top-0 bottom-0 h-full flex items-center">
<span class="material-symbols text-2xl">arrow_right</span> <span class="material-symbols text-2xl" :aria-label="$strings.LabelMore">arrow_right</span>
</div> </div>
<!-- selected checkmark icon --> <!-- selected checkmark icon -->
<div v-if="item.value === selected" class="absolute inset-y-0 right-2 h-full flex items-center pointer-events-none"> <div v-if="item.value === selected" class="absolute inset-y-0 right-2 h-full flex items-center pointer-events-none">
@ -31,8 +33,8 @@
</li> </li>
</template> </template>
</ul> </ul>
<ul v-show="sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label"> <ul v-show="sublist" class="h-full w-full" role="menu">
<li class="text-gray-50 select-none relative py-2 pl-9 cursor-pointer hover:bg-white/5" role="option" @click="sublist = null"> <li class="text-gray-50 select-none relative py-2 pl-9 cursor-pointer hover:bg-white/5" role="menuitem" @click="sublist = null">
<div class="absolute left-1 top-0 bottom-0 h-full flex items-center"> <div class="absolute left-1 top-0 bottom-0 h-full flex items-center">
<span class="material-symbols text-2xl">arrow_left</span> <span class="material-symbols text-2xl">arrow_left</span>
</div> </div>
@ -40,13 +42,13 @@
<span class="font-normal block truncate">{{ $strings.ButtonBack }}</span> <span class="font-normal block truncate">{{ $strings.ButtonBack }}</span>
</div> </div>
</li> </li>
<li v-if="!sublistItems.length" class="text-gray-400 select-none relative px-2" role="option"> <li v-if="!sublistItems.length" class="text-gray-400 select-none relative px-2" role="menuitem">
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
<span class="font-normal block truncate py-2">{{ $getString('LabelLibraryFilterSublistEmpty', [selectedSublistText]) }}</span> <span class="font-normal block truncate py-2">{{ $getString('LabelLibraryFilterSublistEmpty', [selectedSublistText]) }}</span>
</div> </div>
</li> </li>
<template v-for="item in sublistItems"> <template v-for="item in sublistItems">
<li :key="item.value" class="select-none relative px-2 cursor-pointer hover:bg-white/5" :class="`${sublist}.${item.value}` === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedSublistOption(item.value)"> <li :key="item.value" class="select-none relative px-2 cursor-pointer hover:bg-white/5" :class="`${sublist}.${item.value}` === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="menuitem" @click="clickedSublistOption(item.value)">
<div class="flex items-center"> <div class="flex items-center">
<span class="font-normal truncate py-2 text-xs">{{ item.text }}</span> <span class="font-normal truncate py-2 text-xs">{{ item.text }}</span>
</div> </div>

View File

@ -1,20 +1,20 @@
<template> <template>
<div ref="wrapper" class="relative" v-click-outside="clickOutside"> <div ref="wrapper" class="relative" v-click-outside="clickOutside">
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu"> <button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="menu" :aria-expanded="showMenu" @click.prevent="showMenu = !showMenu">
<span class="flex items-center justify-between"> <span class="flex items-center justify-between">
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span> <span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
<span class="material-symbols text-lg text-yellow-400">{{ descending ? 'expand_more' : 'expand_less' }}</span> <span class="material-symbols text-lg text-yellow-400" :aria-label="descending ? $strings.LabelSortDescending : $strings.LabelSortAscending">{{ descending ? 'expand_more' : 'expand_less' }}</span>
</span> </span>
</button> </button>
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm" role="listbox" aria-labelledby="listbox-label"> <ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm" role="menu">
<template v-for="item in selectItems"> <template v-for="item in selectItems">
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item.value)"> <li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="menuitem" @click="clickedOption(item.value)">
<div class="flex items-center"> <div class="flex items-center">
<span class="font-normal ml-3 block truncate">{{ item.text }}</span> <span class="font-normal ml-3 block truncate">{{ item.text }}</span>
</div> </div>
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4"> <span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
<span class="material-symbols text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span> <span class="material-symbols text-xl" :aria-label="descending ? $strings.LabelSortDescending : $strings.LabelSortAscending">{{ descending ? 'expand_more' : 'expand_less' }}</span>
</span> </span>
</li> </li>
</template> </template>

View File

@ -1,20 +1,20 @@
<template> <template>
<div ref="wrapper" class="relative" v-click-outside="clickOutside"> <div ref="wrapper" class="relative" v-click-outside="clickOutside">
<button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu"> <button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none cursor-pointer" aria-haspopup="menu" :aria-expanded="showMenu" @click.prevent="showMenu = !showMenu">
<span class="flex items-center justify-between"> <span class="flex items-center justify-between">
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span> <span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
<span class="material-symbols text-lg text-yellow-400">{{ descending ? 'expand_more' : 'expand_less' }}</span> <span class="material-symbols text-lg text-yellow-400" :aria-label="descending ? $strings.LabelSortDescending : $strings.LabelSortAscending">{{ descending ? 'expand_more' : 'expand_less' }}</span>
</span> </span>
</button> </button>
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm" role="listbox" aria-labelledby="listbox-label"> <ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm" role="menu">
<template v-for="item in items"> <template v-for="item in items">
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item.value)"> <li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="menuitem" @click="clickedOption(item.value)">
<div class="flex items-center"> <div class="flex items-center">
<span class="font-normal ml-3 block truncate">{{ item.text }}</span> <span class="font-normal ml-3 block truncate">{{ item.text }}</span>
</div> </div>
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4"> <span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
<span class="material-symbols text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span> <span class="material-symbols text-xl" :aria-label="descending ? $strings.LabelSortDescending : $strings.LabelSortAscending">{{ descending ? 'expand_more' : 'expand_less' }}</span>
</span> </span>
</li> </li>
</template> </template>

View File

@ -121,6 +121,8 @@ export default {
var img = document.createElement('img') var img = document.createElement('img')
img.src = src img.src = src
img.alt = `${this.name}, ${this.$strings.LabelCover}`
img.ariaHidden = true
img.className = 'absolute top-0 left-0 w-full h-full' img.className = 'absolute top-0 left-0 w-full h-full'
img.style.objectFit = showCoverBg ? 'contain' : 'cover' img.style.objectFit = showCoverBg ? 'contain' : 'cover'

View File

@ -54,8 +54,7 @@ export default {
options: { options: {
provider: undefined, provider: undefined,
overrideDetails: true, overrideDetails: true,
overrideCover: true, overrideCover: true
overrideDefaults: true
} }
} }
}, },
@ -99,8 +98,8 @@ export default {
init() { init() {
// If we don't have a set provider (first open of dialog) or we've switched library, set // If we don't have a set provider (first open of dialog) or we've switched library, set
// the selected provider to the current library default provider // the selected provider to the current library default provider
if (!this.options.provider || this.options.lastUsedLibrary != this.currentLibraryId) { if (!this.options.provider || this.lastUsedLibrary != this.currentLibraryId) {
this.options.lastUsedLibrary = this.currentLibraryId this.lastUsedLibrary = this.currentLibraryId
this.options.provider = this.libraryProvider this.options.provider = this.libraryProvider
} }
}, },

View File

@ -1,8 +1,8 @@
<template> <template>
<div ref="wrapper" class="hidden absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 rounded-lg items-center justify-center" style="z-index: 61" @click="clickClose"> <div ref="wrapper" role="dialog" aria-modal="true" class="hidden absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 rounded-lg items-center justify-center" style="z-index: 61" @click="clickClose">
<div class="absolute top-3 right-3 md:top-5 md:right-5 h-8 w-8 md:h-12 md:w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300"> <button type="button" class="absolute top-3 right-3 md:top-5 md:right-5 h-8 w-8 md:h-12 md:w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" aria-label="Close modal">
<span class="material-symbols text-2xl md:text-4xl">close</span> <span class="material-symbols text-2xl md:text-4xl">close</span>
</div> </button>
<div ref="content" class="text-white"> <div ref="content" class="text-white">
<form v-if="selectedSeries" @submit.prevent="submitSeriesForm"> <form v-if="selectedSeries" @submit.prevent="submitSeriesForm">
<div class="bg-bg rounded-lg px-2 py-6 sm:p-6 md:p-8" @click.stop> <div class="bg-bg rounded-lg px-2 py-6 sm:p-6 md:p-8" @click.stop>

View File

@ -1,12 +1,12 @@
<template> <template>
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary items-center justify-center opacity-0 hidden" :class="`z-${zIndex} bg-opacity-${bgOpacity}`"> <div ref="wrapper" role="dialog" aria-modal="true" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary items-center justify-center opacity-0 hidden" :class="`z-${zIndex} bg-opacity-${bgOpacity}`">
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" /> <div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
<button class="absolute top-4 right-4 landscape:top-4 landscape:right-4 md:portrait:top-5 md:portrait:right-5 lg:top-5 lg:right-5 inline-flex text-gray-200 hover:text-white" aria-label="Close modal" @click="clickClose"> <button class="absolute top-4 right-4 landscape:top-4 landscape:right-4 md:portrait:top-5 md:portrait:right-5 lg:top-5 lg:right-5 inline-flex text-gray-200 hover:text-white" aria-label="Close modal" @click="clickClose">
<span class="material-symbols text-2xl landscape:text-2xl md:portrait:text-4xl lg:text-4xl">close</span> <span class="material-symbols text-2xl landscape:text-2xl md:portrait:text-4xl lg:text-4xl">close</span>
</button> </button>
<slot name="outer" /> <slot name="outer" />
<div ref="content" style="min-width: 380px; min-height: 200px; max-width: 100vw" class="relative text-white" aria-modal="true" :style="{ height: modalHeight, width: modalWidth, marginTop: contentMarginTop + 'px' }" @mousedown="mousedownModal" @mouseup="mouseupModal" v-click-outside="clickBg"> <div ref="content" tabindex="0" style="min-width: 380px; min-height: 200px; max-width: 100vw" class="relative text-white outline-none" :style="{ height: modalHeight, width: modalWidth, marginTop: contentMarginTop + 'px' }" @mousedown="mousedownModal" @mouseup="mouseupModal" v-click-outside="clickBg">
<slot /> <slot />
<div v-if="processing" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-black bg-opacity-60 rounded-lg flex items-center justify-center"> <div v-if="processing" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-black bg-opacity-60 rounded-lg flex items-center justify-center">
<ui-loading-indicator /> <ui-loading-indicator />
@ -126,6 +126,9 @@ export default {
this.$eventBus.$on('modal-hotkey', this.hotkey) this.$eventBus.$on('modal-hotkey', this.hotkey)
this.$store.commit('setOpenModal', this.name) this.$store.commit('setOpenModal', this.name)
// Set focus to the modal content
this.content.focus()
}, },
setHide() { setHide() {
if (this.content) this.content.style.transform = 'scale(0)' if (this.content) this.content.style.transform = 'scale(0)'

View File

@ -19,12 +19,13 @@
<ui-text-input v-model="currentShareUrl" show-copy readonly class="text-base h-10" /> <ui-text-input v-model="currentShareUrl" show-copy readonly class="text-base h-10" />
</div> </div>
<div class="w-full py-2 px-1"> <div class="w-full py-2 px-1">
<p v-if="currentShare.expiresAt" class="text-base">{{ $getString('MessageShareExpiresIn', [currentShareTimeRemaining]) }}</p> <p v-if="currentShare.isDownloadable" class="text-sm mb-2">{{ $strings.LabelDownloadable }}</p>
<p v-if="currentShare.expiresAt">{{ $getString('MessageShareExpiresIn', [currentShareTimeRemaining]) }}</p>
<p v-else>{{ $strings.LabelPermanent }}</p> <p v-else>{{ $strings.LabelPermanent }}</p>
</div> </div>
</template> </template>
<template v-else> <template v-else>
<div class="flex flex-col sm:flex-row items-center justify-between space-y-4 sm:space-y-0 sm:space-x-4 mb-4"> <div class="flex flex-col sm:flex-row items-center justify-between space-y-4 sm:space-y-0 sm:space-x-4 mb-2">
<div class="w-full sm:w-48"> <div class="w-full sm:w-48">
<label class="px-1 text-sm font-semibold block">{{ $strings.LabelSlug }}</label> <label class="px-1 text-sm font-semibold block">{{ $strings.LabelSlug }}</label>
<ui-text-input v-model="newShareSlug" class="text-base h-10" /> <ui-text-input v-model="newShareSlug" class="text-base h-10" />
@ -46,6 +47,15 @@
</div> </div>
</div> </div>
</div> </div>
<div class="flex items-center w-full md:w-1/2 mb-4">
<p class="text-sm text-gray-300 py-1 px-1">{{ $strings.LabelDownloadable }}</p>
<ui-toggle-switch size="sm" v-model="isDownloadable" />
<ui-tooltip :text="$strings.LabelShareDownloadableHelp">
<p class="pl-4 text-sm">
<span class="material-symbols icon-text text-sm">info</span>
</p>
</ui-tooltip>
</div>
<p class="text-sm text-gray-300 py-1 px-1" v-html="$getString('MessageShareURLWillBe', [demoShareUrl])" /> <p class="text-sm text-gray-300 py-1 px-1" v-html="$getString('MessageShareURLWillBe', [demoShareUrl])" />
<p class="text-sm text-gray-300 py-1 px-1" v-html="$getString('MessageShareExpirationWillBe', [expirationDateString])" /> <p class="text-sm text-gray-300 py-1 px-1" v-html="$getString('MessageShareExpirationWillBe', [expirationDateString])" />
</template> </template>
@ -81,7 +91,8 @@ export default {
text: this.$strings.LabelDays, text: this.$strings.LabelDays,
value: 'days' value: 'days'
} }
] ],
isDownloadable: false
} }
}, },
watch: { watch: {
@ -172,7 +183,8 @@ export default {
slug: this.newShareSlug, slug: this.newShareSlug,
mediaItemType: 'book', mediaItemType: 'book',
mediaItemId: this.libraryItem.media.id, mediaItemId: this.libraryItem.media.id,
expiresAt: this.expireDurationSeconds ? Date.now() + this.expireDurationSeconds * 1000 : 0 expiresAt: this.expireDurationSeconds ? Date.now() + this.expireDurationSeconds * 1000 : 0,
isDownloadable: this.isDownloadable
} }
this.processing = true this.processing = true
this.$axios this.$axios

View File

@ -2,7 +2,7 @@
<modals-modal v-model="show" name="changelog" :width="800" :height="'unset'"> <modals-modal v-model="show" name="changelog" :width="800" :height="'unset'">
<template #outer> <template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden"> <div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="text-3xl text-white truncate">Changelog</p> <h1 class="text-3xl text-white truncate">Changelog</h1>
</div> </div>
</template> </template>
<div class="px-8 py-6 w-full rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-y-scroll" style="max-height: 80vh"> <div class="px-8 py-6 w-full rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-y-scroll" style="max-height: 80vh">
@ -13,7 +13,7 @@
</p> </p>
<div class="custom-text" v-html="getChangelog(release)" /> <div class="custom-text" v-html="getChangelog(release)" />
</div> </div>
<div v-if="release !== releasesToShow[releasesToShow.length - 1]" class="border-b border-black-300 my-8" /> <div v-if="release !== releasesToShow[releasesToShow.length - 1]" :key="`${release.name}-divider`" class="border-b border-black-300 my-8" />
</template> </template>
</div> </div>
</modals-modal> </modals-modal>

View File

@ -138,7 +138,6 @@ export default {
.$post(`/api/collections/${collection.id}/batch/remove`, { books: this.selectedBookIds }) .$post(`/api/collections/${collection.id}/batch/remove`, { books: this.selectedBookIds })
.then((updatedCollection) => { .then((updatedCollection) => {
console.log(`Books removed from collection`, updatedCollection) console.log(`Books removed from collection`, updatedCollection)
this.$toast.success(this.$strings.ToastCollectionItemsRemoveSuccess)
this.processing = false this.processing = false
}) })
.catch((error) => { .catch((error) => {
@ -152,7 +151,6 @@ export default {
.$delete(`/api/collections/${collection.id}/book/${this.selectedLibraryItemId}`) .$delete(`/api/collections/${collection.id}/book/${this.selectedLibraryItemId}`)
.then((updatedCollection) => { .then((updatedCollection) => {
console.log(`Book removed from collection`, updatedCollection) console.log(`Book removed from collection`, updatedCollection)
this.$toast.success(this.$strings.ToastCollectionItemsRemoveSuccess)
this.processing = false this.processing = false
}) })
.catch((error) => { .catch((error) => {
@ -167,12 +165,11 @@ export default {
this.processing = true this.processing = true
if (this.showBatchCollectionModal) { if (this.showBatchCollectionModal) {
// BATCH Remove books // BATCH Add books
this.$axios this.$axios
.$post(`/api/collections/${collection.id}/batch/add`, { books: this.selectedBookIds }) .$post(`/api/collections/${collection.id}/batch/add`, { books: this.selectedBookIds })
.then((updatedCollection) => { .then((updatedCollection) => {
console.log(`Books added to collection`, updatedCollection) console.log(`Books added to collection`, updatedCollection)
this.$toast.success(this.$strings.ToastCollectionItemsAddSuccess)
this.processing = false this.processing = false
}) })
.catch((error) => { .catch((error) => {
@ -187,7 +184,6 @@ export default {
.$post(`/api/collections/${collection.id}/book`, { id: this.selectedLibraryItemId }) .$post(`/api/collections/${collection.id}/book`, { id: this.selectedLibraryItemId })
.then((updatedCollection) => { .then((updatedCollection) => {
console.log(`Book added to collection`, updatedCollection) console.log(`Book added to collection`, updatedCollection)
this.$toast.success(this.$strings.ToastCollectionItemsAddSuccess)
this.processing = false this.processing = false
}) })
.catch((error) => { .catch((error) => {
@ -214,7 +210,6 @@ export default {
.$post('/api/collections', newCollection) .$post('/api/collections', newCollection)
.then((data) => { .then((data) => {
console.log('New Collection Created', data) console.log('New Collection Created', data)
this.$toast.success(`Collection "${data.name}" created`)
this.processing = false this.processing = false
this.newCollectionName = '' this.newCollectionName = ''
}) })

View File

@ -2,24 +2,24 @@
<modals-modal v-model="show" name="edit-book" :width="800" :height="height" :processing="processing" :content-margin-top="marginTop"> <modals-modal v-model="show" name="edit-book" :width="800" :height="height" :processing="processing" :content-margin-top="marginTop">
<template #outer> <template #outer>
<div class="absolute top-0 left-0 p-4 landscape:px-4 landscape:py-2 md:portrait:p-5 lg:p-5 w-2/3 overflow-hidden pointer-events-none"> <div class="absolute top-0 left-0 p-4 landscape:px-4 landscape:py-2 md:portrait:p-5 lg:p-5 w-2/3 overflow-hidden pointer-events-none">
<p class="text-xl md:portrait:text-3xl md:landscape:text-lg lg:text-3xl text-white truncate pointer-events-none">{{ title }}</p> <h1 class="text-xl md:portrait:text-3xl md:landscape:text-lg lg:text-3xl text-white truncate pointer-events-none">{{ title }}</h1>
</div> </div>
</template> </template>
<div class="absolute -top-10 left-0 z-10 w-full flex"> <div role="tablist" class="absolute -top-10 left-0 z-10 w-full flex">
<template v-for="tab in availableTabs"> <template v-for="tab in availableTabs">
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-0.5 sm:mr-1 cursor-pointer hover:bg-bg border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div> <button :key="tab.id" role="tab" class="w-28 rounded-t-lg flex items-center justify-center mr-0.5 sm:mr-1 cursor-pointer hover:bg-bg border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</button>
</template> </template>
</div> </div>
<div v-show="canGoPrev" class="absolute -left-24 top-0 bottom-0 h-full pointer-events-none flex items-center px-6"> <div role="tabpanel" class="w-full h-full max-h-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative">
<div class="material-symbols text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goPrevBook" @mousedown.prevent>arrow_back_ios</div> <component v-if="libraryItem && show" :is="tabName" :library-item="libraryItem" :processing.sync="processing" @close="show = false" @selectTab="selectTab" />
</div>
<div v-show="canGoNext" class="absolute -right-24 top-0 bottom-0 h-full pointer-events-none flex items-center px-6">
<div class="material-symbols text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goNextBook" @mousedown.prevent>arrow_forward_ios</div>
</div> </div>
<div class="w-full h-full max-h-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative"> <div v-show="canGoPrev" class="absolute -left-24 top-0 bottom-0 h-full pointer-events-none flex items-center px-6">
<component v-if="libraryItem && show" :is="tabName" :library-item="libraryItem" :processing.sync="processing" @close="show = false" @selectTab="selectTab" /> <button class="material-symbols text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" :aria-label="$strings.ButtonNext" @click.stop.prevent="goPrevBook" @mousedown.prevent>arrow_back_ios</button>
</div>
<div v-show="canGoNext" class="absolute -right-24 top-0 bottom-0 h-full pointer-events-none flex items-center px-6">
<button class="material-symbols text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" :aria-label="$strings.ButtonPrevious" @click.stop.prevent="goNextBook" @mousedown.prevent>arrow_forward_ios</button>
</div> </div>
</modals-modal> </modals-modal>
</template> </template>

View File

@ -130,7 +130,6 @@ export default {
.$post(`/api/playlists/${playlist.id}/batch/remove`, { items: itemObjects }) .$post(`/api/playlists/${playlist.id}/batch/remove`, { items: itemObjects })
.then((updatedPlaylist) => { .then((updatedPlaylist) => {
console.log(`Items removed from playlist`, updatedPlaylist) console.log(`Items removed from playlist`, updatedPlaylist)
this.$toast.success(this.$strings.ToastPlaylistUpdateSuccess)
this.processing = false this.processing = false
}) })
.catch((error) => { .catch((error) => {
@ -148,7 +147,6 @@ export default {
.$post(`/api/playlists/${playlist.id}/batch/add`, { items: itemObjects }) .$post(`/api/playlists/${playlist.id}/batch/add`, { items: itemObjects })
.then((updatedPlaylist) => { .then((updatedPlaylist) => {
console.log(`Items added to playlist`, updatedPlaylist) console.log(`Items added to playlist`, updatedPlaylist)
this.$toast.success(this.$strings.ToastPlaylistUpdateSuccess)
this.processing = false this.processing = false
}) })
.catch((error) => { .catch((error) => {
@ -174,7 +172,6 @@ export default {
.$post('/api/playlists', newPlaylist) .$post('/api/playlists', newPlaylist)
.then((data) => { .then((data) => {
console.log('New playlist created', data) console.log('New playlist created', data)
this.$toast.success(this.$strings.ToastPlaylistCreateSuccess + ': ' + data.name)
this.processing = false this.processing = false
this.newPlaylistName = '' this.newPlaylistName = ''
}) })

View File

@ -170,6 +170,12 @@ export default {
this.show = false this.show = false
} }
}, },
libraryItemUpdated(libraryItem) {
const episode = libraryItem.media.episodes.find((e) => e.id === this.selectedEpisodeId)
if (episode) {
this.episodeItem = episode
}
},
hotkey(action) { hotkey(action) {
if (action === this.$hotkeys.Modal.NEXT_PAGE) { if (action === this.$hotkeys.Modal.NEXT_PAGE) {
this.goNextEpisode() this.goNextEpisode()
@ -178,9 +184,15 @@ export default {
} }
}, },
registerListeners() { registerListeners() {
if (this.libraryItem) {
this.$eventBus.$on(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)
}
this.$eventBus.$on('modal-hotkey', this.hotkey) this.$eventBus.$on('modal-hotkey', this.hotkey)
}, },
unregisterListeners() { unregisterListeners() {
if (this.libraryItem) {
this.$eventBus.$on(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)
}
this.$eventBus.$off('modal-hotkey', this.hotkey) this.$eventBus.$off('modal-hotkey', this.hotkey)
} }
}, },

View File

@ -163,13 +163,10 @@ export default {
this.isProcessing = false this.isProcessing = false
if (updateResult) { if (updateResult) {
if (updateResult) { this.$toast.success(this.$strings.ToastItemUpdateSuccess)
this.$toast.success(this.$strings.ToastItemUpdateSuccess) return true
return true
} else {
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
}
} }
return false return false
} }
}, },

View File

@ -10,9 +10,9 @@
<p class="text-lg font-semibold mb-4">{{ $strings.HeaderRSSFeedIsOpen }}</p> <p class="text-lg font-semibold mb-4">{{ $strings.HeaderRSSFeedIsOpen }}</p>
<div class="w-full relative"> <div class="w-full relative">
<ui-text-input v-model="currentFeed.feedUrl" readonly /> <ui-text-input :value="feedUrl" readonly />
<span class="material-symbols absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(currentFeed.feedUrl)">content_copy</span> <span class="material-symbols absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(feedUrl)">content_copy</span>
</div> </div>
<div v-if="currentFeed.meta" class="mt-5"> <div v-if="currentFeed.meta" class="mt-5">
@ -111,8 +111,11 @@ export default {
userIsAdminOrUp() { userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp'] return this.$store.getters['user/getIsAdminOrUp']
}, },
feedUrl() {
return this.currentFeed ? `${window.origin}${this.$config.routerBasePath}${this.currentFeed.feedUrl}` : ''
},
demoFeedUrl() { demoFeedUrl() {
return `${window.origin}/feed/${this.newFeedSlug}` return `${window.origin}${this.$config.routerBasePath}/feed/${this.newFeedSlug}`
}, },
isHttp() { isHttp() {
return window.origin.startsWith('http://') return window.origin.startsWith('http://')

View File

@ -5,8 +5,8 @@
<p class="text-lg font-semibold mb-4">{{ $strings.HeaderRSSFeedGeneral }}</p> <p class="text-lg font-semibold mb-4">{{ $strings.HeaderRSSFeedGeneral }}</p>
<div class="w-full relative"> <div class="w-full relative">
<ui-text-input v-model="feed.feedUrl" readonly /> <ui-text-input :value="feedUrl" readonly />
<span class="material-symbols absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(feed.feedUrl)">content_copy</span> <span class="material-symbols absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(feedUrl)">content_copy</span>
</div> </div>
<div v-if="feed.meta" class="mt-5"> <div v-if="feed.meta" class="mt-5">
@ -70,6 +70,9 @@ export default {
}, },
_feed() { _feed() {
return this.feed || {} return this.feed || {}
},
feedUrl() {
return this.feed ? `${window.origin}${this.$config.routerBasePath}${this.feed.feedUrl}` : ''
} }
}, },
methods: { methods: {

View File

@ -1,7 +1,7 @@
<template> <template>
<div id="heatmap" class="w-full"> <div id="heatmap" class="w-full">
<div class="mx-auto" :style="{ height: innerHeight + 160 + 'px', width: innerWidth + 52 + 'px' }" style="background-color: rgba(13, 17, 23, 0)"> <div class="mx-auto" :style="{ height: innerHeight + 160 + 'px', width: innerWidth + 52 + 'px' }" style="background-color: rgba(13, 17, 23, 0)">
<p class="mb-2 px-1 text-sm text-gray-200">{{ $getString('MessageListeningSessionsInTheLastYear', [Object.values(daysListening).length]) }}</p> <p class="mb-2 px-1 text-sm text-gray-200">{{ $getString('MessageDaysListenedInTheLastYear', [daysListenedInTheLastYear]) }}</p>
<div class="border border-white border-opacity-25 rounded py-2 w-full" style="background-color: #232323" :style="{ height: innerHeight + 80 + 'px' }"> <div class="border border-white border-opacity-25 rounded py-2 w-full" style="background-color: #232323" :style="{ height: innerHeight + 80 + 'px' }">
<div :style="{ width: innerWidth + 'px', height: innerHeight + 'px' }" class="ml-10 mt-5 absolute" @mouseover="mouseover" @mouseout="mouseout"> <div :style="{ width: innerWidth + 'px', height: innerHeight + 'px' }" class="ml-10 mt-5 absolute" @mouseover="mouseover" @mouseout="mouseout">
<div v-for="dayLabel in dayLabels" :key="dayLabel.label" :style="dayLabel.style" class="absolute top-0 left-0 text-gray-300">{{ dayLabel.label }}</div> <div v-for="dayLabel in dayLabels" :key="dayLabel.label" :style="dayLabel.style" class="absolute top-0 left-0 text-gray-300">{{ dayLabel.label }}</div>
@ -37,6 +37,7 @@ export default {
innerHeight: 13 * 7, innerHeight: 13 * 7,
blockWidth: 13, blockWidth: 13,
data: [], data: [],
daysListenedInTheLastYear: 0,
monthLabels: [], monthLabels: [],
tooltipEl: null, tooltipEl: null,
tooltipTextEl: null, tooltipTextEl: null,
@ -62,9 +63,6 @@ export default {
dayOfWeekToday() { dayOfWeekToday() {
return new Date().getDay() return new Date().getDay()
}, },
firstWeekStart() {
return this.$addDaysToToday(-this.daysToShow)
},
dayLabels() { dayLabels() {
return [ return [
{ {
@ -193,46 +191,59 @@ export default {
buildData() { buildData() {
this.data = [] this.data = []
var maxValue = 0 let maxValue = 0
var minValue = 0 let minValue = 0
Object.values(this.daysListening).forEach((val) => {
if (val > maxValue) maxValue = val const dates = []
if (!minValue || val < minValue) minValue = val
}) const numDaysInTheLastYear = 52 * 7 + this.dayOfWeekToday
const firstDay = this.$addDaysToToday(-numDaysInTheLastYear)
for (let i = 0; i < numDaysInTheLastYear + 1; i++) {
const date = i === 0 ? firstDay : this.$addDaysToDate(firstDay, i)
const dateString = this.$formatJsDate(date, 'yyyy-MM-dd')
if (this.daysListening[dateString] > 0) {
this.daysListenedInTheLastYear++
}
const visibleDayIndex = i - (numDaysInTheLastYear - this.daysToShow)
if (visibleDayIndex < 0) {
continue
}
const dateObj = {
col: Math.floor(visibleDayIndex / 7),
row: visibleDayIndex % 7,
date,
dateString,
datePretty: this.$formatJsDate(date, 'MMM d, yyyy'),
monthString: this.$formatJsDate(date, 'MMM'),
dayOfMonth: Number(dateString.split('-').pop()),
yearString: dateString.split('-').shift(),
value: this.daysListening[dateString] || 0
}
dates.push(dateObj)
if (dateObj.value > 0) {
if (dateObj.value > maxValue) maxValue = dateObj.value
if (!minValue || dateObj.value < minValue) minValue = dateObj.value
}
}
const range = maxValue - minValue + 0.01 const range = maxValue - minValue + 0.01
for (let i = 0; i < this.daysToShow + 1; i++) { for (const dateObj of dates) {
const col = Math.floor(i / 7) let bgColor = this.bgColors[0]
const row = i % 7 let outlineColor = this.outlineColors[0]
if (dateObj.value) {
const date = i === 0 ? this.firstWeekStart : this.$addDaysToDate(this.firstWeekStart, i)
const dateString = this.$formatJsDate(date, 'yyyy-MM-dd')
const datePretty = this.$formatJsDate(date, 'MMM d, yyyy')
const monthString = this.$formatJsDate(date, 'MMM')
const value = this.daysListening[dateString] || 0
const x = col * 13
const y = row * 13
var bgColor = this.bgColors[0]
var outlineColor = this.outlineColors[0]
if (value) {
outlineColor = this.outlineColors[1] outlineColor = this.outlineColors[1]
var percentOfAvg = (value - minValue) / range const percentOfAvg = (dateObj.value - minValue) / range
var bgIndex = Math.floor(percentOfAvg * 4) + 1 const bgIndex = Math.floor(percentOfAvg * 4) + 1
bgColor = this.bgColors[bgIndex] || 'red' bgColor = this.bgColors[bgIndex] || 'red'
} }
this.data.push({ this.data.push({
date, ...dateObj,
dateString, style: `transform:translate(${dateObj.col * 13}px,${dateObj.row * 13}px);background-color:${bgColor};outline:1px solid ${outlineColor};outline-offset:-1px;`
datePretty,
monthString,
dayOfMonth: Number(dateString.split('-').pop()),
yearString: dateString.split('-').shift(),
value,
col,
row,
style: `transform:translate(${x}px,${y}px);background-color:${bgColor};outline:1px solid ${outlineColor};outline-offset:-1px;`
}) })
} }
@ -260,6 +271,7 @@ export default {
const heatmapEl = document.getElementById('heatmap') const heatmapEl = document.getElementById('heatmap')
this.contentWidth = heatmapEl.clientWidth this.contentWidth = heatmapEl.clientWidth
this.maxInnerWidth = this.contentWidth - 52 this.maxInnerWidth = this.contentWidth - 52
this.daysListenedInTheLastYear = 0
this.buildData() this.buildData()
} }
}, },

View File

@ -1,9 +1,9 @@
<template> <template>
<div> <div>
<div v-if="processing" class="max-w-[800px] h-80 md:h-[800px] mx-auto flex items-center justify-center"> <div v-if="processing" role="img" :aria-label="$strings.MessageLoading" class="max-w-[800px] h-80 md:h-[800px] mx-auto flex items-center justify-center">
<widgets-loading-spinner /> <widgets-loading-spinner />
</div> </div>
<img v-else-if="dataUrl" :src="dataUrl" class="mx-auto" /> <img v-else-if="dataUrl" :src="dataUrl" class="mx-auto" :aria-label="$getString('LabelPersonalYearReview', [variant + 1])" />
</div> </div>
</template> </template>

View File

@ -7,7 +7,7 @@
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
<p class="hidden md:block text-xl font-semibold">{{ $getString('HeaderYearReview', [yearInReviewYear]) }}</p> <h1 class="hidden md:block text-xl font-semibold">{{ $getString('HeaderYearReview', [yearInReviewYear]) }}</h1>
<div class="hidden md:block flex-grow" /> <div class="hidden md:block flex-grow" />
<ui-btn class="w-full md:w-auto" @click.stop="clickShowYearInReview">{{ showYearInReview ? $strings.LabelYearReviewHide : $strings.LabelYearReviewShow }}</ui-btn> <ui-btn class="w-full md:w-auto" @click.stop="clickShowYearInReview">{{ showYearInReview ? $strings.LabelYearReviewHide : $strings.LabelYearReviewShow }}</ui-btn>
</div> </div>
@ -16,17 +16,22 @@
<div v-if="showYearInReview"> <div v-if="showYearInReview">
<div class="w-full h-px bg-slate-200/10 my-4" /> <div class="w-full h-px bg-slate-200/10 my-4" />
<div class="flex items-center justify-center mb-2 max-w-[800px] mx-auto"> <div v-if="availableYears.length > 1" class="mb-2 py-2 max-w-[800px] mx-auto">
<!-- year selector -->
<ui-dropdown v-model="yearInReviewYear" small :items="availableYears" :disabled="processingYearInReview" class="max-w-24" @input="yearInReviewYearChanged" />
</div>
<div role="toolbar" class="flex items-center justify-center mb-2 max-w-[800px] mx-auto">
<!-- previous button --> <!-- previous button -->
<ui-btn small :disabled="!yearInReviewVariant || processingYearInReview" class="inline-flex items-center font-semibold" @click="yearInReviewVariant--"> <ui-btn small :disabled="!yearInReviewVariant || processingYearInReview" :aria-label="$strings.ButtonPrevious" class="inline-flex items-center font-semibold" @click="yearInReviewVariant--">
<span class="material-symbols text-lg sm:pr-1 py-px sm:py-0">chevron_left</span> <span class="material-symbols text-lg sm:pr-1 py-px sm:py-0">chevron_left</span>
<span class="hidden sm:inline-block pr-2">{{ $strings.ButtonPrevious }}</span> <span class="hidden sm:inline-block pr-2">{{ $strings.ButtonPrevious }}</span>
</ui-btn> </ui-btn>
<!-- share button --> <!-- share button -->
<ui-btn v-if="showShareButton" small :disabled="processingYearInReview" class="inline-flex sm:hidden items-center font-semibold ml-1 sm:ml-2" @click="shareYearInReview">{{ $strings.ButtonShare }} </ui-btn> <ui-btn v-if="showShareButton" small :disabled="processingYearInReview" class="inline-flex items-center font-semibold ml-1 sm:ml-2" @click="shareYearInReview">{{ $strings.ButtonShare }} </ui-btn>
<div class="flex-grow" /> <div class="flex-grow" />
<p class="hidden sm:block text-lg font-semibold">{{ $getString('LabelPersonalYearReview', [yearInReviewVariant + 1]) }}</p> <h2 class="hidden sm:block text-lg font-semibold">{{ $getString('LabelPersonalYearReview', [yearInReviewVariant + 1]) }}</h2>
<p class="block sm:hidden text-lg font-semibold">{{ yearInReviewVariant + 1 }}</p> <p class="block sm:hidden text-lg font-semibold">{{ yearInReviewVariant + 1 }}</p>
<div class="flex-grow" /> <div class="flex-grow" />
@ -36,7 +41,7 @@
<span class="material-symbols sm:!hidden text-lg py-px">refresh</span> <span class="material-symbols sm:!hidden text-lg py-px">refresh</span>
</ui-btn> </ui-btn>
<!-- next button --> <!-- next button -->
<ui-btn small :disabled="yearInReviewVariant >= 2 || processingYearInReview" class="inline-flex items-center font-semibold" @click="yearInReviewVariant++"> <ui-btn small :disabled="yearInReviewVariant >= 2 || processingYearInReview" :aria-label="$strings.ButtonNext" class="inline-flex items-center font-semibold" @click="yearInReviewVariant++">
<span class="hidden sm:inline-block pl-2">{{ $strings.ButtonNext }}</span> <span class="hidden sm:inline-block pl-2">{{ $strings.ButtonNext }}</span>
<span class="material-symbols text-lg sm:pl-1 py-px sm:py-0">chevron_right</span> <span class="material-symbols text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
</ui-btn> </ui-btn>
@ -46,23 +51,23 @@
<!-- your year in review short --> <!-- your year in review short -->
<div class="w-full max-w-[800px] mx-auto my-4"> <div class="w-full max-w-[800px] mx-auto my-4">
<!-- share button --> <!-- share button -->
<ui-btn v-if="showShareButton" small :disabled="processingYearInReviewShort" class="inline-flex sm:hidden items-center font-semibold mb-1" @click="shareYearInReviewShort">{{ $strings.ButtonShare }}</ui-btn> <ui-btn v-if="showShareButton" small :disabled="processingYearInReviewShort" class="inline-flex items-center font-semibold mb-1" @click="shareYearInReviewShort">{{ $strings.ButtonShare }}</ui-btn>
<stats-year-in-review-short ref="yearInReviewShort" :year="yearInReviewYear" :processing.sync="processingYearInReviewShort" /> <stats-year-in-review-short ref="yearInReviewShort" :year="yearInReviewYear" :processing.sync="processingYearInReviewShort" />
</div> </div>
<!-- your server in review --> <!-- your server in review -->
<div v-if="isAdminOrUp" class="w-full max-w-[800px] mx-auto mb-2 mt-4 border-t pt-4 border-white/10"> <div v-if="isAdminOrUp" role="toolbar" class="w-full max-w-[800px] mx-auto mb-2 mt-4 border-t pt-4 border-white/10">
<div class="flex items-center justify-center mb-2"> <div class="flex items-center justify-center mb-2">
<!-- previous button --> <!-- previous button -->
<ui-btn small :disabled="!yearInReviewServerVariant || processingYearInReviewServer" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant--"> <ui-btn small :disabled="!yearInReviewServerVariant || processingYearInReviewServer" :aria-label="$strings.ButtonPrevious" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant--">
<span class="material-symbols text-lg sm:pr-1 py-px sm:py-0">chevron_left</span> <span class="material-symbols text-lg sm:pr-1 py-px sm:py-0">chevron_left</span>
<span class="hidden sm:inline-block pr-2">{{ $strings.ButtonPrevious }}</span> <span class="hidden sm:inline-block pr-2">{{ $strings.ButtonPrevious }}</span>
</ui-btn> </ui-btn>
<!-- share button --> <!-- share button -->
<ui-btn v-if="showShareButton" small :disabled="processingYearInReviewServer" class="inline-flex sm:hidden items-center font-semibold ml-1 sm:ml-2" @click="shareYearInReviewServer">{{ $strings.ButtonShare }} </ui-btn> <ui-btn v-if="showShareButton" small :disabled="processingYearInReviewServer" class="inline-flex items-center font-semibold ml-1 sm:ml-2" @click="shareYearInReviewServer">{{ $strings.ButtonShare }} </ui-btn>
<div class="flex-grow" /> <div class="flex-grow" />
<p class="hidden sm:block text-lg font-semibold">{{ $getString('LabelServerYearReview', [yearInReviewServerVariant + 1]) }}</p> <h2 class="hidden sm:block text-lg font-semibold">{{ $getString('LabelServerYearReview', [yearInReviewServerVariant + 1]) }}</h2>
<p class="block sm:hidden text-lg font-semibold">{{ yearInReviewServerVariant + 1 }}</p> <p class="block sm:hidden text-lg font-semibold">{{ yearInReviewServerVariant + 1 }}</p>
<div class="flex-grow" /> <div class="flex-grow" />
@ -72,7 +77,7 @@
<span class="material-symbols sm:!hidden text-lg py-px">refresh</span> <span class="material-symbols sm:!hidden text-lg py-px">refresh</span>
</ui-btn> </ui-btn>
<!-- next button --> <!-- next button -->
<ui-btn small :disabled="yearInReviewServerVariant >= 2 || processingYearInReviewServer" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant++"> <ui-btn small :disabled="yearInReviewServerVariant >= 2 || processingYearInReviewServer" :aria-label="$strings.ButtonNext" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant++">
<span class="hidden sm:inline-block pl-2">{{ $strings.ButtonNext }}</span> <span class="hidden sm:inline-block pl-2">{{ $strings.ButtonNext }}</span>
<span class="material-symbols text-lg sm:pl-1 py-px sm:py-0">chevron_right</span> <span class="material-symbols text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
</ui-btn> </ui-btn>
@ -88,6 +93,7 @@ export default {
data() { data() {
return { return {
showYearInReview: false, showYearInReview: false,
availableYears: [],
yearInReviewYear: 0, yearInReviewYear: 0,
yearInReviewVariant: 0, yearInReviewVariant: 0,
yearInReviewServerVariant: 0, yearInReviewServerVariant: 0,
@ -100,6 +106,9 @@ export default {
computed: { computed: {
isAdminOrUp() { isAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp'] return this.$store.getters['user/getIsAdminOrUp']
},
user() {
return this.$store.state.user.user
} }
}, },
methods: { methods: {
@ -112,25 +121,57 @@ export default {
shareYearInReviewShort() { shareYearInReviewShort() {
this.$refs.yearInReviewShort.share() this.$refs.yearInReviewShort.share()
}, },
yearInReviewYearChanged() {
this.$nextTick(() => {
this.refreshYearInReview()
this.refreshYearInReviewServer()
})
},
refreshYearInReviewServer() { refreshYearInReviewServer() {
this.$refs.yearInReviewServer.refresh() if (this.$refs.yearInReviewServer != null) {
this.$refs.yearInReviewServer.refresh()
}
}, },
refreshYearInReview() { refreshYearInReview() {
this.$refs.yearInReview.refresh() if (this.$refs.yearInReview != null && this.$refs.yearInReviewShort != null) {
this.$refs.yearInReviewShort.refresh() this.$refs.yearInReview.refresh()
this.$refs.yearInReviewShort.refresh()
}
}, },
clickShowYearInReview() { clickShowYearInReview() {
this.showYearInReview = !this.showYearInReview this.showYearInReview = !this.showYearInReview
},
getAvailableYears() {
if (this.user) {
const oldestDate = this.user.createdAt
if (oldestDate) {
const date = new Date(oldestDate)
const oldestYear = date.getFullYear()
const currentYear = new Date().getFullYear()
const years = []
for (let year = currentYear; year >= oldestYear; year--) {
years.push({ value: year, text: year.toString() })
}
return years
}
}
// Fallback on error
return [{ value: this.yearInReviewYear, text: this.yearInReviewYear.toString() }]
} }
}, },
beforeMount() { beforeMount() {
this.yearInReviewYear = new Date().getFullYear() this.yearInReviewYear = new Date().getFullYear()
// When not December show previous year // When not December show previous year
if (new Date().getMonth() < 11) { if (new Date().getMonth() < 11) {
this.yearInReviewYear-- this.yearInReviewYear--
} }
}, },
mounted() { mounted() {
this.availableYears = this.getAvailableYears()
if (typeof navigator.share !== 'undefined' && navigator.share) { if (typeof navigator.share !== 'undefined' && navigator.share) {
this.showShareButton = true this.showShareButton = true
} else { } else {

View File

@ -1,9 +1,9 @@
<template> <template>
<div> <div>
<div v-if="processing" class="max-w-[800px] h-80 md:h-[800px] mx-auto flex items-center justify-center"> <div v-if="processing" role="img" :aria-label="$strings.MessageLoading" class="max-w-[800px] h-80 md:h-[800px] mx-auto flex items-center justify-center">
<widgets-loading-spinner /> <widgets-loading-spinner />
</div> </div>
<img v-else-if="dataUrl" :src="dataUrl" class="mx-auto" /> <img v-else-if="dataUrl" :src="dataUrl" class="mx-auto" :aria-label="$getString('LabelServerYearReview', [variant + 1])" />
</div> </div>
</template> </template>

View File

@ -218,7 +218,6 @@ export default {
this.$toast.success(this.$strings.ToastPlaylistRemoveSuccess) this.$toast.success(this.$strings.ToastPlaylistRemoveSuccess)
} else { } else {
console.log(`Item removed from playlist`, updatedPlaylist) console.log(`Item removed from playlist`, updatedPlaylist)
this.$toast.success(this.$strings.ToastPlaylistUpdateSuccess)
} }
}) })
.catch((error) => { .catch((error) => {

View File

@ -1,7 +1,7 @@
<template> <template>
<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-label="$strings.LabelMore" aria-haspopup="menu" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
<span class="material-symbols text-2xl" :class="iconClass">&#xe5d4;</span> <span class="material-symbols text-2xl" :class="iconClass">&#xe5d4;</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">
@ -10,12 +10,12 @@
</slot> </slot>
<transition name="menu"> <transition name="menu">
<div v-show="showMenu" ref="menuWrapper" class="absolute right-0 mt-1 z-10 bg-bg border border-black-200 shadow-lg rounded-md py-1 focus:outline-none sm:text-sm" :style="{ width: menuWidth + 'px' }"> <div v-show="showMenu" ref="menuWrapper" role="menu" class="absolute right-0 mt-1 z-10 bg-bg border border-black-200 shadow-lg rounded-md py-1 focus:outline-none sm:text-sm" :style="{ width: menuWidth + 'px' }">
<template v-for="(item, index) in items"> <template v-for="(item, index) in items">
<template v-if="item.subitems"> <template v-if="item.subitems">
<div :key="index" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-default" :class="{ 'bg-white/5': mouseoverItemIndex == index }" @mouseover="mouseoverItem(index)" @mouseleave="mouseleaveItem(index)" @click.stop> <button :key="index" role="menuitem" aria-haspopup="menu" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-default w-full" :class="{ 'bg-white/5': mouseoverItemIndex == index }" @mouseover="mouseoverItem(index)" @mouseleave="mouseleaveItem(index)" @click.stop>
<p>{{ item.text }}</p> <p>{{ item.text }}</p>
</div> </button>
<div <div
v-if="mouseoverItemIndex === index" v-if="mouseoverItemIndex === index"
:key="`subitems-${index}`" :key="`subitems-${index}`"
@ -25,14 +25,14 @@
:class="openSubMenuLeft ? 'rounded-l-md' : 'rounded-r-md'" :class="openSubMenuLeft ? 'rounded-l-md' : 'rounded-r-md'"
:style="{ left: submenuLeftPos + 'px', top: index * 28 + 'px', width: submenuWidth + 'px' }" :style="{ left: submenuLeftPos + 'px', top: index * 28 + 'px', width: submenuWidth + 'px' }"
> >
<div v-for="(subitem, subitemindex) in item.subitems" :key="`subitem-${subitemindex}`" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer" @click.stop="clickAction(subitem.action, subitem.data)"> <button v-for="(subitem, subitemindex) in item.subitems" :key="`subitem-${subitemindex}`" role="menuitem" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer w-full" @click.stop="clickAction(subitem.action, subitem.data)">
<p>{{ subitem.text }}</p> <p>{{ subitem.text }}</p>
</div> </button>
</div> </div>
</template> </template>
<div v-else :key="index" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer" @click.stop="clickAction(item.action)"> <button v-else :key="index" role="menuitem" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer w-full" @click.stop="clickAction(item.action)">
<p class="text-left">{{ item.text }}</p> <p class="text-left">{{ item.text }}</p>
</div> </button>
</template> </template>
</div> </div>
</transition> </transition>

View File

@ -1,7 +1,7 @@
<template> <template>
<div class="relative w-full" v-click-outside="clickOutsideObj"> <div class="relative w-full" v-click-outside="clickOutsideObj">
<p v-if="label" class="text-sm font-semibold px-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p> <p v-if="label" class="text-sm font-semibold px-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
<button type="button" :aria-label="longLabel" :disabled="disabled" class="relative w-full border rounded shadow-sm pl-3 pr-8 py-2 text-left sm:text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu"> <button type="button" :aria-label="longLabel" :disabled="disabled" class="relative w-full border rounded shadow-sm pl-3 pr-8 py-2 text-left sm:text-sm" :class="buttonClass" aria-haspopup="menu" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
<span class="flex items-center"> <span class="flex items-center">
<span class="block truncate font-sans" :class="{ 'font-semibold': selectedSubtext, 'text-sm': small }">{{ selectedText }}</span> <span class="block truncate font-sans" :class="{ 'font-semibold': selectedSubtext, 'text-sm': small }">{{ selectedText }}</span>
<span v-if="selectedSubtext">:&nbsp;</span> <span v-if="selectedSubtext">:&nbsp;</span>
@ -13,9 +13,9 @@
</button> </button>
<transition name="menu"> <transition name="menu">
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto sm:text-sm" tabindex="-1" role="listbox" :style="{ maxHeight: menuMaxHeight }"> <ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto sm:text-sm" tabindex="-1" role="menu" :style="{ maxHeight: menuMaxHeight }">
<template v-for="item in itemsToShow"> <template v-for="item in itemsToShow">
<li :key="item.value" class="text-gray-100 relative py-2 cursor-pointer hover:bg-black-400" :id="'listbox-option-' + item.value" role="option" tabindex="0" @keyup.enter="clickedOption(item.value)" @click="clickedOption(item.value)"> <li :key="item.value" class="text-gray-100 relative py-2 cursor-pointer hover:bg-black-400" role="menuitem" tabindex="0" @keyup.enter="clickedOption(item.value)" @click="clickedOption(item.value)">
<div class="flex items-center"> <div class="flex items-center">
<span class="ml-3 block truncate font-sans text-sm" :class="{ 'font-semibold': item.subtext }">{{ item.text }}</span> <span class="ml-3 block truncate font-sans text-sm" :class="{ 'font-semibold': item.subtext }">{{ item.text }}</span>
<span v-if="item.subtext">:&nbsp;</span> <span v-if="item.subtext">:&nbsp;</span>
@ -119,4 +119,4 @@ export default {
}, },
mounted() {} mounted() {}
} }
</script> </script>

View File

@ -1,5 +1,5 @@
<template> <template>
<button class="icon-btn rounded-md flex items-center justify-center relative" @mousedown.prevent :disabled="disabled || loading" :class="className" @click="clickBtn"> <button :aria-label="ariaLabel" class="icon-btn rounded-md flex items-center justify-center relative" @mousedown.prevent :disabled="disabled || loading" :class="className" @click="clickBtn">
<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"> <svg 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" />
@ -28,7 +28,8 @@ export default {
size: { size: {
type: Number, type: Number,
default: 9 default: 9
} },
ariaLabel: String
}, },
data() { data() {
return {} return {}

View File

@ -4,7 +4,7 @@
type="button" type="button"
:disabled="disabled" :disabled="disabled"
class="w-10 sm:w-full relative h-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm px-2 text-left text-sm cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200" class="w-10 sm:w-full relative h-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm px-2 text-left text-sm cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200"
aria-haspopup="listbox" aria-haspopup="menu"
:aria-expanded="showMenu" :aria-expanded="showMenu"
:aria-label="$strings.ButtonLibrary + ': ' + currentLibrary.name" :aria-label="$strings.ButtonLibrary + ': ' + currentLibrary.name"
@click.stop.prevent="clickShowMenu" @click.stop.prevent="clickShowMenu"
@ -16,9 +16,9 @@
</button> </button>
<transition name="menu"> <transition name="menu">
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full min-w-48 bg-primary border border-black-200 shadow-lg rounded-b-md py-1 overflow-auto focus:outline-none sm:text-sm librariesDropdownMenu" tabindex="-1" role="listbox"> <ul v-show="showMenu" class="absolute z-10 -mt-px w-full min-w-48 bg-primary border border-black-200 shadow-lg rounded-b-md py-1 overflow-auto focus:outline-none sm:text-sm librariesDropdownMenu" tabindex="-1" role="menu">
<template v-for="library in librariesFiltered"> <template v-for="library in librariesFiltered">
<li :key="library.id" class="text-gray-400 hover:text-white relative py-2 cursor-pointer hover:bg-black-400" role="option" tabindex="0" @keydown.enter="selectLibrary(library)" @click="selectLibrary(library)"> <li :key="library.id" class="text-gray-400 hover:text-white relative py-2 cursor-pointer hover:bg-black-400" role="menuitem" tabindex="0" @keydown.enter="selectLibrary(library)" @click="selectLibrary(library)">
<div class="flex items-center px-2"> <div class="flex items-center px-2">
<ui-library-icon :icon="library.icon" class="mr-1.5" /> <ui-library-icon :icon="library.icon" class="mr-1.5" />
<span class="font-normal block truncate font-sans text-sm">{{ library.name }}</span> <span class="font-normal block truncate font-sans text-sm">{{ library.name }}</span>

View File

@ -1,17 +1,17 @@
<template> <template>
<div class="w-full"> <div class="w-full">
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p> <label :for="identifier" class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</label>
<div ref="wrapper" class="relative"> <div ref="wrapper" class="relative">
<form @submit.prevent="submitForm"> <form @submit.prevent="submitForm">
<div ref="inputWrapper" style="min-height: 36px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-1" :class="wrapperClass" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent> <div ref="inputWrapper" role="list" style="min-height: 36px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-1" :class="wrapperClass" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
<div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 mx-0.5 my-0.5 text-xs bg-bg flex flex-nowrap break-all items-center relative"> <div v-for="item in selected" :key="item" role="listitem" class="rounded-full px-2 py-1 mx-0.5 my-0.5 text-xs bg-bg flex flex-nowrap break-all items-center relative">
<div v-if="!disabled" class="w-full h-full rounded-full absolute top-0 left-0 px-1 bg-bg bg-opacity-75 flex items-center justify-end opacity-0 hover:opacity-100"> <div v-if="!disabled" class="w-full h-full rounded-full absolute top-0 left-0 px-1 bg-bg bg-opacity-75 flex items-center justify-end opacity-0 hover:opacity-100" :class="{ 'opacity-100': inputFocused }">
<span v-if="showEdit" class="material-symbols text-white hover:text-warning cursor-pointer" style="font-size: 1.1rem" @click.stop="editItem(item)">edit</span> <button v-if="showEdit" type="button" :aria-label="$strings.ButtonEdit" class="material-symbols text-white hover:text-warning cursor-pointer" style="font-size: 1.1rem" @click.stop="editItem(item)">edit</button>
<span class="material-symbols text-white hover:text-error cursor-pointer" style="font-size: 1.1rem" @click.stop="removeItem(item)">close</span> <button type="button" :aria-label="$strings.ButtonRemove" class="material-symbols text-white hover:text-error focus:text-error cursor-pointer" style="font-size: 1.1rem" @click.stop="removeItem(item)" @keydown.enter.stop.prevent="removeItem(item)" @focus="setInputFocused(true)" @blur="setInputFocused(false)" tabindex="0">close</button>
</div> </div>
{{ item }} {{ item }}
</div> </div>
<input v-show="!readonly" ref="input" v-model="textInput" :disabled="disabled" class="h-full bg-primary focus:outline-none px-1 w-6" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" @paste="inputPaste" /> <input v-show="!readonly" v-model="textInput" ref="input" :id="identifier" :disabled="disabled" class="h-full bg-primary focus:outline-none px-1 w-6" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" @paste="inputPaste" />
</div> </div>
</form> </form>
@ -66,7 +66,8 @@ export default {
typingTimeout: null, typingTimeout: null,
isFocused: false, isFocused: false,
menu: null, menu: null,
filteredItems: null filteredItems: null,
inputFocused: false
} }
}, },
watch: { watch: {
@ -100,6 +101,9 @@ export default {
} }
return this.filteredItems return this.filteredItems
},
identifier() {
return Math.random().toString(36).substring(2)
} }
}, },
methods: { methods: {
@ -129,6 +133,9 @@ export default {
}, 100) }, 100)
this.setInputWidth() this.setInputWidth()
}, },
setInputFocused(focused) {
this.inputFocused = focused
},
setInputWidth() { setInputWidth() {
setTimeout(() => { setTimeout(() => {
var value = this.$refs.input.value var value = this.$refs.input.value

View File

@ -1,20 +1,20 @@
<template> <template>
<div class="w-full"> <div class="w-full">
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p> <label :for="identifier" class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</label>
<div ref="wrapper" class="relative"> <div ref="wrapper" class="relative">
<form @submit.prevent="submitForm"> <form @submit.prevent="submitForm">
<div ref="inputWrapper" style="min-height: 36px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-0.5" :class="wrapperClass" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent> <div ref="inputWrapper" role="list" style="min-height: 36px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-0.5" :class="wrapperClass" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
<div v-for="item in selected" :key="item.id" class="rounded-full px-2 py-0.5 m-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center justify-center relative min-w-12"> <div v-for="item in selected" :key="item.id" role="listitem" class="rounded-full px-2 py-0.5 m-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center justify-center relative min-w-12">
<div v-if="!disabled" class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg bg-opacity-75 flex items-center justify-end cursor-pointer"> <div v-if="!disabled" class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg bg-opacity-75 flex items-center justify-end cursor-pointer" :class="{ 'opacity-100': inputFocused }">
<span v-if="showEdit" class="material-symbols text-base text-white hover:text-warning mr-1" @click.stop="editItem(item)">edit</span> <button v-if="showEdit" type="button" :aria-label="$strings.ButtonEdit" class="material-symbols text-base text-white hover:text-warning focus:text-warning mr-1" @click.stop="editItem(item)" @keydown.enter.stop.prevent="editItem(item)" @focus="setInputFocused(true)" @blur="setInputFocused(false)" tabindex="0">edit</button>
<span class="material-symbols text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item.id)">close</span> <button type="button" :aria-label="$strings.ButtonRemove" class="material-symbols text-white hover:text-error focus:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item.id)" @keydown.enter.stop="removeItem(item.id)" @focus="setInputFocused(true)" @blur="setInputFocused(false)" tabindex="0">close</button>
</div> </div>
{{ item[textKey] }} {{ item[textKey] }}
</div> </div>
<div v-if="showEdit && !disabled" class="rounded-full cursor-pointer w-6 h-6 mx-0.5 bg-bg flex items-center justify-center"> <div v-if="showEdit && !disabled" class="rounded-full cursor-pointer w-6 h-6 mx-0.5 bg-bg flex items-center justify-center">
<span class="material-symbols text-white hover:text-success pt-px pr-px" style="font-size: 1.1rem" @click.stop="addItem">add</span> <button type="button" :aria-label="$strings.ButtonAdd" class="material-symbols text-white hover:text-success focus:text-success pt-px pr-px" style="font-size: 1.1rem" @click.stop="addItem" @keydown.enter.stop="addItem" tabindex="0">add</button>
</div> </div>
<input v-show="!readonly" ref="input" v-model="textInput" :disabled="disabled" class="h-full bg-primary focus:outline-none px-1 w-6" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" @paste="inputPaste" /> <input v-show="!readonly" v-model="textInput" ref="input" :id="identifier" :disabled="disabled" class="h-full bg-primary focus:outline-none px-1 w-6" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" @paste="inputPaste" />
</div> </div>
</form> </form>
@ -65,6 +65,7 @@ export default {
currentSearch: null, currentSearch: null,
typingTimeout: null, typingTimeout: null,
isFocused: false, isFocused: false,
inputFocused: false,
menu: null, menu: null,
items: [] items: []
} }
@ -102,6 +103,9 @@ export default {
}, },
filterData() { filterData() {
return this.$store.state.libraries.filterData || {} return this.$store.state.libraries.filterData || {}
},
identifier() {
return Math.random().toString(36).substring(2)
} }
}, },
methods: { methods: {
@ -114,6 +118,9 @@ export default {
getIsSelected(itemValue) { getIsSelected(itemValue) {
return !!this.selected.find((i) => i.id === itemValue) return !!this.selected.find((i) => i.id === itemValue)
}, },
setInputFocused(focused) {
this.inputFocused = focused
},
search() { search() {
if (!this.textInput) return if (!this.textInput) return
this.currentSearch = this.textInput this.currentSearch = this.textInput

View File

@ -1,5 +1,5 @@
<template> <template>
<button class="icon-btn rounded-md flex items-center justify-center h-9 w-9 relative" :class="borderless ? '' : 'bg-primary border border-gray-600'" @click="clickBtn"> <button :aria-label="isRead ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" class="icon-btn rounded-md flex items-center justify-center h-9 w-9 relative" :class="borderless ? '' : 'bg-primary border border-gray-600'" @click="clickBtn">
<div class="w-5 h-5 text-white relative"> <div class="w-5 h-5 text-white relative">
<svg v-if="isRead" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="rgb(63, 181, 68)"> <svg v-if="isRead" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="rgb(63, 181, 68)">
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z" /> <path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z" />

View File

@ -1,6 +1,6 @@
<template> <template>
<div> <div>
<button :aria-labelledby="labeledBy" role="checkbox" type="button" class="border rounded-full border-black-100 flex items-center cursor-pointer justify-start" :style="{ width: buttonWidth + 'px' }" :aria-checked="toggleValue" :class="className" @click="clickToggle"> <button :aria-labelledby="labeledBy" :aria-label="label" role="checkbox" type="button" class="border rounded-full border-black-100 flex items-center cursor-pointer justify-start" :style="{ width: buttonWidth + 'px' }" :aria-checked="toggleValue" :class="className" @click="clickToggle">
<span class="rounded-full border border-black-50 shadow transform transition-transform duration-100" :style="{ width: cursorHeightWidth + 'px', height: cursorHeightWidth + 'px' }" :class="switchClassName"></span> <span class="rounded-full border border-black-50 shadow transform transition-transform duration-100" :style="{ width: cursorHeightWidth + 'px', height: cursorHeightWidth + 'px' }" :class="switchClassName"></span>
</button> </button>
</div> </div>
@ -20,6 +20,7 @@ export default {
}, },
disabled: Boolean, disabled: Boolean,
labeledBy: String, labeledBy: String,
label: String,
size: { size: {
type: String, type: String,
default: 'md' default: 'md'

View File

@ -1,6 +1,6 @@
<template> <template>
<div> <div>
<div class="rounded-full py-1 bg-primary px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent> <div aria-hidden="true" class="rounded-full py-1 bg-primary px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent>
<span class="material-symbols" :class="selectedSizeIndex === 0 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="decreaseSize" aria-label="Decrease Cover Size" role="button">&#xe15b;</span> <span class="material-symbols" :class="selectedSizeIndex === 0 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="decreaseSize" aria-label="Decrease Cover Size" role="button">&#xe15b;</span>
<p class="px-2 font-mono" style="font-size: 1rem">{{ bookCoverWidth }}</p> <p class="px-2 font-mono" style="font-size: 1rem">{{ bookCoverWidth }}</p>
<span class="material-symbols" :class="selectedSizeIndex === availableSizes.length - 1 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="increaseSize" aria-label="Increase Cover Size" role="button">&#xe145;</span> <span class="material-symbols" :class="selectedSizeIndex === availableSizes.length - 1 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="increaseSize" aria-label="Increase Cover Size" role="button">&#xe145;</span>

View File

@ -3,10 +3,10 @@
<div class="flex items-center py-3e"> <div class="flex items-center py-3e">
<slot /> <slot />
<div class="flex-grow" /> <div class="flex-grow" />
<button cy-id="leftScrollButton" v-if="isScrollable" class="w-8e h-8e mx-1e flex items-center justify-center rounded-full" :class="canScrollLeft ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollLeft"> <button cy-id="leftScrollButton" v-if="isScrollable" :aria-label="$strings.ButtonScrollLeft" class="w-8e h-8e mx-1e flex items-center justify-center rounded-full" :class="canScrollLeft ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollLeft">
<span class="material-symbols" :style="{ fontSize: 1.5 + 'em' }">chevron_left</span> <span class="material-symbols" :style="{ fontSize: 1.5 + 'em' }">chevron_left</span>
</button> </button>
<button cy-id="rightScrollButton" v-if="isScrollable" class="w-8e h-8e mx-1e flex items-center justify-center rounded-full" :class="canScrollRight ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollRight"> <button cy-id="rightScrollButton" v-if="isScrollable" :aria-label="$strings.ButtonScrollRight" class="w-8e h-8e mx-1e flex items-center justify-center rounded-full" :class="canScrollRight ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollRight">
<span class="material-symbols" :style="{ fontSize: 1.5 + 'em' }">chevron_right</span> <span class="material-symbols" :style="{ fontSize: 1.5 + 'em' }">chevron_right</span>
</button> </button>
</div> </div>

View File

@ -57,9 +57,10 @@ export default {
for (let entry of entries) { for (let entry of entries) {
this.cardWidth = entry.borderBoxSize[0].inlineSize this.cardWidth = entry.borderBoxSize[0].inlineSize
this.cardHeight = entry.borderBoxSize[0].blockSize this.cardHeight = entry.borderBoxSize[0].blockSize
this.resizeObserver.disconnect()
this.$refs.bookshelf.removeChild(instance.$el)
} }
this.coverHeight = instance.coverHeight
this.resizeObserver.disconnect()
this.$refs.bookshelf.removeChild(instance.$el)
}) })
instance.$el.style.visibility = 'hidden' instance.$el.style.visibility = 'hidden'
instance.$el.style.position = 'absolute' instance.$el.style.position = 'absolute'
@ -131,10 +132,7 @@ export default {
this.entityComponentRefs[index] = instance this.entityComponentRefs[index] = instance
instance.$mount() instance.$mount()
const shelfOffsetY = this.shelfPaddingHeight * this.sizeMultiplier instance.$el.style.transform = this.entityTransform((index % this.entitiesPerShelf) + 1)
const row = index % this.entitiesPerShelf
const shelfOffsetX = row * this.totalEntityCardWidth + this.bookshelfMarginLeft
instance.$el.style.transform = `translate3d(${shelfOffsetX}px, ${shelfOffsetY}px, 0px)`
instance.$el.classList.add('absolute', 'top-0', 'left-0') instance.$el.classList.add('absolute', 'top-0', 'left-0')
shelfEl.appendChild(instance.$el) shelfEl.appendChild(instance.$el)

View File

@ -1,12 +1,12 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.17.4", "version": "2.17.7",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.17.4", "version": "2.17.7",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@nuxtjs/axios": "^5.13.6", "@nuxtjs/axios": "^5.13.6",

View File

@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.17.4", "version": "2.17.7",
"buildNumber": 1, "buildNumber": 1,
"description": "Self-hosted audiobook and podcast client", "description": "Self-hosted audiobook and podcast client",
"main": "index.js", "main": "index.js",

View File

@ -6,9 +6,9 @@
<div class="pt-4"> <div class="pt-4">
<h2 class="font-semibold">{{ $strings.HeaderSettingsGeneral }}</h2> <h2 class="font-semibold">{{ $strings.HeaderSettingsGeneral }}</h2>
</div> </div>
<div class="flex items-end py-2"> <div role="article" :aria-label="$strings.LabelSettingsStoreCoversWithItemHelp" class="flex items-end py-2">
<ui-toggle-switch labeledBy="settings-store-cover-with-items" v-model="newServerSettings.storeCoverWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeCoverWithItem', val)" /> <ui-toggle-switch :label="$strings.LabelSettingsStoreCoversWithItem" v-model="newServerSettings.storeCoverWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeCoverWithItem', val)" />
<ui-tooltip :text="$strings.LabelSettingsStoreCoversWithItemHelp"> <ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsStoreCoversWithItemHelp">
<p class="pl-4"> <p class="pl-4">
<span id="settings-store-cover-with-items">{{ $strings.LabelSettingsStoreCoversWithItem }}</span> <span id="settings-store-cover-with-items">{{ $strings.LabelSettingsStoreCoversWithItem }}</span>
<span class="material-symbols icon-text">info</span> <span class="material-symbols icon-text">info</span>
@ -16,9 +16,9 @@
</ui-tooltip> </ui-tooltip>
</div> </div>
<div class="flex items-center py-2"> <div role="article" :aria-label="$strings.LabelSettingsStoreMetadataWithItemHelp" class="flex items-center py-2">
<ui-toggle-switch labeledBy="settings-store-metadata-with-items" v-model="newServerSettings.storeMetadataWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeMetadataWithItem', val)" /> <ui-toggle-switch :label="$strings.LabelSettingsStoreMetadataWithItem" v-model="newServerSettings.storeMetadataWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeMetadataWithItem', val)" />
<ui-tooltip :text="$strings.LabelSettingsStoreMetadataWithItemHelp"> <ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsStoreMetadataWithItemHelp">
<p class="pl-4"> <p class="pl-4">
<span id="settings-store-metadata-with-items">{{ $strings.LabelSettingsStoreMetadataWithItem }}</span> <span id="settings-store-metadata-with-items">{{ $strings.LabelSettingsStoreMetadataWithItem }}</span>
<span class="material-symbols icon-text">info</span> <span class="material-symbols icon-text">info</span>
@ -26,9 +26,9 @@
</ui-tooltip> </ui-tooltip>
</div> </div>
<div class="flex items-center py-2"> <div role="article" :aria-label="$strings.LabelSettingsSortingIgnorePrefixesHelp" class="flex items-center py-2">
<ui-toggle-switch labeledBy="settings-sorting-ignore-prefixes" v-model="newServerSettings.sortingIgnorePrefix" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('sortingIgnorePrefix', val)" /> <ui-toggle-switch :label="$strings.LabelSettingsSortingIgnorePrefixes" v-model="newServerSettings.sortingIgnorePrefix" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('sortingIgnorePrefix', val)" />
<ui-tooltip :text="$strings.LabelSettingsSortingIgnorePrefixesHelp"> <ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsSortingIgnorePrefixesHelp">
<p class="pl-4"> <p class="pl-4">
<span id="settings-sorting-ignore-prefixes">{{ $strings.LabelSettingsSortingIgnorePrefixes }}</span> <span id="settings-sorting-ignore-prefixes">{{ $strings.LabelSettingsSortingIgnorePrefixes }}</span>
<span class="material-symbols icon-text">info</span> <span class="material-symbols icon-text">info</span>
@ -42,18 +42,13 @@
</div> </div>
</div> </div>
<div class="flex items-center py-2 mb-2">
<ui-toggle-switch labeledBy="settings-chromecast-support" v-model="newServerSettings.chromecastEnabled" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('chromecastEnabled', val)" />
<p class="pl-4" id="settings-chromecast-support">{{ $strings.LabelSettingsChromecastSupport }}</p>
</div>
<div class="pt-4"> <div class="pt-4">
<h2 class="font-semibold">{{ $strings.HeaderSettingsScanner }}</h2> <h2 class="font-semibold">{{ $strings.HeaderSettingsScanner }}</h2>
</div> </div>
<div class="flex items-center py-2"> <div role="article" :aria-label="$strings.LabelSettingsParseSubtitlesHelp" class="flex items-center py-2">
<ui-toggle-switch labeledBy="settings-parse-subtitles" v-model="newServerSettings.scannerParseSubtitle" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerParseSubtitle', val)" /> <ui-toggle-switch :label="$strings.LabelSettingsParseSubtitles" v-model="newServerSettings.scannerParseSubtitle" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerParseSubtitle', val)" />
<ui-tooltip :text="$strings.LabelSettingsParseSubtitlesHelp"> <ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsParseSubtitlesHelp">
<p class="pl-4"> <p class="pl-4">
<span id="settings-parse-subtitles">{{ $strings.LabelSettingsParseSubtitles }}</span> <span id="settings-parse-subtitles">{{ $strings.LabelSettingsParseSubtitles }}</span>
<span class="material-symbols icon-text">info</span> <span class="material-symbols icon-text">info</span>
@ -61,9 +56,9 @@
</ui-tooltip> </ui-tooltip>
</div> </div>
<div class="flex items-center py-2"> <div role="article" :aria-label="$strings.LabelSettingsFindCoversHelp" class="flex items-center py-2">
<ui-toggle-switch labeledBy="settings-find-covers" v-model="newServerSettings.scannerFindCovers" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerFindCovers', val)" /> <ui-toggle-switch :label="$strings.LabelSettingsFindCovers" v-model="newServerSettings.scannerFindCovers" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerFindCovers', val)" />
<ui-tooltip :text="$strings.LabelSettingsFindCoversHelp"> <ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsFindCoversHelp">
<p class="pl-4"> <p class="pl-4">
<span id="settings-find-covers">{{ $strings.LabelSettingsFindCovers }}</span> <span id="settings-find-covers">{{ $strings.LabelSettingsFindCovers }}</span>
<span class="material-symbols icon-text">info</span> <span class="material-symbols icon-text">info</span>
@ -75,9 +70,9 @@
<ui-dropdown v-model="newServerSettings.scannerCoverProvider" small :items="providers" label="Cover Provider" @input="updateScannerCoverProvider" :disabled="updatingServerSettings" /> <ui-dropdown v-model="newServerSettings.scannerCoverProvider" small :items="providers" label="Cover Provider" @input="updateScannerCoverProvider" :disabled="updatingServerSettings" />
</div> </div>
<div class="flex items-center py-2"> <div role="article" :aria-label="$strings.LabelSettingsPreferMatchedMetadataHelp" class="flex items-center py-2">
<ui-toggle-switch labeledBy="settings-prefer-matched-metadata" v-model="newServerSettings.scannerPreferMatchedMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferMatchedMetadata', val)" /> <ui-toggle-switch :label="$strings.LabelSettingsPreferMatchedMetadata" v-model="newServerSettings.scannerPreferMatchedMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferMatchedMetadata', val)" />
<ui-tooltip :text="$strings.LabelSettingsPreferMatchedMetadataHelp"> <ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsPreferMatchedMetadataHelp">
<p class="pl-4"> <p class="pl-4">
<span id="settings-prefer-matched-metadata">{{ $strings.LabelSettingsPreferMatchedMetadata }}</span> <span id="settings-prefer-matched-metadata">{{ $strings.LabelSettingsPreferMatchedMetadata }}</span>
<span class="material-symbols icon-text">info</span> <span class="material-symbols icon-text">info</span>
@ -85,15 +80,29 @@
</ui-tooltip> </ui-tooltip>
</div> </div>
<div class="flex items-center py-2"> <div role="article" :aria-label="$strings.LabelSettingsEnableWatcherHelp" class="flex items-center py-2">
<ui-toggle-switch labeledBy="settings-disable-watcher" v-model="scannerEnableWatcher" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerDisableWatcher', !val)" /> <ui-toggle-switch :label="$strings.LabelSettingsEnableWatcher" v-model="scannerEnableWatcher" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerDisableWatcher', !val)" />
<ui-tooltip :text="$strings.LabelSettingsEnableWatcherHelp"> <ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsEnableWatcherHelp">
<p class="pl-4"> <p class="pl-4">
<span id="settings-disable-watcher">{{ $strings.LabelSettingsEnableWatcher }}</span> <span id="settings-disable-watcher">{{ $strings.LabelSettingsEnableWatcher }}</span>
<span class="material-symbols icon-text">info</span> <span class="material-symbols icon-text">info</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> </div>
<div class="pt-4">
<h2 class="font-semibold">{{ $strings.HeaderSettingsWebClient }}</h2>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.chromecastEnabled" :label="$strings.LabelSettingsChromecastSupport" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('chromecastEnabled', val)" />
<p aria-hidden="true" class="pl-4">{{ $strings.LabelSettingsChromecastSupport }}</p>
</div>
<div class="flex items-center py-2 mb-2">
<ui-toggle-switch v-model="newServerSettings.allowIframe" :label="$strings.LabelSettingsAllowIframe" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('allowIframe', val)" />
<p aria-hidden="true" class="pl-4">{{ $strings.LabelSettingsAllowIframe }}</p>
</div>
</div> </div>
<div class="flex-1"> <div class="flex-1">
@ -324,21 +333,21 @@ export default {
}, },
updateServerSettings(payload) { updateServerSettings(payload) {
this.updatingServerSettings = true this.updatingServerSettings = true
this.$store this.$store.dispatch('updateServerSettings', payload).then((response) => {
.dispatch('updateServerSettings', payload) this.updatingServerSettings = false
.then(() => {
this.updatingServerSettings = false
if (payload.language) { if (response.error) {
// Updating language after save allows for re-rendering console.error('Failed to update server settins', response.error)
this.$setLanguageCode(payload.language) this.$toast.error(response.error)
} this.initServerSettings()
}) return
.catch((error) => { }
console.error('Failed to update server settings', error)
this.updatingServerSettings = false if (payload.language) {
this.$toast.error(this.$strings.ToastFailedToUpdate) // Updating language after save allows for re-rendering
}) this.$setLanguageCode(payload.language)
}
})
}, },
initServerSettings() { initServerSettings() {
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {} this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}

View File

@ -25,7 +25,7 @@
<tr v-for="feed in feeds" :key="feed.id" class="cursor-pointer h-12" @click="showFeed(feed)"> <tr v-for="feed in feeds" :key="feed.id" class="cursor-pointer h-12" @click="showFeed(feed)">
<!-- --> <!-- -->
<td> <td>
<img :src="coverUrl(feed)" class="h-full w-full" /> <img :src="coverUrl(feed)" class="h-auto w-full" />
</td> </td>
<!-- --> <!-- -->
<td class="w-48 max-w-64 min-w-24 text-left truncate"> <td class="w-48 max-w-64 min-w-24 text-left truncate">
@ -126,7 +126,7 @@ export default {
}, },
coverUrl(feed) { coverUrl(feed) {
if (!feed.coverPath) return `${this.$config.routerBasePath}/Logo.png` if (!feed.coverPath) return `${this.$config.routerBasePath}/Logo.png`
return `${feed.feedUrl}/cover` return `${this.$config.routerBasePath}${feed.feedUrl}/cover`
}, },
async loadFeeds() { async loadFeeds() {
const data = await this.$axios.$get(`/api/feeds`).catch((err) => { const data = await this.$axios.$get(`/api/feeds`).catch((err) => {

View File

@ -12,12 +12,12 @@
<!-- Item Cover Overlay --> <!-- Item Cover Overlay -->
<div class="absolute top-0 left-0 w-full h-full z-10 opacity-0 group-hover:opacity-100 pointer-events-none"> <div class="absolute top-0 left-0 w-full h-full z-10 opacity-0 group-hover:opacity-100 pointer-events-none">
<div v-show="showPlayButton && !isStreaming" class="h-full flex items-center justify-center pointer-events-none"> <div v-show="showPlayButton && !isStreaming" class="h-full flex items-center justify-center pointer-events-none">
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto cursor-pointer" @click.stop.prevent="playItem"> <button class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto cursor-pointer" :aria-label="$strings.ButtonPlay" @click.stop.prevent="playItem">
<span class="material-symbols fill text-4xl">play_arrow</span> <span class="material-symbols fill text-4xl">play_arrow</span>
</div> </button>
</div> </div>
<span class="absolute bottom-2.5 right-2.5 z-10 material-symbols text-lg cursor-pointer text-white text-opacity-75 hover:text-opacity-100 hover:scale-110 transform duration-200 pointer-events-auto" @click="showEditCover">edit</span> <button class="absolute bottom-2.5 right-2.5 z-10 material-symbols text-lg cursor-pointer text-white text-opacity-75 hover:text-opacity-100 hover:scale-110 transform duration-200 pointer-events-auto" :aria-label="$strings.ButtonEdit" @click="showEditCover">edit</button>
</div> </div>
</div> </div>
</div> </div>
@ -87,7 +87,7 @@
</ui-btn> </ui-btn>
<ui-btn v-else-if="isMissing || isInvalid" color="error" :padding-x="4" small class="flex items-center h-9 mr-2"> <ui-btn v-else-if="isMissing || isInvalid" color="error" :padding-x="4" small class="flex items-center h-9 mr-2">
<span v-show="!isStreaming" class="material-symbols text-2xl -ml-2 pr-1 text-white">error</span> <span class="material-symbols text-2xl -ml-2 pr-1 text-white">error</span>
{{ isMissing ? $strings.LabelMissing : $strings.LabelIncomplete }} {{ isMissing ? $strings.LabelMissing : $strings.LabelIncomplete }}
</ui-btn> </ui-btn>
@ -96,12 +96,12 @@
</ui-tooltip> </ui-tooltip>
<ui-btn v-if="showReadButton" color="info" :padding-x="4" small class="flex items-center h-9 mr-2" @click="openEbook"> <ui-btn v-if="showReadButton" color="info" :padding-x="4" small class="flex items-center h-9 mr-2" @click="openEbook">
<span class="material-symbols text-2xl -ml-2 pr-2 text-white">auto_stories</span> <span class="material-symbols text-2xl -ml-2 pr-2 text-white" aria-hidden="true">auto_stories</span>
{{ $strings.ButtonRead }} {{ $strings.ButtonRead }}
</ui-btn> </ui-btn>
<ui-tooltip v-if="userCanUpdate" :text="$strings.LabelEdit" direction="top"> <ui-tooltip v-if="userCanUpdate" :text="$strings.LabelEdit" direction="top">
<ui-icon-btn icon="&#xe3c9;" outlined class="mx-0.5" @click="editClick" /> <ui-icon-btn icon="&#xe3c9;" outlined class="mx-0.5" :aria-label="$strings.LabelEdit" @click="editClick" />
</ui-tooltip> </ui-tooltip>
<ui-tooltip v-if="!isPodcast" :text="userIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="top"> <ui-tooltip v-if="!isPodcast" :text="userIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="top">
@ -110,12 +110,12 @@
<!-- Only admin or root user can download new episodes --> <!-- Only admin or root user can download new episodes -->
<ui-tooltip v-if="isPodcast && userIsAdminOrUp" :text="$strings.LabelFindEpisodes" direction="top"> <ui-tooltip v-if="isPodcast && userIsAdminOrUp" :text="$strings.LabelFindEpisodes" direction="top">
<ui-icon-btn icon="search" class="mx-0.5" :loading="fetchingRSSFeed" outlined @click="findEpisodesClick" /> <ui-icon-btn icon="search" class="mx-0.5" :aria-label="$strings.LabelFindEpisodes" :loading="fetchingRSSFeed" outlined @click="findEpisodesClick" />
</ui-tooltip> </ui-tooltip>
<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" :aria-label="$strings.LabelMore" @click.stop.prevent="clickShowMenu">
<span class="material-symbols text-2xl">&#xe5d3;</span> <span class="material-symbols text-2xl">&#xe5d3;</span>
</button> </button>
</template> </template>

View File

@ -12,6 +12,10 @@
<div class="w-full pt-16"> <div class="w-full pt-16">
<player-ui ref="audioPlayer" :chapters="chapters" :current-chapter="currentChapter" :paused="isPaused" :loading="!hasLoaded" :is-podcast="false" hide-bookmarks hide-sleep-timer @playPause="playPause" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setVolume="setVolume" @setPlaybackRate="setPlaybackRate" @seek="seek" /> <player-ui ref="audioPlayer" :chapters="chapters" :current-chapter="currentChapter" :paused="isPaused" :loading="!hasLoaded" :is-podcast="false" hide-bookmarks hide-sleep-timer @playPause="playPause" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setVolume="setVolume" @setPlaybackRate="setPlaybackRate" @seek="seek" />
</div> </div>
<ui-tooltip v-if="mediaItemShare.isDownloadable" direction="bottom" :text="$strings.LabelDownload" class="absolute top-0 left-0 m-4">
<button aria-label="Download" class="text-gray-300 hover:text-white" @click="downloadShareItem"><span class="material-symbols text-2xl sm:text-3xl">download</span></button>
</ui-tooltip>
</div> </div>
</div> </div>
</div> </div>
@ -63,6 +67,9 @@ export default {
if (!this.playbackSession.coverPath) return `${this.$config.routerBasePath}/book_placeholder.jpg` if (!this.playbackSession.coverPath) return `${this.$config.routerBasePath}/book_placeholder.jpg`
return `${this.$config.routerBasePath}/public/share/${this.mediaItemShare.slug}/cover` return `${this.$config.routerBasePath}/public/share/${this.mediaItemShare.slug}/cover`
}, },
downloadUrl() {
return `${process.env.serverUrl}/public/share/${this.mediaItemShare.slug}/download`
},
audioTracks() { audioTracks() {
return (this.playbackSession.audioTracks || []).map((track) => { return (this.playbackSession.audioTracks || []).map((track) => {
track.relativeContentUrl = track.contentUrl track.relativeContentUrl = track.contentUrl
@ -103,6 +110,84 @@ export default {
} }
}, },
methods: { methods: {
mediaSessionPlay() {
console.log('Media session play')
this.play()
},
mediaSessionPause() {
console.log('Media session pause')
this.pause()
},
mediaSessionStop() {
console.log('Media session stop')
this.pause()
},
mediaSessionSeekBackward() {
console.log('Media session seek backward')
this.jumpBackward()
},
mediaSessionSeekForward() {
console.log('Media session seek forward')
this.jumpForward()
},
mediaSessionSeekTo(e) {
console.log('Media session seek to', e)
if (e.seekTime !== null && !isNaN(e.seekTime)) {
this.seek(e.seekTime)
}
},
mediaSessionPreviousTrack() {
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.prevChapter()
}
},
mediaSessionNextTrack() {
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.nextChapter()
}
},
updateMediaSessionPlaybackState() {
if ('mediaSession' in navigator) {
navigator.mediaSession.playbackState = this.isPlaying ? 'playing' : 'paused'
}
},
setMediaSession() {
// https://developer.mozilla.org/en-US/docs/Web/API/Media_Session_API
if ('mediaSession' in navigator) {
const chapterInfo = []
if (this.chapters.length > 0) {
this.chapters.forEach((chapter) => {
chapterInfo.push({
title: chapter.title,
startTime: chapter.start
})
})
}
navigator.mediaSession.metadata = new MediaMetadata({
title: this.mediaItemShare.playbackSession.displayTitle || 'No title',
artist: this.mediaItemShare.playbackSession.displayAuthor || 'Unknown',
artwork: [
{
src: this.coverUrl
}
],
chapterInfo
})
console.log('Set media session metadata', navigator.mediaSession.metadata)
navigator.mediaSession.setActionHandler('play', this.mediaSessionPlay)
navigator.mediaSession.setActionHandler('pause', this.mediaSessionPause)
navigator.mediaSession.setActionHandler('stop', this.mediaSessionStop)
navigator.mediaSession.setActionHandler('seekbackward', this.mediaSessionSeekBackward)
navigator.mediaSession.setActionHandler('seekforward', this.mediaSessionSeekForward)
navigator.mediaSession.setActionHandler('seekto', this.mediaSessionSeekTo)
navigator.mediaSession.setActionHandler('previoustrack', this.mediaSessionSeekBackward)
navigator.mediaSession.setActionHandler('nexttrack', this.mediaSessionSeekForward)
} else {
console.warn('Media session not available')
}
},
async coverImageLoaded(e) { async coverImageLoaded(e) {
if (!this.playbackSession.coverPath) return if (!this.playbackSession.coverPath) return
const fac = new FastAverageColor() const fac = new FastAverageColor()
@ -119,8 +204,19 @@ export default {
}) })
}, },
playPause() { playPause() {
if (this.isPlaying) {
this.pause()
} else {
this.play()
}
},
play() {
if (!this.localAudioPlayer || !this.hasLoaded) return if (!this.localAudioPlayer || !this.hasLoaded) return
this.localAudioPlayer.playPause() this.localAudioPlayer.play()
},
pause() {
if (!this.localAudioPlayer || !this.hasLoaded) return
this.localAudioPlayer.pause()
}, },
jumpForward() { jumpForward() {
if (!this.localAudioPlayer || !this.hasLoaded) return if (!this.localAudioPlayer || !this.hasLoaded) return
@ -206,6 +302,7 @@ export default {
} else { } else {
this.stopPlayInterval() this.stopPlayInterval()
} }
this.updateMediaSessionPlaybackState()
}, },
playerTimeUpdate(time) { playerTimeUpdate(time) {
this.setCurrentTime(time) this.setCurrentTime(time)
@ -247,6 +344,9 @@ export default {
}, },
playerFinished() { playerFinished() {
console.log('Player finished') console.log('Player finished')
},
downloadShareItem() {
this.$downloadFile(this.downloadUrl)
} }
}, },
mounted() { mounted() {
@ -266,6 +366,8 @@ export default {
this.localAudioPlayer.on('timeupdate', this.playerTimeUpdate.bind(this)) this.localAudioPlayer.on('timeupdate', this.playerTimeUpdate.bind(this))
this.localAudioPlayer.on('error', this.playerError.bind(this)) this.localAudioPlayer.on('error', this.playerError.bind(this))
this.localAudioPlayer.on('finished', this.playerFinished.bind(this)) this.localAudioPlayer.on('finished', this.playerFinished.bind(this))
this.setMediaSession()
}, },
beforeDestroy() { beforeDestroy() {
window.removeEventListener('resize', this.resize) window.removeEventListener('resize', this.resize)

View File

@ -7,6 +7,7 @@ const defaultCode = 'en-us'
const languageCodeMap = { const languageCodeMap = {
bg: { label: 'Български', dateFnsLocale: 'bg' }, bg: { label: 'Български', dateFnsLocale: 'bg' },
bn: { label: 'বাংলা', dateFnsLocale: 'bn' }, bn: { label: 'বাংলা', dateFnsLocale: 'bn' },
ca: { label: 'Català', dateFnsLocale: 'ca' },
cs: { label: 'Čeština', dateFnsLocale: 'cs' }, cs: { label: 'Čeština', dateFnsLocale: 'cs' },
da: { label: 'Dansk', dateFnsLocale: 'da' }, da: { label: 'Dansk', dateFnsLocale: 'da' },
de: { label: 'Deutsch', dateFnsLocale: 'de' }, de: { label: 'Deutsch', dateFnsLocale: 'de' },
@ -41,6 +42,7 @@ Vue.prototype.$languageCodeOptions = Object.keys(languageCodeMap).map((code) =>
// iTunes search API uses ISO 3166 country codes: https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2 // iTunes search API uses ISO 3166 country codes: https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2
const podcastSearchRegionMap = { const podcastSearchRegionMap = {
au: { label: 'Australia' },
br: { label: 'Brasil' }, br: { label: 'Brasil' },
be: { label: 'België / Belgique / Belgien' }, be: { label: 'België / Belgique / Belgien' },
cz: { label: 'Česko' }, cz: { label: 'Česko' },
@ -56,6 +58,7 @@ const podcastSearchRegionMap = {
hu: { label: 'Magyarország' }, hu: { label: 'Magyarország' },
nl: { label: 'Nederland' }, nl: { label: 'Nederland' },
no: { label: 'Norge' }, no: { label: 'Norge' },
nz: { label: 'New Zealand' },
at: { label: 'Österreich' }, at: { label: 'Österreich' },
pl: { label: 'Polska' }, pl: { label: 'Polska' },
pt: { label: 'Portugal' }, pt: { label: 'Portugal' },

View File

@ -72,16 +72,17 @@ export const actions = {
return this.$axios return this.$axios
.$patch('/api/settings', updatePayload) .$patch('/api/settings', updatePayload)
.then((result) => { .then((result) => {
if (result.success) { if (result.serverSettings) {
commit('setServerSettings', result.serverSettings) commit('setServerSettings', result.serverSettings)
return true
} else {
return false
} }
return result
}) })
.catch((error) => { .catch((error) => {
console.error('Failed to update server settings', error) console.error('Failed to update server settings', error)
return false const errorMsg = error.response?.data || 'Unknown error'
return {
error: errorMsg
}
}) })
}, },
checkForUpdate({ commit }) { checkForUpdate({ commit }) {

1
client/strings/be.json Normal file
View File

@ -0,0 +1 @@
{}

View File

@ -629,7 +629,6 @@
"MessageItemsSelected": "{0} избрани", "MessageItemsSelected": "{0} избрани",
"MessageItemsUpdated": "{0} елемента обновени", "MessageItemsUpdated": "{0} елемента обновени",
"MessageJoinUsOn": "Присъединете се към нас", "MessageJoinUsOn": "Присъединете се към нас",
"MessageListeningSessionsInTheLastYear": "{0} слушателски сесии през последната година",
"MessageLoading": "Зареждане...", "MessageLoading": "Зареждане...",
"MessageLoadingFolders": "Зареждане на Папки...", "MessageLoadingFolders": "Зареждане на Папки...",
"MessageM4BFailed": "M4B Провалено!", "MessageM4BFailed": "M4B Провалено!",
@ -729,7 +728,6 @@
"ToastBookmarkUpdateSuccess": "Отметката е обновена", "ToastBookmarkUpdateSuccess": "Отметката е обновена",
"ToastChaptersHaveErrors": "Главите имат грешки", "ToastChaptersHaveErrors": "Главите имат грешки",
"ToastChaptersMustHaveTitles": "Главите трябва да имат заглавия", "ToastChaptersMustHaveTitles": "Главите трябва да имат заглавия",
"ToastCollectionItemsRemoveSuccess": "Елемент(и) премахнати от колекция",
"ToastCollectionRemoveSuccess": "Колекцията е премахната", "ToastCollectionRemoveSuccess": "Колекцията е премахната",
"ToastCollectionUpdateSuccess": "Колекцията е обновена", "ToastCollectionUpdateSuccess": "Колекцията е обновена",
"ToastItemCoverUpdateSuccess": "Корицата на елемента е обновена", "ToastItemCoverUpdateSuccess": "Корицата на елемента е обновена",

View File

@ -88,6 +88,8 @@
"ButtonSaveTracklist": "ট্র্যাকলিস্ট সংরক্ষণ করুন", "ButtonSaveTracklist": "ট্র্যাকলিস্ট সংরক্ষণ করুন",
"ButtonScan": "স্ক্যান", "ButtonScan": "স্ক্যান",
"ButtonScanLibrary": "স্ক্যান লাইব্রেরি", "ButtonScanLibrary": "স্ক্যান লাইব্রেরি",
"ButtonScrollLeft": "বাম দিকে স্ক্রল করুন",
"ButtonScrollRight": "ডানদিকে স্ক্রল করুন",
"ButtonSearch": "অনুসন্ধান", "ButtonSearch": "অনুসন্ধান",
"ButtonSelectFolderPath": "ফোল্ডারের পথ নির্বাচন করুন", "ButtonSelectFolderPath": "ফোল্ডারের পথ নির্বাচন করুন",
"ButtonSeries": "সিরিজ", "ButtonSeries": "সিরিজ",
@ -190,6 +192,7 @@
"HeaderSettingsExperimental": "পরীক্ষামূলক ফিচার", "HeaderSettingsExperimental": "পরীক্ষামূলক ফিচার",
"HeaderSettingsGeneral": "সাধারণ", "HeaderSettingsGeneral": "সাধারণ",
"HeaderSettingsScanner": "স্ক্যানার", "HeaderSettingsScanner": "স্ক্যানার",
"HeaderSettingsWebClient": "ওয়েব ক্লায়েন্ট",
"HeaderSleepTimer": "স্লিপ টাইমার", "HeaderSleepTimer": "স্লিপ টাইমার",
"HeaderStatsLargestItems": "সবচেয়ে বড় আইটেম", "HeaderStatsLargestItems": "সবচেয়ে বড় আইটেম",
"HeaderStatsLongestItems": "দীর্ঘতম আইটেম (ঘন্টা)", "HeaderStatsLongestItems": "দীর্ঘতম আইটেম (ঘন্টা)",
@ -297,6 +300,7 @@
"LabelDiscover": "আবিষ্কার", "LabelDiscover": "আবিষ্কার",
"LabelDownload": "ডাউনলোড করুন", "LabelDownload": "ডাউনলোড করুন",
"LabelDownloadNEpisodes": "{0}টি পর্ব ডাউনলোড করুন", "LabelDownloadNEpisodes": "{0}টি পর্ব ডাউনলোড করুন",
"LabelDownloadable": "ডাউনলোডযোগ্য",
"LabelDuration": "সময়কাল", "LabelDuration": "সময়কাল",
"LabelDurationComparisonExactMatch": "(সঠিক মিল)", "LabelDurationComparisonExactMatch": "(সঠিক মিল)",
"LabelDurationComparisonLonger": "({0} দীর্ঘ)", "LabelDurationComparisonLonger": "({0} দীর্ঘ)",
@ -542,6 +546,7 @@
"LabelServerYearReview": "সার্ভারের বাৎসরিক পর্যালোচনা ({0})", "LabelServerYearReview": "সার্ভারের বাৎসরিক পর্যালোচনা ({0})",
"LabelSetEbookAsPrimary": "প্রাথমিক হিসাবে সেট করুন", "LabelSetEbookAsPrimary": "প্রাথমিক হিসাবে সেট করুন",
"LabelSetEbookAsSupplementary": "পরিপূরক হিসেবে সেট করুন", "LabelSetEbookAsSupplementary": "পরিপূরক হিসেবে সেট করুন",
"LabelSettingsAllowIframe": "আইফ্রেমে এম্বেড করার অনুমতি দিন",
"LabelSettingsAudiobooksOnly": "শুধুমাত্র অডিও বই", "LabelSettingsAudiobooksOnly": "শুধুমাত্র অডিও বই",
"LabelSettingsAudiobooksOnlyHelp": "এই সেটিংটি সক্ষম করা ই-বই ফাইলগুলিকে উপেক্ষা করবে যদি না সেগুলি একটি অডিওবই ফোল্ডারের মধ্যে থাকে যে ক্ষেত্রে সেগুলিকে সম্পূরক ই-বই হিসাবে সেট করা হবে", "LabelSettingsAudiobooksOnlyHelp": "এই সেটিংটি সক্ষম করা ই-বই ফাইলগুলিকে উপেক্ষা করবে যদি না সেগুলি একটি অডিওবই ফোল্ডারের মধ্যে থাকে যে ক্ষেত্রে সেগুলিকে সম্পূরক ই-বই হিসাবে সেট করা হবে",
"LabelSettingsBookshelfViewHelp": "কাঠের তাক সহ স্কুমরফিক ডিজাইন", "LabelSettingsBookshelfViewHelp": "কাঠের তাক সহ স্কুমরফিক ডিজাইন",
@ -584,6 +589,7 @@
"LabelSettingsStoreMetadataWithItemHelp": "ডিফল্টরূপে মেটাডেটা ফাইলগুলি /মেটাডাটা/আইটেমগুলি -এ সংরক্ষণ করা হয়, এই সেটিংটি সক্ষম করলে মেটাডেটা ফাইলগুলি আপনার লাইব্রেরি আইটেম ফোল্ডারে সংরক্ষণ করা হবে", "LabelSettingsStoreMetadataWithItemHelp": "ডিফল্টরূপে মেটাডেটা ফাইলগুলি /মেটাডাটা/আইটেমগুলি -এ সংরক্ষণ করা হয়, এই সেটিংটি সক্ষম করলে মেটাডেটা ফাইলগুলি আপনার লাইব্রেরি আইটেম ফোল্ডারে সংরক্ষণ করা হবে",
"LabelSettingsTimeFormat": "সময় বিন্যাস", "LabelSettingsTimeFormat": "সময় বিন্যাস",
"LabelShare": "শেয়ার করুন", "LabelShare": "শেয়ার করুন",
"LabelShareDownloadableHelp": "শেয়ার লিঙ্ক সহ ব্যবহারকারীদের লাইব্রেরি আইটেমের একটি জিপ ফাইল ডাউনলোড করার অনুমতি দিন।",
"LabelShareOpen": "শেয়ার খোলা", "LabelShareOpen": "শেয়ার খোলা",
"LabelShareURL": "শেয়ার ইউআরএল", "LabelShareURL": "শেয়ার ইউআরএল",
"LabelShowAll": "সব দেখান", "LabelShowAll": "সব দেখান",
@ -592,6 +598,8 @@
"LabelSize": "আকার", "LabelSize": "আকার",
"LabelSleepTimer": "স্লিপ টাইমার", "LabelSleepTimer": "স্লিপ টাইমার",
"LabelSlug": "স্লাগ", "LabelSlug": "স্লাগ",
"LabelSortAscending": "আরোহী",
"LabelSortDescending": "অবরোহী",
"LabelStart": "শুরু", "LabelStart": "শুরু",
"LabelStartTime": "শুরুর সময়", "LabelStartTime": "শুরুর সময়",
"LabelStarted": "শুরু হয়েছে", "LabelStarted": "শুরু হয়েছে",
@ -679,6 +687,8 @@
"LabelViewPlayerSettings": "প্লেয়ার সেটিংস দেখুন", "LabelViewPlayerSettings": "প্লেয়ার সেটিংস দেখুন",
"LabelViewQueue": "প্লেয়ার সারি দেখুন", "LabelViewQueue": "প্লেয়ার সারি দেখুন",
"LabelVolume": "ভলিউম", "LabelVolume": "ভলিউম",
"LabelWebRedirectURLsDescription": "লগইন করার পরে ওয়েব অ্যাপে পুনঃনির্দেশের অনুমতি দেওয়ার জন্য আপনার OAuth প্রদানকারীতে এই URLগুলোকে অনুমোদন করুন:",
"LabelWebRedirectURLsSubfolder": "রিডাইরেক্ট URL এর জন্য সাবফোল্ডার",
"LabelWeekdaysToRun": "চলতে হবে সপ্তাহের দিন", "LabelWeekdaysToRun": "চলতে হবে সপ্তাহের দিন",
"LabelXBooks": "{0}টি বই", "LabelXBooks": "{0}টি বই",
"LabelXItems": "{0}টি আইটেম", "LabelXItems": "{0}টি আইটেম",
@ -763,7 +773,6 @@
"MessageItemsSelected": "{0}টি আইটেম নির্বাচিত", "MessageItemsSelected": "{0}টি আইটেম নির্বাচিত",
"MessageItemsUpdated": "{0}টি আইটেম আপডেট করা হয়েছে", "MessageItemsUpdated": "{0}টি আইটেম আপডেট করা হয়েছে",
"MessageJoinUsOn": "আমাদের সাথে যোগ দিন", "MessageJoinUsOn": "আমাদের সাথে যোগ দিন",
"MessageListeningSessionsInTheLastYear": "গত বছরে {0}টি শোনার সেশন",
"MessageLoading": "লোড হচ্ছে.।", "MessageLoading": "লোড হচ্ছে.।",
"MessageLoadingFolders": "ফোল্ডার লোড হচ্ছে...", "MessageLoadingFolders": "ফোল্ডার লোড হচ্ছে...",
"MessageLogsDescription": "লগগুলি JSON ফাইল হিসাবে <code>/metadata/logs</code>-এ সংরক্ষণ করা হয়। ক্র্যাশ লগগুলি <code>/metadata/logs/crash_logs.txt</code>-এ সংরক্ষণ করা হয়।", "MessageLogsDescription": "লগগুলি JSON ফাইল হিসাবে <code>/metadata/logs</code>-এ সংরক্ষণ করা হয়। ক্র্যাশ লগগুলি <code>/metadata/logs/crash_logs.txt</code>-এ সংরক্ষণ করা হয়।",
@ -951,8 +960,6 @@
"ToastChaptersRemoved": "অধ্যায়গুলো মুছে ফেলা হয়েছে", "ToastChaptersRemoved": "অধ্যায়গুলো মুছে ফেলা হয়েছে",
"ToastChaptersUpdated": "অধ্যায় আপডেট করা হয়েছে", "ToastChaptersUpdated": "অধ্যায় আপডেট করা হয়েছে",
"ToastCollectionItemsAddFailed": "আইটেম(গুলি) সংগ্রহে যোগ করা ব্যর্থ হয়েছে", "ToastCollectionItemsAddFailed": "আইটেম(গুলি) সংগ্রহে যোগ করা ব্যর্থ হয়েছে",
"ToastCollectionItemsAddSuccess": "আইটেম(গুলি) সংগ্রহে যোগ করা সফল হয়েছে",
"ToastCollectionItemsRemoveSuccess": "আইটেম(গুলি) সংগ্রহ থেকে সরানো হয়েছে",
"ToastCollectionRemoveSuccess": "সংগ্রহ সরানো হয়েছে", "ToastCollectionRemoveSuccess": "সংগ্রহ সরানো হয়েছে",
"ToastCollectionUpdateSuccess": "সংগ্রহ আপডেট করা হয়েছে", "ToastCollectionUpdateSuccess": "সংগ্রহ আপডেট করা হয়েছে",
"ToastCoverUpdateFailed": "কভার আপডেট ব্যর্থ হয়েছে", "ToastCoverUpdateFailed": "কভার আপডেট ব্যর্থ হয়েছে",

1025
client/strings/ca.json Normal file

File diff suppressed because it is too large Load Diff

View File

@ -88,6 +88,8 @@
"ButtonSaveTracklist": "Uložit seznam skladeb", "ButtonSaveTracklist": "Uložit seznam skladeb",
"ButtonScan": "Prohledat", "ButtonScan": "Prohledat",
"ButtonScanLibrary": "Prohledat Knihovnu", "ButtonScanLibrary": "Prohledat Knihovnu",
"ButtonScrollLeft": "Posunout vlevo",
"ButtonScrollRight": "Posunout vpravo",
"ButtonSearch": "Hledat", "ButtonSearch": "Hledat",
"ButtonSelectFolderPath": "Vybrat cestu ke složce", "ButtonSelectFolderPath": "Vybrat cestu ke složce",
"ButtonSeries": "Série", "ButtonSeries": "Série",
@ -190,6 +192,7 @@
"HeaderSettingsExperimental": "Experimentální funkce", "HeaderSettingsExperimental": "Experimentální funkce",
"HeaderSettingsGeneral": "Obecné", "HeaderSettingsGeneral": "Obecné",
"HeaderSettingsScanner": "Skener", "HeaderSettingsScanner": "Skener",
"HeaderSettingsWebClient": "Webový klient",
"HeaderSleepTimer": "Časovač vypnutí", "HeaderSleepTimer": "Časovač vypnutí",
"HeaderStatsLargestItems": "Největší položky", "HeaderStatsLargestItems": "Největší položky",
"HeaderStatsLongestItems": "Nejdelší položky (hod.)", "HeaderStatsLongestItems": "Nejdelší položky (hod.)",
@ -231,7 +234,7 @@
"LabelAppend": "Připojit", "LabelAppend": "Připojit",
"LabelAudioBitrate": "Bitový tok zvuku (např. 128k)", "LabelAudioBitrate": "Bitový tok zvuku (např. 128k)",
"LabelAudioChannels": "Zvukové kanály (1 nebo 2)", "LabelAudioChannels": "Zvukové kanály (1 nebo 2)",
"LabelAudioCodec": "Kodek audia", "LabelAudioCodec": "Audio Kodek",
"LabelAuthor": "Autor", "LabelAuthor": "Autor",
"LabelAuthorFirstLast": "Autor (jméno a příjmení)", "LabelAuthorFirstLast": "Autor (jméno a příjmení)",
"LabelAuthorLastFirst": "Autor (příjmení a jméno)", "LabelAuthorLastFirst": "Autor (příjmení a jméno)",
@ -264,6 +267,7 @@
"LabelChapters": "Kapitoly", "LabelChapters": "Kapitoly",
"LabelChaptersFound": "Kapitoly nalezeny", "LabelChaptersFound": "Kapitoly nalezeny",
"LabelClickForMoreInfo": "Klikněte pro více informací", "LabelClickForMoreInfo": "Klikněte pro více informací",
"LabelClickToUseCurrentValue": "Klikni pro použití aktuální hodnoty",
"LabelClosePlayer": "Zavřít přehrávač", "LabelClosePlayer": "Zavřít přehrávač",
"LabelCodec": "Kodek", "LabelCodec": "Kodek",
"LabelCollapseSeries": "Sbalit sérii", "LabelCollapseSeries": "Sbalit sérii",
@ -313,12 +317,25 @@
"LabelEmailSettingsTestAddress": "Testovací adresa", "LabelEmailSettingsTestAddress": "Testovací adresa",
"LabelEmbeddedCover": "Vložená obálka", "LabelEmbeddedCover": "Vložená obálka",
"LabelEnable": "Povolit", "LabelEnable": "Povolit",
"LabelEncodingBackupLocation": "Záloha původních audio souborů bude uložena v:",
"LabelEncodingChaptersNotEmbedded": "Kapitoly nejsou vloženy ve vícestopých audioknihách.",
"LabelEncodingClearItemCache": "Nezapomeňte pravidelně promazávat mezipaměť položek.",
"LabelEncodingFinishedM4B": "Výsledné M4B bude uloženo do složky s audioknihou v:",
"LabelEncodingInfoEmbedded": "Metadata budou vložena do audio stop ve složce s audioknihou.",
"LabelEncodingStartedNavigation": "Po spuštění úlohy můžete opustit tuto stránku.",
"LabelEncodingTimeWarning": "Encoding může zabrat až 30 minut.",
"LabelEncodingWarningAdvancedSettings": "Varování: Neměňte toto nastavení pokud neznáte možnosti encodingu ffmpeg.",
"LabelEncodingWatcherDisabled": "Pokud máte zakázaný watcher, budete po skončení muset znovu naskenovat tuto audioknihu.",
"LabelEnd": "Konec", "LabelEnd": "Konec",
"LabelEndOfChapter": "Konec kapitoly", "LabelEndOfChapter": "Konec kapitoly",
"LabelEpisode": "Epizoda", "LabelEpisode": "Epizoda",
"LabelEpisodeNotLinkedToRssFeed": "Epizoda není propojená s RSS feed",
"LabelEpisodeNumber": "Epizoda #{0}",
"LabelEpisodeTitle": "Název epizody", "LabelEpisodeTitle": "Název epizody",
"LabelEpisodeType": "Typ epizody", "LabelEpisodeType": "Typ epizody",
"LabelEpisodeUrlFromRssFeed": "URL epizody z RSS feed",
"LabelEpisodes": "Epizody", "LabelEpisodes": "Epizody",
"LabelEpisodic": "Epizodické",
"LabelExample": "Příklad", "LabelExample": "Příklad",
"LabelExpandSeries": "Rozbalit série", "LabelExpandSeries": "Rozbalit série",
"LabelExpandSubSeries": "Rozbalit podsérie", "LabelExpandSubSeries": "Rozbalit podsérie",
@ -346,6 +363,7 @@
"LabelFontScale": "Měřítko písma", "LabelFontScale": "Měřítko písma",
"LabelFontStrikethrough": "Přeškrtnutí", "LabelFontStrikethrough": "Přeškrtnutí",
"LabelFormat": "Formát", "LabelFormat": "Formát",
"LabelFull": "Plné",
"LabelGenre": "Žánr", "LabelGenre": "Žánr",
"LabelGenres": "Žánry", "LabelGenres": "Žánry",
"LabelHardDeleteFile": "Trvale smazat soubor", "LabelHardDeleteFile": "Trvale smazat soubor",
@ -388,6 +406,7 @@
"LabelLess": "Méně", "LabelLess": "Méně",
"LabelLibrariesAccessibleToUser": "Knihovny přístupné uživateli", "LabelLibrariesAccessibleToUser": "Knihovny přístupné uživateli",
"LabelLibrary": "Knihovna", "LabelLibrary": "Knihovna",
"LabelLibraryFilterSublistEmpty": "Žádné {0}",
"LabelLibraryItem": "Položka knihovny", "LabelLibraryItem": "Položka knihovny",
"LabelLibraryName": "Název knihovny", "LabelLibraryName": "Název knihovny",
"LabelLimit": "Omezit", "LabelLimit": "Omezit",
@ -400,6 +419,10 @@
"LabelLowestPriority": "Nejnižší priorita", "LabelLowestPriority": "Nejnižší priorita",
"LabelMatchExistingUsersBy": "Přiřadit stávající uživatele podle", "LabelMatchExistingUsersBy": "Přiřadit stávající uživatele podle",
"LabelMatchExistingUsersByDescription": "Slouží k propojení stávajících uživatelů. Po propojení budou uživatelé přiřazeni k jedinečnému ID od poskytovatele SSO.", "LabelMatchExistingUsersByDescription": "Slouží k propojení stávajících uživatelů. Po propojení budou uživatelé přiřazeni k jedinečnému ID od poskytovatele SSO.",
"LabelMaxEpisodesToDownload": "Maximální # epizod pro stažení. Použijte 0 pro bez omezení.",
"LabelMaxEpisodesToDownloadPerCheck": "Maximální počet nových epizod ke stažení při jedné kontrole",
"LabelMaxEpisodesToKeep": "Maximální počet epizod k zachování",
"LabelMaxEpisodesToKeepHelp": "Hodnotou 0 není nastaven žádný maximální limit. Po automatickém stažení nové epizody se odstraní nejstarší epizoda, pokud máte více než X epizod. Při každém novém stažení se odstraní pouze 1 epizoda.",
"LabelMediaPlayer": "Přehrávač médií", "LabelMediaPlayer": "Přehrávač médií",
"LabelMediaType": "Typ média", "LabelMediaType": "Typ média",
"LabelMetaTag": "Metaznačka", "LabelMetaTag": "Metaznačka",
@ -445,12 +468,14 @@
"LabelOpenIDGroupClaimDescription": "Název požadavku OpenID, který obsahuje seznam uživatelských skupin. Běžně se označuje jako <code>groups</code>. <b>Je-li nakonfigurováno</b>, plikace automaticky přiřadí role na základě členství uživatele ve skupinách, pokud jsou tyto skupiny v požadavku pojmenovány case-insensitive 'admin', 'user' nebo 'guest'. Požadavek by měl obsahovat seznam, a pokud uživatel patří do více skupin, aplikace přiřadí roli odpovídající nejvyšší úrovni práva přístupu. Pokud žádná skupina není shodná, bude přístup odepřen.", "LabelOpenIDGroupClaimDescription": "Název požadavku OpenID, který obsahuje seznam uživatelských skupin. Běžně se označuje jako <code>groups</code>. <b>Je-li nakonfigurováno</b>, plikace automaticky přiřadí role na základě členství uživatele ve skupinách, pokud jsou tyto skupiny v požadavku pojmenovány case-insensitive 'admin', 'user' nebo 'guest'. Požadavek by měl obsahovat seznam, a pokud uživatel patří do více skupin, aplikace přiřadí roli odpovídající nejvyšší úrovni práva přístupu. Pokud žádná skupina není shodná, bude přístup odepřen.",
"LabelOpenRSSFeed": "Otevřít RSS kanál", "LabelOpenRSSFeed": "Otevřít RSS kanál",
"LabelOverwrite": "Přepsat", "LabelOverwrite": "Přepsat",
"LabelPaginationPageXOfY": "Strana {0} z {1}",
"LabelPassword": "Heslo", "LabelPassword": "Heslo",
"LabelPath": "Cesta", "LabelPath": "Cesta",
"LabelPermanent": "Trvalé", "LabelPermanent": "Trvalé",
"LabelPermissionsAccessAllLibraries": "Má přístup ke všem knihovnám", "LabelPermissionsAccessAllLibraries": "Má přístup ke všem knihovnám",
"LabelPermissionsAccessAllTags": "Má přístup ke všem značkám", "LabelPermissionsAccessAllTags": "Má přístup ke všem značkám",
"LabelPermissionsAccessExplicitContent": "Má přístup k explicitnímu obsahu", "LabelPermissionsAccessExplicitContent": "Má přístup k explicitnímu obsahu",
"LabelPermissionsCreateEreader": "Může vytvořit Ereader",
"LabelPermissionsDelete": "Může mazat", "LabelPermissionsDelete": "Může mazat",
"LabelPermissionsDownload": "Může stahovat", "LabelPermissionsDownload": "Může stahovat",
"LabelPermissionsUpdate": "Může aktualizovat", "LabelPermissionsUpdate": "Může aktualizovat",
@ -474,6 +499,8 @@
"LabelPubDate": "Datum vydání", "LabelPubDate": "Datum vydání",
"LabelPublishYear": "Rok vydání", "LabelPublishYear": "Rok vydání",
"LabelPublishedDate": "Vydáno {0}", "LabelPublishedDate": "Vydáno {0}",
"LabelPublishedDecade": "Publikováno (dekáda)",
"LabelPublishedDecades": "Publikováno (dekády)",
"LabelPublisher": "Vydavatel", "LabelPublisher": "Vydavatel",
"LabelPublishers": "Vydavatelé", "LabelPublishers": "Vydavatelé",
"LabelRSSFeedCustomOwnerEmail": "Vlastní e-mail vlastníka", "LabelRSSFeedCustomOwnerEmail": "Vlastní e-mail vlastníka",
@ -493,24 +520,32 @@
"LabelRedo": "Přepracovat", "LabelRedo": "Přepracovat",
"LabelRegion": "Region", "LabelRegion": "Region",
"LabelReleaseDate": "Datum vydání", "LabelReleaseDate": "Datum vydání",
"LabelRemoveAllMetadataAbs": "Odebrat všechny soubory metadata.abs",
"LabelRemoveAllMetadataJson": "Smazat všechny soubory metadata.json",
"LabelRemoveCover": "Odstranit obálku", "LabelRemoveCover": "Odstranit obálku",
"LabelRemoveMetadataFile": "Odstranit soubory metadat ve složkách položek knihovny",
"LabelRemoveMetadataFileHelp": "Odstraníte všechny soubory metadata.json a metadata.abs ve svých složkách {0}.",
"LabelRowsPerPage": "Řádky na stránku", "LabelRowsPerPage": "Řádky na stránku",
"LabelSearchTerm": "Vyhledat termín", "LabelSearchTerm": "Vyhledat termín",
"LabelSearchTitle": "Vyhledat název", "LabelSearchTitle": "Vyhledat název",
"LabelSearchTitleOrASIN": "Vyhledat název nebo ASIN", "LabelSearchTitleOrASIN": "Vyhledat název nebo ASIN",
"LabelSeason": "Sezóna", "LabelSeason": "Sezóna",
"LabelSeasonNumber": "Sezóna č.{0}",
"LabelSelectAll": "Vybrat vše", "LabelSelectAll": "Vybrat vše",
"LabelSelectAllEpisodes": "Vybrat všechny epizody", "LabelSelectAllEpisodes": "Vybrat všechny epizody",
"LabelSelectEpisodesShowing": "Vyberte {0} epizody, které se zobrazují", "LabelSelectEpisodesShowing": "Vyberte {0} epizody, které se zobrazují",
"LabelSelectUsers": "Vybrat uživatele", "LabelSelectUsers": "Vybrat uživatele",
"LabelSendEbookToDevice": "Odeslat e-knihu do...", "LabelSendEbookToDevice": "Odeslat e-knihu do...",
"LabelSequence": "Sekvence", "LabelSequence": "Sekvence",
"LabelSerial": "Sériové",
"LabelSeries": "Série", "LabelSeries": "Série",
"LabelSeriesName": "Název série", "LabelSeriesName": "Název série",
"LabelSeriesProgress": "Průběh série", "LabelSeriesProgress": "Průběh série",
"LabelServerLogLevel": "Úroveň protokolu serveru",
"LabelServerYearReview": "Přehled roku na serveru ({0})", "LabelServerYearReview": "Přehled roku na serveru ({0})",
"LabelSetEbookAsPrimary": "Nastavit jako primární", "LabelSetEbookAsPrimary": "Nastavit jako primární",
"LabelSetEbookAsSupplementary": "Nastavit jako doplňkové", "LabelSetEbookAsSupplementary": "Nastavit jako doplňkové",
"LabelSettingsAllowIframe": "Povolit vložení do rámce iframe",
"LabelSettingsAudiobooksOnly": "Pouze audioknihy", "LabelSettingsAudiobooksOnly": "Pouze audioknihy",
"LabelSettingsAudiobooksOnlyHelp": "Povolením tohoto nastavení budou soubory e-knih ignorovány, pokud nejsou ve složce audioknih, v takovém případě budou nastaveny jako doplňkové e-knihy", "LabelSettingsAudiobooksOnlyHelp": "Povolením tohoto nastavení budou soubory e-knih ignorovány, pokud nejsou ve složce audioknih, v takovém případě budou nastaveny jako doplňkové e-knihy",
"LabelSettingsBookshelfViewHelp": "Skeumorfní design s dřevěnými policemi", "LabelSettingsBookshelfViewHelp": "Skeumorfní design s dřevěnými policemi",
@ -532,6 +567,9 @@
"LabelSettingsHideSingleBookSeriesHelp": "Série, které mají jedinou knihu, budou skryty na stránce série a na domovské stránce.", "LabelSettingsHideSingleBookSeriesHelp": "Série, které mají jedinou knihu, budou skryty na stránce série a na domovské stránce.",
"LabelSettingsHomePageBookshelfView": "Domovská stránka používá zobrazení police s knihami", "LabelSettingsHomePageBookshelfView": "Domovská stránka používá zobrazení police s knihami",
"LabelSettingsLibraryBookshelfView": "Knihovna používá zobrazení police s knihami", "LabelSettingsLibraryBookshelfView": "Knihovna používá zobrazení police s knihami",
"LabelSettingsLibraryMarkAsFinishedPercentComplete": "Procento dokončení je vyšší než",
"LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Zbývající čas je kratší než (sekund)",
"LabelSettingsLibraryMarkAsFinishedWhen": "Označit položku médií jako dokončenou, když",
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Přeskočit předchozí knihy v pokračování série", "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Přeskočit předchozí knihy v pokračování série",
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Polička Pokračovat v sérii na domovské stránce zobrazuje první nezačatou knihu v sériích, které mají alespoň jednu knihu dokončenou a žádnou rozečtenou. Povolením tohoto nastavení budou série pokračovat od poslední dokončené knihy namísto první nezačaté knihy.", "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Polička Pokračovat v sérii na domovské stránce zobrazuje první nezačatou knihu v sériích, které mají alespoň jednu knihu dokončenou a žádnou rozečtenou. Povolením tohoto nastavení budou série pokračovat od poslední dokončené knihy namísto první nezačaté knihy.",
"LabelSettingsParseSubtitles": "Analzyovat podtitul", "LabelSettingsParseSubtitles": "Analzyovat podtitul",
@ -550,12 +588,16 @@
"LabelSettingsStoreMetadataWithItemHelp": "Ve výchozím nastavení jsou soubory metadat uloženy v adresáři /metadata/items, povolením tohoto nastavení budou soubory metadat uloženy ve složkách položek knihovny", "LabelSettingsStoreMetadataWithItemHelp": "Ve výchozím nastavení jsou soubory metadat uloženy v adresáři /metadata/items, povolením tohoto nastavení budou soubory metadat uloženy ve složkách položek knihovny",
"LabelSettingsTimeFormat": "Formát času", "LabelSettingsTimeFormat": "Formát času",
"LabelShare": "Sdílet", "LabelShare": "Sdílet",
"LabelShareOpen": "Otevřít sdílení",
"LabelShareURL": "Sdílet URL", "LabelShareURL": "Sdílet URL",
"LabelShowAll": "Zobrazit vše", "LabelShowAll": "Zobrazit vše",
"LabelShowSeconds": "Zobrazit sekundy", "LabelShowSeconds": "Zobrazit sekundy",
"LabelShowSubtitles": "Zobrazit titulky", "LabelShowSubtitles": "Zobrazit titulky",
"LabelSize": "Velikost", "LabelSize": "Velikost",
"LabelSleepTimer": "Časovač vypnutí", "LabelSleepTimer": "Časovač vypnutí",
"LabelSlug": "URL název",
"LabelSortAscending": "Vzestupně",
"LabelSortDescending": "Sestupně",
"LabelStart": "Spustit", "LabelStart": "Spustit",
"LabelStartTime": "Čas Spuštění", "LabelStartTime": "Čas Spuštění",
"LabelStarted": "Spuštěno", "LabelStarted": "Spuštěno",
@ -594,6 +636,7 @@
"LabelTimeDurationXMinutes": "{0} minut", "LabelTimeDurationXMinutes": "{0} minut",
"LabelTimeDurationXSeconds": "{0} sekund", "LabelTimeDurationXSeconds": "{0} sekund",
"LabelTimeInMinutes": "Čas v minutách", "LabelTimeInMinutes": "Čas v minutách",
"LabelTimeLeft": "{0} zbývá",
"LabelTimeListened": "Čas poslechu", "LabelTimeListened": "Čas poslechu",
"LabelTimeListenedToday": "Čas poslechu dnes", "LabelTimeListenedToday": "Čas poslechu dnes",
"LabelTimeRemaining": "{0} zbývá", "LabelTimeRemaining": "{0} zbývá",
@ -601,6 +644,7 @@
"LabelTitle": "Název", "LabelTitle": "Název",
"LabelToolsEmbedMetadata": "Vložit metadata", "LabelToolsEmbedMetadata": "Vložit metadata",
"LabelToolsEmbedMetadataDescription": "Vložit metadata do zvukových souborů včetně obálky a kapitol.", "LabelToolsEmbedMetadataDescription": "Vložit metadata do zvukových souborů včetně obálky a kapitol.",
"LabelToolsM4bEncoder": "Enkodér M4B",
"LabelToolsMakeM4b": "Vytvořit soubor audioknihy M4B", "LabelToolsMakeM4b": "Vytvořit soubor audioknihy M4B",
"LabelToolsMakeM4bDescription": "Vygenerovat soubor audioknihy M4B s vloženými metadaty, obálkou a kapitolami.", "LabelToolsMakeM4bDescription": "Vygenerovat soubor audioknihy M4B s vloženými metadaty, obálkou a kapitolami.",
"LabelToolsSplitM4b": "Rozdělit M4B na MP3", "LabelToolsSplitM4b": "Rozdělit M4B na MP3",
@ -613,6 +657,7 @@
"LabelTracksMultiTrack": "Více stop", "LabelTracksMultiTrack": "Více stop",
"LabelTracksNone": "Žádné stopy", "LabelTracksNone": "Žádné stopy",
"LabelTracksSingleTrack": "Jedna stopa", "LabelTracksSingleTrack": "Jedna stopa",
"LabelTrailer": "Upoutávka",
"LabelType": "Typ", "LabelType": "Typ",
"LabelUnabridged": "Nezkráceno", "LabelUnabridged": "Nezkráceno",
"LabelUndo": "Zpět", "LabelUndo": "Zpět",
@ -624,10 +669,13 @@
"LabelUpdateDetailsHelp": "Povolit přepsání existujících údajů o vybraných knihách, když je nalezena shoda", "LabelUpdateDetailsHelp": "Povolit přepsání existujících údajů o vybraných knihách, když je nalezena shoda",
"LabelUpdatedAt": "Aktualizováno v", "LabelUpdatedAt": "Aktualizováno v",
"LabelUploaderDragAndDrop": "Přetáhnout soubory nebo složky", "LabelUploaderDragAndDrop": "Přetáhnout soubory nebo složky",
"LabelUploaderDragAndDropFilesOnly": "Přetáhnout a upustit soubory",
"LabelUploaderDropFiles": "Odstranit soubory", "LabelUploaderDropFiles": "Odstranit soubory",
"LabelUploaderItemFetchMetadataHelp": "Automaticky načíst název, autora a sérii", "LabelUploaderItemFetchMetadataHelp": "Automaticky načíst název, autora a sérii",
"LabelUseAdvancedOptions": "Použít pokročilé možnosti",
"LabelUseChapterTrack": "Použít stopu kapitoly", "LabelUseChapterTrack": "Použít stopu kapitoly",
"LabelUseFullTrack": "Použít celou stopu", "LabelUseFullTrack": "Použít celou stopu",
"LabelUseZeroForUnlimited": "Použijte 0 pro neomezené",
"LabelUser": "Uživatel", "LabelUser": "Uživatel",
"LabelUsername": "Uživatelské jméno", "LabelUsername": "Uživatelské jméno",
"LabelValue": "Hodnota", "LabelValue": "Hodnota",
@ -637,6 +685,8 @@
"LabelViewPlayerSettings": "Zobrazit nastavení přehrávače", "LabelViewPlayerSettings": "Zobrazit nastavení přehrávače",
"LabelViewQueue": "Zobrazit frontu přehrávače", "LabelViewQueue": "Zobrazit frontu přehrávače",
"LabelVolume": "Hlasitost", "LabelVolume": "Hlasitost",
"LabelWebRedirectURLsDescription": "Autorizujte tyto adresy URL ve zprostředkovateli OAuth, abyste po přihlášení umožnili přesměrování zpět do webové aplikace:",
"LabelWebRedirectURLsSubfolder": "Podsložka pro přesměrování adres URL",
"LabelWeekdaysToRun": "Dny v týdnu ke spuštění", "LabelWeekdaysToRun": "Dny v týdnu ke spuštění",
"LabelXBooks": "{0} knih", "LabelXBooks": "{0} knih",
"LabelXItems": "{0} položky", "LabelXItems": "{0} položky",
@ -674,6 +724,7 @@
"MessageConfirmDeleteMetadataProvider": "Opravdu chcete vymazat vlastního poskytovatele metadat \"{0}\"?", "MessageConfirmDeleteMetadataProvider": "Opravdu chcete vymazat vlastního poskytovatele metadat \"{0}\"?",
"MessageConfirmDeleteNotification": "Opravdu chcete vymazat tuto notifikaci?", "MessageConfirmDeleteNotification": "Opravdu chcete vymazat tuto notifikaci?",
"MessageConfirmDeleteSession": "Opravdu chcete smazat tuto relaci?", "MessageConfirmDeleteSession": "Opravdu chcete smazat tuto relaci?",
"MessageConfirmEmbedMetadataInAudioFiles": "Jste si jisti, že chcete vložit metadata do {0} zvukových souborů?",
"MessageConfirmForceReScan": "Opravdu chcete vynutit opětovné prohledání?", "MessageConfirmForceReScan": "Opravdu chcete vynutit opětovné prohledání?",
"MessageConfirmMarkAllEpisodesFinished": "Opravdu chcete označit všechny epizody jako dokončené?", "MessageConfirmMarkAllEpisodesFinished": "Opravdu chcete označit všechny epizody jako dokončené?",
"MessageConfirmMarkAllEpisodesNotFinished": "Opravdu chcete označit všechny epizody jako nedokončené?", "MessageConfirmMarkAllEpisodesNotFinished": "Opravdu chcete označit všechny epizody jako nedokončené?",
@ -681,9 +732,11 @@
"MessageConfirmMarkItemNotFinished": "Opravdu chcete označit \"{0}\" jako nedokončené?", "MessageConfirmMarkItemNotFinished": "Opravdu chcete označit \"{0}\" jako nedokončené?",
"MessageConfirmMarkSeriesFinished": "Opravdu chcete označit všechny knihy z této série jako dokončené?", "MessageConfirmMarkSeriesFinished": "Opravdu chcete označit všechny knihy z této série jako dokončené?",
"MessageConfirmMarkSeriesNotFinished": "Opravdu chcete označit všechny knihy z této série jako nedokončené?", "MessageConfirmMarkSeriesNotFinished": "Opravdu chcete označit všechny knihy z této série jako nedokončené?",
"MessageConfirmNotificationTestTrigger": "Spustit toto oznámení s testovacími daty?",
"MessageConfirmPurgeCache": "Vyčistit mezipaměť odstraní celý adresář na adrese <code>/metadata/cache</code>. <br /><br />Určitě chcete odstranit adresář mezipaměti?", "MessageConfirmPurgeCache": "Vyčistit mezipaměť odstraní celý adresář na adrese <code>/metadata/cache</code>. <br /><br />Určitě chcete odstranit adresář mezipaměti?",
"MessageConfirmPurgeItemsCache": "Vyčištění mezipaměti položek odstraní celý adresář <code>/metadata/cache/items</code>.<br />Jste si jistí?", "MessageConfirmPurgeItemsCache": "Vyčištění mezipaměti položek odstraní celý adresář <code>/metadata/cache/items</code>.<br />Jste si jistí?",
"MessageConfirmQuickEmbed": "Varování! Rychlé vložení nezálohuje vaše zvukové soubory. Ujistěte se, že máte zálohu zvukových souborů. <br><br>Chcete pokračovat?", "MessageConfirmQuickEmbed": "Varování! Rychlé vložení nezálohuje vaše zvukové soubory. Ujistěte se, že máte zálohu zvukových souborů. <br><br>Chcete pokračovat?",
"MessageConfirmQuickMatchEpisodes": "Pokud je nalezena shoda při rychlém párování epizod, dojde k přepsání podrobností. Aktualizovány budou pouze nespárované epizody. Jste si jisti?",
"MessageConfirmReScanLibraryItems": "Opravdu chcete znovu prohledat {0} položky?", "MessageConfirmReScanLibraryItems": "Opravdu chcete znovu prohledat {0} položky?",
"MessageConfirmRemoveAllChapters": "Opravdu chcete odstranit všechny kapitoly?", "MessageConfirmRemoveAllChapters": "Opravdu chcete odstranit všechny kapitoly?",
"MessageConfirmRemoveAuthor": "Opravdu chcete odstranit autora \"{0}\"?", "MessageConfirmRemoveAuthor": "Opravdu chcete odstranit autora \"{0}\"?",
@ -691,6 +744,7 @@
"MessageConfirmRemoveEpisode": "Opravdu chcete odstranit epizodu \"{0}\"?", "MessageConfirmRemoveEpisode": "Opravdu chcete odstranit epizodu \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Opravdu chcete odstranit {0} epizody?", "MessageConfirmRemoveEpisodes": "Opravdu chcete odstranit {0} epizody?",
"MessageConfirmRemoveListeningSessions": "Opravdu chcete odebrat {0} poslechových relací?", "MessageConfirmRemoveListeningSessions": "Opravdu chcete odebrat {0} poslechových relací?",
"MessageConfirmRemoveMetadataFiles": "Jste si jisti, že chcete odstranit všechny metadata.{0} soubory ve složkách s položkami ve vaší knihovně?",
"MessageConfirmRemoveNarrator": "Opravdu chcete odebrat předčítání \"{0}\"?", "MessageConfirmRemoveNarrator": "Opravdu chcete odebrat předčítání \"{0}\"?",
"MessageConfirmRemovePlaylist": "Opravdu chcete odstranit svůj playlist \"{0}\"?", "MessageConfirmRemovePlaylist": "Opravdu chcete odstranit svůj playlist \"{0}\"?",
"MessageConfirmRenameGenre": "Opravdu chcete přejmenovat žánr \"{0}\" na \"{1}\" pro všechny položky?", "MessageConfirmRenameGenre": "Opravdu chcete přejmenovat žánr \"{0}\" na \"{1}\" pro všechny položky?",
@ -706,6 +760,7 @@
"MessageDragFilesIntoTrackOrder": "Přetáhněte soubory do správného pořadí stop", "MessageDragFilesIntoTrackOrder": "Přetáhněte soubory do správného pořadí stop",
"MessageEmbedFailed": "Vložení selhalo!", "MessageEmbedFailed": "Vložení selhalo!",
"MessageEmbedFinished": "Vložení dokončeno!", "MessageEmbedFinished": "Vložení dokončeno!",
"MessageEmbedQueue": "Zařazeno do fronty pro vložení metadat ({0} ve frontě)",
"MessageEpisodesQueuedForDownload": "{0} Epizody zařazené do fronty ke stažení", "MessageEpisodesQueuedForDownload": "{0} Epizody zařazené do fronty ke stažení",
"MessageEreaderDevices": "Aby bylo zajištěno doručení elektronických knih, může být nutné přidat výše uvedenou e-mailovou adresu jako platného odesílatele pro každé zařízení uvedené níže.", "MessageEreaderDevices": "Aby bylo zajištěno doručení elektronických knih, může být nutné přidat výše uvedenou e-mailovou adresu jako platného odesílatele pro každé zařízení uvedené níže.",
"MessageFeedURLWillBe": "URL zdroje bude {0}", "MessageFeedURLWillBe": "URL zdroje bude {0}",
@ -716,7 +771,6 @@
"MessageItemsSelected": "{0} vybraných položek", "MessageItemsSelected": "{0} vybraných položek",
"MessageItemsUpdated": "{0} položky byly aktualizovány", "MessageItemsUpdated": "{0} položky byly aktualizovány",
"MessageJoinUsOn": "Přidejte se k nám", "MessageJoinUsOn": "Přidejte se k nám",
"MessageListeningSessionsInTheLastYear": "{0} poslechových relací za poslední rok",
"MessageLoading": "Načítá se...", "MessageLoading": "Načítá se...",
"MessageLoadingFolders": "Načítám složky...", "MessageLoadingFolders": "Načítám složky...",
"MessageLogsDescription": "Protokoly se ukládají do souborů JSON v <code>/metadata/logs</code>. Protokoly o pádech jsou uloženy v <code>/metadata/logs/crash_logs.txt</code>.", "MessageLogsDescription": "Protokoly se ukládají do souborů JSON v <code>/metadata/logs</code>. Protokoly o pádech jsou uloženy v <code>/metadata/logs/crash_logs.txt</code>.",
@ -750,6 +804,7 @@
"MessageNoLogs": "Žádné protokoly", "MessageNoLogs": "Žádné protokoly",
"MessageNoMediaProgress": "Žádný průběh médií", "MessageNoMediaProgress": "Žádný průběh médií",
"MessageNoNotifications": "Žádná oznámení", "MessageNoNotifications": "Žádná oznámení",
"MessageNoPodcastFeed": "Neplatný podcast: Žádný kanál",
"MessageNoPodcastsFound": "Nebyly nalezeny žádné podcasty", "MessageNoPodcastsFound": "Nebyly nalezeny žádné podcasty",
"MessageNoResults": "Žádné výsledky", "MessageNoResults": "Žádné výsledky",
"MessageNoSearchResultsFor": "Nebyly nalezeny žádné výsledky hledání pro \"{0}\"", "MessageNoSearchResultsFor": "Nebyly nalezeny žádné výsledky hledání pro \"{0}\"",
@ -766,7 +821,10 @@
"MessagePlaylistCreateFromCollection": "Vytvořit seznam skladeb z kolekce", "MessagePlaylistCreateFromCollection": "Vytvořit seznam skladeb z kolekce",
"MessagePleaseWait": "Čekejte prosím...", "MessagePleaseWait": "Čekejte prosím...",
"MessagePodcastHasNoRSSFeedForMatching": "Podcast nemá žádnou adresu URL kanálu RSS, kterou by mohl použít pro porovnávání", "MessagePodcastHasNoRSSFeedForMatching": "Podcast nemá žádnou adresu URL kanálu RSS, kterou by mohl použít pro porovnávání",
"MessageQuickMatchDescription": "Vyplňte prázdné detaily položky a obálku prvním výsledkem shody z '{0}'. Nepřepisuje podrobnosti, pokud není povoleno nastavení serveru \"Preferovat párování metadata\".", "MessageQuickEmbedInProgress": "Probíhá rychlé vkládání",
"MessageQuickEmbedQueue": "Zařazeno do fronty pro rychlé vložení ({0} ve frontě)",
"MessageQuickMatchAllEpisodes": "Rychlá shoda všech epizod",
"MessageQuickMatchDescription": "Vyplnit prázdné detaily položky a obálky prvním výsledkem shody z '{0}'. Nepřepisuje detaily, pokud není povoleno nastavení serveru 'Preferovat shodná metadata'.",
"MessageRemoveChapter": "Odstranit kapitolu", "MessageRemoveChapter": "Odstranit kapitolu",
"MessageRemoveEpisodes": "Odstranit {0} epizodu", "MessageRemoveEpisodes": "Odstranit {0} epizodu",
"MessageRemoveFromPlayerQueue": "Odstranit z fronty přehrávače", "MessageRemoveFromPlayerQueue": "Odstranit z fronty přehrávače",
@ -797,10 +855,13 @@
"MessageTaskFailedToMergeAudioFiles": "Spojení audio souborů selhalo", "MessageTaskFailedToMergeAudioFiles": "Spojení audio souborů selhalo",
"MessageTaskFailedToMoveM4bFile": "Přesunutí m4b souboru selhalo", "MessageTaskFailedToMoveM4bFile": "Přesunutí m4b souboru selhalo",
"MessageTaskFailedToWriteMetadataFile": "Zápis souboru metadat selhal", "MessageTaskFailedToWriteMetadataFile": "Zápis souboru metadat selhal",
"MessageTaskMatchingBooksInLibrary": "Párování knih v knihovně „{0}“",
"MessageTaskNoFilesToScan": "Žádné soubory ke skenování", "MessageTaskNoFilesToScan": "Žádné soubory ke skenování",
"MessageTaskOpmlImport": "Import OPML", "MessageTaskOpmlImport": "Import OPML",
"MessageTaskOpmlImportDescription": "Vytváření podcastů z {0} RSS feedů", "MessageTaskOpmlImportDescription": "Vytváření podcastů z {0} RSS feedů",
"MessageTaskOpmlImportFeed": "Importní zdroj OPML",
"MessageTaskOpmlImportFeedDescription": "Importování RSS feedu \"{0}\"", "MessageTaskOpmlImportFeedDescription": "Importování RSS feedu \"{0}\"",
"MessageTaskOpmlImportFeedFailed": "Nepodařilo se získat kanál podcastu",
"MessageTaskOpmlImportFeedPodcastDescription": "Vytváření podcastu \"{0}\"", "MessageTaskOpmlImportFeedPodcastDescription": "Vytváření podcastu \"{0}\"",
"MessageTaskOpmlImportFeedPodcastExists": "Podcast se stejnou cestou již existuje", "MessageTaskOpmlImportFeedPodcastExists": "Podcast se stejnou cestou již existuje",
"MessageTaskOpmlImportFeedPodcastFailed": "Vytváření podcastu selhalo", "MessageTaskOpmlImportFeedPodcastFailed": "Vytváření podcastu selhalo",
@ -881,7 +942,6 @@
"ToastChaptersHaveErrors": "Kapitoly obsahují chyby", "ToastChaptersHaveErrors": "Kapitoly obsahují chyby",
"ToastChaptersMustHaveTitles": "Kapitoly musí mít názvy", "ToastChaptersMustHaveTitles": "Kapitoly musí mít názvy",
"ToastChaptersRemoved": "Kapitoly odstraněny", "ToastChaptersRemoved": "Kapitoly odstraněny",
"ToastCollectionItemsRemoveSuccess": "Položky odstraněny z kolekce",
"ToastCollectionRemoveSuccess": "Kolekce odstraněna", "ToastCollectionRemoveSuccess": "Kolekce odstraněna",
"ToastCollectionUpdateSuccess": "Kolekce aktualizována", "ToastCollectionUpdateSuccess": "Kolekce aktualizována",
"ToastCoverUpdateFailed": "Aktualizace obálky selhala", "ToastCoverUpdateFailed": "Aktualizace obálky selhala",

View File

@ -19,6 +19,7 @@
"ButtonChooseFiles": "Vælg filer", "ButtonChooseFiles": "Vælg filer",
"ButtonClearFilter": "Ryd filter", "ButtonClearFilter": "Ryd filter",
"ButtonCloseFeed": "Luk feed", "ButtonCloseFeed": "Luk feed",
"ButtonCloseSession": "Luk Åben Session",
"ButtonCollections": "Samlinger", "ButtonCollections": "Samlinger",
"ButtonConfigureScanner": "Konfigurer scanner", "ButtonConfigureScanner": "Konfigurer scanner",
"ButtonCreate": "Opret", "ButtonCreate": "Opret",
@ -29,7 +30,9 @@
"ButtonEditChapters": "Rediger kapitler", "ButtonEditChapters": "Rediger kapitler",
"ButtonEditPodcast": "Rediger podcast", "ButtonEditPodcast": "Rediger podcast",
"ButtonEnable": "Aktiver", "ButtonEnable": "Aktiver",
"ButtonForceReScan": "Tvungen genindlæsning", "ButtonFireAndFail": "Affyring Og Fejl",
"ButtonFireOnTest": "Affyring vedTest begivenhed",
"ButtonForceReScan": "Tving genindlæsning",
"ButtonFullPath": "Fuld sti", "ButtonFullPath": "Fuld sti",
"ButtonHide": "Skjul", "ButtonHide": "Skjul",
"ButtonHome": "Hjem", "ButtonHome": "Hjem",
@ -536,7 +539,6 @@
"MessageItemsSelected": "{0} elementer valgt", "MessageItemsSelected": "{0} elementer valgt",
"MessageItemsUpdated": "{0} elementer opdateret", "MessageItemsUpdated": "{0} elementer opdateret",
"MessageJoinUsOn": "Deltag i os på", "MessageJoinUsOn": "Deltag i os på",
"MessageListeningSessionsInTheLastYear": "{0} lyttesessioner i det sidste år",
"MessageLoading": "Indlæser...", "MessageLoading": "Indlæser...",
"MessageLoadingFolders": "Indlæser mapper...", "MessageLoadingFolders": "Indlæser mapper...",
"MessageM4BFailed": "M4B mislykkedes!", "MessageM4BFailed": "M4B mislykkedes!",
@ -637,7 +639,6 @@
"ToastBookmarkUpdateSuccess": "Bogmærke opdateret", "ToastBookmarkUpdateSuccess": "Bogmærke opdateret",
"ToastChaptersHaveErrors": "Kapitler har fejl", "ToastChaptersHaveErrors": "Kapitler har fejl",
"ToastChaptersMustHaveTitles": "Kapitler skal have titler", "ToastChaptersMustHaveTitles": "Kapitler skal have titler",
"ToastCollectionItemsRemoveSuccess": "Element(er) fjernet fra samlingen",
"ToastCollectionRemoveSuccess": "Samling fjernet", "ToastCollectionRemoveSuccess": "Samling fjernet",
"ToastCollectionUpdateSuccess": "Samling opdateret", "ToastCollectionUpdateSuccess": "Samling opdateret",
"ToastItemCoverUpdateSuccess": "Varens omslag opdateret", "ToastItemCoverUpdateSuccess": "Varens omslag opdateret",

View File

@ -88,6 +88,8 @@
"ButtonSaveTracklist": "Speichere die Titelliste", "ButtonSaveTracklist": "Speichere die Titelliste",
"ButtonScan": "Partial-Scan (nur geänderte/neue Medien)", "ButtonScan": "Partial-Scan (nur geänderte/neue Medien)",
"ButtonScanLibrary": "Bibliothek scannen", "ButtonScanLibrary": "Bibliothek scannen",
"ButtonScrollLeft": "Nach Links scrollen",
"ButtonScrollRight": "Nach Rechts scrollen",
"ButtonSearch": "Suchen", "ButtonSearch": "Suchen",
"ButtonSelectFolderPath": "Ordnerpfad auswählen", "ButtonSelectFolderPath": "Ordnerpfad auswählen",
"ButtonSeries": "Serien", "ButtonSeries": "Serien",
@ -190,6 +192,7 @@
"HeaderSettingsExperimental": "Experimentelle Funktionen", "HeaderSettingsExperimental": "Experimentelle Funktionen",
"HeaderSettingsGeneral": "Allgemein", "HeaderSettingsGeneral": "Allgemein",
"HeaderSettingsScanner": "Scanner", "HeaderSettingsScanner": "Scanner",
"HeaderSettingsWebClient": "Web-Client",
"HeaderSleepTimer": "Sleep-Timer", "HeaderSleepTimer": "Sleep-Timer",
"HeaderStatsLargestItems": "Größte Medien", "HeaderStatsLargestItems": "Größte Medien",
"HeaderStatsLongestItems": "Längste Medien (h)", "HeaderStatsLongestItems": "Längste Medien (h)",
@ -542,6 +545,7 @@
"LabelServerYearReview": "Server Jahr in Übersicht ({0})", "LabelServerYearReview": "Server Jahr in Übersicht ({0})",
"LabelSetEbookAsPrimary": "Als Hauptbuch setzen", "LabelSetEbookAsPrimary": "Als Hauptbuch setzen",
"LabelSetEbookAsSupplementary": "Als Ergänzung setzen", "LabelSetEbookAsSupplementary": "Als Ergänzung setzen",
"LabelSettingsAllowIframe": "Einbetten in einem iFrame erlauben",
"LabelSettingsAudiobooksOnly": "Nur Hörbücher", "LabelSettingsAudiobooksOnly": "Nur Hörbücher",
"LabelSettingsAudiobooksOnlyHelp": "Wenn du diese Einstellung aktivierst, werden E-Buch-Dateien ignoriert, es sei denn, sie befinden sich in einem Hörbuchordner. In diesem Fall werden sie als zusätzliche E-Bücher festgelegt", "LabelSettingsAudiobooksOnlyHelp": "Wenn du diese Einstellung aktivierst, werden E-Buch-Dateien ignoriert, es sei denn, sie befinden sich in einem Hörbuchordner. In diesem Fall werden sie als zusätzliche E-Bücher festgelegt",
"LabelSettingsBookshelfViewHelp": "Skeumorphes Design mit Holzeinlegeböden", "LabelSettingsBookshelfViewHelp": "Skeumorphes Design mit Holzeinlegeböden",
@ -592,6 +596,8 @@
"LabelSize": "Größe", "LabelSize": "Größe",
"LabelSleepTimer": "Schlummerfunktion", "LabelSleepTimer": "Schlummerfunktion",
"LabelSlug": "URL Teil", "LabelSlug": "URL Teil",
"LabelSortAscending": "Aufsteigend",
"LabelSortDescending": "Absteigend",
"LabelStart": "Start", "LabelStart": "Start",
"LabelStartTime": "Startzeit", "LabelStartTime": "Startzeit",
"LabelStarted": "Gestartet", "LabelStarted": "Gestartet",
@ -679,6 +685,8 @@
"LabelViewPlayerSettings": "Zeige player Einstellungen", "LabelViewPlayerSettings": "Zeige player Einstellungen",
"LabelViewQueue": "Player-Warteschlange anzeigen", "LabelViewQueue": "Player-Warteschlange anzeigen",
"LabelVolume": "Lautstärke", "LabelVolume": "Lautstärke",
"LabelWebRedirectURLsDescription": "Autorisiere diese URLs bei deinem OAuth-Anbieter, um die Weiterleitung zurück zur Webanwendung nach dem Login zu ermöglichen:",
"LabelWebRedirectURLsSubfolder": "Unterordner für Weiterleitung-URLs",
"LabelWeekdaysToRun": "Wochentage für die Ausführung", "LabelWeekdaysToRun": "Wochentage für die Ausführung",
"LabelXBooks": "{0} Bücher", "LabelXBooks": "{0} Bücher",
"LabelXItems": "{0} Medien", "LabelXItems": "{0} Medien",
@ -763,7 +771,6 @@
"MessageItemsSelected": "{0} ausgewählte Medien", "MessageItemsSelected": "{0} ausgewählte Medien",
"MessageItemsUpdated": "{0} Medien aktualisiert", "MessageItemsUpdated": "{0} Medien aktualisiert",
"MessageJoinUsOn": "Besuche uns auf", "MessageJoinUsOn": "Besuche uns auf",
"MessageListeningSessionsInTheLastYear": "{0} Ereignisse im letzten Jahr",
"MessageLoading": "Wird geladen …", "MessageLoading": "Wird geladen …",
"MessageLoadingFolders": "Lade Ordner...", "MessageLoadingFolders": "Lade Ordner...",
"MessageLogsDescription": "Die Logs werdern in <code>/metadata/logs</code> als JSON Dateien gespeichert. Crash logs werden in <code>/metadata/logs/crash_logs.txt</code> gespeichert.", "MessageLogsDescription": "Die Logs werdern in <code>/metadata/logs</code> als JSON Dateien gespeichert. Crash logs werden in <code>/metadata/logs/crash_logs.txt</code> gespeichert.",
@ -951,8 +958,6 @@
"ToastChaptersRemoved": "Kapitel entfernt", "ToastChaptersRemoved": "Kapitel entfernt",
"ToastChaptersUpdated": "Kapitel aktualisiert", "ToastChaptersUpdated": "Kapitel aktualisiert",
"ToastCollectionItemsAddFailed": "Das Hinzufügen von Element(en) zur Sammlung ist fehlgeschlagen", "ToastCollectionItemsAddFailed": "Das Hinzufügen von Element(en) zur Sammlung ist fehlgeschlagen",
"ToastCollectionItemsAddSuccess": "Element(e) erfolgreich zur Sammlung hinzugefügt",
"ToastCollectionItemsRemoveSuccess": "Medien aus der Sammlung entfernt",
"ToastCollectionRemoveSuccess": "Sammlung entfernt", "ToastCollectionRemoveSuccess": "Sammlung entfernt",
"ToastCollectionUpdateSuccess": "Sammlung aktualisiert", "ToastCollectionUpdateSuccess": "Sammlung aktualisiert",
"ToastCoverUpdateFailed": "Cover-Update fehlgeschlagen", "ToastCoverUpdateFailed": "Cover-Update fehlgeschlagen",

View File

@ -88,6 +88,8 @@
"ButtonSaveTracklist": "Save Tracklist", "ButtonSaveTracklist": "Save Tracklist",
"ButtonScan": "Scan", "ButtonScan": "Scan",
"ButtonScanLibrary": "Scan Library", "ButtonScanLibrary": "Scan Library",
"ButtonScrollLeft": "Scroll Left",
"ButtonScrollRight": "Scroll Right",
"ButtonSearch": "Search", "ButtonSearch": "Search",
"ButtonSelectFolderPath": "Select Folder Path", "ButtonSelectFolderPath": "Select Folder Path",
"ButtonSeries": "Series", "ButtonSeries": "Series",
@ -190,6 +192,7 @@
"HeaderSettingsExperimental": "Experimental Features", "HeaderSettingsExperimental": "Experimental Features",
"HeaderSettingsGeneral": "General", "HeaderSettingsGeneral": "General",
"HeaderSettingsScanner": "Scanner", "HeaderSettingsScanner": "Scanner",
"HeaderSettingsWebClient": "Web Client",
"HeaderSleepTimer": "Sleep Timer", "HeaderSleepTimer": "Sleep Timer",
"HeaderStatsLargestItems": "Largest Items", "HeaderStatsLargestItems": "Largest Items",
"HeaderStatsLongestItems": "Longest Items (hrs)", "HeaderStatsLongestItems": "Longest Items (hrs)",
@ -297,6 +300,7 @@
"LabelDiscover": "Discover", "LabelDiscover": "Discover",
"LabelDownload": "Download", "LabelDownload": "Download",
"LabelDownloadNEpisodes": "Download {0} episodes", "LabelDownloadNEpisodes": "Download {0} episodes",
"LabelDownloadable": "Downloadable",
"LabelDuration": "Duration", "LabelDuration": "Duration",
"LabelDurationComparisonExactMatch": "(exact match)", "LabelDurationComparisonExactMatch": "(exact match)",
"LabelDurationComparisonLonger": "({0} longer)", "LabelDurationComparisonLonger": "({0} longer)",
@ -542,6 +546,7 @@
"LabelServerYearReview": "Server Year in Review ({0})", "LabelServerYearReview": "Server Year in Review ({0})",
"LabelSetEbookAsPrimary": "Set as primary", "LabelSetEbookAsPrimary": "Set as primary",
"LabelSetEbookAsSupplementary": "Set as supplementary", "LabelSetEbookAsSupplementary": "Set as supplementary",
"LabelSettingsAllowIframe": "Allow embedding in an iframe",
"LabelSettingsAudiobooksOnly": "Audiobooks only", "LabelSettingsAudiobooksOnly": "Audiobooks only",
"LabelSettingsAudiobooksOnlyHelp": "Enabling this setting will ignore ebook files unless they are inside an audiobook folder in which case they will be set as supplementary ebooks", "LabelSettingsAudiobooksOnlyHelp": "Enabling this setting will ignore ebook files unless they are inside an audiobook folder in which case they will be set as supplementary ebooks",
"LabelSettingsBookshelfViewHelp": "Skeumorphic design with wooden shelves", "LabelSettingsBookshelfViewHelp": "Skeumorphic design with wooden shelves",
@ -584,6 +589,7 @@
"LabelSettingsStoreMetadataWithItemHelp": "By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders", "LabelSettingsStoreMetadataWithItemHelp": "By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders",
"LabelSettingsTimeFormat": "Time Format", "LabelSettingsTimeFormat": "Time Format",
"LabelShare": "Share", "LabelShare": "Share",
"LabelShareDownloadableHelp": "Allows users with the share link to download a zip file of the library item.",
"LabelShareOpen": "Share Open", "LabelShareOpen": "Share Open",
"LabelShareURL": "Share URL", "LabelShareURL": "Share URL",
"LabelShowAll": "Show All", "LabelShowAll": "Show All",
@ -592,6 +598,8 @@
"LabelSize": "Size", "LabelSize": "Size",
"LabelSleepTimer": "Sleep timer", "LabelSleepTimer": "Sleep timer",
"LabelSlug": "Slug", "LabelSlug": "Slug",
"LabelSortAscending": "Ascending",
"LabelSortDescending": "Descending",
"LabelStart": "Start", "LabelStart": "Start",
"LabelStartTime": "Start Time", "LabelStartTime": "Start Time",
"LabelStarted": "Started", "LabelStarted": "Started",
@ -750,6 +758,7 @@
"MessageConfirmResetProgress": "Are you sure you want to reset your progress?", "MessageConfirmResetProgress": "Are you sure you want to reset your progress?",
"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}\"?",
"MessageConfirmUnlinkOpenId": "Are you sure you want to unlink this user from OpenID?", "MessageConfirmUnlinkOpenId": "Are you sure you want to unlink this user from OpenID?",
"MessageDaysListenedInTheLastYear": "{0} days listened in the last year",
"MessageDownloadingEpisode": "Downloading episode", "MessageDownloadingEpisode": "Downloading episode",
"MessageDragFilesIntoTrackOrder": "Drag files into correct track order", "MessageDragFilesIntoTrackOrder": "Drag files into correct track order",
"MessageEmbedFailed": "Embed Failed!", "MessageEmbedFailed": "Embed Failed!",
@ -765,7 +774,6 @@
"MessageItemsSelected": "{0} Items Selected", "MessageItemsSelected": "{0} Items Selected",
"MessageItemsUpdated": "{0} Items Updated", "MessageItemsUpdated": "{0} Items Updated",
"MessageJoinUsOn": "Join us on", "MessageJoinUsOn": "Join us on",
"MessageListeningSessionsInTheLastYear": "{0} listening sessions in the last year",
"MessageLoading": "Loading...", "MessageLoading": "Loading...",
"MessageLoadingFolders": "Loading folders...", "MessageLoadingFolders": "Loading folders...",
"MessageLogsDescription": "Logs are stored in <code>/metadata/logs</code> as JSON files. Crash logs are stored in <code>/metadata/logs/crash_logs.txt</code>.", "MessageLogsDescription": "Logs are stored in <code>/metadata/logs</code> as JSON files. Crash logs are stored in <code>/metadata/logs/crash_logs.txt</code>.",
@ -953,8 +961,6 @@
"ToastChaptersRemoved": "Chapters removed", "ToastChaptersRemoved": "Chapters removed",
"ToastChaptersUpdated": "Chapters updated", "ToastChaptersUpdated": "Chapters updated",
"ToastCollectionItemsAddFailed": "Item(s) added to collection failed", "ToastCollectionItemsAddFailed": "Item(s) added to collection failed",
"ToastCollectionItemsAddSuccess": "Item(s) added to collection success",
"ToastCollectionItemsRemoveSuccess": "Item(s) removed from collection",
"ToastCollectionRemoveSuccess": "Collection removed", "ToastCollectionRemoveSuccess": "Collection removed",
"ToastCollectionUpdateSuccess": "Collection updated", "ToastCollectionUpdateSuccess": "Collection updated",
"ToastCoverUpdateFailed": "Cover update failed", "ToastCoverUpdateFailed": "Cover update failed",

View File

@ -88,6 +88,8 @@
"ButtonSaveTracklist": "Guardar Tracklist", "ButtonSaveTracklist": "Guardar Tracklist",
"ButtonScan": "Escanear", "ButtonScan": "Escanear",
"ButtonScanLibrary": "Escanear Biblioteca", "ButtonScanLibrary": "Escanear Biblioteca",
"ButtonScrollLeft": "Desplazarse hacia la izquierda",
"ButtonScrollRight": "Desplazarse hacia la derecha",
"ButtonSearch": "Buscar", "ButtonSearch": "Buscar",
"ButtonSelectFolderPath": "Seleccionar Ruta de Carpeta", "ButtonSelectFolderPath": "Seleccionar Ruta de Carpeta",
"ButtonSeries": "Series", "ButtonSeries": "Series",
@ -190,6 +192,7 @@
"HeaderSettingsExperimental": "Funciones Experimentales", "HeaderSettingsExperimental": "Funciones Experimentales",
"HeaderSettingsGeneral": "General", "HeaderSettingsGeneral": "General",
"HeaderSettingsScanner": "Escáner", "HeaderSettingsScanner": "Escáner",
"HeaderSettingsWebClient": "Cliente web",
"HeaderSleepTimer": "Temporizador de apagado", "HeaderSleepTimer": "Temporizador de apagado",
"HeaderStatsLargestItems": "Artículos mas Grandes", "HeaderStatsLargestItems": "Artículos mas Grandes",
"HeaderStatsLongestItems": "Artículos mas Largos (h)", "HeaderStatsLongestItems": "Artículos mas Largos (h)",
@ -542,6 +545,7 @@
"LabelServerYearReview": "Resumen del año del servidor ({0})", "LabelServerYearReview": "Resumen del año del servidor ({0})",
"LabelSetEbookAsPrimary": "Establecer como primario", "LabelSetEbookAsPrimary": "Establecer como primario",
"LabelSetEbookAsSupplementary": "Establecer como suplementario", "LabelSetEbookAsSupplementary": "Establecer como suplementario",
"LabelSettingsAllowIframe": "Permitir incrustación en un iframe",
"LabelSettingsAudiobooksOnly": "Sólo Audiolibros", "LabelSettingsAudiobooksOnly": "Sólo Audiolibros",
"LabelSettingsAudiobooksOnlyHelp": "Al activar esta opción se ignorarán los archivos de ebook a menos de que estén dentro de la carpeta de un audiolibro, en cuyo caso se marcarán como ebooks suplementarios", "LabelSettingsAudiobooksOnlyHelp": "Al activar esta opción se ignorarán los archivos de ebook a menos de que estén dentro de la carpeta de un audiolibro, en cuyo caso se marcarán como ebooks suplementarios",
"LabelSettingsBookshelfViewHelp": "Diseño Esqueuomorfo con Estantes de Madera", "LabelSettingsBookshelfViewHelp": "Diseño Esqueuomorfo con Estantes de Madera",
@ -592,6 +596,8 @@
"LabelSize": "Tamaño", "LabelSize": "Tamaño",
"LabelSleepTimer": "Temporizador de apagado", "LabelSleepTimer": "Temporizador de apagado",
"LabelSlug": "Slug", "LabelSlug": "Slug",
"LabelSortAscending": "Ascendente",
"LabelSortDescending": "Descendente",
"LabelStart": "Iniciar", "LabelStart": "Iniciar",
"LabelStartTime": "Tiempo de Inicio", "LabelStartTime": "Tiempo de Inicio",
"LabelStarted": "Iniciado", "LabelStarted": "Iniciado",
@ -765,7 +771,6 @@
"MessageItemsSelected": "{0} Elementos Seleccionados", "MessageItemsSelected": "{0} Elementos Seleccionados",
"MessageItemsUpdated": "{0} Elementos Actualizados", "MessageItemsUpdated": "{0} Elementos Actualizados",
"MessageJoinUsOn": "Únetenos en", "MessageJoinUsOn": "Únetenos en",
"MessageListeningSessionsInTheLastYear": "{0} sesiones de escucha en el último año",
"MessageLoading": "Cargando...", "MessageLoading": "Cargando...",
"MessageLoadingFolders": "Cargando archivos...", "MessageLoadingFolders": "Cargando archivos...",
"MessageLogsDescription": "Logs son almacenados en <code>/metadata/logs</code> en archivos bajo formato JSON. Logs de fallos son almacenados en <code>/metadata/logs/crash_logs.txt</code>.", "MessageLogsDescription": "Logs son almacenados en <code>/metadata/logs</code> en archivos bajo formato JSON. Logs de fallos son almacenados en <code>/metadata/logs/crash_logs.txt</code>.",
@ -953,8 +958,6 @@
"ToastChaptersRemoved": "Capítulos eliminados", "ToastChaptersRemoved": "Capítulos eliminados",
"ToastChaptersUpdated": "Capítulos actualizados", "ToastChaptersUpdated": "Capítulos actualizados",
"ToastCollectionItemsAddFailed": "Artículo(s) añadido(s) a la colección fallido(s)", "ToastCollectionItemsAddFailed": "Artículo(s) añadido(s) a la colección fallido(s)",
"ToastCollectionItemsAddSuccess": "Artículo(s) añadido(s) a la colección correctamente",
"ToastCollectionItemsRemoveSuccess": "Elementos(s) removidos de la colección",
"ToastCollectionRemoveSuccess": "Colección removida", "ToastCollectionRemoveSuccess": "Colección removida",
"ToastCollectionUpdateSuccess": "Colección actualizada", "ToastCollectionUpdateSuccess": "Colección actualizada",
"ToastCoverUpdateFailed": "Error al actualizar la cubierta", "ToastCoverUpdateFailed": "Error al actualizar la cubierta",

View File

@ -611,7 +611,6 @@
"MessageItemsSelected": "{0} Valitud üksust", "MessageItemsSelected": "{0} Valitud üksust",
"MessageItemsUpdated": "{0} Üksust on uuendatud", "MessageItemsUpdated": "{0} Üksust on uuendatud",
"MessageJoinUsOn": "Liitu meiega", "MessageJoinUsOn": "Liitu meiega",
"MessageListeningSessionsInTheLastYear": "Kuulamissessioone viimase aasta jooksul: {0}",
"MessageLoading": "Laadimine...", "MessageLoading": "Laadimine...",
"MessageLoadingFolders": "Kaustade laadimine...", "MessageLoadingFolders": "Kaustade laadimine...",
"MessageM4BFailed": "M4B ebaõnnestus!", "MessageM4BFailed": "M4B ebaõnnestus!",
@ -713,7 +712,6 @@
"ToastBookmarkUpdateSuccess": "Järjehoidja värskendatud", "ToastBookmarkUpdateSuccess": "Järjehoidja värskendatud",
"ToastChaptersHaveErrors": "Peatükkidel on vigu", "ToastChaptersHaveErrors": "Peatükkidel on vigu",
"ToastChaptersMustHaveTitles": "Peatükkidel peab olema pealkiri", "ToastChaptersMustHaveTitles": "Peatükkidel peab olema pealkiri",
"ToastCollectionItemsRemoveSuccess": "Üksus(ed) eemaldatud kogumist",
"ToastCollectionRemoveSuccess": "Kogum eemaldatud", "ToastCollectionRemoveSuccess": "Kogum eemaldatud",
"ToastCollectionUpdateSuccess": "Kogum värskendatud", "ToastCollectionUpdateSuccess": "Kogum värskendatud",
"ToastItemCoverUpdateSuccess": "Üksuse kaas värskendatud", "ToastItemCoverUpdateSuccess": "Üksuse kaas värskendatud",

View File

@ -592,6 +592,8 @@
"LabelSize": "Taille", "LabelSize": "Taille",
"LabelSleepTimer": "Minuterie de mise en veille", "LabelSleepTimer": "Minuterie de mise en veille",
"LabelSlug": "Identifiant dURL", "LabelSlug": "Identifiant dURL",
"LabelSortAscending": "Croissant",
"LabelSortDescending": "Décroissant",
"LabelStart": "Démarrer", "LabelStart": "Démarrer",
"LabelStartTime": "Heure de démarrage", "LabelStartTime": "Heure de démarrage",
"LabelStarted": "Démarré", "LabelStarted": "Démarré",
@ -763,7 +765,6 @@
"MessageItemsSelected": "{0} éléments sélectionnés", "MessageItemsSelected": "{0} éléments sélectionnés",
"MessageItemsUpdated": "{0} éléments mis à jour", "MessageItemsUpdated": "{0} éléments mis à jour",
"MessageJoinUsOn": "Rejoignez-nous sur", "MessageJoinUsOn": "Rejoignez-nous sur",
"MessageListeningSessionsInTheLastYear": "{0} sessions découte lan dernier",
"MessageLoading": "Chargement…", "MessageLoading": "Chargement…",
"MessageLoadingFolders": "Chargement des dossiers…", "MessageLoadingFolders": "Chargement des dossiers…",
"MessageLogsDescription": "Les journaux sont stockés dans <code>/metadata/logs</code> sous forme de fichiers JSON. Les journaux dincidents sont stockés dans <code>/metadata/logs/crash_logs.txt</code>.", "MessageLogsDescription": "Les journaux sont stockés dans <code>/metadata/logs</code> sous forme de fichiers JSON. Les journaux dincidents sont stockés dans <code>/metadata/logs/crash_logs.txt</code>.",
@ -951,8 +952,6 @@
"ToastChaptersRemoved": "Chapitres supprimés", "ToastChaptersRemoved": "Chapitres supprimés",
"ToastChaptersUpdated": "Chapitres mis à jour", "ToastChaptersUpdated": "Chapitres mis à jour",
"ToastCollectionItemsAddFailed": "Échec de lajout de(s) élément(s) à la collection", "ToastCollectionItemsAddFailed": "Échec de lajout de(s) élément(s) à la collection",
"ToastCollectionItemsAddSuccess": "Ajout de(s) élément(s) à la collection réussi",
"ToastCollectionItemsRemoveSuccess": "Élément(s) supprimé(s) de la collection",
"ToastCollectionRemoveSuccess": "Collection supprimée", "ToastCollectionRemoveSuccess": "Collection supprimée",
"ToastCollectionUpdateSuccess": "Collection mise à jour", "ToastCollectionUpdateSuccess": "Collection mise à jour",
"ToastCoverUpdateFailed": "Échec de la mise à jour de la couverture", "ToastCoverUpdateFailed": "Échec de la mise à jour de la couverture",

View File

@ -642,7 +642,6 @@
"MessageItemsSelected": "{0} פריטים נבחרו", "MessageItemsSelected": "{0} פריטים נבחרו",
"MessageItemsUpdated": "{0} פריטים עודכנו", "MessageItemsUpdated": "{0} פריטים עודכנו",
"MessageJoinUsOn": "הצטרף אלינו ב-", "MessageJoinUsOn": "הצטרף אלינו ב-",
"MessageListeningSessionsInTheLastYear": "{0} מפגשי האזנה בשנה האחרונה",
"MessageLoading": "טוען...", "MessageLoading": "טוען...",
"MessageLoadingFolders": "טוען תיקיות...", "MessageLoadingFolders": "טוען תיקיות...",
"MessageM4BFailed": "M4B נכשל!", "MessageM4BFailed": "M4B נכשל!",
@ -744,7 +743,6 @@
"ToastBookmarkUpdateSuccess": "הסימניה עודכנה בהצלחה", "ToastBookmarkUpdateSuccess": "הסימניה עודכנה בהצלחה",
"ToastChaptersHaveErrors": "פרקים מכילים שגיאות", "ToastChaptersHaveErrors": "פרקים מכילים שגיאות",
"ToastChaptersMustHaveTitles": "פרקים חייבים לכלול כותרות", "ToastChaptersMustHaveTitles": "פרקים חייבים לכלול כותרות",
"ToastCollectionItemsRemoveSuccess": "הפריט(ים) הוסרו מהאוסף בהצלחה",
"ToastCollectionRemoveSuccess": "האוסף הוסר בהצלחה", "ToastCollectionRemoveSuccess": "האוסף הוסר בהצלחה",
"ToastCollectionUpdateSuccess": "האוסף עודכן בהצלחה", "ToastCollectionUpdateSuccess": "האוסף עודכן בהצלחה",
"ToastItemCoverUpdateSuccess": "כריכת הפריט עודכנה בהצלחה", "ToastItemCoverUpdateSuccess": "כריכת הפריט עודכנה בהצלחה",

View File

@ -88,6 +88,8 @@
"ButtonSaveTracklist": "Spremi popis zvučnih zapisa", "ButtonSaveTracklist": "Spremi popis zvučnih zapisa",
"ButtonScan": "Skeniraj", "ButtonScan": "Skeniraj",
"ButtonScanLibrary": "Skeniraj knjižnicu", "ButtonScanLibrary": "Skeniraj knjižnicu",
"ButtonScrollLeft": "Pomicanje lijevo",
"ButtonScrollRight": "Pomicanje desno",
"ButtonSearch": "Traži", "ButtonSearch": "Traži",
"ButtonSelectFolderPath": "Odaberi putanju mape", "ButtonSelectFolderPath": "Odaberi putanju mape",
"ButtonSeries": "Serijali", "ButtonSeries": "Serijali",
@ -190,6 +192,7 @@
"HeaderSettingsExperimental": "Eksperimentalne značajke", "HeaderSettingsExperimental": "Eksperimentalne značajke",
"HeaderSettingsGeneral": "Općenito", "HeaderSettingsGeneral": "Općenito",
"HeaderSettingsScanner": "Skener", "HeaderSettingsScanner": "Skener",
"HeaderSettingsWebClient": "Web klijent",
"HeaderSleepTimer": "Timer za spavanje", "HeaderSleepTimer": "Timer za spavanje",
"HeaderStatsLargestItems": "Najveće stavke", "HeaderStatsLargestItems": "Najveće stavke",
"HeaderStatsLongestItems": "Najduže stavke (sati)", "HeaderStatsLongestItems": "Najduže stavke (sati)",
@ -542,6 +545,7 @@
"LabelServerYearReview": "Godišnji pregled poslužitelja ({0})", "LabelServerYearReview": "Godišnji pregled poslužitelja ({0})",
"LabelSetEbookAsPrimary": "Postavi kao primarno", "LabelSetEbookAsPrimary": "Postavi kao primarno",
"LabelSetEbookAsSupplementary": "Postavi kao dopunsko", "LabelSetEbookAsSupplementary": "Postavi kao dopunsko",
"LabelSettingsAllowIframe": "Omogući ugrađivanje u iframeu",
"LabelSettingsAudiobooksOnly": "Samo zvučne knjige", "LabelSettingsAudiobooksOnly": "Samo zvučne knjige",
"LabelSettingsAudiobooksOnlyHelp": "Ako uključite ovu mogućnost, sustav će zanemariti datoteke e-knjiga ukoliko se ne nalaze u mapi zvučne knjige, gdje će se smatrati dopunskim e-knjigama", "LabelSettingsAudiobooksOnlyHelp": "Ako uključite ovu mogućnost, sustav će zanemariti datoteke e-knjiga ukoliko se ne nalaze u mapi zvučne knjige, gdje će se smatrati dopunskim e-knjigama",
"LabelSettingsBookshelfViewHelp": "Skeumorfni dizajn sa drvenim policama", "LabelSettingsBookshelfViewHelp": "Skeumorfni dizajn sa drvenim policama",
@ -592,6 +596,8 @@
"LabelSize": "Veličina", "LabelSize": "Veličina",
"LabelSleepTimer": "Timer za spavanje", "LabelSleepTimer": "Timer za spavanje",
"LabelSlug": "Slug", "LabelSlug": "Slug",
"LabelSortAscending": "Uzlazno",
"LabelSortDescending": "Silazno",
"LabelStart": "Početak", "LabelStart": "Početak",
"LabelStartTime": "Vrijeme početka", "LabelStartTime": "Vrijeme početka",
"LabelStarted": "Započeto", "LabelStarted": "Započeto",
@ -765,7 +771,6 @@
"MessageItemsSelected": "{0} odabranih stavki", "MessageItemsSelected": "{0} odabranih stavki",
"MessageItemsUpdated": "{0} stavki ažurirano", "MessageItemsUpdated": "{0} stavki ažurirano",
"MessageJoinUsOn": "Pridruži nam se na", "MessageJoinUsOn": "Pridruži nam se na",
"MessageListeningSessionsInTheLastYear": "{0} slušanja u prošloj godini",
"MessageLoading": "Učitavam...", "MessageLoading": "Učitavam...",
"MessageLoadingFolders": "Učitavam mape...", "MessageLoadingFolders": "Učitavam mape...",
"MessageLogsDescription": "Zapisnici se čuvaju u <code>/metadata/logs</code> u obliku JSON datoteka. Zapisnici pada sustava čuvaju se u datoteci <code>/metadata/logs/crash_logs.txt</code>.", "MessageLogsDescription": "Zapisnici se čuvaju u <code>/metadata/logs</code> u obliku JSON datoteka. Zapisnici pada sustava čuvaju se u datoteci <code>/metadata/logs/crash_logs.txt</code>.",
@ -953,8 +958,6 @@
"ToastChaptersRemoved": "Poglavlja uklonjena", "ToastChaptersRemoved": "Poglavlja uklonjena",
"ToastChaptersUpdated": "Poglavlja su ažurirana", "ToastChaptersUpdated": "Poglavlja su ažurirana",
"ToastCollectionItemsAddFailed": "Neuspješno dodavanje stavki u zbirku", "ToastCollectionItemsAddFailed": "Neuspješno dodavanje stavki u zbirku",
"ToastCollectionItemsAddSuccess": "Uspješno dodavanje stavki u zbirku",
"ToastCollectionItemsRemoveSuccess": "Stavke izbrisane iz zbirke",
"ToastCollectionRemoveSuccess": "Zbirka izbrisana", "ToastCollectionRemoveSuccess": "Zbirka izbrisana",
"ToastCollectionUpdateSuccess": "Zbirka ažurirana", "ToastCollectionUpdateSuccess": "Zbirka ažurirana",
"ToastCoverUpdateFailed": "Ažuriranje naslovnice nije uspjelo", "ToastCoverUpdateFailed": "Ažuriranje naslovnice nije uspjelo",

View File

@ -88,6 +88,8 @@
"ButtonSaveTracklist": "Sávlista mentése", "ButtonSaveTracklist": "Sávlista mentése",
"ButtonScan": "Szkennelés", "ButtonScan": "Szkennelés",
"ButtonScanLibrary": "Könyvtár szkennelése", "ButtonScanLibrary": "Könyvtár szkennelése",
"ButtonScrollLeft": "Balra görgetés",
"ButtonScrollRight": "Jobbra görgetés",
"ButtonSearch": "Keresés", "ButtonSearch": "Keresés",
"ButtonSelectFolderPath": "Mappa útvonalának kiválasztása", "ButtonSelectFolderPath": "Mappa útvonalának kiválasztása",
"ButtonSeries": "Sorozatok", "ButtonSeries": "Sorozatok",
@ -180,6 +182,7 @@
"HeaderRemoveEpisodes": "{0} epizód eltávolítása", "HeaderRemoveEpisodes": "{0} epizód eltávolítása",
"HeaderSavedMediaProgress": "Mentett médialejátszási állapot", "HeaderSavedMediaProgress": "Mentett médialejátszási állapot",
"HeaderSchedule": "Ütemezés", "HeaderSchedule": "Ütemezés",
"HeaderScheduleEpisodeDownloads": "Automatikus epizódletöltés ütemezése",
"HeaderScheduleLibraryScans": "Könyvtárak automatikus szkennelésének ütemezése", "HeaderScheduleLibraryScans": "Könyvtárak automatikus szkennelésének ütemezése",
"HeaderSession": "Munkamenet", "HeaderSession": "Munkamenet",
"HeaderSetBackupSchedule": "Biztonsági másolatok ütemezésének beállítása", "HeaderSetBackupSchedule": "Biztonsági másolatok ütemezésének beállítása",
@ -188,13 +191,14 @@
"HeaderSettingsExperimental": "Kísérleti funkciók", "HeaderSettingsExperimental": "Kísérleti funkciók",
"HeaderSettingsGeneral": "Általános", "HeaderSettingsGeneral": "Általános",
"HeaderSettingsScanner": "Szkenner", "HeaderSettingsScanner": "Szkenner",
"HeaderSettingsWebClient": "Webkliens",
"HeaderSleepTimer": "Alvásidőzítő", "HeaderSleepTimer": "Alvásidőzítő",
"HeaderStatsLargestItems": "Legnagyobb elemek", "HeaderStatsLargestItems": "Legnagyobb elemek",
"HeaderStatsLongestItems": "Leghosszabb elemek (órákban)", "HeaderStatsLongestItems": "Leghosszabb elemek (órákban)",
"HeaderStatsMinutesListeningChart": "Hallgatási grafikon percekben (az elmúlt 7 napból)", "HeaderStatsMinutesListeningChart": "Hallgatási grafikon percekben (az elmúlt 7 napból)",
"HeaderStatsRecentSessions": "Legutóbbi munkamenetek", "HeaderStatsRecentSessions": "Legutóbbi munkamenetek",
"HeaderStatsTop10Authors": "Top 10 szerzők", "HeaderStatsTop10Authors": "Top 10 szerző",
"HeaderStatsTop5Genres": "Top 5 műfajok", "HeaderStatsTop5Genres": "Top 5 műfaj",
"HeaderTableOfContents": "Tartalomjegyzék", "HeaderTableOfContents": "Tartalomjegyzék",
"HeaderTools": "Eszközök", "HeaderTools": "Eszközök",
"HeaderUpdateAccount": "Fiók frissítése", "HeaderUpdateAccount": "Fiók frissítése",
@ -202,7 +206,7 @@
"HeaderUpdateDetails": "Részletek frissítése", "HeaderUpdateDetails": "Részletek frissítése",
"HeaderUpdateLibrary": "Könyvtár frissítése", "HeaderUpdateLibrary": "Könyvtár frissítése",
"HeaderUsers": "Felhasználók", "HeaderUsers": "Felhasználók",
"HeaderYearReview": "{0} év áttekintése", "HeaderYearReview": "{0} év visszatekintése",
"HeaderYourStats": "Saját statisztikák", "HeaderYourStats": "Saját statisztikák",
"LabelAbridged": "Tömörített", "LabelAbridged": "Tömörített",
"LabelAbridgedChecked": "Rövidített (ellenőrizve)", "LabelAbridgedChecked": "Rövidített (ellenőrizve)",
@ -225,7 +229,11 @@
"LabelAllUsersExcludingGuests": "Minden felhasználó, vendégek kivételével", "LabelAllUsersExcludingGuests": "Minden felhasználó, vendégek kivételével",
"LabelAllUsersIncludingGuests": "Minden felhasználó, beleértve a vendégeket is", "LabelAllUsersIncludingGuests": "Minden felhasználó, beleértve a vendégeket is",
"LabelAlreadyInYourLibrary": "Már a könyvtárában van", "LabelAlreadyInYourLibrary": "Már a könyvtárában van",
"LabelApiToken": "API Token",
"LabelAppend": "Hozzáfűzés", "LabelAppend": "Hozzáfűzés",
"LabelAudioBitrate": "Audió bitráta (pl.128k)",
"LabelAudioChannels": "Audió csatorna (1 vagy 2)",
"LabelAudioCodec": "Audio Codec",
"LabelAuthor": "Szerző", "LabelAuthor": "Szerző",
"LabelAuthorFirstLast": "Szerző (Keresztnév Vezetéknév)", "LabelAuthorFirstLast": "Szerző (Keresztnév Vezetéknév)",
"LabelAuthorLastFirst": "Szerző (Vezetéknév, Keresztnév)", "LabelAuthorLastFirst": "Szerző (Vezetéknév, Keresztnév)",
@ -238,6 +246,7 @@
"LabelAutoRegister": "Automatikus regisztráció", "LabelAutoRegister": "Automatikus regisztráció",
"LabelAutoRegisterDescription": "Új felhasználók automatikus létrehozása bejelentkezés után", "LabelAutoRegisterDescription": "Új felhasználók automatikus létrehozása bejelentkezés után",
"LabelBackToUser": "Vissza a felhasználóhoz", "LabelBackToUser": "Vissza a felhasználóhoz",
"LabelBackupAudioFiles": "Audiófájlok biztonsági mentése",
"LabelBackupLocation": "Biztonsági másolat helye", "LabelBackupLocation": "Biztonsági másolat helye",
"LabelBackupsEnableAutomaticBackups": "Automatikus biztonsági másolatok engedélyezése", "LabelBackupsEnableAutomaticBackups": "Automatikus biztonsági másolatok engedélyezése",
"LabelBackupsEnableAutomaticBackupsHelp": "Biztonsági másolatok mentése a /metadata/backups mappába", "LabelBackupsEnableAutomaticBackupsHelp": "Biztonsági másolatok mentése a /metadata/backups mappába",
@ -246,15 +255,18 @@
"LabelBackupsNumberToKeep": "Megtartandó biztonsági másolatok száma", "LabelBackupsNumberToKeep": "Megtartandó biztonsági másolatok száma",
"LabelBackupsNumberToKeepHelp": "Egyszerre csak 1 biztonsági másolat kerül eltávolításra, tehát ha már több biztonsági másolat van, mint ez a szám, akkor manuálisan kell eltávolítani őket.", "LabelBackupsNumberToKeepHelp": "Egyszerre csak 1 biztonsági másolat kerül eltávolításra, tehát ha már több biztonsági másolat van, mint ez a szám, akkor manuálisan kell eltávolítani őket.",
"LabelBitrate": "Bitráta", "LabelBitrate": "Bitráta",
"LabelBonus": "Bónusz",
"LabelBooks": "Könyvek", "LabelBooks": "Könyvek",
"LabelButtonText": "Gomb szövege", "LabelButtonText": "Gomb szövege",
"LabelByAuthor": "{} által", "LabelByAuthor": "{} által",
"LabelChangePassword": "Jelszó megváltoztatása", "LabelChangePassword": "Jelszó megváltoztatása",
"LabelChannels": "Csatornák", "LabelChannels": "Csatornák",
"LabelChapterCount": "{0} Fejezet",
"LabelChapterTitle": "Fejezet címe", "LabelChapterTitle": "Fejezet címe",
"LabelChapters": "Fejezetek", "LabelChapters": "Fejezetek",
"LabelChaptersFound": "fejezet található", "LabelChaptersFound": "fejezet található",
"LabelClickForMoreInfo": "További információkért kattintson", "LabelClickForMoreInfo": "További információkért kattintson",
"LabelClickToUseCurrentValue": "Kattintson az aktuális érték használatához",
"LabelClosePlayer": "Lejátszó bezárása", "LabelClosePlayer": "Lejátszó bezárása",
"LabelCodec": "Kodek", "LabelCodec": "Kodek",
"LabelCollapseSeries": "Sorozat összecsukása", "LabelCollapseSeries": "Sorozat összecsukása",
@ -304,16 +316,28 @@
"LabelEmailSettingsTestAddress": "Teszt cím", "LabelEmailSettingsTestAddress": "Teszt cím",
"LabelEmbeddedCover": "Beágyazott borító", "LabelEmbeddedCover": "Beágyazott borító",
"LabelEnable": "Engedélyezés", "LabelEnable": "Engedélyezés",
"LabelEncodingBackupLocation": "Az eredeti hangfájlok biztonsági másolata a következő helyen lesz tárolva:",
"LabelEncodingChaptersNotEmbedded": "A fejezetek nincsenek beágyazva a többsávos hangoskönyvekbe.",
"LabelEncodingClearItemCache": "Győződjön meg róla, hogy rendszeresen tisztítja az elemek gyorsítótárát.",
"LabelEncodingFinishedM4B": "A kész M4B a hangoskönyv mappádba kerül:",
"LabelEncodingStartedNavigation": "Ha a feladat elindult, el lehet navigálni erről az oldalról.",
"LabelEncodingTimeWarning": "A kódolás akár 30 percet is igénybe vehet.",
"LabelEncodingWarningAdvancedSettings": "Figyelmeztetés: Ne frissítse ezeket a beállításokat, hacsak nem ismeri az ffmpeg kódolási beállításait.",
"LabelEncodingWatcherDisabled": "Ha a figyelőt letiltotta, akkor ezt a hangoskönyvet utólag újra be kell olvasnia.",
"LabelEnd": "Vége", "LabelEnd": "Vége",
"LabelEndOfChapter": "Fejezet vége", "LabelEndOfChapter": "Fejezet vége",
"LabelEpisode": "Epizód", "LabelEpisode": "Epizód",
"LabelEpisodeNotLinkedToRssFeed": "Epizód nem kapcsolódik RSS hírcsatonához",
"LabelEpisodeNumber": "Epizód #{0}",
"LabelEpisodeTitle": "Epizód címe", "LabelEpisodeTitle": "Epizód címe",
"LabelEpisodeType": "Epizód típusa", "LabelEpisodeType": "Epizód típusa",
"LabelEpisodeUrlFromRssFeed": "Epizód URL-címe az RSS hírcsatornából",
"LabelEpisodes": "Epizódok", "LabelEpisodes": "Epizódok",
"LabelEpisodic": "Epizódikus",
"LabelExample": "Példa", "LabelExample": "Példa",
"LabelExpandSeries": "Sorozat kinyitása", "LabelExpandSeries": "Sorozat kinyitása",
"LabelExpandSubSeries": "Alsorozat kinyitása", "LabelExpandSubSeries": "Alsorozat kinyitása",
"LabelExplicit": "Explicit", "LabelExplicit": "Szókimondó",
"LabelExplicitChecked": "Explicit (ellenőrizve)", "LabelExplicitChecked": "Explicit (ellenőrizve)",
"LabelExplicitUnchecked": "Nem explicit (nem ellenőrzött)", "LabelExplicitUnchecked": "Nem explicit (nem ellenőrzött)",
"LabelExportOPML": "OPML exportálása", "LabelExportOPML": "OPML exportálása",
@ -337,6 +361,7 @@
"LabelFontScale": "Betűméret skála", "LabelFontScale": "Betűméret skála",
"LabelFontStrikethrough": "Áthúzott", "LabelFontStrikethrough": "Áthúzott",
"LabelFormat": "Formátum", "LabelFormat": "Formátum",
"LabelFull": "Teljes",
"LabelGenre": "Műfaj", "LabelGenre": "Műfaj",
"LabelGenres": "Műfajok", "LabelGenres": "Műfajok",
"LabelHardDeleteFile": "Fájl végleges törlése", "LabelHardDeleteFile": "Fájl végleges törlése",
@ -392,6 +417,10 @@
"LabelLowestPriority": "Legalacsonyabb prioritás", "LabelLowestPriority": "Legalacsonyabb prioritás",
"LabelMatchExistingUsersBy": "Meglévő felhasználók egyeztetése", "LabelMatchExistingUsersBy": "Meglévő felhasználók egyeztetése",
"LabelMatchExistingUsersByDescription": "Meglévő felhasználók összekapcsolására használt. Egyszer összekapcsolva, a felhasználók egyedülálló azonosítóval lesznek egyeztetve az Ön SSO szolgáltatójától", "LabelMatchExistingUsersByDescription": "Meglévő felhasználók összekapcsolására használt. Egyszer összekapcsolva, a felhasználók egyedülálló azonosítóval lesznek egyeztetve az Ön SSO szolgáltatójától",
"LabelMaxEpisodesToDownload": "Letölthető epizódok maximális száma. Használja a 0-t a korlátlan letöltéshez.",
"LabelMaxEpisodesToDownloadPerCheck": "Ellenőrzésenként letölthető új epizódok maximális száma",
"LabelMaxEpisodesToKeep": "Maximálisan megtartható epizódok száma",
"LabelMaxEpisodesToKeepHelp": "A 0 érték nem állít be maximális korlátot. Az új epizód automatikus letöltése után ez a beállítás törli a legrégebbi epizódot, ha X epizódnál több van. Új letöltésenként csak 1 epizódot töröl.",
"LabelMediaPlayer": "Médialejátszó", "LabelMediaPlayer": "Médialejátszó",
"LabelMediaType": "Média típus", "LabelMediaType": "Média típus",
"LabelMetaTag": "Meta címke", "LabelMetaTag": "Meta címke",
@ -399,7 +428,7 @@
"LabelMetadataOrderOfPrecedenceDescription": "A magasabb prioritású metaadat-források felülírják az alacsonyabb prioritásúakat", "LabelMetadataOrderOfPrecedenceDescription": "A magasabb prioritású metaadat-források felülírják az alacsonyabb prioritásúakat",
"LabelMetadataProvider": "Metaadat-szolgáltató", "LabelMetadataProvider": "Metaadat-szolgáltató",
"LabelMinute": "Perc", "LabelMinute": "Perc",
"LabelMinutes": "Percek", "LabelMinutes": "Perc",
"LabelMissing": "Hiányzó", "LabelMissing": "Hiányzó",
"LabelMissingEbook": "Nincs e-könyve", "LabelMissingEbook": "Nincs e-könyve",
"LabelMissingSupplementaryEbook": "Nincs kiegészítő e-könyve", "LabelMissingSupplementaryEbook": "Nincs kiegészítő e-könyve",
@ -434,20 +463,22 @@
"LabelNumberOfEpisodes": "Epizódok száma", "LabelNumberOfEpisodes": "Epizódok száma",
"LabelOpenIDAdvancedPermsClaimDescription": "Az OpenID-igény neve, amely a felhasználói műveletekre vonatkozó haladó jogosultságokat tartalmazza az alkalmazáson belül, és amely a nem adminisztrátori szerepkörökre vonatkozik (<b>ha konfigurálva van</b>). Ha az igény hiányzik a válaszból, az ABS-hez való hozzáférés megtagadásra kerül. Ha egyetlen opció hiányzik, azt <code>false</code>-ként fogja kezelni. Győződj meg arról, hogy az identitásszolgáltató igénye megfelel a várt struktúrának:", "LabelOpenIDAdvancedPermsClaimDescription": "Az OpenID-igény neve, amely a felhasználói műveletekre vonatkozó haladó jogosultságokat tartalmazza az alkalmazáson belül, és amely a nem adminisztrátori szerepkörökre vonatkozik (<b>ha konfigurálva van</b>). Ha az igény hiányzik a válaszból, az ABS-hez való hozzáférés megtagadásra kerül. Ha egyetlen opció hiányzik, azt <code>false</code>-ként fogja kezelni. Győződj meg arról, hogy az identitásszolgáltató igénye megfelel a várt struktúrának:",
"LabelOpenIDClaims": "Hagyd üresen a következő opciókat, hogy letiltsd a haladó csoport- és jogosultság-hozzárendelést, ekkor automatikusan a Felhasználó csoport kerül hozzárendelésre.", "LabelOpenIDClaims": "Hagyd üresen a következő opciókat, hogy letiltsd a haladó csoport- és jogosultság-hozzárendelést, ekkor automatikusan a Felhasználó csoport kerül hozzárendelésre.",
"LabelOpenIDGroupClaimDescription": "Az OpenID-igény neve, amely a felhasználó csoportjainak listáját tartalmazza. Általában groups néven hivatkoznak rá. Ha konfigurálva van, az alkalmazás automatikusan hozzárendeli a szerepköröket a felhasználó csoporttagságai alapján, feltéve, hogy ezek a csoportok az igényben kis- és nagybetűkre érzéketlenül admin, user vagy guest néven szerepelnek. Az igénynek egy listát kell tartalmaznia, és ha egy felhasználó több csoport tagja, az alkalmazás a legmagasabb szintű hozzáféréssel rendelkező szerepkört rendeli hozzá. Ha egyetlen csoport sem felel meg, a hozzáférés megtagadásra kerül.", "LabelOpenIDGroupClaimDescription": "Az OpenID-igény neve, amely a felhasználó csoportjainak listáját tartalmazza. Általában <code>groups<code> néven hivatkoznak rá. <b>Ha konfigurálva van<b>, az alkalmazás automatikusan hozzárendeli a szerepköröket a felhasználó csoporttagságai alapján, feltéve, hogy ezek a csoportok az igényben kis- és nagybetűkre érzéketlenül admin, user vagy guest néven szerepelnek. Az igénynek egy listát kell tartalmaznia, és ha egy felhasználó több csoport tagja, az alkalmazás a legmagasabb szintű hozzáféréssel rendelkező szerepkört rendeli hozzá. Ha egyetlen csoport sem felel meg, a hozzáférés megtagadásra kerül.",
"LabelOpenRSSFeed": "RSS hírcsatorna megnyitása", "LabelOpenRSSFeed": "RSS hírcsatorna megnyitása",
"LabelOverwrite": "Felülírás", "LabelOverwrite": "Felülírás",
"LabelPaginationPageXOfY": "{0} oldal {1}-ból/ből",
"LabelPassword": "Jelszó", "LabelPassword": "Jelszó",
"LabelPath": "Útvonal", "LabelPath": "Útvonal",
"LabelPermanent": "Végleges", "LabelPermanent": "Végleges",
"LabelPermissionsAccessAllLibraries": "Hozzáférhet az összes könyvtárhoz", "LabelPermissionsAccessAllLibraries": "Hozzáférhet az összes könyvtárhoz",
"LabelPermissionsAccessAllTags": "Hozzáférhet az összes címkéhez", "LabelPermissionsAccessAllTags": "Hozzáférhet az összes címkéhez",
"LabelPermissionsAccessExplicitContent": "Hozzáférhet explicit tartalomhoz", "LabelPermissionsAccessExplicitContent": "Hozzáférhet explicit tartalomhoz",
"LabelPermissionsCreateEreader": "Létrehozhat Ereader-t",
"LabelPermissionsDelete": "Törölhet", "LabelPermissionsDelete": "Törölhet",
"LabelPermissionsDownload": "Letölthet", "LabelPermissionsDownload": "Letölthet",
"LabelPermissionsUpdate": "Frissíthet", "LabelPermissionsUpdate": "Frissíthet",
"LabelPermissionsUpload": "Feltölthet", "LabelPermissionsUpload": "Feltölthet",
"LabelPersonalYearReview": "Az éved áttekintése ({0})", "LabelPersonalYearReview": "Az évvisszatekintésed ({0})",
"LabelPhotoPathURL": "Fénykép útvonal/URL", "LabelPhotoPathURL": "Fénykép útvonal/URL",
"LabelPlayMethod": "Lejátszási módszer", "LabelPlayMethod": "Lejátszási módszer",
"LabelPlayerChapterNumberMarker": "{0} a {1} -ből", "LabelPlayerChapterNumberMarker": "{0} a {1} -ből",
@ -466,6 +497,8 @@
"LabelPubDate": "Kiadás dátuma", "LabelPubDate": "Kiadás dátuma",
"LabelPublishYear": "Kiadás éve", "LabelPublishYear": "Kiadás éve",
"LabelPublishedDate": "Kiadva {0}", "LabelPublishedDate": "Kiadva {0}",
"LabelPublishedDecade": "Közzétett évtized",
"LabelPublishedDecades": "Közzétett évtized",
"LabelPublisher": "Kiadó", "LabelPublisher": "Kiadó",
"LabelPublishers": "Kiadók", "LabelPublishers": "Kiadók",
"LabelRSSFeedCustomOwnerEmail": "Egyéni tulajdonos e-mail", "LabelRSSFeedCustomOwnerEmail": "Egyéni tulajdonos e-mail",
@ -475,6 +508,7 @@
"LabelRSSFeedSlug": "RSS hírcsatorna slug", "LabelRSSFeedSlug": "RSS hírcsatorna slug",
"LabelRSSFeedURL": "RSS hírcsatorna URL", "LabelRSSFeedURL": "RSS hírcsatorna URL",
"LabelRandomly": "Véletlenszerűen", "LabelRandomly": "Véletlenszerűen",
"LabelReAddSeriesToContinueListening": "Sorozat újbóli hozzáadása a folytatáshoz",
"LabelRead": "Olvasás", "LabelRead": "Olvasás",
"LabelReadAgain": "Újraolvasás", "LabelReadAgain": "Újraolvasás",
"LabelReadEbookWithoutProgress": "E-könyv olvasása haladás nélkül", "LabelReadEbookWithoutProgress": "E-könyv olvasása haladás nélkül",
@ -484,12 +518,18 @@
"LabelRedo": "Újra", "LabelRedo": "Újra",
"LabelRegion": "Régió", "LabelRegion": "Régió",
"LabelReleaseDate": "Megjelenés dátuma", "LabelReleaseDate": "Megjelenés dátuma",
"LabelRemoveAllMetadataAbs": "Az összes metadata.abs fájl eltávolítása",
"LabelRemoveAllMetadataJson": "Az összes metadata.json fájl eltávolítása",
"LabelRemoveCover": "Borító eltávolítása", "LabelRemoveCover": "Borító eltávolítása",
"LabelRemoveMetadataFile": "Metaadatfájlok eltávolítása a könyvtár elemek mappáiból",
"LabelRemoveMetadataFileHelp": "A metadata.json és metadata.abs fájlokat eltávolítása a {0} mappáidból.",
"LabelRowsPerPage": "Sorok száma oldalanként", "LabelRowsPerPage": "Sorok száma oldalanként",
"LabelSearchTerm": "Keresési kifejezés", "LabelSearchTerm": "Keresési kifejezés",
"LabelSearchTitle": "Cím keresése", "LabelSearchTitle": "Cím keresése",
"LabelSearchTitleOrASIN": "Cím vagy ASIN keresése", "LabelSearchTitleOrASIN": "Cím vagy ASIN keresése",
"LabelSeason": "Évad", "LabelSeason": "Évad",
"LabelSeasonNumber": "Évad #{0}",
"LabelSelectAll": "Minden kiválasztása",
"LabelSelectAllEpisodes": "Összes epizód kiválasztása", "LabelSelectAllEpisodes": "Összes epizód kiválasztása",
"LabelSelectEpisodesShowing": "Kiválasztás {0} megjelenített epizód", "LabelSelectEpisodesShowing": "Kiválasztás {0} megjelenített epizód",
"LabelSelectUsers": "Felhasználók kiválasztása", "LabelSelectUsers": "Felhasználók kiválasztása",
@ -498,8 +538,11 @@
"LabelSeries": "Sorozat", "LabelSeries": "Sorozat",
"LabelSeriesName": "Sorozat neve", "LabelSeriesName": "Sorozat neve",
"LabelSeriesProgress": "Sorozat haladása", "LabelSeriesProgress": "Sorozat haladása",
"LabelServerLogLevel": "Kiszolgáló naplózási szint",
"LabelServerYearReview": "Szerver évvisszatekintés ({0})",
"LabelSetEbookAsPrimary": "Beállítás elsődlegesként", "LabelSetEbookAsPrimary": "Beállítás elsődlegesként",
"LabelSetEbookAsSupplementary": "Beállítás kiegészítőként", "LabelSetEbookAsSupplementary": "Beállítás kiegészítőként",
"LabelSettingsAllowIframe": "A beágyazás engedélyezése egy iframe-be",
"LabelSettingsAudiobooksOnly": "Csak hangoskönyvek", "LabelSettingsAudiobooksOnly": "Csak hangoskönyvek",
"LabelSettingsAudiobooksOnlyHelp": "Ennek a beállításnak az engedélyezése figyelmen kívül hagyja az e-könyv fájlokat, kivéve, ha azok egy hangoskönyv mappában vannak, ebben az esetben kiegészítő e-könyvként lesznek beállítva", "LabelSettingsAudiobooksOnlyHelp": "Ennek a beállításnak az engedélyezése figyelmen kívül hagyja az e-könyv fájlokat, kivéve, ha azok egy hangoskönyv mappában vannak, ebben az esetben kiegészítő e-könyvként lesznek beállítva",
"LabelSettingsBookshelfViewHelp": "Skeuomorfikus dizájn fa polcokkal", "LabelSettingsBookshelfViewHelp": "Skeuomorfikus dizájn fa polcokkal",
@ -511,6 +554,8 @@
"LabelSettingsEnableWatcher": "Figyelő engedélyezése", "LabelSettingsEnableWatcher": "Figyelő engedélyezése",
"LabelSettingsEnableWatcherForLibrary": "Mappafigyelő engedélyezése a könyvtárban", "LabelSettingsEnableWatcherForLibrary": "Mappafigyelő engedélyezése a könyvtárban",
"LabelSettingsEnableWatcherHelp": "Engedélyezi az automatikus elem hozzáadás/frissítés funkciót, amikor fájlváltozásokat észlel. *Szerver újraindítása szükséges", "LabelSettingsEnableWatcherHelp": "Engedélyezi az automatikus elem hozzáadás/frissítés funkciót, amikor fájlváltozásokat észlel. *Szerver újraindítása szükséges",
"LabelSettingsEpubsAllowScriptedContent": "Szkriptelt tartalmak engedélyezése epub-okban",
"LabelSettingsEpubsAllowScriptedContentHelp": "Megengedi, hogy az epub fájlok szkripteket hajtsanak végre. Ezt a beállítást kikapcsolva ajánlott tartani, kivéve, ha megbízik az epub fájlok forrásában.",
"LabelSettingsExperimentalFeatures": "Kísérleti funkciók", "LabelSettingsExperimentalFeatures": "Kísérleti funkciók",
"LabelSettingsExperimentalFeaturesHelp": "Fejlesztés alatt álló funkciók, amelyek visszajelzésre és tesztelésre szorulnak. Kattintson a github megbeszélés megnyitásához.", "LabelSettingsExperimentalFeaturesHelp": "Fejlesztés alatt álló funkciók, amelyek visszajelzésre és tesztelésre szorulnak. Kattintson a github megbeszélés megnyitásához.",
"LabelSettingsFindCovers": "Borítók keresése", "LabelSettingsFindCovers": "Borítók keresése",
@ -519,6 +564,11 @@
"LabelSettingsHideSingleBookSeriesHelp": "A csak egy könyvet tartalmazó sorozatok el lesznek rejtve a sorozatok oldalról és a kezdőlap polcairól.", "LabelSettingsHideSingleBookSeriesHelp": "A csak egy könyvet tartalmazó sorozatok el lesznek rejtve a sorozatok oldalról és a kezdőlap polcairól.",
"LabelSettingsHomePageBookshelfView": "Kezdőlap használja a könyvespolc nézetet", "LabelSettingsHomePageBookshelfView": "Kezdőlap használja a könyvespolc nézetet",
"LabelSettingsLibraryBookshelfView": "Könyvtár használja a könyvespolc nézetet", "LabelSettingsLibraryBookshelfView": "Könyvtár használja a könyvespolc nézetet",
"LabelSettingsLibraryMarkAsFinishedPercentComplete": "Százalékos befejezettség nagyobb mint",
"LabelSettingsLibraryMarkAsFinishedTimeRemaining": "A hátralévő idő kevesebb, mint (másodperc)",
"LabelSettingsLibraryMarkAsFinishedWhen": "A médiaelem befejezettnek jelölése, ha",
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Megelőző könyvek kihagyása a Sorozat folytatásában",
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "A Sorozat folytatása kezdőlap polcán az első nem megkezdett könyv látható egy olyan sorozatban, amelynek legalább egy könyve befejeződött, és nincs folyamatban lévő rész. Ha engedélyezi ezt a beállítást, akkor a sorozatot a legvégső befejezett könyvtől folytatja az első el nem kezdett könyv helyett.",
"LabelSettingsParseSubtitles": "Feliratok elemzése", "LabelSettingsParseSubtitles": "Feliratok elemzése",
"LabelSettingsParseSubtitlesHelp": "Feliratok kinyerése a hangoskönyv mappaneveiből.<br>A feliratnak el kell különülnie egy \" - \" jellel<br>például: \"Könyv címe - Egy felirat itt\" esetén a felirat \"Egy felirat itt\"", "LabelSettingsParseSubtitlesHelp": "Feliratok kinyerése a hangoskönyv mappaneveiből.<br>A feliratnak el kell különülnie egy \" - \" jellel<br>például: \"Könyv címe - Egy felirat itt\" esetén a felirat \"Egy felirat itt\"",
"LabelSettingsPreferMatchedMetadata": "Preferált egyeztetett metaadatok", "LabelSettingsPreferMatchedMetadata": "Preferált egyeztetett metaadatok",
@ -534,10 +584,14 @@
"LabelSettingsStoreMetadataWithItem": "Metaadatok tárolása az elemmel", "LabelSettingsStoreMetadataWithItem": "Metaadatok tárolása az elemmel",
"LabelSettingsStoreMetadataWithItemHelp": "Alapértelmezés szerint a metaadatfájlok a /metadata/items mappában vannak tárolva, ennek a beállításnak az engedélyezése a metaadatfájlokat a könyvtári elem mappáiban tárolja", "LabelSettingsStoreMetadataWithItemHelp": "Alapértelmezés szerint a metaadatfájlok a /metadata/items mappában vannak tárolva, ennek a beállításnak az engedélyezése a metaadatfájlokat a könyvtári elem mappáiban tárolja",
"LabelSettingsTimeFormat": "Időformátum", "LabelSettingsTimeFormat": "Időformátum",
"LabelShare": "Megosztás",
"LabelShowAll": "Mindent mutat", "LabelShowAll": "Mindent mutat",
"LabelShowSubtitles": "Felirat megjelenítése",
"LabelSize": "Méret", "LabelSize": "Méret",
"LabelSleepTimer": "Alvásidőzítő", "LabelSleepTimer": "Alvásidőzítő",
"LabelSlug": "Rövid cím", "LabelSlug": "Rövid cím",
"LabelSortAscending": "Emelkedő",
"LabelSortDescending": "Csökkenő",
"LabelStart": "Kezdés", "LabelStart": "Kezdés",
"LabelStartTime": "Kezdési idő", "LabelStartTime": "Kezdési idő",
"LabelStarted": "Elkezdődött", "LabelStarted": "Elkezdődött",
@ -547,13 +601,13 @@
"LabelStatsBestDay": "Legjobb nap", "LabelStatsBestDay": "Legjobb nap",
"LabelStatsDailyAverage": "Napi átlag", "LabelStatsDailyAverage": "Napi átlag",
"LabelStatsDays": "Napok", "LabelStatsDays": "Napok",
"LabelStatsDaysListened": "Hallgatott napok", "LabelStatsDaysListened": "Napon hallgatva",
"LabelStatsHours": "Órák", "LabelStatsHours": "Órák",
"LabelStatsInARow": "egymás után", "LabelStatsInARow": "egymás után",
"LabelStatsItemsFinished": "Befejezett elemek", "LabelStatsItemsFinished": "Befejezett elem",
"LabelStatsItemsInLibrary": "Elemek a könyvtárban", "LabelStatsItemsInLibrary": "Elemek a könyvtárban",
"LabelStatsMinutes": "percek", "LabelStatsMinutes": "perc",
"LabelStatsMinutesListening": "Hallgatási percek", "LabelStatsMinutesListening": "Hallgatási perc",
"LabelStatsOverallDays": "Összes nap", "LabelStatsOverallDays": "Összes nap",
"LabelStatsOverallHours": "Összes óra", "LabelStatsOverallHours": "Összes óra",
"LabelStatsWeekListening": "Heti hallgatás", "LabelStatsWeekListening": "Heti hallgatás",
@ -565,12 +619,18 @@
"LabelTagsNotAccessibleToUser": "A felhasználó számára nem elérhető címkék", "LabelTagsNotAccessibleToUser": "A felhasználó számára nem elérhető címkék",
"LabelTasks": "Futó feladatok", "LabelTasks": "Futó feladatok",
"LabelTextEditorBulletedList": "Pontozott lista", "LabelTextEditorBulletedList": "Pontozott lista",
"LabelTextEditorLink": "Hivatkozás",
"LabelTextEditorNumberedList": "Számozott lista", "LabelTextEditorNumberedList": "Számozott lista",
"LabelTextEditorUnlink": "Link eltávolítása", "LabelTextEditorUnlink": "Link eltávolítása",
"LabelTheme": "Téma", "LabelTheme": "Téma",
"LabelThemeDark": "Sötét", "LabelThemeDark": "Sötét",
"LabelThemeLight": "Világos", "LabelThemeLight": "Világos",
"LabelTimeBase": "Időalap", "LabelTimeBase": "Időalap",
"LabelTimeDurationXHours": "{0} óra",
"LabelTimeDurationXMinutes": "{0} perc",
"LabelTimeDurationXSeconds": "{0} másodperc",
"LabelTimeInMinutes": "Idő percben",
"LabelTimeLeft": "{0} maradt hátra",
"LabelTimeListened": "Hallgatott idő", "LabelTimeListened": "Hallgatott idő",
"LabelTimeListenedToday": "Ma hallgatott idő", "LabelTimeListenedToday": "Ma hallgatott idő",
"LabelTimeRemaining": "{0} maradt", "LabelTimeRemaining": "{0} maradt",
@ -578,6 +638,7 @@
"LabelTitle": "Cím", "LabelTitle": "Cím",
"LabelToolsEmbedMetadata": "Metaadatok beágyazása", "LabelToolsEmbedMetadata": "Metaadatok beágyazása",
"LabelToolsEmbedMetadataDescription": "Metaadatok beágyazása az audiofájlokba, beleértve a borítóképet és a fejezeteket.", "LabelToolsEmbedMetadataDescription": "Metaadatok beágyazása az audiofájlokba, beleértve a borítóképet és a fejezeteket.",
"LabelToolsM4bEncoder": "M4B kódoló",
"LabelToolsMakeM4b": "M4B Hangoskönyv fájl készítése", "LabelToolsMakeM4b": "M4B Hangoskönyv fájl készítése",
"LabelToolsMakeM4bDescription": ".M4B hangoskönyv fájl generálása beágyazott metaadatokkal, borítóképpel és fejezetekkel.", "LabelToolsMakeM4bDescription": ".M4B hangoskönyv fájl generálása beágyazott metaadatokkal, borítóképpel és fejezetekkel.",
"LabelToolsSplitM4b": "M4B felosztása MP3-ra", "LabelToolsSplitM4b": "M4B felosztása MP3-ra",
@ -590,29 +651,41 @@
"LabelTracksMultiTrack": "Többsávos", "LabelTracksMultiTrack": "Többsávos",
"LabelTracksNone": "Nincsenek sávok", "LabelTracksNone": "Nincsenek sávok",
"LabelTracksSingleTrack": "Egysávos", "LabelTracksSingleTrack": "Egysávos",
"LabelTrailer": "Előzetes",
"LabelType": "Típus", "LabelType": "Típus",
"LabelUnabridged": "Nem tömörített", "LabelUnabridged": "Nem tömörített",
"LabelUndo": "Visszavonás", "LabelUndo": "Visszavonás",
"LabelUnknown": "Ismeretlen", "LabelUnknown": "Ismeretlen",
"LabelUnknownPublishDate": "Ismeretlen megjelenési dátum",
"LabelUpdateCover": "Borító frissítése", "LabelUpdateCover": "Borító frissítése",
"LabelUpdateCoverHelp": "Lehetővé teszi a meglévő borítók felülírását a kiválasztott könyveknél, amikor találatot talál", "LabelUpdateCoverHelp": "Lehetővé teszi a meglévő borítók felülírását a kiválasztott könyveknél, amikor találatot talál",
"LabelUpdateDetails": "Részletek frissítése", "LabelUpdateDetails": "Részletek frissítése",
"LabelUpdateDetailsHelp": "Lehetővé teszi a meglévő részletek felülírását a kiválasztott könyveknél, amikor találatot talál", "LabelUpdateDetailsHelp": "Lehetővé teszi a meglévő részletek felülírását a kiválasztott könyveknél, amikor találatot talál",
"LabelUpdatedAt": "Frissítve", "LabelUpdatedAt": "Frissítve",
"LabelUploaderDragAndDrop": "Fájlok vagy mappák húzása és elengedése", "LabelUploaderDragAndDrop": "Fájlok vagy mappák húzása és elengedése",
"LabelUploaderDragAndDropFilesOnly": "Fájlok húzása és elengedése",
"LabelUploaderDropFiles": "Fájlok elengedése", "LabelUploaderDropFiles": "Fájlok elengedése",
"LabelUploaderItemFetchMetadataHelp": "Cím, szerző és sorozat automatikus lekérése", "LabelUploaderItemFetchMetadataHelp": "Cím, szerző és sorozat automatikus lekérése",
"LabelUseAdvancedOptions": "Haladó beállítások használata",
"LabelUseChapterTrack": "Fejezetsáv használata", "LabelUseChapterTrack": "Fejezetsáv használata",
"LabelUseFullTrack": "Teljes sáv használata", "LabelUseFullTrack": "Teljes sáv használata",
"LabelUseZeroForUnlimited": "Használja a 0-t a korlátlan értékhez",
"LabelUser": "Felhasználó", "LabelUser": "Felhasználó",
"LabelUsername": "Felhasználónév", "LabelUsername": "Felhasználónév",
"LabelValue": "Érték", "LabelValue": "Érték",
"LabelVersion": "Verzió", "LabelVersion": "Verzió",
"LabelViewBookmarks": "Könyvjelzők megtekintése", "LabelViewBookmarks": "Könyvjelzők megtekintése",
"LabelViewChapters": "Fejezetek megtekintése", "LabelViewChapters": "Fejezetek megtekintése",
"LabelViewPlayerSettings": "A lejátszó beállításainak megtekintése",
"LabelViewQueue": "Lejátszó sor megtekintése", "LabelViewQueue": "Lejátszó sor megtekintése",
"LabelVolume": "Hangerő", "LabelVolume": "Hangerő",
"LabelWebRedirectURLsDescription": "Engedélyezze ezeket az URL-címeket az OAuth-szolgáltatóban, hogy a bejelentkezés után vissza lehessen irányítani a webes alkalmazáshoz:",
"LabelWebRedirectURLsSubfolder": "Almappa átirányító URL-ek számára",
"LabelWeekdaysToRun": "Futás napjai", "LabelWeekdaysToRun": "Futás napjai",
"LabelXBooks": "{0} könyv",
"LabelXItems": "{0} elem",
"LabelYearReviewHide": "Az évvisszatekintés elrejtése",
"LabelYearReviewShow": "Évvisszatekintés megtekintése",
"LabelYourAudiobookDuration": "Hangoskönyv időtartama", "LabelYourAudiobookDuration": "Hangoskönyv időtartama",
"LabelYourBookmarks": "Könyvjelzőid", "LabelYourBookmarks": "Könyvjelzőid",
"LabelYourPlaylists": "Lejátszási listáid", "LabelYourPlaylists": "Lejátszási listáid",
@ -620,10 +693,14 @@
"MessageAddToPlayerQueue": "Hozzáadás a lejátszó sorhoz", "MessageAddToPlayerQueue": "Hozzáadás a lejátszó sorhoz",
"MessageAppriseDescription": "Ennek a funkció használatához futtatnia kell egy <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> példányt vagy egy olyan API-t, amely kezeli ezeket a kéréseket. <br />Az Apprise API URL-nek a teljes URL útvonalat kell tartalmaznia az értesítés elküldéséhez, például, ha az API példánya a <code>http://192.168.1.1:8337</code> címen szolgáltatva, akkor <code>http://192.168.1.1:8337/notify</code> értéket kell megadnia.", "MessageAppriseDescription": "Ennek a funkció használatához futtatnia kell egy <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> példányt vagy egy olyan API-t, amely kezeli ezeket a kéréseket. <br />Az Apprise API URL-nek a teljes URL útvonalat kell tartalmaznia az értesítés elküldéséhez, például, ha az API példánya a <code>http://192.168.1.1:8337</code> címen szolgáltatva, akkor <code>http://192.168.1.1:8337/notify</code> értéket kell megadnia.",
"MessageBackupsDescription": "A biztonsági másolatok tartalmazzák a felhasználókat, a felhasználói haladást, a könyvtári elem részleteit, a szerver beállításait és a képeket, amelyek a <code>/metadata/items</code> és <code>/metadata/authors</code> mappákban vannak tárolva. A biztonsági másolatok <strong>nem</strong> tartalmazzák a könyvtári mappákban tárolt fájlokat.", "MessageBackupsDescription": "A biztonsági másolatok tartalmazzák a felhasználókat, a felhasználói haladást, a könyvtári elem részleteit, a szerver beállításait és a képeket, amelyek a <code>/metadata/items</code> és <code>/metadata/authors</code> mappákban vannak tárolva. A biztonsági másolatok <strong>nem</strong> tartalmazzák a könyvtári mappákban tárolt fájlokat.",
"MessageBackupsLocationEditNote": "Megjegyzés: A biztonsági mentés helyének frissítése nem mozgatja vagy módosítja a meglévő biztonsági mentéseket",
"MessageBackupsLocationNoEditNote": "Megjegyzés: A biztonsági mentés helye egy környezeti változóval van beállítva, és itt nem módosítható.",
"MessageBackupsLocationPathEmpty": "A biztonsági mentés helyének elérési útvonala nem lehet üres",
"MessageBatchQuickMatchDescription": "A Gyors egyeztetés megpróbálja hozzáadni a hiányzó borítókat és metaadatokat a kiválasztott elemekhez. Engedélyezze az alábbi opciókat, hogy a Gyors egyeztetés felülírhassa a meglévő borítókat és/vagy metaadatokat.", "MessageBatchQuickMatchDescription": "A Gyors egyeztetés megpróbálja hozzáadni a hiányzó borítókat és metaadatokat a kiválasztott elemekhez. Engedélyezze az alábbi opciókat, hogy a Gyors egyeztetés felülírhassa a meglévő borítókat és/vagy metaadatokat.",
"MessageBookshelfNoCollections": "Még nem készített gyűjteményeket", "MessageBookshelfNoCollections": "Még nem készített gyűjteményeket",
"MessageBookshelfNoRSSFeeds": "Nincsenek nyitott RSS hírcsatornák", "MessageBookshelfNoRSSFeeds": "Nincsenek nyitott RSS hírcsatornák",
"MessageBookshelfNoResultsForFilter": "Nincs eredmény a \"{0}: {1}\" szűrőre", "MessageBookshelfNoResultsForFilter": "Nincs eredmény a \"{0}: {1}\" szűrőre",
"MessageBookshelfNoResultsForQuery": "Nincs eredmény a lekérdezéshez",
"MessageBookshelfNoSeries": "Nincsenek sorozatai", "MessageBookshelfNoSeries": "Nincsenek sorozatai",
"MessageChapterEndIsAfter": "A fejezet vége a hangoskönyv végét követi", "MessageChapterEndIsAfter": "A fejezet vége a hangoskönyv végét követi",
"MessageChapterErrorFirstNotZero": "Az első fejezetnek 0:00-kor kell kezdődnie", "MessageChapterErrorFirstNotZero": "Az első fejezetnek 0:00-kor kell kezdődnie",
@ -633,17 +710,27 @@
"MessageCheckingCron": "Cron ellenőrzése...", "MessageCheckingCron": "Cron ellenőrzése...",
"MessageConfirmCloseFeed": "Biztosan be szeretné zárni ezt a hírcsatornát?", "MessageConfirmCloseFeed": "Biztosan be szeretné zárni ezt a hírcsatornát?",
"MessageConfirmDeleteBackup": "Biztosan törölni szeretné a(z) {0} biztonsági másolatot?", "MessageConfirmDeleteBackup": "Biztosan törölni szeretné a(z) {0} biztonsági másolatot?",
"MessageConfirmDeleteDevice": "Biztos, hogy törölni szeretné a „{0}” e-olvasó eszközt?",
"MessageConfirmDeleteFile": "Ez törölni fogja a fájlt a fájlrendszerből. Biztos benne?", "MessageConfirmDeleteFile": "Ez törölni fogja a fájlt a fájlrendszerből. Biztos benne?",
"MessageConfirmDeleteLibrary": "Biztosan véglegesen törölni szeretné a(z) \"{0}\" könyvtárat?", "MessageConfirmDeleteLibrary": "Biztosan véglegesen törölni szeretné a(z) \"{0}\" könyvtárat?",
"MessageConfirmDeleteLibraryItem": "Ez eltávolítja a könyvtári elemet az adatbázisból és a fájlrendszerből. Biztos benne?", "MessageConfirmDeleteLibraryItem": "Ez eltávolítja a könyvtári elemet az adatbázisból és a fájlrendszerből. Biztos benne?",
"MessageConfirmDeleteLibraryItems": "Ez eltávolítja a(z) {0} könyvtári elemet az adatbázisból és a fájlrendszerből. Biztos benne?", "MessageConfirmDeleteLibraryItems": "Ez eltávolítja a(z) {0} könyvtári elemet az adatbázisból és a fájlrendszerből. Biztos benne?",
"MessageConfirmDeleteMetadataProvider": "Biztos, hogy törölni szeretné a „{0}” egyéni metaadat-szolgáltatót?",
"MessageConfirmDeleteNotification": "Biztos, hogy törölni szeretné ezt az értesítést?",
"MessageConfirmDeleteSession": "Biztosan törölni szeretné ezt a munkamenetet?", "MessageConfirmDeleteSession": "Biztosan törölni szeretné ezt a munkamenetet?",
"MessageConfirmEmbedMetadataInAudioFiles": "Biztos, hogy metaadatokat szeretne beágyazni {0} hangfájlba?",
"MessageConfirmForceReScan": "Biztosan kényszeríteni szeretné az újraszkennelést?", "MessageConfirmForceReScan": "Biztosan kényszeríteni szeretné az újraszkennelést?",
"MessageConfirmMarkAllEpisodesFinished": "Biztosan meg szeretné jelölni az összes epizódot befejezettnek?", "MessageConfirmMarkAllEpisodesFinished": "Biztosan meg szeretné jelölni az összes epizódot befejezettnek?",
"MessageConfirmMarkAllEpisodesNotFinished": "Biztosan meg szeretné jelölni az összes epizódot nem befejezettnek?", "MessageConfirmMarkAllEpisodesNotFinished": "Biztosan meg szeretné jelölni az összes epizódot nem befejezettnek?",
"MessageConfirmMarkItemFinished": "Biztos, hogy a „{0}”-t befejezettnek akarja jelölni?",
"MessageConfirmMarkItemNotFinished": "Biztos, hogy a „{0}”-t befejezetlennek akarja jelölni?",
"MessageConfirmMarkSeriesFinished": "Biztosan meg szeretné jelölni a sorozat összes könyvét befejezettnek?", "MessageConfirmMarkSeriesFinished": "Biztosan meg szeretné jelölni a sorozat összes könyvét befejezettnek?",
"MessageConfirmMarkSeriesNotFinished": "Biztosan meg szeretné jelölni a sorozat összes könyvét nem befejezettnek?", "MessageConfirmMarkSeriesNotFinished": "Biztosan meg szeretné jelölni a sorozat összes könyvét nem befejezettnek?",
"MessageConfirmNotificationTestTrigger": "Ez az értesítés indítható tesztadatokkal?",
"MessageConfirmPurgeCache": "A gyorsítótár kiürítése törli a teljes könyvtárat a <code>/metadata/cache</code> helyről. <br /><br />Biztosan eltávolítja a gyorsítótár könyvtárát?",
"MessageConfirmPurgeItemsCache": "Az elemek gyorsítótárának kiürítése törli a teljes könyvtárat a <code>/metadata/cache/items</code> helyről.<br />Biztos benne?",
"MessageConfirmQuickEmbed": "Figyelem! A Gyors beágyazás nem készít biztonsági másolatot az audiofájlokról. Győződjön meg arról, hogy van biztonsági másolata az audiofájlokról. <br><br>Szeretné folytatni?", "MessageConfirmQuickEmbed": "Figyelem! A Gyors beágyazás nem készít biztonsági másolatot az audiofájlokról. Győződjön meg arról, hogy van biztonsági másolata az audiofájlokról. <br><br>Szeretné folytatni?",
"MessageConfirmQuickMatchEpisodes": "Az epizódok gyors megfeleltetése felülírja a részleteket, ha egyezést talál. Csak a nem egyező epizódok frissülnek. Biztos benne?",
"MessageConfirmReScanLibraryItems": "Biztosan újra szeretné szkennelni a(z) {0} elemet?", "MessageConfirmReScanLibraryItems": "Biztosan újra szeretné szkennelni a(z) {0} elemet?",
"MessageConfirmRemoveAllChapters": "Biztosan eltávolítja az összes fejezetet?", "MessageConfirmRemoveAllChapters": "Biztosan eltávolítja az összes fejezetet?",
"MessageConfirmRemoveAuthor": "Biztosan eltávolítja a(z) \"{0}\" szerzőt?", "MessageConfirmRemoveAuthor": "Biztosan eltávolítja a(z) \"{0}\" szerzőt?",
@ -651,6 +738,7 @@
"MessageConfirmRemoveEpisode": "Biztosan eltávolítja a(z) \"{0}\" epizódot?", "MessageConfirmRemoveEpisode": "Biztosan eltávolítja a(z) \"{0}\" epizódot?",
"MessageConfirmRemoveEpisodes": "Biztosan eltávolítja a(z) {0} epizódot?", "MessageConfirmRemoveEpisodes": "Biztosan eltávolítja a(z) {0} epizódot?",
"MessageConfirmRemoveListeningSessions": "Biztosan eltávolítja a(z) {0} hallgatási munkamenetet?", "MessageConfirmRemoveListeningSessions": "Biztosan eltávolítja a(z) {0} hallgatási munkamenetet?",
"MessageConfirmRemoveMetadataFiles": "Biztos, hogy az összes metaadatot el akarja távolítani {0} fájl van könyvtár mappáiban?",
"MessageConfirmRemoveNarrator": "Biztosan eltávolítja a(z) \"{0}\" előadót?", "MessageConfirmRemoveNarrator": "Biztosan eltávolítja a(z) \"{0}\" előadót?",
"MessageConfirmRemovePlaylist": "Biztosan eltávolítja a(z) \"{0}\" lejátszási listáját?", "MessageConfirmRemovePlaylist": "Biztosan eltávolítja a(z) \"{0}\" lejátszási listáját?",
"MessageConfirmRenameGenre": "Biztosan át szeretné nevezni a(z) \"{0}\" műfajt \"{1}\"-re az összes elemnél?", "MessageConfirmRenameGenre": "Biztosan át szeretné nevezni a(z) \"{0}\" műfajt \"{1}\"-re az összes elemnél?",
@ -659,11 +747,15 @@
"MessageConfirmRenameTag": "Biztosan át szeretné nevezni a(z) \"{0}\" címkét \"{1}\"-re az összes elemnél?", "MessageConfirmRenameTag": "Biztosan át szeretné nevezni a(z) \"{0}\" címkét \"{1}\"-re az összes elemnél?",
"MessageConfirmRenameTagMergeNote": "Megjegyzés: Ez a címke már létezik, így össze lesznek vonva.", "MessageConfirmRenameTagMergeNote": "Megjegyzés: Ez a címke már létezik, így össze lesznek vonva.",
"MessageConfirmRenameTagWarning": "Figyelem! Egy hasonló, de eltérő nagybetűkkel rendelkező címke már létezik \"{0}\".", "MessageConfirmRenameTagWarning": "Figyelem! Egy hasonló, de eltérő nagybetűkkel rendelkező címke már létezik \"{0}\".",
"MessageConfirmResetProgress": "Biztos, hogy vissza akarja állítani a haladási folyamatát?",
"MessageConfirmSendEbookToDevice": "Biztosan el szeretné küldeni a(z) {0} e-könyvet a(z) \"{1}\" eszközre?", "MessageConfirmSendEbookToDevice": "Biztosan el szeretné küldeni a(z) {0} e-könyvet a(z) \"{1}\" eszközre?",
"MessageConfirmUnlinkOpenId": "Biztos, hogy el akarja távolítani ezt a felhasználót az OpenID-ból?",
"MessageDownloadingEpisode": "Epizód letöltése", "MessageDownloadingEpisode": "Epizód letöltése",
"MessageDragFilesIntoTrackOrder": "Húzza a fájlokat a helyes sávrendbe", "MessageDragFilesIntoTrackOrder": "Húzza a fájlokat a helyes sávrendbe",
"MessageEmbedFailed": "A beágyazás sikertelen!",
"MessageEmbedFinished": "Beágyazás befejeződött!", "MessageEmbedFinished": "Beágyazás befejeződött!",
"MessageEpisodesQueuedForDownload": "{0} epizód letöltésre vár", "MessageEpisodesQueuedForDownload": "{0} epizód letöltésre vár",
"MessageEreaderDevices": "Az e-könyvek kézbesítésének biztosítása érdekében a fenti e-mail címet az alább felsorolt minden egyes eszközhöz, mint érvényes feladót kell hozzáadnia.",
"MessageFeedURLWillBe": "A hírcsatorna URL-je {0} lesz", "MessageFeedURLWillBe": "A hírcsatorna URL-je {0} lesz",
"MessageFetching": "Lekérdezés...", "MessageFetching": "Lekérdezés...",
"MessageForceReScanDescription": "minden fájlt újra szkennel, mint egy friss szkennelés. Az audiofájlok ID3 címkéi, OPF fájlok és szövegfájlok újként lesznek szkennelve.", "MessageForceReScanDescription": "minden fájlt újra szkennel, mint egy friss szkennelés. Az audiofájlok ID3 címkéi, OPF fájlok és szövegfájlok újként lesznek szkennelve.",
@ -671,10 +763,10 @@
"MessageInsertChapterBelow": "Fejezet beszúrása alulra", "MessageInsertChapterBelow": "Fejezet beszúrása alulra",
"MessageItemsSelected": "{0} kiválasztott elem", "MessageItemsSelected": "{0} kiválasztott elem",
"MessageItemsUpdated": "{0} frissített elem", "MessageItemsUpdated": "{0} frissített elem",
"MessageJoinUsOn": "Csatlakozzon hozzánk", "MessageJoinUsOn": "Csatlakozzon hozzánk a",
"MessageListeningSessionsInTheLastYear": "{0} hallgatási munkamenet az elmúlt évben",
"MessageLoading": "Betöltés...", "MessageLoading": "Betöltés...",
"MessageLoadingFolders": "Mappák betöltése...", "MessageLoadingFolders": "Mappák betöltése...",
"MessageLogsDescription": "A naplók a <code>/metadata/logs</code> mappában JSON-fájlokként tárolódnak. Az összeomlási naplók a <code>/metadata/logs/crash_logs.txt</code> fájlban tárolódnak.",
"MessageM4BFailed": "M4B sikertelen!", "MessageM4BFailed": "M4B sikertelen!",
"MessageM4BFinished": "M4B befejeződött!", "MessageM4BFinished": "M4B befejeződött!",
"MessageMapChapterTitles": "Fejezetcímek hozzárendelése a meglévő hangoskönyv fejezeteihez anélkül, hogy az időbélyegeket módosítaná", "MessageMapChapterTitles": "Fejezetcímek hozzárendelése a meglévő hangoskönyv fejezeteihez anélkül, hogy az időbélyegeket módosítaná",
@ -691,6 +783,7 @@
"MessageNoCollections": "Nincsenek gyűjtemények", "MessageNoCollections": "Nincsenek gyűjtemények",
"MessageNoCoversFound": "Nem találhatóak borítók", "MessageNoCoversFound": "Nem találhatóak borítók",
"MessageNoDescription": "Nincs leírás", "MessageNoDescription": "Nincs leírás",
"MessageNoDevices": "Nincs eszköz",
"MessageNoDownloadsInProgress": "Jelenleg nincsenek folyamatban lévő letöltések", "MessageNoDownloadsInProgress": "Jelenleg nincsenek folyamatban lévő letöltések",
"MessageNoDownloadsQueued": "Nincsenek várakozó letöltések", "MessageNoDownloadsQueued": "Nincsenek várakozó letöltések",
"MessageNoEpisodeMatchesFound": "Nincs találat az epizódokra", "MessageNoEpisodeMatchesFound": "Nincs találat az epizódokra",
@ -704,6 +797,7 @@
"MessageNoLogs": "Nincsenek naplók", "MessageNoLogs": "Nincsenek naplók",
"MessageNoMediaProgress": "Nincs előrehaladás a médialejátszásban", "MessageNoMediaProgress": "Nincs előrehaladás a médialejátszásban",
"MessageNoNotifications": "Nincsenek értesítések", "MessageNoNotifications": "Nincsenek értesítések",
"MessageNoPodcastFeed": "Érvénytelen podcast: Nincs forrás",
"MessageNoPodcastsFound": "Nem találhatóak podcastok", "MessageNoPodcastsFound": "Nem találhatóak podcastok",
"MessageNoResults": "Nincsenek eredmények", "MessageNoResults": "Nincsenek eredmények",
"MessageNoSearchResultsFor": "Nincs keresési eredmény erre: \"{0}\"", "MessageNoSearchResultsFor": "Nincs keresési eredmény erre: \"{0}\"",
@ -713,11 +807,16 @@
"MessageNoUpdatesWereNecessary": "Nem volt szükség frissítésekre", "MessageNoUpdatesWereNecessary": "Nem volt szükség frissítésekre",
"MessageNoUserPlaylists": "Nincsenek felhasználói lejátszási listák", "MessageNoUserPlaylists": "Nincsenek felhasználói lejátszási listák",
"MessageNotYetImplemented": "Még nem implementált", "MessageNotYetImplemented": "Még nem implementált",
"MessageOpmlPreviewNote": "Megjegyzés: Ez egy előnézeti kép az elemzett OPML fájlról. A podcast tényleges címe az RSS hírcsatornából származik.",
"MessageOr": "vagy", "MessageOr": "vagy",
"MessagePauseChapter": "Fejezet lejátszásának szüneteltetése", "MessagePauseChapter": "Fejezet lejátszásának szüneteltetése",
"MessagePlayChapter": "Fejezet elejének meghallgatása", "MessagePlayChapter": "Fejezet elejének meghallgatása",
"MessagePlaylistCreateFromCollection": "Lejátszási lista létrehozása gyűjteményből", "MessagePlaylistCreateFromCollection": "Lejátszási lista létrehozása gyűjteményből",
"MessagePleaseWait": "Kérem várjon...",
"MessagePodcastHasNoRSSFeedForMatching": "A podcastnak nincs RSS hírcsatorna URL-je az egyeztetéshez", "MessagePodcastHasNoRSSFeedForMatching": "A podcastnak nincs RSS hírcsatorna URL-je az egyeztetéshez",
"MessagePodcastSearchField": "Adja meg a keresési kifejezést vagy az RSS hírcsatorna URL-címét",
"MessageQuickEmbedInProgress": "Gyors beágyazás folyamatban",
"MessageQuickMatchAllEpisodes": "Minden epizód gyors egyeztetése",
"MessageQuickMatchDescription": "Üres elem részletek és borító feltöltése az első találati eredménnyel a(z) '{0}'-ból. Nem írja felül a részleteket, kivéve, ha a 'Preferált egyeztetett metaadatok' szerverbeállítás engedélyezve van.", "MessageQuickMatchDescription": "Üres elem részletek és borító feltöltése az első találati eredménnyel a(z) '{0}'-ból. Nem írja felül a részleteket, kivéve, ha a 'Preferált egyeztetett metaadatok' szerverbeállítás engedélyezve van.",
"MessageRemoveChapter": "Fejezet eltávolítása", "MessageRemoveChapter": "Fejezet eltávolítása",
"MessageRemoveEpisodes": "Epizód(ok) eltávolítása: {0}", "MessageRemoveEpisodes": "Epizód(ok) eltávolítása: {0}",
@ -725,14 +824,49 @@
"MessageRemoveUserWarning": "Biztosan véglegesen törölni szeretné a(z) \"{0}\" felhasználót?", "MessageRemoveUserWarning": "Biztosan véglegesen törölni szeretné a(z) \"{0}\" felhasználót?",
"MessageReportBugsAndContribute": "Hibák jelentése, funkciók kérése és hozzájárulás itt", "MessageReportBugsAndContribute": "Hibák jelentése, funkciók kérése és hozzájárulás itt",
"MessageResetChaptersConfirm": "Biztosan alaphelyzetbe szeretné állítani a fejezeteket és visszavonni a módosításokat?", "MessageResetChaptersConfirm": "Biztosan alaphelyzetbe szeretné állítani a fejezeteket és visszavonni a módosításokat?",
"MessageRestoreBackupConfirm": "Biztosan vissza szeretné állítani a biztonsági másolatot, amely ekkor készült", "MessageRestoreBackupConfirm": "Biztosan vissza szeretné állítani a biztonsági másolatot, amely ekkor készült:",
"MessageRestoreBackupWarning": "A biztonsági mentés visszaállítása felülírja az egész adatbázist, amely a /config mappában található, valamint a borítóképeket a /metadata/items és /metadata/authors mappákban.<br /><br />A biztonsági mentések nem módosítják a könyvtár mappáiban található fájlokat. Ha engedélyezte a szerverbeállításokat a borítóképek és a metaadatok könyvtármappákban való tárolására, akkor ezek nem kerülnek biztonsági mentésre vagy felülírásra.<br /><br />A szerver használó összes kliens automatikusan frissül.", "MessageRestoreBackupWarning": "A biztonsági mentés visszaállítása felülírja az egész adatbázist, amely a /config mappában található, valamint a borítóképeket a /metadata/items és /metadata/authors mappákban.<br /><br />A biztonsági mentések nem módosítják a könyvtár mappáiban található fájlokat. Ha engedélyezte a szerverbeállításokat a borítóképek és a metaadatok könyvtármappákban való tárolására, akkor ezek nem kerülnek biztonsági mentésre vagy felülírásra.<br /><br />A szerver használó összes kliens automatikusan frissül.",
"MessageSearchResultsFor": "Keresési eredmények", "MessageSearchResultsFor": "Keresési eredmények",
"MessageSelected": "{0} kiválasztva", "MessageSelected": "{0} kiválasztva",
"MessageServerCouldNotBeReached": "A szervert nem lehet elérni", "MessageServerCouldNotBeReached": "A szervert nem lehet elérni",
"MessageSetChaptersFromTracksDescription": "Fejezetek beállítása minden egyes hangfájlt egy fejezetként használva, és a fejezet címét a hangfájl neveként", "MessageSetChaptersFromTracksDescription": "Fejezetek beállítása minden egyes hangfájlt egy fejezetként használva, és a fejezet címét a hangfájl neveként",
"MessageShareExpirationWillBe": "A lejárat: <strong>{0}</strong>",
"MessageShareExpiresIn": "{0} múlva jár le",
"MessageStartPlaybackAtTime": "\"{0}\" lejátszásának kezdése {1} -tól?", "MessageStartPlaybackAtTime": "\"{0}\" lejátszásának kezdése {1} -tól?",
"MessageThinking": "Gondolkodás...", "MessageTaskAudioFileNotWritable": "A/Az „{0}” hangfájl nem írható",
"MessageTaskCanceledByUser": "Felhasználó törölte a feladatot",
"MessageTaskDownloadingEpisodeDescription": "„{0}” epizód letöltése",
"MessageTaskEmbeddingMetadata": "Metaadatok beágyazása",
"MessageTaskEmbeddingMetadataDescription": "Metaadatok beágyazása a „{0}” hangoskönyvbe",
"MessageTaskEncodingM4b": "Kódolás M4B-ban",
"MessageTaskEncodingM4bDescription": "„{0}” hangoskönyv kódolása egyetlen m4b fájlba",
"MessageTaskFailed": "Sikertelen",
"MessageTaskFailedToBackupAudioFile": "Nem sikerült a „{0}” hangfájl mentése",
"MessageTaskFailedToCreateCacheDirectory": "Nem sikerült létrehozni a gyorsítótár könyvtárat",
"MessageTaskFailedToEmbedMetadataInFile": "Nem sikerült beágyazni a metaadatokat a „{0}” fájlba",
"MessageTaskFailedToMergeAudioFiles": "A hangfájlok egyesítése nem sikerült",
"MessageTaskFailedToMoveM4bFile": "Nem sikerült m4b fájlt áthelyezni",
"MessageTaskFailedToWriteMetadataFile": "Metaadatfájl írása sikertelen",
"MessageTaskMatchingBooksInLibrary": "Könyvek egyeztetése a \"{0}\" könyvtárban",
"MessageTaskNoFilesToScan": "Nincs beolvasandó fájl",
"MessageTaskOpmlImport": "OPML import",
"MessageTaskOpmlImportDescription": "Podcastok létrehozása {0} RSS hírcsatornából",
"MessageTaskOpmlImportFeedDescription": "RSS feed „{0}” importálása",
"MessageTaskOpmlImportFeedFailed": "Nem sikerült letölteni a podcast feedet",
"MessageTaskOpmlImportFeedPodcastDescription": "„{0}” podcast létrehozása",
"MessageTaskOpmlImportFeedPodcastExists": "Podcast már létezik az elérési útvonalon",
"MessageTaskOpmlImportFeedPodcastFailed": "Nem sikerült podcastot létrehozni",
"MessageTaskOpmlImportFinished": "{0} podcast hozzáadva",
"MessageTaskOpmlParseFailed": "Az OPML fájl elemzése nem sikerült",
"MessageTaskOpmlParseFastFail": "Érvénytelen OPML fájl: <opml> tag nem található VAGY nem találtak <outline> taget",
"MessageTaskScanItemsAdded": "{0} hozzáadva",
"MessageTaskScanItemsMissing": "{0} hiányzik",
"MessageTaskScanItemsUpdated": "{0} frissítve",
"MessageTaskScanNoChangesNeeded": "Nincs szükség változtatásra",
"MessageTaskScanningFileChanges": "Fájlváltozások keresése a „{0}” fájlban",
"MessageTaskScanningLibrary": "„{0}” könyvtár beolvasása",
"MessageTaskTargetDirectoryNotWritable": "A célkönyvtár nem írható",
"MessageThinking": "Gondolkodom...",
"MessageUploaderItemFailed": "A feltöltés sikertelen", "MessageUploaderItemFailed": "A feltöltés sikertelen",
"MessageUploaderItemSuccess": "Sikeresen feltöltve!", "MessageUploaderItemSuccess": "Sikeresen feltöltve!",
"MessageUploading": "Feltöltés...", "MessageUploading": "Feltöltés...",
@ -744,45 +878,100 @@
"NoteChangeRootPassword": "A Root felhasználó az egyetlen felhasználó, akinek lehet üres jelszava", "NoteChangeRootPassword": "A Root felhasználó az egyetlen felhasználó, akinek lehet üres jelszava",
"NoteChapterEditorTimes": "Megjegyzés: Az első fejezet kezdőidejének 0:00 kell lennie, és az utolsó fejezet kezdőideje nem haladhatja meg a hangoskönyv időtartamát.", "NoteChapterEditorTimes": "Megjegyzés: Az első fejezet kezdőidejének 0:00 kell lennie, és az utolsó fejezet kezdőideje nem haladhatja meg a hangoskönyv időtartamát.",
"NoteFolderPicker": "Megjegyzés: azok a mappák, amelyek már hozzá vannak rendelve, nem jelennek meg", "NoteFolderPicker": "Megjegyzés: azok a mappák, amelyek már hozzá vannak rendelve, nem jelennek meg",
"NoteRSSFeedPodcastAppsHttps": "Figyelem: A legtöbb podcast alkalmazás megköveteli, hogy az RSS feed URL HTTPS-t használjon", "NoteRSSFeedPodcastAppsHttps": "Figyelem: A legtöbb podcast alkalmazás megköveteli, hogy az RSS hírcsatorna URL-jában HTTPS-t használjon",
"NoteRSSFeedPodcastAppsPubDate": "Figyelem: Az egy vagy több epizódnak nincs Közzétételi dátuma. Néhány podcast alkalmazás ezt megköveteli.", "NoteRSSFeedPodcastAppsPubDate": "Figyelem: Az egy vagy több epizódnak nincs Közzétételi dátuma. Néhány podcast alkalmazás ezt megköveteli.",
"NoteUploaderFoldersWithMediaFiles": "A médiafájlokat tartalmazó mappák külön könyvtári tételekként lesznek kezelve.", "NoteUploaderFoldersWithMediaFiles": "A médiafájlokat tartalmazó mappák külön könyvtári tételekként lesznek kezelve.",
"NoteUploaderOnlyAudioFiles": "Ha csak hangfájlokat tölt fel, akkor minden egyes hangfájl külön hangoskönyvként lesz kezelve.", "NoteUploaderOnlyAudioFiles": "Ha csak hangfájlokat tölt fel, akkor minden egyes hangfájl külön hangoskönyvként lesz kezelve.",
"NoteUploaderUnsupportedFiles": "A nem támogatott fájlok figyelmen kívül hagyásra kerülnek. Mappa kiválasztása vagy elengedésekor az elem mappáján kívüli egyéb fájlok figyelmen kívül lesznek hagyva.", "NoteUploaderUnsupportedFiles": "A nem támogatott fájlok figyelmen kívül hagyásra kerülnek. Mappa kiválasztása vagy elengedésekor az elem mappáján kívüli egyéb fájlok figyelmen kívül lesznek hagyva.",
"NotificationOnBackupCompletedDescription": "A biztonsági mentés befejezésekor aktiválódik",
"NotificationOnBackupFailedDescription": "A biztonsági mentés sikertelensége esetén aktiválódik",
"NotificationOnEpisodeDownloadedDescription": "Egy podcast epizód automatikus letöltésekor aktiválódik",
"NotificationOnTestDescription": "Esemény az értesítési rendszer teszteléséhez",
"PlaceholderNewCollection": "Új gyűjtemény neve", "PlaceholderNewCollection": "Új gyűjtemény neve",
"PlaceholderNewFolderPath": "Új mappa útvonala", "PlaceholderNewFolderPath": "Új mappa útvonala",
"PlaceholderNewPlaylist": "Új lejátszási lista neve", "PlaceholderNewPlaylist": "Új lejátszási lista neve",
"PlaceholderSearch": "Keresés..", "PlaceholderSearch": "Keresés..",
"PlaceholderSearchEpisode": "Epizód keresése..", "PlaceholderSearchEpisode": "Epizód keresése..",
"StatsAuthorsAdded": "szerző hozzáadva",
"StatsBooksAdded": "könyv hozzáadva",
"StatsBooksAdditional": "Néhány kiegészítés…",
"StatsBooksFinished": "könyv befejezve",
"StatsBooksFinishedThisYear": "Néhány idén befejezett könyv…",
"StatsBooksListenedTo": "hallgatott könyv",
"StatsCollectionGrewTo": "Könyvgyűjtemény nőtt…",
"StatsSessions": "munkamenet",
"StatsSpentListening": "hallgatással töltött idő",
"StatsTopAuthor": "TOP SZERZŐ",
"StatsTopAuthors": "TOP SZERZŐ",
"StatsTopGenre": "TOP MŰFAJ",
"StatsTopGenres": "TOP MŰFAJ",
"StatsTopMonth": "TOP HÓNAP",
"StatsTopNarrator": "TOP ELŐADÓ",
"StatsTopNarrators": "TOP ELŐADÓ",
"StatsTotalDuration": "A teljes időtartam…",
"StatsYearInReview": "ÉVVISSZATEKINTÉS",
"ToastAccountUpdateSuccess": "Fiók frissítve", "ToastAccountUpdateSuccess": "Fiók frissítve",
"ToastAppriseUrlRequired": "Meg kell adnia egy Apprise URL-címet",
"ToastAsinRequired": "ASIN kötelező",
"ToastAuthorImageRemoveSuccess": "Szerző képe eltávolítva", "ToastAuthorImageRemoveSuccess": "Szerző képe eltávolítva",
"ToastAuthorNotFound": "A szerző „{0}” nem található",
"ToastAuthorRemoveSuccess": "Szerző eltávolítva",
"ToastAuthorSearchNotFound": "Szerző nem található",
"ToastAuthorUpdateMerged": "Szerző összevonva", "ToastAuthorUpdateMerged": "Szerző összevonva",
"ToastAuthorUpdateSuccess": "Szerző frissítve", "ToastAuthorUpdateSuccess": "Szerző frissítve",
"ToastAuthorUpdateSuccessNoImageFound": "Szerző frissítve (nem található kép)", "ToastAuthorUpdateSuccessNoImageFound": "Szerző frissítve (nem található kép)",
"ToastBackupAppliedSuccess": "Biztonsági mentés alkalmazva",
"ToastBackupCreateFailed": "A biztonsági mentés létrehozása sikertelen", "ToastBackupCreateFailed": "A biztonsági mentés létrehozása sikertelen",
"ToastBackupCreateSuccess": "Biztonsági mentés létrehozva", "ToastBackupCreateSuccess": "Biztonsági mentés létrehozva",
"ToastBackupDeleteFailed": "A biztonsági mentés törlése sikertelen", "ToastBackupDeleteFailed": "A biztonsági mentés törlése sikertelen",
"ToastBackupDeleteSuccess": "Biztonsági mentés törölve", "ToastBackupDeleteSuccess": "Biztonsági mentés törölve",
"ToastBackupInvalidMaxKeep": "A megőrzendő biztonsági másolatok száma érvénytelen",
"ToastBackupInvalidMaxSize": "Érvénytelen maximális mentésméret",
"ToastBackupRestoreFailed": "A biztonsági mentés visszaállítása sikertelen", "ToastBackupRestoreFailed": "A biztonsági mentés visszaállítása sikertelen",
"ToastBackupUploadFailed": "A biztonsági mentés feltöltése sikertelen", "ToastBackupUploadFailed": "A biztonsági mentés feltöltése sikertelen",
"ToastBackupUploadSuccess": "Biztonsági mentés feltöltve", "ToastBackupUploadSuccess": "Biztonsági mentés feltöltve",
"ToastBatchDeleteFailed": "A tömeges törlés nem sikerült",
"ToastBatchDeleteSuccess": "Sikeres tömeges törlés",
"ToastBatchUpdateFailed": "Kötegelt frissítés sikertelen", "ToastBatchUpdateFailed": "Kötegelt frissítés sikertelen",
"ToastBatchUpdateSuccess": "Kötegelt frissítés sikeres", "ToastBatchUpdateSuccess": "Kötegelt frissítés sikeres",
"ToastBookmarkCreateFailed": "Könyvjelző létrehozása sikertelen", "ToastBookmarkCreateFailed": "Könyvjelző létrehozása sikertelen",
"ToastBookmarkCreateSuccess": "Könyvjelző hozzáadva", "ToastBookmarkCreateSuccess": "Könyvjelző hozzáadva",
"ToastBookmarkRemoveSuccess": "Könyvjelző eltávolítva", "ToastBookmarkRemoveSuccess": "Könyvjelző eltávolítva",
"ToastBookmarkUpdateSuccess": "Könyvjelző frissítve", "ToastBookmarkUpdateSuccess": "Könyvjelző frissítve",
"ToastCachePurgeFailed": "A gyorsítótár törlése sikertelen",
"ToastCachePurgeSuccess": "A gyorsítótár sikeresen törölve",
"ToastChaptersHaveErrors": "A fejezetek hibákat tartalmaznak", "ToastChaptersHaveErrors": "A fejezetek hibákat tartalmaznak",
"ToastChaptersMustHaveTitles": "A fejezeteknek címekkel kell rendelkezniük", "ToastChaptersMustHaveTitles": "A fejezeteknek címekkel kell rendelkezniük",
"ToastCollectionItemsRemoveSuccess": "Elem(ek) eltávolítva a gyűjteményből", "ToastChaptersRemoved": "Fejezetek eltávolítva",
"ToastChaptersUpdated": "Fejezetek frissítve",
"ToastCollectionRemoveSuccess": "Gyűjtemény eltávolítva", "ToastCollectionRemoveSuccess": "Gyűjtemény eltávolítva",
"ToastCollectionUpdateSuccess": "Gyűjtemény frissítve", "ToastCollectionUpdateSuccess": "Gyűjtemény frissítve",
"ToastCoverUpdateFailed": "A borító frissítése nem sikerült",
"ToastDeleteFileFailed": "Nem sikerült törölni a fájlt",
"ToastDeleteFileSuccess": "Fájl törölve",
"ToastDeviceAddFailed": "Nem sikerült eszközt hozzáadni",
"ToastDeviceNameAlreadyExists": "Ilyen nevű olvasóeszköz már létezik",
"ToastDeviceTestEmailFailed": "Teszt email küldése sikertelen",
"ToastDeviceTestEmailSuccess": "Teszt email elküldve",
"ToastEmailSettingsUpdateSuccess": "Email beállítások frissítve",
"ToastEncodeCancelSucces": "Kódolás törölve",
"ToastEpisodeDownloadQueueClearFailed": "Nem sikerült törölni a várólistát",
"ToastEpisodeUpdateSuccess": "{0} epizód frissítve",
"ToastFailedToLoadData": "Sikertelen adatbetöltés",
"ToastFailedToMatch": "Nem sikerült egyezőséget találni",
"ToastFailedToShare": "Nem sikerült megosztani",
"ToastFailedToUpdate": "Nem sikerült frissíteni",
"ToastInvalidImageUrl": "Érvénytelen a kép URL címe",
"ToastInvalidUrl": "Érvénytelen URL",
"ToastItemCoverUpdateSuccess": "Elem borítója frissítve", "ToastItemCoverUpdateSuccess": "Elem borítója frissítve",
"ToastItemDeletedFailed": "Nem sikerült törölni az elemet",
"ToastItemDeletedSuccess": "Elem törölve",
"ToastItemDetailsUpdateSuccess": "Elem részletei frissítve", "ToastItemDetailsUpdateSuccess": "Elem részletei frissítve",
"ToastItemMarkedAsFinishedFailed": "Megjelölés Befejezettként sikertelen", "ToastItemMarkedAsFinishedFailed": "Megjelölés Befejezettként sikertelen",
"ToastItemMarkedAsFinishedSuccess": "Elem megjelölve Befejezettként", "ToastItemMarkedAsFinishedSuccess": "Elem megjelölve Befejezettként",
"ToastItemMarkedAsNotFinishedFailed": "Az elem befejezetlennek jelölése sikertelen", "ToastItemMarkedAsNotFinishedFailed": "Az elem befejezetlennek jelölése sikertelen",
"ToastItemMarkedAsNotFinishedSuccess": "Elem megjelölve Nem Befejezettként", "ToastItemMarkedAsNotFinishedSuccess": "Elem megjelölve Nem Befejezettként",
"ToastItemUpdateSuccess": "Elem frissítve",
"ToastLibraryCreateFailed": "Könyvtár létrehozása sikertelen", "ToastLibraryCreateFailed": "Könyvtár létrehozása sikertelen",
"ToastLibraryCreateSuccess": "\"{0}\" könyvtár létrehozva", "ToastLibraryCreateSuccess": "\"{0}\" könyvtár létrehozva",
"ToastLibraryDeleteFailed": "Könyvtár törlése sikertelen", "ToastLibraryDeleteFailed": "Könyvtár törlése sikertelen",
@ -790,14 +979,34 @@
"ToastLibraryScanFailedToStart": "A beolvasás elindítása sikertelen", "ToastLibraryScanFailedToStart": "A beolvasás elindítása sikertelen",
"ToastLibraryScanStarted": "Könyvtár beolvasása elindítva", "ToastLibraryScanStarted": "Könyvtár beolvasása elindítva",
"ToastLibraryUpdateSuccess": "\"{0}\" könyvtár frissítve", "ToastLibraryUpdateSuccess": "\"{0}\" könyvtár frissítve",
"ToastMatchAllAuthorsFailed": "Nem sikerült az összes szerzőt azonosítani",
"ToastMetadataFilesRemovedError": "Hiba a metaadatok eltávolításakor.{0} fájl",
"ToastMetadataFilesRemovedNoneFound": "Nincsenek metaadatok.{0} fájl a könyvtárban",
"ToastMetadataFilesRemovedNoneRemoved": "Nincsenek metaadatok.{0} fájl eltávolítva",
"ToastMetadataFilesRemovedSuccess": "{0} metaadat.{1} fájl eltávolítva",
"ToastMustHaveAtLeastOnePath": "Legalább egy elérési útvonalnak kell lennie",
"ToastNameEmailRequired": "Név és e-mail cím megadása kötelező",
"ToastNameRequired": "A név megadása kötelező",
"ToastNewEpisodesFound": "{0} új epizód",
"ToastNewUserCreatedFailed": "Nem sikerült a fiókot létrehozni: „{0}”",
"ToastNewUserCreatedSuccess": "Új fiók létrehozva",
"ToastNewUserLibraryError": "Legalább egy könyvtárat ki kell választani",
"ToastNewUserPasswordError": "Kötelező a jelszó, csak a root felhasználónak lehet üres jelszava",
"ToastNewUserUsernameError": "Adjon meg egy felhasználónevet",
"ToastNoNewEpisodesFound": "Nincs új epizód",
"ToastNoUpdatesNecessary": "Nincs szükség frissítésre",
"ToastNotificationSettingsUpdateSuccess": "Értesítési beállítások frissítve",
"ToastNotificationUpdateSuccess": "Értesítés frissítve",
"ToastPlaylistCreateFailed": "Lejátszási lista létrehozása sikertelen", "ToastPlaylistCreateFailed": "Lejátszási lista létrehozása sikertelen",
"ToastPlaylistCreateSuccess": "Lejátszási lista létrehozva", "ToastPlaylistCreateSuccess": "Lejátszási lista létrehozva",
"ToastPlaylistRemoveSuccess": "Lejátszási lista eltávolítva", "ToastPlaylistRemoveSuccess": "Lejátszási lista eltávolítva",
"ToastPlaylistUpdateSuccess": "Lejátszási lista frissítve", "ToastPlaylistUpdateSuccess": "Lejátszási lista frissítve",
"ToastPodcastCreateFailed": "Podcast létrehozása sikertelen", "ToastPodcastCreateFailed": "Podcast létrehozása sikertelen",
"ToastPodcastCreateSuccess": "A podcast sikeresen létrehozva", "ToastPodcastCreateSuccess": "A podcast sikeresen létrehozva",
"ToastPodcastNoEpisodesInFeed": "Nincsenek epizódok az RSS hírcsatornában",
"ToastPodcastNoRssFeed": "A podcastnak nincs RSS-hírcsatornája",
"ToastRSSFeedCloseFailed": "Az RSS hírcsatorna bezárása sikertelen", "ToastRSSFeedCloseFailed": "Az RSS hírcsatorna bezárása sikertelen",
"ToastRSSFeedCloseSuccess": "RSS feed bezárva", "ToastRSSFeedCloseSuccess": "RSS hírfolyam leállítva",
"ToastRemoveItemFromCollectionFailed": "Tétel eltávolítása a gyűjteményből sikertelen", "ToastRemoveItemFromCollectionFailed": "Tétel eltávolítása a gyűjteményből sikertelen",
"ToastRemoveItemFromCollectionSuccess": "Tétel eltávolítva a gyűjteményből", "ToastRemoveItemFromCollectionSuccess": "Tétel eltávolítva a gyűjteményből",
"ToastSendEbookToDeviceFailed": "E-könyv küldése az eszközre sikertelen", "ToastSendEbookToDeviceFailed": "E-könyv küldése az eszközre sikertelen",
@ -809,6 +1018,9 @@
"ToastSocketConnected": "Socket csatlakoztatva", "ToastSocketConnected": "Socket csatlakoztatva",
"ToastSocketDisconnected": "Socket lecsatlakoztatva", "ToastSocketDisconnected": "Socket lecsatlakoztatva",
"ToastSocketFailedToConnect": "A Socket csatlakoztatása sikertelen", "ToastSocketFailedToConnect": "A Socket csatlakoztatása sikertelen",
"ToastUnknownError": "Ismeretlen hiba",
"ToastUserDeleteFailed": "Felhasználó törlése sikertelen", "ToastUserDeleteFailed": "Felhasználó törlése sikertelen",
"ToastUserDeleteSuccess": "Felhasználó törölve" "ToastUserDeleteSuccess": "Felhasználó törölve",
"ToastUserPasswordChangeSuccess": "Jelszó sikeresen megváltoztatva",
"ToastUserRootRequireName": "Egy root felhasználónevet kell megadnia"
} }

View File

@ -762,7 +762,6 @@
"MessageItemsSelected": "{0} oggetti Selezionati", "MessageItemsSelected": "{0} oggetti Selezionati",
"MessageItemsUpdated": "{0} Oggetti aggiornati", "MessageItemsUpdated": "{0} Oggetti aggiornati",
"MessageJoinUsOn": "Unisciti a noi su", "MessageJoinUsOn": "Unisciti a noi su",
"MessageListeningSessionsInTheLastYear": "{0} sessioni di ascolto nell'ultimo anno",
"MessageLoading": "Caricamento…", "MessageLoading": "Caricamento…",
"MessageLoadingFolders": "Caricamento Cartelle...", "MessageLoadingFolders": "Caricamento Cartelle...",
"MessageLogsDescription": "I log vengono archiviati nel percorso <code>/metadata/logs</code> as JSON files. I registri degli arresti anomali vengono archiviati nel percorso <code>/metadata/logs/crash_logs.txt</code>.", "MessageLogsDescription": "I log vengono archiviati nel percorso <code>/metadata/logs</code> as JSON files. I registri degli arresti anomali vengono archiviati nel percorso <code>/metadata/logs/crash_logs.txt</code>.",
@ -950,8 +949,6 @@
"ToastChaptersRemoved": "Capitoli rimossi", "ToastChaptersRemoved": "Capitoli rimossi",
"ToastChaptersUpdated": "Capitoli aggiornati", "ToastChaptersUpdated": "Capitoli aggiornati",
"ToastCollectionItemsAddFailed": "l'aggiunta dell'elemento(i) alla raccolta non è riuscito", "ToastCollectionItemsAddFailed": "l'aggiunta dell'elemento(i) alla raccolta non è riuscito",
"ToastCollectionItemsAddSuccess": "L'aggiunta dell'elemento(i) alla raccolta è riuscito",
"ToastCollectionItemsRemoveSuccess": "Oggetto(i) rimossi dalla Raccolta",
"ToastCollectionRemoveSuccess": "Collezione rimossa", "ToastCollectionRemoveSuccess": "Collezione rimossa",
"ToastCollectionUpdateSuccess": "Raccolta aggiornata", "ToastCollectionUpdateSuccess": "Raccolta aggiornata",
"ToastCoverUpdateFailed": "Aggiornamento cover fallito", "ToastCoverUpdateFailed": "Aggiornamento cover fallito",

View File

@ -104,7 +104,7 @@
"ButtonViewAll": "Peržiūrėti visus", "ButtonViewAll": "Peržiūrėti visus",
"ButtonYes": "Taip", "ButtonYes": "Taip",
"ErrorUploadFetchMetadataAPI": "Klaida gaunant metaduomenis", "ErrorUploadFetchMetadataAPI": "Klaida gaunant metaduomenis",
"ErrorUploadFetchMetadataNoResults": "Nepavyko gauti metaduomenų - pabandykite atnaujinti pavadinimą ir/ar autorių.", "ErrorUploadFetchMetadataNoResults": "Nepavyko gauti metaduomenų - pabandykite atnaujinti pavadinimą ir/ar autorių",
"ErrorUploadLacksTitle": "Pavadinimas yra privalomas", "ErrorUploadLacksTitle": "Pavadinimas yra privalomas",
"HeaderAccount": "Paskyra", "HeaderAccount": "Paskyra",
"HeaderAdvanced": "Papildomi", "HeaderAdvanced": "Papildomi",
@ -419,7 +419,7 @@
"LabelSettingsExperimentalFeatures": "Eksperimentiniai funkcionalumai", "LabelSettingsExperimentalFeatures": "Eksperimentiniai funkcionalumai",
"LabelSettingsExperimentalFeaturesHelp": "Funkcijos, kurios yra kuriamos ir laukiami jūsų komentarai. Spustelėkite, kad atidarytumėte „GitHub“ diskusiją.", "LabelSettingsExperimentalFeaturesHelp": "Funkcijos, kurios yra kuriamos ir laukiami jūsų komentarai. Spustelėkite, kad atidarytumėte „GitHub“ diskusiją.",
"LabelSettingsFindCovers": "Rasti viršelius", "LabelSettingsFindCovers": "Rasti viršelius",
"LabelSettingsFindCoversHelp": "Jei jūsų audioknyga neturi įterpto viršelio arba viršelio paveikslėlio aplanke, bandyti rasti viršelį.<br>Pastaba: Tai padidins skenavimo trukmę.", "LabelSettingsFindCoversHelp": "Jei jūsų audioknyga neturi įterpto viršelio arba viršelio paveikslėlio aplanke, bandyti rasti viršelį.<br>Pastaba: Tai padidins skenavimo trukmę",
"LabelSettingsHideSingleBookSeries": "Slėpti serijas, turinčias tik vieną knygą", "LabelSettingsHideSingleBookSeries": "Slėpti serijas, turinčias tik vieną knygą",
"LabelSettingsHideSingleBookSeriesHelp": "Serijos, turinčios tik vieną knygą, bus paslėptos nuo serijų puslapio ir pagrindinio puslapio lentynų.", "LabelSettingsHideSingleBookSeriesHelp": "Serijos, turinčios tik vieną knygą, bus paslėptos nuo serijų puslapio ir pagrindinio puslapio lentynų.",
"LabelSettingsHomePageBookshelfView": "Naudoti pagrindinio puslapio knygų lentynų vaizdą", "LabelSettingsHomePageBookshelfView": "Naudoti pagrindinio puslapio knygų lentynų vaizdą",
@ -563,7 +563,6 @@
"MessageItemsSelected": "Pasirinkti {0} elementai (-ų)", "MessageItemsSelected": "Pasirinkti {0} elementai (-ų)",
"MessageItemsUpdated": "Atnaujinti {0} elementai (-ų)", "MessageItemsUpdated": "Atnaujinti {0} elementai (-ų)",
"MessageJoinUsOn": "Prisijunkite prie mūsų", "MessageJoinUsOn": "Prisijunkite prie mūsų",
"MessageListeningSessionsInTheLastYear": "{0} klausymo sesijų per paskutinius metus",
"MessageLoading": "Kraunama...", "MessageLoading": "Kraunama...",
"MessageLoadingFolders": "Kraunami aplankai...", "MessageLoadingFolders": "Kraunami aplankai...",
"MessageM4BFailed": "M4B Nepavyko!", "MessageM4BFailed": "M4B Nepavyko!",
@ -666,8 +665,6 @@
"ToastChaptersMustHaveTitles": "Skyriai turi turėti pavadinimus", "ToastChaptersMustHaveTitles": "Skyriai turi turėti pavadinimus",
"ToastChaptersRemoved": "Skyriai pašalinti", "ToastChaptersRemoved": "Skyriai pašalinti",
"ToastCollectionItemsAddFailed": "Nepavyko pridėti į kolekciją", "ToastCollectionItemsAddFailed": "Nepavyko pridėti į kolekciją",
"ToastCollectionItemsAddSuccess": "Pridėta į kolekciją",
"ToastCollectionItemsRemoveSuccess": "Elementai pašalinti iš kolekcijos",
"ToastCollectionRemoveSuccess": "Kolekcija pašalinta", "ToastCollectionRemoveSuccess": "Kolekcija pašalinta",
"ToastCollectionUpdateSuccess": "Kolekcija atnaujinta", "ToastCollectionUpdateSuccess": "Kolekcija atnaujinta",
"ToastCoverUpdateFailed": "Viršelio atnaujinimas nepavyko", "ToastCoverUpdateFailed": "Viršelio atnaujinimas nepavyko",

View File

@ -758,7 +758,6 @@
"MessageItemsSelected": "{0} onderdelen geselecteerd", "MessageItemsSelected": "{0} onderdelen geselecteerd",
"MessageItemsUpdated": "{0} onderdelen bijgewerkt", "MessageItemsUpdated": "{0} onderdelen bijgewerkt",
"MessageJoinUsOn": "Doe mee op", "MessageJoinUsOn": "Doe mee op",
"MessageListeningSessionsInTheLastYear": "{0} luistersessies in het laatste jaar",
"MessageLoading": "Aan het laden...", "MessageLoading": "Aan het laden...",
"MessageLoadingFolders": "Mappen aan het laden...", "MessageLoadingFolders": "Mappen aan het laden...",
"MessageLogsDescription": "Logs worden opgeslagen in <code>/metadata/logs</code> als JSON-bestanden. Crashlogs worden opgeslagen in <code>/metadata/logs/crash_logs.txt</code>.", "MessageLogsDescription": "Logs worden opgeslagen in <code>/metadata/logs</code> als JSON-bestanden. Crashlogs worden opgeslagen in <code>/metadata/logs/crash_logs.txt</code>.",
@ -946,8 +945,6 @@
"ToastChaptersRemoved": "Hoofdstukken verwijderd", "ToastChaptersRemoved": "Hoofdstukken verwijderd",
"ToastChaptersUpdated": "Hoofdstukken bijgewerkt", "ToastChaptersUpdated": "Hoofdstukken bijgewerkt",
"ToastCollectionItemsAddFailed": "Item(s) toegevoegd aan collectie mislukt", "ToastCollectionItemsAddFailed": "Item(s) toegevoegd aan collectie mislukt",
"ToastCollectionItemsAddSuccess": "Item(s) toegevoegd aan collectie gelukt",
"ToastCollectionItemsRemoveSuccess": "Onderdeel (of onderdelen) verwijderd uit collectie",
"ToastCollectionRemoveSuccess": "Collectie verwijderd", "ToastCollectionRemoveSuccess": "Collectie verwijderd",
"ToastCollectionUpdateSuccess": "Collectie bijgewerkt", "ToastCollectionUpdateSuccess": "Collectie bijgewerkt",
"ToastCoverUpdateFailed": "Cover update mislukt", "ToastCoverUpdateFailed": "Cover update mislukt",

View File

@ -4,6 +4,7 @@
"ButtonAddDevice": "Legg til enhet", "ButtonAddDevice": "Legg til enhet",
"ButtonAddLibrary": "Legg til bibliotek", "ButtonAddLibrary": "Legg til bibliotek",
"ButtonAddPodcasts": "Legg til podcast", "ButtonAddPodcasts": "Legg til podcast",
"ButtonAddUser": "Legg til bruker",
"ButtonAddYourFirstLibrary": "Legg til ditt første bibliotek", "ButtonAddYourFirstLibrary": "Legg til ditt første bibliotek",
"ButtonApply": "Bruk", "ButtonApply": "Bruk",
"ButtonApplyChapters": "Bruk kapittel", "ButtonApplyChapters": "Bruk kapittel",
@ -18,6 +19,7 @@
"ButtonChooseFiles": "Velg filer", "ButtonChooseFiles": "Velg filer",
"ButtonClearFilter": "Bytt filter", "ButtonClearFilter": "Bytt filter",
"ButtonCloseFeed": "Lukk Feed", "ButtonCloseFeed": "Lukk Feed",
"ButtonCloseSession": "Lukk åpen økt",
"ButtonCollections": "Samlinger", "ButtonCollections": "Samlinger",
"ButtonConfigureScanner": "Konfigurer skanner", "ButtonConfigureScanner": "Konfigurer skanner",
"ButtonCreate": "Opprett", "ButtonCreate": "Opprett",
@ -27,13 +29,16 @@
"ButtonEdit": "Rediger", "ButtonEdit": "Rediger",
"ButtonEditChapters": "Rediger kapittel", "ButtonEditChapters": "Rediger kapittel",
"ButtonEditPodcast": "Rediger podcast", "ButtonEditPodcast": "Rediger podcast",
"ButtonEnable": "Aktiver",
"ButtonFireAndFail": "Kjør ved feil",
"ButtonFireOnTest": "Kjør onTest-kommando",
"ButtonForceReScan": "Tving skann", "ButtonForceReScan": "Tving skann",
"ButtonFullPath": "Full sti", "ButtonFullPath": "Full sti",
"ButtonHide": "Gjøm", "ButtonHide": "Gjøm",
"ButtonHome": "Hjem", "ButtonHome": "Hjem",
"ButtonIssues": "Problemer", "ButtonIssues": "Problemer",
"ButtonJumpBackward": "Hopp Bakover", "ButtonJumpBackward": "Hopp bakover",
"ButtonJumpForward": "Hopp Fremover", "ButtonJumpForward": "Hopp frem",
"ButtonLatest": "Siste", "ButtonLatest": "Siste",
"ButtonLibrary": "Bibliotek", "ButtonLibrary": "Bibliotek",
"ButtonLogout": "Logg ut", "ButtonLogout": "Logg ut",
@ -43,24 +48,31 @@
"ButtonMatchAllAuthors": "Søk opp alle forfattere", "ButtonMatchAllAuthors": "Søk opp alle forfattere",
"ButtonMatchBooks": "Søk opp bøker", "ButtonMatchBooks": "Søk opp bøker",
"ButtonNevermind": "Avbryt", "ButtonNevermind": "Avbryt",
"ButtonNext": "Neste",
"ButtonNextChapter": "Neste Kapittel", "ButtonNextChapter": "Neste Kapittel",
"ButtonNextItemInQueue": "Neste element i køen",
"ButtonOk": "Ok",
"ButtonOpenFeed": "Åpne Feed", "ButtonOpenFeed": "Åpne Feed",
"ButtonOpenManager": "Åpne behandler", "ButtonOpenManager": "Åpne behandler",
"ButtonPause": "Pause",
"ButtonPlay": "Spill av", "ButtonPlay": "Spill av",
"ButtonPlayAll": "Spill av alle",
"ButtonPlaying": "Spiller av", "ButtonPlaying": "Spiller av",
"ButtonPlaylists": "Spillelister", "ButtonPlaylists": "Spillelister",
"ButtonPrevious": "Forrige", "ButtonPrevious": "Forrige",
"ButtonPreviousChapter": "Forrige Kapittel", "ButtonPreviousChapter": "Forrige Kapittel",
"ButtonProbeAudioFile": "Analyser lydfil",
"ButtonPurgeAllCache": "Tøm alle mellomlager", "ButtonPurgeAllCache": "Tøm alle mellomlager",
"ButtonPurgeItemsCache": "Tøm mellomlager", "ButtonPurgeItemsCache": "Tøm mellomlager",
"ButtonQueueAddItem": "Legg til kø", "ButtonQueueAddItem": "Legg til kø",
"ButtonQueueRemoveItem": "Fjern fra kø", "ButtonQueueRemoveItem": "Fjern fra kø",
"ButtonQuickEmbedMetadata": "Hurtig Innbygging Av Metadata", "ButtonQuickEmbed": "Hurtiginnbygging",
"ButtonQuickEmbedMetadata": "Bygg inn metadata",
"ButtonQuickMatch": "Kjapt søk", "ButtonQuickMatch": "Kjapt søk",
"ButtonReScan": "Skann på nytt", "ButtonReScan": "Skann på nytt",
"ButtonRead": "Les", "ButtonRead": "Les",
"ButtonReadLess": "Les Mindre", "ButtonReadLess": "Vis mindre",
"ButtonReadMore": "Les Mer", "ButtonReadMore": "Vis mer",
"ButtonRefresh": "Oppdater", "ButtonRefresh": "Oppdater",
"ButtonRemove": "Fjern", "ButtonRemove": "Fjern",
"ButtonRemoveAll": "Fjern alle", "ButtonRemoveAll": "Fjern alle",
@ -69,12 +81,15 @@
"ButtonRemoveFromContinueReading": "Fjern fra Fortsett å lese", "ButtonRemoveFromContinueReading": "Fjern fra Fortsett å lese",
"ButtonRemoveSeriesFromContinueSeries": "Fjern serie fra Fortsett serie", "ButtonRemoveSeriesFromContinueSeries": "Fjern serie fra Fortsett serie",
"ButtonReset": "Nullstill", "ButtonReset": "Nullstill",
"ButtonResetToDefault": "Tilbakestill til standard",
"ButtonRestore": "Gjenopprett", "ButtonRestore": "Gjenopprett",
"ButtonSave": "Lagre", "ButtonSave": "Lagre",
"ButtonSaveAndClose": "Lagre og lukk", "ButtonSaveAndClose": "Lagre og lukk",
"ButtonSaveTracklist": "Lagre spilleliste", "ButtonSaveTracklist": "Lagre spilleliste",
"ButtonScan": "Skann", "ButtonScan": "Skann",
"ButtonScanLibrary": "Skann bibliotek", "ButtonScanLibrary": "Skann bibliotek",
"ButtonScrollLeft": "Rull til venstre",
"ButtonScrollRight": "Rull til høyre",
"ButtonSearch": "Søk", "ButtonSearch": "Søk",
"ButtonSelectFolderPath": "Velg mappe", "ButtonSelectFolderPath": "Velg mappe",
"ButtonSeries": "Serier", "ButtonSeries": "Serier",
@ -86,20 +101,26 @@
"ButtonStartMetadataEmbed": "Start Metadata innbaking", "ButtonStartMetadataEmbed": "Start Metadata innbaking",
"ButtonStats": "Statistikk", "ButtonStats": "Statistikk",
"ButtonSubmit": "Send inn", "ButtonSubmit": "Send inn",
"ButtonTest": "Test",
"ButtonUnlinkOpenId": "Koble fra OpenID",
"ButtonUpload": "Last opp", "ButtonUpload": "Last opp",
"ButtonUploadBackup": "Last opp sikkerhetskopi", "ButtonUploadBackup": "Last opp sikkerhetskopi",
"ButtonUploadCover": "Last opp cover", "ButtonUploadCover": "Last opp cover",
"ButtonUploadOPMLFile": "Last opp OPML fil", "ButtonUploadOPMLFile": "Last opp OPML fil",
"ButtonUserDelete": "Slett bruker {0}", "ButtonUserDelete": "Slett bruker {0}",
"ButtonUserEdit": "Rediger bruker {0}", "ButtonUserEdit": "Rediger bruker {0}",
"ButtonViewAll": "Vis alt", "ButtonViewAll": "Vis alle",
"ButtonYes": "Ja", "ButtonYes": "Ja",
"ErrorUploadFetchMetadataAPI": "Feil ved innhenting av metadata", "ErrorUploadFetchMetadataAPI": "Feil ved innhenting av metadata",
"ErrorUploadFetchMetadataNoResults": "Kunne ikke hente metadata - forsøk å oppdatere tittel og/eller forfatter",
"ErrorUploadLacksTitle": "Tittel kreves",
"HeaderAccount": "Konto", "HeaderAccount": "Konto",
"HeaderAddCustomMetadataProvider": "Legg til egendefinert metadata tilbyder",
"HeaderAdvanced": "Avansert", "HeaderAdvanced": "Avansert",
"HeaderAppriseNotificationSettings": "Apprise notifikasjonsinstillinger", "HeaderAppriseNotificationSettings": "Apprise varslingsinstillinger",
"HeaderAudioTracks": "Lydspor", "HeaderAudioTracks": "Lydspor",
"HeaderAudiobookTools": "Lydbok Filbehandlingsverktøy", "HeaderAudiobookTools": "Lydbok Filbehandlingsverktøy",
"HeaderAuthentication": "Autentisering",
"HeaderBackups": "Sikkerhetskopier", "HeaderBackups": "Sikkerhetskopier",
"HeaderChangePassword": "Bytt passord", "HeaderChangePassword": "Bytt passord",
"HeaderChapters": "Kapittel", "HeaderChapters": "Kapittel",
@ -108,6 +129,8 @@
"HeaderCollectionItems": "Samlingsgjenstander", "HeaderCollectionItems": "Samlingsgjenstander",
"HeaderCover": "Omslag", "HeaderCover": "Omslag",
"HeaderCurrentDownloads": "Aktive nedlastinger", "HeaderCurrentDownloads": "Aktive nedlastinger",
"HeaderCustomMessageOnLogin": "Egendefinert melding ved pålogging",
"HeaderCustomMetadataProviders": "Egendefinerte metadata tilbydere",
"HeaderDetails": "Detaljer", "HeaderDetails": "Detaljer",
"HeaderDownloadQueue": "Last ned kø", "HeaderDownloadQueue": "Last ned kø",
"HeaderEbookFiles": "Ebook filer", "HeaderEbookFiles": "Ebook filer",
@ -138,12 +161,17 @@
"HeaderMetadataToEmbed": "Metadata å bake inn", "HeaderMetadataToEmbed": "Metadata å bake inn",
"HeaderNewAccount": "Ny konto", "HeaderNewAccount": "Ny konto",
"HeaderNewLibrary": "Ny bibliotek", "HeaderNewLibrary": "Ny bibliotek",
"HeaderNotifications": "Notifikasjoner", "HeaderNotificationCreate": "Opprett varsling",
"HeaderNotificationUpdate": "Oppdater varsling",
"HeaderNotifications": "Varslinger",
"HeaderOpenIDConnectAuthentication": "Autentisering med OpenID Connect", "HeaderOpenIDConnectAuthentication": "Autentisering med OpenID Connect",
"HeaderOpenListeningSessions": "Åpne lyttesesjoner",
"HeaderOpenRSSFeed": "Åpne RSS Feed", "HeaderOpenRSSFeed": "Åpne RSS Feed",
"HeaderOtherFiles": "Andre filer", "HeaderOtherFiles": "Andre filer",
"HeaderPasswordAuthentication": "Logg inn med brukernavn og passord",
"HeaderPermissions": "Rettigheter", "HeaderPermissions": "Rettigheter",
"HeaderPlayerQueue": "Spiller kø", "HeaderPlayerQueue": "Spiller kø",
"HeaderPlayerSettings": "Avspillingsinnstillinger",
"HeaderPlaylist": "Spilleliste", "HeaderPlaylist": "Spilleliste",
"HeaderPlaylistItems": "Spillelisteelement", "HeaderPlaylistItems": "Spillelisteelement",
"HeaderPodcastsToAdd": "Podcaster å legge til", "HeaderPodcastsToAdd": "Podcaster å legge til",
@ -155,6 +183,7 @@
"HeaderRemoveEpisodes": "Fjern {0} episoder", "HeaderRemoveEpisodes": "Fjern {0} episoder",
"HeaderSavedMediaProgress": "Lagret mediefremgang", "HeaderSavedMediaProgress": "Lagret mediefremgang",
"HeaderSchedule": "Timeplan", "HeaderSchedule": "Timeplan",
"HeaderScheduleEpisodeDownloads": "Planlegg automatisk nedlasting av episoder",
"HeaderScheduleLibraryScans": "Planlegg automatisk bibliotek skann", "HeaderScheduleLibraryScans": "Planlegg automatisk bibliotek skann",
"HeaderSession": "Sesjon", "HeaderSession": "Sesjon",
"HeaderSetBackupSchedule": "Sett timeplan for sikkerhetskopi", "HeaderSetBackupSchedule": "Sett timeplan for sikkerhetskopi",
@ -163,6 +192,7 @@
"HeaderSettingsExperimental": "Eksperimentelle funksjoner", "HeaderSettingsExperimental": "Eksperimentelle funksjoner",
"HeaderSettingsGeneral": "Generell", "HeaderSettingsGeneral": "Generell",
"HeaderSettingsScanner": "Skanner", "HeaderSettingsScanner": "Skanner",
"HeaderSettingsWebClient": "Webklient",
"HeaderSleepTimer": "Sove timer", "HeaderSleepTimer": "Sove timer",
"HeaderStatsLargestItems": "Største enheter", "HeaderStatsLargestItems": "Største enheter",
"HeaderStatsLongestItems": "Lengste enheter (timer)", "HeaderStatsLongestItems": "Lengste enheter (timer)",
@ -177,9 +207,14 @@
"HeaderUpdateDetails": "Oppdater detaljer", "HeaderUpdateDetails": "Oppdater detaljer",
"HeaderUpdateLibrary": "Oppdater bibliotek", "HeaderUpdateLibrary": "Oppdater bibliotek",
"HeaderUsers": "Brukere", "HeaderUsers": "Brukere",
"HeaderYearReview": "{0} oppsummert",
"HeaderYourStats": "Din statistikk", "HeaderYourStats": "Din statistikk",
"LabelAbridged": "Forkortet", "LabelAbridged": "Forkortet",
"LabelAbridgedChecked": "Forkortet (valgt)",
"LabelAbridgedUnchecked": "Forkortet (ikke valgt)",
"LabelAccessibleBy": "Tilgjengelig via",
"LabelAccountType": "Kontotype", "LabelAccountType": "Kontotype",
"LabelAccountTypeAdmin": "Administrator",
"LabelAccountTypeGuest": "Gjest", "LabelAccountTypeGuest": "Gjest",
"LabelAccountTypeUser": "Bruker", "LabelAccountTypeUser": "Bruker",
"LabelActivity": "Aktivitet", "LabelActivity": "Aktivitet",
@ -188,32 +223,55 @@
"LabelAddToPlaylist": "Legg til i spilleliste", "LabelAddToPlaylist": "Legg til i spilleliste",
"LabelAddToPlaylistBatch": "Legg {0} enheter til i spilleliste", "LabelAddToPlaylistBatch": "Legg {0} enheter til i spilleliste",
"LabelAddedAt": "Lagt Til", "LabelAddedAt": "Lagt Til",
"LabelAddedDate": "La til {0}",
"LabelAdminUsersOnly": "Kun administratorer",
"LabelAll": "Alle", "LabelAll": "Alle",
"LabelAllUsers": "Alle brukere", "LabelAllUsers": "Alle brukere",
"LabelAllUsersExcludingGuests": "Alle brukere bortsett fra gjester",
"LabelAllUsersIncludingGuests": "Alle brukere inkludert gjester",
"LabelAlreadyInYourLibrary": "Allerede i biblioteket", "LabelAlreadyInYourLibrary": "Allerede i biblioteket",
"LabelApiToken": "API token",
"LabelAppend": "Legge til", "LabelAppend": "Legge til",
"LabelAudioBitrate": "Bitrate for lyd (f.eks. 128k)",
"LabelAudioChannels": "Lydkanaler (1 eller 2)",
"LabelAudioCodec": "Audio Codec",
"LabelAuthor": "Forfatter", "LabelAuthor": "Forfatter",
"LabelAuthorFirstLast": "Forfatter (Fornavn Etternavn)", "LabelAuthorFirstLast": "Forfatter (Fornavn Etternavn)",
"LabelAuthorLastFirst": "Forfatter (Etternavn Fornavn)", "LabelAuthorLastFirst": "Forfatter (Etternavn Fornavn)",
"LabelAuthors": "Forfattere", "LabelAuthors": "Forfattere",
"LabelAutoDownloadEpisodes": "Last ned episoder automatisk", "LabelAutoDownloadEpisodes": "Last ned episoder automatisk",
"LabelAutoFetchMetadata": "Automatisk henting av metadata",
"LabelAutoFetchMetadataHelp": "Henter metadata for tittel, forfatter og serie for å optimalisere opplasting. Ekstra metadata må kanskje bekreftes etter opplasting.",
"LabelAutoLaunch": "Autostart",
"LabelAutoLaunchDescription": "Omdiriger til leverandør for innlogging automatisk når innloggingssiden åpnes. (Kan overstyres med <code>/login?autoLaunch=0</code>)",
"LabelAutoRegister": "Automatisk registrering",
"LabelAutoRegisterDescription": "Lag bruker automatisk ved første innlogging",
"LabelBackToUser": "Tilbake til bruker", "LabelBackToUser": "Tilbake til bruker",
"LabelBackupAudioFiles": "Sikkerhetskopier lydfiler",
"LabelBackupLocation": "Mappe for sikkerhetskopiering",
"LabelBackupsEnableAutomaticBackups": "Aktiver automatisk sikkerhetskopi", "LabelBackupsEnableAutomaticBackups": "Aktiver automatisk sikkerhetskopi",
"LabelBackupsEnableAutomaticBackupsHelp": "Sikkerhetskopier lagret under /metadata/backups", "LabelBackupsEnableAutomaticBackupsHelp": "Sikkerhetskopier lagret under /metadata/backups",
"LabelBackupsMaxBackupSize": "Maks sikkerhetskopi størrelse (i GB)", "LabelBackupsMaxBackupSize": "Maksimal størrelse for sikkerhetskopi (i GB) (0 for ubegrenset)",
"LabelBackupsMaxBackupSizeHelp": "For å forhindre feilkonfigurasjon, vil sikkerhetskopier mislykkes hvis de oveskride konfigurert størrelse.", "LabelBackupsMaxBackupSizeHelp": "For å forhindre feilkonfigurasjon, vil sikkerhetskopier mislykkes hvis de oveskride konfigurert størrelse.",
"LabelBackupsNumberToKeep": "Antall sikkerhetskopier som skal beholdes", "LabelBackupsNumberToKeep": "Antall sikkerhetskopier som skal beholdes",
"LabelBackupsNumberToKeepHelp": "Kun 1 sikkerhetskopi vil bli fjernet om gangen, hvis du allerede har flere sikkerhetskopier enn dette bør du fjerne de manuelt.", "LabelBackupsNumberToKeepHelp": "Kun 1 sikkerhetskopi vil bli fjernet om gangen, hvis du allerede har flere sikkerhetskopier enn dette bør du fjerne de manuelt.",
"LabelBitrate": "Bithastighet", "LabelBitrate": "Bithastighet",
"LabelBonus": "Bonus",
"LabelBooks": "Bøker", "LabelBooks": "Bøker",
"LabelButtonText": "Tekst på knappen",
"LabelByAuthor": "av {0}",
"LabelChangePassword": "Endre passord", "LabelChangePassword": "Endre passord",
"LabelChannels": "Kanaler", "LabelChannels": "Kanaler",
"LabelChapterCount": "{0} kapitler",
"LabelChapterTitle": "Kapittel tittel", "LabelChapterTitle": "Kapittel tittel",
"LabelChapters": "Kapitler", "LabelChapters": "Kapitler",
"LabelChaptersFound": "kapitler funnet", "LabelChaptersFound": "kapitler funnet",
"LabelClickForMoreInfo": "Klikk for mer informasjon",
"LabelClickToUseCurrentValue": "Klikk for å bruke valgt verdi",
"LabelClosePlayer": "Lukk spiller", "LabelClosePlayer": "Lukk spiller",
"LabelCodec": "Kodek", "LabelCodec": "Kodek",
"LabelCollapseSeries": "Minimer serier", "LabelCollapseSeries": "Minimer serier",
"LabelCollapseSubSeries": "Skjul underserier",
"LabelCollection": "Samling", "LabelCollection": "Samling",
"LabelCollections": "Samlings", "LabelCollections": "Samlings",
"LabelComplete": "Fullfør", "LabelComplete": "Fullfør",
@ -230,58 +288,94 @@
"LabelCustomCronExpression": "Tilpasset Cron utrykk:", "LabelCustomCronExpression": "Tilpasset Cron utrykk:",
"LabelDatetime": "Dato tid", "LabelDatetime": "Dato tid",
"LabelDays": "Dager", "LabelDays": "Dager",
"LabelDeleteFromFileSystemCheckbox": "Slett fra filsystemet (fjern haken for kun å ta bort fra databasen)",
"LabelDescription": "Beskrivelse", "LabelDescription": "Beskrivelse",
"LabelDeselectAll": "Fjern valg", "LabelDeselectAll": "Fjern valg",
"LabelDevice": "Enhet", "LabelDevice": "Enhet",
"LabelDeviceInfo": "Enhetsinformasjon", "LabelDeviceInfo": "Enhetsinformasjon",
"LabelDeviceIsAvailableTo": "Enheten er tilgjengelig for...",
"LabelDirectory": "Mappe", "LabelDirectory": "Mappe",
"LabelDiscFromFilename": "Disk fra filnavn", "LabelDiscFromFilename": "Disk fra filnavn",
"LabelDiscFromMetadata": "Disk fra metadata", "LabelDiscFromMetadata": "Disk fra metadata",
"LabelDiscover": "Oppdagelse", "LabelDiscover": "Oppdag",
"LabelDownload": "Last ned", "LabelDownload": "Last ned",
"LabelDownloadNEpisodes": "Last ned {0} episoder", "LabelDownloadNEpisodes": "Last ned {0} episoder",
"LabelDuration": "Varighet", "LabelDuration": "Varighet",
"LabelDurationComparisonExactMatch": "(nøyaktig treff)",
"LabelDurationComparisonLonger": "({0} lenger)",
"LabelDurationComparisonShorter": "({0} kortere)",
"LabelDurationFound": "Varighet funnet:", "LabelDurationFound": "Varighet funnet:",
"LabelEbook": "Ebok", "LabelEbook": "Ebok",
"LabelEbooks": "E-bøker", "LabelEbooks": "E-bøker",
"LabelEdit": "Rediger", "LabelEdit": "Rediger",
"LabelEmail": "Epost", "LabelEmail": "Epost",
"LabelEmailSettingsFromAddress": "Fra Adresse", "LabelEmailSettingsFromAddress": "Fra Adresse",
"LabelEmailSettingsRejectUnauthorized": "Avvis uautoriserte sertifikat",
"LabelEmailSettingsRejectUnauthorizedHelp": "Ved å deaktivere sjekk av SSL sertifikat eksponerer man tilkoblingen for sikkerhetsrisiko, som for eksempel mann-i-midten-angrep. Slå av kun om du forstår implikasjonene og stoler på e-post-serveren du kobler til!",
"LabelEmailSettingsSecure": "Sikker", "LabelEmailSettingsSecure": "Sikker",
"LabelEmailSettingsSecureHelp": "Hvis aktivert, vil tilkoblingen bruke TLS under tilkobling til tjeneren. Ellers vil TLS bli brukt hvis tjeneren støtter STARTTLS utvidelsen. I de fleste tilfeller aktiver valget hvis du kobler til med port 465. Med port 587 eller 25 deaktiver valget. (fra nodemailer.com/smtp/#authentication)", "LabelEmailSettingsSecureHelp": "Hvis aktivert, vil tilkoblingen bruke TLS under tilkobling til tjeneren. Ellers vil TLS bli brukt hvis tjeneren støtter STARTTLS utvidelsen. I de fleste tilfeller aktiver valget hvis du kobler til med port 465. Med port 587 eller 25 deaktiver valget. (fra nodemailer.com/smtp/#authentication)",
"LabelEmailSettingsTestAddress": "Test Adresse", "LabelEmailSettingsTestAddress": "Test Adresse",
"LabelEmbeddedCover": "Bak inn omslag", "LabelEmbeddedCover": "Bak inn omslag",
"LabelEnable": "Aktiver", "LabelEnable": "Aktiver",
"LabelEncodingBackupLocation": "En sikkerhetskopi av de originale lyd-filene lagres i mappen:",
"LabelEncodingChaptersNotEmbedded": "Kapitler er ikke bygget inn i flersporede lydbøker.",
"LabelEncodingClearItemCache": "Husk å tømme mellomlageret med jevne mellomrom.",
"LabelEncodingFinishedM4B": "Ferdig konvertert M4B-lydbøker legges i lydbok-mappen:",
"LabelEncodingInfoEmbedded": "Metadata bygges inn i lydsporene i lydbokmappen.",
"LabelEncodingStartedNavigation": "Så snart oppgaven er startet kan du navigere bort fra denne siden.",
"LabelEncodingTimeWarning": "Konvertering kan ta opptil 30 minutter.",
"LabelEncodingWarningAdvancedSettings": "Advarsel: Ikke oppdater disse innstillingene med mindre du er godt kjent med hvordan ffmpeg og konverteringsvalgene fungerer.",
"LabelEncodingWatcherDisabled": "Hvis du har slått av overvåking så må du skanne dette biblioteket på nytt etterpå.",
"LabelEnd": "Slutt", "LabelEnd": "Slutt",
"LabelEndOfChapter": "Slutt på kapittel", "LabelEndOfChapter": "Slutt på kapittel",
"LabelEpisode": "Episode",
"LabelEpisodeNotLinkedToRssFeed": "Episode er ikke koblet til en RSS feed",
"LabelEpisodeNumber": "Episode #{0}",
"LabelEpisodeTitle": "Episode tittel", "LabelEpisodeTitle": "Episode tittel",
"LabelEpisodeType": "Episode type", "LabelEpisodeType": "Episode type",
"LabelEpisodeUrlFromRssFeed": "Episode URL fra RSS feed",
"LabelEpisodes": "Episoder",
"LabelEpisodic": "Episodisk",
"LabelExample": "Eksempel", "LabelExample": "Eksempel",
"LabelExpandSeries": "Vis serie",
"LabelExpandSubSeries": "Vis underserie",
"LabelExplicit": "Eksplisitt", "LabelExplicit": "Eksplisitt",
"LabelExplicitChecked": "Eksplisitt (avhuket)",
"LabelExplicitUnchecked": "Ikke eksplisitt (ikke avhuket)",
"LabelExportOPML": "Eksporter OPML", "LabelExportOPML": "Eksporter OPML",
"LabelFeedURL": "Feed Adresse", "LabelFeedURL": "Feed Adresse",
"LabelFetchingMetadata": "Henter metadata",
"LabelFile": "Fil", "LabelFile": "Fil",
"LabelFileBirthtime": "Fil Opprettelsesdato", "LabelFileBirthtime": "Fil Opprettelsesdato",
"LabelFileBornDate": "Født {0}",
"LabelFileModified": "Fil Endret", "LabelFileModified": "Fil Endret",
"LabelFileModifiedDate": "Redigert {0}",
"LabelFilename": "Filnavn", "LabelFilename": "Filnavn",
"LabelFilterByUser": "Filtrer etter bruker", "LabelFilterByUser": "Filtrer etter bruker",
"LabelFindEpisodes": "Finn episoder", "LabelFindEpisodes": "Finn episoder",
"LabelFinished": "Fullført", "LabelFinished": "Fullført",
"LabelFolder": "Mappe", "LabelFolder": "Mappe",
"LabelFolders": "Mapper", "LabelFolders": "Mapper",
"LabelFontBold": "Fet",
"LabelFontBoldness": "Skrifttykkelse", "LabelFontBoldness": "Skrifttykkelse",
"LabelFontFamily": "Fontfamilie", "LabelFontFamily": "Fontfamilie",
"LabelFontItalic": "Kursiv",
"LabelFontScale": "Font størrelse", "LabelFontScale": "Font størrelse",
"LabelFontStrikethrough": "Gjennomstreking",
"LabelFormat": "Format",
"LabelFull": "Full",
"LabelGenre": "Sjanger", "LabelGenre": "Sjanger",
"LabelGenres": "Sjangers", "LabelGenres": "Sjangers",
"LabelHardDeleteFile": "Tving sletting av fil", "LabelHardDeleteFile": "Tving sletting av fil",
"LabelHasEbook": "Har e-bok", "LabelHasEbook": "Har e-bok",
"LabelHasSupplementaryEbook": "Har komplimentær e-bok", "LabelHasSupplementaryEbook": "Har komplimentær e-bok",
"LabelHideSubtitles": "Skjul undertekster", "LabelHideSubtitles": "Skjul undertekster",
"LabelHighestPriority": "Høyeste prioritet",
"LabelHost": "Tjener", "LabelHost": "Tjener",
"LabelHour": "Time", "LabelHour": "Time",
"LabelHours": "Timer", "LabelHours": "Timer",
"LabelIcon": "Ikon", "LabelIcon": "Ikon",
"LabelImageURLFromTheWeb": "Bilde-URL fra nett",
"LabelInProgress": "I gang", "LabelInProgress": "I gang",
"LabelIncludeInTracklist": "Inkluder i sporliste", "LabelIncludeInTracklist": "Inkluder i sporliste",
"LabelIncomplete": "Ufullstendig", "LabelIncomplete": "Ufullstendig",
@ -296,8 +390,11 @@
"LabelIntervalEveryHour": "Hver time", "LabelIntervalEveryHour": "Hver time",
"LabelInvert": "Inverter", "LabelInvert": "Inverter",
"LabelItem": "Enhet", "LabelItem": "Enhet",
"LabelJumpBackwardAmount": "Hopp bakover med",
"LabelJumpForwardAmount": "Hopp forover med",
"LabelLanguage": "Språk", "LabelLanguage": "Språk",
"LabelLanguageDefaultServer": "Standard tjener språk", "LabelLanguageDefaultServer": "Standard tjener språk",
"LabelLanguages": "Språk",
"LabelLastBookAdded": "Siste bok lagt til", "LabelLastBookAdded": "Siste bok lagt til",
"LabelLastBookUpdated": "Siste bok oppdatert", "LabelLastBookUpdated": "Siste bok oppdatert",
"LabelLastSeen": "Sist sett", "LabelLastSeen": "Sist sett",
@ -309,17 +406,36 @@
"LabelLess": "Mindre", "LabelLess": "Mindre",
"LabelLibrariesAccessibleToUser": "Biblioteker tilgjengelig for bruker", "LabelLibrariesAccessibleToUser": "Biblioteker tilgjengelig for bruker",
"LabelLibrary": "Bibliotek", "LabelLibrary": "Bibliotek",
"LabelLibraryFilterSublistEmpty": "",
"LabelLibraryItem": "Bibliotek enhet", "LabelLibraryItem": "Bibliotek enhet",
"LabelLibraryName": "Bibliotek navn", "LabelLibraryName": "Bibliotek navn",
"LabelLimit": "Begrensning", "LabelLimit": "Begrensning",
"LabelLineSpacing": "Linjemellomrom", "LabelLineSpacing": "Linjemellomrom",
"LabelListenAgain": "Lytt igjen", "LabelListenAgain": "Lytt igjen",
"LabelLogLevelDebug": "Debug",
"LabelLogLevelInfo": "Info",
"LabelLogLevelWarn": "Warn",
"LabelLookForNewEpisodesAfterDate": "Se etter nye episoder etter denne datoen", "LabelLookForNewEpisodesAfterDate": "Se etter nye episoder etter denne datoen",
"LabelLowestPriority": "Laveste prioritet",
"LabelMatchExistingUsersBy": "Knytt sammen eksisterende brukere basert på",
"LabelMatchExistingUsersByDescription": "Brukes for å koble til eksisterende brukere. Når koblingen er i orden vil brukerne bli identifisert med en unik id fra SSO-tilbyderen.",
"LabelMaxEpisodesToDownload": "Maksimalt antall episoder som skal lastes ned. Bruk 0 for ubegrenset.",
"LabelMaxEpisodesToDownloadPerCheck": "Maksimalt antall nye episoder som skal lastes ned per sjekk",
"LabelMaxEpisodesToKeep": "Maksimalt antall episoder som skal beholdes",
"LabelMaxEpisodesToKeepHelp": "Sett verdien til null (0) for ubegrenset. Etter at en episode lastes ned automatisk, så slettes den eldste episoden, om du har mer enn X episoder. Det slettes kun én episode per nye nedlasting.",
"LabelMediaPlayer": "Mediespiller", "LabelMediaPlayer": "Mediespiller",
"LabelMediaType": "Medie type", "LabelMediaType": "Medie type",
"LabelMetaTag": "Meta tag",
"LabelMetaTags": "Meta tags",
"LabelMetadataOrderOfPrecedenceDescription": "Høyere prioritert kilder for metadata overstyrer laverer prioriterte kilder for metadata.",
"LabelMetadataProvider": "Metadata Leverandør", "LabelMetadataProvider": "Metadata Leverandør",
"LabelMinute": "Minutt", "LabelMinute": "Minutt",
"LabelMinutes": "Minutter",
"LabelMissing": "Mangler", "LabelMissing": "Mangler",
"LabelMissingEbook": "Har ingen e-bok",
"LabelMissingSupplementaryEbook": "Har ingen komplementær e-bok",
"LabelMobileRedirectURIs": "Tillatte URL-er for vidersending",
"LabelMobileRedirectURIsDescription": "Dette er en liste over godkjente videresendings-URL-er for mobil-apper. Standarden er <code>audiobookshelf://oauth</code>, som du kan fjerne eller supplere med ekstra URL-er for tredjeparts app-integrasjoner. For å tillate alle URL-er kan du bruke kun en (<code>*</code>) .",
"LabelMore": "Mer", "LabelMore": "Mer",
"LabelMoreInfo": "Mer info", "LabelMoreInfo": "Mer info",
"LabelName": "Navn", "LabelName": "Navn",
@ -331,6 +447,7 @@
"LabelNewestEpisodes": "Nyeste episoder", "LabelNewestEpisodes": "Nyeste episoder",
"LabelNextBackupDate": "Neste sikkerhetskopi dato", "LabelNextBackupDate": "Neste sikkerhetskopi dato",
"LabelNextScheduledRun": "Neste planlagte kjøring", "LabelNextScheduledRun": "Neste planlagte kjøring",
"LabelNoCustomMetadataProviders": "Ingen egendefinerte tilbydere for metadata",
"LabelNoEpisodesSelected": "Ingen episoder valgt", "LabelNoEpisodesSelected": "Ingen episoder valgt",
"LabelNotFinished": "Ikke fullført", "LabelNotFinished": "Ikke fullført",
"LabelNotStarted": "Ikke startet", "LabelNotStarted": "Ikke startet",
@ -338,66 +455,95 @@
"LabelNotificationAppriseURL": "Apprise URL(er)", "LabelNotificationAppriseURL": "Apprise URL(er)",
"LabelNotificationAvailableVariables": "Tilgjengelige variabler", "LabelNotificationAvailableVariables": "Tilgjengelige variabler",
"LabelNotificationBodyTemplate": "Kroppsmal", "LabelNotificationBodyTemplate": "Kroppsmal",
"LabelNotificationEvent": "Notifikasjons hendelse", "LabelNotificationEvent": "Varsling",
"LabelNotificationTitleTemplate": "Tittel mal", "LabelNotificationTitleTemplate": "Tittel mal",
"LabelNotificationsMaxFailedAttempts": "Maks mislykkede forsøk", "LabelNotificationsMaxFailedAttempts": "Maks mislykkede forsøk",
"LabelNotificationsMaxFailedAttemptsHelp": "Notifikasjoner er deaktivert når de mislykkes på sende dette flere ganger", "LabelNotificationsMaxFailedAttemptsHelp": "Varslinger deaktiveres når sending feiles dette antallet ganger",
"LabelNotificationsMaxQueueSize": "Maks kø lengde for Notifikasjonshendelser", "LabelNotificationsMaxQueueSize": "Maksimalt antall varslinger i kø",
"LabelNotificationsMaxQueueSizeHelp": "Hendelser er begrenset til avfyre 1 gang per sekund. Hendelser vil bli ignorert om køen er full. Dette forhindrer Notifikasjon spam.", "LabelNotificationsMaxQueueSizeHelp": "Hendelser er begrenset til avfyre én gang per sekund. Hendelser blir ignorert om køen er full. Dette forhindrer overflod av varslinger.",
"LabelNumberOfBooks": "Antall bøker", "LabelNumberOfBooks": "Antall bøker",
"LabelNumberOfEpisodes": "Antall episoder", "LabelNumberOfEpisodes": "Antall episoder",
"LabelOpenIDClaims": "La følge valg være tomme for å slå av avanserte gruppe og tillatelser. Gruppen \"Bruker\" vil da også automatisk legges til.",
"LabelOpenRSSFeed": "Åpne RSS Feed", "LabelOpenRSSFeed": "Åpne RSS Feed",
"LabelOverwrite": "Overskriv", "LabelOverwrite": "Overskriv",
"LabelPaginationPageXOfY": "Side {0} av {1}",
"LabelPassword": "Passord", "LabelPassword": "Passord",
"LabelPath": "Sti", "LabelPath": "Sti",
"LabelPermanent": "Fast", "LabelPermanent": "Fast",
"LabelPermissionsAccessAllLibraries": "Har tilgang til alle bibliotek", "LabelPermissionsAccessAllLibraries": "Har tilgang til alle bibliotek",
"LabelPermissionsAccessAllTags": "Har til gang til alle tags", "LabelPermissionsAccessAllTags": "Har til gang til alle tags",
"LabelPermissionsAccessExplicitContent": "Har tilgang til eksplisitt material", "LabelPermissionsAccessExplicitContent": "Har tilgang til eksplisitt material",
"LabelPermissionsCreateEreader": "Kan opprette e-leser",
"LabelPermissionsDelete": "Kan slette", "LabelPermissionsDelete": "Kan slette",
"LabelPermissionsDownload": "Kan laste ned", "LabelPermissionsDownload": "Kan laste ned",
"LabelPermissionsUpdate": "Kan oppdatere", "LabelPermissionsUpdate": "Kan oppdatere",
"LabelPermissionsUpload": "Kan laste opp", "LabelPermissionsUpload": "Kan laste opp",
"LabelPersonalYearReview": "Oppsummering av året ditt ({0})",
"LabelPhotoPathURL": "Bilde sti/URL", "LabelPhotoPathURL": "Bilde sti/URL",
"LabelPlayMethod": "Avspillingsmetode", "LabelPlayMethod": "Avspillingsmetode",
"LabelPlayerChapterNumberMarker": "{0} av {1}",
"LabelPlaylists": "Spilleliste", "LabelPlaylists": "Spilleliste",
"LabelPodcast": "Podcast",
"LabelPodcastSearchRegion": "Podcast-søkeområde", "LabelPodcastSearchRegion": "Podcast-søkeområde",
"LabelPodcastType": "Podcast type", "LabelPodcastType": "Podcast type",
"LabelPodcasts": "Podcaster", "LabelPodcasts": "Podcaster",
"LabelPort": "Port",
"LabelPrefixesToIgnore": "Prefiks som skal ignoreres (skiller ikke mellom store og små bokstaver)", "LabelPrefixesToIgnore": "Prefiks som skal ignoreres (skiller ikke mellom store og små bokstaver)",
"LabelPreventIndexing": "Forhindre at din feed fra å bli indeksert av iTunes og Google podcast kataloger", "LabelPreventIndexing": "Forhindre at din feed fra å bli indeksert av iTunes og Google podcast kataloger",
"LabelPrimaryEbook": "Primær ebok", "LabelPrimaryEbook": "Primær ebok",
"LabelProgress": "Framgang", "LabelProgress": "Framgang",
"LabelProvider": "Tilbyder", "LabelProvider": "Tilbyder",
"LabelProviderAuthorizationValue": "Autorisasjons header-verdi",
"LabelPubDate": "Publiseringsdato", "LabelPubDate": "Publiseringsdato",
"LabelPublishYear": "Publikasjonsår", "LabelPublishYear": "Publikasjonsår",
"LabelPublishedDate": "Publisert {0}",
"LabelPublishedDecade": "Tiår for utgivelse",
"LabelPublishedDecades": "Tiår for utgivelse",
"LabelPublisher": "Forlegger", "LabelPublisher": "Forlegger",
"LabelPublishers": "Utgivere",
"LabelRSSFeedCustomOwnerEmail": "Tilpasset eier e-post", "LabelRSSFeedCustomOwnerEmail": "Tilpasset eier e-post",
"LabelRSSFeedCustomOwnerName": "Tilpasset eier Navn", "LabelRSSFeedCustomOwnerName": "Tilpasset eier Navn",
"LabelRSSFeedOpen": "RSS Feed åpne", "LabelRSSFeedOpen": "RSS Feed åpne",
"LabelRSSFeedPreventIndexing": "Forhindre indeksering", "LabelRSSFeedPreventIndexing": "Forhindre indeksering",
"LabelRSSFeedSlug": "RSS-informasjonskanalunderadresse", "LabelRSSFeedSlug": "RSS-feed ID",
"LabelRSSFeedURL": "RSS-feed URL",
"LabelRandomly": "Tilfeldig",
"LabelReAddSeriesToContinueListening": "Legg til igjen til \"Fortsett å lytte\"",
"LabelRead": "Les", "LabelRead": "Les",
"LabelReadAgain": "Les igjen", "LabelReadAgain": "Les igjen",
"LabelReadEbookWithoutProgress": "Les ebok uten å beholde fremgang", "LabelReadEbookWithoutProgress": "Les ebok uten å beholde fremgang",
"LabelRecentSeries": "Nylige serier", "LabelRecentSeries": "Nylige serier",
"LabelRecentlyAdded": "Nylig tillagt", "LabelRecentlyAdded": "Nylig tillagt",
"LabelRecommended": "Anbefalte", "LabelRecommended": "Anbefalte",
"LabelRedo": "Gjenta",
"LabelRegion": "Region",
"LabelReleaseDate": "Utgivelsesdato", "LabelReleaseDate": "Utgivelsesdato",
"LabelRemoveAllMetadataAbs": "Fjern alle metadata.abs filer",
"LabelRemoveAllMetadataJson": "Fjern alle metadata.json filer",
"LabelRemoveCover": "Fjern omslag", "LabelRemoveCover": "Fjern omslag",
"LabelRemoveMetadataFile": "Fjern metadata-filer fra biblioteks-mapper",
"LabelRemoveMetadataFileHelp": "Fjern alle metadata.json og metadata.abs i alle {0} mappene.",
"LabelRowsPerPage": "Rader per side",
"LabelSearchTerm": "Søkeord", "LabelSearchTerm": "Søkeord",
"LabelSearchTitle": "Søk tittel", "LabelSearchTitle": "Søk tittel",
"LabelSearchTitleOrASIN": "Søk tittel eller ASIN", "LabelSearchTitleOrASIN": "Søk tittel eller ASIN",
"LabelSeason": "Sesong", "LabelSeason": "Sesong",
"LabelSeasonNumber": "Sesong #{0}",
"LabelSelectAll": "Velg alt",
"LabelSelectAllEpisodes": "Velg alle episoder", "LabelSelectAllEpisodes": "Velg alle episoder",
"LabelSelectEpisodesShowing": "Velg {0} episoder vist", "LabelSelectEpisodesShowing": "Velg {0} episoder vist",
"LabelSelectUsers": "Velg brukere",
"LabelSendEbookToDevice": "Send Ebok til...", "LabelSendEbookToDevice": "Send Ebok til...",
"LabelSequence": "Sekvens", "LabelSequence": "Sekvens",
"LabelSerial": "Serienr.",
"LabelSeries": "Serier", "LabelSeries": "Serier",
"LabelSeriesName": "Serier Navn", "LabelSeriesName": "Serier Navn",
"LabelSeriesProgress": "Serier fremgang", "LabelSeriesProgress": "Serier fremgang",
"LabelServerLogLevel": "Server logg-nivå",
"LabelServerYearReview": "Server - Oppsummering av året ({0})",
"LabelSetEbookAsPrimary": "Sett som primær", "LabelSetEbookAsPrimary": "Sett som primær",
"LabelSetEbookAsSupplementary": "Sett som supplerende", "LabelSetEbookAsSupplementary": "Sett som supplerende",
"LabelSettingsAllowIframe": "Tillat å bygge inn i en iframe",
"LabelSettingsAudiobooksOnly": "Kun lydbøker", "LabelSettingsAudiobooksOnly": "Kun lydbøker",
"LabelSettingsAudiobooksOnlyHelp": "Aktivering av dette valget til ignorere ebok filer utenom de er i en lydbok mappe hvor de vil bli satt som supplerende ebøker", "LabelSettingsAudiobooksOnlyHelp": "Aktivering av dette valget til ignorere ebok filer utenom de er i en lydbok mappe hvor de vil bli satt som supplerende ebøker",
"LabelSettingsBookshelfViewHelp": "Skeuomorf design med hyller av ved", "LabelSettingsBookshelfViewHelp": "Skeuomorf design med hyller av ved",
@ -409,6 +555,8 @@
"LabelSettingsEnableWatcher": "Aktiver overvåker", "LabelSettingsEnableWatcher": "Aktiver overvåker",
"LabelSettingsEnableWatcherForLibrary": "Aktiver mappe overvåker for bibliotek", "LabelSettingsEnableWatcherForLibrary": "Aktiver mappe overvåker for bibliotek",
"LabelSettingsEnableWatcherHelp": "Aktiverer automatisk opprettelse/oppdatering av enheter når filendringer er oppdaget. *Krever restart av server*", "LabelSettingsEnableWatcherHelp": "Aktiverer automatisk opprettelse/oppdatering av enheter når filendringer er oppdaget. *Krever restart av server*",
"LabelSettingsEpubsAllowScriptedContent": "Tillat scripting i innholdet i ebub-bøker",
"LabelSettingsEpubsAllowScriptedContentHelp": "Tillat epub-filer å kjøre script. Det er anbefalt å slå av denne innstillingen med mindre du stoler på kilden til epub-filene.",
"LabelSettingsExperimentalFeatures": "Eksperimentelle funksjoner", "LabelSettingsExperimentalFeatures": "Eksperimentelle funksjoner",
"LabelSettingsExperimentalFeaturesHelp": "Funksjoner under utvikling som kan trenge din tilbakemelding og hjelp med testing. Klikk for å åpne GitHub diskusjon.", "LabelSettingsExperimentalFeaturesHelp": "Funksjoner under utvikling som kan trenge din tilbakemelding og hjelp med testing. Klikk for å åpne GitHub diskusjon.",
"LabelSettingsFindCovers": "Finn omslag", "LabelSettingsFindCovers": "Finn omslag",
@ -417,8 +565,13 @@
"LabelSettingsHideSingleBookSeriesHelp": "Serier som har kun en bok vil bli gjemt på serie- og hjemmeside hyllen.", "LabelSettingsHideSingleBookSeriesHelp": "Serier som har kun en bok vil bli gjemt på serie- og hjemmeside hyllen.",
"LabelSettingsHomePageBookshelfView": "Hjemmeside bruk bokhyllevisning", "LabelSettingsHomePageBookshelfView": "Hjemmeside bruk bokhyllevisning",
"LabelSettingsLibraryBookshelfView": "Bibliotek bruk bokhyllevisning", "LabelSettingsLibraryBookshelfView": "Bibliotek bruk bokhyllevisning",
"LabelSettingsLibraryMarkAsFinishedPercentComplete": "Prosent ferdig er større enn",
"LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Gjenværende tid er mindre enn (sekunder)",
"LabelSettingsLibraryMarkAsFinishedWhen": "Marker som ferdig når",
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Hopp over tidligere bøker i \"Fortsett serien\"",
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "\"Fortsett serie\"-siden viser første bok som ikke er påbegynt i serier der en bok er lest og ingen bøker leses nå. Ved å slå på denne innstillingen så vil man fortsette på serien etter siste leste bok, fremfor første bok som ikke er startet på i en serie.",
"LabelSettingsParseSubtitles": "Analyser undertekster", "LabelSettingsParseSubtitles": "Analyser undertekster",
"LabelSettingsParseSubtitlesHelp": "Trekk ut undertekster fra lydbok mappenavn.<br>undertekster må være separert med \" - \"<br>f.eks. \"Boktittel - Undertekst her\" har Undertekst \"Undertekst her\"", "LabelSettingsParseSubtitlesHelp": "Hent undertittel fra lydbokens mappenavn.<br>Undertittel må være separert med \" - \"<br>f.eks. \"Boktittel - En lengre tittel\" har undertittel \"En lengre tittel\".",
"LabelSettingsPreferMatchedMetadata": "Foretrekk funnet metadata", "LabelSettingsPreferMatchedMetadata": "Foretrekk funnet metadata",
"LabelSettingsPreferMatchedMetadataHelp": "Funnet data vil overskrive enhetens detaljene når man bruker Kjapt søk. Som standard vil Kjapt søk kun fylle inn manglende detaljer.", "LabelSettingsPreferMatchedMetadataHelp": "Funnet data vil overskrive enhetens detaljene når man bruker Kjapt søk. Som standard vil Kjapt søk kun fylle inn manglende detaljer.",
"LabelSettingsSkipMatchingBooksWithASIN": "Hopp over bøker som allerede har ASIN", "LabelSettingsSkipMatchingBooksWithASIN": "Hopp over bøker som allerede har ASIN",
@ -433,10 +586,17 @@
"LabelSettingsStoreMetadataWithItemHelp": "Som standard vil metadata bli lagret under /metadata/items, aktiveres dette valget vil metadata bli lagret i samme mappe som gjenstanden", "LabelSettingsStoreMetadataWithItemHelp": "Som standard vil metadata bli lagret under /metadata/items, aktiveres dette valget vil metadata bli lagret i samme mappe som gjenstanden",
"LabelSettingsTimeFormat": "Tid format", "LabelSettingsTimeFormat": "Tid format",
"LabelShare": "Dele", "LabelShare": "Dele",
"LabelShareOpen": "Åpne deling",
"LabelShareURL": "Dele URL", "LabelShareURL": "Dele URL",
"LabelShowAll": "Vis alt", "LabelShowAll": "Vis alle",
"LabelShowSeconds": "Vis sekunder",
"LabelShowSubtitles": "Vis undertitler",
"LabelSize": "Størrelse", "LabelSize": "Størrelse",
"LabelSleepTimer": "Sove-timer", "LabelSleepTimer": "Sove-timer",
"LabelSlug": "Slug",
"LabelSortAscending": "Stigende",
"LabelSortDescending": "Synkende",
"LabelStart": "Start",
"LabelStartTime": "Start Tid", "LabelStartTime": "Start Tid",
"LabelStarted": "Startet", "LabelStarted": "Startet",
"LabelStartedAt": "Startet", "LabelStartedAt": "Startet",
@ -457,15 +617,24 @@
"LabelStatsWeekListening": "Uker lyttet", "LabelStatsWeekListening": "Uker lyttet",
"LabelSubtitle": "undertekster", "LabelSubtitle": "undertekster",
"LabelSupportedFileTypes": "Støttede filtyper", "LabelSupportedFileTypes": "Støttede filtyper",
"LabelTag": "Tag",
"LabelTags": "Tagger", "LabelTags": "Tagger",
"LabelTagsAccessibleToUser": "Tagger tilgjengelig for bruker", "LabelTagsAccessibleToUser": "Tagger tilgjengelig for bruker",
"LabelTagsNotAccessibleToUser": "Tagger ikke tilgjengelig for bruker", "LabelTagsNotAccessibleToUser": "Tagger ikke tilgjengelig for bruker",
"LabelTasks": "Oppgaver som kjører", "LabelTasks": "Oppgaver som kjører",
"LabelTextEditorBulletedList": "Punkt-liste",
"LabelTextEditorLink": "Link",
"LabelTextEditorNumberedList": "Nummerert liste",
"LabelTextEditorUnlink": "Fjern link",
"LabelTheme": "Tema", "LabelTheme": "Tema",
"LabelThemeDark": "Mørk", "LabelThemeDark": "Mørk",
"LabelThemeLight": "Lys", "LabelThemeLight": "Lys",
"LabelTimeBase": "Tidsbase", "LabelTimeBase": "Tidsbase",
"LabelTimeDurationXHours": "{0} timer",
"LabelTimeDurationXMinutes": "{0} minutter",
"LabelTimeDurationXSeconds": "{0} sekunder",
"LabelTimeInMinutes": "Timer i minutter", "LabelTimeInMinutes": "Timer i minutter",
"LabelTimeLeft": "{0} gjenstår",
"LabelTimeListened": "Tid lyttet", "LabelTimeListened": "Tid lyttet",
"LabelTimeListenedToday": "Tid lyttet idag", "LabelTimeListenedToday": "Tid lyttet idag",
"LabelTimeRemaining": "{0} gjennstående", "LabelTimeRemaining": "{0} gjennstående",
@ -473,6 +642,7 @@
"LabelTitle": "Tittel", "LabelTitle": "Tittel",
"LabelToolsEmbedMetadata": "Bak inn metadata", "LabelToolsEmbedMetadata": "Bak inn metadata",
"LabelToolsEmbedMetadataDescription": "Bak inn metadata i lydfilen, inkludert omslagsbilde og kapitler.", "LabelToolsEmbedMetadataDescription": "Bak inn metadata i lydfilen, inkludert omslagsbilde og kapitler.",
"LabelToolsM4bEncoder": "M4B enkoder",
"LabelToolsMakeM4b": "Lag M4B Lydbokfil", "LabelToolsMakeM4b": "Lag M4B Lydbokfil",
"LabelToolsMakeM4bDescription": "Lager en.M4B lydbokfil med innbakte omslagsbilde og kapitler.", "LabelToolsMakeM4bDescription": "Lager en.M4B lydbokfil med innbakte omslagsbilde og kapitler.",
"LabelToolsSplitM4b": "Del M4B inn i MP3er", "LabelToolsSplitM4b": "Del M4B inn i MP3er",
@ -485,39 +655,56 @@
"LabelTracksMultiTrack": "Flerspor", "LabelTracksMultiTrack": "Flerspor",
"LabelTracksNone": "Ingen spor", "LabelTracksNone": "Ingen spor",
"LabelTracksSingleTrack": "Enkelspor", "LabelTracksSingleTrack": "Enkelspor",
"LabelTrailer": "Trailer",
"LabelType": "Type",
"LabelUnabridged": "Uavkortet", "LabelUnabridged": "Uavkortet",
"LabelUndo": "Angre",
"LabelUnknown": "Ukjent", "LabelUnknown": "Ukjent",
"LabelUnknownPublishDate": "Ukjent publiseringsdato",
"LabelUpdateCover": "Oppdater omslag", "LabelUpdateCover": "Oppdater omslag",
"LabelUpdateCoverHelp": "Tillat overskriving av eksisterende omslag for de valgte bøkene når en lik bok er funnet", "LabelUpdateCoverHelp": "Tillat overskriving av eksisterende omslag for de valgte bøkene når en lik bok er funnet",
"LabelUpdateDetails": "Oppdater detaljer", "LabelUpdateDetails": "Oppdater detaljer",
"LabelUpdateDetailsHelp": "Tillat overskriving av eksisterende detaljer for de valgte bøkene når en lik bok er funnet", "LabelUpdateDetailsHelp": "Tillat overskriving av eksisterende detaljer for de valgte bøkene når en lik bok er funnet",
"LabelUpdatedAt": "Oppdatert", "LabelUpdatedAt": "Oppdatert",
"LabelUploaderDragAndDrop": "Dra og slipp filer eller mapper", "LabelUploaderDragAndDrop": "Dra og slipp filer eller mapper",
"LabelUploaderDragAndDropFilesOnly": "Dra & slipp filer",
"LabelUploaderDropFiles": "Slipp filer", "LabelUploaderDropFiles": "Slipp filer",
"LabelUploaderItemFetchMetadataHelp": "Hent tittel, forfatter og serie automatisk",
"LabelUseAdvancedOptions": "Bruk avanserte valg",
"LabelUseChapterTrack": "Bruk kapittelspor", "LabelUseChapterTrack": "Bruk kapittelspor",
"LabelUseFullTrack": "Bruke hele sporet", "LabelUseFullTrack": "Bruke hele sporet",
"LabelUseZeroForUnlimited": "Bruk 0 for ubegrenset",
"LabelUser": "Bruker", "LabelUser": "Bruker",
"LabelUsername": "Brukernavn", "LabelUsername": "Brukernavn",
"LabelValue": "Verdi", "LabelValue": "Verdi",
"LabelVersion": "Versjon", "LabelVersion": "Versjon",
"LabelViewBookmarks": "Vis bokmerker", "LabelViewBookmarks": "Vis bokmerker",
"LabelViewChapters": "Vis kapitler", "LabelViewChapters": "Vis kapitler",
"LabelViewPlayerSettings": "Vis innstillinger for avspiller",
"LabelViewQueue": "Vis spillerkø", "LabelViewQueue": "Vis spillerkø",
"LabelVolume": "Volum", "LabelVolume": "Volum",
"LabelWebRedirectURLsDescription": "Godkjenn disse URL-ene hos OAuth-tilbyder for å tillate videresending til web-appen etter innlogging:",
"LabelWebRedirectURLsSubfolder": "Undermapper for videresendings-URL-er",
"LabelWeekdaysToRun": "Ukedager å kjøre", "LabelWeekdaysToRun": "Ukedager å kjøre",
"LabelXBooks": "{0} bøker",
"LabelXItems": "{0} elementer",
"LabelYearReviewHide": "Skjul oppsummering av året",
"LabelYearReviewShow": "Vis oppsummering av året",
"LabelYourAudiobookDuration": "Din lydbok lengde", "LabelYourAudiobookDuration": "Din lydbok lengde",
"LabelYourBookmarks": "Dine bokmerker", "LabelYourBookmarks": "Dine bokmerker",
"LabelYourPlaylists": "Dine spillelister", "LabelYourPlaylists": "Dine spillelister",
"LabelYourProgress": "Din fremgang", "LabelYourProgress": "Din fremgang",
"MessageAddToPlayerQueue": "Legg til i kø", "MessageAddToPlayerQueue": "Legg til i kø",
"MessageAppriseDescription": "For å bruke denne funksjonen trenger du en instans av <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> kjørende eller ett api som vil håndere disse forespørslene. <br />Apprise API Url skal være den fulle URL stien for å sende Notifikasjonen, f.eks., hvis din API instans er hos <code>http://192.168.1.1:8337</code> vil du bruke <code>http://192.168.1.1:8337/notify</code>.", "MessageAppriseDescription": "For å bruke denne funksjonen trenger du en instans av <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> kjørende eller et API som håndterer disse forespørslene. <br />Apprise API URL skal være hele URL-en til varslingen, f.eks., hvis din API-instans er på <code>http://192.168.1.1:8337</code> så skal du bruke <code>http://192.168.1.1:8337/notify</code>.",
"MessageBackupsDescription": "Sikkerhetskopier inkluderer, brukerfremgang, detaljer om bibliotekgjenstander, tjener instillinger og bilder lagret under <code>/metadata/items</code> og <code>/metadata/authors</code>. Sikkerhetskopier <strong>vil ikke</strong> inkludere filer som er lagret i bibliotek mappene.", "MessageBackupsDescription": "Sikkerhetskopier inkluderer, brukerfremgang, detaljer om bibliotekgjenstander, tjener instillinger og bilder lagret under <code>/metadata/items</code> og <code>/metadata/authors</code>. Sikkerhetskopier <strong>vil ikke</strong> inkludere filer som er lagret i bibliotek mappene.",
"MessageBackupsLocationEditNote": "Merk: Endring av sikkerhetskopieringssted hverken endrer eller flytter eksisterende sikkerhetskopier", "MessageBackupsLocationEditNote": "Viktig: Endring av mappen for sikkerhetskopi hverken endrer eller flytter eksisterende sikkerhetskopier!",
"MessageBackupsLocationPathEmpty": "Sti til sikkerhetskopieringssted må angis", "MessageBackupsLocationNoEditNote": "NB: Mappen for sikkerhetskopi settes i en miljøvariabel og kan ikke endres her.",
"MessageBackupsLocationPathEmpty": "Mappen for sikkerhetskopiering må angis",
"MessageBatchQuickMatchDescription": "Kjapt søk vil forsøke å legge til manglende omslag og metadata for de valgte gjenstandene. Aktiver dette valget for å tillate Kjapt søk til å overskrive eksisterende omslag og/eller metadata.", "MessageBatchQuickMatchDescription": "Kjapt søk vil forsøke å legge til manglende omslag og metadata for de valgte gjenstandene. Aktiver dette valget for å tillate Kjapt søk til å overskrive eksisterende omslag og/eller metadata.",
"MessageBookshelfNoCollections": "Du har ikke laget noen samlinger ennå", "MessageBookshelfNoCollections": "Du har ikke laget noen samlinger ennå",
"MessageBookshelfNoRSSFeeds": "Ingen RSS feed er åpen", "MessageBookshelfNoRSSFeeds": "Ingen RSS feed er åpen",
"MessageBookshelfNoResultsForFilter": "Ingen resultat for filter \"{0}: {1}\"", "MessageBookshelfNoResultsForFilter": "Ingen resultat for filter \"{0}: {1}\"",
"MessageBookshelfNoResultsForQuery": "Ingen resultater for søket",
"MessageBookshelfNoSeries": "Du har ingen serier", "MessageBookshelfNoSeries": "Du har ingen serier",
"MessageChapterEndIsAfter": "Kapittel slutt er etter slutt av lydboken", "MessageChapterEndIsAfter": "Kapittel slutt er etter slutt av lydboken",
"MessageChapterErrorFirstNotZero": "Første kapittel starter på 0", "MessageChapterErrorFirstNotZero": "Første kapittel starter på 0",
@ -527,18 +714,35 @@
"MessageCheckingCron": "Sjekker cron...", "MessageCheckingCron": "Sjekker cron...",
"MessageConfirmCloseFeed": "Er du sikker på at du vil lukke denne feeden?", "MessageConfirmCloseFeed": "Er du sikker på at du vil lukke denne feeden?",
"MessageConfirmDeleteBackup": "Er du sikker på at du vil slette sikkerhetskopi for {0}?", "MessageConfirmDeleteBackup": "Er du sikker på at du vil slette sikkerhetskopi for {0}?",
"MessageConfirmDeleteDevice": "Er du sikker på at du vil slette e-leser enheten \"{0}\"?",
"MessageConfirmDeleteFile": "Dette vil slette filen fra filsystemet. Er du sikker?", "MessageConfirmDeleteFile": "Dette vil slette filen fra filsystemet. Er du sikker?",
"MessageConfirmDeleteLibrary": "Er du sikker på at du vil slette biblioteket \"{0}\" for godt?", "MessageConfirmDeleteLibrary": "Er du sikker på at du vil slette biblioteket \"{0}\" for godt?",
"MessageConfirmDeleteLibraryItem": "Nå slettes elementet fra databasen og fil-systemet. Er du sikker?",
"MessageConfirmDeleteLibraryItems": "Nå slettes {0} elementer fra databasen og fil-systemet. Er du sikker?",
"MessageConfirmDeleteMetadataProvider": "Er du sikker på at du vil slette den egendefinerte leverandøren av metadata: \"{0}\"?",
"MessageConfirmDeleteNotification": "Er du sikker på at du vil slette dette varselet?",
"MessageConfirmDeleteSession": "Er du sikker på at du vil slette denne sesjonen?", "MessageConfirmDeleteSession": "Er du sikker på at du vil slette denne sesjonen?",
"MessageConfirmEmbedMetadataInAudioFiles": "Er du sikker på at du vil legge til metadata i {0} lyd-filer?",
"MessageConfirmForceReScan": "Er du sikker på at du vil tvinge en ny skann?", "MessageConfirmForceReScan": "Er du sikker på at du vil tvinge en ny skann?",
"MessageConfirmMarkAllEpisodesFinished": "Er du sikker på at du vil markere alle episodene som fullført?", "MessageConfirmMarkAllEpisodesFinished": "Er du sikker på at du vil markere alle episodene som fullført?",
"MessageConfirmMarkAllEpisodesNotFinished": "Er du sikker på at du vil markere alle episodene som ikke fullført?", "MessageConfirmMarkAllEpisodesNotFinished": "Er du sikker på at du vil markere alle episodene som ikke fullført?",
"MessageConfirmMarkItemFinished": "Er du sikker på at du vil markere {0} som ferdig?",
"MessageConfirmMarkItemNotFinished": "Er du sikker på at du vil markere {0} som ikke ferdig?",
"MessageConfirmMarkSeriesFinished": "Er du sikker på at du vil markere alle bøkene i serien som fullført?", "MessageConfirmMarkSeriesFinished": "Er du sikker på at du vil markere alle bøkene i serien som fullført?",
"MessageConfirmMarkSeriesNotFinished": "Er du sikker på at du vil markere alle bøkene i serien som ikke fullført?", "MessageConfirmMarkSeriesNotFinished": "Er du sikker på at du vil markere alle bøkene i serien som ikke fullført?",
"MessageConfirmNotificationTestTrigger": "Utløs dette varselet med test-data?",
"MessageConfirmPurgeCache": "(Purge cache) Dette vil sletter hele mappen <code>/metadata/cache</code>. <br /><br />Er du sikker på at du du vil slette cache-mappen?",
"MessageConfirmPurgeItemsCache": "(Purge items cache) Dette vil sletter hele mappen <code>/metadata/cache/items</code>.<br />Er du sikker?",
"MessageConfirmQuickEmbed": "Advarsel! Rask innbygging av metadata tar ikke backup av lyd-filene først. Forsikre deg om at du har sikkerhetskopi av filene. <br><br> Fortsett?",
"MessageConfirmQuickMatchEpisodes": "Hurtig gjenkjenning av episoder overskriver detaljene hvis en match blir funnet. Kun episoder som ikke allerede er matchet blir oppdatert. Er du sikker?",
"MessageConfirmReScanLibraryItems": "Er du sikker på at du ønsker å skanne {0} elementer på nytt?",
"MessageConfirmRemoveAllChapters": "Er du sikker på at du vil fjerne alle kapitler?", "MessageConfirmRemoveAllChapters": "Er du sikker på at du vil fjerne alle kapitler?",
"MessageConfirmRemoveAuthor": "Er du sikker på at du vil fjerne forfatteren \"{0}\"?",
"MessageConfirmRemoveCollection": "Er du sikker på at du vil fjerne samling\"{0}\"?", "MessageConfirmRemoveCollection": "Er du sikker på at du vil fjerne samling\"{0}\"?",
"MessageConfirmRemoveEpisode": "Er du sikker på at du vil fjerne episode \"{0}\"?", "MessageConfirmRemoveEpisode": "Er du sikker på at du vil fjerne episode \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Er du sikker på at du vil fjerne {0} episoder?", "MessageConfirmRemoveEpisodes": "Er du sikker på at du vil fjerne {0} episoder?",
"MessageConfirmRemoveListeningSessions": "Er du sikker på at du vil fjerne {0} lytte-sesjoner?",
"MessageConfirmRemoveMetadataFiles": "Er du sikker på at du vil fjerne alle metadata.{0}-filer i mappene for biblioteks-elementer?",
"MessageConfirmRemoveNarrator": "Er du sikker på at du vil fjerne forteller \"{0}\"?", "MessageConfirmRemoveNarrator": "Er du sikker på at du vil fjerne forteller \"{0}\"?",
"MessageConfirmRemovePlaylist": "Er du sikker på at du vil fjerne spillelisten \"{0}\"?", "MessageConfirmRemovePlaylist": "Er du sikker på at du vil fjerne spillelisten \"{0}\"?",
"MessageConfirmRenameGenre": "Er du sikker på at du vil endre sjanger \"{0}\" til \"{1}\" for alle gjenstandene?", "MessageConfirmRenameGenre": "Er du sikker på at du vil endre sjanger \"{0}\" til \"{1}\" for alle gjenstandene?",
@ -547,11 +751,16 @@
"MessageConfirmRenameTag": "Er du sikker på at du vil endre tag \"{0}\" til \"{1}\" for alle gjenstandene?", "MessageConfirmRenameTag": "Er du sikker på at du vil endre tag \"{0}\" til \"{1}\" for alle gjenstandene?",
"MessageConfirmRenameTagMergeNote": "Notis: Denne taggen finnes allerede så de vil bli slått sammen.", "MessageConfirmRenameTagMergeNote": "Notis: Denne taggen finnes allerede så de vil bli slått sammen.",
"MessageConfirmRenameTagWarning": "Advarsel! En lignende tag eksisterer allerede (med forsjellige store / små bokstaver) \"{0}\".", "MessageConfirmRenameTagWarning": "Advarsel! En lignende tag eksisterer allerede (med forsjellige store / små bokstaver) \"{0}\".",
"MessageConfirmResetProgress": "Er du sikkert på at du vil tilbakestille fremgangen?",
"MessageConfirmSendEbookToDevice": "Er du sikker på at du vil sende {0} ebok \"{1}\" til enhet \"{2}\"?", "MessageConfirmSendEbookToDevice": "Er du sikker på at du vil sende {0} ebok \"{1}\" til enhet \"{2}\"?",
"MessageConfirmUnlinkOpenId": "Er du sikker på at du vil koble denne brukeren fra OpenID?",
"MessageDownloadingEpisode": "Laster ned episode", "MessageDownloadingEpisode": "Laster ned episode",
"MessageDragFilesIntoTrackOrder": "Dra filene i rett spor rekkefølge", "MessageDragFilesIntoTrackOrder": "Dra filene i rett spor rekkefølge",
"MessageEmbedFailed": "Innbygging feilet!",
"MessageEmbedFinished": "Bak inn Fullført!", "MessageEmbedFinished": "Bak inn Fullført!",
"MessageEmbedQueue": "Lagt i køen for innbygging av metadata ({0} i kø)",
"MessageEpisodesQueuedForDownload": "{0} Episode(r) lagt til i kø for nedlasting", "MessageEpisodesQueuedForDownload": "{0} Episode(r) lagt til i kø for nedlasting",
"MessageEreaderDevices": "For å sikre sendingen av e-bøker, så må du kanskje legge til e-postadressen over som en gyldig avsender for hver enhet i listen over.",
"MessageFeedURLWillBe": "Feed URL vil bli {0}", "MessageFeedURLWillBe": "Feed URL vil bli {0}",
"MessageFetching": "Henter...", "MessageFetching": "Henter...",
"MessageForceReScanDescription": "vil skanne alle filene igjen som en ny skann. Lyd fil ID3 tagger, OPF filer og tekstfiler vil bli skannet som nye.", "MessageForceReScanDescription": "vil skanne alle filene igjen som en ny skann. Lyd fil ID3 tagger, OPF filer og tekstfiler vil bli skannet som nye.",
@ -560,7 +769,6 @@
"MessageItemsSelected": "{0} Gjenstander valgt", "MessageItemsSelected": "{0} Gjenstander valgt",
"MessageItemsUpdated": "{0} Gjenstander oppdatert", "MessageItemsUpdated": "{0} Gjenstander oppdatert",
"MessageJoinUsOn": "Følg oss nå", "MessageJoinUsOn": "Følg oss nå",
"MessageListeningSessionsInTheLastYear": "{0} Lyttesesjoner iløpet av siste året",
"MessageLoading": "Laster...", "MessageLoading": "Laster...",
"MessageLoadingFolders": "Laster mapper...", "MessageLoadingFolders": "Laster mapper...",
"MessageM4BFailed": "M4B mislykkes!", "MessageM4BFailed": "M4B mislykkes!",
@ -591,7 +799,7 @@
"MessageNoListeningSessions": "Ingen Lyttesesjoner", "MessageNoListeningSessions": "Ingen Lyttesesjoner",
"MessageNoLogs": "Ingen logger", "MessageNoLogs": "Ingen logger",
"MessageNoMediaProgress": "Ingen mediefremgang", "MessageNoMediaProgress": "Ingen mediefremgang",
"MessageNoNotifications": "Ingen notifikasjoner", "MessageNoNotifications": "Ingen varslinger",
"MessageNoPodcastsFound": "Ingen podcaster funnet", "MessageNoPodcastsFound": "Ingen podcaster funnet",
"MessageNoResults": "Ingen resultat", "MessageNoResults": "Ingen resultat",
"MessageNoSearchResultsFor": "Ingen søkeresultat for \"{0}\"", "MessageNoSearchResultsFor": "Ingen søkeresultat for \"{0}\"",
@ -646,30 +854,64 @@
"ToastAuthorUpdateMerged": "Forfatter slått sammen", "ToastAuthorUpdateMerged": "Forfatter slått sammen",
"ToastAuthorUpdateSuccess": "Forfatter oppdatert", "ToastAuthorUpdateSuccess": "Forfatter oppdatert",
"ToastAuthorUpdateSuccessNoImageFound": "Forfatter oppdater (ingen bilde funnet)", "ToastAuthorUpdateSuccessNoImageFound": "Forfatter oppdater (ingen bilde funnet)",
"ToastBackupAppliedSuccess": "Sikkerhetskopi slått på",
"ToastBackupCreateFailed": "Mislykkes å lage sikkerhetskopi", "ToastBackupCreateFailed": "Mislykkes å lage sikkerhetskopi",
"ToastBackupCreateSuccess": "Sikkerhetskopi opprettet", "ToastBackupCreateSuccess": "Sikkerhetskopi opprettet",
"ToastBackupDeleteFailed": "Mislykkes å slette sikkerhetskopi", "ToastBackupDeleteFailed": "Mislykkes å slette sikkerhetskopi",
"ToastBackupDeleteSuccess": "Sikkerhetskopi slettet", "ToastBackupDeleteSuccess": "Sikkerhetskopi slettet",
"ToastBackupInvalidMaxKeep": "Ugyldig antall sikkerhetskopier ønskes beholdt",
"ToastBackupInvalidMaxSize": "Ugyldig maksimal størrelse for sikkerhetskopi",
"ToastBackupRestoreFailed": "Misslykkes å gjenopprette sikkerhetskopi", "ToastBackupRestoreFailed": "Misslykkes å gjenopprette sikkerhetskopi",
"ToastBackupUploadFailed": "Misslykkes å laste opp sikkerhetskopi", "ToastBackupUploadFailed": "Misslykkes å laste opp sikkerhetskopi",
"ToastBackupUploadSuccess": "Sikkerhetskopi lastet opp", "ToastBackupUploadSuccess": "Sikkerhetskopi lastet opp",
"ToastBatchDeleteFailed": "Sletting feilet på utvalget",
"ToastBatchDeleteSuccess": "Sletting av samling utført",
"ToastBatchQuickMatchFailed": "Feil ved rask integrering av metadata!",
"ToastBatchQuickMatchStarted": "Rask integrering av metadata for {0} bøker startet!",
"ToastBatchUpdateFailed": "Bulk oppdatering mislykket", "ToastBatchUpdateFailed": "Bulk oppdatering mislykket",
"ToastBatchUpdateSuccess": "Bulk oppdatering fullført", "ToastBatchUpdateSuccess": "Bulk oppdatering fullført",
"ToastBookmarkCreateFailed": "Misslykkes å opprette bokmerke", "ToastBookmarkCreateFailed": "Misslykkes å opprette bokmerke",
"ToastBookmarkCreateSuccess": "Bokmerke lagt til", "ToastBookmarkCreateSuccess": "Bokmerke lagt til",
"ToastBookmarkRemoveSuccess": "Bokmerke fjernet", "ToastBookmarkRemoveSuccess": "Bokmerke fjernet",
"ToastBookmarkUpdateSuccess": "Bokmerke oppdatert", "ToastBookmarkUpdateSuccess": "Bokmerke oppdatert",
"ToastCachePurgeFailed": "Kunne ikke å slette mellomlager",
"ToastCachePurgeSuccess": "Mellomlager slettet",
"ToastChaptersHaveErrors": "Kapittel har feil", "ToastChaptersHaveErrors": "Kapittel har feil",
"ToastChaptersMustHaveTitles": "Kapittel må ha titler", "ToastChaptersMustHaveTitles": "Kapittel må ha titler",
"ToastCollectionItemsRemoveSuccess": "Gjenstand(er) fjernet fra samling", "ToastChaptersRemoved": "Kapitler fjernet",
"ToastChaptersUpdated": "Kapitler oppdatert",
"ToastCollectionItemsAddFailed": "Feil med å legge til element(er)",
"ToastCollectionRemoveSuccess": "Samling fjernet", "ToastCollectionRemoveSuccess": "Samling fjernet",
"ToastCollectionUpdateSuccess": "samlingupdated", "ToastCollectionUpdateSuccess": "samlingupdated",
"ToastCoverUpdateFailed": "Oppdatering av bilde feilet",
"ToastDeleteFileFailed": "Kunne ikke slette fil",
"ToastDeleteFileSuccess": "Fil slettet",
"ToastDeviceAddFailed": "Kunne ikke legge til enhet",
"ToastDeviceNameAlreadyExists": "E-leser med dette navnet eksisterer allerede",
"ToastDeviceTestEmailFailed": "Kunne ikke sende test e-post",
"ToastDeviceTestEmailSuccess": "E-post for testing er sendt",
"ToastEmailSettingsUpdateSuccess": "Innstillinger for e-post oppdatert",
"ToastEncodeCancelFailed": "Kunne ikke stoppe konverteringen",
"ToastEncodeCancelSucces": "Konvertering kansellert",
"ToastEpisodeDownloadQueueClearFailed": "Kunne ikke tømme køen",
"ToastEpisodeDownloadQueueClearSuccess": "Nedlastingskø for eposider tømt",
"ToastEpisodeUpdateSuccess": "{0} episoder oppdatert",
"ToastFailedToLoadData": "Kunne ikke laste inn data",
"ToastFailedToMatch": "Kunne ikke matche",
"ToastFailedToShare": "Deling feilet",
"ToastFailedToUpdate": "Oppdatering feilet",
"ToastInvalidImageUrl": "Ugyldig URL for bilde",
"ToastInvalidMaxEpisodesToDownload": "Ugyldig maksimalt antall for nedlasting av episoder",
"ToastInvalidUrl": "Ugyldig URL",
"ToastItemCoverUpdateSuccess": "Omslag oppdatert", "ToastItemCoverUpdateSuccess": "Omslag oppdatert",
"ToastItemDeletedFailed": "Kunne ikke slette element",
"ToastItemDeletedSuccess": "Element slettet",
"ToastItemDetailsUpdateSuccess": "Detaljer oppdatert", "ToastItemDetailsUpdateSuccess": "Detaljer oppdatert",
"ToastItemMarkedAsFinishedFailed": "Misslykkes å markere som Fullført", "ToastItemMarkedAsFinishedFailed": "Misslykkes å markere som Fullført",
"ToastItemMarkedAsFinishedSuccess": "Gjenstand marker som Fullført", "ToastItemMarkedAsFinishedSuccess": "Gjenstand marker som Fullført",
"ToastItemMarkedAsNotFinishedFailed": "Misslykkes å markere som Ikke Fullført", "ToastItemMarkedAsNotFinishedFailed": "Misslykkes å markere som Ikke Fullført",
"ToastItemMarkedAsNotFinishedSuccess": "Markert som Ikke Fullført", "ToastItemMarkedAsNotFinishedSuccess": "Markert som Ikke Fullført",
"ToastItemUpdateSuccess": "Element oppdatert",
"ToastLibraryCreateFailed": "Misslykkes å opprette bibliotek", "ToastLibraryCreateFailed": "Misslykkes å opprette bibliotek",
"ToastLibraryCreateSuccess": "Bibliotek \"{0}\" opprettet", "ToastLibraryCreateSuccess": "Bibliotek \"{0}\" opprettet",
"ToastLibraryDeleteFailed": "Misslykkes å slette bibliotek", "ToastLibraryDeleteFailed": "Misslykkes å slette bibliotek",
@ -677,25 +919,83 @@
"ToastLibraryScanFailedToStart": "Misslykkes å starte skann", "ToastLibraryScanFailedToStart": "Misslykkes å starte skann",
"ToastLibraryScanStarted": "Bibliotek skann startet", "ToastLibraryScanStarted": "Bibliotek skann startet",
"ToastLibraryUpdateSuccess": "Bibliotek \"{0}\" oppdatert", "ToastLibraryUpdateSuccess": "Bibliotek \"{0}\" oppdatert",
"ToastMatchAllAuthorsFailed": "Kunne ikke finne match for alle forfattere",
"ToastMetadataFilesRemovedError": "Feil ved fjerning av metadata.{0}-filer",
"ToastMetadataFilesRemovedNoneFound": "Ingen metata.{0}-filer funnet i biblioteket",
"ToastMetadataFilesRemovedNoneRemoved": "Ingen metata.{0}-filer fjernet",
"ToastMetadataFilesRemovedSuccess": "{0} metata.{1}-filer fjernet",
"ToastMustHaveAtLeastOnePath": "Påkrevd med minst én mappe",
"ToastNameEmailRequired": "Navn og e-post påkrevd",
"ToastNameRequired": "Navn er påkrevd",
"ToastNewEpisodesFound": "{0} nye episoder funnet",
"ToastNewUserCreatedFailed": "Kunne ikke opprette konto: \"{0}\"",
"ToastNewUserCreatedSuccess": "Ny konto opprettet",
"ToastNewUserLibraryError": "Velg minst ett bibliotek",
"ToastNewUserPasswordError": "Passord kreves. Kun root-bruker kan ha blankt passord",
"ToastNewUserTagError": "Velg minst en tag",
"ToastNewUserUsernameError": "Skriv inn brukernavn",
"ToastNoNewEpisodesFound": "Ingen nye episoder funnet",
"ToastNoUpdatesNecessary": "Ingen oppdateringer nødvendig",
"ToastNotificationCreateFailed": "Kunne ikke opprette varsling",
"ToastNotificationDeleteFailed": "Kunne ikke slette varsling",
"ToastNotificationFailedMaximum": "Maksimalt antall forsøk som feiler må være større eller lik null (0)",
"ToastNotificationQueueMaximum": "Maksimal størrelse på varsel-kø må være større eller lik null (0)",
"ToastNotificationSettingsUpdateSuccess": "Innstillinger for varsling oppdatert",
"ToastNotificationTestTriggerFailed": "Kunne ikke utløse test-varsel",
"ToastNotificationTestTriggerSuccess": "Test-varsel utløst",
"ToastNotificationUpdateSuccess": "Varsel oppdatert",
"ToastPlaylistCreateFailed": "Misslykkes å opprette spilleliste", "ToastPlaylistCreateFailed": "Misslykkes å opprette spilleliste",
"ToastPlaylistCreateSuccess": "Spilleliste opprettet", "ToastPlaylistCreateSuccess": "Spilleliste opprettet",
"ToastPlaylistRemoveSuccess": "Spilleliste fjernet", "ToastPlaylistRemoveSuccess": "Spilleliste fjernet",
"ToastPlaylistUpdateSuccess": "Spilleliste oppdatert", "ToastPlaylistUpdateSuccess": "Spilleliste oppdatert",
"ToastPodcastCreateFailed": "Misslykkes å opprette podcast", "ToastPodcastCreateFailed": "Misslykkes å opprette podcast",
"ToastPodcastCreateSuccess": "Podcast opprettet", "ToastPodcastCreateSuccess": "Podcast opprettet",
"ToastPodcastGetFeedFailed": "Kunne ikke hente podcast-feed",
"ToastPodcastNoEpisodesInFeed": "Ingen episoder funnet i RSS-feed",
"ToastPodcastNoRssFeed": "Podcast har ingen RSS-feed",
"ToastProgressIsNotBeingSynced": "Progresjon synkroniserer ikke, start avspilling på nytt",
"ToastProviderCreatedFailed": "Kunne ikke legge til tilbyder",
"ToastProviderCreatedSuccess": "Ny tilbyder lagt til",
"ToastProviderNameAndUrlRequired": "Navn og URL er påkrevd",
"ToastProviderRemoveSuccess": "Tilbyder fjernet",
"ToastRSSFeedCloseFailed": "Misslykkes å lukke RSS feed", "ToastRSSFeedCloseFailed": "Misslykkes å lukke RSS feed",
"ToastRSSFeedCloseSuccess": "RSS feed lukket", "ToastRSSFeedCloseSuccess": "RSS feed lukket",
"ToastRemoveFailed": "Kunne ikke fjerne",
"ToastRemoveItemFromCollectionFailed": "Misslykkes å fjerne gjenstsand fra samling", "ToastRemoveItemFromCollectionFailed": "Misslykkes å fjerne gjenstsand fra samling",
"ToastRemoveItemFromCollectionSuccess": "Gjenstand fjernet fra samling", "ToastRemoveItemFromCollectionSuccess": "Gjenstand fjernet fra samling",
"ToastRemoveItemsWithIssuesFailed": "Kunne ikke fjerne bibliotek-elementer med feil",
"ToastRemoveItemsWithIssuesSuccess": "Fjernet bibliotek-elementer med feil",
"ToastRenameFailed": "Kunne ikke endre navn",
"ToastRescanFailed": "Ny skanning feilet for {0}",
"ToastRescanRemoved": "Ny skanning utført og element fjernet",
"ToastRescanUpToDate": "Ny skanning utført og element var oppdatert",
"ToastRescanUpdated": "Ny skanning utført og element oppdatert",
"ToastScanFailed": "Kunne ikke skanne bibliotek-element",
"ToastSelectAtLeastOneUser": "Velg minst én bruker",
"ToastSendEbookToDeviceFailed": "Misslykkes å sende ebok", "ToastSendEbookToDeviceFailed": "Misslykkes å sende ebok",
"ToastSendEbookToDeviceSuccess": "Ebok sendt til \"{0}\"", "ToastSendEbookToDeviceSuccess": "Ebok sendt til \"{0}\"",
"ToastSeriesUpdateFailed": "Misslykkes å oppdatere serie", "ToastSeriesUpdateFailed": "Misslykkes å oppdatere serie",
"ToastSeriesUpdateSuccess": "Serie oppdatert", "ToastSeriesUpdateSuccess": "Serie oppdatert",
"ToastServerSettingsUpdateSuccess": "Server-innstillinger oppdatert",
"ToastSessionCloseFailed": "Kunne ikke avslutte sesjon",
"ToastSessionDeleteFailed": "Misslykkes å slette sesjon", "ToastSessionDeleteFailed": "Misslykkes å slette sesjon",
"ToastSessionDeleteSuccess": "Sesjon slettet", "ToastSessionDeleteSuccess": "Sesjon slettet",
"ToastSleepTimerDone": "Søvn-timer ferdig... zZzzZz",
"ToastSlugMustChange": "Slug inneholder ugyldige tegn",
"ToastSlugRequired": "Slug påkrevd",
"ToastSocketConnected": "Socket koblet til", "ToastSocketConnected": "Socket koblet til",
"ToastSocketDisconnected": "Socket koblet fra", "ToastSocketDisconnected": "Socket koblet fra",
"ToastSocketFailedToConnect": "Misslykkes å koble til Socket", "ToastSocketFailedToConnect": "Misslykkes å koble til Socket",
"ToastSortingPrefixesEmptyError": "Må ha minst én sorteringsprefiks",
"ToastSortingPrefixesUpdateSuccess": "Sorteringsprefiks oppdatert ({0} element)",
"ToastTitleRequired": "Tittel påkrevd",
"ToastUnknownError": "Ukjent feil",
"ToastUnlinkOpenIdFailed": "Kunne ikke koble bruker fra OpenID",
"ToastUnlinkOpenIdSuccess": "Bruker koblet fra OpenID",
"ToastUserDeleteFailed": "Misslykkes å slette bruker", "ToastUserDeleteFailed": "Misslykkes å slette bruker",
"ToastUserDeleteSuccess": "Bruker slettet" "ToastUserDeleteSuccess": "Bruker slettet",
"ToastUserPasswordChangeSuccess": "Passord ble endret",
"ToastUserPasswordMismatch": "Passord må stemme overens",
"ToastUserPasswordMustChange": "Nytt passord kan ikke være identisk med gammelt passord",
"ToastUserRootRequireName": "Root-brukernavn er påkrevd"
} }

View File

@ -657,7 +657,6 @@
"MessageInsertChapterBelow": "Wstaw rozdział poniżej", "MessageInsertChapterBelow": "Wstaw rozdział poniżej",
"MessageItemsSelected": "{0} zaznaczone elementy", "MessageItemsSelected": "{0} zaznaczone elementy",
"MessageJoinUsOn": "Dołącz do nas na", "MessageJoinUsOn": "Dołącz do nas na",
"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>.",
@ -772,7 +771,6 @@
"ToastBookmarkCreateSuccess": "Dodano zakładkę", "ToastBookmarkCreateSuccess": "Dodano zakładkę",
"ToastBookmarkRemoveSuccess": "Zakładka została usunięta", "ToastBookmarkRemoveSuccess": "Zakładka została usunięta",
"ToastBookmarkUpdateSuccess": "Zaktualizowano zakładkę", "ToastBookmarkUpdateSuccess": "Zaktualizowano zakładkę",
"ToastCollectionItemsRemoveSuccess": "Przedmiot(y) zostały usunięte z kolekcji",
"ToastCollectionRemoveSuccess": "Kolekcja usunięta", "ToastCollectionRemoveSuccess": "Kolekcja usunięta",
"ToastCollectionUpdateSuccess": "Zaktualizowano kolekcję", "ToastCollectionUpdateSuccess": "Zaktualizowano kolekcję",
"ToastItemCoverUpdateSuccess": "Zaktualizowano okładkę", "ToastItemCoverUpdateSuccess": "Zaktualizowano okładkę",

View File

@ -630,7 +630,6 @@
"MessageItemsSelected": "{0} Itens Selecionados", "MessageItemsSelected": "{0} Itens Selecionados",
"MessageItemsUpdated": "{0} Itens Atualizados", "MessageItemsUpdated": "{0} Itens Atualizados",
"MessageJoinUsOn": "Junte-se a nós", "MessageJoinUsOn": "Junte-se a nós",
"MessageListeningSessionsInTheLastYear": "{0} sessões de escuta no ano anterior",
"MessageLoading": "Carregando...", "MessageLoading": "Carregando...",
"MessageLoadingFolders": "Carregando pastas...", "MessageLoadingFolders": "Carregando pastas...",
"MessageLogsDescription": "Os logs estão armazenados em <code>/metadata/logs</code> como arquivos JSON. Logs de crash estão armazenados em <code>/metadata/logs/crash_logs.txt</code>.", "MessageLogsDescription": "Os logs estão armazenados em <code>/metadata/logs</code> como arquivos JSON. Logs de crash estão armazenados em <code>/metadata/logs/crash_logs.txt</code>.",
@ -735,7 +734,6 @@
"ToastCachePurgeSuccess": "Cache apagado com sucesso", "ToastCachePurgeSuccess": "Cache apagado com sucesso",
"ToastChaptersHaveErrors": "Capítulos com erro", "ToastChaptersHaveErrors": "Capítulos com erro",
"ToastChaptersMustHaveTitles": "Capítulos precisam ter títulos", "ToastChaptersMustHaveTitles": "Capítulos precisam ter títulos",
"ToastCollectionItemsRemoveSuccess": "Item(ns) removidos da coleção",
"ToastCollectionRemoveSuccess": "Coleção removida", "ToastCollectionRemoveSuccess": "Coleção removida",
"ToastCollectionUpdateSuccess": "Coleção atualizada", "ToastCollectionUpdateSuccess": "Coleção atualizada",
"ToastDeleteFileFailed": "Falha ao apagar arquivo", "ToastDeleteFileFailed": "Falha ao apagar arquivo",

View File

@ -88,6 +88,8 @@
"ButtonSaveTracklist": "Сохранить список треков", "ButtonSaveTracklist": "Сохранить список треков",
"ButtonScan": "Сканировать", "ButtonScan": "Сканировать",
"ButtonScanLibrary": "Сканировать библиотеку", "ButtonScanLibrary": "Сканировать библиотеку",
"ButtonScrollLeft": "Перемотать влево",
"ButtonScrollRight": "Перемотать вправо",
"ButtonSearch": "Поиск", "ButtonSearch": "Поиск",
"ButtonSelectFolderPath": "Выберите путь папки", "ButtonSelectFolderPath": "Выберите путь папки",
"ButtonSeries": "Серии", "ButtonSeries": "Серии",
@ -190,6 +192,7 @@
"HeaderSettingsExperimental": "Экспериментальные функции", "HeaderSettingsExperimental": "Экспериментальные функции",
"HeaderSettingsGeneral": "Основные", "HeaderSettingsGeneral": "Основные",
"HeaderSettingsScanner": "Сканер", "HeaderSettingsScanner": "Сканер",
"HeaderSettingsWebClient": "Веб-клиент",
"HeaderSleepTimer": "Таймер сна", "HeaderSleepTimer": "Таймер сна",
"HeaderStatsLargestItems": "Самые большые элементы", "HeaderStatsLargestItems": "Самые большые элементы",
"HeaderStatsLongestItems": "Самые длинные элементы (часов)", "HeaderStatsLongestItems": "Самые длинные элементы (часов)",
@ -297,6 +300,7 @@
"LabelDiscover": "Не начато", "LabelDiscover": "Не начато",
"LabelDownload": "Скачать", "LabelDownload": "Скачать",
"LabelDownloadNEpisodes": "Скачать {0} эпизодов", "LabelDownloadNEpisodes": "Скачать {0} эпизодов",
"LabelDownloadable": "Загружаемый",
"LabelDuration": "Длина", "LabelDuration": "Длина",
"LabelDurationComparisonExactMatch": "(точное совпадение)", "LabelDurationComparisonExactMatch": "(точное совпадение)",
"LabelDurationComparisonLonger": "({0} дольше)", "LabelDurationComparisonLonger": "({0} дольше)",
@ -542,6 +546,7 @@
"LabelServerYearReview": "Итоги года всего сервера ({0})", "LabelServerYearReview": "Итоги года всего сервера ({0})",
"LabelSetEbookAsPrimary": "Установить как основную", "LabelSetEbookAsPrimary": "Установить как основную",
"LabelSetEbookAsSupplementary": "Установить как дополнительную", "LabelSetEbookAsSupplementary": "Установить как дополнительную",
"LabelSettingsAllowIframe": "Разрешить встраивание в iframe",
"LabelSettingsAudiobooksOnly": "Только аудиокниги", "LabelSettingsAudiobooksOnly": "Только аудиокниги",
"LabelSettingsAudiobooksOnlyHelp": "Если включить эту настройку, файлы электронных книг будут игнорироваться, за исключением случаев, когда они находятся в папке с аудиокнигами, в этом случае они будут рассматриваться как дополнительные электронные книги", "LabelSettingsAudiobooksOnlyHelp": "Если включить эту настройку, файлы электронных книг будут игнорироваться, за исключением случаев, когда они находятся в папке с аудиокнигами, в этом случае они будут рассматриваться как дополнительные электронные книги",
"LabelSettingsBookshelfViewHelp": "Конструкция с деревянными полками", "LabelSettingsBookshelfViewHelp": "Конструкция с деревянными полками",
@ -584,6 +589,7 @@
"LabelSettingsStoreMetadataWithItemHelp": "По умолчанию метаинформация сохраняется в папке /metadata/items, при включении этой настройки метаинформация будет храниться в папке элемента", "LabelSettingsStoreMetadataWithItemHelp": "По умолчанию метаинформация сохраняется в папке /metadata/items, при включении этой настройки метаинформация будет храниться в папке элемента",
"LabelSettingsTimeFormat": "Формат времени", "LabelSettingsTimeFormat": "Формат времени",
"LabelShare": "Поделиться", "LabelShare": "Поделиться",
"LabelShareDownloadableHelp": "Позволяет пользователям с помощью ссылки загрузить zip-файл элемента библиотеки.",
"LabelShareOpen": "Общедоступно", "LabelShareOpen": "Общедоступно",
"LabelShareURL": "Общедоступный URL", "LabelShareURL": "Общедоступный URL",
"LabelShowAll": "Показать все", "LabelShowAll": "Показать все",
@ -592,6 +598,8 @@
"LabelSize": "Размер", "LabelSize": "Размер",
"LabelSleepTimer": "Таймер сна", "LabelSleepTimer": "Таймер сна",
"LabelSlug": "Слизень", "LabelSlug": "Слизень",
"LabelSortAscending": "По возрастанию",
"LabelSortDescending": "По убыванию",
"LabelStart": "Начало", "LabelStart": "Начало",
"LabelStartTime": "Время начала", "LabelStartTime": "Время начала",
"LabelStarted": "Начат", "LabelStarted": "Начат",
@ -679,6 +687,8 @@
"LabelViewPlayerSettings": "Просмотр настроек плеера", "LabelViewPlayerSettings": "Просмотр настроек плеера",
"LabelViewQueue": "Очередь воспроизведения", "LabelViewQueue": "Очередь воспроизведения",
"LabelVolume": "Громкость", "LabelVolume": "Громкость",
"LabelWebRedirectURLsDescription": "Авторизуйте эти URL в провайдере OAuth, чтобы разрешить перенаправление обратно в веб-приложение после входа:",
"LabelWebRedirectURLsSubfolder": "Вложенная папка для URL-адресов перенаправления",
"LabelWeekdaysToRun": "Дни недели для запуска", "LabelWeekdaysToRun": "Дни недели для запуска",
"LabelXBooks": "{0} книг", "LabelXBooks": "{0} книг",
"LabelXItems": "{0} элементов", "LabelXItems": "{0} элементов",
@ -763,7 +773,6 @@
"MessageItemsSelected": "{0} Элементов выделено", "MessageItemsSelected": "{0} Элементов выделено",
"MessageItemsUpdated": "{0} Элементов обновлено", "MessageItemsUpdated": "{0} Элементов обновлено",
"MessageJoinUsOn": "Присоединяйтесь к нам в", "MessageJoinUsOn": "Присоединяйтесь к нам в",
"MessageListeningSessionsInTheLastYear": "{0} сеансов прослушивания в прошлом году",
"MessageLoading": "Загрузка...", "MessageLoading": "Загрузка...",
"MessageLoadingFolders": "Загрузка каталогов...", "MessageLoadingFolders": "Загрузка каталогов...",
"MessageLogsDescription": "Журналы хранятся в <code>/metadata/logs</code> в виде JSON-файлов. Журналы сбоев хранятся в <code>/metadata/logs/crash_logs.txt</code>.", "MessageLogsDescription": "Журналы хранятся в <code>/metadata/logs</code> в виде JSON-файлов. Журналы сбоев хранятся в <code>/metadata/logs/crash_logs.txt</code>.",
@ -951,8 +960,6 @@
"ToastChaptersRemoved": "Удалены главы", "ToastChaptersRemoved": "Удалены главы",
"ToastChaptersUpdated": "Обновленные главы", "ToastChaptersUpdated": "Обновленные главы",
"ToastCollectionItemsAddFailed": "Не удалось добавить элемент(ы) в коллекцию", "ToastCollectionItemsAddFailed": "Не удалось добавить элемент(ы) в коллекцию",
"ToastCollectionItemsAddSuccess": "Элемент(ы) добавлены в коллекцию",
"ToastCollectionItemsRemoveSuccess": "Элемент(ы), удалены из коллекции",
"ToastCollectionRemoveSuccess": "Коллекция удалена", "ToastCollectionRemoveSuccess": "Коллекция удалена",
"ToastCollectionUpdateSuccess": "Коллекция обновлена", "ToastCollectionUpdateSuccess": "Коллекция обновлена",
"ToastCoverUpdateFailed": "Не удалось обновить обложку", "ToastCoverUpdateFailed": "Не удалось обновить обложку",

View File

@ -88,6 +88,8 @@
"ButtonSaveTracklist": "Shrani seznam skladb", "ButtonSaveTracklist": "Shrani seznam skladb",
"ButtonScan": "Pregledovanje", "ButtonScan": "Pregledovanje",
"ButtonScanLibrary": "Preglej knjižnico", "ButtonScanLibrary": "Preglej knjižnico",
"ButtonScrollLeft": "Premik levo",
"ButtonScrollRight": "Premik desno",
"ButtonSearch": "Poišči", "ButtonSearch": "Poišči",
"ButtonSelectFolderPath": "Izberite pot do mape", "ButtonSelectFolderPath": "Izberite pot do mape",
"ButtonSeries": "Serije", "ButtonSeries": "Serije",
@ -190,6 +192,7 @@
"HeaderSettingsExperimental": "Eksperimentalne funkcije", "HeaderSettingsExperimental": "Eksperimentalne funkcije",
"HeaderSettingsGeneral": "Splošno", "HeaderSettingsGeneral": "Splošno",
"HeaderSettingsScanner": "Pregledovalnik", "HeaderSettingsScanner": "Pregledovalnik",
"HeaderSettingsWebClient": "Spletni odjemalec",
"HeaderSleepTimer": "Časovnik za izklop", "HeaderSleepTimer": "Časovnik za izklop",
"HeaderStatsLargestItems": "Največji elementi", "HeaderStatsLargestItems": "Največji elementi",
"HeaderStatsLongestItems": "Najdaljši elementi (ure)", "HeaderStatsLongestItems": "Najdaljši elementi (ure)",
@ -297,6 +300,7 @@
"LabelDiscover": "Odkrij", "LabelDiscover": "Odkrij",
"LabelDownload": "Prenos", "LabelDownload": "Prenos",
"LabelDownloadNEpisodes": "Prenesi {0} epizod", "LabelDownloadNEpisodes": "Prenesi {0} epizod",
"LabelDownloadable": "Možen prenos",
"LabelDuration": "Trajanje", "LabelDuration": "Trajanje",
"LabelDurationComparisonExactMatch": "(natančno ujemanje)", "LabelDurationComparisonExactMatch": "(natančno ujemanje)",
"LabelDurationComparisonLonger": "({0} dlje)", "LabelDurationComparisonLonger": "({0} dlje)",
@ -542,6 +546,7 @@
"LabelServerYearReview": "Pregled leta strežnika ({0})", "LabelServerYearReview": "Pregled leta strežnika ({0})",
"LabelSetEbookAsPrimary": "Nastavi kot primarno", "LabelSetEbookAsPrimary": "Nastavi kot primarno",
"LabelSetEbookAsSupplementary": "Nastavi kot dodatno", "LabelSetEbookAsSupplementary": "Nastavi kot dodatno",
"LabelSettingsAllowIframe": "Dovoli vdelavo v iframu",
"LabelSettingsAudiobooksOnly": "Samo zvočne knjige", "LabelSettingsAudiobooksOnly": "Samo zvočne knjige",
"LabelSettingsAudiobooksOnlyHelp": "Če omogočite to nastavitev, bodo datoteke eknjig prezrte, razen če so znotraj mape zvočnih knjig, v tem primeru bodo nastavljene kot dodatne e-knjige", "LabelSettingsAudiobooksOnlyHelp": "Če omogočite to nastavitev, bodo datoteke eknjig prezrte, razen če so znotraj mape zvočnih knjig, v tem primeru bodo nastavljene kot dodatne e-knjige",
"LabelSettingsBookshelfViewHelp": "Skeuomorfna oblika z lesenimi policami", "LabelSettingsBookshelfViewHelp": "Skeuomorfna oblika z lesenimi policami",
@ -584,6 +589,7 @@
"LabelSettingsStoreMetadataWithItemHelp": "Datoteke z metapodatki so privzeto shranjene v /metadata/items, če omogočite to nastavitev, boste datoteke z metapodatki shranili v mape elementov vaše knjižnice", "LabelSettingsStoreMetadataWithItemHelp": "Datoteke z metapodatki so privzeto shranjene v /metadata/items, če omogočite to nastavitev, boste datoteke z metapodatki shranili v mape elementov vaše knjižnice",
"LabelSettingsTimeFormat": "Oblika časa", "LabelSettingsTimeFormat": "Oblika časa",
"LabelShare": "Deli", "LabelShare": "Deli",
"LabelShareDownloadableHelp": "Omogoča uporabnikom s povezavo skupne rabe, da prenesejo zip datoteko elementa knjižnice.",
"LabelShareOpen": "Deli odprto", "LabelShareOpen": "Deli odprto",
"LabelShareURL": "Deli URL", "LabelShareURL": "Deli URL",
"LabelShowAll": "Prikaži vse", "LabelShowAll": "Prikaži vse",
@ -592,6 +598,8 @@
"LabelSize": "Velikost", "LabelSize": "Velikost",
"LabelSleepTimer": "Časovnik za spanje", "LabelSleepTimer": "Časovnik za spanje",
"LabelSlug": "Slug", "LabelSlug": "Slug",
"LabelSortAscending": "Naraščajoče",
"LabelSortDescending": "Padajoče",
"LabelStart": "Začetek", "LabelStart": "Začetek",
"LabelStartTime": "Čas začetka", "LabelStartTime": "Čas začetka",
"LabelStarted": "Začeto", "LabelStarted": "Začeto",
@ -679,6 +687,8 @@
"LabelViewPlayerSettings": "Ogled nastavitev predvajalnika", "LabelViewPlayerSettings": "Ogled nastavitev predvajalnika",
"LabelViewQueue": "Ogled čakalno vrsto predvajalnika", "LabelViewQueue": "Ogled čakalno vrsto predvajalnika",
"LabelVolume": "Glasnost", "LabelVolume": "Glasnost",
"LabelWebRedirectURLsDescription": "Avtorizirajte URL-je pri svojem ponudniku OAuth ter s tem omogočite preusmeritev nazaj v spletno aplikacijo po prijavi:",
"LabelWebRedirectURLsSubfolder": "Podmapa za URL-je preusmeritve",
"LabelWeekdaysToRun": "Delovni dnevi predvajanja", "LabelWeekdaysToRun": "Delovni dnevi predvajanja",
"LabelXBooks": "{0} knjig", "LabelXBooks": "{0} knjig",
"LabelXItems": "{0} elementov", "LabelXItems": "{0} elementov",
@ -763,7 +773,6 @@
"MessageItemsSelected": "{0} izbranih elementov", "MessageItemsSelected": "{0} izbranih elementov",
"MessageItemsUpdated": "Št. posodobljenih elementov: {0}", "MessageItemsUpdated": "Št. posodobljenih elementov: {0}",
"MessageJoinUsOn": "Pridružite se nam", "MessageJoinUsOn": "Pridružite se nam",
"MessageListeningSessionsInTheLastYear": "{0} sej poslušanja v zadnjem letu",
"MessageLoading": "Nalagam...", "MessageLoading": "Nalagam...",
"MessageLoadingFolders": "Nalagam mape...", "MessageLoadingFolders": "Nalagam mape...",
"MessageLogsDescription": "Dnevniki so shranjeni v <code>/metadata/logs</code> kot datoteke JSON. Dnevniki zrušitev so shranjeni v <code>/metadata/logs/crash_logs.txt</code>.", "MessageLogsDescription": "Dnevniki so shranjeni v <code>/metadata/logs</code> kot datoteke JSON. Dnevniki zrušitev so shranjeni v <code>/metadata/logs/crash_logs.txt</code>.",
@ -951,8 +960,6 @@
"ToastChaptersRemoved": "Poglavja so odstranjena", "ToastChaptersRemoved": "Poglavja so odstranjena",
"ToastChaptersUpdated": "Poglavja so posodobljena", "ToastChaptersUpdated": "Poglavja so posodobljena",
"ToastCollectionItemsAddFailed": "Dodajanje elementov v zbirko ni uspelo", "ToastCollectionItemsAddFailed": "Dodajanje elementov v zbirko ni uspelo",
"ToastCollectionItemsAddSuccess": "Dodajanje elementov v zbirko je bilo uspešno",
"ToastCollectionItemsRemoveSuccess": "Elementi so bili odstranjeni iz zbirke",
"ToastCollectionRemoveSuccess": "Zbirka je bila odstranjena", "ToastCollectionRemoveSuccess": "Zbirka je bila odstranjena",
"ToastCollectionUpdateSuccess": "Zbirka je bila posodobljena", "ToastCollectionUpdateSuccess": "Zbirka je bila posodobljena",
"ToastCoverUpdateFailed": "Posodobitev naslovnice ni uspela", "ToastCoverUpdateFailed": "Posodobitev naslovnice ni uspela",

View File

@ -13,7 +13,7 @@
"ButtonBrowseForFolder": "Bläddra efter mapp", "ButtonBrowseForFolder": "Bläddra efter mapp",
"ButtonCancel": "Avbryt", "ButtonCancel": "Avbryt",
"ButtonCancelEncode": "Avbryt kodning", "ButtonCancelEncode": "Avbryt kodning",
"ButtonChangeRootPassword": "Ändra rootlösenord", "ButtonChangeRootPassword": "Ändra lösenordet för root",
"ButtonCheckAndDownloadNewEpisodes": "Kontrollera och ladda ner nya avsnitt", "ButtonCheckAndDownloadNewEpisodes": "Kontrollera och ladda ner nya avsnitt",
"ButtonChooseAFolder": "Välj en mapp", "ButtonChooseAFolder": "Välj en mapp",
"ButtonChooseFiles": "Välj filer", "ButtonChooseFiles": "Välj filer",
@ -29,7 +29,7 @@
"ButtonEditChapters": "Redigera kapitel", "ButtonEditChapters": "Redigera kapitel",
"ButtonEditPodcast": "Redigera podcast", "ButtonEditPodcast": "Redigera podcast",
"ButtonForceReScan": "Tvinga omstart", "ButtonForceReScan": "Tvinga omstart",
"ButtonFullPath": "Full sökväg", "ButtonFullPath": "Fullständig sökväg",
"ButtonHide": "Dölj", "ButtonHide": "Dölj",
"ButtonHome": "Hem", "ButtonHome": "Hem",
"ButtonIssues": "Problem", "ButtonIssues": "Problem",
@ -42,13 +42,18 @@
"ButtonMatchAllAuthors": "Matcha alla författare", "ButtonMatchAllAuthors": "Matcha alla författare",
"ButtonMatchBooks": "Matcha böcker", "ButtonMatchBooks": "Matcha böcker",
"ButtonNevermind": "Glöm det", "ButtonNevermind": "Glöm det",
"ButtonOk": "Okej", "ButtonNext": "Nästa",
"ButtonNextChapter": "Nästa kapitel",
"ButtonOk": "Ok",
"ButtonOpenFeed": "Öppna flöde", "ButtonOpenFeed": "Öppna flöde",
"ButtonOpenManager": "Öppna Manager", "ButtonOpenManager": "Öppna Manager",
"ButtonPause": "Pausa", "ButtonPause": "Pausa",
"ButtonPlay": "Spela", "ButtonPlay": "Spela",
"ButtonPlayAll": "Spela alla",
"ButtonPlaying": "Spelar", "ButtonPlaying": "Spelar",
"ButtonPlaylists": "Spellistor", "ButtonPlaylists": "Spellistor",
"ButtonPrevious": "Föregående",
"ButtonPreviousChapter": "Föregående kapitel",
"ButtonPurgeAllCache": "Rensa all cache", "ButtonPurgeAllCache": "Rensa all cache",
"ButtonPurgeItemsCache": "Rensa föremåls-cache", "ButtonPurgeItemsCache": "Rensa föremåls-cache",
"ButtonQueueAddItem": "Lägg till i kön", "ButtonQueueAddItem": "Lägg till i kön",
@ -56,6 +61,9 @@
"ButtonQuickMatch": "Snabb matchning", "ButtonQuickMatch": "Snabb matchning",
"ButtonReScan": "Omstart", "ButtonReScan": "Omstart",
"ButtonRead": "Läs", "ButtonRead": "Läs",
"ButtonReadLess": "Visa mindre",
"ButtonReadMore": "Visa mer",
"ButtonRefresh": "Uppdatera",
"ButtonRemove": "Ta bort", "ButtonRemove": "Ta bort",
"ButtonRemoveAll": "Ta bort alla", "ButtonRemoveAll": "Ta bort alla",
"ButtonRemoveAllLibraryItems": "Ta bort alla biblioteksobjekt", "ButtonRemoveAllLibraryItems": "Ta bort alla biblioteksobjekt",
@ -72,12 +80,13 @@
"ButtonScanLibrary": "Skanna bibliotek", "ButtonScanLibrary": "Skanna bibliotek",
"ButtonSearch": "Sök", "ButtonSearch": "Sök",
"ButtonSelectFolderPath": "Välj mappens sökväg", "ButtonSelectFolderPath": "Välj mappens sökväg",
"ButtonSeries": "Serie", "ButtonSeries": "Serier",
"ButtonSetChaptersFromTracks": "Ställ in kapitel från spår", "ButtonSetChaptersFromTracks": "Ställ in kapitel från spår",
"ButtonShiftTimes": "Förskjut tider", "ButtonShiftTimes": "Förskjut tider",
"ButtonShow": "Visa", "ButtonShow": "Visa",
"ButtonStartM4BEncode": "Starta M4B-kodning", "ButtonStartM4BEncode": "Starta M4B-kodning",
"ButtonStartMetadataEmbed": "Starta inbäddning av metadata", "ButtonStartMetadataEmbed": "Starta inbäddning av metadata",
"ButtonStats": "Statistik",
"ButtonSubmit": "Skicka", "ButtonSubmit": "Skicka",
"ButtonTest": "Testa", "ButtonTest": "Testa",
"ButtonUpload": "Ladda upp", "ButtonUpload": "Ladda upp",
@ -123,7 +132,7 @@
"HeaderListeningStats": "Lyssningsstatistik", "HeaderListeningStats": "Lyssningsstatistik",
"HeaderLogin": "Logga in", "HeaderLogin": "Logga in",
"HeaderLogs": "Loggar", "HeaderLogs": "Loggar",
"HeaderManageGenres": "Hantera genrer", "HeaderManageGenres": "Hantera kategorier",
"HeaderManageTags": "Hantera taggar", "HeaderManageTags": "Hantera taggar",
"HeaderMapDetails": "Karta detaljer", "HeaderMapDetails": "Karta detaljer",
"HeaderMatch": "Matcha", "HeaderMatch": "Matcha",
@ -154,13 +163,14 @@
"HeaderSettingsExperimental": "Experimentella funktioner", "HeaderSettingsExperimental": "Experimentella funktioner",
"HeaderSettingsGeneral": "Allmänt", "HeaderSettingsGeneral": "Allmänt",
"HeaderSettingsScanner": "Skanner", "HeaderSettingsScanner": "Skanner",
"HeaderSettingsWebClient": "Webklient",
"HeaderSleepTimer": "Sovtidtagare", "HeaderSleepTimer": "Sovtidtagare",
"HeaderStatsLargestItems": "Största föremål", "HeaderStatsLargestItems": "Största objekt",
"HeaderStatsLongestItems": "Längsta föremål (tim)", "HeaderStatsLongestItems": "Längsta objekt (tim)",
"HeaderStatsMinutesListeningChart": "Minuters lyssning (senaste 7 dagar)", "HeaderStatsMinutesListeningChart": "Minuters lyssning (senaste 7 dagar)",
"HeaderStatsRecentSessions": "Senaste sessioner", "HeaderStatsRecentSessions": "Senaste sessioner",
"HeaderStatsTop10Authors": "Topp 10 författare", "HeaderStatsTop10Authors": "10 populäraste författarna",
"HeaderStatsTop5Genres": "Topp 5 genrer", "HeaderStatsTop5Genres": "5 populäraste kategorierna",
"HeaderTableOfContents": "Innehållsförteckning", "HeaderTableOfContents": "Innehållsförteckning",
"HeaderTools": "Verktyg", "HeaderTools": "Verktyg",
"HeaderUpdateAccount": "Uppdatera konto", "HeaderUpdateAccount": "Uppdatera konto",
@ -168,7 +178,8 @@
"HeaderUpdateDetails": "Uppdatera detaljer", "HeaderUpdateDetails": "Uppdatera detaljer",
"HeaderUpdateLibrary": "Uppdatera bibliotek", "HeaderUpdateLibrary": "Uppdatera bibliotek",
"HeaderUsers": "Användare", "HeaderUsers": "Användare",
"HeaderYourStats": "Dina statistik", "HeaderYearReview": "Sammanställning för {0}",
"HeaderYourStats": "Din statistik",
"LabelAbridged": "Förkortad", "LabelAbridged": "Förkortad",
"LabelAccountType": "Kontotyp", "LabelAccountType": "Kontotyp",
"LabelAccountTypeGuest": "Gäst", "LabelAccountTypeGuest": "Gäst",
@ -191,18 +202,23 @@
"LabelAuthorLastFirst": "Författare (Efternamn, Förnamn)", "LabelAuthorLastFirst": "Författare (Efternamn, Förnamn)",
"LabelAuthors": "Författare", "LabelAuthors": "Författare",
"LabelAutoDownloadEpisodes": "Automatisk nedladdning av avsnitt", "LabelAutoDownloadEpisodes": "Automatisk nedladdning av avsnitt",
"LabelAutoFetchMetadata": "Automatisk nedladdning av metadata",
"LabelAutoFetchMetadataHelp": "Hämtar metadata för titel, författare och serier. Kompletterande metadata får adderas efter uppladdningen.",
"LabelBackToUser": "Tillbaka till användaren", "LabelBackToUser": "Tillbaka till användaren",
"LabelBackupLocation": "Säkerhetskopia Plats", "LabelBackupLocation": "Plats för säkerhetskopia",
"LabelBackupsEnableAutomaticBackups": "Aktivera automatiska säkerhetskopior", "LabelBackupsEnableAutomaticBackups": "Aktivera automatiska säkerhetskopior",
"LabelBackupsEnableAutomaticBackupsHelp": "Säkerhetskopior sparas i /metadata/säkerhetskopior", "LabelBackupsEnableAutomaticBackupsHelp": "Säkerhetskopior sparas i \"/metadata/backups\"",
"LabelBackupsMaxBackupSize": "Maximal säkerhetskopiostorlek (i GB)", "LabelBackupsMaxBackupSize": "Maximal storlek på säkerhetskopia (i GB) (0 = obegränsad)",
"LabelBackupsMaxBackupSizeHelp": "Som ett skydd mot felkonfiguration kommer säkerhetskopior att misslyckas om de överskrider den konfigurerade storleken.", "LabelBackupsMaxBackupSizeHelp": "Som ett skydd mot felkonfiguration kommer säkerhetskopior att misslyckas om de överskrider den konfigurerade storleken.",
"LabelBackupsNumberToKeep": "Antal säkerhetskopior att behålla", "LabelBackupsNumberToKeep": "Antal säkerhetskopior att behålla",
"LabelBackupsNumberToKeepHelp": "Endast en säkerhetskopia tas bort åt gången, så om du redan har fler säkerhetskopior än detta bör du ta bort dem manuellt.", "LabelBackupsNumberToKeepHelp": "Endast en säkerhetskopia tas bort åt gången, så om du redan har fler säkerhetskopior än detta bör du ta bort dem manuellt.",
"LabelBitrate": "Bitfrekvens", "LabelBitrate": "Bitfrekvens",
"LabelBooks": "Böcker", "LabelBooks": "Böcker",
"LabelButtonText": "Knapptext",
"LabelByAuthor": "av {0}",
"LabelChangePassword": "Ändra lösenord", "LabelChangePassword": "Ändra lösenord",
"LabelChannels": "Kanaler", "LabelChannels": "Kanaler",
"LabelChapterCount": "{0} kapitel",
"LabelChapterTitle": "Kapitelrubrik", "LabelChapterTitle": "Kapitelrubrik",
"LabelChapters": "Kapitel", "LabelChapters": "Kapitel",
"LabelChaptersFound": "hittade kapitel", "LabelChaptersFound": "hittade kapitel",
@ -215,7 +231,7 @@
"LabelConfirmPassword": "Bekräfta lösenord", "LabelConfirmPassword": "Bekräfta lösenord",
"LabelContinueListening": "Fortsätt Lyssna", "LabelContinueListening": "Fortsätt Lyssna",
"LabelContinueReading": "Fortsätt Läsa", "LabelContinueReading": "Fortsätt Läsa",
"LabelContinueSeries": "Forsätt Serie", "LabelContinueSeries": "Fortsätt Serie",
"LabelCover": "Omslag", "LabelCover": "Omslag",
"LabelCoverImageURL": "URL till omslagsbild", "LabelCoverImageURL": "URL till omslagsbild",
"LabelCreatedAt": "Skapad vid", "LabelCreatedAt": "Skapad vid",
@ -267,8 +283,8 @@
"LabelFontBoldness": "Fetstil", "LabelFontBoldness": "Fetstil",
"LabelFontFamily": "Teckensnittsfamilj", "LabelFontFamily": "Teckensnittsfamilj",
"LabelFontScale": "Teckensnittsskala", "LabelFontScale": "Teckensnittsskala",
"LabelGenre": "Genre", "LabelGenre": "Kategori",
"LabelGenres": "Genrer", "LabelGenres": "Kategorier",
"LabelHardDeleteFile": "Hård radering av fil", "LabelHardDeleteFile": "Hård radering av fil",
"LabelHasEbook": "Har E-bok", "LabelHasEbook": "Har E-bok",
"LabelHasSupplementaryEbook": "Har komplimenterande E-bok", "LabelHasSupplementaryEbook": "Har komplimenterande E-bok",
@ -316,19 +332,19 @@
"LabelMediaType": "Mediatyp", "LabelMediaType": "Mediatyp",
"LabelMetaTag": "Metamärke", "LabelMetaTag": "Metamärke",
"LabelMetaTags": "Metamärken", "LabelMetaTags": "Metamärken",
"LabelMetadataProvider": "Metadataleverantör", "LabelMetadataProvider": "Källa för metadata",
"LabelMinute": "Minut", "LabelMinute": "Minut",
"LabelMissing": "Saknad", "LabelMissing": "Saknad",
"LabelMore": "Mer", "LabelMore": "Mer",
"LabelMoreInfo": "Mer information", "LabelMoreInfo": "Mer information",
"LabelName": "Namn", "LabelName": "Namn",
"LabelNarrator": "Berättare", "LabelNarrator": "Uppläsare",
"LabelNarrators": "Berättare", "LabelNarrators": "Uppläsare",
"LabelNew": "Ny", "LabelNew": "Ny",
"LabelNewPassword": "Nytt lösenord", "LabelNewPassword": "Nytt lösenord",
"LabelNewestAuthors": "Senast tillagda författare", "LabelNewestAuthors": "Senast tillagda författare",
"LabelNewestEpisodes": "Senast tillagda avsnitt", "LabelNewestEpisodes": "Senast tillagda avsnitt",
"LabelNextBackupDate": "Nästa säkerhetskopia datum", "LabelNextBackupDate": "Nästa datum för säkerhetskopia",
"LabelNextScheduledRun": "Nästa schemalagda körning", "LabelNextScheduledRun": "Nästa schemalagda körning",
"LabelNoEpisodesSelected": "Inga avsnitt valda", "LabelNoEpisodesSelected": "Inga avsnitt valda",
"LabelNotFinished": "Ej avslutad", "LabelNotFinished": "Ej avslutad",
@ -367,7 +383,7 @@
"LabelPreventIndexing": "Förhindra att ditt flöde indexeras av iTunes och Google-podcastsökmotorer", "LabelPreventIndexing": "Förhindra att ditt flöde indexeras av iTunes och Google-podcastsökmotorer",
"LabelPrimaryEbook": "Primär e-bok", "LabelPrimaryEbook": "Primär e-bok",
"LabelProgress": "Framsteg", "LabelProgress": "Framsteg",
"LabelProvider": "Leverantör", "LabelProvider": "Källa",
"LabelPubDate": "Publiceringsdatum", "LabelPubDate": "Publiceringsdatum",
"LabelPublishYear": "Publiceringsår", "LabelPublishYear": "Publiceringsår",
"LabelPublisher": "Utgivare", "LabelPublisher": "Utgivare",
@ -388,14 +404,14 @@
"LabelRemoveCover": "Ta bort omslag", "LabelRemoveCover": "Ta bort omslag",
"LabelSearchTerm": "Sökterm", "LabelSearchTerm": "Sökterm",
"LabelSearchTitle": "Sök titel", "LabelSearchTitle": "Sök titel",
"LabelSearchTitleOrASIN": "Sök titel eller ASIN", "LabelSearchTitleOrASIN": "Sök titel eller ASIN-kod",
"LabelSeason": "Säsong", "LabelSeason": "Säsong",
"LabelSelectAllEpisodes": "Välj alla avsnitt", "LabelSelectAllEpisodes": "Välj alla avsnitt",
"LabelSelectEpisodesShowing": "Välj {0} avsnitt som visas", "LabelSelectEpisodesShowing": "Välj {0} avsnitt som visas",
"LabelSelectUsers": "Välj användare", "LabelSelectUsers": "Välj användare",
"LabelSendEbookToDevice": "Skicka e-bok till...", "LabelSendEbookToDevice": "Skicka e-bok till...",
"LabelSequence": "Sekvens", "LabelSequence": "Sekvens",
"LabelSeries": "Serie", "LabelSeries": "Serier",
"LabelSeriesName": "Serienamn", "LabelSeriesName": "Serienamn",
"LabelSeriesProgress": "Serieframsteg", "LabelSeriesProgress": "Serieframsteg",
"LabelSetEbookAsPrimary": "Ange som primär", "LabelSetEbookAsPrimary": "Ange som primär",
@ -403,7 +419,7 @@
"LabelSettingsAudiobooksOnly": "Endast ljudböcker", "LabelSettingsAudiobooksOnly": "Endast ljudböcker",
"LabelSettingsAudiobooksOnlyHelp": "Aktivera detta alternativ kommer att ignorera e-boksfiler om de inte finns inom en ljudboksmapp, i vilket fall de kommer att anges som kompletterande e-böcker", "LabelSettingsAudiobooksOnlyHelp": "Aktivera detta alternativ kommer att ignorera e-boksfiler om de inte finns inom en ljudboksmapp, i vilket fall de kommer att anges som kompletterande e-böcker",
"LabelSettingsBookshelfViewHelp": "Skeumorfisk design med trähyllor", "LabelSettingsBookshelfViewHelp": "Skeumorfisk design med trähyllor",
"LabelSettingsChromecastSupport": "Chromecast-stöd", "LabelSettingsChromecastSupport": "Stöd för Chromecast",
"LabelSettingsDateFormat": "Datumformat", "LabelSettingsDateFormat": "Datumformat",
"LabelSettingsDisableWatcher": "Inaktivera Watcher", "LabelSettingsDisableWatcher": "Inaktivera Watcher",
"LabelSettingsDisableWatcherForLibrary": "Inaktivera mappbevakning för bibliotek", "LabelSettingsDisableWatcherForLibrary": "Inaktivera mappbevakning för bibliotek",
@ -415,24 +431,24 @@
"LabelSettingsExperimentalFeaturesHelp": "Funktioner under utveckling som behöver din feedback och hjälp med testning. Klicka för att öppna diskussionen på GitHub.", "LabelSettingsExperimentalFeaturesHelp": "Funktioner under utveckling som behöver din feedback och hjälp med testning. Klicka för att öppna diskussionen på GitHub.",
"LabelSettingsFindCovers": "Hitta omslag", "LabelSettingsFindCovers": "Hitta omslag",
"LabelSettingsFindCoversHelp": "Om din ljudbok inte har ett inbäddat omslag eller en omslagsbild i mappen kommer skannern att försöka hitta ett omslag.<br>Observera: Detta kommer att förlänga skannningstiden", "LabelSettingsFindCoversHelp": "Om din ljudbok inte har ett inbäddat omslag eller en omslagsbild i mappen kommer skannern att försöka hitta ett omslag.<br>Observera: Detta kommer att förlänga skannningstiden",
"LabelSettingsHideSingleBookSeries": "Dölj enboksserier", "LabelSettingsHideSingleBookSeries": "Dölj serier med en bok",
"LabelSettingsHideSingleBookSeriesHelp": "Serier som har en enda bok kommer att döljas från seriesidan och hyllsidan på startsidan.", "LabelSettingsHideSingleBookSeriesHelp": "Serier som har en enda bok kommer att döljas från seriesidan och hyllsidan på startsidan.",
"LabelSettingsHomePageBookshelfView": "Startsida använd bokhyllvy", "LabelSettingsHomePageBookshelfView": "Startsida använd bokhyllvy",
"LabelSettingsLibraryBookshelfView": "Bibliotek använd bokhyllvy", "LabelSettingsLibraryBookshelfView": "Bibliotek använd bokhyllvy",
"LabelSettingsParseSubtitles": "Analysera undertexter", "LabelSettingsParseSubtitles": "Analysera undertexter",
"LabelSettingsParseSubtitlesHelp": "Extrahera undertexter från mappnamn för ljudböcker.<br>Undertext måste vara åtskilda av \" - \"<br>t.ex. \"Boktitel - En undertitel här\" har undertiteln \"En undertitel här\"", "LabelSettingsParseSubtitlesHelp": "Extrahera undertitlar från namnet på mappar för ljudböcker.<br>Undertiteln måste vara åtskilda med ett bindestreck \" - \".<br>Mappen \"Boktitel - En undertitel här\" har undertiteln \"En undertitel här\"",
"LabelSettingsPreferMatchedMetadata": "Föredra matchad metadata", "LabelSettingsPreferMatchedMetadata": "Föredra matchad metadata",
"LabelSettingsPreferMatchedMetadataHelp": "Matchad data kommer att åsidosätta objektdetaljer vid snabbmatchning. Som standard kommer snabbmatchning endast att fylla i saknade detaljer.", "LabelSettingsPreferMatchedMetadataHelp": "Matchad data kommer att åsidosätta objektdetaljer vid snabbmatchning. Som standard kommer snabbmatchning endast att fylla i saknade detaljer.",
"LabelSettingsSkipMatchingBooksWithASIN": "Hoppa över matchande böcker med ASIN", "LabelSettingsSkipMatchingBooksWithASIN": "Hoppa över matchande böcker med ASIN-kod",
"LabelSettingsSkipMatchingBooksWithISBN": "Hoppa över matchande böcker med ISBN", "LabelSettingsSkipMatchingBooksWithISBN": "Hoppa över matchande böcker med ISBN",
"LabelSettingsSortingIgnorePrefixes": "Ignorera prefix vid sortering", "LabelSettingsSortingIgnorePrefixes": "Ignorera prefix vid sortering",
"LabelSettingsSortingIgnorePrefixesHelp": "t.ex. för prefixet \"the\" kommer boktiteln \"The Book Title\" att sorteras som \"Book Title, The\"", "LabelSettingsSortingIgnorePrefixesHelp": "För prefix som t.ex. \"the\" kommer boktiteln \"The Book Title\" att sorteras som \"Book Title, The\"",
"LabelSettingsSquareBookCovers": "Använd fyrkantiga bokomslag", "LabelSettingsSquareBookCovers": "Använd fyrkantiga bokomslag",
"LabelSettingsSquareBookCoversHelp": "Föredrar att använda fyrkantiga omslag över standard 1.6:1 bokomslag", "LabelSettingsSquareBookCoversHelp": "Föredrar att använda fyrkantiga omslag över standard 1.6:1 bokomslag",
"LabelSettingsStoreCoversWithItem": "Lagra omslag med objekt", "LabelSettingsStoreCoversWithItem": "Lagra omslag med objekt",
"LabelSettingsStoreCoversWithItemHelp": "Som standard lagras omslag i /metadata/items, att aktivera detta alternativ kommer att lagra omslag i din biblioteksmapp. Endast en fil med namnet \"cover\" kommer att behållas", "LabelSettingsStoreCoversWithItemHelp": "Som standard lagras bokomslag i mappen /metadata/items. Genom att aktivera detta alternativ kommer omslagen att lagra i din biblioteksmapp. Endast en fil med namnet \"cover\" kommer att behållas",
"LabelSettingsStoreMetadataWithItem": "Lagra metadata med objekt", "LabelSettingsStoreMetadataWithItem": "Lagra metadata med objekt",
"LabelSettingsStoreMetadataWithItemHelp": "Som standard lagras metadatafiler i /metadata/items, att aktivera detta alternativ kommer att lagra metadatafiler i dina biblioteksmappar", "LabelSettingsStoreMetadataWithItemHelp": "Som standard lagras metadatafiler i mappen /metadata/items. Genom att aktivera detta alternativ kommer metadatafilerna att lagras i dina biblioteksmappar",
"LabelSettingsTimeFormat": "Tidsformat", "LabelSettingsTimeFormat": "Tidsformat",
"LabelShowAll": "Visa alla", "LabelShowAll": "Visa alla",
"LabelSize": "Storlek", "LabelSize": "Storlek",
@ -457,7 +473,7 @@
"LabelStatsOverallHours": "Totalt antal timmar", "LabelStatsOverallHours": "Totalt antal timmar",
"LabelStatsWeekListening": "Veckans lyssnande", "LabelStatsWeekListening": "Veckans lyssnande",
"LabelSubtitle": "Underrubrik", "LabelSubtitle": "Underrubrik",
"LabelSupportedFileTypes": "Stödda filtyper", "LabelSupportedFileTypes": "Filtyper som accepteras",
"LabelTag": "Tagg", "LabelTag": "Tagg",
"LabelTags": "Taggar", "LabelTags": "Taggar",
"LabelTagsAccessibleToUser": "Taggar tillgängliga för användaren", "LabelTagsAccessibleToUser": "Taggar tillgängliga för användaren",
@ -467,17 +483,22 @@
"LabelThemeDark": "Mörkt", "LabelThemeDark": "Mörkt",
"LabelThemeLight": "Ljust", "LabelThemeLight": "Ljust",
"LabelTimeBase": "Tidsbas", "LabelTimeBase": "Tidsbas",
"LabelTimeDurationXHours": "{0} timmar",
"LabelTimeDurationXMinutes": "{0} minuter",
"LabelTimeDurationXSeconds": "{0} sekunder",
"LabelTimeInMinutes": "Tid i minuter",
"LabelTimeLeft": "{0} återstår",
"LabelTimeListened": "Tid lyssnad", "LabelTimeListened": "Tid lyssnad",
"LabelTimeListenedToday": "Tid lyssnad idag", "LabelTimeListenedToday": "Tid lyssnad idag",
"LabelTimeRemaining": "{0} kvar", "LabelTimeRemaining": "{0} återstår",
"LabelTimeToShift": "Tid att skifta i sekunder", "LabelTimeToShift": "Tid att skifta i sekunder",
"LabelTitle": "Titel", "LabelTitle": "Titel",
"LabelToolsEmbedMetadata": "Bädda in metadata", "LabelToolsEmbedMetadata": "Bädda in metadata",
"LabelToolsEmbedMetadataDescription": "Bädda in metadata i ljudfiler, inklusive omslagsbild och kapitel.", "LabelToolsEmbedMetadataDescription": "Bädda in metadata i ljudfiler, inklusive omslagsbild och kapitel.",
"LabelToolsMakeM4b": "Skapa M4B ljudbok", "LabelToolsMakeM4b": "Skapa M4B ljudbok",
"LabelToolsMakeM4bDescription": "Skapa en .M4B ljudboksfil med inbäddad metadata, omslagsbild och kapitel.", "LabelToolsMakeM4bDescription": "Skapa en .M4B ljudboksfil med inbäddad metadata, omslagsbild och kapitel.",
"LabelToolsSplitM4b": "Dela M4B till MP3-filer", "LabelToolsSplitM4b": "Dela upp M4B-fil i MP3-filer",
"LabelToolsSplitM4bDescription": "Skapa MP3-filer från en M4B fil uppdelad i kapitel med inbäddad metadata, omslagsbild och kapitel.", "LabelToolsSplitM4bDescription": "Skapa MP3-filer från en M4B-fil uppdelad i kapitel med inbäddad metadata, omslagsbild och kapitel.",
"LabelTotalDuration": "Total varaktighet", "LabelTotalDuration": "Total varaktighet",
"LabelTotalTimeListened": "Total tid lyssnad", "LabelTotalTimeListened": "Total tid lyssnad",
"LabelTrackFromFilename": "Spår från filnamn", "LabelTrackFromFilename": "Spår från filnamn",
@ -486,6 +507,7 @@
"LabelTracksMultiTrack": "Flerspårigt", "LabelTracksMultiTrack": "Flerspårigt",
"LabelTracksNone": "Inga spår", "LabelTracksNone": "Inga spår",
"LabelTracksSingleTrack": "Enspårigt", "LabelTracksSingleTrack": "Enspårigt",
"LabelTrailer": "Trailer",
"LabelType": "Typ", "LabelType": "Typ",
"LabelUnabridged": "Oavkortad", "LabelUnabridged": "Oavkortad",
"LabelUnknown": "Okänd", "LabelUnknown": "Okänd",
@ -496,16 +518,20 @@
"LabelUpdatedAt": "Uppdaterad vid", "LabelUpdatedAt": "Uppdaterad vid",
"LabelUploaderDragAndDrop": "Dra och släpp filer eller mappar", "LabelUploaderDragAndDrop": "Dra och släpp filer eller mappar",
"LabelUploaderDropFiles": "Släpp filer", "LabelUploaderDropFiles": "Släpp filer",
"LabelUploaderItemFetchMetadataHelp": "Hämtar automatiskt titel, författare och serier.",
"LabelUseChapterTrack": "Använd kapitelspår", "LabelUseChapterTrack": "Använd kapitelspår",
"LabelUseFullTrack": "Använd hela spåret", "LabelUseFullTrack": "Använd hela spåret",
"LabelUser": "Användare", "LabelUser": "Användare",
"LabelUsername": "Användarnamn", "LabelUsername": "Användarnamn",
"LabelValue": "Värde", "LabelValue": "Värde",
"LabelVersion": "Version",
"LabelViewBookmarks": "Visa bokmärken", "LabelViewBookmarks": "Visa bokmärken",
"LabelViewChapters": "Visa kapitel", "LabelViewChapters": "Visa kapitel",
"LabelViewQueue": "Visa spellista", "LabelViewQueue": "Visa spellista",
"LabelVolume": "Volym", "LabelVolume": "Volym",
"LabelWeekdaysToRun": "Vardagar att köra", "LabelWeekdaysToRun": "Vardagar att köra",
"LabelYearReviewHide": "Dölj sammanställning för året",
"LabelYearReviewShow": "Visa sammanställning för året",
"LabelYourAudiobookDuration": "Din ljudboks varaktighet", "LabelYourAudiobookDuration": "Din ljudboks varaktighet",
"LabelYourBookmarks": "Dina bokmärken", "LabelYourBookmarks": "Dina bokmärken",
"LabelYourPlaylists": "Dina spellistor", "LabelYourPlaylists": "Dina spellistor",
@ -535,22 +561,22 @@
"MessageConfirmMarkAllEpisodesFinished": "Är du säker på att du vill markera alla avsnitt som avslutade?", "MessageConfirmMarkAllEpisodesFinished": "Är du säker på att du vill markera alla avsnitt som avslutade?",
"MessageConfirmMarkAllEpisodesNotFinished": "Är du säker på att du vill markera alla avsnitt som inte avslutade?", "MessageConfirmMarkAllEpisodesNotFinished": "Är du säker på att du vill markera alla avsnitt som inte avslutade?",
"MessageConfirmMarkSeriesFinished": "Är du säker på att du vill markera alla böcker i denna serie som avslutade?", "MessageConfirmMarkSeriesFinished": "Är du säker på att du vill markera alla böcker i denna serie som avslutade?",
"MessageConfirmMarkSeriesNotFinished": "Är du säker på att du vill markera alla böcker i denna serie som inte avslutade?", "MessageConfirmMarkSeriesNotFinished": "Är du säker på att du vill markera alla böcker i denna serie som ej avslutade?",
"MessageConfirmQuickEmbed": "Varning! Quick embed kommer inte att säkerhetskopiera dina ljudfiler. Se till att du har en säkerhetskopia av dina ljudfiler. <br><br>Vill du fortsätta?", "MessageConfirmQuickEmbed": "VARNING! Quick embed kommer inte att säkerhetskopiera dina ljudfiler. Se till att du har en säkerhetskopia av dina ljudfiler. <br><br>Vill du fortsätta?",
"MessageConfirmReScanLibraryItems": "Är du säker på att du vill göra omgenomsökning för {0} objekt?", "MessageConfirmReScanLibraryItems": "Är du säker på att du vill göra omgenomsökning för {0} objekt?",
"MessageConfirmRemoveAllChapters": "Är du säker på att du vill ta bort alla kapitel?", "MessageConfirmRemoveAllChapters": "Är du säker på att du vill ta bort alla kapitel?",
"MessageConfirmRemoveAuthor": "Är du säker på att du vill ta bort författaren \"{0}\"?", "MessageConfirmRemoveAuthor": "Är du säker på att du vill ta bort författaren \"{0}\"?",
"MessageConfirmRemoveCollection": "Är du säker på att du vill ta bort samlingen \"{0}\"?", "MessageConfirmRemoveCollection": "Är du säker på att du vill ta bort samlingen \"{0}\"?",
"MessageConfirmRemoveEpisode": "Är du säker på att du vill ta bort avsnittet \"{0}\"?", "MessageConfirmRemoveEpisode": "Är du säker på att du vill ta bort avsnittet \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Är du säker på att du vill ta bort {0} avsnitt?", "MessageConfirmRemoveEpisodes": "Är du säker på att du vill ta bort {0} avsnitt?",
"MessageConfirmRemoveNarrator": "Är du säker på att du vill ta bort berättaren \"{0}\"?", "MessageConfirmRemoveNarrator": "Är du säker på att du vill ta bort uppläsaren \"{0}\"?",
"MessageConfirmRemovePlaylist": "Är du säker på att du vill ta bort din spellista \"{0}\"?", "MessageConfirmRemovePlaylist": "Är du säker på att du vill ta bort din spellista \"{0}\"?",
"MessageConfirmRenameGenre": "Är du säker på att du vill byta namn på genren \"{0}\" till \"{1}\" för alla objekt?", "MessageConfirmRenameGenre": "Är du säker på att du vill byta namn på kategori \"{0}\" till \"{1}\" för alla objekt?",
"MessageConfirmRenameGenreMergeNote": "Observera: Den här genren finns redan, så de kommer att slås samman.", "MessageConfirmRenameGenreMergeNote": "OBS: Den här kategorin finns redan, så de kommer att slås samman.",
"MessageConfirmRenameGenreWarning": "Varning! En liknande genre med annat skrivsätt finns redan \"{0}\".", "MessageConfirmRenameGenreWarning": "Varning! En liknande kategori med annat skrivsätt finns redan \"{0}\".",
"MessageConfirmRenameTag": "Är du säker på att du vill byta namn på taggen \"{0}\" till \"{1}\" för alla objekt?", "MessageConfirmRenameTag": "Är du säker på att du vill byta namn på taggen \"{0}\" till \"{1}\" för alla objekt?",
"MessageConfirmRenameTagMergeNote": "Observera: Den här taggen finns redan, så de kommer att slås samman.", "MessageConfirmRenameTagMergeNote": "OBS: Den här taggen finns redan, så de kommer att slås samman.",
"MessageConfirmRenameTagWarning": "Varning! En liknande tagg med annat skrivsätt finns redan \"{0}\".", "MessageConfirmRenameTagWarning": "VARNING! En liknande tagg med annat skrivsätt finns redan \"{0}\".",
"MessageConfirmSendEbookToDevice": "Är du säker på att du vill skicka {0} e-bok \"{1}\" till enheten \"{2}\"?", "MessageConfirmSendEbookToDevice": "Är du säker på att du vill skicka {0} e-bok \"{1}\" till enheten \"{2}\"?",
"MessageDownloadingEpisode": "Laddar ner avsnitt", "MessageDownloadingEpisode": "Laddar ner avsnitt",
"MessageDragFilesIntoTrackOrder": "Dra filer till rätt spårordning", "MessageDragFilesIntoTrackOrder": "Dra filer till rätt spårordning",
@ -564,7 +590,6 @@
"MessageItemsSelected": "{0} Objekt markerade", "MessageItemsSelected": "{0} Objekt markerade",
"MessageItemsUpdated": "{0} Objekt uppdaterade", "MessageItemsUpdated": "{0} Objekt uppdaterade",
"MessageJoinUsOn": "Anslut dig till oss på", "MessageJoinUsOn": "Anslut dig till oss på",
"MessageListeningSessionsInTheLastYear": "{0} lyssningssessioner det senaste året",
"MessageLoading": "Laddar...", "MessageLoading": "Laddar...",
"MessageLoadingFolders": "Laddar mappar...", "MessageLoadingFolders": "Laddar mappar...",
"MessageM4BFailed": "M4B misslyckades!", "MessageM4BFailed": "M4B misslyckades!",
@ -574,7 +599,7 @@
"MessageMarkAllEpisodesNotFinished": "Markera alla avsnitt som inte avslutade", "MessageMarkAllEpisodesNotFinished": "Markera alla avsnitt som inte avslutade",
"MessageMarkAsFinished": "Markera som avslutad", "MessageMarkAsFinished": "Markera som avslutad",
"MessageMarkAsNotFinished": "Markera som inte avslutad", "MessageMarkAsNotFinished": "Markera som inte avslutad",
"MessageMatchBooksDescription": "kommer att försöka matcha böcker i biblioteket med en bok från den valda sökleverantören och fylla i tomma detaljer och omslagskonst. Överskriver inte detaljer.", "MessageMatchBooksDescription": "kommer att försöka matcha böcker i biblioteket med en bok från den valda källan och fylla i uppgifter som saknas och bokomslag. Inga befintliga uppgifter kommer att ersättas.",
"MessageNoAudioTracks": "Inga ljudspår", "MessageNoAudioTracks": "Inga ljudspår",
"MessageNoAuthors": "Inga författare", "MessageNoAuthors": "Inga författare",
"MessageNoBackups": "Inga säkerhetskopior", "MessageNoBackups": "Inga säkerhetskopior",
@ -588,7 +613,7 @@
"MessageNoEpisodeMatchesFound": "Inga matchande avsnitt hittades", "MessageNoEpisodeMatchesFound": "Inga matchande avsnitt hittades",
"MessageNoEpisodes": "Inga avsnitt", "MessageNoEpisodes": "Inga avsnitt",
"MessageNoFoldersAvailable": "Inga mappar tillgängliga", "MessageNoFoldersAvailable": "Inga mappar tillgängliga",
"MessageNoGenres": "Inga genrer", "MessageNoGenres": "Inga kategorier",
"MessageNoIssues": "Inga problem", "MessageNoIssues": "Inga problem",
"MessageNoItems": "Inga objekt", "MessageNoItems": "Inga objekt",
"MessageNoItemsFound": "Inga objekt hittades", "MessageNoItemsFound": "Inga objekt hittades",
@ -637,7 +662,7 @@
"NoteFolderPicker": "Obs: Mappar som redan är kartlagda kommer inte att visas", "NoteFolderPicker": "Obs: Mappar som redan är kartlagda kommer inte att visas",
"NoteRSSFeedPodcastAppsHttps": "Varning: De flesta podcastappar kräver att RSS-flödets URL används med HTTPS", "NoteRSSFeedPodcastAppsHttps": "Varning: De flesta podcastappar kräver att RSS-flödets URL används med HTTPS",
"NoteRSSFeedPodcastAppsPubDate": "Varning: 1 eller flera av dina avsnitt har inte ett publiceringsdatum. Vissa podcastappar kräver detta.", "NoteRSSFeedPodcastAppsPubDate": "Varning: 1 eller flera av dina avsnitt har inte ett publiceringsdatum. Vissa podcastappar kräver detta.",
"NoteUploaderFoldersWithMediaFiles": "Mappar med mediefiler hanteras som separata biblioteksobjekt.", "NoteUploaderFoldersWithMediaFiles": "Mappar med flera mediefiler hanteras som separata objekt i biblioteket.",
"NoteUploaderOnlyAudioFiles": "Om du bara laddar upp ljudfiler kommer varje ljudfil att hanteras som en separat ljudbok.", "NoteUploaderOnlyAudioFiles": "Om du bara laddar upp ljudfiler kommer varje ljudfil att hanteras som en separat ljudbok.",
"NoteUploaderUnsupportedFiles": "Oaccepterade filer ignoreras. När du väljer eller släpper en mapp ignoreras andra filer som inte finns i ett objektmapp.", "NoteUploaderUnsupportedFiles": "Oaccepterade filer ignoreras. När du väljer eller släpper en mapp ignoreras andra filer som inte finns i ett objektmapp.",
"PlaceholderNewCollection": "Nytt samlingsnamn", "PlaceholderNewCollection": "Nytt samlingsnamn",
@ -645,29 +670,42 @@
"PlaceholderNewPlaylist": "Nytt spellistanamn", "PlaceholderNewPlaylist": "Nytt spellistanamn",
"PlaceholderSearch": "Sök...", "PlaceholderSearch": "Sök...",
"PlaceholderSearchEpisode": "Sök avsnitt...", "PlaceholderSearchEpisode": "Sök avsnitt...",
"StatsTopAuthor": "POPULÄRAST FÖRFATTAREN",
"StatsTopAuthors": "POPULÄRASTE FÖRFATTARNA",
"StatsTopGenre": "Populäraste kategorin",
"StatsTopGenres": "Populäraste kategorierna",
"StatsTopMonth": "Bästa månaden",
"StatsTopNarrator": "Populärast uppläsarna",
"StatsTopNarrators": "Populäraste uppläsaren",
"StatsYearInReview": "SAMMANSTÄLLNING AV ÅRET",
"ToastAccountUpdateSuccess": "Kontot uppdaterat", "ToastAccountUpdateSuccess": "Kontot uppdaterat",
"ToastAsinRequired": "En ASIN-kod krävs",
"ToastAuthorImageRemoveSuccess": "Författarens bild borttagen", "ToastAuthorImageRemoveSuccess": "Författarens bild borttagen",
"ToastAuthorNotFound": "Författaren \"{0}\" kunde inte identifieras",
"ToastAuthorRemoveSuccess": "Författaren har raderats",
"ToastAuthorSearchNotFound": "Författaren kunde inte identifieras",
"ToastAuthorUpdateMerged": "Författaren sammanslagen", "ToastAuthorUpdateMerged": "Författaren sammanslagen",
"ToastAuthorUpdateSuccess": "Författaren uppdaterad", "ToastAuthorUpdateSuccess": "Författaren uppdaterad",
"ToastAuthorUpdateSuccessNoImageFound": "Författaren uppdaterad (ingen bild hittad)", "ToastAuthorUpdateSuccessNoImageFound": "Författaren uppdaterad (ingen bild hittad)",
"ToastBackupCreateFailed": "Det gick inte att skapa en säkerhetskopia", "ToastBackupCreateFailed": "Det gick inte att skapa en säkerhetskopia",
"ToastBackupCreateSuccess": "Säkerhetskopia skapad", "ToastBackupCreateSuccess": "Säkerhetskopian har skapats",
"ToastBackupDeleteFailed": "Det gick inte att ta bort säkerhetskopian", "ToastBackupDeleteFailed": "Det gick inte att radera säkerhetskopian",
"ToastBackupDeleteSuccess": "Säkerhetskopan borttagen", "ToastBackupDeleteSuccess": "Säkerhetskopian har raderats",
"ToastBackupRestoreFailed": "Det gick inte att återställa säkerhetskopan", "ToastBackupInvalidMaxKeep": "Felaktigt antal kopior av backup har angivits",
"ToastBackupUploadFailed": "Det gick inte att ladda upp säkerhetskopan", "ToastBackupInvalidMaxSize": "Felaktig storlek på backup har angivits",
"ToastBackupUploadSuccess": "Säkerhetskopan uppladdad", "ToastBackupRestoreFailed": "Det gick inte att återställa säkerhetskopian",
"ToastBackupUploadFailed": "Det gick inte att ladda upp säkerhetskopian",
"ToastBackupUploadSuccess": "Säkerhetskopian uppladdad",
"ToastBatchUpdateFailed": "Batchuppdateringen misslyckades", "ToastBatchUpdateFailed": "Batchuppdateringen misslyckades",
"ToastBatchUpdateSuccess": "Batchuppdateringen lyckades", "ToastBatchUpdateSuccess": "Batchuppdateringen lyckades",
"ToastBookmarkCreateFailed": "Det gick inte att skapa bokmärket", "ToastBookmarkCreateFailed": "Det gick inte att skapa bokmärket",
"ToastBookmarkCreateSuccess": "Bokmärket tillagt", "ToastBookmarkCreateSuccess": "Bokmärket har adderats",
"ToastBookmarkRemoveSuccess": "Bokmärket borttaget", "ToastBookmarkRemoveSuccess": "Bokmärket har raderats",
"ToastBookmarkUpdateSuccess": "Bokmärket uppdaterat", "ToastBookmarkUpdateSuccess": "Bokmärket har uppdaterats",
"ToastChaptersHaveErrors": "Kapitlen har fel", "ToastChaptersHaveErrors": "Kapitlen har fel",
"ToastChaptersMustHaveTitles": "Kapitel måste ha titlar", "ToastChaptersMustHaveTitles": "Kapitel måste ha titlar",
"ToastCollectionItemsRemoveSuccess": "Objekt borttagna från samlingen", "ToastCollectionRemoveSuccess": "Samlingen har raderats",
"ToastCollectionRemoveSuccess": "Samlingen borttagen", "ToastCollectionUpdateSuccess": "Samlingen har uppdaterats",
"ToastCollectionUpdateSuccess": "Samlingen uppdaterad",
"ToastItemCoverUpdateSuccess": "Objektets omslag uppdaterat", "ToastItemCoverUpdateSuccess": "Objektets omslag uppdaterat",
"ToastItemDetailsUpdateSuccess": "Objektdetaljer uppdaterade", "ToastItemDetailsUpdateSuccess": "Objektdetaljer uppdaterade",
"ToastItemMarkedAsFinishedFailed": "Misslyckades med att markera som färdig", "ToastItemMarkedAsFinishedFailed": "Misslyckades med att markera som färdig",
@ -693,8 +731,8 @@
"ToastRemoveItemFromCollectionSuccess": "Objektet borttaget från samlingen", "ToastRemoveItemFromCollectionSuccess": "Objektet borttaget från samlingen",
"ToastSendEbookToDeviceFailed": "Misslyckades med att skicka e-boken till enheten", "ToastSendEbookToDeviceFailed": "Misslyckades med att skicka e-boken till enheten",
"ToastSendEbookToDeviceSuccess": "E-boken skickad till enheten \"{0}\"", "ToastSendEbookToDeviceSuccess": "E-boken skickad till enheten \"{0}\"",
"ToastSeriesUpdateFailed": "Serieuppdateringen misslyckades", "ToastSeriesUpdateFailed": "Uppdateringen av serier misslyckades",
"ToastSeriesUpdateSuccess": "Serieuppdateringen lyckades", "ToastSeriesUpdateSuccess": "Uppdateringen av serierna lyckades",
"ToastSessionDeleteFailed": "Misslyckades med att ta bort sessionen", "ToastSessionDeleteFailed": "Misslyckades med att ta bort sessionen",
"ToastSessionDeleteSuccess": "Sessionen borttagen", "ToastSessionDeleteSuccess": "Sessionen borttagen",
"ToastSocketConnected": "Socket ansluten", "ToastSocketConnected": "Socket ansluten",

View File

@ -88,6 +88,8 @@
"ButtonSaveTracklist": "Зберегти порядок", "ButtonSaveTracklist": "Зберегти порядок",
"ButtonScan": "Сканувати", "ButtonScan": "Сканувати",
"ButtonScanLibrary": "Сканувати бібліотеку", "ButtonScanLibrary": "Сканувати бібліотеку",
"ButtonScrollLeft": "Прокрутити ліворуч",
"ButtonScrollRight": "Прокрутити праворуч",
"ButtonSearch": "Пошук", "ButtonSearch": "Пошук",
"ButtonSelectFolderPath": "Обрати шлях до теки", "ButtonSelectFolderPath": "Обрати шлях до теки",
"ButtonSeries": "Серії", "ButtonSeries": "Серії",
@ -190,6 +192,7 @@
"HeaderSettingsExperimental": "Експериментальні функції", "HeaderSettingsExperimental": "Експериментальні функції",
"HeaderSettingsGeneral": "Основне", "HeaderSettingsGeneral": "Основне",
"HeaderSettingsScanner": "Сканер", "HeaderSettingsScanner": "Сканер",
"HeaderSettingsWebClient": "Вебклієнт",
"HeaderSleepTimer": "Таймер вимкнення", "HeaderSleepTimer": "Таймер вимкнення",
"HeaderStatsLargestItems": "Найбільші елементи", "HeaderStatsLargestItems": "Найбільші елементи",
"HeaderStatsLongestItems": "Найдовші елементи (год)", "HeaderStatsLongestItems": "Найдовші елементи (год)",
@ -297,6 +300,7 @@
"LabelDiscover": "Огляд", "LabelDiscover": "Огляд",
"LabelDownload": "Завантажити", "LabelDownload": "Завантажити",
"LabelDownloadNEpisodes": "Завантажити епізодів: {0}", "LabelDownloadNEpisodes": "Завантажити епізодів: {0}",
"LabelDownloadable": "Можна завантажити",
"LabelDuration": "Тривалість", "LabelDuration": "Тривалість",
"LabelDurationComparisonExactMatch": "(повний збіг)", "LabelDurationComparisonExactMatch": "(повний збіг)",
"LabelDurationComparisonLonger": "(на {0} довше)", "LabelDurationComparisonLonger": "(на {0} довше)",
@ -542,6 +546,7 @@
"LabelServerYearReview": "Підсумки року сервера ({0})", "LabelServerYearReview": "Підсумки року сервера ({0})",
"LabelSetEbookAsPrimary": "Зробити основною", "LabelSetEbookAsPrimary": "Зробити основною",
"LabelSetEbookAsSupplementary": "Зробити додатковою", "LabelSetEbookAsSupplementary": "Зробити додатковою",
"LabelSettingsAllowIframe": "Дозволити вбудовування у iframe",
"LabelSettingsAudiobooksOnly": "Лише аудіокниги", "LabelSettingsAudiobooksOnly": "Лише аудіокниги",
"LabelSettingsAudiobooksOnlyHelp": "Увімкніть цей параметр, щоб ігнорувати файли електронних книг, якщо вони не знаходяться у теці аудіокниги, тоді вони будуть встановлені як додаткові електронні книги", "LabelSettingsAudiobooksOnlyHelp": "Увімкніть цей параметр, щоб ігнорувати файли електронних книг, якщо вони не знаходяться у теці аудіокниги, тоді вони будуть встановлені як додаткові електронні книги",
"LabelSettingsBookshelfViewHelp": "Імітує вигляд дерев'яних полиць", "LabelSettingsBookshelfViewHelp": "Імітує вигляд дерев'яних полиць",
@ -584,6 +589,7 @@
"LabelSettingsStoreMetadataWithItemHelp": "За замовчуванням файли метаданих зберігаються у /metadata/items. Цей параметр увімкне збереження метаданих у теці елемента бібліотеки", "LabelSettingsStoreMetadataWithItemHelp": "За замовчуванням файли метаданих зберігаються у /metadata/items. Цей параметр увімкне збереження метаданих у теці елемента бібліотеки",
"LabelSettingsTimeFormat": "Формат часу", "LabelSettingsTimeFormat": "Формат часу",
"LabelShare": "Поділитися", "LabelShare": "Поділитися",
"LabelShareDownloadableHelp": "Дозволяє користувачам із посиланням для спільного доступу завантажувати zip-файл елемента бібліотеки.",
"LabelShareOpen": "Поділитися відкрито", "LabelShareOpen": "Поділитися відкрито",
"LabelShareURL": "Поділитися URL", "LabelShareURL": "Поділитися URL",
"LabelShowAll": "Показати все", "LabelShowAll": "Показати все",
@ -592,6 +598,8 @@
"LabelSize": "Розмір", "LabelSize": "Розмір",
"LabelSleepTimer": "Таймер вимкнення", "LabelSleepTimer": "Таймер вимкнення",
"LabelSlug": "Назва", "LabelSlug": "Назва",
"LabelSortAscending": "По зростанню",
"LabelSortDescending": "По спаданню",
"LabelStart": "Початок", "LabelStart": "Початок",
"LabelStartTime": "Час початку", "LabelStartTime": "Час початку",
"LabelStarted": "Почато", "LabelStarted": "Почато",
@ -679,6 +687,8 @@
"LabelViewPlayerSettings": "Переглянути налаштування програвача", "LabelViewPlayerSettings": "Переглянути налаштування програвача",
"LabelViewQueue": "Переглянути чергу відтворення", "LabelViewQueue": "Переглянути чергу відтворення",
"LabelVolume": "Гучність", "LabelVolume": "Гучність",
"LabelWebRedirectURLsDescription": "Авторизуйте ці URL у вашому OAuth постачальнику, щоб дозволити редирекцію назад до веб-додатку після входу:",
"LabelWebRedirectURLsSubfolder": "Підпапка для Redirect URL",
"LabelWeekdaysToRun": "Виконувати у дні", "LabelWeekdaysToRun": "Виконувати у дні",
"LabelXBooks": "{0} книг", "LabelXBooks": "{0} книг",
"LabelXItems": "{0} елементів", "LabelXItems": "{0} елементів",
@ -763,7 +773,6 @@
"MessageItemsSelected": "Обрано елементів: {0}", "MessageItemsSelected": "Обрано елементів: {0}",
"MessageItemsUpdated": "Оновлено елементів: {0}", "MessageItemsUpdated": "Оновлено елементів: {0}",
"MessageJoinUsOn": "Приєднуйтесь до", "MessageJoinUsOn": "Приєднуйтесь до",
"MessageListeningSessionsInTheLastYear": "Сесій прослуховування минулого року: {0}",
"MessageLoading": "Завантаження...", "MessageLoading": "Завантаження...",
"MessageLoadingFolders": "Завантаження тек...", "MessageLoadingFolders": "Завантаження тек...",
"MessageLogsDescription": "Журнали зберігаються у <code>/metadata/logs</code> як JSON-файли. Журнали збоїв зберігаються у <code>/metadata/logs/crash_logs.txt</code>.", "MessageLogsDescription": "Журнали зберігаються у <code>/metadata/logs</code> як JSON-файли. Журнали збоїв зберігаються у <code>/metadata/logs/crash_logs.txt</code>.",
@ -879,7 +888,7 @@
"MessageXLibraryIsEmpty": "Бібліотека {0} порожня!", "MessageXLibraryIsEmpty": "Бібліотека {0} порожня!",
"MessageYourAudiobookDurationIsLonger": "Тривалість вашої аудіокниги довша за віднайдену", "MessageYourAudiobookDurationIsLonger": "Тривалість вашої аудіокниги довша за віднайдену",
"MessageYourAudiobookDurationIsShorter": "Тривалість вашої аудіокниги коротша за віднайдену", "MessageYourAudiobookDurationIsShorter": "Тривалість вашої аудіокниги коротша за віднайдену",
"NoteChangeRootPassword": "Кореневий користувач — єдиний, хто може мати порожній пароль", "NoteChangeRootPassword": "Тільки користувач root — єдиний, хто може мати порожній пароль",
"NoteChapterEditorTimes": "Примітка: Перша глава мусить починатися з 0:00, а час початку останньої глави не може бути більшим за зазначену тривалість аудіокниги.", "NoteChapterEditorTimes": "Примітка: Перша глава мусить починатися з 0:00, а час початку останньої глави не може бути більшим за зазначену тривалість аудіокниги.",
"NoteFolderPicker": "Примітка: вже обрані теки не буде показано", "NoteFolderPicker": "Примітка: вже обрані теки не буде показано",
"NoteRSSFeedPodcastAppsHttps": "Попередження: Більшість додатків подкастів вимагатимуть використання протоколу HTTPS від RSS-каналу", "NoteRSSFeedPodcastAppsHttps": "Попередження: Більшість додатків подкастів вимагатимуть використання протоколу HTTPS від RSS-каналу",
@ -951,8 +960,6 @@
"ToastChaptersRemoved": "Розділи видалені", "ToastChaptersRemoved": "Розділи видалені",
"ToastChaptersUpdated": "Розділи оновлені", "ToastChaptersUpdated": "Розділи оновлені",
"ToastCollectionItemsAddFailed": "Не вдалося додати елемент(и) до колекції", "ToastCollectionItemsAddFailed": "Не вдалося додати елемент(и) до колекції",
"ToastCollectionItemsAddSuccess": "Елемент(и) успішно додано до колекції",
"ToastCollectionItemsRemoveSuccess": "Елемент(и) видалено з добірки",
"ToastCollectionRemoveSuccess": "Добірку видалено", "ToastCollectionRemoveSuccess": "Добірку видалено",
"ToastCollectionUpdateSuccess": "Добірку оновлено", "ToastCollectionUpdateSuccess": "Добірку оновлено",
"ToastCoverUpdateFailed": "Не вдалося оновити обкладинку", "ToastCoverUpdateFailed": "Не вдалося оновити обкладинку",

View File

@ -581,7 +581,6 @@
"MessageItemsSelected": "{0} Mục Đã Chọn", "MessageItemsSelected": "{0} Mục Đã Chọn",
"MessageItemsUpdated": "{0} Mục Đã Cập Nhật", "MessageItemsUpdated": "{0} Mục Đã Cập Nhật",
"MessageJoinUsOn": "Tham gia cùng chúng tôi trên", "MessageJoinUsOn": "Tham gia cùng chúng tôi trên",
"MessageListeningSessionsInTheLastYear": "{0} phiên nghe trong năm qua",
"MessageLoading": "Đang tải...", "MessageLoading": "Đang tải...",
"MessageLoadingFolders": "Đang tải các thư mục...", "MessageLoadingFolders": "Đang tải các thư mục...",
"MessageM4BFailed": "M4B thất bại!", "MessageM4BFailed": "M4B thất bại!",
@ -683,7 +682,6 @@
"ToastBookmarkUpdateSuccess": "Đánh dấu đã được cập nhật", "ToastBookmarkUpdateSuccess": "Đánh dấu đã được cập nhật",
"ToastChaptersHaveErrors": "Các chương có lỗi", "ToastChaptersHaveErrors": "Các chương có lỗi",
"ToastChaptersMustHaveTitles": "Các chương phải có tiêu đề", "ToastChaptersMustHaveTitles": "Các chương phải có tiêu đề",
"ToastCollectionItemsRemoveSuccess": "Mục đã được xóa khỏi bộ sưu tập",
"ToastCollectionRemoveSuccess": "Bộ sưu tập đã được xóa", "ToastCollectionRemoveSuccess": "Bộ sưu tập đã được xóa",
"ToastCollectionUpdateSuccess": "Bộ sưu tập đã được cập nhật", "ToastCollectionUpdateSuccess": "Bộ sưu tập đã được cập nhật",
"ToastItemCoverUpdateSuccess": "Ảnh bìa mục đã được cập nhật", "ToastItemCoverUpdateSuccess": "Ảnh bìa mục đã được cập nhật",

View File

@ -88,6 +88,8 @@
"ButtonSaveTracklist": "保存音轨列表", "ButtonSaveTracklist": "保存音轨列表",
"ButtonScan": "扫描", "ButtonScan": "扫描",
"ButtonScanLibrary": "扫描库", "ButtonScanLibrary": "扫描库",
"ButtonScrollLeft": "向左滚动",
"ButtonScrollRight": "向右滚动",
"ButtonSearch": "查找", "ButtonSearch": "查找",
"ButtonSelectFolderPath": "选择文件夹路径", "ButtonSelectFolderPath": "选择文件夹路径",
"ButtonSeries": "系列", "ButtonSeries": "系列",
@ -190,6 +192,7 @@
"HeaderSettingsExperimental": "实验功能", "HeaderSettingsExperimental": "实验功能",
"HeaderSettingsGeneral": "通用", "HeaderSettingsGeneral": "通用",
"HeaderSettingsScanner": "扫描", "HeaderSettingsScanner": "扫描",
"HeaderSettingsWebClient": "网页客户端",
"HeaderSleepTimer": "睡眠计时", "HeaderSleepTimer": "睡眠计时",
"HeaderStatsLargestItems": "最大的项目", "HeaderStatsLargestItems": "最大的项目",
"HeaderStatsLongestItems": "项目时长(小时)", "HeaderStatsLongestItems": "项目时长(小时)",
@ -542,6 +545,7 @@
"LabelServerYearReview": "服务器年度回顾 ({0})", "LabelServerYearReview": "服务器年度回顾 ({0})",
"LabelSetEbookAsPrimary": "设置为主", "LabelSetEbookAsPrimary": "设置为主",
"LabelSetEbookAsSupplementary": "设置为补充", "LabelSetEbookAsSupplementary": "设置为补充",
"LabelSettingsAllowIframe": "允许嵌入到 iframe 中",
"LabelSettingsAudiobooksOnly": "只有有声读物", "LabelSettingsAudiobooksOnly": "只有有声读物",
"LabelSettingsAudiobooksOnlyHelp": "启用此设置将忽略电子书文件, 除非它们位于有声读物文件夹中, 在这种情况下, 它们将被设置为补充电子书", "LabelSettingsAudiobooksOnlyHelp": "启用此设置将忽略电子书文件, 除非它们位于有声读物文件夹中, 在这种情况下, 它们将被设置为补充电子书",
"LabelSettingsBookshelfViewHelp": "带有木架子的拟物化设计", "LabelSettingsBookshelfViewHelp": "带有木架子的拟物化设计",
@ -592,6 +596,8 @@
"LabelSize": "文件大小", "LabelSize": "文件大小",
"LabelSleepTimer": "睡眠定时", "LabelSleepTimer": "睡眠定时",
"LabelSlug": "Slug", "LabelSlug": "Slug",
"LabelSortAscending": "升序",
"LabelSortDescending": "降序",
"LabelStart": "开始", "LabelStart": "开始",
"LabelStartTime": "开始时间", "LabelStartTime": "开始时间",
"LabelStarted": "开始于", "LabelStarted": "开始于",
@ -765,7 +771,6 @@
"MessageItemsSelected": "已选定 {0} 个项目", "MessageItemsSelected": "已选定 {0} 个项目",
"MessageItemsUpdated": "已更新 {0} 个项目", "MessageItemsUpdated": "已更新 {0} 个项目",
"MessageJoinUsOn": "加入我们", "MessageJoinUsOn": "加入我们",
"MessageListeningSessionsInTheLastYear": "去年收听 {0} 个会话",
"MessageLoading": "正在加载...", "MessageLoading": "正在加载...",
"MessageLoadingFolders": "加载文件夹...", "MessageLoadingFolders": "加载文件夹...",
"MessageLogsDescription": "日志以 JSON 文件形式存储在 <code>/metadata/logs</code> 目录中. 崩溃日志存储在 <code>/metadata/logs/crash_logs.txt</code> 目录中.", "MessageLogsDescription": "日志以 JSON 文件形式存储在 <code>/metadata/logs</code> 目录中. 崩溃日志存储在 <code>/metadata/logs/crash_logs.txt</code> 目录中.",
@ -953,8 +958,6 @@
"ToastChaptersRemoved": "已删除章节", "ToastChaptersRemoved": "已删除章节",
"ToastChaptersUpdated": "章节已更新", "ToastChaptersUpdated": "章节已更新",
"ToastCollectionItemsAddFailed": "项目添加到收藏夹失败", "ToastCollectionItemsAddFailed": "项目添加到收藏夹失败",
"ToastCollectionItemsAddSuccess": "项目添加到收藏夹成功",
"ToastCollectionItemsRemoveSuccess": "项目从收藏夹移除",
"ToastCollectionRemoveSuccess": "收藏夹已删除", "ToastCollectionRemoveSuccess": "收藏夹已删除",
"ToastCollectionUpdateSuccess": "收藏夹已更新", "ToastCollectionUpdateSuccess": "收藏夹已更新",
"ToastCoverUpdateFailed": "封面更新失败", "ToastCoverUpdateFailed": "封面更新失败",

View File

@ -625,7 +625,6 @@
"MessageItemsSelected": "已選定 {0} 個項目", "MessageItemsSelected": "已選定 {0} 個項目",
"MessageItemsUpdated": "已更新 {0} 個項目", "MessageItemsUpdated": "已更新 {0} 個項目",
"MessageJoinUsOn": "加入我們", "MessageJoinUsOn": "加入我們",
"MessageListeningSessionsInTheLastYear": "去年收聽 {0} 個會話",
"MessageLoading": "讀取...", "MessageLoading": "讀取...",
"MessageLoadingFolders": "讀取資料夾...", "MessageLoadingFolders": "讀取資料夾...",
"MessageM4BFailed": "M4B 失敗!", "MessageM4BFailed": "M4B 失敗!",
@ -727,7 +726,6 @@
"ToastBookmarkUpdateSuccess": "書籤已更新", "ToastBookmarkUpdateSuccess": "書籤已更新",
"ToastChaptersHaveErrors": "章節有錯誤", "ToastChaptersHaveErrors": "章節有錯誤",
"ToastChaptersMustHaveTitles": "章節必須有標題", "ToastChaptersMustHaveTitles": "章節必須有標題",
"ToastCollectionItemsRemoveSuccess": "項目從收藏夾移除",
"ToastCollectionRemoveSuccess": "收藏夾已刪除", "ToastCollectionRemoveSuccess": "收藏夾已刪除",
"ToastCollectionUpdateSuccess": "收藏夾已更新", "ToastCollectionUpdateSuccess": "收藏夾已更新",
"ToastItemCoverUpdateSuccess": "項目封面已更新", "ToastItemCoverUpdateSuccess": "項目封面已更新",

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.17.4", "version": "2.17.7",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.17.4", "version": "2.17.7",
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"axios": "^0.27.2", "axios": "^0.27.2",

View File

@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.17.4", "version": "2.17.7",
"buildNumber": 1, "buildNumber": 1,
"description": "Self-hosted audiobook and podcast server", "description": "Self-hosted audiobook and podcast server",
"main": "index.js", "main": "index.js",

View File

@ -401,75 +401,6 @@ class Database {
return this.models.setting.updateSettingObj(settings.toJSON()) return this.models.setting.updateSettingObj(settings.toJSON())
} }
updateBulkBooks(oldBooks) {
if (!this.sequelize) return false
return Promise.all(oldBooks.map((oldBook) => this.models.book.saveFromOld(oldBook)))
}
createBulkCollectionBooks(collectionBooks) {
if (!this.sequelize) return false
return this.models.collectionBook.bulkCreate(collectionBooks)
}
createPlaylistMediaItem(playlistMediaItem) {
if (!this.sequelize) return false
return this.models.playlistMediaItem.create(playlistMediaItem)
}
createBulkPlaylistMediaItems(playlistMediaItems) {
if (!this.sequelize) return false
return this.models.playlistMediaItem.bulkCreate(playlistMediaItems)
}
async createLibraryItem(oldLibraryItem) {
if (!this.sequelize) return false
await oldLibraryItem.saveMetadata()
await this.models.libraryItem.fullCreateFromOld(oldLibraryItem)
}
/**
* Save metadata file and update library item
*
* @param {import('./objects/LibraryItem')} oldLibraryItem
* @returns {Promise<boolean>}
*/
async updateLibraryItem(oldLibraryItem) {
if (!this.sequelize) return false
await oldLibraryItem.saveMetadata()
const updated = await this.models.libraryItem.fullUpdateFromOld(oldLibraryItem)
// Clear library filter data cache
if (updated) {
delete this.libraryFilterData[oldLibraryItem.libraryId]
}
return updated
}
async createFeed(oldFeed) {
if (!this.sequelize) return false
await this.models.feed.fullCreateFromOld(oldFeed)
}
updateFeed(oldFeed) {
if (!this.sequelize) return false
return this.models.feed.fullUpdateFromOld(oldFeed)
}
async removeFeed(feedId) {
if (!this.sequelize) return false
await this.models.feed.removeById(feedId)
}
async createBulkBookAuthors(bookAuthors) {
if (!this.sequelize) return false
await this.models.bookAuthor.bulkCreate(bookAuthors)
}
async removeBulkBookAuthors(authorId = null, bookId = null) {
if (!this.sequelize) return false
if (!authorId && !bookId) return
await this.models.bookAuthor.removeByIds(authorId, bookId)
}
getPlaybackSessions(where = null) { getPlaybackSessions(where = null) {
if (!this.sequelize) return false if (!this.sequelize) return false
return this.models.playbackSession.getOldPlaybackSessions(where) return this.models.playbackSession.getOldPlaybackSessions(where)
@ -695,7 +626,7 @@ class Database {
/** /**
* Clean invalid records in database * Clean invalid records in database
* Series should have atleast one Book * Series should have atleast one Book
* Book and Podcast must have an associated LibraryItem * Book and Podcast must have an associated LibraryItem (and vice versa)
* Remove playback sessions that are 3 seconds or less * Remove playback sessions that are 3 seconds or less
*/ */
async cleanDatabase() { async cleanDatabase() {
@ -725,6 +656,49 @@ class Database {
await book.destroy() await book.destroy()
} }
// Remove invalid LibraryItem records
const libraryItemsWithNoMedia = await this.libraryItemModel.findAll({
include: [
{
model: this.bookModel,
attributes: ['id']
},
{
model: this.podcastModel,
attributes: ['id']
}
],
where: {
'$book.id$': null,
'$podcast.id$': null
}
})
for (const libraryItem of libraryItemsWithNoMedia) {
Logger.warn(`Found libraryItem "${libraryItem.id}" with no media - removing it`)
await libraryItem.destroy()
}
const playlistMediaItemsWithNoMediaItem = await this.playlistMediaItemModel.findAll({
include: [
{
model: this.bookModel,
attributes: ['id']
},
{
model: this.podcastEpisodeModel,
attributes: ['id']
}
],
where: {
'$book.id$': null,
'$podcastEpisode.id$': null
}
})
for (const playlistMediaItem of playlistMediaItemsWithNoMediaItem) {
Logger.warn(`Found playlistMediaItem with no book or podcastEpisode - removing it`)
await playlistMediaItem.destroy()
}
// Remove empty series // Remove empty series
const emptySeries = await this.seriesModel.findAll({ const emptySeries = await this.seriesModel.findAll({
include: { include: {

View File

@ -6,6 +6,7 @@ const util = require('util')
const fs = require('./libs/fsExtra') const fs = require('./libs/fsExtra')
const fileUpload = require('./libs/expressFileupload') const fileUpload = require('./libs/expressFileupload')
const cookieParser = require('cookie-parser') const cookieParser = require('cookie-parser')
const axios = require('axios')
const { version } = require('../package.json') const { version } = require('../package.json')
@ -53,8 +54,36 @@ class Server {
global.RouterBasePath = ROUTER_BASE_PATH global.RouterBasePath = ROUTER_BASE_PATH
global.XAccel = process.env.USE_X_ACCEL global.XAccel = process.env.USE_X_ACCEL
global.AllowCors = process.env.ALLOW_CORS === '1' global.AllowCors = process.env.ALLOW_CORS === '1'
global.AllowIframe = process.env.ALLOW_IFRAME === '1'
global.DisableSsrfRequestFilter = process.env.DISABLE_SSRF_REQUEST_FILTER === '1' if (process.env.EXP_PROXY_SUPPORT === '1') {
// https://github.com/advplyr/audiobookshelf/pull/3754
Logger.info(`[Server] Experimental Proxy Support Enabled, SSRF Request Filter was Disabled`)
global.DisableSsrfRequestFilter = () => true
axios.defaults.maxRedirects = 0
axios.interceptors.response.use(
(response) => response,
(error) => {
if ([301, 302].includes(error.response?.status)) {
return axios({
...error.config,
url: error.response.headers.location
})
}
return Promise.reject(error)
}
)
} else if (process.env.DISABLE_SSRF_REQUEST_FILTER === '1') {
Logger.info(`[Server] SSRF Request Filter Disabled`)
global.DisableSsrfRequestFilter = () => true
} else if (process.env.SSRF_REQUEST_FILTER_WHITELIST?.length) {
const whitelistedUrls = process.env.SSRF_REQUEST_FILTER_WHITELIST.split(',').map((url) => url.trim())
if (whitelistedUrls.length) {
Logger.info(`[Server] SSRF Request Filter Whitelisting: ${whitelistedUrls.join(',')}`)
global.DisableSsrfRequestFilter = (url) => whitelistedUrls.includes(new URL(url).hostname)
}
}
if (!fs.pathExistsSync(global.ConfigPath)) { if (!fs.pathExistsSync(global.ConfigPath)) {
fs.mkdirSync(global.ConfigPath) fs.mkdirSync(global.ConfigPath)
@ -72,7 +101,6 @@ class Server {
this.playbackSessionManager = new PlaybackSessionManager() this.playbackSessionManager = new PlaybackSessionManager()
this.podcastManager = new PodcastManager() this.podcastManager = new PodcastManager()
this.audioMetadataManager = new AudioMetadataMangaer() this.audioMetadataManager = new AudioMetadataMangaer()
this.rssFeedManager = new RssFeedManager()
this.cronManager = new CronManager(this.podcastManager, this.playbackSessionManager) this.cronManager = new CronManager(this.podcastManager, this.playbackSessionManager)
this.apiCacheManager = new ApiCacheManager() this.apiCacheManager = new ApiCacheManager()
this.binaryManager = new BinaryManager() this.binaryManager = new BinaryManager()
@ -138,7 +166,7 @@ class Server {
await ShareManager.init() await ShareManager.init()
await this.backupManager.init() await this.backupManager.init()
await this.rssFeedManager.init() await RssFeedManager.init()
const libraries = await Database.libraryModel.getAllWithFolders() const libraries = await Database.libraryModel.getAllWithFolders()
await this.cronManager.init(libraries) await this.cronManager.init(libraries)
@ -195,7 +223,7 @@ class Server {
const app = express() const app = express()
app.use((req, res, next) => { app.use((req, res, next) => {
if (!global.AllowIframe) { if (!global.ServerSettings.allowIframe) {
// Prevent clickjacking by disallowing iframes // Prevent clickjacking by disallowing iframes
res.setHeader('Content-Security-Policy', "frame-ancestors 'self'") res.setHeader('Content-Security-Policy', "frame-ancestors 'self'")
} }
@ -251,14 +279,17 @@ class Server {
const router = express.Router() const router = express.Router()
// if RouterBasePath is set, modify all requests to include the base path // if RouterBasePath is set, modify all requests to include the base path
if (global.RouterBasePath) { app.use((req, res, next) => {
app.use((req, res, next) => { const urlStartsWithRouterBasePath = req.url.startsWith(global.RouterBasePath)
if (!req.url.startsWith(global.RouterBasePath)) { const host = req.get('host')
req.url = `${global.RouterBasePath}${req.url}` const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
} const prefix = urlStartsWithRouterBasePath ? global.RouterBasePath : ''
next() req.originalHostPrefix = `${protocol}://${host}${prefix}`
}) if (!urlStartsWithRouterBasePath) {
} req.url = `${global.RouterBasePath}${req.url}`
}
next()
})
app.use(global.RouterBasePath, router) app.use(global.RouterBasePath, router)
app.disable('x-powered-by') app.disable('x-powered-by')
@ -289,14 +320,14 @@ class Server {
// RSS Feed temp route // RSS Feed temp route
router.get('/feed/:slug', (req, res) => { router.get('/feed/:slug', (req, res) => {
Logger.info(`[Server] Requesting rss feed ${req.params.slug}`) Logger.info(`[Server] Requesting rss feed ${req.params.slug}`)
this.rssFeedManager.getFeed(req, res) RssFeedManager.getFeed(req, res)
}) })
router.get('/feed/:slug/cover*', (req, res) => { router.get('/feed/:slug/cover*', (req, res) => {
this.rssFeedManager.getFeedCover(req, res) RssFeedManager.getFeedCover(req, res)
}) })
router.get('/feed/:slug/item/:episodeId/*', (req, res) => { router.get('/feed/:slug/item/:episodeId/*', (req, res) => {
Logger.debug(`[Server] Requesting rss feed episode ${req.params.slug}/${req.params.episodeId}`) Logger.debug(`[Server] Requesting rss feed episode ${req.params.slug}/${req.params.episodeId}`)
this.rssFeedManager.getFeedItem(req, res) RssFeedManager.getFeedItem(req, res)
}) })
// Auth routes // Auth routes

View File

@ -190,7 +190,9 @@ class FolderWatcher extends EventEmitter {
return return
} }
Logger.debug('[Watcher] File Added', path) Logger.debug('[Watcher] File Added', path)
this.addFileUpdate(libraryId, path, 'added') if (!this.addFileUpdate(libraryId, path, 'added')) {
return
}
if (!this.filesBeingAdded.has(path)) { if (!this.filesBeingAdded.has(path)) {
this.filesBeingAdded.add(path) this.filesBeingAdded.add(path)
@ -261,22 +263,23 @@ class FolderWatcher extends EventEmitter {
* @param {string} libraryId * @param {string} libraryId
* @param {string} path * @param {string} path
* @param {string} type * @param {string} type
* @returns {boolean} - If file was added to pending updates
*/ */
addFileUpdate(libraryId, path, type) { addFileUpdate(libraryId, path, type) {
if (this.pendingFilePaths.includes(path)) return if (this.pendingFilePaths.includes(path)) return false
// Get file library // Get file library
const libwatcher = this.libraryWatchers.find((lw) => lw.id === libraryId) const libwatcher = this.libraryWatchers.find((lw) => lw.id === libraryId)
if (!libwatcher) { if (!libwatcher) {
Logger.error(`[Watcher] Invalid library id from watcher ${libraryId}`) Logger.error(`[Watcher] Invalid library id from watcher ${libraryId}`)
return return false
} }
// Get file folder // Get file folder
const folder = libwatcher.libraryFolders.find((fold) => isSameOrSubPath(fold.path, path)) const folder = libwatcher.libraryFolders.find((fold) => isSameOrSubPath(fold.path, path))
if (!folder) { if (!folder) {
Logger.error(`[Watcher] New file folder not found in library "${libwatcher.name}" with path "${path}"`) Logger.error(`[Watcher] New file folder not found in library "${libwatcher.name}" with path "${path}"`)
return return false
} }
const folderPath = filePathToPOSIX(folder.path) const folderPath = filePathToPOSIX(folder.path)
@ -285,14 +288,14 @@ class FolderWatcher extends EventEmitter {
if (Path.extname(relPath).toLowerCase() === '.part') { if (Path.extname(relPath).toLowerCase() === '.part') {
Logger.debug(`[Watcher] Ignoring .part file "${relPath}"`) Logger.debug(`[Watcher] Ignoring .part file "${relPath}"`)
return return false
} }
// Ignore files/folders starting with "." // Ignore files/folders starting with "."
const hasDotPath = relPath.split('/').find((p) => p.startsWith('.')) const hasDotPath = relPath.split('/').find((p) => p.startsWith('.'))
if (hasDotPath) { if (hasDotPath) {
Logger.debug(`[Watcher] Ignoring dot path "${relPath}" | Piece "${hasDotPath}"`) Logger.debug(`[Watcher] Ignoring dot path "${relPath}" | Piece "${hasDotPath}"`)
return return false
} }
Logger.debug(`[Watcher] Modified file in library "${libwatcher.name}" and folder "${folder.id}" with relPath "${relPath}"`) Logger.debug(`[Watcher] Modified file in library "${libwatcher.name}" and folder "${folder.id}" with relPath "${relPath}"`)
@ -318,6 +321,7 @@ class FolderWatcher extends EventEmitter {
}) })
this.handlePendingFileUpdatesTimeout() this.handlePendingFileUpdatesTimeout()
return true
} }
/** /**

View File

@ -44,16 +44,21 @@ class AuthorController {
// Used on author landing page to include library items and items grouped in series // Used on author landing page to include library items and items grouped in series
if (include.includes('items')) { if (include.includes('items')) {
authorJson.libraryItems = await Database.libraryItemModel.getForAuthor(req.author, req.user) const libraryItems = await Database.libraryItemModel.getForAuthor(req.author, req.user)
if (include.includes('series')) { if (include.includes('series')) {
const seriesMap = {} const seriesMap = {}
// Group items into series // Group items into series
authorJson.libraryItems.forEach((li) => { libraryItems.forEach((li) => {
if (li.media.metadata.series) { if (li.media.series?.length) {
li.media.metadata.series.forEach((series) => { li.media.series.forEach((series) => {
const itemWithSeries = li.toJSONMinified() const itemWithSeries = li.toOldJSONMinified()
itemWithSeries.media.metadata.series = series itemWithSeries.media.metadata.series = {
id: series.id,
name: series.name,
nameIgnorePrefix: series.nameIgnorePrefix,
sequence: series.bookSeries.sequence
}
if (seriesMap[series.id]) { if (seriesMap[series.id]) {
seriesMap[series.id].items.push(itemWithSeries) seriesMap[series.id].items.push(itemWithSeries)
@ -76,7 +81,7 @@ class AuthorController {
} }
// Minify library items // Minify library items
authorJson.libraryItems = authorJson.libraryItems.map((li) => li.toJSONMinified()) authorJson.libraryItems = libraryItems.map((li) => li.toOldJSONMinified())
} }
return res.json(authorJson) return res.json(authorJson)
@ -125,7 +130,7 @@ class AuthorController {
const bookAuthorsToCreate = [] const bookAuthorsToCreate = []
const allItemsWithAuthor = await Database.authorModel.getAllLibraryItemsForAuthor(req.author.id) const allItemsWithAuthor = await Database.authorModel.getAllLibraryItemsForAuthor(req.author.id)
const oldLibraryItems = [] const libraryItems = []
allItemsWithAuthor.forEach((libraryItem) => { allItemsWithAuthor.forEach((libraryItem) => {
// Replace old author with merging author for each book // Replace old author with merging author for each book
libraryItem.media.authors = libraryItem.media.authors.filter((au) => au.id !== req.author.id) libraryItem.media.authors = libraryItem.media.authors.filter((au) => au.id !== req.author.id)
@ -134,23 +139,22 @@ class AuthorController {
name: existingAuthor.name name: existingAuthor.name
}) })
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem) libraryItems.push(libraryItem)
oldLibraryItems.push(oldLibraryItem)
bookAuthorsToCreate.push({ bookAuthorsToCreate.push({
bookId: libraryItem.media.id, bookId: libraryItem.media.id,
authorId: existingAuthor.id authorId: existingAuthor.id
}) })
}) })
if (oldLibraryItems.length) { if (libraryItems.length) {
await Database.removeBulkBookAuthors(req.author.id) // Remove all old BookAuthor await Database.bookAuthorModel.removeByIds(req.author.id) // Remove all old BookAuthor
await Database.createBulkBookAuthors(bookAuthorsToCreate) // Create all new BookAuthor await Database.bookAuthorModel.bulkCreate(bookAuthorsToCreate) // Create all new BookAuthor
for (const libraryItem of allItemsWithAuthor) { for (const libraryItem of libraryItems) {
await libraryItem.saveMetadataFile() await libraryItem.saveMetadataFile()
} }
SocketAuthority.emitter( SocketAuthority.emitter(
'items_updated', 'items_updated',
oldLibraryItems.map((li) => li.toJSONExpanded()) libraryItems.map((li) => li.toOldJSONExpanded())
) )
} }
@ -190,7 +194,7 @@ class AuthorController {
const allItemsWithAuthor = await Database.authorModel.getAllLibraryItemsForAuthor(req.author.id) const allItemsWithAuthor = await Database.authorModel.getAllLibraryItemsForAuthor(req.author.id)
numBooksForAuthor = allItemsWithAuthor.length numBooksForAuthor = allItemsWithAuthor.length
const oldLibraryItems = [] const libraryItems = []
// Update author name on all books // Update author name on all books
for (const libraryItem of allItemsWithAuthor) { for (const libraryItem of allItemsWithAuthor) {
libraryItem.media.authors = libraryItem.media.authors.map((au) => { libraryItem.media.authors = libraryItem.media.authors.map((au) => {
@ -199,16 +203,16 @@ class AuthorController {
} }
return au return au
}) })
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
oldLibraryItems.push(oldLibraryItem) libraryItems.push(libraryItem)
await libraryItem.saveMetadataFile() await libraryItem.saveMetadataFile()
} }
if (oldLibraryItems.length) { if (libraryItems.length) {
SocketAuthority.emitter( SocketAuthority.emitter(
'items_updated', 'items_updated',
oldLibraryItems.map((li) => li.toJSONExpanded()) libraryItems.map((li) => li.toOldJSONExpanded())
) )
} }
} else { } else {
@ -238,8 +242,18 @@ class AuthorController {
await CacheManager.purgeImageCache(req.author.id) // Purge cache await CacheManager.purgeImageCache(req.author.id) // Purge cache
} }
// Load library items so that metadata file can be updated
const allItemsWithAuthor = await Database.authorModel.getAllLibraryItemsForAuthor(req.author.id)
allItemsWithAuthor.forEach((libraryItem) => {
libraryItem.media.authors = libraryItem.media.authors.filter((au) => au.id !== req.author.id)
})
await req.author.destroy() await req.author.destroy()
for (const libraryItem of allItemsWithAuthor) {
await libraryItem.saveMetadataFile()
}
SocketAuthority.emitter('author_removed', req.author.toOldJSON()) SocketAuthority.emitter('author_removed', req.author.toOldJSON())
// Update filter data // Update filter data

View File

@ -4,13 +4,18 @@ const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority') const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database') const Database = require('../Database')
const Collection = require('../objects/Collection') const RssFeedManager = require('../managers/RssFeedManager')
/** /**
* @typedef RequestUserObject * @typedef RequestUserObject
* @property {import('../models/User')} user * @property {import('../models/User')} user
* *
* @typedef {Request & RequestUserObject} RequestWithUser * @typedef {Request & RequestUserObject} RequestWithUser
*
* @typedef RequestEntityObject
* @property {import('../models/Collection')} collection
*
* @typedef {RequestWithUser & RequestEntityObject} CollectionControllerRequest
*/ */
class CollectionController { class CollectionController {
@ -24,36 +29,71 @@ class CollectionController {
* @param {Response} res * @param {Response} res
*/ */
async create(req, res) { async create(req, res) {
const newCollection = new Collection() const reqBody = req.body || {}
req.body.userId = req.user.id
if (!newCollection.setData(req.body)) { // Validation
if (!reqBody.name || !reqBody.libraryId) {
return res.status(400).send('Invalid collection data') return res.status(400).send('Invalid collection data')
} }
if (reqBody.description && typeof reqBody.description !== 'string') {
return res.status(400).send('Invalid collection description')
}
const libraryItemIds = (reqBody.books || []).filter((b) => !!b && typeof b == 'string')
if (!libraryItemIds.length) {
return res.status(400).send('Invalid collection data. No books')
}
// Create collection record // Load library items
await Database.collectionModel.createFromOld(newCollection) const libraryItems = await Database.libraryItemModel.findAll({
attributes: ['id', 'mediaId', 'mediaType', 'libraryId'],
// Get library items in collection where: {
const libraryItemsInCollection = await Database.libraryItemModel.getForCollection(newCollection) id: libraryItemIds,
libraryId: reqBody.libraryId,
// Create collectionBook records mediaType: 'book'
let order = 1
const collectionBooksToAdd = []
for (const libraryItemId of newCollection.books) {
const libraryItem = libraryItemsInCollection.find((li) => li.id === libraryItemId)
if (libraryItem) {
collectionBooksToAdd.push({
collectionId: newCollection.id,
bookId: libraryItem.media.id,
order: order++
})
} }
} })
if (collectionBooksToAdd.length) { if (libraryItems.length !== libraryItemIds.length) {
await Database.createBulkCollectionBooks(collectionBooksToAdd) return res.status(400).send('Invalid collection data. Invalid books')
} }
const jsonExpanded = newCollection.toJSONExpanded(libraryItemsInCollection) /** @type {import('../models/Collection')} */
let newCollection = null
const transaction = await Database.sequelize.transaction()
try {
// Create collection
newCollection = await Database.collectionModel.create(
{
libraryId: reqBody.libraryId,
name: reqBody.name,
description: reqBody.description || null
},
{ transaction }
)
// Create collectionBooks
const collectionBookPayloads = libraryItemIds.map((llid, index) => {
const libraryItem = libraryItems.find((li) => li.id === llid)
return {
collectionId: newCollection.id,
bookId: libraryItem.mediaId,
order: index + 1
}
})
await Database.collectionBookModel.bulkCreate(collectionBookPayloads, { transaction })
await transaction.commit()
} catch (error) {
await transaction.rollback()
Logger.error('[CollectionController] create:', error)
return res.status(500).send('Failed to create collection')
}
// Load books expanded
newCollection.books = await newCollection.getBooksExpandedWithLibraryItem()
// Note: The old collection model stores expanded libraryItems in the books property
const jsonExpanded = newCollection.toOldJSONExpanded()
SocketAuthority.emitter('collection_added', jsonExpanded) SocketAuthority.emitter('collection_added', jsonExpanded)
res.json(jsonExpanded) res.json(jsonExpanded)
} }
@ -74,7 +114,7 @@ class CollectionController {
/** /**
* GET: /api/collections/:id * GET: /api/collections/:id
* *
* @param {RequestWithUser} req * @param {CollectionControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async findOne(req, res) { async findOne(req, res) {
@ -93,7 +133,7 @@ class CollectionController {
* PATCH: /api/collections/:id * PATCH: /api/collections/:id
* Update collection * Update collection
* *
* @param {RequestWithUser} req * @param {CollectionControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async update(req, res) { async update(req, res) {
@ -115,6 +155,7 @@ class CollectionController {
} }
// If books array is passed in then update order in collection // If books array is passed in then update order in collection
let collectionBooksUpdated = false
if (req.body.books?.length) { if (req.body.books?.length) {
const collectionBooks = await req.collection.getCollectionBooks({ const collectionBooks = await req.collection.getCollectionBooks({
include: { include: {
@ -133,9 +174,15 @@ class CollectionController {
await collectionBooks[i].update({ await collectionBooks[i].update({
order: i + 1 order: i + 1
}) })
wasUpdated = true collectionBooksUpdated = true
} }
} }
if (collectionBooksUpdated) {
req.collection.changed('updatedAt', true)
await req.collection.save()
wasUpdated = true
}
} }
const jsonExpanded = await req.collection.getOldJsonExpanded() const jsonExpanded = await req.collection.getOldJsonExpanded()
@ -148,14 +195,16 @@ class CollectionController {
/** /**
* DELETE: /api/collections/:id * DELETE: /api/collections/:id
* *
* @param {RequestWithUser} req * @this {import('../routers/ApiRouter')}
*
* @param {CollectionControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async delete(req, res) { async delete(req, res) {
const jsonExpanded = await req.collection.getOldJsonExpanded() const jsonExpanded = await req.collection.getOldJsonExpanded()
// Close rss feed - remove from db and emit socket event // Close rss feed - remove from db and emit socket event
await this.rssFeedManager.closeFeedForEntityId(req.collection.id) await RssFeedManager.closeFeedForEntityId(req.collection.id)
await req.collection.destroy() await req.collection.destroy()
@ -168,11 +217,13 @@ class CollectionController {
* Add a single book to a collection * Add a single book to a collection
* Req.body { id: <library item id> } * Req.body { id: <library item id> }
* *
* @param {RequestWithUser} req * @param {CollectionControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async addBook(req, res) { async addBook(req, res) {
const libraryItem = await Database.libraryItemModel.getOldById(req.body.id) const libraryItem = await Database.libraryItemModel.findByPk(req.body.id, {
attributes: ['libraryId', 'mediaId']
})
if (!libraryItem) { if (!libraryItem) {
return res.status(404).send('Book not found') return res.status(404).send('Book not found')
} }
@ -182,14 +233,14 @@ class CollectionController {
// Check if book is already in collection // Check if book is already in collection
const collectionBooks = await req.collection.getCollectionBooks() const collectionBooks = await req.collection.getCollectionBooks()
if (collectionBooks.some((cb) => cb.bookId === libraryItem.media.id)) { if (collectionBooks.some((cb) => cb.bookId === libraryItem.mediaId)) {
return res.status(400).send('Book already in collection') return res.status(400).send('Book already in collection')
} }
// Create collectionBook record // Create collectionBook record
await Database.collectionBookModel.create({ await Database.collectionBookModel.create({
collectionId: req.collection.id, collectionId: req.collection.id,
bookId: libraryItem.media.id, bookId: libraryItem.mediaId,
order: collectionBooks.length + 1 order: collectionBooks.length + 1
}) })
const jsonExpanded = await req.collection.getOldJsonExpanded() const jsonExpanded = await req.collection.getOldJsonExpanded()
@ -202,11 +253,13 @@ class CollectionController {
* Remove a single book from a collection. Re-order books * Remove a single book from a collection. Re-order books
* TODO: bookId is actually libraryItemId. Clients need updating to use bookId * TODO: bookId is actually libraryItemId. Clients need updating to use bookId
* *
* @param {RequestWithUser} req * @param {CollectionControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async removeBook(req, res) { async removeBook(req, res) {
const libraryItem = await Database.libraryItemModel.getOldById(req.params.bookId) const libraryItem = await Database.libraryItemModel.findByPk(req.params.bookId, {
attributes: ['mediaId']
})
if (!libraryItem) { if (!libraryItem) {
return res.sendStatus(404) return res.sendStatus(404)
} }
@ -217,7 +270,7 @@ class CollectionController {
}) })
let jsonExpanded = null let jsonExpanded = null
const collectionBookToRemove = collectionBooks.find((cb) => cb.bookId === libraryItem.media.id) const collectionBookToRemove = collectionBooks.find((cb) => cb.bookId === libraryItem.mediaId)
if (collectionBookToRemove) { if (collectionBookToRemove) {
// Remove collection book record // Remove collection book record
await collectionBookToRemove.destroy() await collectionBookToRemove.destroy()
@ -225,7 +278,7 @@ class CollectionController {
// Update order on collection books // Update order on collection books
let order = 1 let order = 1
for (const collectionBook of collectionBooks) { for (const collectionBook of collectionBooks) {
if (collectionBook.bookId === libraryItem.media.id) continue if (collectionBook.bookId === libraryItem.mediaId) continue
if (collectionBook.order !== order) { if (collectionBook.order !== order) {
await collectionBook.update({ await collectionBook.update({
order order
@ -247,29 +300,31 @@ class CollectionController {
* Add multiple books to collection * Add multiple books to collection
* Req.body { books: <Array of library item ids> } * Req.body { books: <Array of library item ids> }
* *
* @param {RequestWithUser} req * @param {CollectionControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async addBatch(req, res) { async addBatch(req, res) {
// filter out invalid libraryItemIds // filter out invalid libraryItemIds
const bookIdsToAdd = (req.body.books || []).filter((b) => !!b && typeof b == 'string') const bookIdsToAdd = (req.body.books || []).filter((b) => !!b && typeof b == 'string')
if (!bookIdsToAdd.length) { if (!bookIdsToAdd.length) {
return res.status(500).send('Invalid request body') return res.status(400).send('Invalid request body')
} }
// Get library items associated with ids // Get library items associated with ids
const libraryItems = await Database.libraryItemModel.findAll({ const libraryItems = await Database.libraryItemModel.findAll({
attributes: ['id', 'mediaId', 'mediaType', 'libraryId'],
where: { where: {
id: { id: bookIdsToAdd,
[Sequelize.Op.in]: bookIdsToAdd libraryId: req.collection.libraryId,
} mediaType: 'book'
},
include: {
model: Database.bookModel
} }
}) })
if (!libraryItems.length) {
return res.status(400).send('Invalid request body. No valid books')
}
// Get collection books already in collection // Get collection books already in collection
/** @type {import('../models/CollectionBook')[]} */
const collectionBooks = await req.collection.getCollectionBooks() const collectionBooks = await req.collection.getCollectionBooks()
let order = collectionBooks.length + 1 let order = collectionBooks.length + 1
@ -278,10 +333,10 @@ class CollectionController {
// Check and set new collection books to add // Check and set new collection books to add
for (const libraryItem of libraryItems) { for (const libraryItem of libraryItems) {
if (!collectionBooks.some((cb) => cb.bookId === libraryItem.media.id)) { if (!collectionBooks.some((cb) => cb.bookId === libraryItem.mediaId)) {
collectionBooksToAdd.push({ collectionBooksToAdd.push({
collectionId: req.collection.id, collectionId: req.collection.id,
bookId: libraryItem.media.id, bookId: libraryItem.mediaId,
order: order++ order: order++
}) })
hasUpdated = true hasUpdated = true
@ -292,7 +347,8 @@ class CollectionController {
let jsonExpanded = null let jsonExpanded = null
if (hasUpdated) { if (hasUpdated) {
await Database.createBulkCollectionBooks(collectionBooksToAdd) await Database.collectionBookModel.bulkCreate(collectionBooksToAdd)
jsonExpanded = await req.collection.getOldJsonExpanded() jsonExpanded = await req.collection.getOldJsonExpanded()
SocketAuthority.emitter('collection_updated', jsonExpanded) SocketAuthority.emitter('collection_updated', jsonExpanded)
} else { } else {
@ -306,7 +362,7 @@ class CollectionController {
* Remove multiple books from collection * Remove multiple books from collection
* Req.body { books: <Array of library item ids> } * Req.body { books: <Array of library item ids> }
* *
* @param {RequestWithUser} req * @param {CollectionControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async removeBatch(req, res) { async removeBatch(req, res) {
@ -319,9 +375,7 @@ class CollectionController {
// Get library items associated with ids // Get library items associated with ids
const libraryItems = await Database.libraryItemModel.findAll({ const libraryItems = await Database.libraryItemModel.findAll({
where: { where: {
id: { id: bookIdsToRemove
[Sequelize.Op.in]: bookIdsToRemove
}
}, },
include: { include: {
model: Database.bookModel model: Database.bookModel
@ -329,6 +383,7 @@ class CollectionController {
}) })
// Get collection books already in collection // Get collection books already in collection
/** @type {import('../models/CollectionBook')[]} */
const collectionBooks = await req.collection.getCollectionBooks({ const collectionBooks = await req.collection.getCollectionBooks({
order: [['order', 'ASC']] order: [['order', 'ASC']]
}) })

View File

@ -106,7 +106,7 @@ class EmailController {
return res.sendStatus(403) return res.sendStatus(403)
} }
const libraryItem = await Database.libraryItemModel.getOldById(req.body.libraryItemId) const libraryItem = await Database.libraryItemModel.getExpandedById(req.body.libraryItemId)
if (!libraryItem) { if (!libraryItem) {
return res.status(404).send('Library item not found') return res.status(404).send('Library item not found')
} }

View File

@ -18,6 +18,8 @@ const LibraryScanner = require('../scanner/LibraryScanner')
const Scanner = require('../scanner/Scanner') const Scanner = require('../scanner/Scanner')
const Database = require('../Database') const Database = require('../Database')
const Watcher = require('../Watcher') const Watcher = require('../Watcher')
const RssFeedManager = require('../managers/RssFeedManager')
const libraryFilters = require('../utils/queries/libraryFilters') const libraryFilters = require('../utils/queries/libraryFilters')
const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters') const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters')
const authorFilters = require('../utils/queries/authorFilters') const authorFilters = require('../utils/queries/authorFilters')
@ -759,8 +761,8 @@ class LibraryController {
} }
if (include.includes('rssfeed')) { if (include.includes('rssfeed')) {
const feedObj = await this.rssFeedManager.findFeedForEntityId(seriesJson.id) const feedObj = await RssFeedManager.findFeedForEntityId(seriesJson.id)
seriesJson.rssFeed = feedObj?.toJSONMinified() || null seriesJson.rssFeed = feedObj?.toOldJSONMinified() || null
} }
res.json(seriesJson) res.json(seriesJson)
@ -1143,14 +1145,14 @@ class LibraryController {
await libraryItem.media.update({ await libraryItem.media.update({
narrators: libraryItem.media.narrators narrators: libraryItem.media.narrators
}) })
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
itemsUpdated.push(oldLibraryItem) itemsUpdated.push(libraryItem)
} }
if (itemsUpdated.length) { if (itemsUpdated.length) {
SocketAuthority.emitter( SocketAuthority.emitter(
'items_updated', 'items_updated',
itemsUpdated.map((li) => li.toJSONExpanded()) itemsUpdated.map((li) => li.toOldJSONExpanded())
) )
} }
@ -1187,14 +1189,14 @@ class LibraryController {
await libraryItem.media.update({ await libraryItem.media.update({
narrators: libraryItem.media.narrators narrators: libraryItem.media.narrators
}) })
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
itemsUpdated.push(oldLibraryItem) itemsUpdated.push(libraryItem)
} }
if (itemsUpdated.length) { if (itemsUpdated.length) {
SocketAuthority.emitter( SocketAuthority.emitter(
'items_updated', 'items_updated',
itemsUpdated.map((li) => li.toJSONExpanded()) itemsUpdated.map((li) => li.toOldJSONExpanded())
) )
} }
@ -1215,7 +1217,7 @@ class LibraryController {
Logger.error(`[LibraryController] Non-root user "${req.user.username}" attempted to match library items`) Logger.error(`[LibraryController] Non-root user "${req.user.username}" attempted to match library items`)
return res.sendStatus(403) return res.sendStatus(403)
} }
Scanner.matchLibraryItems(req.library) Scanner.matchLibraryItems(this, req.library)
res.sendStatus(200) res.sendStatus(200)
} }

View File

@ -13,6 +13,8 @@ const { getAudioMimeTypeFromExtname, encodeUriPath } = require('../utils/fileUti
const LibraryItemScanner = require('../scanner/LibraryItemScanner') const LibraryItemScanner = require('../scanner/LibraryItemScanner')
const AudioFileScanner = require('../scanner/AudioFileScanner') const AudioFileScanner = require('../scanner/AudioFileScanner')
const Scanner = require('../scanner/Scanner') const Scanner = require('../scanner/Scanner')
const RssFeedManager = require('../managers/RssFeedManager')
const CacheManager = require('../managers/CacheManager') const CacheManager = require('../managers/CacheManager')
const CoverManager = require('../managers/CoverManager') const CoverManager = require('../managers/CoverManager')
const ShareManager = require('../managers/ShareManager') const ShareManager = require('../managers/ShareManager')
@ -22,6 +24,16 @@ const ShareManager = require('../managers/ShareManager')
* @property {import('../models/User')} user * @property {import('../models/User')} user
* *
* @typedef {Request & RequestUserObject} RequestWithUser * @typedef {Request & RequestUserObject} RequestWithUser
*
* @typedef RequestEntityObject
* @property {import('../models/LibraryItem')} libraryItem
*
* @typedef {RequestWithUser & RequestEntityObject} LibraryItemControllerRequest
*
* @typedef RequestLibraryFileObject
* @property {import('../objects/files/LibraryFile')} libraryFile
*
* @typedef {RequestWithUser & RequestEntityObject & RequestLibraryFileObject} LibraryItemControllerRequestWithFile
*/ */
class LibraryItemController { class LibraryItemController {
@ -33,23 +45,23 @@ class LibraryItemController {
* ?include=progress,rssfeed,downloads,share * ?include=progress,rssfeed,downloads,share
* ?expanded=1 * ?expanded=1
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async findOne(req, res) { async findOne(req, res) {
const includeEntities = (req.query.include || '').split(',') const includeEntities = (req.query.include || '').split(',')
if (req.query.expanded == 1) { if (req.query.expanded == 1) {
var item = req.libraryItem.toJSONExpanded() const item = req.libraryItem.toOldJSONExpanded()
// Include users media progress // Include users media progress
if (includeEntities.includes('progress')) { if (includeEntities.includes('progress')) {
var episodeId = req.query.episode || null const episodeId = req.query.episode || null
item.userMediaProgress = req.user.getOldMediaProgress(item.id, episodeId) item.userMediaProgress = req.user.getOldMediaProgress(item.id, episodeId)
} }
if (includeEntities.includes('rssfeed')) { if (includeEntities.includes('rssfeed')) {
const feedData = await this.rssFeedManager.findFeedForEntityId(item.id) const feedData = await RssFeedManager.findFeedForEntityId(item.id)
item.rssFeed = feedData?.toJSONMinified() || null item.rssFeed = feedData?.toOldJSONMinified() || null
} }
if (item.mediaType === 'book' && req.user.isAdminOrUp && includeEntities.includes('share')) { if (item.mediaType === 'book' && req.user.isAdminOrUp && includeEntities.includes('share')) {
@ -66,28 +78,7 @@ class LibraryItemController {
return res.json(item) return res.json(item)
} }
res.json(req.libraryItem) res.json(req.libraryItem.toOldJSON())
}
/**
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async update(req, res) {
var libraryItem = req.libraryItem
// Item has cover and update is removing cover so purge it from cache
if (libraryItem.media.coverPath && req.body.media && (req.body.media.coverPath === '' || req.body.media.coverPath === null)) {
await CacheManager.purgeCoverCache(libraryItem.id)
}
const hasUpdates = libraryItem.update(req.body)
if (hasUpdates) {
Logger.debug(`[LibraryItemController] Updated now saving`)
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
}
res.json(libraryItem.toJSON())
} }
/** /**
@ -98,7 +89,7 @@ class LibraryItemController {
* *
* @this {import('../routers/ApiRouter')} * @this {import('../routers/ApiRouter')}
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async delete(req, res) { async delete(req, res) {
@ -109,14 +100,14 @@ class LibraryItemController {
const authorIds = [] const authorIds = []
const seriesIds = [] const seriesIds = []
if (req.libraryItem.isPodcast) { if (req.libraryItem.isPodcast) {
mediaItemIds.push(...req.libraryItem.media.episodes.map((ep) => ep.id)) mediaItemIds.push(...req.libraryItem.media.podcastEpisodes.map((ep) => ep.id))
} else { } else {
mediaItemIds.push(req.libraryItem.media.id) mediaItemIds.push(req.libraryItem.media.id)
if (req.libraryItem.media.metadata.authors?.length) { if (req.libraryItem.media.authors?.length) {
authorIds.push(...req.libraryItem.media.metadata.authors.map((au) => au.id)) authorIds.push(...req.libraryItem.media.authors.map((au) => au.id))
} }
if (req.libraryItem.media.metadata.series?.length) { if (req.libraryItem.media.series?.length) {
seriesIds.push(...req.libraryItem.media.metadata.series.map((se) => se.id)) seriesIds.push(...req.libraryItem.media.series.map((se) => se.id))
} }
} }
@ -153,7 +144,7 @@ class LibraryItemController {
* GET: /api/items/:id/download * GET: /api/items/:id/download
* Download library item. Zip file if multiple files. * Download library item. Zip file if multiple files.
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async download(req, res) { async download(req, res) {
@ -162,7 +153,7 @@ class LibraryItemController {
return res.sendStatus(403) return res.sendStatus(403)
} }
const libraryItemPath = req.libraryItem.path const libraryItemPath = req.libraryItem.path
const itemTitle = req.libraryItem.media.metadata.title const itemTitle = req.libraryItem.media.title
Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${itemTitle}" at "${libraryItemPath}"`) Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${itemTitle}" at "${libraryItemPath}"`)
@ -192,11 +183,10 @@ class LibraryItemController {
* *
* @this {import('../routers/ApiRouter')} * @this {import('../routers/ApiRouter')}
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async updateMedia(req, res) { async updateMedia(req, res) {
const libraryItem = req.libraryItem
const mediaPayload = req.body const mediaPayload = req.body
if (mediaPayload.url) { if (mediaPayload.url) {
@ -204,69 +194,79 @@ class LibraryItemController {
if (res.writableEnded || res.headersSent) return if (res.writableEnded || res.headersSent) return
} }
// Book specific
if (libraryItem.isBook) {
await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryItem.libraryId)
}
// Podcast specific // Podcast specific
let isPodcastAutoDownloadUpdated = false let isPodcastAutoDownloadUpdated = false
if (libraryItem.isPodcast) { if (req.libraryItem.isPodcast) {
if (mediaPayload.autoDownloadEpisodes !== undefined && libraryItem.media.autoDownloadEpisodes !== mediaPayload.autoDownloadEpisodes) { if (mediaPayload.autoDownloadEpisodes !== undefined && req.libraryItem.media.autoDownloadEpisodes !== mediaPayload.autoDownloadEpisodes) {
isPodcastAutoDownloadUpdated = true isPodcastAutoDownloadUpdated = true
} else if (mediaPayload.autoDownloadSchedule !== undefined && libraryItem.media.autoDownloadSchedule !== mediaPayload.autoDownloadSchedule) { } else if (mediaPayload.autoDownloadSchedule !== undefined && req.libraryItem.media.autoDownloadSchedule !== mediaPayload.autoDownloadSchedule) {
isPodcastAutoDownloadUpdated = true isPodcastAutoDownloadUpdated = true
} }
} }
// Book specific - Get all series being removed from this item let hasUpdates = (await req.libraryItem.media.updateFromRequest(mediaPayload)) || mediaPayload.url
let seriesRemoved = []
if (libraryItem.isBook && mediaPayload.metadata?.series) {
const seriesIdsInUpdate = mediaPayload.metadata.series?.map((se) => se.id) || []
seriesRemoved = libraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id))
}
let authorsRemoved = [] if (req.libraryItem.isBook && Array.isArray(mediaPayload.metadata?.series)) {
if (libraryItem.isBook && mediaPayload.metadata?.authors) { const seriesUpdateData = await req.libraryItem.media.updateSeriesFromRequest(mediaPayload.metadata.series, req.libraryItem.libraryId)
const authorIdsInUpdate = mediaPayload.metadata.authors.map((au) => au.id) if (seriesUpdateData?.seriesRemoved.length) {
authorsRemoved = libraryItem.media.metadata.authors.filter((au) => !authorIdsInUpdate.includes(au.id))
}
const hasUpdates = libraryItem.media.update(mediaPayload) || mediaPayload.url
if (hasUpdates) {
libraryItem.updatedAt = Date.now()
if (isPodcastAutoDownloadUpdated) {
this.cronManager.checkUpdatePodcastCron(libraryItem)
}
Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`)
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
if (authorsRemoved.length) {
// Check remove empty authors
Logger.debug(`[LibraryItemController] Authors were removed from book. Check if authors are now empty.`)
await this.checkRemoveAuthorsWithNoBooks(authorsRemoved.map((au) => au.id))
}
if (seriesRemoved.length) {
// Check remove empty series // Check remove empty series
Logger.debug(`[LibraryItemController] Series were removed from book. Check if series are now empty.`) Logger.debug(`[LibraryItemController] Series were removed from book. Check if series are now empty.`)
await this.checkRemoveEmptySeries(seriesRemoved.map((se) => se.id)) await this.checkRemoveEmptySeries(seriesUpdateData.seriesRemoved.map((se) => se.id))
} }
if (seriesUpdateData?.seriesAdded.length) {
// Add series to filter data
seriesUpdateData.seriesAdded.forEach((se) => {
Database.addSeriesToFilterData(req.libraryItem.libraryId, se.name, se.id)
})
}
if (seriesUpdateData?.hasUpdates) {
hasUpdates = true
}
}
if (req.libraryItem.isBook && Array.isArray(mediaPayload.metadata?.authors)) {
const authorNames = mediaPayload.metadata.authors.map((au) => (typeof au.name === 'string' ? au.name.trim() : null)).filter((au) => au)
const authorUpdateData = await req.libraryItem.media.updateAuthorsFromRequest(authorNames, req.libraryItem.libraryId)
if (authorUpdateData?.authorsRemoved.length) {
// Check remove empty authors
Logger.debug(`[LibraryItemController] Authors were removed from book. Check if authors are now empty.`)
await this.checkRemoveAuthorsWithNoBooks(authorUpdateData.authorsRemoved.map((au) => au.id))
hasUpdates = true
}
if (authorUpdateData?.authorsAdded.length) {
// Add authors to filter data
authorUpdateData.authorsAdded.forEach((au) => {
Database.addAuthorToFilterData(req.libraryItem.libraryId, au.name, au.id)
})
hasUpdates = true
}
}
if (hasUpdates) {
req.libraryItem.changed('updatedAt', true)
await req.libraryItem.save()
await req.libraryItem.saveMetadataFile()
if (isPodcastAutoDownloadUpdated) {
this.cronManager.checkUpdatePodcastCron(req.libraryItem)
}
Logger.debug(`[LibraryItemController] Updated library item media ${req.libraryItem.media.title}`)
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
} }
res.json({ res.json({
updated: hasUpdates, updated: hasUpdates,
libraryItem libraryItem: req.libraryItem.toOldJSON()
}) })
} }
/** /**
* POST: /api/items/:id/cover * POST: /api/items/:id/cover
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
* @param {boolean} [updateAndReturnJson=true] * @param {boolean} [updateAndReturnJson=true] - Allows the function to be used for both direct API calls and internally
*/ */
async uploadCover(req, res, updateAndReturnJson = true) { async uploadCover(req, res, updateAndReturnJson = true) {
if (!req.user.canUpload) { if (!req.user.canUpload) {
@ -274,15 +274,13 @@ class LibraryItemController {
return res.sendStatus(403) return res.sendStatus(403)
} }
let libraryItem = req.libraryItem
let result = null let result = null
if (req.body?.url) { if (req.body?.url) {
Logger.debug(`[LibraryItemController] Requesting download cover from url "${req.body.url}"`) Logger.debug(`[LibraryItemController] Requesting download cover from url "${req.body.url}"`)
result = await CoverManager.downloadCoverFromUrl(libraryItem, req.body.url) result = await CoverManager.downloadCoverFromUrlNew(req.body.url, req.libraryItem.id, req.libraryItem.isFile ? null : req.libraryItem.path)
} else if (req.files?.cover) { } else if (req.files?.cover) {
Logger.debug(`[LibraryItemController] Handling uploaded cover`) Logger.debug(`[LibraryItemController] Handling uploaded cover`)
result = await CoverManager.uploadCover(libraryItem, req.files.cover) result = await CoverManager.uploadCover(req.libraryItem, req.files.cover)
} else { } else {
return res.status(400).send('Invalid request no file or url') return res.status(400).send('Invalid request no file or url')
} }
@ -293,9 +291,16 @@ class LibraryItemController {
return res.status(500).send('Unknown error occurred') return res.status(500).send('Unknown error occurred')
} }
req.libraryItem.media.coverPath = result.cover
req.libraryItem.media.changed('coverPath', true)
await req.libraryItem.media.save()
if (updateAndReturnJson) { if (updateAndReturnJson) {
await Database.updateLibraryItem(libraryItem) // client uses updatedAt timestamp in URL to force refresh cover
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) req.libraryItem.changed('updatedAt', true)
await req.libraryItem.save()
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
res.json({ res.json({
success: true, success: true,
cover: result.cover cover: result.cover
@ -306,22 +311,28 @@ class LibraryItemController {
/** /**
* PATCH: /api/items/:id/cover * PATCH: /api/items/:id/cover
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async updateCover(req, res) { async updateCover(req, res) {
const libraryItem = req.libraryItem
if (!req.body.cover) { if (!req.body.cover) {
return res.status(400).send('Invalid request no cover path') return res.status(400).send('Invalid request no cover path')
} }
const validationResult = await CoverManager.validateCoverPath(req.body.cover, libraryItem) const validationResult = await CoverManager.validateCoverPath(req.body.cover, req.libraryItem)
if (validationResult.error) { if (validationResult.error) {
return res.status(500).send(validationResult.error) return res.status(500).send(validationResult.error)
} }
if (validationResult.updated) { if (validationResult.updated) {
await Database.updateLibraryItem(libraryItem) req.libraryItem.media.coverPath = validationResult.cover
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) req.libraryItem.media.changed('coverPath', true)
await req.libraryItem.media.save()
// client uses updatedAt timestamp in URL to force refresh cover
req.libraryItem.changed('updatedAt', true)
await req.libraryItem.save()
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
} }
res.json({ res.json({
success: true, success: true,
@ -332,17 +343,22 @@ class LibraryItemController {
/** /**
* DELETE: /api/items/:id/cover * DELETE: /api/items/:id/cover
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async removeCover(req, res) { async removeCover(req, res) {
var libraryItem = req.libraryItem if (req.libraryItem.media.coverPath) {
req.libraryItem.media.coverPath = null
req.libraryItem.media.changed('coverPath', true)
await req.libraryItem.media.save()
if (libraryItem.media.coverPath) { // client uses updatedAt timestamp in URL to force refresh cover
libraryItem.updateMediaCover('') req.libraryItem.changed('updatedAt', true)
await CacheManager.purgeCoverCache(libraryItem.id) await req.libraryItem.save()
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) await CacheManager.purgeCoverCache(req.libraryItem.id)
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
} }
res.sendStatus(200) res.sendStatus(200)
@ -351,7 +367,7 @@ class LibraryItemController {
/** /**
* GET: /api/items/:id/cover * GET: /api/items/:id/cover
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async getCover(req, res) { async getCover(req, res) {
@ -393,11 +409,11 @@ class LibraryItemController {
* *
* @this {import('../routers/ApiRouter')} * @this {import('../routers/ApiRouter')}
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
startPlaybackSession(req, res) { startPlaybackSession(req, res) {
if (!req.libraryItem.media.numTracks) { if (!req.libraryItem.hasAudioTracks) {
Logger.error(`[LibraryItemController] startPlaybackSession cannot playback ${req.libraryItem.id}`) Logger.error(`[LibraryItemController] startPlaybackSession cannot playback ${req.libraryItem.id}`)
return res.sendStatus(404) return res.sendStatus(404)
} }
@ -410,18 +426,18 @@ class LibraryItemController {
* *
* @this {import('../routers/ApiRouter')} * @this {import('../routers/ApiRouter')}
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
startEpisodePlaybackSession(req, res) { startEpisodePlaybackSession(req, res) {
var libraryItem = req.libraryItem if (!req.libraryItem.isPodcast) {
if (!libraryItem.media.numTracks) { Logger.error(`[LibraryItemController] startEpisodePlaybackSession invalid media type ${req.libraryItem.id}`)
Logger.error(`[LibraryItemController] startPlaybackSession cannot playback ${libraryItem.id}`) return res.sendStatus(400)
return res.sendStatus(404)
} }
var episodeId = req.params.episodeId
if (!libraryItem.media.episodes.find((ep) => ep.id === episodeId)) { const episodeId = req.params.episodeId
Logger.error(`[LibraryItemController] startPlaybackSession episode ${episodeId} not found for item ${libraryItem.id}`) if (!req.libraryItem.media.podcastEpisodes.some((ep) => ep.id === episodeId)) {
Logger.error(`[LibraryItemController] startPlaybackSession episode ${episodeId} not found for item ${req.libraryItem.id}`)
return res.sendStatus(404) return res.sendStatus(404)
} }
@ -431,33 +447,72 @@ class LibraryItemController {
/** /**
* PATCH: /api/items/:id/tracks * PATCH: /api/items/:id/tracks
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async updateTracks(req, res) { async updateTracks(req, res) {
var libraryItem = req.libraryItem const orderedFileData = req.body?.orderedFileData
var orderedFileData = req.body.orderedFileData
if (!libraryItem.media.updateAudioTracks) { if (!req.libraryItem.isBook) {
Logger.error(`[LibraryItemController] updateTracks invalid media type ${libraryItem.id}`) Logger.error(`[LibraryItemController] updateTracks invalid media type ${req.libraryItem.id}`)
return res.sendStatus(500) return res.sendStatus(400)
} }
libraryItem.media.updateAudioTracks(orderedFileData) if (!Array.isArray(orderedFileData) || !orderedFileData.length) {
await Database.updateLibraryItem(libraryItem) Logger.error(`[LibraryItemController] updateTracks invalid orderedFileData ${req.libraryItem.id}`)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) return res.sendStatus(400)
res.json(libraryItem.toJSON()) }
// Ensure that each orderedFileData has a valid ino and is in the book audioFiles
if (orderedFileData.some((fileData) => !fileData?.ino || !req.libraryItem.media.audioFiles.some((af) => af.ino === fileData.ino))) {
Logger.error(`[LibraryItemController] updateTracks invalid orderedFileData ${req.libraryItem.id}`)
return res.sendStatus(400)
}
let index = 1
const updatedAudioFiles = orderedFileData.map((fileData) => {
const audioFile = req.libraryItem.media.audioFiles.find((af) => af.ino === fileData.ino)
audioFile.manuallyVerified = true
audioFile.exclude = !!fileData.exclude
if (audioFile.exclude) {
audioFile.index = -1
} else {
audioFile.index = index++
}
return audioFile
})
updatedAudioFiles.sort((a, b) => a.index - b.index)
req.libraryItem.media.audioFiles = updatedAudioFiles
req.libraryItem.media.changed('audioFiles', true)
await req.libraryItem.media.save()
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
res.json(req.libraryItem.toOldJSON())
} }
/** /**
* POST /api/items/:id/match * POST /api/items/:id/match
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async match(req, res) { async match(req, res) {
var libraryItem = req.libraryItem const reqBody = req.body || {}
var options = req.body || {} const options = {}
var matchResult = await Scanner.quickMatchLibraryItem(libraryItem, options) const matchOptions = ['provider', 'title', 'author', 'isbn', 'asin']
for (const key of matchOptions) {
if (reqBody[key] && typeof reqBody[key] === 'string') {
options[key] = reqBody[key]
}
}
if (reqBody.overrideCover !== undefined) {
options.overrideCover = !!reqBody.overrideCover
}
if (reqBody.overrideDetails !== undefined) {
options.overrideDetails = !!reqBody.overrideDetails
}
const matchResult = await Scanner.quickMatchLibraryItem(this, req.libraryItem, options)
res.json(matchResult) res.json(matchResult)
} }
@ -480,11 +535,11 @@ class LibraryItemController {
const hardDelete = req.query.hard == 1 // Delete files from filesystem const hardDelete = req.query.hard == 1 // Delete files from filesystem
const { libraryItemIds } = req.body const { libraryItemIds } = req.body
if (!libraryItemIds?.length) { if (!libraryItemIds?.length || !Array.isArray(libraryItemIds)) {
return res.status(400).send('Invalid request body') return res.status(400).send('Invalid request body')
} }
const itemsToDelete = await Database.libraryItemModel.getAllOldLibraryItems({ const itemsToDelete = await Database.libraryItemModel.findAllExpandedWhere({
id: libraryItemIds id: libraryItemIds
}) })
@ -495,19 +550,19 @@ class LibraryItemController {
const libraryId = itemsToDelete[0].libraryId const libraryId = itemsToDelete[0].libraryId
for (const libraryItem of itemsToDelete) { for (const libraryItem of itemsToDelete) {
const libraryItemPath = libraryItem.path const libraryItemPath = libraryItem.path
Logger.info(`[LibraryItemController] (${hardDelete ? 'Hard' : 'Soft'}) deleting Library Item "${libraryItem.media.metadata.title}" with id "${libraryItem.id}"`) Logger.info(`[LibraryItemController] (${hardDelete ? 'Hard' : 'Soft'}) deleting Library Item "${libraryItem.media.title}" with id "${libraryItem.id}"`)
const mediaItemIds = [] const mediaItemIds = []
const seriesIds = [] const seriesIds = []
const authorIds = [] const authorIds = []
if (libraryItem.isPodcast) { if (libraryItem.isPodcast) {
mediaItemIds.push(...libraryItem.media.episodes.map((ep) => ep.id)) mediaItemIds.push(...libraryItem.media.podcastEpisodes.map((ep) => ep.id))
} else { } else {
mediaItemIds.push(libraryItem.media.id) mediaItemIds.push(libraryItem.media.id)
if (libraryItem.media.metadata.series?.length) { if (libraryItem.media.series?.length) {
seriesIds.push(...libraryItem.media.metadata.series.map((se) => se.id)) seriesIds.push(...libraryItem.media.series.map((se) => se.id))
} }
if (libraryItem.media.metadata.authors?.length) { if (libraryItem.media.authors?.length) {
authorIds.push(...libraryItem.media.metadata.authors.map((au) => au.id)) authorIds.push(...libraryItem.media.authors.map((au) => au.id))
} }
} }
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds) await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
@ -552,7 +607,7 @@ class LibraryItemController {
} }
// Get all library items to update // Get all library items to update
const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({ const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({
id: libraryItemIds id: libraryItemIds
}) })
if (updatePayloads.length !== libraryItems.length) { if (updatePayloads.length !== libraryItems.length) {
@ -569,26 +624,46 @@ class LibraryItemController {
const mediaPayload = updatePayload.mediaPayload const mediaPayload = updatePayload.mediaPayload
const libraryItem = libraryItems.find((li) => li.id === updatePayload.id) const libraryItem = libraryItems.find((li) => li.id === updatePayload.id)
await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryItem.libraryId) let hasUpdates = await libraryItem.media.updateFromRequest(mediaPayload)
if (libraryItem.isBook) { if (libraryItem.isBook && Array.isArray(mediaPayload.metadata?.series)) {
if (Array.isArray(mediaPayload.metadata?.series)) { const seriesUpdateData = await libraryItem.media.updateSeriesFromRequest(mediaPayload.metadata.series, libraryItem.libraryId)
const seriesIdsInUpdate = mediaPayload.metadata.series.map((se) => se.id) if (seriesUpdateData?.seriesRemoved.length) {
const seriesRemoved = libraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id)) seriesIdsRemoved.push(...seriesUpdateData.seriesRemoved.map((se) => se.id))
seriesIdsRemoved.push(...seriesRemoved.map((se) => se.id))
} }
if (Array.isArray(mediaPayload.metadata?.authors)) { if (seriesUpdateData?.seriesAdded.length) {
const authorIdsInUpdate = mediaPayload.metadata.authors.map((au) => au.id) seriesUpdateData.seriesAdded.forEach((se) => {
const authorsRemoved = libraryItem.media.metadata.authors.filter((au) => !authorIdsInUpdate.includes(au.id)) Database.addSeriesToFilterData(libraryItem.libraryId, se.name, se.id)
authorIdsRemoved.push(...authorsRemoved.map((au) => au.id)) })
}
if (seriesUpdateData?.hasUpdates) {
hasUpdates = true
} }
} }
if (libraryItem.media.update(mediaPayload)) { if (libraryItem.isBook && Array.isArray(mediaPayload.metadata?.authors)) {
Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`) const authorNames = mediaPayload.metadata.authors.map((au) => (typeof au.name === 'string' ? au.name.trim() : null)).filter((au) => au)
const authorUpdateData = await libraryItem.media.updateAuthorsFromRequest(authorNames, libraryItem.libraryId)
if (authorUpdateData?.authorsRemoved.length) {
authorIdsRemoved.push(...authorUpdateData.authorsRemoved.map((au) => au.id))
hasUpdates = true
}
if (authorUpdateData?.authorsAdded.length) {
authorUpdateData.authorsAdded.forEach((au) => {
Database.addAuthorToFilterData(libraryItem.libraryId, au.name, au.id)
})
hasUpdates = true
}
}
await Database.updateLibraryItem(libraryItem) if (hasUpdates) {
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) libraryItem.changed('updatedAt', true)
await libraryItem.save()
await libraryItem.saveMetadataFile()
Logger.debug(`[LibraryItemController] Updated library item media "${libraryItem.media.title}"`)
SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
itemsUpdated++ itemsUpdated++
} }
} }
@ -617,11 +692,11 @@ class LibraryItemController {
if (!libraryItemIds.length) { if (!libraryItemIds.length) {
return res.status(403).send('Invalid payload') return res.status(403).send('Invalid payload')
} }
const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({ const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({
id: libraryItemIds id: libraryItemIds
}) })
res.json({ res.json({
libraryItems: libraryItems.map((li) => li.toJSONExpanded()) libraryItems: libraryItems.map((li) => li.toOldJSONExpanded())
}) })
} }
@ -640,12 +715,11 @@ class LibraryItemController {
let itemsUpdated = 0 let itemsUpdated = 0
let itemsUnmatched = 0 let itemsUnmatched = 0
const options = req.body.options || {}
if (!req.body.libraryItemIds?.length) { if (!req.body.libraryItemIds?.length) {
return res.sendStatus(400) return res.sendStatus(400)
} }
const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({ const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({
id: req.body.libraryItemIds id: req.body.libraryItemIds
}) })
if (!libraryItems?.length) { if (!libraryItems?.length) {
@ -654,8 +728,20 @@ class LibraryItemController {
res.sendStatus(200) res.sendStatus(200)
const reqBodyOptions = req.body.options || {}
const options = {}
if (reqBodyOptions.provider && typeof reqBodyOptions.provider === 'string') {
options.provider = reqBodyOptions.provider
}
if (reqBodyOptions.overrideCover !== undefined) {
options.overrideCover = !!reqBodyOptions.overrideCover
}
if (reqBodyOptions.overrideDetails !== undefined) {
options.overrideDetails = !!reqBodyOptions.overrideDetails
}
for (const libraryItem of libraryItems) { for (const libraryItem of libraryItems) {
const matchResult = await Scanner.quickMatchLibraryItem(libraryItem, options) const matchResult = await Scanner.quickMatchLibraryItem(this, libraryItem, options)
if (matchResult.updated) { if (matchResult.updated) {
itemsUpdated++ itemsUpdated++
} else if (matchResult.warning) { } else if (matchResult.warning) {
@ -714,7 +800,7 @@ class LibraryItemController {
/** /**
* POST: /api/items/:id/scan * POST: /api/items/:id/scan
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async scan(req, res) { async scan(req, res) {
@ -738,7 +824,7 @@ class LibraryItemController {
/** /**
* GET: /api/items/:id/metadata-object * GET: /api/items/:id/metadata-object
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
getMetadataObject(req, res) { getMetadataObject(req, res) {
@ -747,7 +833,7 @@ class LibraryItemController {
return res.sendStatus(403) return res.sendStatus(403)
} }
if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) { if (req.libraryItem.isMissing || !req.libraryItem.isBook || !req.libraryItem.media.includedAudioFiles.length) {
Logger.error(`[LibraryItemController] Invalid library item`) Logger.error(`[LibraryItemController] Invalid library item`)
return res.sendStatus(500) return res.sendStatus(500)
} }
@ -758,7 +844,7 @@ class LibraryItemController {
/** /**
* POST: /api/items/:id/chapters * POST: /api/items/:id/chapters
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async updateMediaChapters(req, res) { async updateMediaChapters(req, res) {
@ -767,26 +853,53 @@ class LibraryItemController {
return res.sendStatus(403) return res.sendStatus(403)
} }
if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) { if (req.libraryItem.isMissing || !req.libraryItem.isBook || !req.libraryItem.media.hasAudioTracks) {
Logger.error(`[LibraryItemController] Invalid library item`) Logger.error(`[LibraryItemController] Invalid library item`)
return res.sendStatus(500) return res.sendStatus(500)
} }
if (!req.body.chapters) { if (!Array.isArray(req.body.chapters) || req.body.chapters.some((c) => !c.title || typeof c.title !== 'string' || c.start === undefined || typeof c.start !== 'number' || c.end === undefined || typeof c.end !== 'number')) {
Logger.error(`[LibraryItemController] Invalid payload`) Logger.error(`[LibraryItemController] Invalid payload`)
return res.sendStatus(400) return res.sendStatus(400)
} }
const chapters = req.body.chapters || [] const chapters = req.body.chapters || []
const wasUpdated = req.libraryItem.media.updateChapters(chapters)
if (wasUpdated) { let hasUpdates = false
await Database.updateLibraryItem(req.libraryItem) if (chapters.length !== req.libraryItem.media.chapters.length) {
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded()) req.libraryItem.media.chapters = chapters.map((c, index) => {
return {
id: index,
title: c.title,
start: c.start,
end: c.end
}
})
hasUpdates = true
} else {
for (const [index, chapter] of chapters.entries()) {
const currentChapter = req.libraryItem.media.chapters[index]
if (currentChapter.title !== chapter.title || currentChapter.start !== chapter.start || currentChapter.end !== chapter.end) {
currentChapter.title = chapter.title
currentChapter.start = chapter.start
currentChapter.end = chapter.end
hasUpdates = true
}
}
}
if (hasUpdates) {
req.libraryItem.media.changed('chapters', true)
await req.libraryItem.media.save()
await req.libraryItem.saveMetadataFile()
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
} }
res.json({ res.json({
success: true, success: true,
updated: wasUpdated updated: hasUpdates
}) })
} }
@ -794,7 +907,7 @@ class LibraryItemController {
* GET: /api/items/:id/ffprobe/:fileid * GET: /api/items/:id/ffprobe/:fileid
* FFProbe JSON result from audio file * FFProbe JSON result from audio file
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async getFFprobeData(req, res) { async getFFprobeData(req, res) {
@ -802,25 +915,21 @@ class LibraryItemController {
Logger.error(`[LibraryItemController] Non-admin user "${req.user.username}" attempted to get ffprobe data`) Logger.error(`[LibraryItemController] Non-admin user "${req.user.username}" attempted to get ffprobe data`)
return res.sendStatus(403) return res.sendStatus(403)
} }
if (req.libraryFile.fileType !== 'audio') {
Logger.error(`[LibraryItemController] Invalid filetype "${req.libraryFile.fileType}" for fileid "${req.params.fileid}". Expected audio file`)
return res.sendStatus(400)
}
const audioFile = req.libraryItem.media.findFileWithInode(req.params.fileid) const audioFile = req.libraryItem.getAudioFileWithIno(req.params.fileid)
if (!audioFile) { if (!audioFile) {
Logger.error(`[LibraryItemController] Audio file not found with inode value ${req.params.fileid}`) Logger.error(`[LibraryItemController] Audio file not found with inode value ${req.params.fileid}`)
return res.sendStatus(404) return res.sendStatus(404)
} }
const ffprobeData = await AudioFileScanner.probeAudioFile(audioFile) const ffprobeData = await AudioFileScanner.probeAudioFile(audioFile.metadata.path)
res.json(ffprobeData) res.json(ffprobeData)
} }
/** /**
* GET api/items/:id/file/:fileid * GET api/items/:id/file/:fileid
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequestWithFile} req
* @param {Response} res * @param {Response} res
*/ */
async getLibraryFile(req, res) { async getLibraryFile(req, res) {
@ -843,7 +952,7 @@ class LibraryItemController {
/** /**
* DELETE api/items/:id/file/:fileid * DELETE api/items/:id/file/:fileid
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequestWithFile} req
* @param {Response} res * @param {Response} res
*/ */
async deleteLibraryFile(req, res) { async deleteLibraryFile(req, res) {
@ -854,17 +963,49 @@ class LibraryItemController {
await fs.remove(libraryFile.metadata.path).catch((error) => { await fs.remove(libraryFile.metadata.path).catch((error) => {
Logger.error(`[LibraryItemController] Failed to delete library file at "${libraryFile.metadata.path}"`, error) Logger.error(`[LibraryItemController] Failed to delete library file at "${libraryFile.metadata.path}"`, error)
}) })
req.libraryItem.removeLibraryFile(req.params.fileid)
if (req.libraryItem.media.removeFileWithInode(req.params.fileid)) { req.libraryItem.libraryFiles = req.libraryItem.libraryFiles.filter((lf) => lf.ino !== req.params.fileid)
// If book has no more media files then mark it as missing req.libraryItem.changed('libraryFiles', true)
if (req.libraryItem.mediaType === 'book' && !req.libraryItem.media.hasMediaEntities) {
req.libraryItem.setMissing() if (req.libraryItem.isBook) {
if (req.libraryItem.media.audioFiles.some((af) => af.ino === req.params.fileid)) {
req.libraryItem.media.audioFiles = req.libraryItem.media.audioFiles.filter((af) => af.ino !== req.params.fileid)
req.libraryItem.media.changed('audioFiles', true)
} else if (req.libraryItem.media.ebookFile?.ino === req.params.fileid) {
req.libraryItem.media.ebookFile = null
req.libraryItem.media.changed('ebookFile', true)
} }
if (!req.libraryItem.media.hasMediaFiles) {
req.libraryItem.isMissing = true
}
} else if (req.libraryItem.media.podcastEpisodes.some((ep) => ep.audioFile.ino === req.params.fileid)) {
const episodeToRemove = req.libraryItem.media.podcastEpisodes.find((ep) => ep.audioFile.ino === req.params.fileid)
// Remove episode from all playlists
await Database.playlistModel.removeMediaItemsFromPlaylists([episodeToRemove.id])
// Remove episode media progress
const numProgressRemoved = await Database.mediaProgressModel.destroy({
where: {
mediaItemId: episodeToRemove.id
}
})
if (numProgressRemoved > 0) {
Logger.info(`[LibraryItemController] Removed media progress for episode ${episodeToRemove.id}`)
}
// Remove episode
await episodeToRemove.destroy()
req.libraryItem.media.podcastEpisodes = req.libraryItem.media.podcastEpisodes.filter((ep) => ep.audioFile.ino !== req.params.fileid)
} }
req.libraryItem.updatedAt = Date.now()
await Database.updateLibraryItem(req.libraryItem) if (req.libraryItem.media.changed()) {
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded()) await req.libraryItem.media.save()
}
await req.libraryItem.save()
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
res.sendStatus(200) res.sendStatus(200)
} }
@ -872,7 +1013,7 @@ class LibraryItemController {
* GET api/items/:id/file/:fileid/download * GET api/items/:id/file/:fileid/download
* Same as GET api/items/:id/file/:fileid but allows logging and restricting downloads * Same as GET api/items/:id/file/:fileid but allows logging and restricting downloads
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequestWithFile} req
* @param {Response} res * @param {Response} res
*/ */
async downloadLibraryFile(req, res) { async downloadLibraryFile(req, res) {
@ -884,7 +1025,7 @@ class LibraryItemController {
return res.sendStatus(403) return res.sendStatus(403)
} }
Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${req.libraryItem.media.metadata.title}" file at "${libraryFile.metadata.path}"`) Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${req.libraryItem.media.title}" file at "${libraryFile.metadata.path}"`)
if (global.XAccel) { if (global.XAccel) {
const encodedURI = encodeUriPath(global.XAccel + libraryFile.metadata.path) const encodedURI = encodeUriPath(global.XAccel + libraryFile.metadata.path)
@ -920,13 +1061,13 @@ class LibraryItemController {
* fileid is only required when reading a supplementary ebook * fileid is only required when reading a supplementary ebook
* when no fileid is passed in the primary ebook will be returned * when no fileid is passed in the primary ebook will be returned
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async getEBookFile(req, res) { async getEBookFile(req, res) {
let ebookFile = null let ebookFile = null
if (req.params.fileid) { if (req.params.fileid) {
ebookFile = req.libraryItem.libraryFiles.find((lf) => lf.ino === req.params.fileid) ebookFile = req.libraryItem.getLibraryFileWithIno(req.params.fileid)
if (!ebookFile?.isEBookFile) { if (!ebookFile?.isEBookFile) {
Logger.error(`[LibraryItemController] Invalid ebook file id "${req.params.fileid}"`) Logger.error(`[LibraryItemController] Invalid ebook file id "${req.params.fileid}"`)
return res.status(400).send('Invalid ebook file id') return res.status(400).send('Invalid ebook file id')
@ -936,12 +1077,12 @@ class LibraryItemController {
} }
if (!ebookFile) { if (!ebookFile) {
Logger.error(`[LibraryItemController] No ebookFile for library item "${req.libraryItem.media.metadata.title}"`) Logger.error(`[LibraryItemController] No ebookFile for library item "${req.libraryItem.media.title}"`)
return res.sendStatus(404) return res.sendStatus(404)
} }
const ebookFilePath = ebookFile.metadata.path const ebookFilePath = ebookFile.metadata.path
Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${req.libraryItem.media.metadata.title}" ebook at "${ebookFilePath}"`) Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${req.libraryItem.media.title}" ebook at "${ebookFilePath}"`)
if (global.XAccel) { if (global.XAccel) {
const encodedURI = encodeUriPath(global.XAccel + ebookFilePath) const encodedURI = encodeUriPath(global.XAccel + ebookFilePath)
@ -964,28 +1105,55 @@ class LibraryItemController {
* if an ebook file is the primary ebook, then it will be changed to supplementary * if an ebook file is the primary ebook, then it will be changed to supplementary
* if an ebook file is supplementary, then it will be changed to primary * if an ebook file is supplementary, then it will be changed to primary
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequestWithFile} req
* @param {Response} res * @param {Response} res
*/ */
async updateEbookFileStatus(req, res) { async updateEbookFileStatus(req, res) {
const ebookLibraryFile = req.libraryItem.libraryFiles.find((lf) => lf.ino === req.params.fileid) if (!req.libraryItem.isBook) {
if (!ebookLibraryFile?.isEBookFile) { Logger.error(`[LibraryItemController] Invalid media type for ebook file status update`)
return res.sendStatus(400)
}
if (!req.libraryFile?.isEBookFile) {
Logger.error(`[LibraryItemController] Invalid ebook file id "${req.params.fileid}"`) Logger.error(`[LibraryItemController] Invalid ebook file id "${req.params.fileid}"`)
return res.status(400).send('Invalid ebook file id') return res.status(400).send('Invalid ebook file id')
} }
const ebookLibraryFile = req.libraryFile
let primaryEbookFile = null
const ebookLibraryFileInos = req.libraryItem
.getLibraryFiles()
.filter((lf) => lf.isEBookFile)
.map((lf) => lf.ino)
if (ebookLibraryFile.isSupplementary) { if (ebookLibraryFile.isSupplementary) {
Logger.info(`[LibraryItemController] Updating ebook file "${ebookLibraryFile.metadata.filename}" to primary`) Logger.info(`[LibraryItemController] Updating ebook file "${ebookLibraryFile.metadata.filename}" to primary`)
req.libraryItem.setPrimaryEbook(ebookLibraryFile)
primaryEbookFile = ebookLibraryFile.toJSON()
delete primaryEbookFile.isSupplementary
delete primaryEbookFile.fileType
primaryEbookFile.ebookFormat = ebookLibraryFile.metadata.format
} else { } else {
Logger.info(`[LibraryItemController] Updating ebook file "${ebookLibraryFile.metadata.filename}" to supplementary`) Logger.info(`[LibraryItemController] Updating ebook file "${ebookLibraryFile.metadata.filename}" to supplementary`)
ebookLibraryFile.isSupplementary = true
req.libraryItem.setPrimaryEbook(null)
} }
req.libraryItem.updatedAt = Date.now() req.libraryItem.media.ebookFile = primaryEbookFile
await Database.updateLibraryItem(req.libraryItem) req.libraryItem.media.changed('ebookFile', true)
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded()) await req.libraryItem.media.save()
req.libraryItem.libraryFiles = req.libraryItem.libraryFiles.map((lf) => {
if (ebookLibraryFileInos.includes(lf.ino)) {
lf.isSupplementary = lf.ino !== primaryEbookFile?.ino
}
return lf
})
req.libraryItem.changed('libraryFiles', true)
req.libraryItem.isMissing = !req.libraryItem.media.hasMediaFiles
await req.libraryItem.save()
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
res.sendStatus(200) res.sendStatus(200)
} }
@ -996,7 +1164,7 @@ class LibraryItemController {
* @param {NextFunction} next * @param {NextFunction} next
*/ */
async middleware(req, res, next) { async middleware(req, res, next) {
req.libraryItem = await Database.libraryItemModel.getOldById(req.params.id) req.libraryItem = await Database.libraryItemModel.getExpandedById(req.params.id)
if (!req.libraryItem?.media) return res.sendStatus(404) if (!req.libraryItem?.media) return res.sendStatus(404)
// Check user can access this library item // Check user can access this library item
@ -1006,7 +1174,7 @@ class LibraryItemController {
// For library file routes, get the library file // For library file routes, get the library file
if (req.params.fileid) { if (req.params.fileid) {
req.libraryFile = req.libraryItem.libraryFiles.find((lf) => lf.ino === req.params.fileid) req.libraryFile = req.libraryItem.getLibraryFileWithIno(req.params.fileid)
if (!req.libraryFile) { if (!req.libraryFile) {
Logger.error(`[LibraryItemController] Library file "${req.params.fileid}" does not exist for library item`) Logger.error(`[LibraryItemController] Library file "${req.params.fileid}" does not exist for library item`)
return res.sendStatus(404) return res.sendStatus(404)

View File

@ -66,7 +66,7 @@ class MeController {
const libraryItem = await Database.libraryItemModel.findByPk(req.params.libraryItemId) const libraryItem = await Database.libraryItemModel.findByPk(req.params.libraryItemId)
const episode = await Database.podcastEpisodeModel.findByPk(req.params.episodeId) const episode = await Database.podcastEpisodeModel.findByPk(req.params.episodeId)
if (!libraryItem || (libraryItem.mediaType === 'podcast' && !episode)) { if (!libraryItem || (libraryItem.isPodcast && !episode)) {
Logger.error(`[MeController] Media item not found for library item id "${req.params.libraryItemId}"`) Logger.error(`[MeController] Media item not found for library item id "${req.params.libraryItemId}"`)
return res.sendStatus(404) return res.sendStatus(404)
} }
@ -296,7 +296,7 @@ class MeController {
const mediaProgressesInProgress = req.user.mediaProgresses.filter((mp) => !mp.isFinished && (mp.currentTime > 0 || mp.ebookProgress > 0)) const mediaProgressesInProgress = req.user.mediaProgresses.filter((mp) => !mp.isFinished && (mp.currentTime > 0 || mp.ebookProgress > 0))
const libraryItemsIds = [...new Set(mediaProgressesInProgress.map((mp) => mp.extraData?.libraryItemId).filter((id) => id))] const libraryItemsIds = [...new Set(mediaProgressesInProgress.map((mp) => mp.extraData?.libraryItemId).filter((id) => id))]
const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({ id: libraryItemsIds }) const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({ id: libraryItemsIds })
let itemsInProgress = [] let itemsInProgress = []
@ -304,19 +304,19 @@ class MeController {
const oldMediaProgress = mediaProgress.getOldMediaProgress() const oldMediaProgress = mediaProgress.getOldMediaProgress()
const libraryItem = libraryItems.find((li) => li.id === oldMediaProgress.libraryItemId) const libraryItem = libraryItems.find((li) => li.id === oldMediaProgress.libraryItemId)
if (libraryItem) { if (libraryItem) {
if (oldMediaProgress.episodeId && libraryItem.mediaType === 'podcast') { if (oldMediaProgress.episodeId && libraryItem.isPodcast) {
const episode = libraryItem.media.episodes.find((ep) => ep.id === oldMediaProgress.episodeId) const episode = libraryItem.media.podcastEpisodes.find((ep) => ep.id === oldMediaProgress.episodeId)
if (episode) { if (episode) {
const libraryItemWithEpisode = { const libraryItemWithEpisode = {
...libraryItem.toJSONMinified(), ...libraryItem.toOldJSONMinified(),
recentEpisode: episode.toJSON(), recentEpisode: episode.toOldJSON(libraryItem.id),
progressLastUpdate: oldMediaProgress.lastUpdate progressLastUpdate: oldMediaProgress.lastUpdate
} }
itemsInProgress.push(libraryItemWithEpisode) itemsInProgress.push(libraryItemWithEpisode)
} }
} else if (!oldMediaProgress.episodeId) { } else if (!oldMediaProgress.episodeId) {
itemsInProgress.push({ itemsInProgress.push({
...libraryItem.toJSONMinified(), ...libraryItem.toOldJSONMinified(),
progressLastUpdate: oldMediaProgress.lastUpdate progressLastUpdate: oldMediaProgress.lastUpdate
}) })
} }

View File

@ -126,6 +126,10 @@ class MiscController {
if (!isObject(settingsUpdate)) { if (!isObject(settingsUpdate)) {
return res.status(400).send('Invalid settings update object') return res.status(400).send('Invalid settings update object')
} }
if (settingsUpdate.allowIframe == false && process.env.ALLOW_IFRAME === '1') {
Logger.warn('Cannot disable iframe when ALLOW_IFRAME is enabled in environment')
return res.status(400).send('Cannot disable iframe when ALLOW_IFRAME is enabled in environment')
}
const madeUpdates = Database.serverSettings.update(settingsUpdate) const madeUpdates = Database.serverSettings.update(settingsUpdate)
if (madeUpdates) { if (madeUpdates) {
@ -137,7 +141,6 @@ class MiscController {
} }
} }
return res.json({ return res.json({
success: true,
serverSettings: Database.serverSettings.toJSONForBrowser() serverSettings: Database.serverSettings.toJSONForBrowser()
}) })
} }
@ -339,8 +342,8 @@ class MiscController {
tags: libraryItem.media.tags tags: libraryItem.media.tags
}) })
await libraryItem.saveMetadataFile() await libraryItem.saveMetadataFile()
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded()) SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
numItemsUpdated++ numItemsUpdated++
} }
} }
@ -382,8 +385,8 @@ class MiscController {
tags: libraryItem.media.tags tags: libraryItem.media.tags
}) })
await libraryItem.saveMetadataFile() await libraryItem.saveMetadataFile()
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded()) SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
numItemsUpdated++ numItemsUpdated++
} }
@ -477,8 +480,8 @@ class MiscController {
genres: libraryItem.media.genres genres: libraryItem.media.genres
}) })
await libraryItem.saveMetadataFile() await libraryItem.saveMetadataFile()
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded()) SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
numItemsUpdated++ numItemsUpdated++
} }
} }
@ -520,8 +523,8 @@ class MiscController {
genres: libraryItem.media.genres genres: libraryItem.media.genres
}) })
await libraryItem.saveMetadataFile() await libraryItem.saveMetadataFile()
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded()) SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
numItemsUpdated++ numItemsUpdated++
} }

View File

@ -3,13 +3,16 @@ const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority') const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database') const Database = require('../Database')
const Playlist = require('../objects/Playlist')
/** /**
* @typedef RequestUserObject * @typedef RequestUserObject
* @property {import('../models/User')} user * @property {import('../models/User')} user
* *
* @typedef {Request & RequestUserObject} RequestWithUser * @typedef {Request & RequestUserObject} RequestWithUser
*
* @typedef RequestEntityObject
* @property {import('../models/Playlist')} playlist
*
* @typedef {RequestWithUser & RequestEntityObject} PlaylistControllerRequest
*/ */
class PlaylistController { class PlaylistController {
@ -23,48 +26,103 @@ class PlaylistController {
* @param {Response} res * @param {Response} res
*/ */
async create(req, res) { async create(req, res) {
const oldPlaylist = new Playlist() const reqBody = req.body || {}
req.body.userId = req.user.id
const success = oldPlaylist.setData(req.body) // Validation
if (!success) { if (!reqBody.name || !reqBody.libraryId) {
return res.status(400).send('Invalid playlist request data') return res.status(400).send('Invalid playlist data')
}
if (reqBody.description && typeof reqBody.description !== 'string') {
return res.status(400).send('Invalid playlist description')
}
const items = reqBody.items || []
const isPodcast = items.some((i) => i.episodeId)
const libraryItemIds = new Set()
for (const item of items) {
if (!item.libraryItemId || typeof item.libraryItemId !== 'string') {
return res.status(400).send('Invalid playlist item')
}
if (isPodcast && (!item.episodeId || typeof item.episodeId !== 'string')) {
return res.status(400).send('Invalid playlist item episodeId')
} else if (!isPodcast && item.episodeId) {
return res.status(400).send('Invalid playlist item episodeId')
}
libraryItemIds.add(item.libraryItemId)
} }
// Create Playlist record // Load library items
const newPlaylist = await Database.playlistModel.createFromOld(oldPlaylist) const libraryItems = await Database.libraryItemModel.findAll({
attributes: ['id', 'mediaId', 'mediaType', 'libraryId'],
// Lookup all library items in playlist
const libraryItemIds = oldPlaylist.items.map((i) => i.libraryItemId).filter((i) => i)
const libraryItemsInPlaylist = await Database.libraryItemModel.findAll({
where: { where: {
id: libraryItemIds id: Array.from(libraryItemIds),
libraryId: reqBody.libraryId,
mediaType: isPodcast ? 'podcast' : 'book'
} }
}) })
if (libraryItems.length !== libraryItemIds.size) {
return res.status(400).send('Invalid playlist data. Invalid items')
}
// Create playlistMediaItem records // Validate podcast episodes
const mediaItemsToAdd = [] if (isPodcast) {
let order = 1 const podcastEpisodeIds = items.map((i) => i.episodeId)
for (const mediaItemObj of oldPlaylist.items) { const podcastEpisodes = await Database.podcastEpisodeModel.findAll({
const libraryItem = libraryItemsInPlaylist.find((li) => li.id === mediaItemObj.libraryItemId) attributes: ['id'],
if (!libraryItem) continue where: {
id: podcastEpisodeIds
mediaItemsToAdd.push({ }
mediaItemId: mediaItemObj.episodeId || libraryItem.mediaId,
mediaItemType: mediaItemObj.episodeId ? 'podcastEpisode' : 'book',
playlistId: oldPlaylist.id,
order: order++
}) })
} if (podcastEpisodes.length !== podcastEpisodeIds.length) {
if (mediaItemsToAdd.length) { return res.status(400).send('Invalid playlist data. Invalid podcast episodes')
await Database.createBulkPlaylistMediaItems(mediaItemsToAdd) }
} }
const jsonExpanded = await newPlaylist.getOldJsonExpanded() const transaction = await Database.sequelize.transaction()
SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded) try {
res.json(jsonExpanded) // Create playlist
const newPlaylist = await Database.playlistModel.create(
{
libraryId: reqBody.libraryId,
userId: req.user.id,
name: reqBody.name,
description: reqBody.description || null
},
{ transaction }
)
// Create playlistMediaItems
const playlistItemPayloads = []
for (const [index, item] of items.entries()) {
const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId)
playlistItemPayloads.push({
playlistId: newPlaylist.id,
mediaItemId: item.episodeId || libraryItem.mediaId,
mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',
order: index + 1
})
}
await Database.playlistMediaItemModel.bulkCreate(playlistItemPayloads, { transaction })
await transaction.commit()
newPlaylist.playlistMediaItems = await newPlaylist.getMediaItemsExpandedWithLibraryItem()
const jsonExpanded = newPlaylist.toOldJSONExpanded()
SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded)
res.json(jsonExpanded)
} catch (error) {
await transaction.rollback()
Logger.error('[PlaylistController] create:', error)
res.status(500).send('Failed to create playlist')
}
} }
/** /**
* @deprecated - Use /api/libraries/:libraryId/playlists
* This is not used by Abs web client or mobile apps
* TODO: Remove this endpoint or make it the primary
*
* GET: /api/playlists * GET: /api/playlists
* Get all playlists for user * Get all playlists for user
* *
@ -72,68 +130,89 @@ class PlaylistController {
* @param {Response} res * @param {Response} res
*/ */
async findAllForUser(req, res) { async findAllForUser(req, res) {
const playlistsForUser = await Database.playlistModel.findAll({ const playlistsForUser = await Database.playlistModel.getOldPlaylistsForUserAndLibrary(req.user.id)
where: {
userId: req.user.id
}
})
const playlists = []
for (const playlist of playlistsForUser) {
const jsonExpanded = await playlist.getOldJsonExpanded()
playlists.push(jsonExpanded)
}
res.json({ res.json({
playlists playlists: playlistsForUser
}) })
} }
/** /**
* GET: /api/playlists/:id * GET: /api/playlists/:id
* *
* @param {RequestWithUser} req * @param {PlaylistControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async findOne(req, res) { async findOne(req, res) {
const jsonExpanded = await req.playlist.getOldJsonExpanded() req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
res.json(jsonExpanded) res.json(req.playlist.toOldJSONExpanded())
} }
/** /**
* PATCH: /api/playlists/:id * PATCH: /api/playlists/:id
* Update playlist * Update playlist
* *
* @param {RequestWithUser} req * Used for updating name and description or reordering items
*
* @param {PlaylistControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async update(req, res) { async update(req, res) {
const updatedPlaylist = req.playlist.set(req.body) // Validation
let wasUpdated = false const reqBody = req.body || {}
const changed = updatedPlaylist.changed() if (reqBody.libraryId || reqBody.userId) {
if (changed?.length) { // Could allow support for this if needed with additional validation
await req.playlist.save() return res.status(400).send('Invalid playlist data. Cannot update libraryId or userId')
Logger.debug(`[PlaylistController] Updated playlist ${req.playlist.id} keys [${changed.join(',')}]`) }
wasUpdated = true if (reqBody.name && typeof reqBody.name !== 'string') {
return res.status(400).send('Invalid playlist name')
}
if (reqBody.description && typeof reqBody.description !== 'string') {
return res.status(400).send('Invalid playlist description')
}
if (reqBody.items && (!Array.isArray(reqBody.items) || reqBody.items.some((i) => !i.libraryItemId || typeof i.libraryItemId !== 'string' || (i.episodeId && typeof i.episodeId !== 'string')))) {
return res.status(400).send('Invalid playlist items')
} }
// If array of items is passed in then update order of playlist media items const playlistUpdatePayload = {}
const libraryItemIds = req.body.items?.map((i) => i.libraryItemId).filter((i) => i) || [] if (reqBody.name) playlistUpdatePayload.name = reqBody.name
if (libraryItemIds.length) { if (reqBody.description) playlistUpdatePayload.description = reqBody.description
// Update name and description
let wasUpdated = false
if (Object.keys(playlistUpdatePayload).length) {
req.playlist.set(playlistUpdatePayload)
const changed = req.playlist.changed()
if (changed?.length) {
await req.playlist.save()
Logger.debug(`[PlaylistController] Updated playlist ${req.playlist.id} keys [${changed.join(',')}]`)
wasUpdated = true
}
}
// If array of items is set then update order of playlist media items
if (reqBody.items?.length) {
const libraryItemIds = Array.from(new Set(reqBody.items.map((i) => i.libraryItemId)))
const libraryItems = await Database.libraryItemModel.findAll({ const libraryItems = await Database.libraryItemModel.findAll({
attributes: ['id', 'mediaId', 'mediaType'],
where: { where: {
id: libraryItemIds id: libraryItemIds
} }
}) })
const existingPlaylistMediaItems = await updatedPlaylist.getPlaylistMediaItems({ if (libraryItems.length !== libraryItemIds.length) {
return res.status(400).send('Invalid playlist items. Items not found')
}
/** @type {import('../models/PlaylistMediaItem')[]} */
const existingPlaylistMediaItems = await req.playlist.getPlaylistMediaItems({
order: [['order', 'ASC']] order: [['order', 'ASC']]
}) })
if (existingPlaylistMediaItems.length !== reqBody.items.length) {
return res.status(400).send('Invalid playlist items. Length mismatch')
}
// Set an array of mediaItemId // Set an array of mediaItemId
const newMediaItemIdOrder = [] const newMediaItemIdOrder = []
for (const item of req.body.items) { for (const item of reqBody.items) {
const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId) const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId)
if (!libraryItem) {
continue
}
const mediaItemId = item.episodeId || libraryItem.mediaId const mediaItemId = item.episodeId || libraryItem.mediaId
newMediaItemIdOrder.push(mediaItemId) newMediaItemIdOrder.push(mediaItemId)
} }
@ -146,21 +225,21 @@ class PlaylistController {
}) })
// Update order on playlistMediaItem records // Update order on playlistMediaItem records
let order = 1 for (const [index, playlistMediaItem] of existingPlaylistMediaItems.entries()) {
for (const playlistMediaItem of existingPlaylistMediaItems) { if (playlistMediaItem.order !== index + 1) {
if (playlistMediaItem.order !== order) {
await playlistMediaItem.update({ await playlistMediaItem.update({
order order: index + 1
}) })
wasUpdated = true wasUpdated = true
} }
order++
} }
} }
const jsonExpanded = await updatedPlaylist.getOldJsonExpanded() req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
const jsonExpanded = req.playlist.toOldJSONExpanded()
if (wasUpdated) { if (wasUpdated) {
SocketAuthority.clientEmitter(updatedPlaylist.userId, 'playlist_updated', jsonExpanded) SocketAuthority.clientEmitter(req.playlist.userId, 'playlist_updated', jsonExpanded)
} }
res.json(jsonExpanded) res.json(jsonExpanded)
} }
@ -169,11 +248,13 @@ class PlaylistController {
* DELETE: /api/playlists/:id * DELETE: /api/playlists/:id
* Remove playlist * Remove playlist
* *
* @param {RequestWithUser} req * @param {PlaylistControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async delete(req, res) { async delete(req, res) {
const jsonExpanded = await req.playlist.getOldJsonExpanded() req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
const jsonExpanded = req.playlist.toOldJSONExpanded()
await req.playlist.destroy() await req.playlist.destroy()
SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_removed', jsonExpanded) SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_removed', jsonExpanded)
res.sendStatus(200) res.sendStatus(200)
@ -183,43 +264,64 @@ class PlaylistController {
* POST: /api/playlists/:id/item * POST: /api/playlists/:id/item
* Add item to playlist * Add item to playlist
* *
* @param {RequestWithUser} req * This is not used by Abs web client or mobile apps. Only the batch endpoints are used.
*
* @param {PlaylistControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async addItem(req, res) { async addItem(req, res) {
const oldPlaylist = await Database.playlistModel.getById(req.playlist.id) const itemToAdd = req.body || {}
const itemToAdd = req.body
if (!itemToAdd.libraryItemId) { if (!itemToAdd.libraryItemId) {
return res.status(400).send('Request body has no libraryItemId') return res.status(400).send('Request body has no libraryItemId')
} }
const libraryItem = await Database.libraryItemModel.getOldById(itemToAdd.libraryItemId) const libraryItem = await Database.libraryItemModel.getExpandedById(itemToAdd.libraryItemId)
if (!libraryItem) { if (!libraryItem) {
return res.status(400).send('Library item not found') return res.status(400).send('Library item not found')
} }
if (libraryItem.libraryId !== oldPlaylist.libraryId) { if (libraryItem.libraryId !== req.playlist.libraryId) {
return res.status(400).send('Library item in different library') return res.status(400).send('Library item in different library')
} }
if (oldPlaylist.containsItem(itemToAdd)) {
return res.status(400).send('Item already in playlist')
}
if ((itemToAdd.episodeId && !libraryItem.isPodcast) || (libraryItem.isPodcast && !itemToAdd.episodeId)) { if ((itemToAdd.episodeId && !libraryItem.isPodcast) || (libraryItem.isPodcast && !itemToAdd.episodeId)) {
return res.status(400).send('Invalid item to add for this library type') return res.status(400).send('Invalid item to add for this library type')
} }
if (itemToAdd.episodeId && !libraryItem.media.checkHasEpisode(itemToAdd.episodeId)) { if (itemToAdd.episodeId && !libraryItem.media.podcastEpisodes.some((pe) => pe.id === itemToAdd.episodeId)) {
return res.status(400).send('Episode not found in library item') return res.status(400).send('Episode not found in library item')
} }
const playlistMediaItem = { req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
playlistId: oldPlaylist.id,
mediaItemId: itemToAdd.episodeId || libraryItem.media.id, if (req.playlist.checkHasMediaItem(itemToAdd.libraryItemId, itemToAdd.episodeId)) {
mediaItemType: itemToAdd.episodeId ? 'podcastEpisode' : 'book', return res.status(400).send('Item already in playlist')
order: oldPlaylist.items.length + 1 }
const jsonExpanded = req.playlist.toOldJSONExpanded()
const playlistMediaItem = {
playlistId: req.playlist.id,
mediaItemId: itemToAdd.episodeId || libraryItem.media.id,
mediaItemType: itemToAdd.episodeId ? 'podcastEpisode' : 'book',
order: req.playlist.playlistMediaItems.length + 1
}
await Database.playlistMediaItemModel.create(playlistMediaItem)
// Add the new item to to the old json expanded to prevent having to fully reload the playlist media items
if (itemToAdd.episodeId) {
const episode = libraryItem.media.podcastEpisodes.find((ep) => ep.id === itemToAdd.episodeId)
jsonExpanded.items.push({
episodeId: itemToAdd.episodeId,
episode: episode.toOldJSONExpanded(libraryItem.id),
libraryItemId: libraryItem.id,
libraryItem: libraryItem.toOldJSONMinified()
})
} else {
jsonExpanded.items.push({
libraryItemId: libraryItem.id,
libraryItem: libraryItem.toOldJSONExpanded()
})
} }
await Database.createPlaylistMediaItem(playlistMediaItem)
const jsonExpanded = await req.playlist.getOldJsonExpanded()
SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_updated', jsonExpanded) SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_updated', jsonExpanded)
res.json(jsonExpanded) res.json(jsonExpanded)
} }
@ -228,43 +330,36 @@ class PlaylistController {
* DELETE: /api/playlists/:id/item/:libraryItemId/:episodeId? * DELETE: /api/playlists/:id/item/:libraryItemId/:episodeId?
* Remove item from playlist * Remove item from playlist
* *
* @param {RequestWithUser} req * @param {PlaylistControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async removeItem(req, res) { async removeItem(req, res) {
const oldLibraryItem = await Database.libraryItemModel.getOldById(req.params.libraryItemId) req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
if (!oldLibraryItem) {
return res.status(404).send('Library item not found') let playlistMediaItem = null
if (req.params.episodeId) {
playlistMediaItem = req.playlist.playlistMediaItems.find((pmi) => pmi.mediaItemId === req.params.episodeId)
} else {
playlistMediaItem = req.playlist.playlistMediaItems.find((pmi) => pmi.mediaItem.libraryItem?.id === req.params.libraryItemId)
} }
if (!playlistMediaItem) {
// Get playlist media items
const mediaItemId = req.params.episodeId || oldLibraryItem.media.id
const playlistMediaItems = await req.playlist.getPlaylistMediaItems({
order: [['order', 'ASC']]
})
// Check if media item to delete is in playlist
const mediaItemToRemove = playlistMediaItems.find((pmi) => pmi.mediaItemId === mediaItemId)
if (!mediaItemToRemove) {
return res.status(404).send('Media item not found in playlist') return res.status(404).send('Media item not found in playlist')
} }
// Remove record // Remove record
await mediaItemToRemove.destroy() await playlistMediaItem.destroy()
req.playlist.playlistMediaItems = req.playlist.playlistMediaItems.filter((pmi) => pmi.id !== playlistMediaItem.id)
// Update playlist media items order // Update playlist media items order
let order = 1 for (const [index, mediaItem] of req.playlist.playlistMediaItems.entries()) {
for (const mediaItem of playlistMediaItems) { if (mediaItem.order !== index + 1) {
if (mediaItem.mediaItemId === mediaItemId) continue
if (mediaItem.order !== order) {
await mediaItem.update({ await mediaItem.update({
order order: index + 1
}) })
} }
order++
} }
const jsonExpanded = await req.playlist.getOldJsonExpanded() const jsonExpanded = req.playlist.toOldJSONExpanded()
// Playlist is removed when there are no items // Playlist is removed when there are no items
if (!jsonExpanded.items.length) { if (!jsonExpanded.items.length) {
@ -282,64 +377,68 @@ class PlaylistController {
* POST: /api/playlists/:id/batch/add * POST: /api/playlists/:id/batch/add
* Batch add playlist items * Batch add playlist items
* *
* @param {RequestWithUser} req * @param {PlaylistControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async addBatch(req, res) { async addBatch(req, res) {
if (!req.body.items?.length) { if (!req.body.items?.length || !Array.isArray(req.body.items) || req.body.items.some((i) => !i?.libraryItemId || typeof i.libraryItemId !== 'string' || (i.episodeId && typeof i.episodeId !== 'string'))) {
return res.status(400).send('Invalid request body') return res.status(400).send('Invalid request body items')
}
const itemsToAdd = req.body.items
const libraryItemIds = itemsToAdd.map((i) => i.libraryItemId).filter((i) => i)
if (!libraryItemIds.length) {
return res.status(400).send('Invalid request body')
} }
// Find all library items // Find all library items
const libraryItems = await Database.libraryItemModel.findAll({ const libraryItemIds = new Set(req.body.items.map((i) => i.libraryItemId).filter((i) => i))
where: {
id: libraryItemIds
}
})
// Get all existing playlist media items const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({ id: Array.from(libraryItemIds) })
const existingPlaylistMediaItems = await req.playlist.getPlaylistMediaItems({ if (libraryItems.length !== libraryItemIds.size) {
order: [['order', 'ASC']] return res.status(400).send('Invalid request body items')
}) }
req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
const mediaItemsToAdd = [] const mediaItemsToAdd = []
const jsonExpanded = req.playlist.toOldJSONExpanded()
// Setup array of playlistMediaItem records to add // Setup array of playlistMediaItem records to add
let order = existingPlaylistMediaItems.length + 1 let order = req.playlist.playlistMediaItems.length + 1
for (const item of itemsToAdd) { for (const item of req.body.items) {
const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId) const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId)
if (!libraryItem) {
return res.status(404).send('Item not found with id ' + item.libraryItemId) const mediaItemId = item.episodeId || libraryItem.media.id
if (req.playlist.playlistMediaItems.some((pmi) => pmi.mediaItemId === mediaItemId)) {
// Already exists in playlist
continue
} else { } else {
const mediaItemId = item.episodeId || libraryItem.mediaId mediaItemsToAdd.push({
if (existingPlaylistMediaItems.some((pmi) => pmi.mediaItemId === mediaItemId)) { playlistId: req.playlist.id,
// Already exists in playlist mediaItemId,
continue mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',
order: order++
})
// Add the new item to to the old json expanded to prevent having to fully reload the playlist media items
if (item.episodeId) {
const episode = libraryItem.media.podcastEpisodes.find((ep) => ep.id === item.episodeId)
jsonExpanded.items.push({
episodeId: item.episodeId,
episode: episode.toOldJSONExpanded(libraryItem.id),
libraryItemId: libraryItem.id,
libraryItem: libraryItem.toOldJSONMinified()
})
} else { } else {
mediaItemsToAdd.push({ jsonExpanded.items.push({
playlistId: req.playlist.id, libraryItemId: libraryItem.id,
mediaItemId, libraryItem: libraryItem.toOldJSONExpanded()
mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',
order: order++
}) })
} }
} }
} }
let jsonExpanded = null
if (mediaItemsToAdd.length) { if (mediaItemsToAdd.length) {
await Database.createBulkPlaylistMediaItems(mediaItemsToAdd) await Database.playlistMediaItemModel.bulkCreate(mediaItemsToAdd)
jsonExpanded = await req.playlist.getOldJsonExpanded()
SocketAuthority.clientEmitter(req.playlist.userId, 'playlist_updated', jsonExpanded) SocketAuthority.clientEmitter(req.playlist.userId, 'playlist_updated', jsonExpanded)
} else {
jsonExpanded = await req.playlist.getOldJsonExpanded()
} }
res.json(jsonExpanded) res.json(jsonExpanded)
} }
@ -347,50 +446,40 @@ class PlaylistController {
* POST: /api/playlists/:id/batch/remove * POST: /api/playlists/:id/batch/remove
* Batch remove playlist items * Batch remove playlist items
* *
* @param {RequestWithUser} req * @param {PlaylistControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async removeBatch(req, res) { async removeBatch(req, res) {
if (!req.body.items?.length) { if (!req.body.items?.length || !Array.isArray(req.body.items) || req.body.items.some((i) => !i?.libraryItemId || typeof i.libraryItemId !== 'string' || (i.episodeId && typeof i.episodeId !== 'string'))) {
return res.status(400).send('Invalid request body') return res.status(400).send('Invalid request body items')
} }
const itemsToRemove = req.body.items req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem()
const libraryItemIds = itemsToRemove.map((i) => i.libraryItemId).filter((i) => i)
if (!libraryItemIds.length) {
return res.status(400).send('Invalid request body')
}
// Find all library items
const libraryItems = await Database.libraryItemModel.findAll({
where: {
id: libraryItemIds
}
})
// Get all existing playlist media items for playlist
const existingPlaylistMediaItems = await req.playlist.getPlaylistMediaItems({
order: [['order', 'ASC']]
})
let numMediaItems = existingPlaylistMediaItems.length
// Remove playlist media items // Remove playlist media items
let hasUpdated = false let hasUpdated = false
for (const item of itemsToRemove) { for (const item of req.body.items) {
const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId) let playlistMediaItem = null
if (!libraryItem) continue if (item.episodeId) {
const mediaItemId = item.episodeId || libraryItem.mediaId playlistMediaItem = req.playlist.playlistMediaItems.find((pmi) => pmi.mediaItemId === item.episodeId)
const existingMediaItem = existingPlaylistMediaItems.find((pmi) => pmi.mediaItemId === mediaItemId) } else {
if (!existingMediaItem) continue playlistMediaItem = req.playlist.playlistMediaItems.find((pmi) => pmi.mediaItem.libraryItem?.id === item.libraryItemId)
await existingMediaItem.destroy() }
if (!playlistMediaItem) {
Logger.warn(`[PlaylistController] Playlist item not found in playlist ${req.playlist.id}`, item)
continue
}
await playlistMediaItem.destroy()
req.playlist.playlistMediaItems = req.playlist.playlistMediaItems.filter((pmi) => pmi.id !== playlistMediaItem.id)
hasUpdated = true hasUpdated = true
numMediaItems--
} }
const jsonExpanded = await req.playlist.getOldJsonExpanded() const jsonExpanded = req.playlist.toOldJSONExpanded()
if (hasUpdated) { if (hasUpdated) {
// Playlist is removed when there are no items // Playlist is removed when there are no items
if (!numMediaItems) { if (!req.playlist.playlistMediaItems.length) {
Logger.info(`[PlaylistController] Playlist "${req.playlist.name}" has no more items - removing it`) Logger.info(`[PlaylistController] Playlist "${req.playlist.name}" has no more items - removing it`)
await req.playlist.destroy() await req.playlist.destroy()
SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_removed', jsonExpanded) SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_removed', jsonExpanded)
@ -425,33 +514,41 @@ class PlaylistController {
return res.status(400).send('Collection has no books') return res.status(400).send('Collection has no books')
} }
const oldPlaylist = new Playlist() const transaction = await Database.sequelize.transaction()
oldPlaylist.setData({ try {
userId: req.user.id, const playlist = await Database.playlistModel.create(
libraryId: collection.libraryId, {
name: collection.name, userId: req.user.id,
description: collection.description || null libraryId: collection.libraryId,
}) name: collection.name,
description: collection.description || null
},
{ transaction }
)
// Create Playlist record const mediaItemsToAdd = []
const newPlaylist = await Database.playlistModel.createFromOld(oldPlaylist) for (const [index, libraryItem] of collectionExpanded.books.entries()) {
mediaItemsToAdd.push({
playlistId: playlist.id,
mediaItemId: libraryItem.media.id,
mediaItemType: 'book',
order: index + 1
})
}
await Database.playlistMediaItemModel.bulkCreate(mediaItemsToAdd, { transaction })
// Create PlaylistMediaItem records await transaction.commit()
const mediaItemsToAdd = []
let order = 1 playlist.playlistMediaItems = await playlist.getMediaItemsExpandedWithLibraryItem()
for (const libraryItem of collectionExpanded.books) {
mediaItemsToAdd.push({ const jsonExpanded = playlist.toOldJSONExpanded()
playlistId: newPlaylist.id, SocketAuthority.clientEmitter(playlist.userId, 'playlist_added', jsonExpanded)
mediaItemId: libraryItem.media.id, res.json(jsonExpanded)
mediaItemType: 'book', } catch (error) {
order: order++ await transaction.rollback()
}) Logger.error('[PlaylistController] createFromCollection:', error)
res.status(500).send('Failed to create playlist')
} }
await Database.createBulkPlaylistMediaItems(mediaItemsToAdd)
const jsonExpanded = await newPlaylist.getOldJsonExpanded()
SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded)
res.json(jsonExpanded)
} }
/** /**

View File

@ -1,3 +1,4 @@
const Path = require('path')
const { Request, Response, NextFunction } = require('express') const { Request, Response, NextFunction } = require('express')
const Logger = require('../Logger') const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority') const SocketAuthority = require('../SocketAuthority')
@ -12,13 +13,16 @@ const { validateUrl } = require('../utils/index')
const Scanner = require('../scanner/Scanner') const Scanner = require('../scanner/Scanner')
const CoverManager = require('../managers/CoverManager') const CoverManager = require('../managers/CoverManager')
const LibraryItem = require('../objects/LibraryItem')
/** /**
* @typedef RequestUserObject * @typedef RequestUserObject
* @property {import('../models/User')} user * @property {import('../models/User')} user
* *
* @typedef {Request & RequestUserObject} RequestWithUser * @typedef {Request & RequestUserObject} RequestWithUser
*
* @typedef RequestEntityObject
* @property {import('../models/LibraryItem')} libraryItem
*
* @typedef {RequestWithUser & RequestEntityObject} RequestWithLibraryItem
*/ */
class PodcastController { class PodcastController {
@ -37,6 +41,9 @@ class PodcastController {
return res.sendStatus(403) return res.sendStatus(403)
} }
const payload = req.body const payload = req.body
if (!payload.media || !payload.media.metadata) {
return res.status(400).send('Invalid request body. "media" and "media.metadata" are required')
}
const library = await Database.libraryModel.findByIdWithFolders(payload.libraryId) const library = await Database.libraryModel.findByIdWithFolders(payload.libraryId)
if (!library) { if (!library) {
@ -78,48 +85,87 @@ class PodcastController {
let relPath = payload.path.replace(folder.fullPath, '') let relPath = payload.path.replace(folder.fullPath, '')
if (relPath.startsWith('/')) relPath = relPath.slice(1) if (relPath.startsWith('/')) relPath = relPath.slice(1)
const libraryItemPayload = { let newLibraryItem = null
path: podcastPath, const transaction = await Database.sequelize.transaction()
relPath, try {
folderId: payload.folderId, const podcast = await Database.podcastModel.createFromRequest(payload.media, transaction)
libraryId: payload.libraryId,
ino: libraryItemFolderStats.ino, newLibraryItem = await Database.libraryItemModel.create(
mtimeMs: libraryItemFolderStats.mtimeMs || 0, {
ctimeMs: libraryItemFolderStats.ctimeMs || 0, ino: libraryItemFolderStats.ino,
birthtimeMs: libraryItemFolderStats.birthtimeMs || 0, path: podcastPath,
media: payload.media relPath,
mediaId: podcast.id,
mediaType: 'podcast',
isFile: false,
isMissing: false,
isInvalid: false,
mtime: libraryItemFolderStats.mtimeMs || 0,
ctime: libraryItemFolderStats.ctimeMs || 0,
birthtime: libraryItemFolderStats.birthtimeMs || 0,
size: 0,
libraryFiles: [],
extraData: {},
libraryId: library.id,
libraryFolderId: folder.id
},
{ transaction }
)
await transaction.commit()
} catch (error) {
Logger.error(`[PodcastController] Failed to create podcast: ${error}`)
await transaction.rollback()
return res.status(500).send('Failed to create podcast')
} }
const libraryItem = new LibraryItem() newLibraryItem.media = await newLibraryItem.getMediaExpanded()
libraryItem.setData('podcast', libraryItemPayload)
// Download and save cover image // Download and save cover image
if (payload.media.metadata.imageUrl) { if (typeof payload.media.metadata.imageUrl === 'string' && payload.media.metadata.imageUrl.startsWith('http')) {
// TODO: Scan cover image to library files
// Podcast cover will always go into library item folder // Podcast cover will always go into library item folder
const coverResponse = await CoverManager.downloadCoverFromUrl(libraryItem, payload.media.metadata.imageUrl, true) const coverResponse = await CoverManager.downloadCoverFromUrlNew(payload.media.metadata.imageUrl, newLibraryItem.id, newLibraryItem.path, true)
if (coverResponse) { if (coverResponse.error) {
if (coverResponse.error) { Logger.error(`[PodcastController] Download cover error from "${payload.media.metadata.imageUrl}": ${coverResponse.error}`)
Logger.error(`[PodcastController] Download cover error from "${payload.media.metadata.imageUrl}": ${coverResponse.error}`) } else if (coverResponse.cover) {
} else if (coverResponse.cover) { const coverImageFileStats = await getFileTimestampsWithIno(coverResponse.cover)
libraryItem.media.coverPath = coverResponse.cover if (!coverImageFileStats) {
Logger.error(`[PodcastController] Failed to get cover image stats for "${coverResponse.cover}"`)
} else {
// Add libraryFile to libraryItem and coverPath to podcast
const newLibraryFile = {
ino: coverImageFileStats.ino,
fileType: 'image',
addedAt: Date.now(),
updatedAt: Date.now(),
metadata: {
filename: Path.basename(coverResponse.cover),
ext: Path.extname(coverResponse.cover).slice(1),
path: coverResponse.cover,
relPath: Path.basename(coverResponse.cover),
size: coverImageFileStats.size,
mtimeMs: coverImageFileStats.mtimeMs || 0,
ctimeMs: coverImageFileStats.ctimeMs || 0,
birthtimeMs: coverImageFileStats.birthtimeMs || 0
}
}
newLibraryItem.libraryFiles.push(newLibraryFile)
newLibraryItem.changed('libraryFiles', true)
await newLibraryItem.save()
newLibraryItem.media.coverPath = coverResponse.cover
await newLibraryItem.media.save()
} }
} }
} }
await Database.createLibraryItem(libraryItem) SocketAuthority.emitter('item_added', newLibraryItem.toOldJSONExpanded())
SocketAuthority.emitter('item_added', libraryItem.toJSONExpanded())
res.json(libraryItem.toJSONExpanded()) res.json(newLibraryItem.toOldJSONExpanded())
if (payload.episodesToDownload?.length) {
Logger.info(`[PodcastController] Podcast created now starting ${payload.episodesToDownload.length} episode downloads`)
this.podcastManager.downloadPodcastEpisodes(libraryItem, payload.episodesToDownload)
}
// Turn on podcast auto download cron if not already on // Turn on podcast auto download cron if not already on
if (libraryItem.media.autoDownloadEpisodes) { if (newLibraryItem.media.autoDownloadEpisodes) {
this.cronManager.checkUpdatePodcastCron(libraryItem) this.cronManager.checkUpdatePodcastCron(newLibraryItem)
} }
} }
@ -213,7 +259,7 @@ class PodcastController {
* *
* @this import('../routers/ApiRouter') * @this import('../routers/ApiRouter')
* *
* @param {RequestWithUser} req * @param {RequestWithLibraryItem} req
* @param {Response} res * @param {Response} res
*/ */
async checkNewEpisodes(req, res) { async checkNewEpisodes(req, res) {
@ -222,15 +268,14 @@ class PodcastController {
return res.sendStatus(403) return res.sendStatus(403)
} }
var libraryItem = req.libraryItem if (!req.libraryItem.media.feedURL) {
if (!libraryItem.media.metadata.feedUrl) { Logger.error(`[PodcastController] checkNewEpisodes no feed url for item ${req.libraryItem.id}`)
Logger.error(`[PodcastController] checkNewEpisodes no feed url for item ${libraryItem.id}`) return res.status(400).send('Podcast has no rss feed url')
return res.status(500).send('Podcast has no rss feed url')
} }
const maxEpisodesToDownload = !isNaN(req.query.limit) ? Number(req.query.limit) : 3 const maxEpisodesToDownload = !isNaN(req.query.limit) ? Number(req.query.limit) : 3
var newEpisodes = await this.podcastManager.checkAndDownloadNewEpisodes(libraryItem, maxEpisodesToDownload) const newEpisodes = await this.podcastManager.checkAndDownloadNewEpisodes(req.libraryItem, maxEpisodesToDownload)
res.json({ res.json({
episodes: newEpisodes || [] episodes: newEpisodes || []
}) })
@ -258,23 +303,28 @@ class PodcastController {
* *
* @this {import('../routers/ApiRouter')} * @this {import('../routers/ApiRouter')}
* *
* @param {RequestWithUser} req * @param {RequestWithLibraryItem} req
* @param {Response} res * @param {Response} res
*/ */
getEpisodeDownloads(req, res) { getEpisodeDownloads(req, res) {
var libraryItem = req.libraryItem const downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(req.libraryItem.id)
var downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(libraryItem.id)
res.json({ res.json({
downloads: downloadsInQueue.map((d) => d.toJSONForClient()) downloads: downloadsInQueue.map((d) => d.toJSONForClient())
}) })
} }
/**
* GET: /api/podcasts/:id/search-episode
* Search for an episode in a podcast
*
* @param {RequestWithLibraryItem} req
* @param {Response} res
*/
async findEpisode(req, res) { async findEpisode(req, res) {
const rssFeedUrl = req.libraryItem.media.metadata.feedUrl const rssFeedUrl = req.libraryItem.media.feedURL
if (!rssFeedUrl) { if (!rssFeedUrl) {
Logger.error(`[PodcastController] findEpisode: Podcast has no feed url`) Logger.error(`[PodcastController] findEpisode: Podcast has no feed url`)
return res.status(500).send('Podcast does not have an RSS feed URL') return res.status(400).send('Podcast does not have an RSS feed URL')
} }
const searchTitle = req.query.title const searchTitle = req.query.title
@ -292,7 +342,7 @@ class PodcastController {
* *
* @this {import('../routers/ApiRouter')} * @this {import('../routers/ApiRouter')}
* *
* @param {RequestWithUser} req * @param {RequestWithLibraryItem} req
* @param {Response} res * @param {Response} res
*/ */
async downloadEpisodes(req, res) { async downloadEpisodes(req, res) {
@ -300,13 +350,13 @@ class PodcastController {
Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to download episodes`) Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to download episodes`)
return res.sendStatus(403) return res.sendStatus(403)
} }
const libraryItem = req.libraryItem
const episodes = req.body const episodes = req.body
if (!episodes?.length) { if (!Array.isArray(episodes) || !episodes.length) {
return res.sendStatus(400) return res.sendStatus(400)
} }
this.podcastManager.downloadPodcastEpisodes(libraryItem, episodes) this.podcastManager.downloadPodcastEpisodes(req.libraryItem, episodes)
res.sendStatus(200) res.sendStatus(200)
} }
@ -315,7 +365,7 @@ class PodcastController {
* *
* @this {import('../routers/ApiRouter')} * @this {import('../routers/ApiRouter')}
* *
* @param {RequestWithUser} req * @param {RequestWithLibraryItem} req
* @param {Response} res * @param {Response} res
*/ */
async quickMatchEpisodes(req, res) { async quickMatchEpisodes(req, res) {
@ -327,8 +377,7 @@ class PodcastController {
const overrideDetails = req.query.override === '1' const overrideDetails = req.query.override === '1'
const episodesUpdated = await Scanner.quickMatchPodcastEpisodes(req.libraryItem, { overrideDetails }) const episodesUpdated = await Scanner.quickMatchPodcastEpisodes(req.libraryItem, { overrideDetails })
if (episodesUpdated) { if (episodesUpdated) {
await Database.updateLibraryItem(req.libraryItem) SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded())
} }
res.json({ res.json({
@ -339,58 +388,76 @@ class PodcastController {
/** /**
* PATCH: /api/podcasts/:id/episode/:episodeId * PATCH: /api/podcasts/:id/episode/:episodeId
* *
* @param {RequestWithUser} req * @param {RequestWithLibraryItem} req
* @param {Response} res * @param {Response} res
*/ */
async updateEpisode(req, res) { async updateEpisode(req, res) {
const libraryItem = req.libraryItem /** @type {import('../models/PodcastEpisode')} */
const episode = req.libraryItem.media.podcastEpisodes.find((ep) => ep.id === req.params.episodeId)
var episodeId = req.params.episodeId if (!episode) {
if (!libraryItem.media.checkHasEpisode(episodeId)) {
return res.status(404).send('Episode not found') return res.status(404).send('Episode not found')
} }
if (libraryItem.media.updateEpisode(episodeId, req.body)) { const updatePayload = {}
await Database.updateLibraryItem(libraryItem) const supportedStringKeys = ['title', 'subtitle', 'description', 'pubDate', 'episode', 'season', 'episodeType']
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) for (const key in req.body) {
if (supportedStringKeys.includes(key) && typeof req.body[key] === 'string') {
updatePayload[key] = req.body[key]
} else if (key === 'chapters' && Array.isArray(req.body[key]) && req.body[key].every((ch) => typeof ch === 'object' && ch.title && ch.start)) {
updatePayload[key] = req.body[key]
} else if (key === 'publishedAt' && typeof req.body[key] === 'number') {
updatePayload[key] = req.body[key]
}
} }
res.json(libraryItem.toJSONExpanded()) if (Object.keys(updatePayload).length) {
episode.set(updatePayload)
if (episode.changed()) {
Logger.info(`[PodcastController] Updated episode "${episode.title}" keys`, episode.changed())
await episode.save()
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
} else {
Logger.info(`[PodcastController] No changes to episode "${episode.title}"`)
}
}
res.json(req.libraryItem.toOldJSONExpanded())
} }
/** /**
* GET: /api/podcasts/:id/episode/:episodeId * GET: /api/podcasts/:id/episode/:episodeId
* *
* @param {RequestWithUser} req * @param {RequestWithLibraryItem} req
* @param {Response} res * @param {Response} res
*/ */
async getEpisode(req, res) { async getEpisode(req, res) {
const episodeId = req.params.episodeId const episodeId = req.params.episodeId
const libraryItem = req.libraryItem
const episode = libraryItem.media.episodes.find((ep) => ep.id === episodeId) /** @type {import('../models/PodcastEpisode')} */
const episode = req.libraryItem.media.podcastEpisodes.find((ep) => ep.id === episodeId)
if (!episode) { if (!episode) {
Logger.error(`[PodcastController] getEpisode episode ${episodeId} not found for item ${libraryItem.id}`) Logger.error(`[PodcastController] getEpisode episode ${episodeId} not found for item ${req.libraryItem.id}`)
return res.sendStatus(404) return res.sendStatus(404)
} }
res.json(episode) res.json(episode.toOldJSON(req.libraryItem.id))
} }
/** /**
* DELETE: /api/podcasts/:id/episode/:episodeId * DELETE: /api/podcasts/:id/episode/:episodeId
* *
* @param {RequestWithUser} req * @param {RequestWithLibraryItem} req
* @param {Response} res * @param {Response} res
*/ */
async removeEpisode(req, res) { async removeEpisode(req, res) {
const episodeId = req.params.episodeId const episodeId = req.params.episodeId
const libraryItem = req.libraryItem
const hardDelete = req.query.hard === '1' const hardDelete = req.query.hard === '1'
const episode = libraryItem.media.episodes.find((ep) => ep.id === episodeId) /** @type {import('../models/PodcastEpisode')} */
const episode = req.libraryItem.media.podcastEpisodes.find((ep) => ep.id === episodeId)
if (!episode) { if (!episode) {
Logger.error(`[PodcastController] removeEpisode episode ${episodeId} not found for item ${libraryItem.id}`) Logger.error(`[PodcastController] removeEpisode episode ${episodeId} not found for item ${req.libraryItem.id}`)
return res.sendStatus(404) return res.sendStatus(404)
} }
@ -407,36 +474,8 @@ class PodcastController {
}) })
} }
// Remove episode from Podcast and library file // Remove episode from playlists
const episodeRemoved = libraryItem.media.removeEpisode(episodeId) await Database.playlistModel.removeMediaItemsFromPlaylists([episodeId])
if (episodeRemoved?.audioFile) {
libraryItem.removeLibraryFile(episodeRemoved.audioFile.ino)
}
// Update/remove playlists that had this podcast episode
const playlistMediaItems = await Database.playlistMediaItemModel.findAll({
where: {
mediaItemId: episodeId
},
include: {
model: Database.playlistModel,
include: Database.playlistMediaItemModel
}
})
for (const pmi of playlistMediaItems) {
const numItems = pmi.playlist.playlistMediaItems.length - 1
if (!numItems) {
Logger.info(`[PodcastController] Playlist "${pmi.playlist.name}" has no more items - removing it`)
const jsonExpanded = await pmi.playlist.getOldJsonExpanded()
SocketAuthority.clientEmitter(pmi.playlist.userId, 'playlist_removed', jsonExpanded)
await pmi.playlist.destroy()
} else {
await pmi.destroy()
const jsonExpanded = await pmi.playlist.getOldJsonExpanded()
SocketAuthority.clientEmitter(pmi.playlist.userId, 'playlist_updated', jsonExpanded)
}
}
// Remove media progress for this episode // Remove media progress for this episode
const mediaProgressRemoved = await Database.mediaProgressModel.destroy({ const mediaProgressRemoved = await Database.mediaProgressModel.destroy({
@ -448,9 +487,16 @@ class PodcastController {
Logger.info(`[PodcastController] Removed ${mediaProgressRemoved} media progress for episode ${episode.id}`) Logger.info(`[PodcastController] Removed ${mediaProgressRemoved} media progress for episode ${episode.id}`)
} }
await Database.updateLibraryItem(libraryItem) // Remove episode
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) await episode.destroy()
res.json(libraryItem.toJSON())
// Remove library file
req.libraryItem.libraryFiles = req.libraryItem.libraryFiles.filter((file) => file.ino !== episode.audioFile.ino)
req.libraryItem.changed('libraryFiles', true)
await req.libraryItem.save()
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
res.json(req.libraryItem.toOldJSON())
} }
/** /**
@ -460,15 +506,15 @@ class PodcastController {
* @param {NextFunction} next * @param {NextFunction} next
*/ */
async middleware(req, res, next) { async middleware(req, res, next) {
const item = await Database.libraryItemModel.getOldById(req.params.id) const libraryItem = await Database.libraryItemModel.getExpandedById(req.params.id)
if (!item?.media) return res.sendStatus(404) if (!libraryItem?.media) return res.sendStatus(404)
if (!item.isPodcast) { if (!libraryItem.isPodcast) {
return res.sendStatus(500) return res.sendStatus(500)
} }
// Check user can access this library item // Check user can access this library item
if (!req.user.checkCanAccessLibraryItem(item)) { if (!req.user.checkCanAccessLibraryItem(libraryItem)) {
return res.sendStatus(403) return res.sendStatus(403)
} }
@ -480,7 +526,7 @@ class PodcastController {
return res.sendStatus(403) return res.sendStatus(403)
} }
req.libraryItem = item req.libraryItem = libraryItem
next() next()
} }
} }

View File

@ -1,7 +1,8 @@
const { Request, Response, NextFunction } = require('express') const { Request, Response, NextFunction } = require('express')
const Logger = require('../Logger') const Logger = require('../Logger')
const Database = require('../Database') const Database = require('../Database')
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
const RssFeedManager = require('../managers/RssFeedManager')
/** /**
* @typedef RequestUserObject * @typedef RequestUserObject
@ -22,10 +23,10 @@ class RSSFeedController {
* @param {Response} res * @param {Response} res
*/ */
async getAll(req, res) { async getAll(req, res) {
const feeds = await this.rssFeedManager.getFeeds() const feeds = await RssFeedManager.getFeeds()
res.json({ res.json({
feeds: feeds.map((f) => f.toJSON()), feeds: feeds.map((f) => f.toOldJSON()),
minified: feeds.map((f) => f.toJSONMinified()) minified: feeds.map((f) => f.toOldJSONMinified())
}) })
} }
@ -38,38 +39,43 @@ class RSSFeedController {
* @param {Response} res * @param {Response} res
*/ */
async openRSSFeedForItem(req, res) { async openRSSFeedForItem(req, res) {
const options = req.body || {} const reqBody = req.body || {}
const item = await Database.libraryItemModel.getOldById(req.params.itemId) const itemExpanded = await Database.libraryItemModel.getExpandedById(req.params.itemId)
if (!item) return res.sendStatus(404) if (!itemExpanded) return res.sendStatus(404)
// Check user can access this library item // Check user can access this library item
if (!req.user.checkCanAccessLibraryItem(item)) { if (!req.user.checkCanAccessLibraryItem(itemExpanded)) {
Logger.error(`[RSSFeedController] User "${req.user.username}" attempted to open an RSS feed for item "${item.media.metadata.title}" that they don\'t have access to`) Logger.error(`[RSSFeedController] User "${req.user.username}" attempted to open an RSS feed for item "${itemExpanded.media.title}" that they don\'t have access to`)
return res.sendStatus(403) return res.sendStatus(403)
} }
// Check request body options exist // Check request body options exist
if (!options.serverAddress || !options.slug) { if (!reqBody.serverAddress || !reqBody.slug || typeof reqBody.serverAddress !== 'string' || typeof reqBody.slug !== 'string') {
Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`) Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`)
return res.status(400).send('Invalid request body') return res.status(400).send('Invalid request body')
} }
// Check item has audio tracks // Check item has audio tracks
if (!item.media.numTracks) { if (!itemExpanded.hasAudioTracks()) {
Logger.error(`[RSSFeedController] Cannot open RSS feed for item "${item.media.metadata.title}" because it has no audio tracks`) Logger.error(`[RSSFeedController] Cannot open RSS feed for item "${itemExpanded.media.title}" because it has no audio tracks`)
return res.status(400).send('Item has no audio tracks') return res.status(400).send('Item has no audio tracks')
} }
// Check that this slug is not being used for another feed (slug will also be the Feed id) // Check that this slug is not being used for another feed (slug will also be the Feed id)
if (await this.rssFeedManager.findFeedBySlug(options.slug)) { if (await RssFeedManager.checkExistsBySlug(reqBody.slug)) {
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`) Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${reqBody.slug}" is already in use`)
return res.status(400).send('Slug already in use') return res.status(400).send('Slug already in use')
} }
const feed = await this.rssFeedManager.openFeedForItem(req.user.id, item, req.body) const feed = await RssFeedManager.openFeedForItem(req.user.id, itemExpanded, reqBody)
if (!feed) {
Logger.error(`[RSSFeedController] Failed to open RSS feed for item "${itemExpanded.media.title}"`)
return res.status(500).send('Failed to open RSS feed')
}
res.json({ res.json({
feed: feed.toJSONMinified() feed: feed.toOldJSONMinified()
}) })
} }
@ -82,35 +88,37 @@ class RSSFeedController {
* @param {Response} res * @param {Response} res
*/ */
async openRSSFeedForCollection(req, res) { async openRSSFeedForCollection(req, res) {
const options = req.body || {} const reqBody = req.body || {}
const collection = await Database.collectionModel.findByPk(req.params.collectionId)
if (!collection) return res.sendStatus(404)
// Check request body options exist // Check request body options exist
if (!options.serverAddress || !options.slug) { if (!reqBody.serverAddress || !reqBody.slug || typeof reqBody.serverAddress !== 'string' || typeof reqBody.slug !== 'string') {
Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`) Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`)
return res.status(400).send('Invalid request body') return res.status(400).send('Invalid request body')
} }
// Check that this slug is not being used for another feed (slug will also be the Feed id) // Check that this slug is not being used for another feed (slug will also be the Feed id)
if (await this.rssFeedManager.findFeedBySlug(options.slug)) { if (await RssFeedManager.checkExistsBySlug(reqBody.slug)) {
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`) Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${reqBody.slug}" is already in use`)
return res.status(400).send('Slug already in use') return res.status(400).send('Slug already in use')
} }
const collectionExpanded = await collection.getOldJsonExpanded() const collection = await Database.collectionModel.getExpandedById(req.params.collectionId)
const collectionItemsWithTracks = collectionExpanded.books.filter((li) => li.media.tracks.length) if (!collection) return res.sendStatus(404)
// Check collection has audio tracks // Check collection has audio tracks
if (!collectionItemsWithTracks.length) { if (!collection.books.some((book) => book.includedAudioFiles.length)) {
Logger.error(`[RSSFeedController] Cannot open RSS feed for collection "${collection.name}" because it has no audio tracks`) Logger.error(`[RSSFeedController] Cannot open RSS feed for collection "${collection.name}" because it has no audio tracks`)
return res.status(400).send('Collection has no audio tracks') return res.status(400).send('Collection has no audio tracks')
} }
const feed = await this.rssFeedManager.openFeedForCollection(req.user.id, collectionExpanded, req.body) const feed = await RssFeedManager.openFeedForCollection(req.user.id, collection, reqBody)
if (!feed) {
Logger.error(`[RSSFeedController] Failed to open RSS feed for collection "${collection.name}"`)
return res.status(500).send('Failed to open RSS feed')
}
res.json({ res.json({
feed: feed.toJSONMinified() feed: feed.toOldJSONMinified()
}) })
} }
@ -123,37 +131,37 @@ class RSSFeedController {
* @param {Response} res * @param {Response} res
*/ */
async openRSSFeedForSeries(req, res) { async openRSSFeedForSeries(req, res) {
const options = req.body || {} const reqBody = req.body || {}
const series = await Database.seriesModel.findByPk(req.params.seriesId)
if (!series) return res.sendStatus(404)
// Check request body options exist // Check request body options exist
if (!options.serverAddress || !options.slug) { if (!reqBody.serverAddress || !reqBody.slug || typeof reqBody.serverAddress !== 'string' || typeof reqBody.slug !== 'string') {
Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`) Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`)
return res.status(400).send('Invalid request body') return res.status(400).send('Invalid request body')
} }
// Check that this slug is not being used for another feed (slug will also be the Feed id) // Check that this slug is not being used for another feed (slug will also be the Feed id)
if (await this.rssFeedManager.findFeedBySlug(options.slug)) { if (await RssFeedManager.checkExistsBySlug(reqBody.slug)) {
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`) Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${reqBody.slug}" is already in use`)
return res.status(400).send('Slug already in use') return res.status(400).send('Slug already in use')
} }
const seriesJson = series.toOldJSON() const series = await Database.seriesModel.getExpandedById(req.params.seriesId)
if (!series) return res.sendStatus(404)
// Get books in series that have audio tracks
seriesJson.books = (await libraryItemsBookFilters.getLibraryItemsForSeries(series)).filter((li) => li.media.numTracks)
// Check series has audio tracks // Check series has audio tracks
if (!seriesJson.books.length) { if (!series.books.some((book) => book.includedAudioFiles.length)) {
Logger.error(`[RSSFeedController] Cannot open RSS feed for series "${seriesJson.name}" because it has no audio tracks`) Logger.error(`[RSSFeedController] Cannot open RSS feed for series "${series.name}" because it has no audio tracks`)
return res.status(400).send('Series has no audio tracks') return res.status(400).send('Series has no audio tracks')
} }
const feed = await this.rssFeedManager.openFeedForSeries(req.user.id, seriesJson, req.body) const feed = await RssFeedManager.openFeedForSeries(req.user.id, series, req.body)
if (!feed) {
Logger.error(`[RSSFeedController] Failed to open RSS feed for series "${series.name}"`)
return res.status(500).send('Failed to open RSS feed')
}
res.json({ res.json({
feed: feed.toJSONMinified() feed: feed.toOldJSONMinified()
}) })
} }
@ -165,8 +173,16 @@ class RSSFeedController {
* @param {RequestWithUser} req * @param {RequestWithUser} req
* @param {Response} res * @param {Response} res
*/ */
closeRSSFeed(req, res) { async closeRSSFeed(req, res) {
this.rssFeedManager.closeRssFeed(req, res) const feed = await Database.feedModel.findByPk(req.params.id)
if (!feed) {
Logger.error(`[RSSFeedController] Cannot close RSS feed because feed "${req.params.id}" does not exist`)
return res.sendStatus(404)
}
await RssFeedManager.handleCloseFeed(feed)
res.sendStatus(200)
} }
/** /**

View File

@ -24,7 +24,7 @@ class SearchController {
*/ */
async findBooks(req, res) { async findBooks(req, res) {
const id = req.query.id const id = req.query.id
const libraryItem = await Database.libraryItemModel.getOldById(id) const libraryItem = await Database.libraryItemModel.getExpandedById(id)
const provider = req.query.provider || 'google' const provider = req.query.provider || 'google'
const title = req.query.title || '' const title = req.query.title || ''
const author = req.query.author || '' const author = req.query.author || ''

View File

@ -2,6 +2,9 @@ const { Request, Response, NextFunction } = require('express')
const Logger = require('../Logger') const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority') const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database') const Database = require('../Database')
const RssFeedManager = require('../managers/RssFeedManager')
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters') const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
/** /**
@ -51,8 +54,8 @@ class SeriesController {
} }
if (include.includes('rssfeed')) { if (include.includes('rssfeed')) {
const feedObj = await this.rssFeedManager.findFeedForEntityId(seriesJson.id) const feedObj = await RssFeedManager.findFeedForEntityId(seriesJson.id)
seriesJson.rssFeed = feedObj?.toJSONMinified() || null seriesJson.rssFeed = feedObj?.toOldJSONMinified() || null
} }
res.json(seriesJson) res.json(seriesJson)

View File

@ -149,7 +149,7 @@ class SessionController {
* @param {Response} res * @param {Response} res
*/ */
async getOpenSession(req, res) { async getOpenSession(req, res) {
const libraryItem = await Database.libraryItemModel.getOldById(req.playbackSession.libraryItemId) const libraryItem = await Database.libraryItemModel.getExpandedById(req.playbackSession.libraryItemId)
const sessionForClient = req.playbackSession.toJSONForClient(libraryItem) const sessionForClient = req.playbackSession.toJSONForClient(libraryItem)
res.json(sessionForClient) res.json(sessionForClient)
} }

Some files were not shown because too many files have changed in this diff Show More