Compare commits

..

94 Commits

Author SHA1 Message Date
advplyr
3cae110360 Merge branch 'master' into plugin-implementation-demo 2024-12-30 13:48:02 -06:00
advplyr
2464aac2bf Version bump v2.17.6 2024-12-29 17:11:46 -06:00
advplyr
b6b786e3a6 Merge pull request #3735 from weblate/weblate-audiobookshelf-abs-web-client
Translations update from Hosted Weblate
2024-12-29 16:54:46 -06:00
Jan-Eric Myhrgren
bacb8aeac7 Translated using Weblate (Swedish)
Currently translated at 68.7% (743 of 1080 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sv/
2024-12-29 22:53:11 +00:00
pranelio
ba9277cc44 Translated using Weblate (Lithuanian)
Currently translated at 65.2% (705 of 1080 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/lt/
2024-12-29 22:53:10 +00:00
Plazec
3cc5fae586 Translated using Weblate (Czech)
Currently translated at 87.9% (950 of 1080 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/cs/
2024-12-29 22:53:10 +00:00
Tamanegii
da7d9c10ad Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (1080 of 1080 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/
2024-12-29 22:53:09 +00:00
Øystein S. Hegnander
aa82439125 Translated using Weblate (Norwegian Bokmål)
Currently translated at 91.9% (993 of 1080 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/nb_NO/
2024-12-29 22:53:09 +00:00
ugyes
2e0156d9fa Translated using Weblate (Hungarian)
Currently translated at 95.0% (1026 of 1080 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hu/
2024-12-29 22:53:08 +00:00
Øystein S. Hegnander
20e0172fa3 Translated using Weblate (Norwegian Bokmål)
Currently translated at 82.3% (889 of 1080 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/nb_NO/
2024-12-29 22:53:07 +00:00
jonarihen
6928f6eeb6 Translated using Weblate (Danish)
Currently translated at 62.3% (673 of 1080 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/da/
2024-12-29 22:53:07 +00:00
Greg Lorenzen
4cdc2a8c28 Feat/download via share link (#3666)
* Adds share download endpoint
* Adds Downloadable toggle to share modal

---------

Co-authored-by: advplyr <advplyr@protonmail.com>
2024-12-29 16:52:57 -06:00
advplyr
e0c674d9a9 Fix:Opening audiobook RSS feeds use audiofile name #3752 2024-12-28 16:36:53 -06:00
advplyr
727310ab75 Merge pull request #3751 from nichwall/rss_feed_image_fix
Change: height of RSS feed preview to match aspect ratio
2024-12-27 17:19:24 -06:00
Nicholas Wallace
f46b5a533c Change: height of RSS feed preview to match aspect ratio 2024-12-26 22:53:45 -07:00
advplyr
f3e9cfbe45 Merge pull request #3726 from mikiher/lazy-bookshelf-optimizations
LazyBookshelf optimizations
2024-12-26 16:42:28 -06:00
advplyr
4d8501c347 Update skeleton card to have box shadow, fix last row of skeleton cards 2024-12-26 16:34:25 -06:00
advplyr
c60d74774a Merge branch 'master' into plugin-implementation-demo 2024-12-26 15:38:28 -06:00
advplyr
b4e8f16174 Merge pull request #3575 from glorenzen/multi-select-keyboard-navigation
Multi select keyboard navigation
2024-12-25 09:45:28 -06:00
advplyr
7073f17cca Accessibility update for multi select inputs and edit series modal 2024-12-25 09:40:16 -06:00
advplyr
e1c41e4e58 Accessibility update edit modal tabs 2024-12-25 09:18:18 -06:00
advplyr
13f73cc79d Merge branch 'master' into multi-select-keyboard-navigation 2024-12-25 09:04:43 -06:00
advplyr
d811ec3806 Merge pull request #3714 from nichwall/zip_download_speedup
Change: no compression when downloading library item as zip file
2024-12-25 08:59:43 -06:00
advplyr
e8505cb637 Merge pull request #3727 from brinlyau/patch-1
feat: Added Australia and New Zealand podcast regions
2024-12-24 15:18:50 -06:00
advplyr
94fdd99ab5 Fix wrong url used for SSRF filter in fileUtils 2024-12-24 15:07:11 -06:00
advplyr
7557f3e2b9 Fix plugin scan to not include subdirs, update external plugin path env variable to DEV_PLUGINS_PATH to support a folder of dev plugins 2024-12-24 13:04:54 -06:00
advplyr
331c7c011c Support SSRF_REQUEST_FILTER_WHITELIST as a comma separated string of hostnames to pass through the ssrf request filter #3742 2024-12-23 17:18:08 -06:00
advplyr
5f680d7277 Allow env variable to point to specific plugin path for debugging 2024-12-23 16:53:47 -06:00
advplyr
cbbdb0ec29 Update plugin extension prompt yes button text 2024-12-22 16:31:02 -06:00
advplyr
c8682c8456 Add minimal template plugin 2024-12-22 16:01:55 -06:00
advplyr
e7e0056288 Update plugins to only be enabled when ALLOW_PLUGINS=1 env variable is set or AllowPlugins: true in dev.js 2024-12-22 15:27:12 -06:00
advplyr
50e84fc2d5 Update PluginManager to singleton, update PluginContext, support prompt object in plugin extension 2024-12-22 15:15:56 -06:00
advplyr
a762e6ca03 Merge branch 'master' into plugin-implementation-demo 2024-12-22 12:03:08 -06:00
advplyr
5fa263023f Fix:Quick match not removing empty series/authors #3743 2024-12-22 10:58:22 -06:00
advplyr
7eb315a371 Fix watcher skip dot files #3230 2024-12-21 17:22:48 -06:00
advplyr
fe4d3c0852 Update plugins page ui 2024-12-21 17:09:19 -06:00
advplyr
048790b33a Updates on plugin apis and example 2024-12-21 16:48:56 -06:00
advplyr
fc17a74865 Update plugin to use uuid for id, update example plugin with taskmanager and socketauthority test 2024-12-21 14:54:43 -06:00
advplyr
cfe3deff3b Add isMissing to Plugin model, add manifest version and name validation, create/update plugins table 2024-12-21 13:26:42 -06:00
advplyr
5a96d8aeb3 Add Plugin model with migration 2024-12-21 12:43:20 -06:00
advplyr
23b480b11a Remove separate plugins dir and use metadata dir for plugins folder 2024-12-21 10:20:09 -06:00
mikiher
780c0dcb99 Merge branch 'master' into lazy-bookshelf-optimizations 2024-12-21 17:50:51 +02:00
mikiher
004210ee02 reuse entityTransform in mountEntityCard 2024-12-21 17:48:22 +02:00
mikiher
921880445a Introduce static skeleton cards 2024-12-21 17:42:32 +02:00
advplyr
0099ae633a Config page localization updates 2024-12-20 17:27:31 -06:00
advplyr
ad89fb2eac Update example plugin and add plugins frontend page with save config endpoint 2024-12-20 17:21:00 -06:00
advplyr
91d99deba1 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2024-12-19 18:07:28 -06:00
advplyr
e21cbc9ff4 Update .gitignore 2024-12-19 18:07:24 -06:00
advplyr
600c1e4668 Delete plugins directory 2024-12-19 18:06:42 -06:00
advplyr
aea2951b89 Accessibility updates to config page settings 2024-12-19 18:04:56 -06:00
advplyr
62bd7e73f4 Example of potential plugin implementation 2024-12-19 17:48:18 -06:00
advplyr
71b943f434 Update mobile toolbar nav to show queue for podcast libraries #3719 2024-12-18 17:44:46 -06:00
advplyr
ed0484a8e1 Merge pull request #3701 from weblate/weblate-audiobookshelf-abs-web-client
Translations update from Hosted Weblate
2024-12-18 05:13:49 -06:00
kuci-JK
5302f3225b Translated using Weblate (Czech)
Currently translated at 86.8% (938 of 1080 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/cs/
2024-12-18 00:44:26 +01:00
Plazec
a94a7b7940 Translated using Weblate (Czech)
Currently translated at 86.8% (938 of 1080 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/cs/
2024-12-18 00:44:26 +01:00
Dmitry
4318f64d60 Translated using Weblate (Russian)
Currently translated at 100.0% (1080 of 1080 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ru/
2024-12-18 00:44:25 +01:00
ugyes
26a6618e8f Translated using Weblate (Hungarian)
Currently translated at 95.0% (1026 of 1080 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hu/
2024-12-18 00:44:25 +01:00
ugyes
c242e9d3d6 Translated using Weblate (Hungarian)
Currently translated at 92.4% (998 of 1080 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hu/
2024-12-18 00:44:25 +01:00
gallegonovato
4ecb22f70d Translated using Weblate (Spanish)
Currently translated at 100.0% (1080 of 1080 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/es/
2024-12-18 00:44:25 +01:00
kuci-JK
547a49e95b Translated using Weblate (Czech)
Currently translated at 86.2% (931 of 1080 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/cs/
2024-12-18 00:44:25 +01:00
Plazec
b6875af148 Translated using Weblate (Czech)
Currently translated at 86.2% (931 of 1080 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/cs/
2024-12-18 00:44:25 +01:00
Pierrick Guillaume
c652b5bf74 Translated using Weblate (French)
Currently translated at 99.4% (1074 of 1080 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fr/
2024-12-18 00:44:25 +01:00
kuci-JK
eb0b92a547 Translated using Weblate (Czech)
Currently translated at 83.8% (906 of 1080 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/cs/
2024-12-18 00:44:25 +01:00
advplyr
b56bcbb802 Added translation using Weblate (Belarusian) 2024-12-18 00:44:25 +01:00
thehijacker
3b8af95211 Translated using Weblate (Slovenian)
Currently translated at 100.0% (1080 of 1080 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/
2024-12-18 00:44:25 +01:00
Bezruchenko Simon
a3332f0478 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (1080 of 1080 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/uk/
2024-12-18 00:44:25 +01:00
biuklija
46421d5f2c Translated using Weblate (Croatian)
Currently translated at 100.0% (1080 of 1080 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/
2024-12-18 00:44:25 +01:00
Mario
7db28d0e98 Translated using Weblate (German)
Currently translated at 100.0% (1080 of 1080 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/
2024-12-18 00:44:25 +01:00
Bezruchenko Simon
31d26929af Translated using Weblate (Ukrainian)
Currently translated at 100.0% (1078 of 1078 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/uk/
2024-12-18 00:44:25 +01:00
gallegonovato
086da5f6a1 Translated using Weblate (Spanish)
Currently translated at 100.0% (1078 of 1078 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/es/
2024-12-18 00:44:25 +01:00
thehijacker
09421a44e2 Translated using Weblate (Slovenian)
Currently translated at 100.0% (1078 of 1078 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/
2024-12-18 00:44:25 +01:00
Vito0912
fde51da479 Translated using Weblate (German)
Currently translated at 100.0% (1078 of 1078 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/
2024-12-18 00:44:25 +01:00
Mario
f3536dc3a3 Translated using Weblate (German)
Currently translated at 100.0% (1078 of 1078 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/
2024-12-18 00:44:25 +01:00
Bezruchenko Simon
a0c93e5dec Translated using Weblate (Ukrainian)
Currently translated at 100.0% (1076 of 1076 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/uk/
2024-12-18 00:44:25 +01:00
biuklija
63aa6aa950 Translated using Weblate (Croatian)
Currently translated at 100.0% (1076 of 1076 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/
2024-12-18 00:44:25 +01:00
Vito0912
680099cab4 Translated using Weblate (German)
Currently translated at 100.0% (1076 of 1076 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/
2024-12-18 00:44:25 +01:00
thehijacker
66f3f3eddf Translated using Weblate (Slovenian)
Currently translated at 100.0% (1076 of 1076 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/
2024-12-18 00:44:25 +01:00
Alex
a400c149a6 Translated using Weblate (Russian)
Currently translated at 100.0% (1076 of 1076 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ru/
2024-12-18 00:44:25 +01:00
Petter Schaug-Pettersen
244b5ab36d Translated using Weblate (Norwegian Bokmål)
Currently translated at 63.1% (679 of 1076 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/nb_NO/
2024-12-18 00:44:25 +01:00
ugyes
f26747627e Translated using Weblate (Hungarian)
Currently translated at 87.3% (940 of 1076 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hu/
2024-12-18 00:44:25 +01:00
biuklija
f57a07c483 Translated using Weblate (Croatian)
Currently translated at 99.9% (1075 of 1076 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/
2024-12-18 00:44:25 +01:00
gallegonovato
080b879d8a Translated using Weblate (Spanish)
Currently translated at 100.0% (1076 of 1076 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/es/
2024-12-18 00:44:25 +01:00
advplyr
63b3f22504 Trim podcast descriptions #3720 2024-12-17 17:44:18 -06:00
Brinly
91f17efd5f feat: Added Australia and New Zealand podcast regions 2024-12-17 12:42:28 +01:00
Vito0912
858d697d0f DropDown for Year in Review (#3717)
* Accessibility updates
* Show "Share" button on large screen sizes

---------

Co-authored-by: advplyr <advplyr@protonmail.com>
2024-12-16 16:44:06 -06:00
mikiher
ba55413e63 LazyBookshelf optimizations 2024-12-16 19:21:44 +02:00
advplyr
6cef1e3f12 Merge pull request #3724 from advplyr/feed_migration
Refactor Feed model to create new feed for collection
2024-12-15 17:59:17 -06:00
Nicholas Wallace
61729881cb Change: no compression when downloading library item as zip file 2024-12-07 16:52:31 -07:00
Greg Lorenzen
27c9381e1d Merge branch 'master' into multi-select-keyboard-navigation 2024-11-15 12:06:25 -08:00
Greg Lorenzen
0812e189f7 Add keyboard input to MultiSelect component 2024-11-07 03:38:30 +00:00
Greg Lorenzen
588def6d33 Merge branch 'advplyr:master' into multi-select-keyboard-navigation 2024-11-06 19:37:26 -08:00
Greg Lorenzen
a0b3960ee4 Fix enter key and focus for edit modal 2024-10-31 16:29:48 +00:00
Greg Lorenzen
e55db0afdc Add focus and enter key support to the add button in MultiSelectQueryInput 2024-10-31 15:44:19 +00:00
Greg Lorenzen
ae9efe6359 Add keyboard focus to MultiSelectQueryInput edit and close 2024-10-31 15:30:51 +00:00
84 changed files with 2574 additions and 361 deletions

1
.gitignore vendored
View File

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

View File

@@ -42,6 +42,9 @@
<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>
</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 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 -->
@@ -265,6 +268,9 @@ export default {
isPodcastLatestPage() {
return this.$route.name === 'library-library-podcast-latest'
},
isPodcastDownloadQueuePage() {
return this.$route.name === 'library-library-podcast-download-queue'
},
isAuthorsPage() {
return this.page === 'authors'
},

View File

@@ -112,6 +112,14 @@ export default {
}
]
if (this.$store.state.pluginsEnabled) {
configRoutes.push({
id: 'config-plugins',
title: 'Plugins',
path: '/config/plugins'
})
}
if (this.currentLibraryId) {
configRoutes.push({
id: 'library-stats',

View File

@@ -2,6 +2,10 @@
<div id="bookshelf" ref="bookshelf" class="w-full overflow-y-auto" :style="{ fontSize: sizeMultiplier + 'rem' }">
<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' }">
<!-- 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>
</template>
@@ -65,7 +69,13 @@ export default {
tempIsScanning: false,
cardWidth: 0,
cardHeight: 0,
resizeObserver: null
coverHeight: 0,
resizeObserver: null,
lastScrollTop: 0,
lastTimestamp: 0,
postScrollTimeout: null,
currFirstEntityIndex: -1,
currLastEntityIndex: -1
}
},
watch: {
@@ -171,9 +181,6 @@ export default {
bookWidth() {
return this.cardWidth
},
bookHeight() {
return this.cardHeight
},
shelfPadding() {
if (this.bookshelfWidth < 640) return 32 * this.sizeMultiplier
return 64 * this.sizeMultiplier
@@ -184,9 +191,6 @@ export default {
entityWidth() {
return this.cardWidth
},
entityHeight() {
return this.cardHeight
},
shelfPaddingHeight() {
return 16
},
@@ -354,50 +358,53 @@ export default {
}
},
loadPage(page) {
this.pagesLoaded[page] = true
this.fetchEntites(page)
if (!this.pagesLoaded[page]) this.pagesLoaded[page] = this.fetchEntites(page)
return this.pagesLoaded[page]
},
showHideBookPlaceholder(index, show) {
var el = document.getElementById(`book-${index}-placeholder`)
if (el) el.style.display = show ? 'flex' : 'none'
},
mountEntites(fromIndex, toIndex) {
mountEntities(fromIndex, toIndex) {
for (let i = fromIndex; i < toIndex; i++) {
if (!this.entityIndexesMounted.includes(i)) {
this.cardsHelpers.mountEntityCard(i)
}
}
},
handleScroll(scrollTop) {
this.currScrollTop = scrollTop
var firstShelfIndex = Math.floor(scrollTop / this.shelfHeight)
var lastShelfIndex = Math.ceil((scrollTop + this.bookshelfHeight) / this.shelfHeight)
lastShelfIndex = Math.min(this.totalShelves - 1, lastShelfIndex)
var firstBookIndex = firstShelfIndex * this.entitiesPerShelf
var lastBookIndex = lastShelfIndex * this.entitiesPerShelf + this.entitiesPerShelf
lastBookIndex = Math.min(this.totalEntities, lastBookIndex)
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)
}
getVisibleIndices(scrollTop) {
const firstShelfIndex = Math.floor(scrollTop / this.shelfHeight)
const lastShelfIndex = Math.min(Math.ceil((scrollTop + this.bookshelfHeight) / this.shelfHeight), this.totalShelves - 1)
const firstEntityIndex = firstShelfIndex * this.entitiesPerShelf
const lastEntityIndex = Math.min(lastShelfIndex * this.entitiesPerShelf + this.entitiesPerShelf, this.totalEntities)
return { firstEntityIndex, lastEntityIndex }
},
postScroll() {
const { firstEntityIndex, lastEntityIndex } = this.getVisibleIndices(this.currScrollTop)
this.entityIndexesMounted = this.entityIndexesMounted.filter((_index) => {
if (_index < firstBookIndex || _index >= lastBookIndex) {
var el = document.getElementById(`book-card-${_index}`)
if (el) el.remove()
if (_index < firstEntityIndex || _index >= lastEntityIndex) {
var el = this.entityComponentRefs[_index]
if (el && el.$el) el.$el.remove()
return false
}
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() {
if (this.isFetchingEntities) {
@@ -405,8 +412,6 @@ export default {
return
}
this.destroyEntityComponents()
this.entityIndexesMounted = []
this.entityComponentRefs = {}
this.pagesLoaded = {}
this.entities = []
this.totalShelves = 0
@@ -416,40 +421,21 @@ export default {
this.initialized = false
this.initSizeData()
this.pagesLoaded[0] = true
await this.fetchEntites(0)
await this.loadPage(0)
var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf)
this.mountEntites(0, lastBookIndex)
this.mountEntities(0, lastBookIndex)
},
remountEntities() {
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() {
async rebuild() {
this.initSizeData()
var lastBookIndex = Math.min(this.totalEntities, this.booksPerFetch)
this.entityIndexesMounted = []
for (let i = 0; i < lastBookIndex; i++) {
this.entityIndexesMounted.push(i)
if (!this.entities[i]) {
const page = Math.floor(i / this.booksPerFetch)
this.loadPage(page)
}
}
this.destroyEntityComponents()
await this.loadPage(0)
var bookshelfEl = document.getElementById('bookshelf')
if (bookshelfEl) {
bookshelfEl.scrollTop = 0
}
this.$nextTick(this.remountEntities)
this.mountEntities(0, lastBookIndex)
},
buildSearchParams() {
if (this.page === 'search' || this.page === 'collections') {
@@ -513,12 +499,29 @@ export default {
if (wasUpdated) {
this.resetEntities()
} 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) {
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)
},
libraryItemAdded(libraryItem) {
@@ -667,13 +670,14 @@ export default {
},
updatePagesLoaded() {
let numPages = Math.ceil(this.totalEntities / this.booksPerFetch)
this.pagesLoaded = {}
for (let page = 0; page < numPages; page++) {
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++) {
const index = page * this.booksPerFetch + i
if (!this.entities[index]) {
this.pagesLoaded[page] = false
if (this.pagesLoaded[page]) delete this.pagesLoaded[page]
break
}
}
@@ -688,7 +692,6 @@ export default {
var entitiesPerShelfBefore = this.entitiesPerShelf
var { clientHeight, clientWidth } = bookshelf
// console.log('Init bookshelf width', clientWidth, 'window width', window.innerWidth)
this.mountWindowWidth = window.innerWidth
this.bookshelfHeight = clientHeight
this.bookshelfWidth = clientWidth
@@ -713,10 +716,9 @@ export default {
this.initSizeData(bookshelf)
this.checkUpdateSearchParams()
this.pagesLoaded[0] = true
await this.fetchEntites(0)
await this.loadPage(0)
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
if (this.$store.state.lastBookshelfScrollData[this.page] && window.bookshelf) {
@@ -747,7 +749,7 @@ export default {
var bookshelf = document.getElementById('bookshelf')
if (bookshelf) {
this.init(bookshelf)
bookshelf.addEventListener('scroll', this.scroll)
bookshelf.addEventListener('scroll', this.scroll, { passive: true })
}
})
@@ -810,10 +812,14 @@ export default {
},
destroyEntityComponents() {
for (const key in this.entityComponentRefs) {
if (this.entityComponentRefs[key] && this.entityComponentRefs[key].destroy) {
this.entityComponentRefs[key].destroy()
const ref = this.entityComponentRefs[key]
if (ref && ref.destroy) {
if (ref.$el) ref.$el.remove()
ref.destroy()
}
}
this.entityComponentRefs = {}
this.entityIndexesMounted = []
},
scan() {
this.tempIsScanning = true
@@ -826,6 +832,14 @@ export default {
.finally(() => {
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() {

View File

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

View File

@@ -19,7 +19,7 @@
</div>
<!-- Cover Image -->
<img cy-id="coverImage" v-show="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 }" />
<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 -->
<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' }">

View File

@@ -54,8 +54,7 @@ export default {
options: {
provider: undefined,
overrideDetails: true,
overrideCover: true,
overrideDefaults: true
overrideCover: true
}
}
},
@@ -99,8 +98,8 @@ export default {
init() {
// 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
if (!this.options.provider || this.options.lastUsedLibrary != this.currentLibraryId) {
this.options.lastUsedLibrary = this.currentLibraryId
if (!this.options.provider || this.lastUsedLibrary != this.currentLibraryId) {
this.lastUsedLibrary = this.currentLibraryId
this.options.provider = this.libraryProvider
}
},

View File

@@ -1,8 +1,8 @@
<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 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">
<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">
<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>
</div>
</button>
<div ref="content" class="text-white">
<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>

View File

@@ -19,12 +19,13 @@
<ui-text-input v-model="currentShareUrl" show-copy readonly class="text-base h-10" />
</div>
<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>
</div>
</template>
<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">
<label class="px-1 text-sm font-semibold block">{{ $strings.LabelSlug }}</label>
<ui-text-input v-model="newShareSlug" class="text-base h-10" />
@@ -46,6 +47,15 @@
</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('MessageShareExpirationWillBe', [expirationDateString])" />
</template>
@@ -81,7 +91,8 @@ export default {
text: this.$strings.LabelDays,
value: 'days'
}
]
],
isDownloadable: false
}
},
watch: {
@@ -172,7 +183,8 @@ export default {
slug: this.newShareSlug,
mediaItemType: 'book',
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.$axios

View File

@@ -2,24 +2,24 @@
<modals-modal v-model="show" name="edit-book" :width="800" :height="height" :processing="processing" :content-margin-top="marginTop">
<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">
<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>
</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">
<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>
</div>
<div v-show="canGoPrev" class="absolute -left-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="goPrevBook" @mousedown.prevent>arrow_back_ios</div>
</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 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">
<component v-if="libraryItem && show" :is="tabName" :library-item="libraryItem" :processing.sync="processing" @close="show = false" @selectTab="selectTab" />
</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">
<component v-if="libraryItem && show" :is="tabName" :library-item="libraryItem" :processing.sync="processing" @close="show = false" @selectTab="selectTab" />
<div v-show="canGoPrev" class="absolute -left-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.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>
</modals-modal>
</template>

View File

@@ -7,6 +7,14 @@
<ui-checkbox v-if="checkboxLabel" v-model="checkboxValue" checkbox-bg="bg" :label="checkboxLabel" label-class="pl-2 text-base" class="mb-6 px-1" />
<div v-if="formFields.length" class="mb-6 space-y-2">
<template v-for="field in formFields">
<ui-select-input v-if="field.type === 'select'" :key="field.name" v-model="formData[field.name]" :label="field.label" :items="field.options" class="px-1" />
<ui-textarea-with-label v-else-if="field.type === 'textarea'" :key="field.name" v-model="formData[field.name]" :label="field.label" class="px-1" />
<ui-text-input-with-label v-else-if="field.type === 'text'" :key="field.name" v-model="formData[field.name]" :label="field.label" class="px-1" />
</template>
</div>
<div class="flex px-1 items-center">
<ui-btn v-if="isYesNo" color="primary" @click="nevermind">{{ $strings.ButtonCancel }}</ui-btn>
<div class="flex-grow" />
@@ -25,7 +33,8 @@ export default {
return {
el: null,
content: null,
checkboxValue: false
checkboxValue: false,
formData: {}
}
},
watch: {
@@ -61,6 +70,9 @@ export default {
persistent() {
return !!this.confirmPromptOptions.persistent
},
formFields() {
return this.confirmPromptOptions.formFields || []
},
checkboxLabel() {
return this.confirmPromptOptions.checkboxLabel
},
@@ -100,11 +112,31 @@ export default {
this.show = false
},
confirm() {
if (this.callback) this.callback(true, this.checkboxValue)
if (this.callback) {
if (this.formFields.length) {
const formFieldData = {
...this.formData
}
this.callback(true, formFieldData)
} else {
this.callback(true, this.checkboxValue)
}
}
this.show = false
},
setShow() {
this.checkboxValue = this.checkboxDefaultValue
if (this.formFields.length) {
this.formFields.forEach((field) => {
let defaultValue = ''
if (field.type === 'boolean') defaultValue = false
if (field.type === 'select') defaultValue = field.options[0].value
this.$set(this.formData, field.name, defaultValue)
})
}
this.$eventBus.$emit('showing-prompt', true)
document.body.appendChild(this.el)
setTimeout(() => {

View File

@@ -1,9 +1,9 @@
<template>
<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 />
</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>
</template>

View File

@@ -7,7 +7,7 @@
</div>
<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" />
<ui-btn class="w-full md:w-auto" @click.stop="clickShowYearInReview">{{ showYearInReview ? $strings.LabelYearReviewHide : $strings.LabelYearReviewShow }}</ui-btn>
</div>
@@ -16,17 +16,22 @@
<div v-if="showYearInReview">
<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 -->
<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="hidden sm:inline-block pr-2">{{ $strings.ButtonPrevious }}</span>
</ui-btn>
<!-- 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" />
<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>
<div class="flex-grow" />
@@ -36,7 +41,7 @@
<span class="material-symbols sm:!hidden text-lg py-px">refresh</span>
</ui-btn>
<!-- 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="material-symbols text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
</ui-btn>
@@ -46,23 +51,23 @@
<!-- your year in review short -->
<div class="w-full max-w-[800px] mx-auto my-4">
<!-- 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" />
</div>
<!-- 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">
<!-- 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="hidden sm:inline-block pr-2">{{ $strings.ButtonPrevious }}</span>
</ui-btn>
<!-- 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" />
<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>
<div class="flex-grow" />
@@ -72,7 +77,7 @@
<span class="material-symbols sm:!hidden text-lg py-px">refresh</span>
</ui-btn>
<!-- 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="material-symbols text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
</ui-btn>
@@ -88,6 +93,7 @@ export default {
data() {
return {
showYearInReview: false,
availableYears: [],
yearInReviewYear: 0,
yearInReviewVariant: 0,
yearInReviewServerVariant: 0,
@@ -100,6 +106,9 @@ export default {
computed: {
isAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
user() {
return this.$store.state.user.user
}
},
methods: {
@@ -112,25 +121,57 @@ export default {
shareYearInReviewShort() {
this.$refs.yearInReviewShort.share()
},
yearInReviewYearChanged() {
this.$nextTick(() => {
this.refreshYearInReview()
this.refreshYearInReviewServer()
})
},
refreshYearInReviewServer() {
this.$refs.yearInReviewServer.refresh()
if (this.$refs.yearInReviewServer != null) {
this.$refs.yearInReviewServer.refresh()
}
},
refreshYearInReview() {
this.$refs.yearInReview.refresh()
this.$refs.yearInReviewShort.refresh()
if (this.$refs.yearInReview != null && this.$refs.yearInReviewShort != null) {
this.$refs.yearInReview.refresh()
this.$refs.yearInReviewShort.refresh()
}
},
clickShowYearInReview() {
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() {
this.yearInReviewYear = new Date().getFullYear()
// When not December show previous year
if (new Date().getMonth() < 11) {
this.yearInReviewYear--
}
},
mounted() {
this.availableYears = this.getAvailableYears()
if (typeof navigator.share !== 'undefined' && navigator.share) {
this.showShareButton = true
} else {

View File

@@ -1,9 +1,9 @@
<template>
<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 />
</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>
</template>

View File

@@ -31,6 +31,7 @@
</div>
</template>
<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)">
<span v-if="item.icon" class="material-symbols text-base mr-1">{{ item.icon }}</span>
<p class="text-left">{{ item.text }}</p>
</button>
</template>

View File

@@ -1,7 +1,7 @@
<template>
<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>
<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="block truncate font-sans" :class="{ 'font-semibold': selectedSubtext, 'text-sm': small }">{{ selectedText }}</span>
<span v-if="selectedSubtext">:&nbsp;</span>
@@ -13,9 +13,9 @@
</button>
<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">
<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">
<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>
@@ -119,4 +119,4 @@ export default {
},
mounted() {}
}
</script>
</script>

View File

@@ -1,17 +1,17 @@
<template>
<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">
<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 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-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">
<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>
<span class="material-symbols text-white hover:text-error cursor-pointer" style="font-size: 1.1rem" @click.stop="removeItem(item)">close</span>
<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" 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" :class="{ 'opacity-100': inputFocused }">
<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>
<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>
{{ item }}
</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>
</form>
@@ -66,7 +66,8 @@ export default {
typingTimeout: null,
isFocused: false,
menu: null,
filteredItems: null
filteredItems: null,
inputFocused: false
}
},
watch: {
@@ -100,6 +101,9 @@ export default {
}
return this.filteredItems
},
identifier() {
return Math.random().toString(36).substring(2)
}
},
methods: {
@@ -129,6 +133,9 @@ export default {
}, 100)
this.setInputWidth()
},
setInputFocused(focused) {
this.inputFocused = focused
},
setInputWidth() {
setTimeout(() => {
var value = this.$refs.input.value

View File

@@ -1,20 +1,20 @@
<template>
<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">
<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 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-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">
<span v-if="showEdit" class="material-symbols text-base text-white hover:text-warning mr-1" @click.stop="editItem(item)">edit</span>
<span class="material-symbols text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item.id)">close</span>
<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" 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" :class="{ 'opacity-100': inputFocused }">
<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>
<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>
{{ item[textKey] }}
</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">
<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>
<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>
</form>
@@ -65,6 +65,7 @@ export default {
currentSearch: null,
typingTimeout: null,
isFocused: false,
inputFocused: false,
menu: null,
items: []
}
@@ -102,6 +103,9 @@ export default {
},
filterData() {
return this.$store.state.libraries.filterData || {}
},
identifier() {
return Math.random().toString(36).substring(2)
}
},
methods: {
@@ -114,6 +118,9 @@ export default {
getIsSelected(itemValue) {
return !!this.selected.find((i) => i.id === itemValue)
},
setInputFocused(focused) {
this.inputFocused = focused
},
search() {
if (!this.textInput) return
this.currentSearch = this.textInput

View File

@@ -1,6 +1,6 @@
<template>
<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>
</button>
</div>
@@ -20,6 +20,7 @@ export default {
},
disabled: Boolean,
labeledBy: String,
label: String,
size: {
type: String,
default: 'md'

View File

@@ -57,9 +57,10 @@ export default {
for (let entry of entries) {
this.cardWidth = entry.borderBoxSize[0].inlineSize
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.position = 'absolute'
@@ -131,10 +132,7 @@ export default {
this.entityComponentRefs[index] = instance
instance.$mount()
const shelfOffsetY = this.shelfPaddingHeight * this.sizeMultiplier
const row = index % this.entitiesPerShelf
const shelfOffsetX = row * this.totalEntityCardWidth + this.bookshelfMarginLeft
instance.$el.style.transform = `translate3d(${shelfOffsetX}px, ${shelfOffsetY}px, 0px)`
instance.$el.style.transform = this.entityTransform((index % this.entitiesPerShelf) + 1)
instance.$el.classList.add('absolute', 'top-0', 'left-0')
shelfEl.appendChild(instance.$el)

View File

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

View File

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

View File

@@ -6,9 +6,9 @@
<div class="pt-4">
<h2 class="font-semibold">{{ $strings.HeaderSettingsGeneral }}</h2>
</div>
<div 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-tooltip :text="$strings.LabelSettingsStoreCoversWithItemHelp">
<div role="article" :aria-label="$strings.LabelSettingsStoreCoversWithItemHelp" class="flex items-end py-2">
<ui-toggle-switch :label="$strings.LabelSettingsStoreCoversWithItem" v-model="newServerSettings.storeCoverWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeCoverWithItem', val)" />
<ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsStoreCoversWithItemHelp">
<p class="pl-4">
<span id="settings-store-cover-with-items">{{ $strings.LabelSettingsStoreCoversWithItem }}</span>
<span class="material-symbols icon-text">info</span>
@@ -16,9 +16,9 @@
</ui-tooltip>
</div>
<div 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-tooltip :text="$strings.LabelSettingsStoreMetadataWithItemHelp">
<div role="article" :aria-label="$strings.LabelSettingsStoreMetadataWithItemHelp" class="flex items-center py-2">
<ui-toggle-switch :label="$strings.LabelSettingsStoreMetadataWithItem" v-model="newServerSettings.storeMetadataWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeMetadataWithItem', val)" />
<ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsStoreMetadataWithItemHelp">
<p class="pl-4">
<span id="settings-store-metadata-with-items">{{ $strings.LabelSettingsStoreMetadataWithItem }}</span>
<span class="material-symbols icon-text">info</span>
@@ -26,9 +26,9 @@
</ui-tooltip>
</div>
<div 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-tooltip :text="$strings.LabelSettingsSortingIgnorePrefixesHelp">
<div role="article" :aria-label="$strings.LabelSettingsSortingIgnorePrefixesHelp" class="flex items-center py-2">
<ui-toggle-switch :label="$strings.LabelSettingsSortingIgnorePrefixes" v-model="newServerSettings.sortingIgnorePrefix" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('sortingIgnorePrefix', val)" />
<ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsSortingIgnorePrefixesHelp">
<p class="pl-4">
<span id="settings-sorting-ignore-prefixes">{{ $strings.LabelSettingsSortingIgnorePrefixes }}</span>
<span class="material-symbols icon-text">info</span>
@@ -46,9 +46,9 @@
<h2 class="font-semibold">{{ $strings.HeaderSettingsScanner }}</h2>
</div>
<div 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-tooltip :text="$strings.LabelSettingsParseSubtitlesHelp">
<div role="article" :aria-label="$strings.LabelSettingsParseSubtitlesHelp" class="flex items-center py-2">
<ui-toggle-switch :label="$strings.LabelSettingsParseSubtitles" v-model="newServerSettings.scannerParseSubtitle" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerParseSubtitle', val)" />
<ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsParseSubtitlesHelp">
<p class="pl-4">
<span id="settings-parse-subtitles">{{ $strings.LabelSettingsParseSubtitles }}</span>
<span class="material-symbols icon-text">info</span>
@@ -56,9 +56,9 @@
</ui-tooltip>
</div>
<div 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-tooltip :text="$strings.LabelSettingsFindCoversHelp">
<div role="article" :aria-label="$strings.LabelSettingsFindCoversHelp" class="flex items-center py-2">
<ui-toggle-switch :label="$strings.LabelSettingsFindCovers" v-model="newServerSettings.scannerFindCovers" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerFindCovers', val)" />
<ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsFindCoversHelp">
<p class="pl-4">
<span id="settings-find-covers">{{ $strings.LabelSettingsFindCovers }}</span>
<span class="material-symbols icon-text">info</span>
@@ -70,9 +70,9 @@
<ui-dropdown v-model="newServerSettings.scannerCoverProvider" small :items="providers" label="Cover Provider" @input="updateScannerCoverProvider" :disabled="updatingServerSettings" />
</div>
<div 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-tooltip :text="$strings.LabelSettingsPreferMatchedMetadataHelp">
<div role="article" :aria-label="$strings.LabelSettingsPreferMatchedMetadataHelp" class="flex items-center py-2">
<ui-toggle-switch :label="$strings.LabelSettingsPreferMatchedMetadata" v-model="newServerSettings.scannerPreferMatchedMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferMatchedMetadata', val)" />
<ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsPreferMatchedMetadataHelp">
<p class="pl-4">
<span id="settings-prefer-matched-metadata">{{ $strings.LabelSettingsPreferMatchedMetadata }}</span>
<span class="material-symbols icon-text">info</span>
@@ -80,9 +80,9 @@
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch labeledBy="settings-disable-watcher" v-model="scannerEnableWatcher" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerDisableWatcher', !val)" />
<ui-tooltip :text="$strings.LabelSettingsEnableWatcherHelp">
<div role="article" :aria-label="$strings.LabelSettingsEnableWatcherHelp" class="flex items-center py-2">
<ui-toggle-switch :label="$strings.LabelSettingsEnableWatcher" v-model="scannerEnableWatcher" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerDisableWatcher', !val)" />
<ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsEnableWatcherHelp">
<p class="pl-4">
<span id="settings-disable-watcher">{{ $strings.LabelSettingsEnableWatcher }}</span>
<span class="material-symbols icon-text">info</span>
@@ -95,13 +95,13 @@
</div>
<div class="flex items-center py-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>
<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 labeledBy="settings-allow-iframe" v-model="newServerSettings.allowIframe" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('allowIframe', val)" />
<p class="pl-4" id="settings-allow-iframe">{{ $strings.LabelSettingsAllowIframe }}</p>
<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>

View File

@@ -0,0 +1,154 @@
<template>
<div>
<app-settings-content :header-text="`Plugin: ${pluginManifest.name}`">
<template #header-prefix>
<nuxt-link to="/config/plugins" class="w-8 h-8 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center mr-2">
<span class="material-symbols text-2xl">arrow_back</span>
</nuxt-link>
</template>
<template #header-items>
<ui-tooltip v-if="pluginManifest.documentationUrl" :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
<a :href="pluginManifest.documentationUrl" target="_blank" class="inline-flex">
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
</a>
</ui-tooltip>
<div class="flex-grow" />
<a v-if="repositoryUrl" :href="repositoryUrl" target="_blank" class="abs-btn outline-none rounded-md shadow-md relative border border-gray-600 text-center bg-primary text-white px-4 py-1 text-sm inline-flex items-center space-x-2"><span>Source</span><span class="material-symbols text-base">open_in_new</span> </a>
</template>
<div class="py-4">
<p v-if="configDescription" class="mb-4">{{ configDescription }}</p>
<form v-if="configFormFields.length" @submit.prevent="handleFormSubmit">
<template v-for="field in configFormFields">
<div :key="field.name" class="flex items-center mb-4">
<label :for="field.name" class="w-1/3 text-gray-200">{{ field.label }}</label>
<div class="w-2/3">
<input :id="field.name" :type="field.type" :placeholder="field.placeholder" class="w-full bg-bg border border-white border-opacity-20 rounded-md p-2 text-gray-200" />
</div>
</div>
</template>
<div class="flex justify-end">
<ui-btn class="bg-primary bg-opacity-70 text-white rounded-md p-2" :loading="processing" type="submit">{{ $strings.ButtonSave }}</ui-btn>
</div>
</form>
</div>
</app-settings-content>
</div>
</template>
<script>
export default {
async asyncData({ store, redirect, params, app }) {
if (!store.getters['user/getIsAdminOrUp']) {
redirect('/')
}
const pluginConfigData = await app.$axios.$get(`/api/plugins/${params.id}/config`).catch((error) => {
console.error('Failed to get plugin config', error)
return null
})
if (!pluginConfigData) {
redirect('/config/plugins')
}
const pluginManifest = store.state.plugins.find((plugin) => plugin.id === params.id)
if (!pluginManifest) {
redirect('/config/plugins')
}
return {
pluginManifest,
pluginConfig: pluginConfigData.config
}
},
data() {
return {
processing: false
}
},
computed: {
pluginManifestConfig() {
return this.pluginManifest.config
},
pluginLocalization() {
return this.pluginManifest.localization || {}
},
localizedStrings() {
const localeKey = this.$languageCodes.current
if (!localeKey) return {}
return this.pluginLocalization[localeKey] || {}
},
configDescription() {
if (this.pluginManifestConfig.descriptionKey && this.localizedStrings[this.pluginManifestConfig.descriptionKey]) {
return this.localizedStrings[this.pluginManifestConfig.descriptionKey]
}
return this.pluginManifestConfig.description
},
configFormFields() {
return this.pluginManifestConfig.formFields || []
},
repositoryUrl() {
return this.pluginManifest.repositoryUrl
}
},
methods: {
getFormData() {
const formData = {}
this.configFormFields.forEach((field) => {
if (field.type === 'checkbox') {
formData[field.name] = document.getElementById(field.name).checked
} else {
formData[field.name] = document.getElementById(field.name).value
}
})
return formData
},
handleFormSubmit() {
const formData = this.getFormData()
console.log('Form data', formData)
const payload = {
config: formData
}
this.processing = true
this.$axios
.$post(`/api/plugins/${this.pluginManifest.id}/config`, payload)
.then(() => {
console.log('Plugin configuration saved')
})
.catch((error) => {
const errorMsg = error.response?.data || 'Error saving plugin configuration'
console.error('Failed to save config:', error)
this.$toast.error(errorMsg)
})
.finally(() => {
this.processing = false
})
},
initializeForm() {
if (!this.pluginConfig) return
this.configFormFields.forEach((field) => {
if (this.pluginConfig[field.name] === undefined) {
return
}
const value = this.pluginConfig[field.name]
if (field.type === 'checkbox') {
document.getElementById(field.name).checked = value
} else {
document.getElementById(field.name).value = value
}
})
}
},
mounted() {
console.log('Plugin manifest', this.pluginManifest, 'config', this.pluginConfig)
this.initializeForm()
},
beforeDestroy() {}
}
</script>

View File

@@ -0,0 +1,48 @@
<template>
<div>
<app-settings-content :header-text="'Plugins'">
<template #header-items>
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
<a href="https://www.audiobookshelf.org/guides" target="_blank" class="inline-flex">
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
</a>
</ui-tooltip>
</template>
<div class="py-4">
<p v-if="!plugins.length" class="text-gray-300">No plugins installed</p>
<template v-for="plugin in plugins">
<nuxt-link :key="plugin.id" :to="`/config/plugins/${plugin.id}`" class="block w-full rounded bg-primary/40 hover:bg-primary/60 text-gray-300 hover:text-white p-4 my-2">
<div class="flex items-center space-x-4">
<p class="text-lg">{{ plugin.name }}</p>
<p class="text-sm text-gray-300">{{ plugin.description }}</p>
<div class="flex-grow" />
<span class="material-symbols">arrow_forward</span>
</div>
</nuxt-link>
</template>
</div>
</app-settings-content>
</div>
</template>
<script>
export default {
asyncData({ store, redirect }) {
if (!store.getters['user/getIsAdminOrUp']) {
redirect('/')
}
},
data() {
return {}
},
computed: {
plugins() {
return this.$store.state.plugins
}
},
methods: {},
mounted() {},
beforeDestroy() {}
}
</script>

View File

@@ -25,7 +25,7 @@
<tr v-for="feed in feeds" :key="feed.id" class="cursor-pointer h-12" @click="showFeed(feed)">
<!-- -->
<td>
<img :src="coverUrl(feed)" class="h-full w-full" />
<img :src="coverUrl(feed)" class="h-auto w-full" />
</td>
<!-- -->
<td class="w-48 max-w-64 min-w-24 text-left truncate">

View File

@@ -364,6 +364,9 @@ export default {
showCollectionsButton() {
return this.isBook && this.userCanUpdate
},
pluginExtensions() {
return this.$store.getters['getPluginExtensions']('item.detail.actions')
},
contextMenuItems() {
const items = []
@@ -429,6 +432,18 @@ export default {
})
}
if (this.pluginExtensions.length) {
this.pluginExtensions.forEach((plugin) => {
plugin.extensions.forEach((pext) => {
items.push({
text: pext.label,
action: `plugin-${plugin.id}-action-${pext.name}`,
icon: 'extension'
})
})
})
}
return items
}
},
@@ -763,7 +778,54 @@ export default {
} else if (action === 'share') {
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
this.$store.commit('globals/setShareModal', this.mediaItemShare)
} else if (action.startsWith('plugin-')) {
const actionStrSplit = action.replace('plugin-', '').split('-action-')
const pluginId = actionStrSplit[0]
const pluginAction = actionStrSplit[1]
this.onPluginAction(pluginId, pluginAction)
}
},
onPluginAction(pluginId, pluginAction) {
const plugin = this.pluginExtensions.find((p) => p.id === pluginId)
const extension = plugin.extensions.find((ext) => ext.name === pluginAction)
if (extension.prompt) {
const payload = {
message: extension.prompt.message,
formFields: extension.prompt.formFields || [],
yesButtonText: this.$strings.ButtonSubmit,
callback: (confirmed, promptData) => {
if (confirmed) {
this.sendPluginAction(pluginId, pluginAction, promptData)
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
} else {
this.sendPluginAction(pluginId, pluginAction)
}
},
sendPluginAction(pluginId, pluginAction, promptData = null) {
this.$axios
.$post(`/api/plugins/${pluginId}/action`, {
pluginAction,
target: 'item.detail.actions',
data: {
entityId: this.libraryItemId,
entityType: 'libraryItem',
userId: this.$store.state.user.user.id,
promptData
}
})
.then((data) => {
console.log('Plugin action response', data)
})
.catch((error) => {
const errorMsg = error.response?.data || 'Plugin action failed'
console.error('Plugin action failed:', error)
this.$toast.error(errorMsg)
})
}
},
mounted() {

View File

@@ -166,10 +166,14 @@ export default {
location.reload()
},
setUser({ user, userDefaultLibraryId, serverSettings, Source, ereaderDevices }) {
setUser({ user, userDefaultLibraryId, serverSettings, Source, ereaderDevices, plugins }) {
this.$store.commit('setServerSettings', serverSettings)
this.$store.commit('setSource', Source)
this.$store.commit('libraries/setEReaderDevices', ereaderDevices)
if (plugins !== undefined) {
this.$store.commit('setPlugins', plugins)
}
this.$setServerLanguageCode(serverSettings.language)
if (serverSettings.chromecastEnabled) {

View File

@@ -12,6 +12,10 @@
<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" />
</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>
@@ -63,6 +67,9 @@ export default {
if (!this.playbackSession.coverPath) return `${this.$config.routerBasePath}/book_placeholder.jpg`
return `${this.$config.routerBasePath}/public/share/${this.mediaItemShare.slug}/cover`
},
downloadUrl() {
return `${process.env.serverUrl}/public/share/${this.mediaItemShare.slug}/download`
},
audioTracks() {
return (this.playbackSession.audioTracks || []).map((track) => {
track.relativeContentUrl = track.contentUrl
@@ -247,6 +254,9 @@ export default {
},
playerFinished() {
console.log('Player finished')
},
downloadShareItem() {
this.$downloadFile(this.downloadUrl)
}
},
mounted() {

View File

@@ -42,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
const podcastSearchRegionMap = {
au: { label: 'Australia' },
br: { label: 'Brasil' },
be: { label: 'België / Belgique / Belgien' },
cz: { label: 'Česko' },
@@ -57,6 +58,7 @@ const podcastSearchRegionMap = {
hu: { label: 'Magyarország' },
nl: { label: 'Nederland' },
no: { label: 'Norge' },
nz: { label: 'New Zealand' },
at: { label: 'Österreich' },
pl: { label: 'Polska' },
pt: { label: 'Portugal' },

View File

@@ -28,7 +28,9 @@ export const state = () => ({
openModal: null,
innerModalOpen: false,
lastBookshelfScrollData: {},
routerBasePath: '/'
routerBasePath: '/',
plugins: [],
pluginsEnabled: false
})
export const getters = {
@@ -61,6 +63,20 @@ export const getters = {
getHomeBookshelfView: (state) => {
if (!state.serverSettings || isNaN(state.serverSettings.homeBookshelfView)) return Constants.BookshelfView.STANDARD
return state.serverSettings.homeBookshelfView
},
getPluginExtensions: (state) => (target) => {
if (!state.pluginsEnabled) return []
return state.plugins
.map((pext) => {
const extensionsMatchingTarget = pext.extensions?.filter((ext) => ext.target === target) || []
if (!extensionsMatchingTarget.length) return null
return {
id: pext.id,
name: pext.name,
extensions: extensionsMatchingTarget
}
})
.filter(Boolean)
}
}
@@ -239,5 +255,9 @@ export const mutations = {
},
setInnerModalOpen(state, val) {
state.innerModalOpen = val
},
setPlugins(state, val) {
state.plugins = val
state.pluginsEnabled = true
}
}

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

@@ -0,0 +1 @@
{}

View File

@@ -88,6 +88,8 @@
"ButtonSaveTracklist": "Uložit seznam skladeb",
"ButtonScan": "Prohledat",
"ButtonScanLibrary": "Prohledat Knihovnu",
"ButtonScrollLeft": "Posunout vlevo",
"ButtonScrollRight": "Posunout vpravo",
"ButtonSearch": "Hledat",
"ButtonSelectFolderPath": "Vybrat cestu ke složce",
"ButtonSeries": "Série",
@@ -190,6 +192,7 @@
"HeaderSettingsExperimental": "Experimentální funkce",
"HeaderSettingsGeneral": "Obecné",
"HeaderSettingsScanner": "Skener",
"HeaderSettingsWebClient": "Webový klient",
"HeaderSleepTimer": "Časovač vypnutí",
"HeaderStatsLargestItems": "Největší položky",
"HeaderStatsLongestItems": "Nejdelší položky (hod.)",
@@ -231,7 +234,7 @@
"LabelAppend": "Připojit",
"LabelAudioBitrate": "Bitový tok zvuku (např. 128k)",
"LabelAudioChannels": "Zvukové kanály (1 nebo 2)",
"LabelAudioCodec": "Kodek audia",
"LabelAudioCodec": "Audio Kodek",
"LabelAuthor": "Autor",
"LabelAuthorFirstLast": "Autor (jméno a příjmení)",
"LabelAuthorLastFirst": "Autor (příjmení a jméno)",
@@ -264,6 +267,7 @@
"LabelChapters": "Kapitoly",
"LabelChaptersFound": "Kapitoly nalezeny",
"LabelClickForMoreInfo": "Klikněte pro více informací",
"LabelClickToUseCurrentValue": "Klikni pro použití aktuální hodnoty",
"LabelClosePlayer": "Zavřít přehrávač",
"LabelCodec": "Kodek",
"LabelCollapseSeries": "Sbalit sérii",
@@ -313,12 +317,25 @@
"LabelEmailSettingsTestAddress": "Testovací adresa",
"LabelEmbeddedCover": "Vložená obálka",
"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",
"LabelEndOfChapter": "Konec kapitoly",
"LabelEpisode": "Epizoda",
"LabelEpisodeNotLinkedToRssFeed": "Epizoda není propojená s RSS feed",
"LabelEpisodeNumber": "Epizoda #{0}",
"LabelEpisodeTitle": "Název epizody",
"LabelEpisodeType": "Typ epizody",
"LabelEpisodeUrlFromRssFeed": "URL epizody z RSS feed",
"LabelEpisodes": "Epizody",
"LabelEpisodic": "Epizodické",
"LabelExample": "Příklad",
"LabelExpandSeries": "Rozbalit série",
"LabelExpandSubSeries": "Rozbalit podsérie",
@@ -346,6 +363,7 @@
"LabelFontScale": "Měřítko písma",
"LabelFontStrikethrough": "Přeškrtnutí",
"LabelFormat": "Formát",
"LabelFull": "Plné",
"LabelGenre": "Žánr",
"LabelGenres": "Žánry",
"LabelHardDeleteFile": "Trvale smazat soubor",
@@ -388,6 +406,7 @@
"LabelLess": "Méně",
"LabelLibrariesAccessibleToUser": "Knihovny přístupné uživateli",
"LabelLibrary": "Knihovna",
"LabelLibraryFilterSublistEmpty": "Žádné {0}",
"LabelLibraryItem": "Položka knihovny",
"LabelLibraryName": "Název knihovny",
"LabelLimit": "Omezit",
@@ -400,6 +419,10 @@
"LabelLowestPriority": "Nejnižší priorita",
"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.",
"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í",
"LabelMediaType": "Typ média",
"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.",
"LabelOpenRSSFeed": "Otevřít RSS kanál",
"LabelOverwrite": "Přepsat",
"LabelPaginationPageXOfY": "Strana {0} z {1}",
"LabelPassword": "Heslo",
"LabelPath": "Cesta",
"LabelPermanent": "Trvalé",
"LabelPermissionsAccessAllLibraries": "Má přístup ke všem knihovnám",
"LabelPermissionsAccessAllTags": "Má přístup ke všem značkám",
"LabelPermissionsAccessExplicitContent": "Má přístup k explicitnímu obsahu",
"LabelPermissionsCreateEreader": "Může vytvořit Ereader",
"LabelPermissionsDelete": "Může mazat",
"LabelPermissionsDownload": "Může stahovat",
"LabelPermissionsUpdate": "Může aktualizovat",
@@ -474,6 +499,8 @@
"LabelPubDate": "Datum vydání",
"LabelPublishYear": "Rok vydání",
"LabelPublishedDate": "Vydáno {0}",
"LabelPublishedDecade": "Publikováno (dekáda)",
"LabelPublishedDecades": "Publikováno (dekády)",
"LabelPublisher": "Vydavatel",
"LabelPublishers": "Vydavatelé",
"LabelRSSFeedCustomOwnerEmail": "Vlastní e-mail vlastníka",
@@ -493,24 +520,32 @@
"LabelRedo": "Přepracovat",
"LabelRegion": "Region",
"LabelReleaseDate": "Datum vydání",
"LabelRemoveAllMetadataAbs": "Odebrat všechny soubory metadata.abs",
"LabelRemoveAllMetadataJson": "Smazat všechny soubory metadata.json",
"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",
"LabelSearchTerm": "Vyhledat termín",
"LabelSearchTitle": "Vyhledat název",
"LabelSearchTitleOrASIN": "Vyhledat název nebo ASIN",
"LabelSeason": "Sezóna",
"LabelSeasonNumber": "Sezóna č.{0}",
"LabelSelectAll": "Vybrat vše",
"LabelSelectAllEpisodes": "Vybrat všechny epizody",
"LabelSelectEpisodesShowing": "Vyberte {0} epizody, které se zobrazují",
"LabelSelectUsers": "Vybrat uživatele",
"LabelSendEbookToDevice": "Odeslat e-knihu do...",
"LabelSequence": "Sekvence",
"LabelSerial": "Sériové",
"LabelSeries": "Série",
"LabelSeriesName": "Název série",
"LabelSeriesProgress": "Průběh série",
"LabelServerLogLevel": "Úroveň protokolu serveru",
"LabelServerYearReview": "Přehled roku na serveru ({0})",
"LabelSetEbookAsPrimary": "Nastavit jako primární",
"LabelSetEbookAsSupplementary": "Nastavit jako doplňkové",
"LabelSettingsAllowIframe": "Povolit vložení do rámce iframe",
"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",
"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.",
"LabelSettingsHomePageBookshelfView": "Domovská stránka 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",
"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",
@@ -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",
"LabelSettingsTimeFormat": "Formát času",
"LabelShare": "Sdílet",
"LabelShareOpen": "Otevřít sdílení",
"LabelShareURL": "Sdílet URL",
"LabelShowAll": "Zobrazit vše",
"LabelShowSeconds": "Zobrazit sekundy",
"LabelShowSubtitles": "Zobrazit titulky",
"LabelSize": "Velikost",
"LabelSleepTimer": "Časovač vypnutí",
"LabelSlug": "URL název",
"LabelSortAscending": "Vzestupně",
"LabelSortDescending": "Sestupně",
"LabelStart": "Spustit",
"LabelStartTime": "Čas Spuštění",
"LabelStarted": "Spuštěno",
@@ -594,6 +636,7 @@
"LabelTimeDurationXMinutes": "{0} minut",
"LabelTimeDurationXSeconds": "{0} sekund",
"LabelTimeInMinutes": "Čas v minutách",
"LabelTimeLeft": "{0} zbývá",
"LabelTimeListened": "Čas poslechu",
"LabelTimeListenedToday": "Čas poslechu dnes",
"LabelTimeRemaining": "{0} zbývá",
@@ -601,6 +644,7 @@
"LabelTitle": "Název",
"LabelToolsEmbedMetadata": "Vložit metadata",
"LabelToolsEmbedMetadataDescription": "Vložit metadata do zvukových souborů včetně obálky a kapitol.",
"LabelToolsM4bEncoder": "Enkodér M4B",
"LabelToolsMakeM4b": "Vytvořit soubor audioknihy M4B",
"LabelToolsMakeM4bDescription": "Vygenerovat soubor audioknihy M4B s vloženými metadaty, obálkou a kapitolami.",
"LabelToolsSplitM4b": "Rozdělit M4B na MP3",
@@ -613,6 +657,7 @@
"LabelTracksMultiTrack": "Více stop",
"LabelTracksNone": "Žádné stopy",
"LabelTracksSingleTrack": "Jedna stopa",
"LabelTrailer": "Upoutávka",
"LabelType": "Typ",
"LabelUnabridged": "Nezkráceno",
"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",
"LabelUpdatedAt": "Aktualizováno v",
"LabelUploaderDragAndDrop": "Přetáhnout soubory nebo složky",
"LabelUploaderDragAndDropFilesOnly": "Přetáhnout a upustit soubory",
"LabelUploaderDropFiles": "Odstranit soubory",
"LabelUploaderItemFetchMetadataHelp": "Automaticky načíst název, autora a sérii",
"LabelUseAdvancedOptions": "Použít pokročilé možnosti",
"LabelUseChapterTrack": "Použít stopu kapitoly",
"LabelUseFullTrack": "Použít celou stopu",
"LabelUseZeroForUnlimited": "Použijte 0 pro neomezené",
"LabelUser": "Uživatel",
"LabelUsername": "Uživatelské jméno",
"LabelValue": "Hodnota",
@@ -637,6 +685,8 @@
"LabelViewPlayerSettings": "Zobrazit nastavení přehrávače",
"LabelViewQueue": "Zobrazit frontu přehrávače",
"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í",
"LabelXBooks": "{0} knih",
"LabelXItems": "{0} položky",
@@ -674,6 +724,7 @@
"MessageConfirmDeleteMetadataProvider": "Opravdu chcete vymazat vlastního poskytovatele metadat \"{0}\"?",
"MessageConfirmDeleteNotification": "Opravdu chcete vymazat tuto notifikaci?",
"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í?",
"MessageConfirmMarkAllEpisodesFinished": "Opravdu chcete označit všechny epizody jako dokonč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é?",
"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é?",
"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?",
"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?",
"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?",
"MessageConfirmRemoveAllChapters": "Opravdu chcete odstranit všechny kapitoly?",
"MessageConfirmRemoveAuthor": "Opravdu chcete odstranit autora \"{0}\"?",
@@ -691,6 +744,7 @@
"MessageConfirmRemoveEpisode": "Opravdu chcete odstranit epizodu \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Opravdu chcete odstranit {0} epizody?",
"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}\"?",
"MessageConfirmRemovePlaylist": "Opravdu chcete odstranit svůj playlist \"{0}\"?",
"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",
"MessageEmbedFailed": "Vložení selhalo!",
"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í",
"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}",
@@ -750,6 +805,7 @@
"MessageNoLogs": "Žádné protokoly",
"MessageNoMediaProgress": "Žádný průběh médií",
"MessageNoNotifications": "Žádná oznámení",
"MessageNoPodcastFeed": "Neplatný podcast: Žádný kanál",
"MessageNoPodcastsFound": "Nebyly nalezeny žádné podcasty",
"MessageNoResults": "Žádné výsledky",
"MessageNoSearchResultsFor": "Nebyly nalezeny žádné výsledky hledání pro \"{0}\"",
@@ -766,7 +822,10 @@
"MessagePlaylistCreateFromCollection": "Vytvořit seznam skladeb z kolekce",
"MessagePleaseWait": "Čekejte prosím...",
"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",
"MessageRemoveEpisodes": "Odstranit {0} epizodu",
"MessageRemoveFromPlayerQueue": "Odstranit z fronty přehrávače",
@@ -797,10 +856,13 @@
"MessageTaskFailedToMergeAudioFiles": "Spojení audio souborů selhalo",
"MessageTaskFailedToMoveM4bFile": "Přesunutí m4b souboru selhalo",
"MessageTaskFailedToWriteMetadataFile": "Zápis souboru metadat selhal",
"MessageTaskMatchingBooksInLibrary": "Párování knih v knihovně „{0}“",
"MessageTaskNoFilesToScan": "Žádné soubory ke skenování",
"MessageTaskOpmlImport": "Import OPML",
"MessageTaskOpmlImportDescription": "Vytváření podcastů z {0} RSS feedů",
"MessageTaskOpmlImportFeed": "Importní zdroj OPML",
"MessageTaskOpmlImportFeedDescription": "Importování RSS feedu \"{0}\"",
"MessageTaskOpmlImportFeedFailed": "Nepodařilo se získat kanál podcastu",
"MessageTaskOpmlImportFeedPodcastDescription": "Vytváření podcastu \"{0}\"",
"MessageTaskOpmlImportFeedPodcastExists": "Podcast se stejnou cestou již existuje",
"MessageTaskOpmlImportFeedPodcastFailed": "Vytváření podcastu selhalo",

View File

@@ -19,6 +19,7 @@
"ButtonChooseFiles": "Vælg filer",
"ButtonClearFilter": "Ryd filter",
"ButtonCloseFeed": "Luk feed",
"ButtonCloseSession": "Luk Åben Session",
"ButtonCollections": "Samlinger",
"ButtonConfigureScanner": "Konfigurer scanner",
"ButtonCreate": "Opret",
@@ -29,7 +30,9 @@
"ButtonEditChapters": "Rediger kapitler",
"ButtonEditPodcast": "Rediger podcast",
"ButtonEnable": "Aktiver",
"ButtonForceReScan": "Tvungen genindlæsning",
"ButtonFireAndFail": "Affyring Og Fejl",
"ButtonFireOnTest": "Affyring vedTest begivenhed",
"ButtonForceReScan": "Tving genindlæsning",
"ButtonFullPath": "Fuld sti",
"ButtonHide": "Skjul",
"ButtonHome": "Hjem",

View File

@@ -88,6 +88,8 @@
"ButtonSaveTracklist": "Speichere die Titelliste",
"ButtonScan": "Partial-Scan (nur geänderte/neue Medien)",
"ButtonScanLibrary": "Bibliothek scannen",
"ButtonScrollLeft": "Nach Links scrollen",
"ButtonScrollRight": "Nach Rechts scrollen",
"ButtonSearch": "Suchen",
"ButtonSelectFolderPath": "Ordnerpfad auswählen",
"ButtonSeries": "Serien",
@@ -190,6 +192,7 @@
"HeaderSettingsExperimental": "Experimentelle Funktionen",
"HeaderSettingsGeneral": "Allgemein",
"HeaderSettingsScanner": "Scanner",
"HeaderSettingsWebClient": "Web-Client",
"HeaderSleepTimer": "Sleep-Timer",
"HeaderStatsLargestItems": "Größte Medien",
"HeaderStatsLongestItems": "Längste Medien (h)",
@@ -542,6 +545,7 @@
"LabelServerYearReview": "Server Jahr in Übersicht ({0})",
"LabelSetEbookAsPrimary": "Als Hauptbuch setzen",
"LabelSetEbookAsSupplementary": "Als Ergänzung setzen",
"LabelSettingsAllowIframe": "Einbetten in einem iFrame erlauben",
"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",
"LabelSettingsBookshelfViewHelp": "Skeumorphes Design mit Holzeinlegeböden",
@@ -592,6 +596,8 @@
"LabelSize": "Größe",
"LabelSleepTimer": "Schlummerfunktion",
"LabelSlug": "URL Teil",
"LabelSortAscending": "Aufsteigend",
"LabelSortDescending": "Absteigend",
"LabelStart": "Start",
"LabelStartTime": "Startzeit",
"LabelStarted": "Gestartet",
@@ -679,7 +685,7 @@
"LabelViewPlayerSettings": "Zeige player Einstellungen",
"LabelViewQueue": "Player-Warteschlange anzeigen",
"LabelVolume": "Lautstärke",
"LabelWebRedirectURLsDescription": "Autorisieren Sie diese URLs bei ihrem OAuth-Anbieter, um die Weiterleitung zurück zur Webanwendung nach dem Login zu ermöglichen:",
"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",
"LabelXBooks": "{0} Bücher",

View File

@@ -300,6 +300,7 @@
"LabelDiscover": "Discover",
"LabelDownload": "Download",
"LabelDownloadNEpisodes": "Download {0} episodes",
"LabelDownloadable": "Downloadable",
"LabelDuration": "Duration",
"LabelDurationComparisonExactMatch": "(exact match)",
"LabelDurationComparisonLonger": "({0} longer)",
@@ -588,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",
"LabelSettingsTimeFormat": "Time Format",
"LabelShare": "Share",
"LabelShareDownloadableHelp": "Allows users with the share link to download a zip file of the library item.",
"LabelShareOpen": "Share Open",
"LabelShareURL": "Share URL",
"LabelShowAll": "Show All",

View File

@@ -88,6 +88,8 @@
"ButtonSaveTracklist": "Guardar Tracklist",
"ButtonScan": "Escanear",
"ButtonScanLibrary": "Escanear Biblioteca",
"ButtonScrollLeft": "Desplazarse hacia la izquierda",
"ButtonScrollRight": "Desplazarse hacia la derecha",
"ButtonSearch": "Buscar",
"ButtonSelectFolderPath": "Seleccionar Ruta de Carpeta",
"ButtonSeries": "Series",
@@ -190,6 +192,7 @@
"HeaderSettingsExperimental": "Funciones Experimentales",
"HeaderSettingsGeneral": "General",
"HeaderSettingsScanner": "Escáner",
"HeaderSettingsWebClient": "Cliente web",
"HeaderSleepTimer": "Temporizador de apagado",
"HeaderStatsLargestItems": "Artículos mas Grandes",
"HeaderStatsLongestItems": "Artículos mas Largos (h)",
@@ -542,6 +545,7 @@
"LabelServerYearReview": "Resumen del año del servidor ({0})",
"LabelSetEbookAsPrimary": "Establecer como primario",
"LabelSetEbookAsSupplementary": "Establecer como suplementario",
"LabelSettingsAllowIframe": "Permitir incrustación en un iframe",
"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",
"LabelSettingsBookshelfViewHelp": "Diseño Esqueuomorfo con Estantes de Madera",
@@ -592,6 +596,8 @@
"LabelSize": "Tamaño",
"LabelSleepTimer": "Temporizador de apagado",
"LabelSlug": "Slug",
"LabelSortAscending": "Ascendente",
"LabelSortDescending": "Descendente",
"LabelStart": "Iniciar",
"LabelStartTime": "Tiempo de Inicio",
"LabelStarted": "Iniciado",

View File

@@ -592,6 +592,8 @@
"LabelSize": "Taille",
"LabelSleepTimer": "Minuterie de mise en veille",
"LabelSlug": "Identifiant dURL",
"LabelSortAscending": "Croissant",
"LabelSortDescending": "Décroissant",
"LabelStart": "Démarrer",
"LabelStartTime": "Heure de démarrage",
"LabelStarted": "Démarré",

View File

@@ -88,6 +88,8 @@
"ButtonSaveTracklist": "Spremi popis zvučnih zapisa",
"ButtonScan": "Skeniraj",
"ButtonScanLibrary": "Skeniraj knjižnicu",
"ButtonScrollLeft": "Pomicanje lijevo",
"ButtonScrollRight": "Pomicanje desno",
"ButtonSearch": "Traži",
"ButtonSelectFolderPath": "Odaberi putanju mape",
"ButtonSeries": "Serijali",
@@ -190,6 +192,7 @@
"HeaderSettingsExperimental": "Eksperimentalne značajke",
"HeaderSettingsGeneral": "Općenito",
"HeaderSettingsScanner": "Skener",
"HeaderSettingsWebClient": "Web klijent",
"HeaderSleepTimer": "Timer za spavanje",
"HeaderStatsLargestItems": "Najveće stavke",
"HeaderStatsLongestItems": "Najduže stavke (sati)",
@@ -542,6 +545,7 @@
"LabelServerYearReview": "Godišnji pregled poslužitelja ({0})",
"LabelSetEbookAsPrimary": "Postavi kao primarno",
"LabelSetEbookAsSupplementary": "Postavi kao dopunsko",
"LabelSettingsAllowIframe": "Omogući ugrađivanje u iframeu",
"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",
"LabelSettingsBookshelfViewHelp": "Skeumorfni dizajn sa drvenim policama",
@@ -592,6 +596,8 @@
"LabelSize": "Veličina",
"LabelSleepTimer": "Timer za spavanje",
"LabelSlug": "Slug",
"LabelSortAscending": "Uzlazno",
"LabelSortDescending": "Silazno",
"LabelStart": "Početak",
"LabelStartTime": "Vrijeme početka",
"LabelStarted": "Započeto",

View File

@@ -88,6 +88,8 @@
"ButtonSaveTracklist": "Sávlista mentése",
"ButtonScan": "Szkennelés",
"ButtonScanLibrary": "Könyvtár szkennelése",
"ButtonScrollLeft": "Balra görgetés",
"ButtonScrollRight": "Jobbra görgetés",
"ButtonSearch": "Keresés",
"ButtonSelectFolderPath": "Mappa útvonalának kiválasztása",
"ButtonSeries": "Sorozatok",
@@ -180,6 +182,7 @@
"HeaderRemoveEpisodes": "{0} epizód eltávolítása",
"HeaderSavedMediaProgress": "Mentett médialejátszási állapot",
"HeaderSchedule": "Ütemezés",
"HeaderScheduleEpisodeDownloads": "Automatikus epizódletöltés ütemezése",
"HeaderScheduleLibraryScans": "Könyvtárak automatikus szkennelésének ütemezése",
"HeaderSession": "Munkamenet",
"HeaderSetBackupSchedule": "Biztonsági másolatok ütemezésének beállítása",
@@ -188,13 +191,14 @@
"HeaderSettingsExperimental": "Kísérleti funkciók",
"HeaderSettingsGeneral": "Általános",
"HeaderSettingsScanner": "Szkenner",
"HeaderSettingsWebClient": "Webkliens",
"HeaderSleepTimer": "Alvásidőzítő",
"HeaderStatsLargestItems": "Legnagyobb elemek",
"HeaderStatsLongestItems": "Leghosszabb elemek (órákban)",
"HeaderStatsMinutesListeningChart": "Hallgatási grafikon percekben (az elmúlt 7 napból)",
"HeaderStatsRecentSessions": "Legutóbbi munkamenetek",
"HeaderStatsTop10Authors": "Top 10 szerzők",
"HeaderStatsTop5Genres": "Top 5 műfajok",
"HeaderStatsTop10Authors": "Top 10 szerző",
"HeaderStatsTop5Genres": "Top 5 műfaj",
"HeaderTableOfContents": "Tartalomjegyzék",
"HeaderTools": "Eszközök",
"HeaderUpdateAccount": "Fiók frissítése",
@@ -202,7 +206,7 @@
"HeaderUpdateDetails": "Részletek frissítése",
"HeaderUpdateLibrary": "Könyvtár frissítése",
"HeaderUsers": "Felhasználók",
"HeaderYearReview": "{0} év áttekintése",
"HeaderYearReview": "{0} év visszatekintése",
"HeaderYourStats": "Saját statisztikák",
"LabelAbridged": "Tömörített",
"LabelAbridgedChecked": "Rövidített (ellenőrizve)",
@@ -225,7 +229,11 @@
"LabelAllUsersExcludingGuests": "Minden felhasználó, vendégek kivételével",
"LabelAllUsersIncludingGuests": "Minden felhasználó, beleértve a vendégeket is",
"LabelAlreadyInYourLibrary": "Már a könyvtárában van",
"LabelApiToken": "API Token",
"LabelAppend": "Hozzáfűzés",
"LabelAudioBitrate": "Audió bitráta (pl.128k)",
"LabelAudioChannels": "Audió csatorna (1 vagy 2)",
"LabelAudioCodec": "Audio Codec",
"LabelAuthor": "Szerző",
"LabelAuthorFirstLast": "Szerző (Keresztnév Vezetéknév)",
"LabelAuthorLastFirst": "Szerző (Vezetéknév, Keresztnév)",
@@ -238,6 +246,7 @@
"LabelAutoRegister": "Automatikus regisztráció",
"LabelAutoRegisterDescription": "Új felhasználók automatikus létrehozása bejelentkezés után",
"LabelBackToUser": "Vissza a felhasználóhoz",
"LabelBackupAudioFiles": "Audiófájlok biztonsági mentése",
"LabelBackupLocation": "Biztonsági másolat helye",
"LabelBackupsEnableAutomaticBackups": "Automatikus biztonsági másolatok engedélyezése",
"LabelBackupsEnableAutomaticBackupsHelp": "Biztonsági másolatok mentése a /metadata/backups mappába",
@@ -246,15 +255,18 @@
"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.",
"LabelBitrate": "Bitráta",
"LabelBonus": "Bónusz",
"LabelBooks": "Könyvek",
"LabelButtonText": "Gomb szövege",
"LabelByAuthor": "{} által",
"LabelChangePassword": "Jelszó megváltoztatása",
"LabelChannels": "Csatornák",
"LabelChapterCount": "{0} Fejezet",
"LabelChapterTitle": "Fejezet címe",
"LabelChapters": "Fejezetek",
"LabelChaptersFound": "fejezet található",
"LabelClickForMoreInfo": "További információkért kattintson",
"LabelClickToUseCurrentValue": "Kattintson az aktuális érték használatához",
"LabelClosePlayer": "Lejátszó bezárása",
"LabelCodec": "Kodek",
"LabelCollapseSeries": "Sorozat összecsukása",
@@ -304,16 +316,28 @@
"LabelEmailSettingsTestAddress": "Teszt cím",
"LabelEmbeddedCover": "Beágyazott borító",
"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",
"LabelEndOfChapter": "Fejezet vége",
"LabelEpisode": "Epizód",
"LabelEpisodeNotLinkedToRssFeed": "Epizód nem kapcsolódik RSS hírcsatonához",
"LabelEpisodeNumber": "Epizód #{0}",
"LabelEpisodeTitle": "Epizód címe",
"LabelEpisodeType": "Epizód típusa",
"LabelEpisodeUrlFromRssFeed": "Epizód URL-címe az RSS hírcsatornából",
"LabelEpisodes": "Epizódok",
"LabelEpisodic": "Epizódikus",
"LabelExample": "Példa",
"LabelExpandSeries": "Sorozat kinyitása",
"LabelExpandSubSeries": "Alsorozat kinyitása",
"LabelExplicit": "Explicit",
"LabelExplicit": "Szókimondó",
"LabelExplicitChecked": "Explicit (ellenőrizve)",
"LabelExplicitUnchecked": "Nem explicit (nem ellenőrzött)",
"LabelExportOPML": "OPML exportálása",
@@ -337,6 +361,7 @@
"LabelFontScale": "Betűméret skála",
"LabelFontStrikethrough": "Áthúzott",
"LabelFormat": "Formátum",
"LabelFull": "Teljes",
"LabelGenre": "Műfaj",
"LabelGenres": "Műfajok",
"LabelHardDeleteFile": "Fájl végleges törlése",
@@ -392,6 +417,10 @@
"LabelLowestPriority": "Legalacsonyabb prioritás",
"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",
"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ó",
"LabelMediaType": "Média típus",
"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",
"LabelMetadataProvider": "Metaadat-szolgáltató",
"LabelMinute": "Perc",
"LabelMinutes": "Percek",
"LabelMinutes": "Perc",
"LabelMissing": "Hiányzó",
"LabelMissingEbook": "Nincs e-könyve",
"LabelMissingSupplementaryEbook": "Nincs kiegészítő e-könyve",
@@ -434,20 +463,22 @@
"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:",
"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",
"LabelOverwrite": "Felülírás",
"LabelPaginationPageXOfY": "{0} oldal {1}-ból/ből",
"LabelPassword": "Jelszó",
"LabelPath": "Útvonal",
"LabelPermanent": "Végleges",
"LabelPermissionsAccessAllLibraries": "Hozzáférhet az összes könyvtárhoz",
"LabelPermissionsAccessAllTags": "Hozzáférhet az összes címkéhez",
"LabelPermissionsAccessExplicitContent": "Hozzáférhet explicit tartalomhoz",
"LabelPermissionsCreateEreader": "Létrehozhat Ereader-t",
"LabelPermissionsDelete": "Törölhet",
"LabelPermissionsDownload": "Letölthet",
"LabelPermissionsUpdate": "Frissíthet",
"LabelPermissionsUpload": "Feltölthet",
"LabelPersonalYearReview": "Az éved áttekintése ({0})",
"LabelPersonalYearReview": "Az évvisszatekintésed ({0})",
"LabelPhotoPathURL": "Fénykép útvonal/URL",
"LabelPlayMethod": "Lejátszási módszer",
"LabelPlayerChapterNumberMarker": "{0} a {1} -ből",
@@ -466,6 +497,8 @@
"LabelPubDate": "Kiadás dátuma",
"LabelPublishYear": "Kiadás éve",
"LabelPublishedDate": "Kiadva {0}",
"LabelPublishedDecade": "Közzétett évtized",
"LabelPublishedDecades": "Közzétett évtized",
"LabelPublisher": "Kiadó",
"LabelPublishers": "Kiadók",
"LabelRSSFeedCustomOwnerEmail": "Egyéni tulajdonos e-mail",
@@ -475,6 +508,7 @@
"LabelRSSFeedSlug": "RSS hírcsatorna slug",
"LabelRSSFeedURL": "RSS hírcsatorna URL",
"LabelRandomly": "Véletlenszerűen",
"LabelReAddSeriesToContinueListening": "Sorozat újbóli hozzáadása a folytatáshoz",
"LabelRead": "Olvasás",
"LabelReadAgain": "Újraolvasás",
"LabelReadEbookWithoutProgress": "E-könyv olvasása haladás nélkül",
@@ -484,12 +518,18 @@
"LabelRedo": "Újra",
"LabelRegion": "Régió",
"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",
"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",
"LabelSearchTerm": "Keresési kifejezés",
"LabelSearchTitle": "Cím keresése",
"LabelSearchTitleOrASIN": "Cím vagy ASIN keresése",
"LabelSeason": "Évad",
"LabelSeasonNumber": "Évad #{0}",
"LabelSelectAll": "Minden kiválasztása",
"LabelSelectAllEpisodes": "Összes epizód kiválasztása",
"LabelSelectEpisodesShowing": "Kiválasztás {0} megjelenített epizód",
"LabelSelectUsers": "Felhasználók kiválasztása",
@@ -498,8 +538,11 @@
"LabelSeries": "Sorozat",
"LabelSeriesName": "Sorozat neve",
"LabelSeriesProgress": "Sorozat haladása",
"LabelServerLogLevel": "Kiszolgáló naplózási szint",
"LabelServerYearReview": "Szerver évvisszatekintés ({0})",
"LabelSetEbookAsPrimary": "Beállítás elsődlegeské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",
"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",
@@ -511,6 +554,8 @@
"LabelSettingsEnableWatcher": "Figyelő engedélyezése",
"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",
"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",
"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",
@@ -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.",
"LabelSettingsHomePageBookshelfView": "Kezdőlap 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",
"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",
@@ -534,10 +584,14 @@
"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",
"LabelSettingsTimeFormat": "Időformátum",
"LabelShare": "Megosztás",
"LabelShowAll": "Mindent mutat",
"LabelShowSubtitles": "Felirat megjelenítése",
"LabelSize": "Méret",
"LabelSleepTimer": "Alvásidőzítő",
"LabelSlug": "Rövid cím",
"LabelSortAscending": "Emelkedő",
"LabelSortDescending": "Csökkenő",
"LabelStart": "Kezdés",
"LabelStartTime": "Kezdési idő",
"LabelStarted": "Elkezdődött",
@@ -547,13 +601,13 @@
"LabelStatsBestDay": "Legjobb nap",
"LabelStatsDailyAverage": "Napi átlag",
"LabelStatsDays": "Napok",
"LabelStatsDaysListened": "Hallgatott napok",
"LabelStatsDaysListened": "Napon hallgatva",
"LabelStatsHours": "Órák",
"LabelStatsInARow": "egymás után",
"LabelStatsItemsFinished": "Befejezett elemek",
"LabelStatsItemsFinished": "Befejezett elem",
"LabelStatsItemsInLibrary": "Elemek a könyvtárban",
"LabelStatsMinutes": "percek",
"LabelStatsMinutesListening": "Hallgatási percek",
"LabelStatsMinutes": "perc",
"LabelStatsMinutesListening": "Hallgatási perc",
"LabelStatsOverallDays": "Összes nap",
"LabelStatsOverallHours": "Összes óra",
"LabelStatsWeekListening": "Heti hallgatás",
@@ -565,12 +619,18 @@
"LabelTagsNotAccessibleToUser": "A felhasználó számára nem elérhető címkék",
"LabelTasks": "Futó feladatok",
"LabelTextEditorBulletedList": "Pontozott lista",
"LabelTextEditorLink": "Hivatkozás",
"LabelTextEditorNumberedList": "Számozott lista",
"LabelTextEditorUnlink": "Link eltávolítása",
"LabelTheme": "Téma",
"LabelThemeDark": "Sötét",
"LabelThemeLight": "Világos",
"LabelTimeBase": "Időalap",
"LabelTimeDurationXHours": "{0} óra",
"LabelTimeDurationXMinutes": "{0} perc",
"LabelTimeDurationXSeconds": "{0} másodperc",
"LabelTimeInMinutes": "Idő percben",
"LabelTimeLeft": "{0} maradt hátra",
"LabelTimeListened": "Hallgatott idő",
"LabelTimeListenedToday": "Ma hallgatott idő",
"LabelTimeRemaining": "{0} maradt",
@@ -578,6 +638,7 @@
"LabelTitle": "Cím",
"LabelToolsEmbedMetadata": "Metaadatok beágyazása",
"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",
"LabelToolsMakeM4bDescription": ".M4B hangoskönyv fájl generálása beágyazott metaadatokkal, borítóképpel és fejezetekkel.",
"LabelToolsSplitM4b": "M4B felosztása MP3-ra",
@@ -590,29 +651,41 @@
"LabelTracksMultiTrack": "Többsávos",
"LabelTracksNone": "Nincsenek sávok",
"LabelTracksSingleTrack": "Egysávos",
"LabelTrailer": "Előzetes",
"LabelType": "Típus",
"LabelUnabridged": "Nem tömörített",
"LabelUndo": "Visszavonás",
"LabelUnknown": "Ismeretlen",
"LabelUnknownPublishDate": "Ismeretlen megjelenési dátum",
"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",
"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",
"LabelUpdatedAt": "Frissítve",
"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",
"LabelUploaderItemFetchMetadataHelp": "Cím, szerző és sorozat automatikus lekérése",
"LabelUseAdvancedOptions": "Haladó beállítások használata",
"LabelUseChapterTrack": "Fejezetsáv használata",
"LabelUseFullTrack": "Teljes sáv használata",
"LabelUseZeroForUnlimited": "Használja a 0-t a korlátlan értékhez",
"LabelUser": "Felhasználó",
"LabelUsername": "Felhasználónév",
"LabelValue": "Érték",
"LabelVersion": "Verzió",
"LabelViewBookmarks": "Könyvjelzők megtekintése",
"LabelViewChapters": "Fejezetek megtekintése",
"LabelViewPlayerSettings": "A lejátszó beállításainak megtekintése",
"LabelViewQueue": "Lejátszó sor megtekintése",
"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",
"LabelXBooks": "{0} könyv",
"LabelXItems": "{0} elem",
"LabelYearReviewHide": "Az évvisszatekintés elrejtése",
"LabelYearReviewShow": "Évvisszatekintés megtekintése",
"LabelYourAudiobookDuration": "Hangoskönyv időtartama",
"LabelYourBookmarks": "Könyvjelzőid",
"LabelYourPlaylists": "Lejátszási listáid",
@@ -620,10 +693,14 @@
"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.",
"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.",
"MessageBookshelfNoCollections": "Még nem készített gyűjteményeket",
"MessageBookshelfNoRSSFeeds": "Nincsenek nyitott RSS hírcsatornák",
"MessageBookshelfNoResultsForFilter": "Nincs eredmény a \"{0}: {1}\" szűrőre",
"MessageBookshelfNoResultsForQuery": "Nincs eredmény a lekérdezéshez",
"MessageBookshelfNoSeries": "Nincsenek sorozatai",
"MessageChapterEndIsAfter": "A fejezet vége a hangoskönyv végét követi",
"MessageChapterErrorFirstNotZero": "Az első fejezetnek 0:00-kor kell kezdődnie",
@@ -633,17 +710,27 @@
"MessageCheckingCron": "Cron ellenőrzése...",
"MessageConfirmCloseFeed": "Biztosan be szeretné zárni ezt a hírcsatornát?",
"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?",
"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?",
"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?",
"MessageConfirmEmbedMetadataInAudioFiles": "Biztos, hogy metaadatokat szeretne beágyazni {0} hangfájlba?",
"MessageConfirmForceReScan": "Biztosan kényszeríteni szeretné az újraszkennelést?",
"MessageConfirmMarkAllEpisodesFinished": "Biztosan meg szeretné jelölni az összes epizódot 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?",
"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?",
"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?",
"MessageConfirmRemoveAllChapters": "Biztosan eltávolítja az összes fejezetet?",
"MessageConfirmRemoveAuthor": "Biztosan eltávolítja a(z) \"{0}\" szerzőt?",
@@ -651,6 +738,7 @@
"MessageConfirmRemoveEpisode": "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?",
"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?",
"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?",
@@ -659,11 +747,15 @@
"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.",
"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?",
"MessageConfirmUnlinkOpenId": "Biztos, hogy el akarja távolítani ezt a felhasználót az OpenID-ból?",
"MessageDownloadingEpisode": "Epizód letöltése",
"MessageDragFilesIntoTrackOrder": "Húzza a fájlokat a helyes sávrendbe",
"MessageEmbedFailed": "A beágyazás sikertelen!",
"MessageEmbedFinished": "Beágyazás befejeződött!",
"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",
"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.",
@@ -671,10 +763,11 @@
"MessageInsertChapterBelow": "Fejezet beszúrása alulra",
"MessageItemsSelected": "{0} kiválasztott 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...",
"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!",
"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á",
@@ -691,6 +784,7 @@
"MessageNoCollections": "Nincsenek gyűjtemények",
"MessageNoCoversFound": "Nem találhatóak borítók",
"MessageNoDescription": "Nincs leírás",
"MessageNoDevices": "Nincs eszköz",
"MessageNoDownloadsInProgress": "Jelenleg nincsenek folyamatban lévő letöltések",
"MessageNoDownloadsQueued": "Nincsenek várakozó letöltések",
"MessageNoEpisodeMatchesFound": "Nincs találat az epizódokra",
@@ -704,6 +798,7 @@
"MessageNoLogs": "Nincsenek naplók",
"MessageNoMediaProgress": "Nincs előrehaladás a médialejátszásban",
"MessageNoNotifications": "Nincsenek értesítések",
"MessageNoPodcastFeed": "Érvénytelen podcast: Nincs forrás",
"MessageNoPodcastsFound": "Nem találhatóak podcastok",
"MessageNoResults": "Nincsenek eredmények",
"MessageNoSearchResultsFor": "Nincs keresési eredmény erre: \"{0}\"",
@@ -713,11 +808,16 @@
"MessageNoUpdatesWereNecessary": "Nem volt szükség frissítésekre",
"MessageNoUserPlaylists": "Nincsenek felhasználói lejátszási listák",
"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",
"MessagePauseChapter": "Fejezet lejátszásának szüneteltetése",
"MessagePlayChapter": "Fejezet elejének meghallgatása",
"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",
"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.",
"MessageRemoveChapter": "Fejezet eltávolítása",
"MessageRemoveEpisodes": "Epizód(ok) eltávolítása: {0}",
@@ -725,14 +825,49 @@
"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",
"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.",
"MessageSearchResultsFor": "Keresési eredmények",
"MessageSelected": "{0} kiválasztva",
"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",
"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?",
"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",
"MessageUploaderItemSuccess": "Sikeresen feltöltve!",
"MessageUploading": "Feltöltés...",
@@ -744,45 +879,101 @@
"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.",
"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.",
"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.",
"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",
"PlaceholderNewFolderPath": "Új mappa útvonala",
"PlaceholderNewPlaylist": "Új lejátszási lista neve",
"PlaceholderSearch": "Keresés..",
"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",
"ToastAppriseUrlRequired": "Meg kell adnia egy Apprise URL-címet",
"ToastAsinRequired": "ASIN kötelező",
"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",
"ToastAuthorUpdateSuccess": "Szerző frissítve",
"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",
"ToastBackupCreateSuccess": "Biztonsági mentés létrehozva",
"ToastBackupDeleteFailed": "A biztonsági mentés törlése sikertelen",
"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",
"ToastBackupUploadFailed": "A biztonsági mentés feltöltése sikertelen",
"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",
"ToastBatchUpdateSuccess": "Kötegelt frissítés sikeres",
"ToastBookmarkCreateFailed": "Könyvjelző létrehozása sikertelen",
"ToastBookmarkCreateSuccess": "Könyvjelző hozzáadva",
"ToastBookmarkRemoveSuccess": "Könyvjelző eltávolítva",
"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",
"ToastChaptersMustHaveTitles": "A fejezeteknek címekkel kell rendelkezniük",
"ToastChaptersRemoved": "Fejezetek eltávolítva",
"ToastChaptersUpdated": "Fejezetek frissítve",
"ToastCollectionItemsRemoveSuccess": "Elem(ek) eltávolítva a gyűjteményből",
"ToastCollectionRemoveSuccess": "Gyűjtemény eltávolítva",
"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",
"ToastItemDeletedFailed": "Nem sikerült törölni az elemet",
"ToastItemDeletedSuccess": "Elem törölve",
"ToastItemDetailsUpdateSuccess": "Elem részletei frissítve",
"ToastItemMarkedAsFinishedFailed": "Megjelölés Befejezettként sikertelen",
"ToastItemMarkedAsFinishedSuccess": "Elem megjelölve Befejezettként",
"ToastItemMarkedAsNotFinishedFailed": "Az elem befejezetlennek jelölése sikertelen",
"ToastItemMarkedAsNotFinishedSuccess": "Elem megjelölve Nem Befejezettként",
"ToastItemUpdateSuccess": "Elem frissítve",
"ToastLibraryCreateFailed": "Könyvtár létrehozása sikertelen",
"ToastLibraryCreateSuccess": "\"{0}\" könyvtár létrehozva",
"ToastLibraryDeleteFailed": "Könyvtár törlése sikertelen",
@@ -790,14 +981,34 @@
"ToastLibraryScanFailedToStart": "A beolvasás elindítása sikertelen",
"ToastLibraryScanStarted": "Könyvtár beolvasása elindítva",
"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",
"ToastPlaylistCreateSuccess": "Lejátszási lista létrehozva",
"ToastPlaylistRemoveSuccess": "Lejátszási lista eltávolítva",
"ToastPlaylistUpdateSuccess": "Lejátszási lista frissítve",
"ToastPodcastCreateFailed": "Podcast létrehozása sikertelen",
"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",
"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",
"ToastRemoveItemFromCollectionSuccess": "Tétel eltávolítva a gyűjteményből",
"ToastSendEbookToDeviceFailed": "E-könyv küldése az eszközre sikertelen",
@@ -809,6 +1020,9 @@
"ToastSocketConnected": "Socket csatlakoztatva",
"ToastSocketDisconnected": "Socket lecsatlakoztatva",
"ToastSocketFailedToConnect": "A Socket csatlakoztatása sikertelen",
"ToastUnknownError": "Ismeretlen hiba",
"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

@@ -104,7 +104,7 @@
"ButtonViewAll": "Peržiūrėti visus",
"ButtonYes": "Taip",
"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",
"HeaderAccount": "Paskyra",
"HeaderAdvanced": "Papildomi",
@@ -419,7 +419,7 @@
"LabelSettingsExperimentalFeatures": "Eksperimentiniai funkcionalumai",
"LabelSettingsExperimentalFeaturesHelp": "Funkcijos, kurios yra kuriamos ir laukiami jūsų komentarai. Spustelėkite, kad atidarytumėte „GitHub“ diskusiją.",
"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ą",
"LabelSettingsHideSingleBookSeriesHelp": "Serijos, turinčios tik vieną knygą, bus paslėptos nuo serijų puslapio ir pagrindinio puslapio lentynų.",
"LabelSettingsHomePageBookshelfView": "Naudoti pagrindinio puslapio knygų lentynų vaizdą",

View File

@@ -4,6 +4,7 @@
"ButtonAddDevice": "Legg til enhet",
"ButtonAddLibrary": "Legg til bibliotek",
"ButtonAddPodcasts": "Legg til podcast",
"ButtonAddUser": "Legg til bruker",
"ButtonAddYourFirstLibrary": "Legg til ditt første bibliotek",
"ButtonApply": "Bruk",
"ButtonApplyChapters": "Bruk kapittel",
@@ -18,6 +19,7 @@
"ButtonChooseFiles": "Velg filer",
"ButtonClearFilter": "Bytt filter",
"ButtonCloseFeed": "Lukk Feed",
"ButtonCloseSession": "Lukk åpen økt",
"ButtonCollections": "Samlinger",
"ButtonConfigureScanner": "Konfigurer skanner",
"ButtonCreate": "Opprett",
@@ -27,13 +29,16 @@
"ButtonEdit": "Rediger",
"ButtonEditChapters": "Rediger kapittel",
"ButtonEditPodcast": "Rediger podcast",
"ButtonEnable": "Aktiver",
"ButtonFireAndFail": "Kjør ved feil",
"ButtonFireOnTest": "Kjør onTest-kommando",
"ButtonForceReScan": "Tving skann",
"ButtonFullPath": "Full sti",
"ButtonHide": "Gjøm",
"ButtonHome": "Hjem",
"ButtonIssues": "Problemer",
"ButtonJumpBackward": "Hopp Bakover",
"ButtonJumpForward": "Hopp Fremover",
"ButtonJumpBackward": "Hopp bakover",
"ButtonJumpForward": "Hopp frem",
"ButtonLatest": "Siste",
"ButtonLibrary": "Bibliotek",
"ButtonLogout": "Logg ut",
@@ -43,24 +48,31 @@
"ButtonMatchAllAuthors": "Søk opp alle forfattere",
"ButtonMatchBooks": "Søk opp bøker",
"ButtonNevermind": "Avbryt",
"ButtonNext": "Neste",
"ButtonNextChapter": "Neste Kapittel",
"ButtonNextItemInQueue": "Neste element i køen",
"ButtonOk": "Ok",
"ButtonOpenFeed": "Åpne Feed",
"ButtonOpenManager": "Åpne behandler",
"ButtonPause": "Pause",
"ButtonPlay": "Spill av",
"ButtonPlayAll": "Spill av alle",
"ButtonPlaying": "Spiller av",
"ButtonPlaylists": "Spillelister",
"ButtonPrevious": "Forrige",
"ButtonPreviousChapter": "Forrige Kapittel",
"ButtonProbeAudioFile": "Analyser lydfil",
"ButtonPurgeAllCache": "Tøm alle mellomlager",
"ButtonPurgeItemsCache": "Tøm mellomlager",
"ButtonQueueAddItem": "Legg til kø",
"ButtonQueueRemoveItem": "Fjern fra kø",
"ButtonQuickEmbedMetadata": "Hurtig Innbygging Av Metadata",
"ButtonQuickEmbed": "Hurtiginnbygging",
"ButtonQuickEmbedMetadata": "Bygg inn metadata",
"ButtonQuickMatch": "Kjapt søk",
"ButtonReScan": "Skann på nytt",
"ButtonRead": "Les",
"ButtonReadLess": "Les Mindre",
"ButtonReadMore": "Les Mer",
"ButtonReadLess": "Vis mindre",
"ButtonReadMore": "Vis mer",
"ButtonRefresh": "Oppdater",
"ButtonRemove": "Fjern",
"ButtonRemoveAll": "Fjern alle",
@@ -69,12 +81,15 @@
"ButtonRemoveFromContinueReading": "Fjern fra Fortsett å lese",
"ButtonRemoveSeriesFromContinueSeries": "Fjern serie fra Fortsett serie",
"ButtonReset": "Nullstill",
"ButtonResetToDefault": "Tilbakestill til standard",
"ButtonRestore": "Gjenopprett",
"ButtonSave": "Lagre",
"ButtonSaveAndClose": "Lagre og lukk",
"ButtonSaveTracklist": "Lagre spilleliste",
"ButtonScan": "Skann",
"ButtonScanLibrary": "Skann bibliotek",
"ButtonScrollLeft": "Rull til venstre",
"ButtonScrollRight": "Rull til høyre",
"ButtonSearch": "Søk",
"ButtonSelectFolderPath": "Velg mappe",
"ButtonSeries": "Serier",
@@ -86,20 +101,26 @@
"ButtonStartMetadataEmbed": "Start Metadata innbaking",
"ButtonStats": "Statistikk",
"ButtonSubmit": "Send inn",
"ButtonTest": "Test",
"ButtonUnlinkOpenId": "Koble fra OpenID",
"ButtonUpload": "Last opp",
"ButtonUploadBackup": "Last opp sikkerhetskopi",
"ButtonUploadCover": "Last opp cover",
"ButtonUploadOPMLFile": "Last opp OPML fil",
"ButtonUserDelete": "Slett bruker {0}",
"ButtonUserEdit": "Rediger bruker {0}",
"ButtonViewAll": "Vis alt",
"ButtonViewAll": "Vis alle",
"ButtonYes": "Ja",
"ErrorUploadFetchMetadataAPI": "Feil ved innhenting av metadata",
"ErrorUploadFetchMetadataNoResults": "Kunne ikke hente metadata - forsøk å oppdatere tittel og/eller forfatter",
"ErrorUploadLacksTitle": "Tittel kreves",
"HeaderAccount": "Konto",
"HeaderAddCustomMetadataProvider": "Legg til egendefinert metadata tilbyder",
"HeaderAdvanced": "Avansert",
"HeaderAppriseNotificationSettings": "Apprise notifikasjonsinstillinger",
"HeaderAppriseNotificationSettings": "Apprise varslingsinstillinger",
"HeaderAudioTracks": "Lydspor",
"HeaderAudiobookTools": "Lydbok Filbehandlingsverktøy",
"HeaderAuthentication": "Autentisering",
"HeaderBackups": "Sikkerhetskopier",
"HeaderChangePassword": "Bytt passord",
"HeaderChapters": "Kapittel",
@@ -108,6 +129,8 @@
"HeaderCollectionItems": "Samlingsgjenstander",
"HeaderCover": "Omslag",
"HeaderCurrentDownloads": "Aktive nedlastinger",
"HeaderCustomMessageOnLogin": "Egendefinert melding ved pålogging",
"HeaderCustomMetadataProviders": "Egendefinerte metadata tilbydere",
"HeaderDetails": "Detaljer",
"HeaderDownloadQueue": "Last ned kø",
"HeaderEbookFiles": "Ebook filer",
@@ -138,12 +161,17 @@
"HeaderMetadataToEmbed": "Metadata å bake inn",
"HeaderNewAccount": "Ny konto",
"HeaderNewLibrary": "Ny bibliotek",
"HeaderNotifications": "Notifikasjoner",
"HeaderNotificationCreate": "Opprett varsling",
"HeaderNotificationUpdate": "Oppdater varsling",
"HeaderNotifications": "Varslinger",
"HeaderOpenIDConnectAuthentication": "Autentisering med OpenID Connect",
"HeaderOpenListeningSessions": "Åpne lyttesesjoner",
"HeaderOpenRSSFeed": "Åpne RSS Feed",
"HeaderOtherFiles": "Andre filer",
"HeaderPasswordAuthentication": "Logg inn med brukernavn og passord",
"HeaderPermissions": "Rettigheter",
"HeaderPlayerQueue": "Spiller kø",
"HeaderPlayerSettings": "Avspillingsinnstillinger",
"HeaderPlaylist": "Spilleliste",
"HeaderPlaylistItems": "Spillelisteelement",
"HeaderPodcastsToAdd": "Podcaster å legge til",
@@ -155,6 +183,7 @@
"HeaderRemoveEpisodes": "Fjern {0} episoder",
"HeaderSavedMediaProgress": "Lagret mediefremgang",
"HeaderSchedule": "Timeplan",
"HeaderScheduleEpisodeDownloads": "Planlegg automatisk nedlasting av episoder",
"HeaderScheduleLibraryScans": "Planlegg automatisk bibliotek skann",
"HeaderSession": "Sesjon",
"HeaderSetBackupSchedule": "Sett timeplan for sikkerhetskopi",
@@ -163,6 +192,7 @@
"HeaderSettingsExperimental": "Eksperimentelle funksjoner",
"HeaderSettingsGeneral": "Generell",
"HeaderSettingsScanner": "Skanner",
"HeaderSettingsWebClient": "Webklient",
"HeaderSleepTimer": "Sove timer",
"HeaderStatsLargestItems": "Største enheter",
"HeaderStatsLongestItems": "Lengste enheter (timer)",
@@ -177,9 +207,14 @@
"HeaderUpdateDetails": "Oppdater detaljer",
"HeaderUpdateLibrary": "Oppdater bibliotek",
"HeaderUsers": "Brukere",
"HeaderYearReview": "{0} oppsummert",
"HeaderYourStats": "Din statistikk",
"LabelAbridged": "Forkortet",
"LabelAbridgedChecked": "Forkortet (valgt)",
"LabelAbridgedUnchecked": "Forkortet (ikke valgt)",
"LabelAccessibleBy": "Tilgjengelig via",
"LabelAccountType": "Kontotype",
"LabelAccountTypeAdmin": "Administrator",
"LabelAccountTypeGuest": "Gjest",
"LabelAccountTypeUser": "Bruker",
"LabelActivity": "Aktivitet",
@@ -188,32 +223,55 @@
"LabelAddToPlaylist": "Legg til i spilleliste",
"LabelAddToPlaylistBatch": "Legg {0} enheter til i spilleliste",
"LabelAddedAt": "Lagt Til",
"LabelAddedDate": "La til {0}",
"LabelAdminUsersOnly": "Kun administratorer",
"LabelAll": "Alle",
"LabelAllUsers": "Alle brukere",
"LabelAllUsersExcludingGuests": "Alle brukere bortsett fra gjester",
"LabelAllUsersIncludingGuests": "Alle brukere inkludert gjester",
"LabelAlreadyInYourLibrary": "Allerede i biblioteket",
"LabelApiToken": "API token",
"LabelAppend": "Legge til",
"LabelAudioBitrate": "Bitrate for lyd (f.eks. 128k)",
"LabelAudioChannels": "Lydkanaler (1 eller 2)",
"LabelAudioCodec": "Audio Codec",
"LabelAuthor": "Forfatter",
"LabelAuthorFirstLast": "Forfatter (Fornavn Etternavn)",
"LabelAuthorLastFirst": "Forfatter (Etternavn Fornavn)",
"LabelAuthors": "Forfattere",
"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",
"LabelBackupAudioFiles": "Sikkerhetskopier lydfiler",
"LabelBackupLocation": "Mappe for sikkerhetskopiering",
"LabelBackupsEnableAutomaticBackups": "Aktiver automatisk sikkerhetskopi",
"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.",
"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.",
"LabelBitrate": "Bithastighet",
"LabelBonus": "Bonus",
"LabelBooks": "Bøker",
"LabelButtonText": "Tekst på knappen",
"LabelByAuthor": "av {0}",
"LabelChangePassword": "Endre passord",
"LabelChannels": "Kanaler",
"LabelChapterCount": "{0} kapitler",
"LabelChapterTitle": "Kapittel tittel",
"LabelChapters": "Kapitler",
"LabelChaptersFound": "kapitler funnet",
"LabelClickForMoreInfo": "Klikk for mer informasjon",
"LabelClickToUseCurrentValue": "Klikk for å bruke valgt verdi",
"LabelClosePlayer": "Lukk spiller",
"LabelCodec": "Kodek",
"LabelCollapseSeries": "Minimer serier",
"LabelCollapseSubSeries": "Skjul underserier",
"LabelCollection": "Samling",
"LabelCollections": "Samlings",
"LabelComplete": "Fullfør",
@@ -230,58 +288,94 @@
"LabelCustomCronExpression": "Tilpasset Cron utrykk:",
"LabelDatetime": "Dato tid",
"LabelDays": "Dager",
"LabelDeleteFromFileSystemCheckbox": "Slett fra filsystemet (fjern haken for kun å ta bort fra databasen)",
"LabelDescription": "Beskrivelse",
"LabelDeselectAll": "Fjern valg",
"LabelDevice": "Enhet",
"LabelDeviceInfo": "Enhetsinformasjon",
"LabelDeviceIsAvailableTo": "Enheten er tilgjengelig for...",
"LabelDirectory": "Mappe",
"LabelDiscFromFilename": "Disk fra filnavn",
"LabelDiscFromMetadata": "Disk fra metadata",
"LabelDiscover": "Oppdagelse",
"LabelDiscover": "Oppdag",
"LabelDownload": "Last ned",
"LabelDownloadNEpisodes": "Last ned {0} episoder",
"LabelDuration": "Varighet",
"LabelDurationComparisonExactMatch": "(nøyaktig treff)",
"LabelDurationComparisonLonger": "({0} lenger)",
"LabelDurationComparisonShorter": "({0} kortere)",
"LabelDurationFound": "Varighet funnet:",
"LabelEbook": "Ebok",
"LabelEbooks": "E-bøker",
"LabelEdit": "Rediger",
"LabelEmail": "Epost",
"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",
"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",
"LabelEmbeddedCover": "Bak inn omslag",
"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",
"LabelEndOfChapter": "Slutt på kapittel",
"LabelEpisode": "Episode",
"LabelEpisodeNotLinkedToRssFeed": "Episode er ikke koblet til en RSS feed",
"LabelEpisodeNumber": "Episode #{0}",
"LabelEpisodeTitle": "Episode tittel",
"LabelEpisodeType": "Episode type",
"LabelEpisodeUrlFromRssFeed": "Episode URL fra RSS feed",
"LabelEpisodes": "Episoder",
"LabelEpisodic": "Episodisk",
"LabelExample": "Eksempel",
"LabelExpandSeries": "Vis serie",
"LabelExpandSubSeries": "Vis underserie",
"LabelExplicit": "Eksplisitt",
"LabelExplicitChecked": "Eksplisitt (avhuket)",
"LabelExplicitUnchecked": "Ikke eksplisitt (ikke avhuket)",
"LabelExportOPML": "Eksporter OPML",
"LabelFeedURL": "Feed Adresse",
"LabelFetchingMetadata": "Henter metadata",
"LabelFile": "Fil",
"LabelFileBirthtime": "Fil Opprettelsesdato",
"LabelFileBornDate": "Født {0}",
"LabelFileModified": "Fil Endret",
"LabelFileModifiedDate": "Redigert {0}",
"LabelFilename": "Filnavn",
"LabelFilterByUser": "Filtrer etter bruker",
"LabelFindEpisodes": "Finn episoder",
"LabelFinished": "Fullført",
"LabelFolder": "Mappe",
"LabelFolders": "Mapper",
"LabelFontBold": "Fet",
"LabelFontBoldness": "Skrifttykkelse",
"LabelFontFamily": "Fontfamilie",
"LabelFontItalic": "Kursiv",
"LabelFontScale": "Font størrelse",
"LabelFontStrikethrough": "Gjennomstreking",
"LabelFormat": "Format",
"LabelFull": "Full",
"LabelGenre": "Sjanger",
"LabelGenres": "Sjangers",
"LabelHardDeleteFile": "Tving sletting av fil",
"LabelHasEbook": "Har e-bok",
"LabelHasSupplementaryEbook": "Har komplimentær e-bok",
"LabelHideSubtitles": "Skjul undertekster",
"LabelHighestPriority": "Høyeste prioritet",
"LabelHost": "Tjener",
"LabelHour": "Time",
"LabelHours": "Timer",
"LabelIcon": "Ikon",
"LabelImageURLFromTheWeb": "Bilde-URL fra nett",
"LabelInProgress": "I gang",
"LabelIncludeInTracklist": "Inkluder i sporliste",
"LabelIncomplete": "Ufullstendig",
@@ -296,8 +390,11 @@
"LabelIntervalEveryHour": "Hver time",
"LabelInvert": "Inverter",
"LabelItem": "Enhet",
"LabelJumpBackwardAmount": "Hopp bakover med",
"LabelJumpForwardAmount": "Hopp forover med",
"LabelLanguage": "Språk",
"LabelLanguageDefaultServer": "Standard tjener språk",
"LabelLanguages": "Språk",
"LabelLastBookAdded": "Siste bok lagt til",
"LabelLastBookUpdated": "Siste bok oppdatert",
"LabelLastSeen": "Sist sett",
@@ -309,17 +406,36 @@
"LabelLess": "Mindre",
"LabelLibrariesAccessibleToUser": "Biblioteker tilgjengelig for bruker",
"LabelLibrary": "Bibliotek",
"LabelLibraryFilterSublistEmpty": "",
"LabelLibraryItem": "Bibliotek enhet",
"LabelLibraryName": "Bibliotek navn",
"LabelLimit": "Begrensning",
"LabelLineSpacing": "Linjemellomrom",
"LabelListenAgain": "Lytt igjen",
"LabelLogLevelDebug": "Debug",
"LabelLogLevelInfo": "Info",
"LabelLogLevelWarn": "Warn",
"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",
"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",
"LabelMinute": "Minutt",
"LabelMinutes": "Minutter",
"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",
"LabelMoreInfo": "Mer info",
"LabelName": "Navn",
@@ -331,6 +447,7 @@
"LabelNewestEpisodes": "Nyeste episoder",
"LabelNextBackupDate": "Neste sikkerhetskopi dato",
"LabelNextScheduledRun": "Neste planlagte kjøring",
"LabelNoCustomMetadataProviders": "Ingen egendefinerte tilbydere for metadata",
"LabelNoEpisodesSelected": "Ingen episoder valgt",
"LabelNotFinished": "Ikke fullført",
"LabelNotStarted": "Ikke startet",
@@ -338,66 +455,95 @@
"LabelNotificationAppriseURL": "Apprise URL(er)",
"LabelNotificationAvailableVariables": "Tilgjengelige variabler",
"LabelNotificationBodyTemplate": "Kroppsmal",
"LabelNotificationEvent": "Notifikasjons hendelse",
"LabelNotificationEvent": "Varsling",
"LabelNotificationTitleTemplate": "Tittel mal",
"LabelNotificationsMaxFailedAttempts": "Maks mislykkede forsøk",
"LabelNotificationsMaxFailedAttemptsHelp": "Notifikasjoner er deaktivert når de mislykkes på sende dette flere ganger",
"LabelNotificationsMaxQueueSize": "Maks kø lengde for Notifikasjonshendelser",
"LabelNotificationsMaxQueueSizeHelp": "Hendelser er begrenset til avfyre 1 gang per sekund. Hendelser vil bli ignorert om køen er full. Dette forhindrer Notifikasjon spam.",
"LabelNotificationsMaxFailedAttemptsHelp": "Varslinger deaktiveres når sending feiles dette antallet ganger",
"LabelNotificationsMaxQueueSize": "Maksimalt antall varslinger i kø",
"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",
"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",
"LabelOverwrite": "Overskriv",
"LabelPaginationPageXOfY": "Side {0} av {1}",
"LabelPassword": "Passord",
"LabelPath": "Sti",
"LabelPermanent": "Fast",
"LabelPermissionsAccessAllLibraries": "Har tilgang til alle bibliotek",
"LabelPermissionsAccessAllTags": "Har til gang til alle tags",
"LabelPermissionsAccessExplicitContent": "Har tilgang til eksplisitt material",
"LabelPermissionsCreateEreader": "Kan opprette e-leser",
"LabelPermissionsDelete": "Kan slette",
"LabelPermissionsDownload": "Kan laste ned",
"LabelPermissionsUpdate": "Kan oppdatere",
"LabelPermissionsUpload": "Kan laste opp",
"LabelPersonalYearReview": "Oppsummering av året ditt ({0})",
"LabelPhotoPathURL": "Bilde sti/URL",
"LabelPlayMethod": "Avspillingsmetode",
"LabelPlayerChapterNumberMarker": "{0} av {1}",
"LabelPlaylists": "Spilleliste",
"LabelPodcast": "Podcast",
"LabelPodcastSearchRegion": "Podcast-søkeområde",
"LabelPodcastType": "Podcast type",
"LabelPodcasts": "Podcaster",
"LabelPort": "Port",
"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",
"LabelPrimaryEbook": "Primær ebok",
"LabelProgress": "Framgang",
"LabelProvider": "Tilbyder",
"LabelProviderAuthorizationValue": "Autorisasjons header-verdi",
"LabelPubDate": "Publiseringsdato",
"LabelPublishYear": "Publikasjonsår",
"LabelPublishedDate": "Publisert {0}",
"LabelPublishedDecade": "Tiår for utgivelse",
"LabelPublishedDecades": "Tiår for utgivelse",
"LabelPublisher": "Forlegger",
"LabelPublishers": "Utgivere",
"LabelRSSFeedCustomOwnerEmail": "Tilpasset eier e-post",
"LabelRSSFeedCustomOwnerName": "Tilpasset eier Navn",
"LabelRSSFeedOpen": "RSS Feed åpne",
"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",
"LabelReadAgain": "Les igjen",
"LabelReadEbookWithoutProgress": "Les ebok uten å beholde fremgang",
"LabelRecentSeries": "Nylige serier",
"LabelRecentlyAdded": "Nylig tillagt",
"LabelRecommended": "Anbefalte",
"LabelRedo": "Gjenta",
"LabelRegion": "Region",
"LabelReleaseDate": "Utgivelsesdato",
"LabelRemoveAllMetadataAbs": "Fjern alle metadata.abs filer",
"LabelRemoveAllMetadataJson": "Fjern alle metadata.json filer",
"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",
"LabelSearchTitle": "Søk tittel",
"LabelSearchTitleOrASIN": "Søk tittel eller ASIN",
"LabelSeason": "Sesong",
"LabelSeasonNumber": "Sesong #{0}",
"LabelSelectAll": "Velg alt",
"LabelSelectAllEpisodes": "Velg alle episoder",
"LabelSelectEpisodesShowing": "Velg {0} episoder vist",
"LabelSelectUsers": "Velg brukere",
"LabelSendEbookToDevice": "Send Ebok til...",
"LabelSequence": "Sekvens",
"LabelSerial": "Serienr.",
"LabelSeries": "Serier",
"LabelSeriesName": "Serier Navn",
"LabelSeriesProgress": "Serier fremgang",
"LabelServerLogLevel": "Server logg-nivå",
"LabelServerYearReview": "Server - Oppsummering av året ({0})",
"LabelSetEbookAsPrimary": "Sett som primær",
"LabelSetEbookAsSupplementary": "Sett som supplerende",
"LabelSettingsAllowIframe": "Tillat å bygge inn i en iframe",
"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",
"LabelSettingsBookshelfViewHelp": "Skeuomorf design med hyller av ved",
@@ -409,6 +555,8 @@
"LabelSettingsEnableWatcher": "Aktiver overvåker",
"LabelSettingsEnableWatcherForLibrary": "Aktiver mappe overvåker for bibliotek",
"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",
"LabelSettingsExperimentalFeaturesHelp": "Funksjoner under utvikling som kan trenge din tilbakemelding og hjelp med testing. Klikk for å åpne GitHub diskusjon.",
"LabelSettingsFindCovers": "Finn omslag",
@@ -417,8 +565,13 @@
"LabelSettingsHideSingleBookSeriesHelp": "Serier som har kun en bok vil bli gjemt på serie- og hjemmeside hyllen.",
"LabelSettingsHomePageBookshelfView": "Hjemmeside 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",
"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",
"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",
@@ -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",
"LabelSettingsTimeFormat": "Tid format",
"LabelShare": "Dele",
"LabelShareOpen": "Åpne deling",
"LabelShareURL": "Dele URL",
"LabelShowAll": "Vis alt",
"LabelShowAll": "Vis alle",
"LabelShowSeconds": "Vis sekunder",
"LabelShowSubtitles": "Vis undertitler",
"LabelSize": "Størrelse",
"LabelSleepTimer": "Sove-timer",
"LabelSlug": "Slug",
"LabelSortAscending": "Stigende",
"LabelSortDescending": "Synkende",
"LabelStart": "Start",
"LabelStartTime": "Start Tid",
"LabelStarted": "Startet",
"LabelStartedAt": "Startet",
@@ -457,15 +617,24 @@
"LabelStatsWeekListening": "Uker lyttet",
"LabelSubtitle": "undertekster",
"LabelSupportedFileTypes": "Støttede filtyper",
"LabelTag": "Tag",
"LabelTags": "Tagger",
"LabelTagsAccessibleToUser": "Tagger tilgjengelig for bruker",
"LabelTagsNotAccessibleToUser": "Tagger ikke tilgjengelig for bruker",
"LabelTasks": "Oppgaver som kjører",
"LabelTextEditorBulletedList": "Punkt-liste",
"LabelTextEditorLink": "Link",
"LabelTextEditorNumberedList": "Nummerert liste",
"LabelTextEditorUnlink": "Fjern link",
"LabelTheme": "Tema",
"LabelThemeDark": "Mørk",
"LabelThemeLight": "Lys",
"LabelTimeBase": "Tidsbase",
"LabelTimeDurationXHours": "{0} timer",
"LabelTimeDurationXMinutes": "{0} minutter",
"LabelTimeDurationXSeconds": "{0} sekunder",
"LabelTimeInMinutes": "Timer i minutter",
"LabelTimeLeft": "{0} gjenstår",
"LabelTimeListened": "Tid lyttet",
"LabelTimeListenedToday": "Tid lyttet idag",
"LabelTimeRemaining": "{0} gjennstående",
@@ -473,6 +642,7 @@
"LabelTitle": "Tittel",
"LabelToolsEmbedMetadata": "Bak inn metadata",
"LabelToolsEmbedMetadataDescription": "Bak inn metadata i lydfilen, inkludert omslagsbilde og kapitler.",
"LabelToolsM4bEncoder": "M4B enkoder",
"LabelToolsMakeM4b": "Lag M4B Lydbokfil",
"LabelToolsMakeM4bDescription": "Lager en.M4B lydbokfil med innbakte omslagsbilde og kapitler.",
"LabelToolsSplitM4b": "Del M4B inn i MP3er",
@@ -485,39 +655,56 @@
"LabelTracksMultiTrack": "Flerspor",
"LabelTracksNone": "Ingen spor",
"LabelTracksSingleTrack": "Enkelspor",
"LabelTrailer": "Trailer",
"LabelType": "Type",
"LabelUnabridged": "Uavkortet",
"LabelUndo": "Angre",
"LabelUnknown": "Ukjent",
"LabelUnknownPublishDate": "Ukjent publiseringsdato",
"LabelUpdateCover": "Oppdater omslag",
"LabelUpdateCoverHelp": "Tillat overskriving av eksisterende omslag for de valgte bøkene når en lik bok er funnet",
"LabelUpdateDetails": "Oppdater detaljer",
"LabelUpdateDetailsHelp": "Tillat overskriving av eksisterende detaljer for de valgte bøkene når en lik bok er funnet",
"LabelUpdatedAt": "Oppdatert",
"LabelUploaderDragAndDrop": "Dra og slipp filer eller mapper",
"LabelUploaderDragAndDropFilesOnly": "Dra & slipp filer",
"LabelUploaderDropFiles": "Slipp filer",
"LabelUploaderItemFetchMetadataHelp": "Hent tittel, forfatter og serie automatisk",
"LabelUseAdvancedOptions": "Bruk avanserte valg",
"LabelUseChapterTrack": "Bruk kapittelspor",
"LabelUseFullTrack": "Bruke hele sporet",
"LabelUseZeroForUnlimited": "Bruk 0 for ubegrenset",
"LabelUser": "Bruker",
"LabelUsername": "Brukernavn",
"LabelValue": "Verdi",
"LabelVersion": "Versjon",
"LabelViewBookmarks": "Vis bokmerker",
"LabelViewChapters": "Vis kapitler",
"LabelViewPlayerSettings": "Vis innstillinger for avspiller",
"LabelViewQueue": "Vis spillerkø",
"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",
"LabelXBooks": "{0} bøker",
"LabelXItems": "{0} elementer",
"LabelYearReviewHide": "Skjul oppsummering av året",
"LabelYearReviewShow": "Vis oppsummering av året",
"LabelYourAudiobookDuration": "Din lydbok lengde",
"LabelYourBookmarks": "Dine bokmerker",
"LabelYourPlaylists": "Dine spillelister",
"LabelYourProgress": "Din fremgang",
"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 <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.",
"MessageBackupsLocationEditNote": "Merk: Endring av sikkerhetskopieringssted hverken endrer eller flytter eksisterende sikkerhetskopier",
"MessageBackupsLocationPathEmpty": "Sti til sikkerhetskopieringssted må angis",
"MessageBackupsLocationEditNote": "Viktig: Endring av mappen for sikkerhetskopi hverken endrer eller flytter eksisterende sikkerhetskopier!",
"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.",
"MessageBookshelfNoCollections": "Du har ikke laget noen samlinger ennå",
"MessageBookshelfNoRSSFeeds": "Ingen RSS feed er åpen",
"MessageBookshelfNoResultsForFilter": "Ingen resultat for filter \"{0}: {1}\"",
"MessageBookshelfNoResultsForQuery": "Ingen resultater for søket",
"MessageBookshelfNoSeries": "Du har ingen serier",
"MessageChapterEndIsAfter": "Kapittel slutt er etter slutt av lydboken",
"MessageChapterErrorFirstNotZero": "Første kapittel starter på 0",
@@ -527,18 +714,35 @@
"MessageCheckingCron": "Sjekker cron...",
"MessageConfirmCloseFeed": "Er du sikker på at du vil lukke denne feeden?",
"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?",
"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?",
"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?",
"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?",
"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?",
"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?",
"MessageConfirmRemoveAuthor": "Er du sikker på at du vil fjerne forfatteren \"{0}\"?",
"MessageConfirmRemoveCollection": "Er du sikker på at du vil fjerne samling\"{0}\"?",
"MessageConfirmRemoveEpisode": "Er du sikker på at du vil fjerne episode \"{0}\"?",
"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}\"?",
"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?",
@@ -547,11 +751,16 @@
"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.",
"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}\"?",
"MessageConfirmUnlinkOpenId": "Er du sikker på at du vil koble denne brukeren fra OpenID?",
"MessageDownloadingEpisode": "Laster ned episode",
"MessageDragFilesIntoTrackOrder": "Dra filene i rett spor rekkefølge",
"MessageEmbedFailed": "Innbygging feilet!",
"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",
"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}",
"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.",
@@ -591,7 +800,7 @@
"MessageNoListeningSessions": "Ingen Lyttesesjoner",
"MessageNoLogs": "Ingen logger",
"MessageNoMediaProgress": "Ingen mediefremgang",
"MessageNoNotifications": "Ingen notifikasjoner",
"MessageNoNotifications": "Ingen varslinger",
"MessageNoPodcastsFound": "Ingen podcaster funnet",
"MessageNoResults": "Ingen resultat",
"MessageNoSearchResultsFor": "Ingen søkeresultat for \"{0}\"",
@@ -646,30 +855,66 @@
"ToastAuthorUpdateMerged": "Forfatter slått sammen",
"ToastAuthorUpdateSuccess": "Forfatter oppdatert",
"ToastAuthorUpdateSuccessNoImageFound": "Forfatter oppdater (ingen bilde funnet)",
"ToastBackupAppliedSuccess": "Sikkerhetskopi slått på",
"ToastBackupCreateFailed": "Mislykkes å lage sikkerhetskopi",
"ToastBackupCreateSuccess": "Sikkerhetskopi opprettet",
"ToastBackupDeleteFailed": "Mislykkes å slette sikkerhetskopi",
"ToastBackupDeleteSuccess": "Sikkerhetskopi slettet",
"ToastBackupInvalidMaxKeep": "Ugyldig antall sikkerhetskopier ønskes beholdt",
"ToastBackupInvalidMaxSize": "Ugyldig maksimal størrelse for sikkerhetskopi",
"ToastBackupRestoreFailed": "Misslykkes å gjenopprette sikkerhetskopi",
"ToastBackupUploadFailed": "Misslykkes å laste opp sikkerhetskopi",
"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",
"ToastBatchUpdateSuccess": "Bulk oppdatering fullført",
"ToastBookmarkCreateFailed": "Misslykkes å opprette bokmerke",
"ToastBookmarkCreateSuccess": "Bokmerke lagt til",
"ToastBookmarkRemoveSuccess": "Bokmerke fjernet",
"ToastBookmarkUpdateSuccess": "Bokmerke oppdatert",
"ToastCachePurgeFailed": "Kunne ikke å slette mellomlager",
"ToastCachePurgeSuccess": "Mellomlager slettet",
"ToastChaptersHaveErrors": "Kapittel har feil",
"ToastChaptersMustHaveTitles": "Kapittel må ha titler",
"ToastChaptersRemoved": "Kapitler fjernet",
"ToastChaptersUpdated": "Kapitler oppdatert",
"ToastCollectionItemsAddFailed": "Feil med å legge til element(er)",
"ToastCollectionItemsAddSuccess": "Element(er) lagt til samlingen",
"ToastCollectionItemsRemoveSuccess": "Gjenstand(er) fjernet fra samling",
"ToastCollectionRemoveSuccess": "Samling fjernet",
"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",
"ToastItemDeletedFailed": "Kunne ikke slette element",
"ToastItemDeletedSuccess": "Element slettet",
"ToastItemDetailsUpdateSuccess": "Detaljer oppdatert",
"ToastItemMarkedAsFinishedFailed": "Misslykkes å markere som Fullført",
"ToastItemMarkedAsFinishedSuccess": "Gjenstand marker som Fullført",
"ToastItemMarkedAsNotFinishedFailed": "Misslykkes å markere som Ikke Fullført",
"ToastItemMarkedAsNotFinishedSuccess": "Markert som Ikke Fullført",
"ToastItemUpdateSuccess": "Element oppdatert",
"ToastLibraryCreateFailed": "Misslykkes å opprette bibliotek",
"ToastLibraryCreateSuccess": "Bibliotek \"{0}\" opprettet",
"ToastLibraryDeleteFailed": "Misslykkes å slette bibliotek",
@@ -677,25 +922,83 @@
"ToastLibraryScanFailedToStart": "Misslykkes å starte skann",
"ToastLibraryScanStarted": "Bibliotek skann startet",
"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",
"ToastPlaylistCreateSuccess": "Spilleliste opprettet",
"ToastPlaylistRemoveSuccess": "Spilleliste fjernet",
"ToastPlaylistUpdateSuccess": "Spilleliste oppdatert",
"ToastPodcastCreateFailed": "Misslykkes å opprette podcast",
"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",
"ToastRSSFeedCloseSuccess": "RSS feed lukket",
"ToastRemoveFailed": "Kunne ikke fjerne",
"ToastRemoveItemFromCollectionFailed": "Misslykkes å fjerne gjenstsand 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",
"ToastSendEbookToDeviceSuccess": "Ebok sendt til \"{0}\"",
"ToastSeriesUpdateFailed": "Misslykkes å oppdatere serie",
"ToastSeriesUpdateSuccess": "Serie oppdatert",
"ToastServerSettingsUpdateSuccess": "Server-innstillinger oppdatert",
"ToastSessionCloseFailed": "Kunne ikke avslutte sesjon",
"ToastSessionDeleteFailed": "Misslykkes å slette sesjon",
"ToastSessionDeleteSuccess": "Sesjon slettet",
"ToastSleepTimerDone": "Søvn-timer ferdig... zZzzZz",
"ToastSlugMustChange": "Slug inneholder ugyldige tegn",
"ToastSlugRequired": "Slug påkrevd",
"ToastSocketConnected": "Socket koblet til",
"ToastSocketDisconnected": "Socket koblet fra",
"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",
"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

@@ -88,6 +88,8 @@
"ButtonSaveTracklist": "Сохранить список треков",
"ButtonScan": "Сканировать",
"ButtonScanLibrary": "Сканировать библиотеку",
"ButtonScrollLeft": "Перемотать влево",
"ButtonScrollRight": "Перемотать вправо",
"ButtonSearch": "Поиск",
"ButtonSelectFolderPath": "Выберите путь папки",
"ButtonSeries": "Серии",
@@ -190,6 +192,7 @@
"HeaderSettingsExperimental": "Экспериментальные функции",
"HeaderSettingsGeneral": "Основные",
"HeaderSettingsScanner": "Сканер",
"HeaderSettingsWebClient": "Веб-клиент",
"HeaderSleepTimer": "Таймер сна",
"HeaderStatsLargestItems": "Самые большые элементы",
"HeaderStatsLongestItems": "Самые длинные элементы (часов)",
@@ -542,6 +545,7 @@
"LabelServerYearReview": "Итоги года всего сервера ({0})",
"LabelSetEbookAsPrimary": "Установить как основную",
"LabelSetEbookAsSupplementary": "Установить как дополнительную",
"LabelSettingsAllowIframe": "Разрешить встраивание в iframe",
"LabelSettingsAudiobooksOnly": "Только аудиокниги",
"LabelSettingsAudiobooksOnlyHelp": "Если включить эту настройку, файлы электронных книг будут игнорироваться, за исключением случаев, когда они находятся в папке с аудиокнигами, в этом случае они будут рассматриваться как дополнительные электронные книги",
"LabelSettingsBookshelfViewHelp": "Конструкция с деревянными полками",
@@ -592,6 +596,8 @@
"LabelSize": "Размер",
"LabelSleepTimer": "Таймер сна",
"LabelSlug": "Слизень",
"LabelSortAscending": "По возрастанию",
"LabelSortDescending": "По убыванию",
"LabelStart": "Начало",
"LabelStartTime": "Время начала",
"LabelStarted": "Начат",
@@ -679,6 +685,8 @@
"LabelViewPlayerSettings": "Просмотр настроек плеера",
"LabelViewQueue": "Очередь воспроизведения",
"LabelVolume": "Громкость",
"LabelWebRedirectURLsDescription": "Авторизуйте эти URL в провайдере OAuth, чтобы разрешить перенаправление обратно в веб-приложение после входа:",
"LabelWebRedirectURLsSubfolder": "Вложенная папка для URL-адресов перенаправления",
"LabelWeekdaysToRun": "Дни недели для запуска",
"LabelXBooks": "{0} книг",
"LabelXItems": "{0} элементов",

View File

@@ -88,6 +88,8 @@
"ButtonSaveTracklist": "Shrani seznam skladb",
"ButtonScan": "Pregledovanje",
"ButtonScanLibrary": "Preglej knjižnico",
"ButtonScrollLeft": "Premik levo",
"ButtonScrollRight": "Premik desno",
"ButtonSearch": "Poišči",
"ButtonSelectFolderPath": "Izberite pot do mape",
"ButtonSeries": "Serije",
@@ -190,6 +192,7 @@
"HeaderSettingsExperimental": "Eksperimentalne funkcije",
"HeaderSettingsGeneral": "Splošno",
"HeaderSettingsScanner": "Pregledovalnik",
"HeaderSettingsWebClient": "Spletni odjemalec",
"HeaderSleepTimer": "Časovnik za izklop",
"HeaderStatsLargestItems": "Največji elementi",
"HeaderStatsLongestItems": "Najdaljši elementi (ure)",
@@ -542,6 +545,7 @@
"LabelServerYearReview": "Pregled leta strežnika ({0})",
"LabelSetEbookAsPrimary": "Nastavi kot primarno",
"LabelSetEbookAsSupplementary": "Nastavi kot dodatno",
"LabelSettingsAllowIframe": "Dovoli vdelavo v iframu",
"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",
"LabelSettingsBookshelfViewHelp": "Skeuomorfna oblika z lesenimi policami",
@@ -592,6 +596,8 @@
"LabelSize": "Velikost",
"LabelSleepTimer": "Časovnik za spanje",
"LabelSlug": "Slug",
"LabelSortAscending": "Naraščajoče",
"LabelSortDescending": "Padajoče",
"LabelStart": "Začetek",
"LabelStartTime": "Čas začetka",
"LabelStarted": "Začeto",

View File

@@ -13,7 +13,7 @@
"ButtonBrowseForFolder": "Bläddra efter mapp",
"ButtonCancel": "Avbryt",
"ButtonCancelEncode": "Avbryt kodning",
"ButtonChangeRootPassword": "Ändra rootlösenord",
"ButtonChangeRootPassword": "Ändra lösenordet för root",
"ButtonCheckAndDownloadNewEpisodes": "Kontrollera och ladda ner nya avsnitt",
"ButtonChooseAFolder": "Välj en mapp",
"ButtonChooseFiles": "Välj filer",
@@ -29,7 +29,7 @@
"ButtonEditChapters": "Redigera kapitel",
"ButtonEditPodcast": "Redigera podcast",
"ButtonForceReScan": "Tvinga omstart",
"ButtonFullPath": "Full sökväg",
"ButtonFullPath": "Fullständig sökväg",
"ButtonHide": "Dölj",
"ButtonHome": "Hem",
"ButtonIssues": "Problem",
@@ -42,13 +42,18 @@
"ButtonMatchAllAuthors": "Matcha alla författare",
"ButtonMatchBooks": "Matcha böcker",
"ButtonNevermind": "Glöm det",
"ButtonOk": "Okej",
"ButtonNext": "Nästa",
"ButtonNextChapter": "Nästa kapitel",
"ButtonOk": "Ok",
"ButtonOpenFeed": "Öppna flöde",
"ButtonOpenManager": "Öppna Manager",
"ButtonPause": "Pausa",
"ButtonPlay": "Spela",
"ButtonPlayAll": "Spela alla",
"ButtonPlaying": "Spelar",
"ButtonPlaylists": "Spellistor",
"ButtonPrevious": "Föregående",
"ButtonPreviousChapter": "Föregående kapitel",
"ButtonPurgeAllCache": "Rensa all cache",
"ButtonPurgeItemsCache": "Rensa föremåls-cache",
"ButtonQueueAddItem": "Lägg till i kön",
@@ -56,6 +61,9 @@
"ButtonQuickMatch": "Snabb matchning",
"ButtonReScan": "Omstart",
"ButtonRead": "Läs",
"ButtonReadLess": "Visa mindre",
"ButtonReadMore": "Visa mer",
"ButtonRefresh": "Uppdatera",
"ButtonRemove": "Ta bort",
"ButtonRemoveAll": "Ta bort alla",
"ButtonRemoveAllLibraryItems": "Ta bort alla biblioteksobjekt",
@@ -72,12 +80,13 @@
"ButtonScanLibrary": "Skanna bibliotek",
"ButtonSearch": "Sök",
"ButtonSelectFolderPath": "Välj mappens sökväg",
"ButtonSeries": "Serie",
"ButtonSeries": "Serier",
"ButtonSetChaptersFromTracks": "Ställ in kapitel från spår",
"ButtonShiftTimes": "Förskjut tider",
"ButtonShow": "Visa",
"ButtonStartM4BEncode": "Starta M4B-kodning",
"ButtonStartMetadataEmbed": "Starta inbäddning av metadata",
"ButtonStats": "Statistik",
"ButtonSubmit": "Skicka",
"ButtonTest": "Testa",
"ButtonUpload": "Ladda upp",
@@ -123,7 +132,7 @@
"HeaderListeningStats": "Lyssningsstatistik",
"HeaderLogin": "Logga in",
"HeaderLogs": "Loggar",
"HeaderManageGenres": "Hantera genrer",
"HeaderManageGenres": "Hantera kategorier",
"HeaderManageTags": "Hantera taggar",
"HeaderMapDetails": "Karta detaljer",
"HeaderMatch": "Matcha",
@@ -154,13 +163,14 @@
"HeaderSettingsExperimental": "Experimentella funktioner",
"HeaderSettingsGeneral": "Allmänt",
"HeaderSettingsScanner": "Skanner",
"HeaderSettingsWebClient": "Webklient",
"HeaderSleepTimer": "Sovtidtagare",
"HeaderStatsLargestItems": "Största föremål",
"HeaderStatsLongestItems": "Längsta föremål (tim)",
"HeaderStatsLargestItems": "Största objekt",
"HeaderStatsLongestItems": "Längsta objekt (tim)",
"HeaderStatsMinutesListeningChart": "Minuters lyssning (senaste 7 dagar)",
"HeaderStatsRecentSessions": "Senaste sessioner",
"HeaderStatsTop10Authors": "Topp 10 författare",
"HeaderStatsTop5Genres": "Topp 5 genrer",
"HeaderStatsTop10Authors": "10 populäraste författarna",
"HeaderStatsTop5Genres": "5 populäraste kategorierna",
"HeaderTableOfContents": "Innehållsförteckning",
"HeaderTools": "Verktyg",
"HeaderUpdateAccount": "Uppdatera konto",
@@ -168,7 +178,8 @@
"HeaderUpdateDetails": "Uppdatera detaljer",
"HeaderUpdateLibrary": "Uppdatera bibliotek",
"HeaderUsers": "Användare",
"HeaderYourStats": "Dina statistik",
"HeaderYearReview": "Sammanställning för {0}",
"HeaderYourStats": "Din statistik",
"LabelAbridged": "Förkortad",
"LabelAccountType": "Kontotyp",
"LabelAccountTypeGuest": "Gäst",
@@ -191,18 +202,23 @@
"LabelAuthorLastFirst": "Författare (Efternamn, Förnamn)",
"LabelAuthors": "Författare",
"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",
"LabelBackupLocation": "Säkerhetskopia Plats",
"LabelBackupLocation": "Plats för säkerhetskopia",
"LabelBackupsEnableAutomaticBackups": "Aktivera automatiska säkerhetskopior",
"LabelBackupsEnableAutomaticBackupsHelp": "Säkerhetskopior sparas i /metadata/säkerhetskopior",
"LabelBackupsMaxBackupSize": "Maximal säkerhetskopiostorlek (i GB)",
"LabelBackupsEnableAutomaticBackupsHelp": "Säkerhetskopior sparas i \"/metadata/backups\"",
"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.",
"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.",
"LabelBitrate": "Bitfrekvens",
"LabelBooks": "Böcker",
"LabelButtonText": "Knapptext",
"LabelByAuthor": "av {0}",
"LabelChangePassword": "Ändra lösenord",
"LabelChannels": "Kanaler",
"LabelChapterCount": "{0} kapitel",
"LabelChapterTitle": "Kapitelrubrik",
"LabelChapters": "Kapitel",
"LabelChaptersFound": "hittade kapitel",
@@ -215,7 +231,7 @@
"LabelConfirmPassword": "Bekräfta lösenord",
"LabelContinueListening": "Fortsätt Lyssna",
"LabelContinueReading": "Fortsätt Läsa",
"LabelContinueSeries": "Forsätt Serie",
"LabelContinueSeries": "Fortsätt Serie",
"LabelCover": "Omslag",
"LabelCoverImageURL": "URL till omslagsbild",
"LabelCreatedAt": "Skapad vid",
@@ -267,8 +283,8 @@
"LabelFontBoldness": "Fetstil",
"LabelFontFamily": "Teckensnittsfamilj",
"LabelFontScale": "Teckensnittsskala",
"LabelGenre": "Genre",
"LabelGenres": "Genrer",
"LabelGenre": "Kategori",
"LabelGenres": "Kategorier",
"LabelHardDeleteFile": "Hård radering av fil",
"LabelHasEbook": "Har E-bok",
"LabelHasSupplementaryEbook": "Har komplimenterande E-bok",
@@ -316,19 +332,19 @@
"LabelMediaType": "Mediatyp",
"LabelMetaTag": "Metamärke",
"LabelMetaTags": "Metamärken",
"LabelMetadataProvider": "Metadataleverantör",
"LabelMetadataProvider": "Källa för metadata",
"LabelMinute": "Minut",
"LabelMissing": "Saknad",
"LabelMore": "Mer",
"LabelMoreInfo": "Mer information",
"LabelName": "Namn",
"LabelNarrator": "Berättare",
"LabelNarrators": "Berättare",
"LabelNarrator": "Uppläsare",
"LabelNarrators": "Uppläsare",
"LabelNew": "Ny",
"LabelNewPassword": "Nytt lösenord",
"LabelNewestAuthors": "Senast tillagda författare",
"LabelNewestEpisodes": "Senast tillagda avsnitt",
"LabelNextBackupDate": "Nästa säkerhetskopia datum",
"LabelNextBackupDate": "Nästa datum för säkerhetskopia",
"LabelNextScheduledRun": "Nästa schemalagda körning",
"LabelNoEpisodesSelected": "Inga avsnitt valda",
"LabelNotFinished": "Ej avslutad",
@@ -367,7 +383,7 @@
"LabelPreventIndexing": "Förhindra att ditt flöde indexeras av iTunes och Google-podcastsökmotorer",
"LabelPrimaryEbook": "Primär e-bok",
"LabelProgress": "Framsteg",
"LabelProvider": "Leverantör",
"LabelProvider": "Källa",
"LabelPubDate": "Publiceringsdatum",
"LabelPublishYear": "Publiceringsår",
"LabelPublisher": "Utgivare",
@@ -388,14 +404,14 @@
"LabelRemoveCover": "Ta bort omslag",
"LabelSearchTerm": "Sökterm",
"LabelSearchTitle": "Sök titel",
"LabelSearchTitleOrASIN": "Sök titel eller ASIN",
"LabelSearchTitleOrASIN": "Sök titel eller ASIN-kod",
"LabelSeason": "Säsong",
"LabelSelectAllEpisodes": "Välj alla avsnitt",
"LabelSelectEpisodesShowing": "Välj {0} avsnitt som visas",
"LabelSelectUsers": "Välj användare",
"LabelSendEbookToDevice": "Skicka e-bok till...",
"LabelSequence": "Sekvens",
"LabelSeries": "Serie",
"LabelSeries": "Serier",
"LabelSeriesName": "Serienamn",
"LabelSeriesProgress": "Serieframsteg",
"LabelSetEbookAsPrimary": "Ange som primär",
@@ -403,7 +419,7 @@
"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",
"LabelSettingsBookshelfViewHelp": "Skeumorfisk design med trähyllor",
"LabelSettingsChromecastSupport": "Chromecast-stöd",
"LabelSettingsChromecastSupport": "Stöd för Chromecast",
"LabelSettingsDateFormat": "Datumformat",
"LabelSettingsDisableWatcher": "Inaktivera Watcher",
"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.",
"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",
"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.",
"LabelSettingsHomePageBookshelfView": "Startsida använd bokhyllvy",
"LabelSettingsLibraryBookshelfView": "Bibliotek använd bokhyllvy",
"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",
"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",
"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",
"LabelSettingsSquareBookCoversHelp": "Föredrar att använda fyrkantiga omslag över standard 1.6:1 bokomslag",
"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",
"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",
"LabelShowAll": "Visa alla",
"LabelSize": "Storlek",
@@ -457,7 +473,7 @@
"LabelStatsOverallHours": "Totalt antal timmar",
"LabelStatsWeekListening": "Veckans lyssnande",
"LabelSubtitle": "Underrubrik",
"LabelSupportedFileTypes": "Stödda filtyper",
"LabelSupportedFileTypes": "Filtyper som accepteras",
"LabelTag": "Tagg",
"LabelTags": "Taggar",
"LabelTagsAccessibleToUser": "Taggar tillgängliga för användaren",
@@ -467,17 +483,22 @@
"LabelThemeDark": "Mörkt",
"LabelThemeLight": "Ljust",
"LabelTimeBase": "Tidsbas",
"LabelTimeDurationXHours": "{0} timmar",
"LabelTimeDurationXMinutes": "{0} minuter",
"LabelTimeDurationXSeconds": "{0} sekunder",
"LabelTimeInMinutes": "Tid i minuter",
"LabelTimeLeft": "{0} återstår",
"LabelTimeListened": "Tid lyssnad",
"LabelTimeListenedToday": "Tid lyssnad idag",
"LabelTimeRemaining": "{0} kvar",
"LabelTimeRemaining": "{0} återstår",
"LabelTimeToShift": "Tid att skifta i sekunder",
"LabelTitle": "Titel",
"LabelToolsEmbedMetadata": "Bädda in metadata",
"LabelToolsEmbedMetadataDescription": "Bädda in metadata i ljudfiler, inklusive omslagsbild och kapitel.",
"LabelToolsMakeM4b": "Skapa M4B ljudbok",
"LabelToolsMakeM4bDescription": "Skapa en .M4B ljudboksfil med inbäddad metadata, omslagsbild och kapitel.",
"LabelToolsSplitM4b": "Dela M4B till MP3-filer",
"LabelToolsSplitM4bDescription": "Skapa MP3-filer från en M4B fil uppdelad i kapitel med inbäddad metadata, omslagsbild och kapitel.",
"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.",
"LabelTotalDuration": "Total varaktighet",
"LabelTotalTimeListened": "Total tid lyssnad",
"LabelTrackFromFilename": "Spår från filnamn",
@@ -486,6 +507,7 @@
"LabelTracksMultiTrack": "Flerspårigt",
"LabelTracksNone": "Inga spår",
"LabelTracksSingleTrack": "Enspårigt",
"LabelTrailer": "Trailer",
"LabelType": "Typ",
"LabelUnabridged": "Oavkortad",
"LabelUnknown": "Okänd",
@@ -496,16 +518,20 @@
"LabelUpdatedAt": "Uppdaterad vid",
"LabelUploaderDragAndDrop": "Dra och släpp filer eller mappar",
"LabelUploaderDropFiles": "Släpp filer",
"LabelUploaderItemFetchMetadataHelp": "Hämtar automatiskt titel, författare och serier.",
"LabelUseChapterTrack": "Använd kapitelspår",
"LabelUseFullTrack": "Använd hela spåret",
"LabelUser": "Användare",
"LabelUsername": "Användarnamn",
"LabelValue": "Värde",
"LabelVersion": "Version",
"LabelViewBookmarks": "Visa bokmärken",
"LabelViewChapters": "Visa kapitel",
"LabelViewQueue": "Visa spellista",
"LabelVolume": "Volym",
"LabelWeekdaysToRun": "Vardagar att köra",
"LabelYearReviewHide": "Dölj sammanställning för året",
"LabelYearReviewShow": "Visa sammanställning för året",
"LabelYourAudiobookDuration": "Din ljudboks varaktighet",
"LabelYourBookmarks": "Dina bokmärken",
"LabelYourPlaylists": "Dina spellistor",
@@ -535,22 +561,22 @@
"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?",
"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?",
"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?",
"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?",
"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?",
"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}\"?",
"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?",
"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}\"?",
"MessageConfirmRenameGenre": "Är du säker på att du vill byta namn på genren \"{0}\" till \"{1}\" för alla objekt?",
"MessageConfirmRenameGenreMergeNote": "Observera: Den här genren finns redan, så de kommer att slås samman.",
"MessageConfirmRenameGenreWarning": "Varning! En liknande genre med annat skrivsätt finns redan \"{0}\".",
"MessageConfirmRenameGenre": "Är du säker på att du vill byta namn på kategori \"{0}\" till \"{1}\" för alla objekt?",
"MessageConfirmRenameGenreMergeNote": "OBS: Den här kategorin finns redan, så de kommer att slås samman.",
"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?",
"MessageConfirmRenameTagMergeNote": "Observera: 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}\".",
"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}\".",
"MessageConfirmSendEbookToDevice": "Är du säker på att du vill skicka {0} e-bok \"{1}\" till enheten \"{2}\"?",
"MessageDownloadingEpisode": "Laddar ner avsnitt",
"MessageDragFilesIntoTrackOrder": "Dra filer till rätt spårordning",
@@ -574,7 +600,7 @@
"MessageMarkAllEpisodesNotFinished": "Markera alla avsnitt som inte avslutade",
"MessageMarkAsFinished": "Markera som 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",
"MessageNoAuthors": "Inga författare",
"MessageNoBackups": "Inga säkerhetskopior",
@@ -588,7 +614,7 @@
"MessageNoEpisodeMatchesFound": "Inga matchande avsnitt hittades",
"MessageNoEpisodes": "Inga avsnitt",
"MessageNoFoldersAvailable": "Inga mappar tillgängliga",
"MessageNoGenres": "Inga genrer",
"MessageNoGenres": "Inga kategorier",
"MessageNoIssues": "Inga problem",
"MessageNoItems": "Inga objekt",
"MessageNoItemsFound": "Inga objekt hittades",
@@ -637,7 +663,7 @@
"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",
"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.",
"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",
@@ -645,29 +671,43 @@
"PlaceholderNewPlaylist": "Nytt spellistanamn",
"PlaceholderSearch": "Sök...",
"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",
"ToastAsinRequired": "En ASIN-kod krävs",
"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",
"ToastAuthorUpdateSuccess": "Författaren uppdaterad",
"ToastAuthorUpdateSuccessNoImageFound": "Författaren uppdaterad (ingen bild hittad)",
"ToastBackupCreateFailed": "Det gick inte att skapa en säkerhetskopia",
"ToastBackupCreateSuccess": "Säkerhetskopia skapad",
"ToastBackupDeleteFailed": "Det gick inte att ta bort säkerhetskopian",
"ToastBackupDeleteSuccess": "Säkerhetskopan borttagen",
"ToastBackupRestoreFailed": "Det gick inte att återställa säkerhetskopan",
"ToastBackupUploadFailed": "Det gick inte att ladda upp säkerhetskopan",
"ToastBackupUploadSuccess": "Säkerhetskopan uppladdad",
"ToastBackupCreateSuccess": "Säkerhetskopian har skapats",
"ToastBackupDeleteFailed": "Det gick inte att radera säkerhetskopian",
"ToastBackupDeleteSuccess": "Säkerhetskopian har raderats",
"ToastBackupInvalidMaxKeep": "Felaktigt antal kopior av backup har angivits",
"ToastBackupInvalidMaxSize": "Felaktig storlek på backup har angivits",
"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",
"ToastBatchUpdateSuccess": "Batchuppdateringen lyckades",
"ToastBookmarkCreateFailed": "Det gick inte att skapa bokmärket",
"ToastBookmarkCreateSuccess": "Bokmärket tillagt",
"ToastBookmarkRemoveSuccess": "Bokmärket borttaget",
"ToastBookmarkUpdateSuccess": "Bokmärket uppdaterat",
"ToastBookmarkCreateSuccess": "Bokmärket har adderats",
"ToastBookmarkRemoveSuccess": "Bokmärket har raderats",
"ToastBookmarkUpdateSuccess": "Bokmärket har uppdaterats",
"ToastChaptersHaveErrors": "Kapitlen har fel",
"ToastChaptersMustHaveTitles": "Kapitel måste ha titlar",
"ToastCollectionItemsRemoveSuccess": "Objekt borttagna från samlingen",
"ToastCollectionRemoveSuccess": "Samlingen borttagen",
"ToastCollectionUpdateSuccess": "Samlingen uppdaterad",
"ToastCollectionRemoveSuccess": "Samlingen har raderats",
"ToastCollectionUpdateSuccess": "Samlingen har uppdaterats",
"ToastItemCoverUpdateSuccess": "Objektets omslag uppdaterat",
"ToastItemDetailsUpdateSuccess": "Objektdetaljer uppdaterade",
"ToastItemMarkedAsFinishedFailed": "Misslyckades med att markera som färdig",
@@ -693,8 +733,8 @@
"ToastRemoveItemFromCollectionSuccess": "Objektet borttaget från samlingen",
"ToastSendEbookToDeviceFailed": "Misslyckades med att skicka e-boken till enheten",
"ToastSendEbookToDeviceSuccess": "E-boken skickad till enheten \"{0}\"",
"ToastSeriesUpdateFailed": "Serieuppdateringen misslyckades",
"ToastSeriesUpdateSuccess": "Serieuppdateringen lyckades",
"ToastSeriesUpdateFailed": "Uppdateringen av serier misslyckades",
"ToastSeriesUpdateSuccess": "Uppdateringen av serierna lyckades",
"ToastSessionDeleteFailed": "Misslyckades med att ta bort sessionen",
"ToastSessionDeleteSuccess": "Sessionen borttagen",
"ToastSocketConnected": "Socket ansluten",

View File

@@ -88,6 +88,8 @@
"ButtonSaveTracklist": "Зберегти порядок",
"ButtonScan": "Сканувати",
"ButtonScanLibrary": "Сканувати бібліотеку",
"ButtonScrollLeft": "Прокрутити ліворуч",
"ButtonScrollRight": "Прокрутити праворуч",
"ButtonSearch": "Пошук",
"ButtonSelectFolderPath": "Обрати шлях до теки",
"ButtonSeries": "Серії",
@@ -190,6 +192,7 @@
"HeaderSettingsExperimental": "Експериментальні функції",
"HeaderSettingsGeneral": "Основне",
"HeaderSettingsScanner": "Сканер",
"HeaderSettingsWebClient": "Вебклієнт",
"HeaderSleepTimer": "Таймер вимкнення",
"HeaderStatsLargestItems": "Найбільші елементи",
"HeaderStatsLongestItems": "Найдовші елементи (год)",
@@ -542,6 +545,7 @@
"LabelServerYearReview": "Підсумки року сервера ({0})",
"LabelSetEbookAsPrimary": "Зробити основною",
"LabelSetEbookAsSupplementary": "Зробити додатковою",
"LabelSettingsAllowIframe": "Дозволити вбудовування у iframe",
"LabelSettingsAudiobooksOnly": "Лише аудіокниги",
"LabelSettingsAudiobooksOnlyHelp": "Увімкніть цей параметр, щоб ігнорувати файли електронних книг, якщо вони не знаходяться у теці аудіокниги, тоді вони будуть встановлені як додаткові електронні книги",
"LabelSettingsBookshelfViewHelp": "Імітує вигляд дерев'яних полиць",
@@ -592,6 +596,8 @@
"LabelSize": "Розмір",
"LabelSleepTimer": "Таймер вимкнення",
"LabelSlug": "Назва",
"LabelSortAscending": "По зростанню",
"LabelSortDescending": "По спаданню",
"LabelStart": "Початок",
"LabelStartTime": "Час початку",
"LabelStarted": "Почато",
@@ -881,7 +887,7 @@
"MessageXLibraryIsEmpty": "Бібліотека {0} порожня!",
"MessageYourAudiobookDurationIsLonger": "Тривалість вашої аудіокниги довша за віднайдену",
"MessageYourAudiobookDurationIsShorter": "Тривалість вашої аудіокниги коротша за віднайдену",
"NoteChangeRootPassword": "Кореневий користувач — єдиний, хто може мати порожній пароль",
"NoteChangeRootPassword": "Тільки користувач root — єдиний, хто може мати порожній пароль",
"NoteChapterEditorTimes": "Примітка: Перша глава мусить починатися з 0:00, а час початку останньої глави не може бути більшим за зазначену тривалість аудіокниги.",
"NoteFolderPicker": "Примітка: вже обрані теки не буде показано",
"NoteRSSFeedPodcastAppsHttps": "Попередження: Більшість додатків подкастів вимагатимуть використання протоколу HTTPS від RSS-каналу",

View File

@@ -88,6 +88,8 @@
"ButtonSaveTracklist": "保存音轨列表",
"ButtonScan": "扫描",
"ButtonScanLibrary": "扫描库",
"ButtonScrollLeft": "向左滚动",
"ButtonScrollRight": "向右滚动",
"ButtonSearch": "查找",
"ButtonSelectFolderPath": "选择文件夹路径",
"ButtonSeries": "系列",
@@ -190,6 +192,7 @@
"HeaderSettingsExperimental": "实验功能",
"HeaderSettingsGeneral": "通用",
"HeaderSettingsScanner": "扫描",
"HeaderSettingsWebClient": "网页客户端",
"HeaderSleepTimer": "睡眠计时",
"HeaderStatsLargestItems": "最大的项目",
"HeaderStatsLongestItems": "项目时长(小时)",
@@ -542,6 +545,7 @@
"LabelServerYearReview": "服务器年度回顾 ({0})",
"LabelSetEbookAsPrimary": "设置为主",
"LabelSetEbookAsSupplementary": "设置为补充",
"LabelSettingsAllowIframe": "允许嵌入到 iframe 中",
"LabelSettingsAudiobooksOnly": "只有有声读物",
"LabelSettingsAudiobooksOnlyHelp": "启用此设置将忽略电子书文件, 除非它们位于有声读物文件夹中, 在这种情况下, 它们将被设置为补充电子书",
"LabelSettingsBookshelfViewHelp": "带有木架子的拟物化设计",
@@ -592,6 +596,8 @@
"LabelSize": "文件大小",
"LabelSleepTimer": "睡眠定时",
"LabelSlug": "Slug",
"LabelSortAscending": "升序",
"LabelSortDescending": "降序",
"LabelStart": "开始",
"LabelStartTime": "开始时间",
"LabelStarted": "开始于",

View File

@@ -13,6 +13,8 @@ if (isDev) {
if (devEnv.SkipBinariesCheck) process.env.SKIP_BINARIES_CHECK = '1'
if (devEnv.AllowIframe) process.env.ALLOW_IFRAME = '1'
if (devEnv.BackupPath) process.env.BACKUP_PATH = devEnv.BackupPath
if (devEnv.AllowPlugins) process.env.ALLOW_PLUGINS = '1'
if (devEnv.DevPluginsPath) process.env.DEV_PLUGINS_PATH = devEnv.DevPluginsPath
process.env.SOURCE = 'local'
process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath || ''
}

4
package-lock.json generated
View File

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

View File

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

View File

@@ -16,8 +16,8 @@ const server = require('./server/Server')
global.appRoot = __dirname
var inputConfig = options.config ? Path.resolve(options.config) : null
var inputMetadata = options.metadata ? Path.resolve(options.metadata) : null
const inputConfig = options.config ? Path.resolve(options.config) : null
const inputMetadata = options.metadata ? Path.resolve(options.metadata) : null
const PORT = options.port || process.env.PORT || 3333
const HOST = options.host || process.env.HOST

View File

@@ -16,6 +16,8 @@ const Logger = require('./Logger')
*/
class Auth {
constructor() {
this.pluginManifests = []
// Map of openId sessions indexed by oauth2 state-variable
this.openIdAuthSession = new Map()
this.ignorePatterns = [/\/api\/items\/[^/]+\/cover/, /\/api\/authors\/[^/]+\/image/]
@@ -933,11 +935,28 @@ class Auth {
*/
async getUserLoginResponsePayload(user) {
const libraryIds = await Database.libraryModel.getAllLibraryIds()
let plugins = undefined
if (process.env.ALLOW_PLUGINS === '1') {
// TODO: Should be better handled by the PluginManager
// restrict plugin extensions that are not allowed for the user type
plugins = this.pluginManifests.map((manifest) => {
const manifestExtensions = (manifest.extensions || []).filter((ext) => {
if (ext.restrictToAccountTypes?.length) {
return ext.restrictToAccountTypes.includes(user.type)
}
return true
})
return { ...manifest, extensions: manifestExtensions }
})
}
return {
user: user.toOldJSONForBrowser(),
userDefaultLibraryId: user.getDefaultLibraryId(libraryIds),
serverSettings: Database.serverSettings.toJSONForBrowser(),
ereaderDevices: Database.emailSettings.getEReaderDevices(user),
plugins,
Source: global.Source
}
}

View File

@@ -152,6 +152,11 @@ class Database {
return this.models.device
}
/** @type {typeof import('./models/Plugin')} */
get pluginModel() {
return this.models.plugin
}
/**
* Check if db file exists
* @returns {boolean}
@@ -305,6 +310,7 @@ class Database {
require('./models/Setting').init(this.sequelize)
require('./models/CustomMetadataProvider').init(this.sequelize)
require('./models/MediaItemShare').init(this.sequelize)
require('./models/Plugin').init(this.sequelize)
return this.sequelize.sync({ force, alter: false })
}

View File

@@ -28,7 +28,6 @@ const AbMergeManager = require('./managers/AbMergeManager')
const CacheManager = require('./managers/CacheManager')
const BackupManager = require('./managers/BackupManager')
const PlaybackSessionManager = require('./managers/PlaybackSessionManager')
const PodcastManager = require('./managers/PodcastManager')
const AudioMetadataMangaer = require('./managers/AudioMetadataManager')
const RssFeedManager = require('./managers/RssFeedManager')
const CronManager = require('./managers/CronManager')
@@ -36,6 +35,7 @@ const ApiCacheManager = require('./managers/ApiCacheManager')
const BinaryManager = require('./managers/BinaryManager')
const ShareManager = require('./managers/ShareManager')
const LibraryScanner = require('./scanner/LibraryScanner')
const PluginManager = require('./managers/PluginManager')
//Import the main Passport and Express-Session library
const passport = require('passport')
@@ -53,7 +53,17 @@ class Server {
global.RouterBasePath = ROUTER_BASE_PATH
global.XAccel = process.env.USE_X_ACCEL
global.AllowCors = process.env.ALLOW_CORS === '1'
global.DisableSsrfRequestFilter = process.env.DISABLE_SSRF_REQUEST_FILTER === '1'
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)) {
fs.mkdirSync(global.ConfigPath)
@@ -69,9 +79,8 @@ class Server {
this.backupManager = new BackupManager()
this.abMergeManager = new AbMergeManager()
this.playbackSessionManager = new PlaybackSessionManager()
this.podcastManager = new PodcastManager()
this.audioMetadataManager = new AudioMetadataMangaer()
this.cronManager = new CronManager(this.podcastManager, this.playbackSessionManager)
this.cronManager = new CronManager(this.playbackSessionManager)
this.apiCacheManager = new ApiCacheManager()
this.binaryManager = new BinaryManager()
@@ -151,6 +160,15 @@ class Server {
LibraryScanner.scanFilesChanged(pendingFileUpdates, pendingTask)
})
}
if (process.env.ALLOW_PLUGINS === '1') {
Logger.info(`[Server] Experimental plugin support enabled`)
// Initialize plugins
await PluginManager.init()
// TODO: Prevents circular dependency for SocketAuthority
this.auth.pluginManifests = PluginManager.pluginManifests
}
}
/**

View File

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

View File

@@ -19,6 +19,7 @@ const Scanner = require('../scanner/Scanner')
const Database = require('../Database')
const Watcher = require('../Watcher')
const RssFeedManager = require('../managers/RssFeedManager')
const PodcastManager = require('../managers/PodcastManager')
const libraryFilters = require('../utils/queries/libraryFilters')
const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters')
@@ -219,7 +220,7 @@ class LibraryController {
* @param {Response} res
*/
async getEpisodeDownloadQueue(req, res) {
const libraryDownloadQueueDetails = this.podcastManager.getDownloadQueueDetails(req.library.id)
const libraryDownloadQueueDetails = PodcastManager.getDownloadQueueDetails(req.library.id)
res.json(libraryDownloadQueueDetails)
}
@@ -1217,7 +1218,7 @@ class LibraryController {
Logger.error(`[LibraryController] Non-root user "${req.user.username}" attempted to match library items`)
return res.sendStatus(403)
}
Scanner.matchLibraryItems(req.library)
Scanner.matchLibraryItems(this, req.library)
res.sendStatus(200)
}
@@ -1288,7 +1289,7 @@ class LibraryController {
}
})
const opmlText = this.podcastManager.generateOPMLFileText(podcasts)
const opmlText = PodcastManager.generateOPMLFileText(podcasts)
res.type('application/xml')
res.send(opmlText)
}

View File

@@ -18,6 +18,7 @@ const RssFeedManager = require('../managers/RssFeedManager')
const CacheManager = require('../managers/CacheManager')
const CoverManager = require('../managers/CoverManager')
const ShareManager = require('../managers/ShareManager')
const PodcastManager = require('../managers/PodcastManager')
/**
* @typedef RequestUserObject
@@ -59,10 +60,10 @@ class LibraryItemController {
}
if (item.mediaType === 'podcast' && includeEntities.includes('downloads')) {
const downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(req.libraryItem.id)
const downloadsInQueue = PodcastManager.getEpisodeDownloadsInQueue(req.libraryItem.id)
item.episodeDownloadsQueued = downloadsInQueue.map((d) => d.toJSONForClient())
if (this.podcastManager.currentDownload?.libraryItemId === req.libraryItem.id) {
item.episodesDownloading = [this.podcastManager.currentDownload.toJSONForClient()]
if (PodcastManager.currentDownload?.libraryItemId === req.libraryItem.id) {
item.episodesDownloading = [PodcastManager.currentDownload.toJSONForClient()]
}
}
@@ -456,10 +457,24 @@ class LibraryItemController {
* @param {Response} res
*/
async match(req, res) {
var libraryItem = req.libraryItem
const libraryItem = req.libraryItem
const reqBody = req.body || {}
var options = req.body || {}
var matchResult = await Scanner.quickMatchLibraryItem(libraryItem, options)
const 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
}
var matchResult = await Scanner.quickMatchLibraryItem(this, libraryItem, options)
res.json(matchResult)
}
@@ -642,7 +657,6 @@ class LibraryItemController {
let itemsUpdated = 0
let itemsUnmatched = 0
const options = req.body.options || {}
if (!req.body.libraryItemIds?.length) {
return res.sendStatus(400)
}
@@ -656,8 +670,20 @@ class LibraryItemController {
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) {
const matchResult = await Scanner.quickMatchLibraryItem(libraryItem, options)
const matchResult = await Scanner.quickMatchLibraryItem(this, libraryItem, options)
if (matchResult.updated) {
itemsUpdated++
} else if (matchResult.warning) {

View File

@@ -0,0 +1,82 @@
const { Request, Response, NextFunction } = require('express')
const PluginManager = require('../managers/PluginManager')
const Logger = require('../Logger')
class PluginController {
constructor() {}
/**
*
* @param {Request} req
* @param {Response} res
*/
getConfig(req, res) {
if (!req.user.isAdminOrUp) {
return res.sendStatus(403)
}
res.json({
config: req.pluginData.instance.config
})
}
/**
* POST: /api/plugins/:id/action
*
* @param {Request} req
* @param {Response} res
*/
async handleAction(req, res) {
const actionName = req.body.pluginAction
const target = req.body.target
const data = req.body.data
Logger.info(`[PluginController] Handle plugin "${req.pluginData.manifest.name}" action ${actionName} ${target}`, data)
const actionData = await PluginManager.onAction(req.pluginData, actionName, target, data)
if (!actionData || actionData.error) {
return res.status(400).send(actionData?.error || 'Error performing action')
}
res.sendStatus(200)
}
/**
* POST: /api/plugins/:id/config
*
* @param {Request} req
* @param {Response} res
*/
async handleConfigSave(req, res) {
if (!req.user.isAdminOrUp) {
return res.sendStatus(403)
}
if (!req.body.config || typeof req.body.config !== 'object') {
return res.status(400).send('Invalid config')
}
const config = req.body.config
Logger.info(`[PluginController] Handle save config for plugin ${req.pluginData.manifest.name}`, config)
const saveData = await PluginManager.onConfigSave(req.pluginData, config)
if (!saveData || saveData.error) {
return res.status(400).send(saveData?.error || 'Error saving config')
}
res.sendStatus(200)
}
/**
*
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
*/
async middleware(req, res, next) {
if (req.params.id) {
const pluginData = PluginManager.getPluginDataById(req.params.id)
if (!pluginData) {
return res.sendStatus(404)
}
await pluginData.instance.reload()
req.pluginData = pluginData
}
next()
}
}
module.exports = new PluginController()

View File

@@ -11,6 +11,7 @@ const { validateUrl } = require('../utils/index')
const Scanner = require('../scanner/Scanner')
const CoverManager = require('../managers/CoverManager')
const PodcastManager = require('../managers/PodcastManager')
const LibraryItem = require('../objects/LibraryItem')
@@ -114,7 +115,7 @@ class PodcastController {
if (payload.episodesToDownload?.length) {
Logger.info(`[PodcastController] Podcast created now starting ${payload.episodesToDownload.length} episode downloads`)
this.podcastManager.downloadPodcastEpisodes(libraryItem, payload.episodesToDownload)
PodcastManager.downloadPodcastEpisodes(libraryItem, payload.episodesToDownload)
}
// Turn on podcast auto download cron if not already on
@@ -169,7 +170,7 @@ class PodcastController {
}
res.json({
feeds: this.podcastManager.getParsedOPMLFileFeeds(req.body.opmlText)
feeds: PodcastManager.getParsedOPMLFileFeeds(req.body.opmlText)
})
}
@@ -203,7 +204,7 @@ class PodcastController {
return res.status(404).send('Folder not found')
}
const autoDownloadEpisodes = !!req.body.autoDownloadEpisodes
this.podcastManager.createPodcastsFromFeedUrls(rssFeeds, folder, autoDownloadEpisodes, this.cronManager)
PodcastManager.createPodcastsFromFeedUrls(rssFeeds, folder, autoDownloadEpisodes, this.cronManager)
res.sendStatus(200)
}
@@ -230,7 +231,7 @@ class PodcastController {
const maxEpisodesToDownload = !isNaN(req.query.limit) ? Number(req.query.limit) : 3
var newEpisodes = await this.podcastManager.checkAndDownloadNewEpisodes(libraryItem, maxEpisodesToDownload)
var newEpisodes = await PodcastManager.checkAndDownloadNewEpisodes(libraryItem, maxEpisodesToDownload)
res.json({
episodes: newEpisodes || []
})
@@ -239,8 +240,6 @@ class PodcastController {
/**
* GET: /api/podcasts/:id/clear-queue
*
* @this {import('../routers/ApiRouter')}
*
* @param {RequestWithUser} req
* @param {Response} res
*/
@@ -249,22 +248,20 @@ class PodcastController {
Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempting to clear download queue`)
return res.sendStatus(403)
}
this.podcastManager.clearDownloadQueue(req.params.id)
PodcastManager.clearDownloadQueue(req.params.id)
res.sendStatus(200)
}
/**
* GET: /api/podcasts/:id/downloads
*
* @this {import('../routers/ApiRouter')}
*
* @param {RequestWithUser} req
* @param {Response} res
*/
getEpisodeDownloads(req, res) {
var libraryItem = req.libraryItem
var downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(libraryItem.id)
var downloadsInQueue = PodcastManager.getEpisodeDownloadsInQueue(libraryItem.id)
res.json({
downloads: downloadsInQueue.map((d) => d.toJSONForClient())
})
@@ -290,8 +287,6 @@ class PodcastController {
/**
* POST: /api/podcasts/:id/download-episodes
*
* @this {import('../routers/ApiRouter')}
*
* @param {RequestWithUser} req
* @param {Response} res
*/
@@ -306,7 +301,7 @@ class PodcastController {
return res.sendStatus(400)
}
this.podcastManager.downloadPodcastEpisodes(libraryItem, episodes)
PodcastManager.downloadPodcastEpisodes(libraryItem, episodes)
res.sendStatus(200)
}

View File

@@ -7,6 +7,7 @@ const Database = require('../Database')
const { PlayMethod } = require('../utils/constants')
const { getAudioMimeTypeFromExtname, encodeUriPath } = require('../utils/fileUtils')
const zipHelpers = require('../utils/zipHelpers')
const PlaybackSession = require('../objects/PlaybackSession')
const ShareManager = require('../managers/ShareManager')
@@ -210,6 +211,65 @@ class ShareController {
res.sendFile(audioTrackPath)
}
/**
* Public route - requires share_session_id cookie
*
* GET: /api/share/:slug/download
* Downloads media item share
*
* @param {Request} req
* @param {Response} res
*/
async downloadMediaItemShare(req, res) {
if (!req.cookies.share_session_id) {
return res.status(404).send('Share session not set')
}
const { slug } = req.params
const mediaItemShare = ShareManager.findBySlug(slug)
if (!mediaItemShare) {
return res.status(404)
}
if (!mediaItemShare.isDownloadable) {
return res.status(403).send('Download is not allowed for this item')
}
const playbackSession = ShareManager.findPlaybackSessionBySessionId(req.cookies.share_session_id)
if (!playbackSession || playbackSession.mediaItemShareId !== mediaItemShare.id) {
return res.status(404).send('Share session not found')
}
const libraryItem = await Database.libraryItemModel.findByPk(playbackSession.libraryItemId, {
attributes: ['id', 'path', 'relPath', 'isFile']
})
if (!libraryItem) {
return res.status(404).send('Library item not found')
}
const itemPath = libraryItem.path
const itemTitle = playbackSession.displayTitle
Logger.info(`[ShareController] Requested download for book "${itemTitle}" at "${itemPath}"`)
try {
if (libraryItem.isFile) {
const audioMimeType = getAudioMimeTypeFromExtname(Path.extname(itemPath))
if (audioMimeType) {
res.setHeader('Content-Type', audioMimeType)
}
await new Promise((resolve, reject) => res.download(itemPath, libraryItem.relPath, (error) => (error ? reject(error) : resolve())))
} else {
const filename = `${itemTitle}.zip`
await zipHelpers.zipDirectoryPipe(itemPath, filename, res)
}
Logger.info(`[ShareController] Downloaded item "${itemTitle}" at "${itemPath}"`)
} catch (error) {
Logger.error(`[ShareController] Download failed for item "${itemTitle}" at "${itemPath}"`, error)
res.status(500).send('Failed to download the item')
}
}
/**
* Public route - requires share_session_id cookie
*
@@ -259,7 +319,7 @@ class ShareController {
return res.sendStatus(403)
}
const { slug, expiresAt, mediaItemType, mediaItemId } = req.body
const { slug, expiresAt, mediaItemType, mediaItemId, isDownloadable } = req.body
if (!slug?.trim?.() || typeof mediaItemType !== 'string' || typeof mediaItemId !== 'string') {
return res.status(400).send('Missing or invalid required fields')
@@ -298,7 +358,8 @@ class ShareController {
expiresAt: expiresAt || null,
mediaItemId,
mediaItemType,
userId: req.user.id
userId: req.user.id,
isDownloadable
})
ShareManager.openMediaItemShare(mediaItemShare)

View File

@@ -5,11 +5,10 @@ const Database = require('../Database')
const LibraryScanner = require('../scanner/LibraryScanner')
const ShareManager = require('./ShareManager')
const PodcastManager = require('./PodcastManager')
class CronManager {
constructor(podcastManager, playbackSessionManager) {
/** @type {import('./PodcastManager')} */
this.podcastManager = podcastManager
constructor(playbackSessionManager) {
/** @type {import('./PlaybackSessionManager')} */
this.playbackSessionManager = playbackSessionManager
@@ -163,7 +162,7 @@ class CronManager {
task
})
} catch (error) {
Logger.error(`[PodcastManager] Failed to schedule podcast cron ${this.serverSettings.podcastEpisodeSchedule}`, error)
Logger.error(`[CronManager] Failed to schedule podcast cron ${this.serverSettings.podcastEpisodeSchedule}`, error)
}
}
@@ -192,7 +191,7 @@ class CronManager {
// Run episode checks
for (const libraryItem of libraryItems) {
const keepAutoDownloading = await this.podcastManager.runEpisodeCheck(libraryItem)
const keepAutoDownloading = await PodcastManager.runEpisodeCheck(libraryItem)
if (!keepAutoDownloading) {
// auto download was disabled
podcastCron.libraryItemIds = podcastCron.libraryItemIds.filter((lid) => lid !== libraryItem.id) // Filter it out

View File

@@ -0,0 +1,274 @@
const Path = require('path')
const Logger = require('../Logger')
const Database = require('../Database')
const SocketAuthority = require('../SocketAuthority')
const TaskManager = require('../managers/TaskManager')
const ShareManager = require('../managers/ShareManager')
const RssFeedManager = require('../managers/RssFeedManager')
const PodcastManager = require('../managers/PodcastManager')
const fsExtra = require('../libs/fsExtra')
const { isUUID, parseSemverStrict } = require('../utils')
/**
* @typedef PluginContext
* @property {import('../Logger')} Logger
* @property {import('../Database')} Database
* @property {import('../SocketAuthority')} SocketAuthority
* @property {import('../managers/TaskManager')} TaskManager
* @property {import('../models/Plugin')} pluginInstance
* @property {import('../managers/ShareManager')} ShareManager
* @property {import('../managers/RssFeedManager')} RssFeedManager
* @property {import('../managers/PodcastManager')} PodcastManager
*/
/**
* @typedef PluginData
* @property {string} id
* @property {Object} manifest
* @property {import('../models/Plugin')} instance
* @property {Function} init
* @property {Function} onAction
* @property {Function} onConfigSave
*/
class PluginManager {
constructor() {
/** @type {PluginData[]} */
this.plugins = []
}
get pluginMetadataPath() {
return Path.posix.join(global.MetadataPath, 'plugins')
}
get pluginManifests() {
return this.plugins.map((plugin) => plugin.manifest)
}
/**
*
* @param {import('../models/Plugin')} pluginInstance
* @returns {PluginContext}
*/
getPluginContext(pluginInstance) {
return {
Logger,
Database,
SocketAuthority,
TaskManager,
pluginInstance,
ShareManager,
RssFeedManager,
PodcastManager
}
}
/**
*
* @param {string} id
* @returns {PluginData}
*/
getPluginDataById(id) {
return this.plugins.find((plugin) => plugin.manifest.id === id)
}
/**
* Validate and load a plugin from a directory
* TODO: Validatation
*
* @param {string} dirname
* @param {string} pluginPath
* @returns {Promise<PluginData>}
*/
async loadPlugin(dirname, pluginPath) {
const pluginFiles = await fsExtra.readdir(pluginPath, { withFileTypes: true }).then((files) => files.filter((file) => !file.isDirectory()))
if (!pluginFiles.length) {
Logger.error(`No files found in plugin ${pluginPath}`)
return null
}
const manifestFile = pluginFiles.find((file) => file.name === 'manifest.json')
if (!manifestFile) {
Logger.error(`No manifest found for plugin ${pluginPath}`)
return null
}
const indexFile = pluginFiles.find((file) => file.name === 'index.js')
if (!indexFile) {
Logger.error(`No index file found for plugin ${pluginPath}`)
return null
}
let manifestJson = null
try {
manifestJson = await fsExtra.readFile(Path.join(pluginPath, manifestFile.name), 'utf8').then((data) => JSON.parse(data))
} catch (error) {
Logger.error(`Error parsing manifest file for plugin ${pluginPath}`, error)
return null
}
// TODO: Validate manifest json
if (!isUUID(manifestJson.id)) {
Logger.error(`Invalid plugin ID in manifest for plugin ${pluginPath}`)
return null
}
if (!parseSemverStrict(manifestJson.version)) {
Logger.error(`Invalid plugin version in manifest for plugin ${pluginPath}`)
return null
}
// TODO: Enforcing plugin name to be the same as the directory name? Ensures plugins are identifiable in the file system. May have issues with unicode characters.
if (dirname !== manifestJson.name) {
Logger.error(`Plugin directory name "${dirname}" does not match manifest name "${manifestJson.name}"`)
return null
}
let pluginContents = null
try {
pluginContents = require(Path.join(pluginPath, indexFile.name))
} catch (error) {
Logger.error(`Error loading plugin ${pluginPath}`, error)
return null
}
if (typeof pluginContents.init !== 'function') {
Logger.error(`Plugin ${pluginPath} does not have an init function`)
return null
}
return {
id: manifestJson.id,
manifest: manifestJson,
init: pluginContents.init,
onAction: pluginContents.onAction,
onConfigSave: pluginContents.onConfigSave
}
}
/**
* Get all plugins from the /metadata/plugins directory
*/
async getPluginsFromDirPath(pluginsPath) {
// Get all directories in the plugins directory
const pluginDirs = await fsExtra.readdir(pluginsPath, { withFileTypes: true }).then((files) => files.filter((file) => file.isDirectory()))
const pluginsFound = []
for (const pluginDir of pluginDirs) {
Logger.debug(`[PluginManager] Checking if directory "${pluginDir.name}" is a plugin`)
const plugin = await this.loadPlugin(pluginDir.name, Path.join(pluginsPath, pluginDir.name))
if (plugin) {
Logger.debug(`[PluginManager] Found plugin "${plugin.manifest.name}"`)
pluginsFound.push(plugin)
}
}
return pluginsFound
}
/**
* Load plugins from the /metadata/plugins directory and update the database
*/
async loadPlugins() {
await fsExtra.ensureDir(this.pluginMetadataPath)
const pluginsFound = await this.getPluginsFromDirPath(this.pluginMetadataPath)
if (process.env.DEV_PLUGINS_PATH) {
const devPluginsFound = await this.getPluginsFromDirPath(process.env.DEV_PLUGINS_PATH)
if (!devPluginsFound.length) {
Logger.warn(`[PluginManager] No plugins found in DEV_PLUGINS_PATH: ${process.env.DEV_PLUGINS_PATH}`)
} else {
pluginsFound.push(...devPluginsFound)
}
}
const existingPlugins = await Database.pluginModel.findAll()
// Add new plugins or update existing plugins
for (const plugin of pluginsFound) {
const existingPlugin = existingPlugins.find((p) => p.id === plugin.manifest.id)
if (existingPlugin) {
// TODO: Should automatically update?
if (existingPlugin.version !== plugin.manifest.version) {
Logger.info(`[PluginManager] Updating plugin "${plugin.manifest.name}" version from "${existingPlugin.version}" to version "${plugin.manifest.version}"`)
await existingPlugin.update({ version: plugin.manifest.version, isMissing: false })
} else if (existingPlugin.isMissing) {
Logger.info(`[PluginManager] Plugin "${plugin.manifest.name}" was missing but is now found`)
await existingPlugin.update({ isMissing: false })
} else {
Logger.debug(`[PluginManager] Plugin "${plugin.manifest.name}" already exists in the database with version "${plugin.manifest.version}"`)
}
plugin.instance = existingPlugin
} else {
plugin.instance = await Database.pluginModel.create({
id: plugin.manifest.id,
name: plugin.manifest.name,
version: plugin.manifest.version
})
Logger.info(`[PluginManager] Added plugin "${plugin.manifest.name}" to the database`)
}
}
// Mark missing plugins
for (const plugin of existingPlugins) {
const foundPlugin = pluginsFound.find((p) => p.manifest.id === plugin.id)
if (!foundPlugin && !plugin.isMissing) {
Logger.info(`[PluginManager] Plugin "${plugin.name}" not found or invalid - marking as missing`)
await plugin.update({ isMissing: true })
}
}
this.plugins = pluginsFound
}
/**
* Load and initialize all plugins
*/
async init() {
await this.loadPlugins()
for (const plugin of this.plugins) {
Logger.info(`[PluginManager] Initializing plugin ${plugin.manifest.name}`)
plugin.init(this.getPluginContext(plugin.instance))
}
}
/**
*
* @param {PluginData} plugin
* @param {string} actionName
* @param {string} target
* @param {Object} data
* @returns {Promise<boolean|{error:string}>}
*/
onAction(plugin, actionName, target, data) {
if (!plugin.onAction) {
Logger.error(`[PluginManager] onAction not implemented for plugin ${plugin.manifest.name}`)
return false
}
const pluginExtension = plugin.manifest.extensions.find((extension) => extension.name === actionName)
if (!pluginExtension) {
Logger.error(`[PluginManager] Extension ${actionName} not found for plugin ${plugin.manifest.name}`)
return false
}
Logger.info(`[PluginManager] Calling onAction for plugin ${plugin.manifest.name}`)
return plugin.onAction(this.getPluginContext(plugin.instance), actionName, target, data)
}
/**
*
* @param {PluginData} plugin
* @param {Object} config
* @returns {Promise<boolean|{error:string}>}
*/
onConfigSave(plugin, config) {
if (!plugin.onConfigSave) {
Logger.error(`[PluginManager] onConfigSave not implemented for plugin ${plugin.manifest.name}`)
return false
}
Logger.info(`[PluginManager] Calling onConfigSave for plugin ${plugin.manifest.name}`)
return plugin.onConfigSave(this.getPluginContext(plugin.instance), config)
}
}
module.exports = new PluginManager()

View File

@@ -586,4 +586,4 @@ class PodcastManager {
Logger.info(`[PodcastManager] createPodcastsFromFeedUrls: Finished OPML import. Created ${numPodcastsAdded} podcasts out of ${rssFeedUrls.length} RSS feed URLs`)
}
}
module.exports = PodcastManager
module.exports = new PodcastManager()

View File

@@ -342,7 +342,6 @@ class RssFeedManager {
}
})
if (!feed) {
Logger.warn(`[RssFeedManager] closeFeedForEntityId: Feed not found for entity id ${entityId}`)
return false
}
return this.handleCloseFeed(feed)

View File

@@ -11,3 +11,4 @@ Please add a record of every database migration that you create to this file. Th
| v2.17.3 | v2.17.3-fk-constraints | Changes the foreign key constraints for tables due to sequelize bug dropping constraints in v2.17.0 migration |
| v2.17.4 | v2.17.4-use-subfolder-for-oidc-redirect-uris | Save subfolder to OIDC redirect URIs to support existing installations |
| v2.17.5 | v2.17.5-remove-host-from-feed-urls | removes the host (serverAddress) from URL columns in the feeds and feedEpisodes tables |
| v2.17.6 | v2.17.6-share-add-isdownloadable | Adds the isDownloadable column to the mediaItemShares table |

View File

@@ -0,0 +1,68 @@
/**
* @typedef MigrationContext
* @property {import('sequelize').QueryInterface} queryInterface - a Sequelize QueryInterface object.
* @property {import('../Logger')} logger - a Logger object.
*
* @typedef MigrationOptions
* @property {MigrationContext} context - an object containing the migration context.
*/
const migrationVersion = '2.17.6'
const migrationName = `${migrationVersion}-share-add-isdownloadable`
const loggerPrefix = `[${migrationVersion} migration]`
/**
* This migration script adds the isDownloadable column to the mediaItemShares table.
*
* @param {MigrationOptions} options - an object containing the migration context.
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
*/
async function up({ context: { queryInterface, logger } }) {
logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)
if (await queryInterface.tableExists('mediaItemShares')) {
const tableDescription = await queryInterface.describeTable('mediaItemShares')
if (!tableDescription.isDownloadable) {
logger.info(`${loggerPrefix} Adding isDownloadable column to mediaItemShares table`)
await queryInterface.addColumn('mediaItemShares', 'isDownloadable', {
type: queryInterface.sequelize.Sequelize.DataTypes.BOOLEAN,
defaultValue: false,
allowNull: false
})
logger.info(`${loggerPrefix} Added isDownloadable column to mediaItemShares table`)
} else {
logger.info(`${loggerPrefix} isDownloadable column already exists in mediaItemShares table`)
}
} else {
logger.info(`${loggerPrefix} mediaItemShares table does not exist`)
}
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
}
/**
* This migration script removes the isDownloadable column from the mediaItemShares table.
*
* @param {MigrationOptions} options - an object containing the migration context.
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
*/
async function down({ context: { queryInterface, logger } }) {
logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)
if (await queryInterface.tableExists('mediaItemShares')) {
const tableDescription = await queryInterface.describeTable('mediaItemShares')
if (tableDescription.isDownloadable) {
logger.info(`${loggerPrefix} Removing isDownloadable column from mediaItemShares table`)
await queryInterface.removeColumn('mediaItemShares', 'isDownloadable')
logger.info(`${loggerPrefix} Removed isDownloadable column from mediaItemShares table`)
} else {
logger.info(`${loggerPrefix} isDownloadable column does not exist in mediaItemShares table`)
}
} else {
logger.info(`${loggerPrefix} mediaItemShares table does not exist`)
}
logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
}
module.exports = { up, down }

View File

@@ -0,0 +1,68 @@
/**
* @typedef MigrationContext
* @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.
* @property {import('../Logger')} logger - a Logger object.
*
* @typedef MigrationOptions
* @property {MigrationContext} context - an object containing the migration context.
*/
const migrationVersion = '2.18.0'
const migrationName = `${migrationVersion}-add-plugins-table`
const loggerPrefix = `[${migrationVersion} migration]`
/**
* This upward migration creates the plugins table if it does not exist.
*
* @param {MigrationOptions} options - an object containing the migration context.
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
*/
async function up({ context: { queryInterface, logger } }) {
// Upwards migration script
logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)
if (!(await queryInterface.tableExists('plugins'))) {
const DataTypes = queryInterface.sequelize.Sequelize.DataTypes
await queryInterface.createTable('plugins', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
name: DataTypes.STRING,
version: DataTypes.STRING,
isMissing: DataTypes.BOOLEAN,
config: DataTypes.JSON,
extraData: DataTypes.JSON,
createdAt: DataTypes.DATE,
updatedAt: DataTypes.DATE
})
logger.info(`${loggerPrefix} Table 'plugins' created`)
} else {
logger.info(`${loggerPrefix} Table 'plugins' already exists`)
}
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
}
/**
* This downward migration script drops the plugins table if it exists.
*
* @param {MigrationOptions} options - an object containing the migration context.
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
*/
async function down({ context: { queryInterface, logger } }) {
// Downward migration script
logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)
if (await queryInterface.tableExists('plugins')) {
await queryInterface.dropTable('plugins')
logger.info(`${loggerPrefix} Table 'plugins' dropped`)
} else {
logger.info(`${loggerPrefix} Table 'plugins' does not exist`)
}
logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
}
module.exports = { up, down }

View File

@@ -138,7 +138,7 @@ class FeedEpisode extends Model {
const contentUrl = `/feed/${slug}/item/${episodeId}/media${Path.extname(audioTrack.metadata.filename)}`
let title = audioTrack.title
let title = Path.basename(audioTrack.metadata.filename, Path.extname(audioTrack.metadata.filename))
if (book.trackList.length == 1) {
// If audiobook is a single file, use book title instead of chapter/file title
title = book.title

View File

@@ -12,6 +12,7 @@ const { DataTypes, Model } = require('sequelize')
* @property {Object} extraData
* @property {Date} createdAt
* @property {Date} updatedAt
* @property {boolean} isDownloadable
*
* @typedef {MediaItemShareObject & MediaItemShare} MediaItemShareModel
*/
@@ -25,11 +26,40 @@ const { DataTypes, Model } = require('sequelize')
* @property {Date} expiresAt
* @property {Date} createdAt
* @property {Date} updatedAt
* @property {boolean} isDownloadable
*/
class MediaItemShare extends Model {
constructor(values, options) {
super(values, options)
/** @type {UUIDV4} */
this.id
/** @type {UUIDV4} */
this.mediaItemId
/** @type {string} */
this.mediaItemType
/** @type {string} */
this.slug
/** @type {string} */
this.pash
/** @type {UUIDV4} */
this.userId
/** @type {Date} */
this.expiresAt
/** @type {Object} */
this.extraData
/** @type {Date} */
this.createdAt
/** @type {Date} */
this.updatedAt
/** @type {boolean} */
this.isDownloadable
// Expanded properties
/** @type {import('./Book')|import('./PodcastEpisode')} */
this.mediaItem
}
toJSONForClient() {
@@ -40,7 +70,8 @@ class MediaItemShare extends Model {
slug: this.slug,
expiresAt: this.expiresAt,
createdAt: this.createdAt,
updatedAt: this.updatedAt
updatedAt: this.updatedAt,
isDownloadable: this.isDownloadable
}
}
@@ -114,7 +145,8 @@ class MediaItemShare extends Model {
slug: DataTypes.STRING,
pash: DataTypes.STRING,
expiresAt: DataTypes.DATE,
extraData: DataTypes.JSON
extraData: DataTypes.JSON,
isDownloadable: DataTypes.BOOLEAN
},
{
sequelize,

54
server/models/Plugin.js Normal file
View File

@@ -0,0 +1,54 @@
const { DataTypes, Model } = require('sequelize')
class Plugin extends Model {
constructor(values, options) {
super(values, options)
/** @type {UUIDV4} */
this.id
/** @type {string} */
this.name
/** @type {string} */
this.version
/** @type {boolean} */
this.isMissing
/** @type {Object} */
this.config
/** @type {Object} */
this.extraData
/** @type {Date} */
this.createdAt
/** @type {Date} */
this.updatedAt
}
/**
* Initialize model
* @param {import('../Database').sequelize} sequelize
*/
static init(sequelize) {
super.init(
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
name: DataTypes.STRING,
version: DataTypes.STRING,
isMissing: {
type: DataTypes.BOOLEAN,
defaultValue: false
},
config: DataTypes.JSON,
extraData: DataTypes.JSON
},
{
sequelize,
modelName: 'plugin'
}
)
}
}
module.exports = Plugin

View File

@@ -33,6 +33,7 @@ const RSSFeedController = require('../controllers/RSSFeedController')
const CustomMetadataProviderController = require('../controllers/CustomMetadataProviderController')
const MiscController = require('../controllers/MiscController')
const ShareController = require('../controllers/ShareController')
const PluginController = require('../controllers/PluginController')
const { getTitleIgnorePrefix } = require('../utils/index')
@@ -46,8 +47,6 @@ class ApiRouter {
this.abMergeManager = Server.abMergeManager
/** @type {import('../managers/BackupManager')} */
this.backupManager = Server.backupManager
/** @type {import('../managers/PodcastManager')} */
this.podcastManager = Server.podcastManager
/** @type {import('../managers/AudioMetadataManager')} */
this.audioMetadataManager = Server.audioMetadataManager
/** @type {import('../managers/CronManager')} */
@@ -320,6 +319,13 @@ class ApiRouter {
this.router.post('/share/mediaitem', ShareController.createMediaItemShare.bind(this))
this.router.delete('/share/mediaitem/:id', ShareController.deleteMediaItemShare.bind(this))
//
// Plugin routes
//
this.router.get('/plugins/:id/config', PluginController.middleware.bind(this), PluginController.getConfig.bind(this))
this.router.post('/plugins/:id/action', PluginController.middleware.bind(this), PluginController.handleAction.bind(this))
this.router.post('/plugins/:id/config', PluginController.middleware.bind(this), PluginController.handleConfigSave.bind(this))
//
// Misc Routes
//

View File

@@ -15,6 +15,7 @@ class PublicRouter {
this.router.get('/share/:slug', ShareController.getMediaItemShareBySlug.bind(this))
this.router.get('/share/:slug/track/:index', ShareController.getMediaItemShareAudioTrack.bind(this))
this.router.get('/share/:slug/cover', ShareController.getMediaItemShareCoverImage.bind(this))
this.router.get('/share/:slug/download', ShareController.downloadMediaItemShare.bind(this))
this.router.patch('/share/:slug/progress', ShareController.updateMediaItemShareProgress.bind(this))
}
}

View File

@@ -13,36 +13,58 @@ const LibraryScanner = require('./LibraryScanner')
const CoverManager = require('../managers/CoverManager')
const TaskManager = require('../managers/TaskManager')
/**
* @typedef QuickMatchOptions
* @property {string} [provider]
* @property {string} [title]
* @property {string} [author]
* @property {string} [isbn] - This override is currently unused in Abs clients
* @property {string} [asin] - This override is currently unused in Abs clients
* @property {boolean} [overrideCover]
* @property {boolean} [overrideDetails]
*/
class Scanner {
constructor() {}
async quickMatchLibraryItem(libraryItem, options = {}) {
var provider = options.provider || 'google'
var searchTitle = options.title || libraryItem.media.metadata.title
var searchAuthor = options.author || libraryItem.media.metadata.authorName
var overrideDefaults = options.overrideDefaults || false
/**
*
* @param {import('../routers/ApiRouter')} apiRouterCtx
* @param {import('../objects/LibraryItem')} libraryItem
* @param {QuickMatchOptions} options
* @returns {Promise<{updated: boolean, libraryItem: import('../objects/LibraryItem')}>}
*/
async quickMatchLibraryItem(apiRouterCtx, libraryItem, options = {}) {
const provider = options.provider || 'google'
const searchTitle = options.title || libraryItem.media.metadata.title
const searchAuthor = options.author || libraryItem.media.metadata.authorName
// Set to override existing metadata if scannerPreferMatchedMetadata setting is true and
// the overrideDefaults option is not set or set to false.
if (overrideDefaults == false && Database.serverSettings.scannerPreferMatchedMetadata) {
// If overrideCover and overrideDetails is not sent in options than use the server setting to determine if we should override
if (options.overrideCover === undefined && options.overrideDetails === undefined && Database.serverSettings.scannerPreferMatchedMetadata) {
options.overrideCover = true
options.overrideDetails = true
}
var updatePayload = {}
var hasUpdated = false
let updatePayload = {}
let hasUpdated = false
let existingAuthors = [] // Used for checking if authors or series are now empty
let existingSeries = []
if (libraryItem.isBook) {
var searchISBN = options.isbn || libraryItem.media.metadata.isbn
var searchASIN = options.asin || libraryItem.media.metadata.asin
existingAuthors = libraryItem.media.metadata.authors.map((a) => a.id)
existingSeries = libraryItem.media.metadata.series.map((s) => s.id)
var results = await BookFinder.search(libraryItem, provider, searchTitle, searchAuthor, searchISBN, searchASIN, { maxFuzzySearches: 2 })
const searchISBN = options.isbn || libraryItem.media.metadata.isbn
const searchASIN = options.asin || libraryItem.media.metadata.asin
const results = await BookFinder.search(libraryItem, provider, searchTitle, searchAuthor, searchISBN, searchASIN, { maxFuzzySearches: 2 })
if (!results.length) {
return {
warning: `No ${provider} match found`
}
}
var matchData = results[0]
const matchData = results[0]
// Update cover if not set OR overrideCover flag
if (matchData.cover && (!libraryItem.media.coverPath || options.overrideCover)) {
@@ -58,13 +80,13 @@ class Scanner {
updatePayload = await this.quickMatchBookBuildUpdatePayload(libraryItem, matchData, options)
} else if (libraryItem.isPodcast) {
// Podcast quick match
var results = await PodcastFinder.search(searchTitle)
const results = await PodcastFinder.search(searchTitle)
if (!results.length) {
return {
warning: `No ${provider} match found`
}
}
var matchData = results[0]
const matchData = results[0]
// Update cover if not set OR overrideCover flag
if (matchData.cover && (!libraryItem.media.coverPath || options.overrideCover)) {
@@ -95,6 +117,19 @@ class Scanner {
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
// Check if any authors or series are now empty and should be removed
if (libraryItem.isBook) {
const authorsRemoved = existingAuthors.filter((aid) => !libraryItem.media.metadata.authors.find((au) => au.id === aid))
const seriesRemoved = existingSeries.filter((sid) => !libraryItem.media.metadata.series.find((se) => se.id === sid))
if (authorsRemoved.length) {
await apiRouterCtx.checkRemoveAuthorsWithNoBooks(authorsRemoved)
}
if (seriesRemoved.length) {
await apiRouterCtx.checkRemoveEmptySeries(seriesRemoved)
}
}
}
return {
@@ -149,6 +184,13 @@ class Scanner {
return updatePayload
}
/**
*
* @param {import('../objects/LibraryItem')} libraryItem
* @param {*} matchData
* @param {QuickMatchOptions} options
* @returns
*/
async quickMatchBookBuildUpdatePayload(libraryItem, matchData, options) {
// Update media metadata if not set OR overrideDetails flag
const detailKeysToUpdate = ['title', 'subtitle', 'description', 'narrator', 'publisher', 'publishedYear', 'genres', 'tags', 'language', 'explicit', 'abridged', 'asin', 'isbn']
@@ -307,12 +349,13 @@ class Scanner {
/**
* Quick match library items
*
* @param {import('../routers/ApiRouter')} apiRouterCtx
* @param {import('../models/Library')} library
* @param {import('../objects/LibraryItem')[]} libraryItems
* @param {LibraryScan} libraryScan
* @returns {Promise<boolean>} false if scan canceled
*/
async matchLibraryItemsChunk(library, libraryItems, libraryScan) {
async matchLibraryItemsChunk(apiRouterCtx, library, libraryItems, libraryScan) {
for (let i = 0; i < libraryItems.length; i++) {
const libraryItem = libraryItems[i]
@@ -327,7 +370,7 @@ class Scanner {
}
Logger.debug(`[Scanner] matchLibraryItems: Quick matching "${libraryItem.media.metadata.title}" (${i + 1} of ${libraryItems.length})`)
const result = await this.quickMatchLibraryItem(libraryItem, { provider: library.provider })
const result = await this.quickMatchLibraryItem(apiRouterCtx, libraryItem, { provider: library.provider })
if (result.warning) {
Logger.warn(`[Scanner] matchLibraryItems: Match warning ${result.warning} for library item "${libraryItem.media.metadata.title}"`)
} else if (result.updated) {
@@ -346,9 +389,10 @@ class Scanner {
/**
* Quick match all library items for library
*
* @param {import('../routers/ApiRouter')} apiRouterCtx
* @param {import('../models/Library')} library
*/
async matchLibraryItems(library) {
async matchLibraryItems(apiRouterCtx, library) {
if (library.mediaType === 'podcast') {
Logger.error(`[Scanner] matchLibraryItems: Match all not supported for podcasts yet`)
return
@@ -388,7 +432,7 @@ class Scanner {
hasMoreChunks = libraryItems.length === limit
let oldLibraryItems = libraryItems.map((li) => Database.libraryItemModel.getOldLibraryItem(li))
const shouldContinue = await this.matchLibraryItemsChunk(library, oldLibraryItems, libraryScan)
const shouldContinue = await this.matchLibraryItemsChunk(apiRouterCtx, library, oldLibraryItems, libraryScan)
if (!shouldContinue) {
isCanceled = true
break

View File

@@ -277,8 +277,8 @@ module.exports.downloadFile = (url, filepath, contentTypeFilter = null) => {
'User-Agent': 'audiobookshelf (+https://audiobookshelf.org)'
},
timeout: 30000,
httpAgent: global.DisableSsrfRequestFilter ? null : ssrfFilter(url),
httpsAgent: global.DisableSsrfRequestFilter ? null : ssrfFilter(url)
httpAgent: global.DisableSsrfRequestFilter?.(url) ? null : ssrfFilter(url),
httpsAgent: global.DisableSsrfRequestFilter?.(url) ? null : ssrfFilter(url)
})
.then((response) => {
// Validate content type

View File

@@ -243,3 +243,21 @@ module.exports.isValidASIN = (str) => {
if (!str || typeof str !== 'string') return false
return /^[A-Z0-9]{10}$/.test(str)
}
/**
* Parse semver string that must be in format "major.minor.patch" all numbers
*
* @param {string} version
* @returns {{major: number, minor: number, patch: number} | null}
*/
module.exports.parseSemverStrict = (version) => {
if (typeof version !== 'string') {
return null
}
const [major, minor, patch] = version.split('.').map(Number)
if (isNaN(major) || isNaN(minor) || isNaN(patch)) {
return null
}
return { major, minor, patch }
}

View File

@@ -59,8 +59,8 @@ function extractPodcastMetadata(channel) {
if (channel['description']) {
const rawDescription = extractFirstArrayItem(channel, 'description') || ''
metadata.description = htmlSanitizer.sanitize(rawDescription)
metadata.descriptionPlain = htmlSanitizer.stripAllTags(rawDescription)
metadata.description = htmlSanitizer.sanitize(rawDescription.trim())
metadata.descriptionPlain = htmlSanitizer.stripAllTags(rawDescription.trim())
}
const arrayFields = ['title', 'language', 'itunes:explicit', 'itunes:author', 'pubDate', 'link', 'itunes:type']
@@ -103,8 +103,8 @@ function extractEpisodeData(item) {
// Supposed to be the plaintext description but not always followed
if (item['description']) {
const rawDescription = extractFirstArrayItem(item, 'description') || ''
if (!episode.description) episode.description = htmlSanitizer.sanitize(rawDescription)
episode.descriptionPlain = htmlSanitizer.stripAllTags(rawDescription)
if (!episode.description) episode.description = htmlSanitizer.sanitize(rawDescription.trim())
episode.descriptionPlain = htmlSanitizer.stripAllTags(rawDescription.trim())
}
if (item['pubDate']) {
@@ -244,8 +244,8 @@ module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => {
Accept: 'application/rss+xml, application/xhtml+xml, application/xml, */*;q=0.8',
'User-Agent': userAgent
},
httpAgent: global.DisableSsrfRequestFilter ? null : ssrfFilter(feedUrl),
httpsAgent: global.DisableSsrfRequestFilter ? null : ssrfFilter(feedUrl)
httpAgent: global.DisableSsrfRequestFilter?.(feedUrl) ? null : ssrfFilter(feedUrl),
httpsAgent: global.DisableSsrfRequestFilter?.(feedUrl) ? null : ssrfFilter(feedUrl)
})
.then(async (data) => {
// Adding support for ios-8859-1 encoded RSS feeds.

View File

@@ -7,7 +7,7 @@ module.exports.zipDirectoryPipe = (path, filename, res) => {
res.attachment(filename)
const archive = archiver('zip', {
zlib: { level: 9 } // Sets the compression level.
zlib: { level: 0 } // Sets the compression level.
})
// listen for all archive data to be written
@@ -49,4 +49,4 @@ module.exports.zipDirectoryPipe = (path, filename, res) => {
archive.finalize()
})
}
}

View File

@@ -0,0 +1,5 @@
describe('PluginManager', () => {
it('should register a plugin', () => {
// Test implementation
})
})

View File

@@ -0,0 +1,152 @@
/**
* Called on initialization of the plugin
*
* @param {import('../../../server/managers/PluginManager').PluginContext} context
*/
module.exports.init = async (context) => {
// Set default config on first init
if (!context.pluginInstance.config) {
context.Logger.info('[ExamplePlugin] First init. Setting default config')
context.pluginInstance.config = {
requestAddress: '',
enable: false
}
await context.pluginInstance.save()
}
context.Database.mediaProgressModel.addHook('afterSave', (instance, options) => {
context.Logger.debug(`[ExamplePlugin] mediaProgressModel afterSave hook for mediaProgress ${instance.id}`)
handleMediaProgressUpdate(context, instance)
})
context.Logger.info('[ExamplePlugin] Example plugin initialized')
}
/**
* Called when an extension action is triggered
*
* @param {import('../../../server/managers/PluginManager').PluginContext} context
* @param {string} actionName
* @param {string} target
* @param {*} data
* @returns {Promise<boolean|{error: string}>}
*/
module.exports.onAction = async (context, actionName, target, data) => {
context.Logger.info('[ExamplePlugin] Example plugin onAction', actionName, target, data)
createTask(context)
return true
}
/**
* Called when the plugin config page is saved
*
* @param {import('../../../server/managers/PluginManager').PluginContext} context
* @param {*} config
* @returns {Promise<boolean|{error: string}>}
*/
module.exports.onConfigSave = async (context, config) => {
context.Logger.info('[ExamplePlugin] Example plugin onConfigSave', config)
if (!config.requestAddress || typeof config.requestAddress !== 'string') {
context.Logger.error('[ExamplePlugin] Invalid request address')
return {
error: 'Invalid request address'
}
}
if (typeof config.enable !== 'boolean') {
context.Logger.error('[ExamplePlugin] Invalid enable value')
return {
error: 'Invalid enable value'
}
}
// Config would need to be validated
const updatedConfig = {
requestAddress: config.requestAddress,
enable: config.enable
}
context.pluginInstance.config = updatedConfig
await context.pluginInstance.save()
context.Logger.info('[ExamplePlugin] Example plugin config saved', updatedConfig)
return true
}
//
// Helper functions
//
let numProgressSyncs = 0
/**
* Send media progress update to external requestAddress defined in config
*
* @param {import('../../../server/managers/PluginManager').PluginContext} context
* @param {import('../../../server/models/MediaProgress')} mediaProgress
*/
async function handleMediaProgressUpdate(context, mediaProgress) {
// Need to reload the model instance since it was passed in during init and may have values changed
await context.pluginInstance.reload()
if (!context.pluginInstance.config?.enable) {
return
}
const requestAddress = context.pluginInstance.config.requestAddress
if (!requestAddress) {
context.Logger.error('[ExamplePlugin] Request address not set')
return
}
const mediaItem = await mediaProgress.getMediaItem()
if (!mediaItem) {
context.Logger.error(`[ExamplePlugin] Media item not found for mediaProgress ${mediaProgress.id}`)
} else {
const mediaProgressDuration = mediaProgress.duration
const progressPercent = mediaProgressDuration > 0 ? (mediaProgress.currentTime / mediaProgressDuration) * 100 : 0
context.Logger.info(`[ExamplePlugin] Media progress update for "${mediaItem.title}" ${Math.round(progressPercent)}% (total numProgressSyncs: ${numProgressSyncs})`)
fetch(requestAddress, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: mediaItem.title,
progress: progressPercent
})
})
.then(() => {
context.Logger.info(`[ExamplePlugin] Media progress update sent for "${mediaItem.title}" ${Math.round(progressPercent)}%`)
numProgressSyncs++
sendAdminMessageToast(context, `Synced "${mediaItem.title}" (total syncs: ${numProgressSyncs})`)
})
.catch((error) => {
context.Logger.error(`[ExamplePlugin] Error sending media progress update: ${error.message}`)
})
}
}
/**
* Test socket authority
*
* @param {import('../../../server/managers/PluginManager').PluginContext} context
* @param {string} message
*/
async function sendAdminMessageToast(context, message) {
context.SocketAuthority.adminEmitter('admin_message', message)
}
/**
* Test task manager
*
* @param {import('../../../server/managers/PluginManager').PluginContext} context
*/
async function createTask(context) {
const task = context.TaskManager.createAndAddTask('example_action', { text: 'Example Task' }, { text: 'This is an example task' }, true)
const pluginConfigEnabled = !!context.pluginInstance.config.enable
setTimeout(() => {
task.setFinished({ text: `Plugin is ${pluginConfigEnabled ? 'enabled' : 'disabled'}` })
context.TaskManager.taskFinished(task)
}, 5000)
}

View File

@@ -0,0 +1,53 @@
{
"id": "e6205690-916c-4add-9a2b-2548266996ef",
"name": "Example",
"version": "1.0.0",
"owner": "advplyr",
"repositoryUrl": "https://github.com/example/example-plugin",
"documentationUrl": "https://example.com",
"description": "This is an example plugin",
"descriptionKey": "ExamplePluginDescription",
"extensions": [
{
"target": "item.detail.actions",
"name": "itemActionExample",
"label": "Item Example Action",
"labelKey": "ItemExampleAction"
}
],
"config": {
"description": "This is a description on how to configure the plugin",
"descriptionKey": "ExamplePluginConfigurationDescription",
"formFields": [
{
"name": "requestAddress",
"label": "Request Address",
"labelKey": "LabelRequestAddress",
"type": "text"
},
{
"name": "enable",
"label": "Enable",
"labelKey": "LabelEnable",
"type": "checkbox"
}
]
},
"localization": {
"de": {
"ExamplePluginDescription": "Dies ist ein Beispiel-Plugin",
"ItemExampleAction": "Item Example Action",
"LabelEnable": "Enable",
"ExamplePluginConfigurationDescription": "This is a description on how to configure the plugin",
"LabelRequestAddress": "Request Address"
}
},
"releases": [
{
"version": "1.0.0",
"changelog": "Initial release",
"timestamp": "2022-01-01T00:00:00Z",
"downloadUrl": ""
}
]
}

View File

@@ -0,0 +1,36 @@
/**
* Called on initialization of the plugin
*
* @param {import('../../../server/managers/PluginManager').PluginContext} context
*/
module.exports.init = async (context) => {
context.Logger.info('[TemplatePlugin] plugin initialized')
// Can be used to initialize plugin config and/or setup Database hooks
}
/**
* Called when an extension action is triggered
*
* @param {import('../../../server/managers/PluginManager').PluginContext} context
* @param {string} actionName
* @param {string} target
* @param {Object} data
* @returns {Promise<boolean|{error: string}>}
*/
module.exports.onAction = async (context, actionName, target, data) => {
context.Logger.info('[TemplatePlugin] plugin onAction', actionName, target, data)
return true
}
/**
* Called when the plugin config page is saved
*
* @param {import('../../../server/managers/PluginManager').PluginContext} context
* @param {Object} config
* @returns {Promise<boolean|{error: string}>}
*/
module.exports.onConfigSave = async (context, config) => {
context.Logger.info('[TemplatePlugin] plugin onConfigSave', config)
// Maintener is responsible for validating and saving the config to their `pluginInstance`
return true
}

View File

@@ -0,0 +1,20 @@
{
"id": "e6205690-916c-4add-9a2b-2548266996eg",
"name": "Template",
"version": "1.0.0",
"owner": "advplyr",
"repositoryUrl": "https://github.com/advplr/abs-report-for-review-plugin",
"documentationUrl": "https://audiobookshelf.org/guides",
"description": "This is a minimal template for an abs plugin",
"extensions": [],
"config": {},
"localization": {},
"releases": [
{
"version": "1.0.0",
"changelog": "Initial release",
"timestamp": "2022-01-01T00:00:00Z",
"downloadUrl": ""
}
]
}

View File

View File

@@ -0,0 +1,68 @@
const chai = require('chai')
const sinon = require('sinon')
const { expect } = chai
const { DataTypes } = require('sequelize')
const { up, down } = require('../../../server/migrations/v2.17.6-share-add-isdownloadable')
describe('Migration v2.17.6-share-add-isDownloadable', () => {
let queryInterface, logger
beforeEach(() => {
queryInterface = {
addColumn: sinon.stub().resolves(),
removeColumn: sinon.stub().resolves(),
tableExists: sinon.stub().resolves(true),
describeTable: sinon.stub().resolves({ isDownloadable: undefined }),
sequelize: {
Sequelize: {
DataTypes: {
BOOLEAN: DataTypes.BOOLEAN
}
}
}
}
logger = {
info: sinon.stub(),
error: sinon.stub()
}
})
describe('up', () => {
it('should add the isDownloadable column to mediaItemShares table', async () => {
await up({ context: { queryInterface, logger } })
expect(queryInterface.addColumn.calledOnce).to.be.true
expect(
queryInterface.addColumn.calledWith('mediaItemShares', 'isDownloadable', {
type: DataTypes.BOOLEAN,
defaultValue: false,
allowNull: false
})
).to.be.true
expect(logger.info.calledWith('[2.17.6 migration] UPGRADE BEGIN: 2.17.6-share-add-isdownloadable')).to.be.true
expect(logger.info.calledWith('[2.17.6 migration] Adding isDownloadable column to mediaItemShares table')).to.be.true
expect(logger.info.calledWith('[2.17.6 migration] Added isDownloadable column to mediaItemShares table')).to.be.true
expect(logger.info.calledWith('[2.17.6 migration] UPGRADE END: 2.17.6-share-add-isdownloadable')).to.be.true
})
})
describe('down', () => {
it('should remove the isDownloadable column from mediaItemShares table', async () => {
queryInterface.describeTable.resolves({ isDownloadable: true })
await down({ context: { queryInterface, logger } })
expect(queryInterface.removeColumn.calledOnce).to.be.true
expect(queryInterface.removeColumn.calledWith('mediaItemShares', 'isDownloadable')).to.be.true
expect(logger.info.calledWith('[2.17.6 migration] DOWNGRADE BEGIN: 2.17.6-share-add-isdownloadable')).to.be.true
expect(logger.info.calledWith('[2.17.6 migration] Removing isDownloadable column from mediaItemShares table')).to.be.true
expect(logger.info.calledWith('[2.17.6 migration] Removed isDownloadable column from mediaItemShares table')).to.be.true
expect(logger.info.calledWith('[2.17.6 migration] DOWNGRADE END: 2.17.6-share-add-isdownloadable')).to.be.true
})
})
})