mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-07-15 03:45:03 +02:00
Compare commits
168 commits
Author | SHA1 | Date | |
---|---|---|---|
|
e25e2b238f | ||
|
99110f587a | ||
|
b553e959e2 | ||
|
f7b94a4b6d | ||
|
264ae928a9 | ||
|
f5248a9f00 | ||
|
3473ff594a | ||
|
20bb6e13b5 | ||
|
a05d32b1d7 | ||
|
c6b3521cb6 | ||
|
2444504c6a | ||
|
d38532c07a | ||
|
4f7831611f | ||
|
d09db19cd5 | ||
|
030e43f382 | ||
|
f081a7fdc1 | ||
|
f0d5f46199 | ||
|
0b8f6db45e | ||
|
806c0a2991 | ||
|
7d6d3e6687 | ||
|
ad07ed7e25 | ||
|
d3402e30c2 | ||
|
25fe4dee3a | ||
|
3c21c82ce1 | ||
|
3c8876a37d | ||
|
fba70c9831 | ||
|
27e40d16fd | ||
|
448cbf8530 | ||
|
f1153f9da5 | ||
|
d09a21d922 | ||
|
62afa3c3ee | ||
|
85446be0e5 | ||
|
018ca8e7ee | ||
|
f02453ac92 | ||
|
84b77f4c7f | ||
|
d41276ba8c | ||
|
576d7dc024 | ||
|
6d2b1df560 | ||
|
8255e4308c | ||
|
794adf0292 | ||
|
f2e0b9762c | ||
|
7d0def0edb | ||
|
0653572396 | ||
|
d9a3750667 | ||
|
9c0c7b6b08 | ||
|
df1391d93f | ||
|
8775e55762 | ||
|
d0d152c20d | ||
|
4ff7355262 | ||
|
6cc7a44a22 | ||
|
ad092ef8f8 | ||
|
4102ed8be4 | ||
|
691f291843 | ||
|
ac381854e5 | ||
|
9c8900560c | ||
|
d9cfcc86e7 | ||
|
ce803dd6de | ||
|
97afd22f81 | ||
|
e24eaab3f1 | ||
|
e201247d69 | ||
|
a24dae5262 | ||
|
e59babdf24 | ||
|
8dbe1e4e5d | ||
|
cdc37ddb0f | ||
|
f127a7beb5 | ||
|
df60aeb456 | ||
|
30c327d92a | ||
|
596bddf791 | ||
|
44ff90a6f2 | ||
|
293851d931 | ||
|
8b995a179d | ||
|
4d32a22de9 | ||
|
af1ff12dbb | ||
|
d96ed01ce4 | ||
|
7610e97f0f | ||
|
4f5123e842 | ||
|
d102065d02 | ||
|
34315d4c10 | ||
|
276a179446 | ||
|
4462d32e98 | ||
|
9722674072 | ||
|
35bb77c9c2 | ||
|
cf6f49ce75 | ||
|
d614373c64 | ||
|
b9969c78a6 | ||
|
fbf482d6b6 | ||
|
dd74d0a726 | ||
|
b13b80e011 | ||
|
e384863148 | ||
|
d21fe49ce2 | ||
|
a992400d6a | ||
|
108b2a60f5 | ||
|
af684e6a69 | ||
|
5336d0525e | ||
|
bb4eec9355 | ||
|
28404f37b8 | ||
|
7b92c15a46 | ||
|
c150ed4e98 | ||
|
cb7632b216 | ||
|
b8849677de | ||
|
9bf8d7de11 | ||
|
6634ce8fd4 | ||
|
9d4303ef7b | ||
|
1f7be58124 | ||
|
6b8b27b04f | ||
|
ba4061e5a4 | ||
|
693dc00fa3 | ||
|
f3f5f3b9bd | ||
|
b515c6c746 | ||
|
35e196238a | ||
|
2dc93258f1 | ||
|
5123f7d240 | ||
|
06d3bd76a8 | ||
|
52196afd99 | ||
|
3e44ee6f50 | ||
|
9841826e10 | ||
|
def93d18ec | ||
|
387a3d05b4 | ||
|
398d04fc08 | ||
|
c5e5e516af | ||
|
1c6f99b876 | ||
|
d0af82e71a | ||
|
76e7616439 | ||
|
fe99a269bc | ||
|
5315f65023 | ||
|
c2809808c3 | ||
|
204ac4f204 | ||
|
accd5d1096 | ||
|
5025c6a3ea | ||
|
6d0d1415e4 | ||
|
514f5c2409 | ||
|
2cc58b2c8a | ||
|
777a055fcd | ||
|
b45085d2d6 | ||
|
22f6e86a12 | ||
|
dc6783ea76 | ||
|
a6f10ca48e | ||
|
aac01d6d9a | ||
|
a617994207 | ||
|
7a33a412fc | ||
|
0135b3560c | ||
|
6968a5c02a | ||
|
5e2bb0b12c | ||
|
7122756e58 | ||
|
8ecc912c2d | ||
|
c8cea4e6af | ||
|
0c5d05d319 | ||
|
4a3eb7727b | ||
|
81640464ba | ||
|
eda7036f70 | ||
|
e669a8d378 | ||
|
8e01859075 | ||
|
f0525d4f0d | ||
|
84c9c6cb50 | ||
|
346df3680c | ||
|
6aa7c8a3d8 | ||
|
704c6f7bde | ||
|
f01055f6e6 | ||
|
759c58d3f7 | ||
|
357176b301 | ||
|
9bb4dc3ab0 | ||
|
709c33f27a | ||
|
4d846e225a | ||
|
5dc6d613bd | ||
|
63ccdb68f0 | ||
|
424ef1aec3 | ||
|
b6995ba5d1 | ||
|
9968743a93 |
119 changed files with 4099 additions and 1226 deletions
|
@ -57,7 +57,7 @@ WORKDIR /app
|
||||||
# Copy compiled frontend and server from build stages
|
# Copy compiled frontend and server from build stages
|
||||||
COPY --from=build-client /client/dist /app/client/dist
|
COPY --from=build-client /client/dist /app/client/dist
|
||||||
COPY --from=build-server /server /app
|
COPY --from=build-server /server /app
|
||||||
COPY --from=build-server /usr/local/lib/nusqlite3 /usr/local/lib/nusqlite3
|
COPY --from=build-server ${NUSQLITE3_PATH} ${NUSQLITE3_PATH}
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
|
|
|
@ -70,6 +70,11 @@ export default {
|
||||||
title: this.$strings.HeaderUsers,
|
title: this.$strings.HeaderUsers,
|
||||||
path: '/config/users'
|
path: '/config/users'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'config-api-keys',
|
||||||
|
title: this.$strings.HeaderApiKeys,
|
||||||
|
path: '/config/api-keys'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'config-sessions',
|
id: 'config-sessions',
|
||||||
title: this.$strings.HeaderListeningSessions,
|
title: this.$strings.HeaderListeningSessions,
|
||||||
|
|
|
@ -778,10 +778,6 @@ export default {
|
||||||
windowResize() {
|
windowResize() {
|
||||||
this.executeRebuild()
|
this.executeRebuild()
|
||||||
},
|
},
|
||||||
socketInit() {
|
|
||||||
// Server settings are set on socket init
|
|
||||||
this.executeRebuild()
|
|
||||||
},
|
|
||||||
initListeners() {
|
initListeners() {
|
||||||
window.addEventListener('resize', this.windowResize)
|
window.addEventListener('resize', this.windowResize)
|
||||||
|
|
||||||
|
@ -794,7 +790,6 @@ export default {
|
||||||
})
|
})
|
||||||
|
|
||||||
this.$eventBus.$on('bookshelf_clear_selection', this.clearSelectedEntities)
|
this.$eventBus.$on('bookshelf_clear_selection', this.clearSelectedEntities)
|
||||||
this.$eventBus.$on('socket_init', this.socketInit)
|
|
||||||
this.$eventBus.$on('user-settings', this.settingsUpdated)
|
this.$eventBus.$on('user-settings', this.settingsUpdated)
|
||||||
|
|
||||||
if (this.$root.socket) {
|
if (this.$root.socket) {
|
||||||
|
@ -826,7 +821,6 @@ export default {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.$eventBus.$off('bookshelf_clear_selection', this.clearSelectedEntities)
|
this.$eventBus.$off('bookshelf_clear_selection', this.clearSelectedEntities)
|
||||||
this.$eventBus.$off('socket_init', this.socketInit)
|
|
||||||
this.$eventBus.$off('user-settings', this.settingsUpdated)
|
this.$eventBus.$off('user-settings', this.settingsUpdated)
|
||||||
|
|
||||||
if (this.$root.socket) {
|
if (this.$root.socket) {
|
||||||
|
|
|
@ -71,9 +71,6 @@ export default {
|
||||||
coverHeight() {
|
coverHeight() {
|
||||||
return this.cardHeight
|
return this.cardHeight
|
||||||
},
|
},
|
||||||
userToken() {
|
|
||||||
return this.store.getters['user/getToken']
|
|
||||||
},
|
|
||||||
_author() {
|
_author() {
|
||||||
return this.author || {}
|
return this.author || {}
|
||||||
},
|
},
|
||||||
|
|
|
@ -198,7 +198,7 @@ export default {
|
||||||
return this.store.getters['user/getSizeMultiplier']
|
return this.store.getters['user/getSizeMultiplier']
|
||||||
},
|
},
|
||||||
dateFormat() {
|
dateFormat() {
|
||||||
return this.store.state.serverSettings.dateFormat
|
return this.store.getters['getServerSetting']('dateFormat')
|
||||||
},
|
},
|
||||||
_libraryItem() {
|
_libraryItem() {
|
||||||
return this.libraryItem || {}
|
return this.libraryItem || {}
|
||||||
|
|
|
@ -71,7 +71,7 @@ export default {
|
||||||
return this.height * this.sizeMultiplier
|
return this.height * this.sizeMultiplier
|
||||||
},
|
},
|
||||||
dateFormat() {
|
dateFormat() {
|
||||||
return this.store.state.serverSettings.dateFormat
|
return this.store.getters['getServerSetting']('dateFormat')
|
||||||
},
|
},
|
||||||
labelFontSize() {
|
labelFontSize() {
|
||||||
if (this.width < 160) return 0.75
|
if (this.width < 160) return 0.75
|
||||||
|
|
|
@ -94,6 +94,9 @@ export default {
|
||||||
userIsAdminOrUp() {
|
userIsAdminOrUp() {
|
||||||
return this.$store.getters['user/getIsAdminOrUp']
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
},
|
},
|
||||||
|
userCanAccessExplicitContent() {
|
||||||
|
return this.$store.getters['user/getUserCanAccessExplicitContent']
|
||||||
|
},
|
||||||
libraryMediaType() {
|
libraryMediaType() {
|
||||||
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||||
},
|
},
|
||||||
|
@ -239,6 +242,15 @@ export default {
|
||||||
sublist: false
|
sublist: false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if (this.userCanAccessExplicitContent) {
|
||||||
|
items.push({
|
||||||
|
text: this.$strings.LabelExplicit,
|
||||||
|
value: 'explicit',
|
||||||
|
sublist: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (this.userIsAdminOrUp) {
|
if (this.userIsAdminOrUp) {
|
||||||
items.push({
|
items.push({
|
||||||
text: this.$strings.LabelShareOpen,
|
text: this.$strings.LabelShareOpen,
|
||||||
|
@ -249,7 +261,7 @@ export default {
|
||||||
return items
|
return items
|
||||||
},
|
},
|
||||||
podcastItems() {
|
podcastItems() {
|
||||||
return [
|
const items = [
|
||||||
{
|
{
|
||||||
text: this.$strings.LabelAll,
|
text: this.$strings.LabelAll,
|
||||||
value: 'all'
|
value: 'all'
|
||||||
|
@ -283,6 +295,16 @@ export default {
|
||||||
sublist: false
|
sublist: false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if (this.userCanAccessExplicitContent) {
|
||||||
|
items.push({
|
||||||
|
text: this.$strings.LabelExplicit,
|
||||||
|
value: 'explicit',
|
||||||
|
sublist: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
},
|
},
|
||||||
selectItems() {
|
selectItems() {
|
||||||
if (this.isSeries) return this.seriesItems
|
if (this.isSeries) return this.seriesItems
|
||||||
|
|
|
@ -39,9 +39,6 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
userToken() {
|
|
||||||
return this.$store.getters['user/getToken']
|
|
||||||
},
|
|
||||||
_author() {
|
_author() {
|
||||||
return this.author || {}
|
return this.author || {}
|
||||||
},
|
},
|
||||||
|
|
|
@ -309,9 +309,9 @@ export default {
|
||||||
} else {
|
} else {
|
||||||
console.log('Account updated', data.user)
|
console.log('Account updated', data.user)
|
||||||
|
|
||||||
if (data.user.id === this.user.id && data.user.token !== this.user.token) {
|
if (data.user.id === this.user.id && data.user.accessToken !== this.user.accessToken) {
|
||||||
console.log('Current user token was updated')
|
console.log('Current user access token was updated')
|
||||||
this.$store.commit('user/setUserToken', data.user.token)
|
this.$store.commit('user/setAccessToken', data.user.accessToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.$toast.success(this.$strings.ToastAccountUpdateSuccess)
|
this.$toast.success(this.$strings.ToastAccountUpdateSuccess)
|
||||||
|
@ -351,9 +351,6 @@ export default {
|
||||||
this.$toast.error(errMsg || 'Failed to create account')
|
this.$toast.error(errMsg || 'Failed to create account')
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
toggleActive() {
|
|
||||||
this.newUser.isActive = !this.newUser.isActive
|
|
||||||
},
|
|
||||||
userTypeUpdated(type) {
|
userTypeUpdated(type) {
|
||||||
this.newUser.permissions = {
|
this.newUser.permissions = {
|
||||||
download: type !== 'guest',
|
download: type !== 'guest',
|
||||||
|
|
60
client/components/modals/ApiKeyCreatedModal.vue
Normal file
60
client/components/modals/ApiKeyCreatedModal.vue
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
<template>
|
||||||
|
<modals-modal ref="modal" v-model="show" name="api-key-created" :width="800" :height="'unset'" persistent>
|
||||||
|
<template #outer>
|
||||||
|
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||||
|
<p class="text-3xl text-white truncate">{{ title }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<form @submit.prevent="submitForm">
|
||||||
|
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 overflow-y-auto overflow-x-hidden" style="min-height: 200px; max-height: 80vh">
|
||||||
|
<div class="w-full p-8">
|
||||||
|
<p class="text-lg text-white mb-4">{{ $getString('LabelApiKeyCreated', [apiKeyName]) }}</p>
|
||||||
|
|
||||||
|
<p class="text-lg text-white mb-4">{{ $strings.LabelApiKeyCreatedDescription }}</p>
|
||||||
|
|
||||||
|
<ui-text-input label="API Key" :value="apiKeyKey" readonly show-copy />
|
||||||
|
|
||||||
|
<div class="flex justify-end mt-4">
|
||||||
|
<ui-btn color="bg-primary" @click="show = false">{{ $strings.ButtonClose }}</ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</modals-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: Boolean,
|
||||||
|
apiKey: {
|
||||||
|
type: Object,
|
||||||
|
default: () => null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
return this.$strings.HeaderNewApiKey
|
||||||
|
},
|
||||||
|
apiKeyName() {
|
||||||
|
return this.apiKey?.name || ''
|
||||||
|
},
|
||||||
|
apiKeyKey() {
|
||||||
|
return this.apiKey?.apiKey || ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
198
client/components/modals/ApiKeyModal.vue
Normal file
198
client/components/modals/ApiKeyModal.vue
Normal file
|
@ -0,0 +1,198 @@
|
||||||
|
<template>
|
||||||
|
<modals-modal ref="modal" v-model="show" name="api-key" :width="800" :height="'unset'" :processing="processing">
|
||||||
|
<template #outer>
|
||||||
|
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||||
|
<p class="text-3xl text-white truncate">{{ title }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<form @submit.prevent="submitForm">
|
||||||
|
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 overflow-y-auto overflow-x-hidden" style="min-height: 400px; max-height: 80vh">
|
||||||
|
<div class="w-full p-8">
|
||||||
|
<div class="flex py-2">
|
||||||
|
<div class="w-1/2 px-2">
|
||||||
|
<ui-text-input-with-label v-model.trim="newApiKey.name" :readonly="!isNew" :label="$strings.LabelName" />
|
||||||
|
</div>
|
||||||
|
<div v-if="isNew" class="w-1/2 px-2">
|
||||||
|
<ui-text-input-with-label v-model.trim="newApiKey.expiresIn" :label="$strings.LabelExpiresInSeconds" type="number" :min="0" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center pt-4 pb-2 gap-2">
|
||||||
|
<div class="flex items-center px-2">
|
||||||
|
<p class="px-3 font-semibold" id="user-enabled-toggle">{{ $strings.LabelEnable }}</p>
|
||||||
|
<ui-toggle-switch :disabled="isExpired && !apiKey.isActive" labeledBy="user-enabled-toggle" v-model="newApiKey.isActive" />
|
||||||
|
</div>
|
||||||
|
<div v-if="isExpired" class="px-2">
|
||||||
|
<p class="text-sm text-error">{{ $strings.LabelExpired }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full border-t border-b border-black-200 py-4 px-3 mt-4">
|
||||||
|
<p class="text-lg mb-2 font-semibold">{{ $strings.LabelApiKeyUser }}</p>
|
||||||
|
<p class="text-sm mb-2 text-gray-400">{{ $strings.LabelApiKeyUserDescription }}</p>
|
||||||
|
<ui-select-input v-model="newApiKey.userId" :disabled="isExpired && !apiKey.isActive" :items="userItems" :placeholder="$strings.LabelSelectUser" :label="$strings.LabelApiKeyUser" label-hidden />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex pt-4 px-2">
|
||||||
|
<div class="grow" />
|
||||||
|
<ui-btn color="bg-success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</modals-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: Boolean,
|
||||||
|
apiKey: {
|
||||||
|
type: Object,
|
||||||
|
default: () => null
|
||||||
|
},
|
||||||
|
users: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
processing: false,
|
||||||
|
newApiKey: {},
|
||||||
|
isNew: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
show: {
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
return this.isNew ? this.$strings.HeaderNewApiKey : this.$strings.HeaderUpdateApiKey
|
||||||
|
},
|
||||||
|
userItems() {
|
||||||
|
return this.users
|
||||||
|
.filter((u) => {
|
||||||
|
// Only show root user if the current user is root
|
||||||
|
return u.type !== 'root' || this.$store.getters['user/getIsRoot']
|
||||||
|
})
|
||||||
|
.map((u) => ({ text: u.username, value: u.id, subtext: u.type }))
|
||||||
|
},
|
||||||
|
isExpired() {
|
||||||
|
if (!this.apiKey || !this.apiKey.expiresAt) return false
|
||||||
|
|
||||||
|
return new Date(this.apiKey.expiresAt).getTime() < Date.now()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
submitForm() {
|
||||||
|
if (!this.newApiKey.name) {
|
||||||
|
this.$toast.error(this.$strings.ToastNameRequired)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.newApiKey.userId) {
|
||||||
|
this.$toast.error(this.$strings.ToastNewApiKeyUserError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isNew) {
|
||||||
|
this.submitCreateApiKey()
|
||||||
|
} else {
|
||||||
|
this.submitUpdateApiKey()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
submitUpdateApiKey() {
|
||||||
|
if (this.newApiKey.isActive === this.apiKey.isActive && this.newApiKey.userId === this.apiKey.userId) {
|
||||||
|
this.$toast.info(this.$strings.ToastNoUpdatesNecessary)
|
||||||
|
this.show = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = {
|
||||||
|
isActive: this.newApiKey.isActive,
|
||||||
|
userId: this.newApiKey.userId
|
||||||
|
}
|
||||||
|
|
||||||
|
this.processing = true
|
||||||
|
this.$axios
|
||||||
|
.$patch(`/api/api-keys/${this.apiKey.id}`, apiKey)
|
||||||
|
.then((data) => {
|
||||||
|
this.processing = false
|
||||||
|
if (data.error) {
|
||||||
|
this.$toast.error(`${this.$strings.ToastFailedToUpdate}: ${data.error}`)
|
||||||
|
} else {
|
||||||
|
this.show = false
|
||||||
|
this.$emit('updated', data.apiKey)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.processing = false
|
||||||
|
console.error('Failed to update apiKey', error)
|
||||||
|
var errMsg = error.response ? error.response.data || '' : ''
|
||||||
|
this.$toast.error(errMsg || this.$strings.ToastFailedToUpdate)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
submitCreateApiKey() {
|
||||||
|
const apiKey = { ...this.newApiKey }
|
||||||
|
|
||||||
|
if (this.newApiKey.expiresIn) {
|
||||||
|
apiKey.expiresIn = parseInt(this.newApiKey.expiresIn)
|
||||||
|
} else {
|
||||||
|
delete apiKey.expiresIn
|
||||||
|
}
|
||||||
|
|
||||||
|
this.processing = true
|
||||||
|
this.$axios
|
||||||
|
.$post('/api/api-keys', apiKey)
|
||||||
|
.then((data) => {
|
||||||
|
this.processing = false
|
||||||
|
if (data.error) {
|
||||||
|
this.$toast.error(this.$strings.ToastFailedToCreate + ': ' + data.error)
|
||||||
|
} else {
|
||||||
|
this.show = false
|
||||||
|
this.$emit('created', data.apiKey)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.processing = false
|
||||||
|
console.error('Failed to create apiKey', error)
|
||||||
|
var errMsg = error.response ? error.response.data || '' : ''
|
||||||
|
this.$toast.error(errMsg || this.$strings.ToastFailedToCreate)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
this.isNew = !this.apiKey
|
||||||
|
|
||||||
|
if (this.apiKey) {
|
||||||
|
this.newApiKey = {
|
||||||
|
name: this.apiKey.name,
|
||||||
|
isActive: this.apiKey.isActive,
|
||||||
|
userId: this.apiKey.userId
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.newApiKey = {
|
||||||
|
name: null,
|
||||||
|
expiresIn: null,
|
||||||
|
isActive: true,
|
||||||
|
userId: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -79,10 +79,10 @@ export default {
|
||||||
return !this.bookmarks.find((bm) => Math.abs(this.currentTime - bm.time) < 1)
|
return !this.bookmarks.find((bm) => Math.abs(this.currentTime - bm.time) < 1)
|
||||||
},
|
},
|
||||||
dateFormat() {
|
dateFormat() {
|
||||||
return this.$store.state.serverSettings.dateFormat
|
return this.$store.getters['getServerSetting']('dateFormat')
|
||||||
},
|
},
|
||||||
timeFormat() {
|
timeFormat() {
|
||||||
return this.$store.state.serverSettings.timeFormat
|
return this.$store.getters['getServerSetting']('timeFormat')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|
|
@ -159,10 +159,10 @@ export default {
|
||||||
return 'Unknown'
|
return 'Unknown'
|
||||||
},
|
},
|
||||||
dateFormat() {
|
dateFormat() {
|
||||||
return this.$store.state.serverSettings.dateFormat
|
return this.$store.getters['getServerSetting']('dateFormat')
|
||||||
},
|
},
|
||||||
timeFormat() {
|
timeFormat() {
|
||||||
return this.$store.state.serverSettings.timeFormat
|
return this.$store.getters['getServerSetting']('timeFormat')
|
||||||
},
|
},
|
||||||
isOpenSession() {
|
isOpenSession() {
|
||||||
return !!this._session.open
|
return !!this._session.open
|
||||||
|
|
|
@ -23,7 +23,7 @@ export default {
|
||||||
processing: Boolean,
|
processing: Boolean,
|
||||||
persistent: {
|
persistent: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true
|
default: false
|
||||||
},
|
},
|
||||||
width: {
|
width: {
|
||||||
type: [String, Number],
|
type: [String, Number],
|
||||||
|
@ -99,7 +99,7 @@ export default {
|
||||||
this.preventClickoutside = false
|
this.preventClickoutside = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (this.processing && this.persistent) return
|
if (this.processing || this.persistent) return
|
||||||
if (ev.srcElement && ev.srcElement.classList.contains('modal-bg')) {
|
if (ev.srcElement && ev.srcElement.classList.contains('modal-bg')) {
|
||||||
this.show = false
|
this.show = false
|
||||||
}
|
}
|
||||||
|
|
|
@ -144,7 +144,7 @@ export default {
|
||||||
expirationDateString() {
|
expirationDateString() {
|
||||||
if (!this.expireDurationSeconds) return this.$strings.LabelPermanent
|
if (!this.expireDurationSeconds) return this.$strings.LabelPermanent
|
||||||
const dateMs = Date.now() + this.expireDurationSeconds * 1000
|
const dateMs = Date.now() + this.expireDurationSeconds * 1000
|
||||||
return this.$formatDatetime(dateMs, this.$store.state.serverSettings.dateFormat, this.$store.state.serverSettings.timeFormat)
|
return this.$formatDatetime(dateMs, this.$store.getters['getServerSetting']('dateFormat'), this.$store.getters['getServerSetting']('timeFormat'))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|
|
@ -40,7 +40,7 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
dateFormat() {
|
dateFormat() {
|
||||||
return this.$store.state.serverSettings.dateFormat
|
return this.$store.getters['getServerSetting']('dateFormat')
|
||||||
},
|
},
|
||||||
releasesToShow() {
|
releasesToShow() {
|
||||||
return this.versionData?.releasesToShow || []
|
return this.versionData?.releasesToShow || []
|
||||||
|
|
|
@ -29,9 +29,6 @@ export default {
|
||||||
media() {
|
media() {
|
||||||
return this.libraryItem.media || {}
|
return this.libraryItem.media || {}
|
||||||
},
|
},
|
||||||
userToken() {
|
|
||||||
return this.$store.getters['user/getToken']
|
|
||||||
},
|
|
||||||
userCanUpdate() {
|
userCanUpdate() {
|
||||||
return this.$store.getters['user/getUserCanUpdate']
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
},
|
},
|
||||||
|
|
|
@ -35,7 +35,14 @@
|
||||||
<widgets-podcast-type-indicator :type="episode.episodeType" />
|
<widgets-podcast-type-indicator :type="episode.episodeType" />
|
||||||
</div>
|
</div>
|
||||||
<p v-if="episode.subtitle" class="mb-1 text-sm text-gray-300 line-clamp-2">{{ episode.subtitle }}</p>
|
<p v-if="episode.subtitle" class="mb-1 text-sm text-gray-300 line-clamp-2">{{ episode.subtitle }}</p>
|
||||||
<p class="text-xs text-gray-300">Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}</p>
|
<div class="flex items-center space-x-2">
|
||||||
|
<!-- published -->
|
||||||
|
<p class="text-xs text-gray-300 w-40">Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}</p>
|
||||||
|
<!-- duration -->
|
||||||
|
<p v-if="episode.durationSeconds && !isNaN(episode.durationSeconds)" class="text-xs text-gray-300 min-w-28">{{ $strings.LabelDuration }}: {{ $elapsedPretty(episode.durationSeconds) }}</p>
|
||||||
|
<!-- size -->
|
||||||
|
<p v-if="episode.enclosure?.length && !isNaN(episode.enclosure.length) && Number(episode.enclosure.length) > 0" class="text-xs text-gray-300">{{ $strings.LabelSize }}: {{ $bytesPretty(Number(episode.enclosure.length)) }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -11,7 +11,7 @@
|
||||||
{{ $getString('MessageConfirmRemoveEpisode', [episodeTitle]) }}
|
{{ $getString('MessageConfirmRemoveEpisode', [episodeTitle]) }}
|
||||||
</p>
|
</p>
|
||||||
<p v-else class="text-lg text-gray-200 mb-4">{{ $getString('MessageConfirmRemoveEpisodes', [episodes.length]) }}</p>
|
<p v-else class="text-lg text-gray-200 mb-4">{{ $getString('MessageConfirmRemoveEpisodes', [episodes.length]) }}</p>
|
||||||
<p class="text-xs font-semibold text-warning/90">Note: This does not delete the audio file unless toggling "Hard delete file"</p>
|
<p class="text-xs font-semibold text-warning/90">{{ $strings.MessageConfirmRemoveEpisodeNote }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between items-center pt-4">
|
<div class="flex justify-between items-center pt-4">
|
||||||
<ui-checkbox v-model="hardDeleteFile" :label="$strings.LabelHardDeleteFile" check-color="error" checkbox-bg="bg" small label-class="text-base text-gray-200 pl-3" />
|
<ui-checkbox v-model="hardDeleteFile" :label="$strings.LabelHardDeleteFile" check-color="error" checkbox-bg="bg" small label-class="text-base text-gray-200 pl-3" />
|
||||||
|
|
|
@ -16,7 +16,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p dir="auto" class="text-lg font-semibold mb-6">{{ title }}</p>
|
<p dir="auto" class="text-lg font-semibold mb-6">{{ title }}</p>
|
||||||
<div v-if="description" dir="auto" class="default-style less-spacing" v-html="description" />
|
<div v-if="description" dir="auto" class="default-style less-spacing" @click="handleDescriptionClick" v-html="description" />
|
||||||
<p v-else class="mb-2">{{ $strings.MessageNoDescription }}</p>
|
<p v-else class="mb-2">{{ $strings.MessageNoDescription }}</p>
|
||||||
|
|
||||||
<div class="w-full h-px bg-white/5 my-4" />
|
<div class="w-full h-px bg-white/5 my-4" />
|
||||||
|
@ -34,6 +34,12 @@
|
||||||
{{ audioFileSize }}
|
{{ audioFileSize }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="grow">
|
||||||
|
<p class="font-semibold text-xs mb-1">{{ $strings.LabelDuration }}</p>
|
||||||
|
<p class="mb-2 text-xs">
|
||||||
|
{{ audioFileDuration }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</modals-modal>
|
</modals-modal>
|
||||||
|
@ -68,7 +74,7 @@ export default {
|
||||||
return this.episode.title || 'No Episode Title'
|
return this.episode.title || 'No Episode Title'
|
||||||
},
|
},
|
||||||
description() {
|
description() {
|
||||||
return this.episode.description || ''
|
return this.parseDescription(this.episode.description || '')
|
||||||
},
|
},
|
||||||
media() {
|
media() {
|
||||||
return this.libraryItem?.media || {}
|
return this.libraryItem?.media || {}
|
||||||
|
@ -90,11 +96,49 @@ export default {
|
||||||
|
|
||||||
return this.$bytesPretty(size)
|
return this.$bytesPretty(size)
|
||||||
},
|
},
|
||||||
|
audioFileDuration() {
|
||||||
|
const duration = this.episode.duration || 0
|
||||||
|
return this.$elapsedPretty(duration)
|
||||||
|
},
|
||||||
bookCoverAspectRatio() {
|
bookCoverAspectRatio() {
|
||||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {},
|
methods: {
|
||||||
|
handleDescriptionClick(e) {
|
||||||
|
if (e.target.matches('span.time-marker')) {
|
||||||
|
const time = parseInt(e.target.dataset.time)
|
||||||
|
if (!isNaN(time)) {
|
||||||
|
this.$eventBus.$emit('play-item', {
|
||||||
|
episodeId: this.episodeId,
|
||||||
|
libraryItemId: this.libraryItem.id,
|
||||||
|
startTime: time
|
||||||
|
})
|
||||||
|
}
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
parseDescription(description) {
|
||||||
|
const timeMarkerLinkRegex = /<a href="#([^"]*?\b\d{1,2}:\d{1,2}(?::\d{1,2})?)">(.*?)<\/a>/g
|
||||||
|
const timeMarkerRegex = /\b\d{1,2}:\d{1,2}(?::\d{1,2})?\b/g
|
||||||
|
|
||||||
|
function convertToSeconds(time) {
|
||||||
|
const timeParts = time.split(':').map(Number)
|
||||||
|
return timeParts.reduce((acc, part, index) => acc * 60 + part, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return description
|
||||||
|
.replace(timeMarkerLinkRegex, (match, href, displayTime) => {
|
||||||
|
const time = displayTime.match(timeMarkerRegex)[0]
|
||||||
|
const seekTimeInSeconds = convertToSeconds(time)
|
||||||
|
return `<span class="time-marker cursor-pointer text-blue-400 hover:text-blue-300" data-time="${seekTimeInSeconds}">${displayTime}</span>`
|
||||||
|
})
|
||||||
|
.replace(timeMarkerRegex, (match) => {
|
||||||
|
const seekTimeInSeconds = convertToSeconds(match)
|
||||||
|
return `<span class="time-marker cursor-pointer text-blue-400 hover:text-blue-300" data-time="${seekTimeInSeconds}">${match}</span>`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -129,9 +129,6 @@ export default {
|
||||||
return `${hoursRounded}h`
|
return `${hoursRounded}h`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
token() {
|
|
||||||
return this.$store.getters['user/getToken']
|
|
||||||
},
|
|
||||||
timeRemaining() {
|
timeRemaining() {
|
||||||
if (this.useChapterTrack && this.currentChapter) {
|
if (this.useChapterTrack && this.currentChapter) {
|
||||||
var currChapTime = this.currentTime - this.currentChapter.start
|
var currChapTime = this.currentTime - this.currentChapter.start
|
||||||
|
|
|
@ -104,9 +104,6 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
userToken() {
|
|
||||||
return this.$store.getters['user/getToken']
|
|
||||||
},
|
|
||||||
libraryItemId() {
|
libraryItemId() {
|
||||||
return this.libraryItem?.id
|
return this.libraryItem?.id
|
||||||
},
|
},
|
||||||
|
@ -234,10 +231,7 @@ export default {
|
||||||
async extract() {
|
async extract() {
|
||||||
this.loading = true
|
this.loading = true
|
||||||
var buff = await this.$axios.$get(this.ebookUrl, {
|
var buff = await this.$axios.$get(this.ebookUrl, {
|
||||||
responseType: 'blob',
|
responseType: 'blob'
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${this.userToken}`
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
const archive = await Archive.open(buff)
|
const archive = await Archive.open(buff)
|
||||||
const originalFilesObject = await archive.getFilesObject()
|
const originalFilesObject = await archive.getFilesObject()
|
||||||
|
|
|
@ -57,9 +57,6 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
userToken() {
|
|
||||||
return this.$store.getters['user/getToken']
|
|
||||||
},
|
|
||||||
/** @returns {string} */
|
/** @returns {string} */
|
||||||
libraryItemId() {
|
libraryItemId() {
|
||||||
return this.libraryItem?.id
|
return this.libraryItem?.id
|
||||||
|
@ -97,9 +94,9 @@ export default {
|
||||||
},
|
},
|
||||||
ebookUrl() {
|
ebookUrl() {
|
||||||
if (this.fileId) {
|
if (this.fileId) {
|
||||||
return `${this.$config.routerBasePath}/api/items/${this.libraryItemId}/ebook/${this.fileId}`
|
return `/api/items/${this.libraryItemId}/ebook/${this.fileId}`
|
||||||
}
|
}
|
||||||
return `${this.$config.routerBasePath}/api/items/${this.libraryItemId}/ebook`
|
return `/api/items/${this.libraryItemId}/ebook`
|
||||||
},
|
},
|
||||||
themeRules() {
|
themeRules() {
|
||||||
const isDark = this.ereaderSettings.theme === 'dark'
|
const isDark = this.ereaderSettings.theme === 'dark'
|
||||||
|
@ -309,14 +306,24 @@ export default {
|
||||||
/** @type {EpubReader} */
|
/** @type {EpubReader} */
|
||||||
const reader = this
|
const reader = this
|
||||||
|
|
||||||
|
// Use axios to make request because we have token refresh logic in interceptor
|
||||||
|
const customRequest = async (url) => {
|
||||||
|
try {
|
||||||
|
return this.$axios.$get(url, {
|
||||||
|
responseType: 'arraybuffer'
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('EpubReader.initEpub customRequest failed:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** @type {ePub.Book} */
|
/** @type {ePub.Book} */
|
||||||
reader.book = new ePub(reader.ebookUrl, {
|
reader.book = new ePub(reader.ebookUrl, {
|
||||||
width: this.readerWidth,
|
width: this.readerWidth,
|
||||||
height: this.readerHeight - 50,
|
height: this.readerHeight - 50,
|
||||||
openAs: 'epub',
|
openAs: 'epub',
|
||||||
requestHeaders: {
|
requestMethod: customRequest
|
||||||
Authorization: `Bearer ${this.userToken}`
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/** @type {ePub.Rendition} */
|
/** @type {ePub.Rendition} */
|
||||||
|
@ -337,29 +344,33 @@ export default {
|
||||||
this.applyTheme()
|
this.applyTheme()
|
||||||
})
|
})
|
||||||
|
|
||||||
reader.book.ready.then(() => {
|
reader.book.ready
|
||||||
// set up event listeners
|
.then(() => {
|
||||||
reader.rendition.on('relocated', reader.relocated)
|
// set up event listeners
|
||||||
reader.rendition.on('keydown', reader.keyUp)
|
reader.rendition.on('relocated', reader.relocated)
|
||||||
|
reader.rendition.on('keydown', reader.keyUp)
|
||||||
|
|
||||||
reader.rendition.on('touchstart', (event) => {
|
reader.rendition.on('touchstart', (event) => {
|
||||||
this.$emit('touchstart', event)
|
this.$emit('touchstart', event)
|
||||||
})
|
|
||||||
reader.rendition.on('touchend', (event) => {
|
|
||||||
this.$emit('touchend', event)
|
|
||||||
})
|
|
||||||
|
|
||||||
// load ebook cfi locations
|
|
||||||
const savedLocations = this.loadLocations()
|
|
||||||
if (savedLocations) {
|
|
||||||
reader.book.locations.load(savedLocations)
|
|
||||||
} else {
|
|
||||||
reader.book.locations.generate().then(() => {
|
|
||||||
this.checkSaveLocations(reader.book.locations.save())
|
|
||||||
})
|
})
|
||||||
}
|
reader.rendition.on('touchend', (event) => {
|
||||||
this.getChapters()
|
this.$emit('touchend', event)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// load ebook cfi locations
|
||||||
|
const savedLocations = this.loadLocations()
|
||||||
|
if (savedLocations) {
|
||||||
|
reader.book.locations.load(savedLocations)
|
||||||
|
} else {
|
||||||
|
reader.book.locations.generate().then(() => {
|
||||||
|
this.checkSaveLocations(reader.book.locations.save())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
this.getChapters()
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('EpubReader.initEpub failed:', error)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
getChapters() {
|
getChapters() {
|
||||||
// Load the list of chapters in the book. See https://github.com/futurepress/epub.js/issues/759
|
// Load the list of chapters in the book. See https://github.com/futurepress/epub.js/issues/759
|
||||||
|
|
|
@ -26,9 +26,6 @@ export default {
|
||||||
return {}
|
return {}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
userToken() {
|
|
||||||
return this.$store.getters['user/getToken']
|
|
||||||
},
|
|
||||||
libraryItemId() {
|
libraryItemId() {
|
||||||
return this.libraryItem?.id
|
return this.libraryItem?.id
|
||||||
},
|
},
|
||||||
|
@ -96,11 +93,8 @@ export default {
|
||||||
},
|
},
|
||||||
async initMobi() {
|
async initMobi() {
|
||||||
// Fetch mobi file as blob
|
// Fetch mobi file as blob
|
||||||
var buff = await this.$axios.$get(this.ebookUrl, {
|
const buff = await this.$axios.$get(this.ebookUrl, {
|
||||||
responseType: 'blob',
|
responseType: 'blob'
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${this.userToken}`
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
var reader = new FileReader()
|
var reader = new FileReader()
|
||||||
reader.onload = async (event) => {
|
reader.onload = async (event) => {
|
||||||
|
|
|
@ -55,7 +55,8 @@ export default {
|
||||||
loadedRatio: 0,
|
loadedRatio: 0,
|
||||||
page: 1,
|
page: 1,
|
||||||
numPages: 0,
|
numPages: 0,
|
||||||
pdfDocInitParams: null
|
pdfDocInitParams: null,
|
||||||
|
isRefreshing: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
@ -152,7 +153,34 @@ export default {
|
||||||
this.page++
|
this.page++
|
||||||
this.updateProgress()
|
this.updateProgress()
|
||||||
},
|
},
|
||||||
error(err) {
|
async refreshToken() {
|
||||||
|
if (this.isRefreshing) return
|
||||||
|
this.isRefreshing = true
|
||||||
|
const newAccessToken = await this.$store.dispatch('user/refreshToken').catch((error) => {
|
||||||
|
console.error('Failed to refresh token', error)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
if (!newAccessToken) {
|
||||||
|
// Redirect to login on failed refresh
|
||||||
|
this.$router.push('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force Vue to re-render the PDF component by creating a new object
|
||||||
|
this.pdfDocInitParams = {
|
||||||
|
url: this.ebookUrl,
|
||||||
|
httpHeaders: {
|
||||||
|
Authorization: `Bearer ${newAccessToken}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.isRefreshing = false
|
||||||
|
},
|
||||||
|
async error(err) {
|
||||||
|
if (err && err.status === 401) {
|
||||||
|
console.log('Received 401 error, refreshing token')
|
||||||
|
await this.refreshToken()
|
||||||
|
return
|
||||||
|
}
|
||||||
console.error(err)
|
console.error(err)
|
||||||
},
|
},
|
||||||
resize() {
|
resize() {
|
||||||
|
|
|
@ -266,9 +266,6 @@ export default {
|
||||||
isComic() {
|
isComic() {
|
||||||
return this.ebookFormat == 'cbz' || this.ebookFormat == 'cbr'
|
return this.ebookFormat == 'cbz' || this.ebookFormat == 'cbr'
|
||||||
},
|
},
|
||||||
userToken() {
|
|
||||||
return this.$store.getters['user/getToken']
|
|
||||||
},
|
|
||||||
keepProgress() {
|
keepProgress() {
|
||||||
return this.$store.state.ereaderKeepProgress
|
return this.$store.state.ereaderKeepProgress
|
||||||
},
|
},
|
||||||
|
|
177
client/components/tables/ApiKeysTable.vue
Normal file
177
client/components/tables/ApiKeysTable.vue
Normal file
|
@ -0,0 +1,177 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="text-center">
|
||||||
|
<table v-if="apiKeys.length > 0" id="api-keys">
|
||||||
|
<tr>
|
||||||
|
<th>{{ $strings.LabelName }}</th>
|
||||||
|
<th class="w-44">{{ $strings.LabelApiKeyUser }}</th>
|
||||||
|
<th class="w-32">{{ $strings.LabelExpiresAt }}</th>
|
||||||
|
<th class="w-32">{{ $strings.LabelCreatedAt }}</th>
|
||||||
|
<th class="w-32"></th>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="apiKey in apiKeys" :key="apiKey.id" :class="apiKey.isActive ? '' : 'bg-error/10!'">
|
||||||
|
<td>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<p class="pl-2 truncate">{{ apiKey.name }}</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-xs">
|
||||||
|
<nuxt-link v-if="apiKey.user" :to="`/config/users/${apiKey.user.id}`" class="text-xs hover:underline">
|
||||||
|
{{ apiKey.user.username }}
|
||||||
|
</nuxt-link>
|
||||||
|
<p v-else class="text-xs">Error</p>
|
||||||
|
</td>
|
||||||
|
<td class="text-xs">
|
||||||
|
<p v-if="apiKey.expiresAt" class="text-xs" :title="apiKey.expiresAt">{{ getExpiresAtText(apiKey) }}</p>
|
||||||
|
<p v-else class="text-xs">{{ $strings.LabelExpiresNever }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="text-xs font-mono">
|
||||||
|
<ui-tooltip direction="top" :text="$formatJsDatetime(new Date(apiKey.createdAt), dateFormat, timeFormat)">
|
||||||
|
{{ $formatJsDate(new Date(apiKey.createdAt), dateFormat) }}
|
||||||
|
</ui-tooltip>
|
||||||
|
</td>
|
||||||
|
<td class="py-0">
|
||||||
|
<div class="w-full flex justify-left">
|
||||||
|
<div class="h-8 w-8 flex items-center justify-center text-white/50 hover:text-white/100 cursor-pointer" @click.stop="editApiKey(apiKey)">
|
||||||
|
<button type="button" :aria-label="$strings.ButtonEdit" class="material-symbols text-base">edit</button>
|
||||||
|
</div>
|
||||||
|
<div class="h-8 w-8 flex items-center justify-center text-white/50 hover:text-error cursor-pointer" @click.stop="deleteApiKeyClick(apiKey)">
|
||||||
|
<button type="button" :aria-label="$strings.ButtonDelete" class="material-symbols text-base">delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p v-else class="text-base text-gray-300 py-4">{{ $strings.LabelNoApiKeys }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
apiKeys: [],
|
||||||
|
isDeletingApiKey: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
dateFormat() {
|
||||||
|
return this.$store.state.serverSettings.dateFormat
|
||||||
|
},
|
||||||
|
timeFormat() {
|
||||||
|
return this.$store.state.serverSettings.timeFormat
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getExpiresAtText(apiKey) {
|
||||||
|
if (new Date(apiKey.expiresAt).getTime() < Date.now()) {
|
||||||
|
return this.$strings.LabelExpired
|
||||||
|
}
|
||||||
|
return this.$formatJsDatetime(new Date(apiKey.expiresAt), this.dateFormat, this.timeFormat)
|
||||||
|
},
|
||||||
|
deleteApiKeyClick(apiKey) {
|
||||||
|
if (this.isDeletingApiKey) return
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
message: this.$getString('MessageConfirmDeleteApiKey', [apiKey.name]),
|
||||||
|
callback: (confirmed) => {
|
||||||
|
if (confirmed) {
|
||||||
|
this.deleteApiKey(apiKey)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'yesNo'
|
||||||
|
}
|
||||||
|
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||||
|
},
|
||||||
|
deleteApiKey(apiKey) {
|
||||||
|
this.isDeletingApiKey = true
|
||||||
|
this.$axios
|
||||||
|
.$delete(`/api/api-keys/${apiKey.id}`)
|
||||||
|
.then((data) => {
|
||||||
|
if (data.error) {
|
||||||
|
this.$toast.error(data.error)
|
||||||
|
} else {
|
||||||
|
this.removeApiKey(apiKey.id)
|
||||||
|
this.$emit('numApiKeys', this.apiKeys.length)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to delete apiKey', error)
|
||||||
|
this.$toast.error(this.$strings.ToastFailedToDelete)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.isDeletingApiKey = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
editApiKey(apiKey) {
|
||||||
|
this.$emit('edit', apiKey)
|
||||||
|
},
|
||||||
|
addApiKey(apiKey) {
|
||||||
|
this.apiKeys.push(apiKey)
|
||||||
|
},
|
||||||
|
removeApiKey(apiKeyId) {
|
||||||
|
this.apiKeys = this.apiKeys.filter((a) => a.id !== apiKeyId)
|
||||||
|
},
|
||||||
|
updateApiKey(apiKey) {
|
||||||
|
this.apiKeys = this.apiKeys.map((a) => (a.id === apiKey.id ? apiKey : a))
|
||||||
|
},
|
||||||
|
loadApiKeys() {
|
||||||
|
this.$axios
|
||||||
|
.$get('/api/api-keys')
|
||||||
|
.then((res) => {
|
||||||
|
this.apiKeys = res.apiKeys.sort((a, b) => {
|
||||||
|
return a.createdAt - b.createdAt
|
||||||
|
})
|
||||||
|
this.$emit('numApiKeys', this.apiKeys.length)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to load apiKeys', error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.loadApiKeys()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#api-keys {
|
||||||
|
table-layout: fixed;
|
||||||
|
border-collapse: collapse;
|
||||||
|
border: 1px solid #474747;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#api-keys td,
|
||||||
|
#api-keys th {
|
||||||
|
/* border: 1px solid #2e2e2e; */
|
||||||
|
padding: 8px 8px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
#api-keys td.py-0 {
|
||||||
|
padding: 0px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#api-keys tr:nth-child(even) {
|
||||||
|
background-color: #373838;
|
||||||
|
}
|
||||||
|
|
||||||
|
#api-keys tr:nth-child(odd) {
|
||||||
|
background-color: #2f2f2f;
|
||||||
|
}
|
||||||
|
|
||||||
|
#api-keys tr:hover {
|
||||||
|
background-color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
#api-keys th {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding-top: 5px;
|
||||||
|
padding-bottom: 5px;
|
||||||
|
background-color: #272727;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -78,10 +78,10 @@ export default {
|
||||||
return this.$store.getters['user/getToken']
|
return this.$store.getters['user/getToken']
|
||||||
},
|
},
|
||||||
dateFormat() {
|
dateFormat() {
|
||||||
return this.$store.state.serverSettings.dateFormat
|
return this.$store.getters['getServerSetting']('dateFormat')
|
||||||
},
|
},
|
||||||
timeFormat() {
|
timeFormat() {
|
||||||
return this.$store.state.serverSettings.timeFormat
|
return this.$store.getters['getServerSetting']('timeFormat')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|
|
@ -49,9 +49,6 @@ export default {
|
||||||
libraryItemId() {
|
libraryItemId() {
|
||||||
return this.libraryItem.id
|
return this.libraryItem.id
|
||||||
},
|
},
|
||||||
userToken() {
|
|
||||||
return this.$store.getters['user/getToken']
|
|
||||||
},
|
|
||||||
userCanDownload() {
|
userCanDownload() {
|
||||||
return this.$store.getters['user/getUserCanDownload']
|
return this.$store.getters['user/getUserCanDownload']
|
||||||
},
|
},
|
||||||
|
|
|
@ -53,9 +53,6 @@ export default {
|
||||||
libraryItemId() {
|
libraryItemId() {
|
||||||
return this.libraryItem.id
|
return this.libraryItem.id
|
||||||
},
|
},
|
||||||
userToken() {
|
|
||||||
return this.$store.getters['user/getToken']
|
|
||||||
},
|
|
||||||
userCanDownload() {
|
userCanDownload() {
|
||||||
return this.$store.getters['user/getUserCanDownload']
|
return this.$store.getters['user/getUserCanDownload']
|
||||||
},
|
},
|
||||||
|
|
|
@ -76,10 +76,10 @@ export default {
|
||||||
return usermap
|
return usermap
|
||||||
},
|
},
|
||||||
dateFormat() {
|
dateFormat() {
|
||||||
return this.$store.state.serverSettings.dateFormat
|
return this.$store.getters['getServerSetting']('dateFormat')
|
||||||
},
|
},
|
||||||
timeFormat() {
|
timeFormat() {
|
||||||
return this.$store.state.serverSettings.timeFormat
|
return this.$store.getters['getServerSetting']('timeFormat')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|
|
@ -112,7 +112,7 @@ export default {
|
||||||
return this.episode?.publishedAt
|
return this.episode?.publishedAt
|
||||||
},
|
},
|
||||||
dateFormat() {
|
dateFormat() {
|
||||||
return this.store.state.serverSettings.dateFormat
|
return this.store.getters['getServerSetting']('dateFormat')
|
||||||
},
|
},
|
||||||
itemProgress() {
|
itemProgress() {
|
||||||
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId, this.episodeId)
|
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId, this.episodeId)
|
||||||
|
|
|
@ -239,10 +239,10 @@ export default {
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
dateFormat() {
|
dateFormat() {
|
||||||
return this.$store.state.serverSettings.dateFormat
|
return this.$store.getters['getServerSetting']('dateFormat')
|
||||||
},
|
},
|
||||||
timeFormat() {
|
timeFormat() {
|
||||||
return this.$store.state.serverSettings.timeFormat
|
return this.$store.getters['getServerSetting']('timeFormat')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|
|
@ -85,9 +85,6 @@ export default {
|
||||||
this.$emit('input', val)
|
this.$emit('input', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
userToken() {
|
|
||||||
return this.$store.getters['user/getToken']
|
|
||||||
},
|
|
||||||
wrapperClass() {
|
wrapperClass() {
|
||||||
var classes = []
|
var classes = []
|
||||||
if (this.disabled) classes.push('bg-black-300')
|
if (this.disabled) classes.push('bg-black-300')
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="relative w-full">
|
<div class="relative w-full">
|
||||||
<p v-if="label" class="text-sm font-semibold px-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
|
<p v-if="label && !labelHidden" class="text-sm font-semibold px-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
|
||||||
<button ref="buttonWrapper" type="button" :aria-label="longLabel" :disabled="disabled" class="relative w-full border rounded-sm shadow-xs pl-3 pr-8 py-2 text-left sm:text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
<button ref="buttonWrapper" type="button" :aria-label="longLabel" :disabled="disabled" class="relative w-full border rounded-sm shadow-xs pl-3 pr-8 py-2 text-left sm:text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
||||||
<span class="flex items-center">
|
<span class="flex items-center">
|
||||||
<span class="block truncate font-sans" :class="{ 'font-semibold': selectedSubtext, 'text-sm': small }">{{ selectedText }}</span>
|
<span class="block truncate font-sans" :class="{ 'font-semibold': selectedSubtext, 'text-sm': small, 'text-gray-400': !selectedText }">{{ selectedText || placeholder }}</span>
|
||||||
<span v-if="selectedSubtext">: </span>
|
<span v-if="selectedSubtext">: </span>
|
||||||
<span v-if="selectedSubtext" class="font-normal block truncate font-sans text-sm text-gray-400">{{ selectedSubtext }}</span>
|
<span v-if="selectedSubtext" class="font-normal block truncate font-sans text-sm text-gray-400">{{ selectedSubtext }}</span>
|
||||||
</span>
|
</span>
|
||||||
|
@ -36,10 +36,15 @@ export default {
|
||||||
type: String,
|
type: String,
|
||||||
default: ''
|
default: ''
|
||||||
},
|
},
|
||||||
|
labelHidden: Boolean,
|
||||||
items: {
|
items: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => []
|
default: () => []
|
||||||
},
|
},
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
disabled: Boolean,
|
disabled: Boolean,
|
||||||
small: Boolean,
|
small: Boolean,
|
||||||
menuMaxHeight: {
|
menuMaxHeight: {
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
|
<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
|
||||||
</label>
|
</label>
|
||||||
</slot>
|
</slot>
|
||||||
<ui-text-input :placeholder="placeholder || label" :inputId="identifier" ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" :show-copy="showCopy" class="w-full" :class="inputClass" :trim-whitespace="trimWhitespace" @blur="inputBlurred" />
|
<ui-text-input :placeholder="placeholder || label" :inputId="identifier" ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" :min="min" :show-copy="showCopy" class="w-full" :class="inputClass" :trim-whitespace="trimWhitespace" @blur="inputBlurred" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -21,6 +21,7 @@ export default {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'text'
|
default: 'text'
|
||||||
},
|
},
|
||||||
|
min: [String, Number],
|
||||||
readonly: Boolean,
|
readonly: Boolean,
|
||||||
disabled: Boolean,
|
disabled: Boolean,
|
||||||
inputClass: String,
|
inputClass: String,
|
||||||
|
|
|
@ -318,10 +318,8 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
handleAttachmentAdd(event) {
|
handleAttachmentAdd(event) {
|
||||||
// Prevent pasting in images from the browser
|
// Prevent pasting in images/any files from the browser
|
||||||
if (!event.attachment.file) {
|
event.attachment.remove()
|
||||||
event.attachment.remove()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|
|
@ -85,7 +85,7 @@ export default {
|
||||||
nextRun() {
|
nextRun() {
|
||||||
if (!this.cronExpression) return ''
|
if (!this.cronExpression) return ''
|
||||||
const parsed = this.$getNextScheduledDate(this.cronExpression)
|
const parsed = this.$getNextScheduledDate(this.cronExpression)
|
||||||
return this.$formatJsDatetime(parsed, this.$store.state.serverSettings.dateFormat, this.$store.state.serverSettings.timeFormat) || ''
|
return this.$formatJsDatetime(parsed, this.$store.getters['getServerSetting']('dateFormat'), this.$store.getters['getServerSetting']('timeFormat')) || ''
|
||||||
},
|
},
|
||||||
description() {
|
description() {
|
||||||
if ((this.selectedInterval !== 'custom' || !this.selectedWeekdays.length) && this.selectedInterval !== 'daily') return ''
|
if ((this.selectedInterval !== 'custom' || !this.selectedWeekdays.length) && this.selectedInterval !== 'daily') return ''
|
||||||
|
|
|
@ -143,10 +143,18 @@ export default {
|
||||||
localStorage.setItem('embedMetadataCodec', val)
|
localStorage.setItem('embedMetadataCodec', val)
|
||||||
},
|
},
|
||||||
getEncodingOptions() {
|
getEncodingOptions() {
|
||||||
return {
|
if (this.showAdvancedView) {
|
||||||
codec: this.selectedCodec || 'aac',
|
return {
|
||||||
bitrate: this.selectedBitrate || '128k',
|
codec: this.customCodec || this.selectedCodec || 'aac',
|
||||||
channels: this.selectedChannels || 2
|
bitrate: this.customBitrate || this.selectedBitrate || '128k',
|
||||||
|
channels: this.customChannels || this.selectedChannels || 2
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
codec: this.selectedCodec || 'aac',
|
||||||
|
bitrate: this.selectedBitrate || '128k',
|
||||||
|
channels: this.selectedChannels || 2
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
setPreset() {
|
setPreset() {
|
||||||
|
|
|
@ -248,4 +248,4 @@ export default {
|
||||||
transform: scale(0);
|
transform: scale(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -109,4 +109,4 @@ export default {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -40,6 +40,7 @@ describe('LazySeriesCard', () => {
|
||||||
},
|
},
|
||||||
$store: {
|
$store: {
|
||||||
getters: {
|
getters: {
|
||||||
|
getServerSetting: () => 'MM/dd/yyyy',
|
||||||
'user/getUserCanUpdate': true,
|
'user/getUserCanUpdate': true,
|
||||||
'user/getUserMediaProgress': (id) => null,
|
'user/getUserMediaProgress': (id) => null,
|
||||||
'user/getSizeMultiplier': 1,
|
'user/getSizeMultiplier': 1,
|
||||||
|
|
|
@ -33,6 +33,7 @@ export default {
|
||||||
return {
|
return {
|
||||||
socket: null,
|
socket: null,
|
||||||
isSocketConnected: false,
|
isSocketConnected: false,
|
||||||
|
isSocketAuthenticated: false,
|
||||||
isFirstSocketConnection: true,
|
isFirstSocketConnection: true,
|
||||||
socketConnectionToastId: null,
|
socketConnectionToastId: null,
|
||||||
currentLang: null,
|
currentLang: null,
|
||||||
|
@ -81,9 +82,28 @@ export default {
|
||||||
document.body.classList.add('app-bar')
|
document.body.classList.add('app-bar')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
tokenRefreshed(newAccessToken) {
|
||||||
|
if (this.isSocketConnected && !this.isSocketAuthenticated) {
|
||||||
|
console.log('[SOCKET] Re-authenticating socket after token refresh')
|
||||||
|
this.socket.emit('auth', newAccessToken)
|
||||||
|
}
|
||||||
|
},
|
||||||
updateSocketConnectionToast(content, type, timeout) {
|
updateSocketConnectionToast(content, type, timeout) {
|
||||||
if (this.socketConnectionToastId !== null && this.socketConnectionToastId !== undefined) {
|
if (this.socketConnectionToastId !== null && this.socketConnectionToastId !== undefined) {
|
||||||
this.$toast.update(this.socketConnectionToastId, { content: content, options: { timeout: timeout, type: type, closeButton: false, position: 'bottom-center', onClose: () => null, closeOnClick: timeout !== null } }, false)
|
const toastUpdateOptions = {
|
||||||
|
content: content,
|
||||||
|
options: {
|
||||||
|
timeout: timeout,
|
||||||
|
type: type,
|
||||||
|
closeButton: false,
|
||||||
|
position: 'bottom-center',
|
||||||
|
onClose: () => {
|
||||||
|
this.socketConnectionToastId = null
|
||||||
|
},
|
||||||
|
closeOnClick: timeout !== null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.$toast.update(this.socketConnectionToastId, toastUpdateOptions, false)
|
||||||
} else {
|
} else {
|
||||||
this.socketConnectionToastId = this.$toast[type](content, { position: 'bottom-center', timeout: timeout, closeButton: false, closeOnClick: timeout !== null })
|
this.socketConnectionToastId = this.$toast[type](content, { position: 'bottom-center', timeout: timeout, closeButton: false, closeOnClick: timeout !== null })
|
||||||
}
|
}
|
||||||
|
@ -109,7 +129,7 @@ export default {
|
||||||
this.updateSocketConnectionToast(this.$strings.ToastSocketDisconnected, 'error', null)
|
this.updateSocketConnectionToast(this.$strings.ToastSocketDisconnected, 'error', null)
|
||||||
},
|
},
|
||||||
reconnect() {
|
reconnect() {
|
||||||
console.error('[SOCKET] reconnected')
|
console.log('[SOCKET] reconnected')
|
||||||
},
|
},
|
||||||
reconnectAttempt(val) {
|
reconnectAttempt(val) {
|
||||||
console.log(`[SOCKET] reconnect attempt ${val}`)
|
console.log(`[SOCKET] reconnect attempt ${val}`)
|
||||||
|
@ -120,6 +140,10 @@ export default {
|
||||||
reconnectFailed() {
|
reconnectFailed() {
|
||||||
console.error('[SOCKET] reconnect failed')
|
console.error('[SOCKET] reconnect failed')
|
||||||
},
|
},
|
||||||
|
authFailed(payload) {
|
||||||
|
console.error('[SOCKET] auth failed', payload.message)
|
||||||
|
this.isSocketAuthenticated = false
|
||||||
|
},
|
||||||
init(payload) {
|
init(payload) {
|
||||||
console.log('Init Payload', payload)
|
console.log('Init Payload', payload)
|
||||||
|
|
||||||
|
@ -127,7 +151,7 @@ export default {
|
||||||
this.$store.commit('users/setUsersOnline', payload.usersOnline)
|
this.$store.commit('users/setUsersOnline', payload.usersOnline)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.$eventBus.$emit('socket_init')
|
this.isSocketAuthenticated = true
|
||||||
},
|
},
|
||||||
streamOpen(stream) {
|
streamOpen(stream) {
|
||||||
if (this.$refs.mediaPlayerContainer) this.$refs.mediaPlayerContainer.streamOpen(stream)
|
if (this.$refs.mediaPlayerContainer) this.$refs.mediaPlayerContainer.streamOpen(stream)
|
||||||
|
@ -354,6 +378,15 @@ export default {
|
||||||
this.$store.commit('scanners/removeCustomMetadataProvider', provider)
|
this.$store.commit('scanners/removeCustomMetadataProvider', provider)
|
||||||
},
|
},
|
||||||
initializeSocket() {
|
initializeSocket() {
|
||||||
|
if (this.$root.socket) {
|
||||||
|
// Can happen in dev due to hot reload
|
||||||
|
console.warn('Socket already initialized')
|
||||||
|
this.socket = this.$root.socket
|
||||||
|
this.isSocketConnected = this.$root.socket?.connected
|
||||||
|
this.isFirstSocketConnection = false
|
||||||
|
this.socketConnectionToastId = null
|
||||||
|
return
|
||||||
|
}
|
||||||
this.socket = this.$nuxtSocket({
|
this.socket = this.$nuxtSocket({
|
||||||
name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod',
|
name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod',
|
||||||
persist: 'main',
|
persist: 'main',
|
||||||
|
@ -364,6 +397,7 @@ export default {
|
||||||
path: `${this.$config.routerBasePath}/socket.io`
|
path: `${this.$config.routerBasePath}/socket.io`
|
||||||
})
|
})
|
||||||
this.$root.socket = this.socket
|
this.$root.socket = this.socket
|
||||||
|
this.isSocketAuthenticated = false
|
||||||
console.log('Socket initialized')
|
console.log('Socket initialized')
|
||||||
|
|
||||||
// Pre-defined socket events
|
// Pre-defined socket events
|
||||||
|
@ -377,6 +411,7 @@ export default {
|
||||||
|
|
||||||
// Event received after authorizing socket
|
// Event received after authorizing socket
|
||||||
this.socket.on('init', this.init)
|
this.socket.on('init', this.init)
|
||||||
|
this.socket.on('auth_failed', this.authFailed)
|
||||||
|
|
||||||
// Stream Listeners
|
// Stream Listeners
|
||||||
this.socket.on('stream_open', this.streamOpen)
|
this.socket.on('stream_open', this.streamOpen)
|
||||||
|
@ -571,6 +606,7 @@ export default {
|
||||||
this.updateBodyClass()
|
this.updateBodyClass()
|
||||||
this.resize()
|
this.resize()
|
||||||
this.$eventBus.$on('change-lang', this.changeLanguage)
|
this.$eventBus.$on('change-lang', this.changeLanguage)
|
||||||
|
this.$eventBus.$on('token_refreshed', this.tokenRefreshed)
|
||||||
window.addEventListener('resize', this.resize)
|
window.addEventListener('resize', this.resize)
|
||||||
window.addEventListener('keydown', this.keyDown)
|
window.addEventListener('keydown', this.keyDown)
|
||||||
|
|
||||||
|
@ -594,6 +630,7 @@ export default {
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
this.$eventBus.$off('change-lang', this.changeLanguage)
|
this.$eventBus.$off('change-lang', this.changeLanguage)
|
||||||
|
this.$eventBus.$off('token_refreshed', this.tokenRefreshed)
|
||||||
window.removeEventListener('resize', this.resize)
|
window.removeEventListener('resize', this.resize)
|
||||||
window.removeEventListener('keydown', this.keyDown)
|
window.removeEventListener('keydown', this.keyDown)
|
||||||
}
|
}
|
||||||
|
|
|
@ -73,7 +73,8 @@ module.exports = {
|
||||||
|
|
||||||
// Axios module configuration: https://go.nuxtjs.dev/config-axios
|
// Axios module configuration: https://go.nuxtjs.dev/config-axios
|
||||||
axios: {
|
axios: {
|
||||||
baseURL: routerBasePath
|
baseURL: routerBasePath,
|
||||||
|
progress: false
|
||||||
},
|
},
|
||||||
|
|
||||||
// nuxt/pwa https://pwa.nuxtjs.org
|
// nuxt/pwa https://pwa.nuxtjs.org
|
||||||
|
|
4
client/package-lock.json
generated
4
client/package-lock.json
generated
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.24.0",
|
"version": "2.26.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.24.0",
|
"version": "2.26.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/axios": "^5.13.6",
|
"@nuxtjs/axios": "^5.13.6",
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.24.0",
|
"version": "2.26.0",
|
||||||
"buildNumber": 1,
|
"buildNumber": 1,
|
||||||
"description": "Self-hosted audiobook and podcast client",
|
"description": "Self-hosted audiobook and podcast client",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
|
|
|
@ -182,18 +182,19 @@ export default {
|
||||||
password: this.password,
|
password: this.password,
|
||||||
newPassword: this.newPassword
|
newPassword: this.newPassword
|
||||||
})
|
})
|
||||||
.then((res) => {
|
.then(() => {
|
||||||
if (res.success) {
|
this.$toast.success(this.$strings.ToastUserPasswordChangeSuccess)
|
||||||
this.$toast.success(this.$strings.ToastUserPasswordChangeSuccess)
|
this.resetForm()
|
||||||
this.resetForm()
|
|
||||||
} else {
|
|
||||||
this.$toast.error(res.error || this.$strings.ToastUnknownError)
|
|
||||||
}
|
|
||||||
this.changingPassword = false
|
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error(error)
|
console.error('Failed to change password', error)
|
||||||
this.$toast.error(this.$strings.ToastUnknownError)
|
let errorMessage = this.$strings.ToastUnknownError
|
||||||
|
if (error.response?.data && typeof error.response.data === 'string') {
|
||||||
|
errorMessage = error.response.data
|
||||||
|
}
|
||||||
|
this.$toast.error(errorMessage)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
this.changingPassword = false
|
this.changingPassword = false
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
|
@ -28,14 +28,14 @@
|
||||||
<div class="flex justify-center flex-wrap lg:flex-nowrap gap-4">
|
<div class="flex justify-center flex-wrap lg:flex-nowrap gap-4">
|
||||||
<div class="w-full max-w-2xl border border-white/10 bg-bg">
|
<div class="w-full max-w-2xl border border-white/10 bg-bg">
|
||||||
<div class="flex py-2 px-4">
|
<div class="flex py-2 px-4">
|
||||||
<div class="w-1/3 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelMetaTag }}</div>
|
<div class="w-28 min-w-28 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelMetaTag }}</div>
|
||||||
<div class="w-2/3 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelValue }}</div>
|
<div class="grow text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelValue }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full max-h-72 overflow-auto">
|
<div class="w-full max-h-72 overflow-auto">
|
||||||
<template v-for="(value, key, index) in metadataObject">
|
<template v-for="(value, key, index) in metadataObject">
|
||||||
<div :key="key" class="flex py-1 px-4 text-sm" :class="index % 2 === 0 ? 'bg-primary/25' : ''">
|
<div :key="key" class="flex py-1 px-4 text-sm" :class="index % 2 === 0 ? 'bg-primary/25' : ''">
|
||||||
<div class="w-1/3 font-semibold">{{ key }}</div>
|
<div class="w-28 min-w-28 font-semibold">{{ key }}</div>
|
||||||
<div class="w-2/3">
|
<div class="grow">
|
||||||
{{ value }}
|
{{ value }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -45,18 +45,18 @@
|
||||||
<div class="w-full max-w-2xl border border-white/10 bg-bg">
|
<div class="w-full max-w-2xl border border-white/10 bg-bg">
|
||||||
<div class="flex py-2 px-4 bg-primary/25">
|
<div class="flex py-2 px-4 bg-primary/25">
|
||||||
<div class="grow text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelChapterTitle }}</div>
|
<div class="grow text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelChapterTitle }}</div>
|
||||||
<div class="w-24 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelStart }}</div>
|
<div class="w-16 min-w-16 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelStart }}</div>
|
||||||
<div class="w-24 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelEnd }}</div>
|
<div class="w-16 min-w-16 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelEnd }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full max-h-72 overflow-auto">
|
<div class="w-full max-h-72 overflow-auto">
|
||||||
<p v-if="!metadataChapters.length" class="py-5 text-center text-gray-200">{{ $strings.MessageNoChapters }}</p>
|
<p v-if="!metadataChapters.length" class="py-5 text-center text-gray-200">{{ $strings.MessageNoChapters }}</p>
|
||||||
<template v-for="(chapter, index) in metadataChapters">
|
<template v-for="(chapter, index) in metadataChapters">
|
||||||
<div :key="index" class="flex py-1 px-4 text-sm" :class="index % 2 === 1 ? 'bg-primary/25' : ''">
|
<div :key="index" class="flex py-1 px-4 text-sm" :class="index % 2 === 1 ? 'bg-primary/25' : ''">
|
||||||
<div class="grow font-semibold">{{ chapter.title }}</div>
|
<div class="grow font-semibold">{{ chapter.title }}</div>
|
||||||
<div class="w-24">
|
<div class="w-16 min-w-16">
|
||||||
{{ $secondsToTimestamp(chapter.start) }}
|
{{ $secondsToTimestamp(chapter.start) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="w-24">
|
<div class="w-16 min-w-16">
|
||||||
{{ $secondsToTimestamp(chapter.end) }}
|
{{ $secondsToTimestamp(chapter.end) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -356,6 +356,8 @@ export default {
|
||||||
|
|
||||||
const encodeOptions = this.$refs.encoderOptionsCard.getEncodingOptions()
|
const encodeOptions = this.$refs.encoderOptionsCard.getEncodingOptions()
|
||||||
|
|
||||||
|
this.encodingOptions = encodeOptions
|
||||||
|
|
||||||
const queryParams = new URLSearchParams(encodeOptions)
|
const queryParams = new URLSearchParams(encodeOptions)
|
||||||
|
|
||||||
this.processing = true
|
this.processing = true
|
||||||
|
|
|
@ -53,6 +53,7 @@ export default {
|
||||||
else if (pageName === 'sessions') return this.$strings.HeaderListeningSessions
|
else if (pageName === 'sessions') return this.$strings.HeaderListeningSessions
|
||||||
else if (pageName === 'stats') return this.$strings.HeaderYourStats
|
else if (pageName === 'stats') return this.$strings.HeaderYourStats
|
||||||
else if (pageName === 'users') return this.$strings.HeaderUsers
|
else if (pageName === 'users') return this.$strings.HeaderUsers
|
||||||
|
else if (pageName === 'api-keys') return this.$strings.HeaderApiKeys
|
||||||
else if (pageName === 'item-metadata-utils') return this.$strings.HeaderItemMetadataUtils
|
else if (pageName === 'item-metadata-utils') return this.$strings.HeaderItemMetadataUtils
|
||||||
else if (pageName === 'rss-feeds') return this.$strings.HeaderRSSFeeds
|
else if (pageName === 'rss-feeds') return this.$strings.HeaderRSSFeeds
|
||||||
else if (pageName === 'email') return this.$strings.HeaderEmail
|
else if (pageName === 'email') return this.$strings.HeaderEmail
|
||||||
|
|
84
client/pages/config/api-keys/index.vue
Normal file
84
client/pages/config/api-keys/index.vue
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<app-settings-content :header-text="$strings.HeaderApiKeys">
|
||||||
|
<template #header-items>
|
||||||
|
<div v-if="numApiKeys" class="mx-2 px-1.5 rounded-lg bg-primary/50 text-gray-300/90 text-sm inline-flex items-center justify-center">
|
||||||
|
<span>{{ numApiKeys }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||||
|
<a href="https://www.audiobookshelf.org/guides/api-keys" target="_blank" class="inline-flex">
|
||||||
|
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||||
|
</a>
|
||||||
|
</ui-tooltip>
|
||||||
|
|
||||||
|
<div class="grow" />
|
||||||
|
|
||||||
|
<ui-btn color="bg-primary" :disabled="loadingUsers || users.length === 0" small @click="setShowApiKeyModal()">{{ $strings.ButtonAddApiKey }}</ui-btn>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<tables-api-keys-table ref="apiKeysTable" class="pt-2" @edit="setShowApiKeyModal" @numApiKeys="(count) => (numApiKeys = count)" />
|
||||||
|
</app-settings-content>
|
||||||
|
<modals-api-key-modal ref="apiKeyModal" v-model="showApiKeyModal" :api-key="selectedApiKey" :users="users" @created="apiKeyCreated" @updated="apiKeyUpdated" />
|
||||||
|
<modals-api-key-created-modal ref="apiKeyCreatedModal" v-model="showApiKeyCreatedModal" :api-key="selectedApiKey" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
asyncData({ store, redirect }) {
|
||||||
|
if (!store.getters['user/getIsAdminOrUp']) {
|
||||||
|
redirect('/')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
loadingUsers: false,
|
||||||
|
selectedApiKey: null,
|
||||||
|
showApiKeyModal: false,
|
||||||
|
showApiKeyCreatedModal: false,
|
||||||
|
numApiKeys: 0,
|
||||||
|
users: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
apiKeyCreated(apiKey) {
|
||||||
|
this.numApiKeys++
|
||||||
|
this.selectedApiKey = apiKey
|
||||||
|
this.showApiKeyCreatedModal = true
|
||||||
|
if (this.$refs.apiKeysTable) {
|
||||||
|
this.$refs.apiKeysTable.addApiKey(apiKey)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
apiKeyUpdated(apiKey) {
|
||||||
|
if (this.$refs.apiKeysTable) {
|
||||||
|
this.$refs.apiKeysTable.updateApiKey(apiKey)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setShowApiKeyModal(selectedApiKey) {
|
||||||
|
this.selectedApiKey = selectedApiKey
|
||||||
|
this.showApiKeyModal = true
|
||||||
|
},
|
||||||
|
loadUsers() {
|
||||||
|
this.loadingUsers = true
|
||||||
|
this.$axios
|
||||||
|
.$get('/api/users')
|
||||||
|
.then((res) => {
|
||||||
|
this.users = res.users.sort((a, b) => {
|
||||||
|
return a.createdAt - b.createdAt
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed', error)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.loadingUsers = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.loadUsers()
|
||||||
|
},
|
||||||
|
beforeDestroy() {}
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -78,10 +78,10 @@ export default {
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
dateFormat() {
|
dateFormat() {
|
||||||
return this.$store.state.serverSettings.dateFormat
|
return this.$store.getters['getServerSetting']('dateFormat')
|
||||||
},
|
},
|
||||||
timeFormat() {
|
timeFormat() {
|
||||||
return this.$store.state.serverSettings.timeFormat
|
return this.$store.getters['getServerSetting']('timeFormat')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|
|
@ -68,7 +68,7 @@
|
||||||
<p class="text-xs truncate" v-html="getDeviceInfoString(session.deviceInfo)" />
|
<p class="text-xs truncate" v-html="getDeviceInfoString(session.deviceInfo)" />
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center w-24 min-w-24 sm:w-32 sm:min-w-32">
|
<td class="text-center w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||||
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
|
<p class="text-xs font-mono">{{ $elapsedPrettyLocalized(session.timeListening) }}</p>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center hover:underline w-24 min-w-24" @click.stop="clickCurrentTime(session)">
|
<td class="text-center hover:underline w-24 min-w-24" @click.stop="clickCurrentTime(session)">
|
||||||
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
|
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
|
||||||
|
@ -250,10 +250,10 @@ export default {
|
||||||
return user?.username || null
|
return user?.username || null
|
||||||
},
|
},
|
||||||
dateFormat() {
|
dateFormat() {
|
||||||
return this.$store.state.serverSettings.dateFormat
|
return this.$store.getters['getServerSetting']('dateFormat')
|
||||||
},
|
},
|
||||||
timeFormat() {
|
timeFormat() {
|
||||||
return this.$store.state.serverSettings.timeFormat
|
return this.$store.getters['getServerSetting']('timeFormat')
|
||||||
},
|
},
|
||||||
numSelected() {
|
numSelected() {
|
||||||
return this.listeningSessions.filter((s) => s.selected).length
|
return this.listeningSessions.filter((s) => s.selected).length
|
||||||
|
|
|
@ -13,8 +13,8 @@
|
||||||
<widgets-online-indicator :value="!!userOnline" />
|
<widgets-online-indicator :value="!!userOnline" />
|
||||||
<h1 class="text-xl pl-2">{{ username }}</h1>
|
<h1 class="text-xl pl-2">{{ username }}</h1>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="userToken" class="flex text-xs mt-4">
|
<div v-if="legacyToken" class="flex text-xs mt-4">
|
||||||
<ui-text-input-with-label :label="$strings.LabelApiToken" :value="userToken" readonly show-copy />
|
<ui-text-input-with-label label="Legacy API Token" :value="legacyToken" readonly show-copy />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full h-px bg-white/10 my-2" />
|
<div class="w-full h-px bg-white/10 my-2" />
|
||||||
<div class="py-2">
|
<div class="py-2">
|
||||||
|
@ -100,9 +100,12 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
userToken() {
|
legacyToken() {
|
||||||
return this.user.token
|
return this.user.token
|
||||||
},
|
},
|
||||||
|
userToken() {
|
||||||
|
return this.user.accessToken
|
||||||
|
},
|
||||||
bookCoverAspectRatio() {
|
bookCoverAspectRatio() {
|
||||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||||
},
|
},
|
||||||
|
@ -129,10 +132,10 @@ export default {
|
||||||
return this.listeningSessions.sessions[0]
|
return this.listeningSessions.sessions[0]
|
||||||
},
|
},
|
||||||
dateFormat() {
|
dateFormat() {
|
||||||
return this.$store.state.serverSettings.dateFormat
|
return this.$store.getters['getServerSetting']('dateFormat')
|
||||||
},
|
},
|
||||||
timeFormat() {
|
timeFormat() {
|
||||||
return this.$store.state.serverSettings.timeFormat
|
return this.$store.getters['getServerSetting']('timeFormat')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|
|
@ -40,7 +40,7 @@
|
||||||
<p class="text-xs truncate" v-html="getDeviceInfoString(session.deviceInfo)" />
|
<p class="text-xs truncate" v-html="getDeviceInfoString(session.deviceInfo)" />
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
|
<p class="text-xs font-mono">{{ $elapsedPrettyLocalized(session.timeListening) }}</p>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center hover:underline" @click.stop="clickCurrentTime(session)">
|
<td class="text-center hover:underline" @click.stop="clickCurrentTime(session)">
|
||||||
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
|
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
|
||||||
|
@ -98,10 +98,10 @@ export default {
|
||||||
return this.$store.getters['users/getIsUserOnline'](this.user.id)
|
return this.$store.getters['users/getIsUserOnline'](this.user.id)
|
||||||
},
|
},
|
||||||
dateFormat() {
|
dateFormat() {
|
||||||
return this.$store.state.serverSettings.dateFormat
|
return this.$store.getters['getServerSetting']('dateFormat')
|
||||||
},
|
},
|
||||||
timeFormat() {
|
timeFormat() {
|
||||||
return this.$store.state.serverSettings.timeFormat
|
return this.$store.getters['getServerSetting']('timeFormat')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|
|
@ -193,7 +193,7 @@ export default {
|
||||||
return `${process.env.serverUrl}/api/items/${this.libraryItemId}/download?token=${this.userToken}`
|
return `${process.env.serverUrl}/api/items/${this.libraryItemId}/download?token=${this.userToken}`
|
||||||
},
|
},
|
||||||
dateFormat() {
|
dateFormat() {
|
||||||
return this.$store.state.serverSettings.dateFormat
|
return this.$store.getters['getServerSetting']('dateFormat')
|
||||||
},
|
},
|
||||||
userIsAdminOrUp() {
|
userIsAdminOrUp() {
|
||||||
return this.$store.getters['user/getIsAdminOrUp']
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
|
|
|
@ -10,7 +10,7 @@
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-for="narrator in narrators" :key="narrator.id">
|
<tr v-for="narrator in narrators" :key="narrator.id">
|
||||||
<td>
|
<td>
|
||||||
<p v-if="selectedNarrator?.id !== narrator.id" class="text-sm md:text-base text-gray-100">{{ narrator.name }}</p>
|
<nuxt-link v-if="selectedNarrator?.id !== narrator.id" :to="`/library/${currentLibraryId}/bookshelf?filter=narrators.${narrator.id}`" class="text-sm md:text-base text-gray-100 hover:underline">{{ narrator.name }}</nuxt-link>
|
||||||
<form v-else @submit.prevent="saveClick">
|
<form v-else @submit.prevent="saveClick">
|
||||||
<ui-text-input v-model="newNarratorName" />
|
<ui-text-input v-model="newNarratorName" />
|
||||||
</form>
|
</form>
|
||||||
|
|
|
@ -141,7 +141,7 @@ export default {
|
||||||
return episodeIds
|
return episodeIds
|
||||||
},
|
},
|
||||||
dateFormat() {
|
dateFormat() {
|
||||||
return this.$store.state.serverSettings.dateFormat
|
return this.$store.getters['getServerSetting']('dateFormat')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|
|
@ -40,6 +40,15 @@
|
||||||
|
|
||||||
<p v-if="error" class="text-error text-center py-2">{{ error }}</p>
|
<p v-if="error" class="text-error text-center py-2">{{ error }}</p>
|
||||||
|
|
||||||
|
<div v-if="showNewAuthSystemMessage" class="mb-4">
|
||||||
|
<widgets-alert type="warning">
|
||||||
|
<div>
|
||||||
|
<p>{{ $strings.MessageAuthenticationSecurityMessage }}</p>
|
||||||
|
<a v-if="showNewAuthSystemAdminMessage" href="https://github.com/advplyr/audiobookshelf/discussions/4460" target="_blank" class="underline">{{ $strings.LabelMoreInfo }}</a>
|
||||||
|
</div>
|
||||||
|
</widgets-alert>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form v-show="login_local" @submit.prevent="submitForm">
|
<form v-show="login_local" @submit.prevent="submitForm">
|
||||||
<label class="text-xs text-gray-300 uppercase">{{ $strings.LabelUsername }}</label>
|
<label class="text-xs text-gray-300 uppercase">{{ $strings.LabelUsername }}</label>
|
||||||
<ui-text-input v-model.trim="username" :disabled="processing" class="mb-3 w-full" inputName="username" />
|
<ui-text-input v-model.trim="username" :disabled="processing" class="mb-3 w-full" inputName="username" />
|
||||||
|
@ -85,7 +94,10 @@ export default {
|
||||||
MetadataPath: '',
|
MetadataPath: '',
|
||||||
login_local: true,
|
login_local: true,
|
||||||
login_openid: false,
|
login_openid: false,
|
||||||
authFormData: null
|
authFormData: null,
|
||||||
|
// New JWT auth system re-login flags
|
||||||
|
showNewAuthSystemMessage: false,
|
||||||
|
showNewAuthSystemAdminMessage: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
|
@ -179,11 +191,14 @@ export default {
|
||||||
|
|
||||||
this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId)
|
this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId)
|
||||||
this.$store.commit('user/setUser', user)
|
this.$store.commit('user/setUser', user)
|
||||||
|
this.$store.commit('user/setAccessToken', user.accessToken)
|
||||||
|
|
||||||
this.$store.dispatch('user/loadUserSettings')
|
this.$store.dispatch('user/loadUserSettings')
|
||||||
},
|
},
|
||||||
async submitForm() {
|
async submitForm() {
|
||||||
this.error = null
|
this.error = null
|
||||||
|
this.showNewAuthSystemMessage = false
|
||||||
|
this.showNewAuthSystemAdminMessage = false
|
||||||
this.processing = true
|
this.processing = true
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
|
@ -217,15 +232,24 @@ export default {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
|
// Force re-login if user is using an old token with no expiration
|
||||||
|
if (res.user.isOldToken) {
|
||||||
|
this.username = res.user.username
|
||||||
|
this.showNewAuthSystemMessage = true
|
||||||
|
// Admin user sees link to github discussion
|
||||||
|
this.showNewAuthSystemAdminMessage = res.user.type === 'admin' || res.user.type === 'root'
|
||||||
|
return false
|
||||||
|
}
|
||||||
this.setUser(res)
|
this.setUser(res)
|
||||||
this.processing = false
|
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Authorize error', error)
|
console.error('Authorize error', error)
|
||||||
this.processing = false
|
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.processing = false
|
||||||
|
})
|
||||||
},
|
},
|
||||||
checkStatus() {
|
checkStatus() {
|
||||||
this.processing = true
|
this.processing = true
|
||||||
|
@ -280,8 +304,9 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
if (this.$route.query?.setToken) {
|
// Token passed as query parameter after successful oidc login
|
||||||
localStorage.setItem('token', this.$route.query.setToken)
|
if (this.$route.query?.accessToken) {
|
||||||
|
localStorage.setItem('token', this.$route.query.accessToken)
|
||||||
}
|
}
|
||||||
if (localStorage.getItem('token')) {
|
if (localStorage.getItem('token')) {
|
||||||
if (await this.checkAuth()) return // if valid user no need to check status
|
if (await this.checkAuth()) return // if valid user no need to check status
|
||||||
|
|
|
@ -1,4 +1,19 @@
|
||||||
export default function ({ $axios, store, $config }) {
|
export default function ({ $axios, store, $root, app }) {
|
||||||
|
// Track if we're currently refreshing to prevent multiple refresh attempts
|
||||||
|
let isRefreshing = false
|
||||||
|
let failedQueue = []
|
||||||
|
|
||||||
|
const processQueue = (error, token = null) => {
|
||||||
|
failedQueue.forEach(({ resolve, reject }) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error)
|
||||||
|
} else {
|
||||||
|
resolve(token)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
failedQueue = []
|
||||||
|
}
|
||||||
|
|
||||||
$axios.onRequest((config) => {
|
$axios.onRequest((config) => {
|
||||||
if (!config.url) {
|
if (!config.url) {
|
||||||
console.error('Axios request invalid config', config)
|
console.error('Axios request invalid config', config)
|
||||||
|
@ -7,7 +22,7 @@ export default function ({ $axios, store, $config }) {
|
||||||
if (config.url.startsWith('http:') || config.url.startsWith('https:')) {
|
if (config.url.startsWith('http:') || config.url.startsWith('https:')) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const bearerToken = store.state.user.user?.token || null
|
const bearerToken = store.getters['user/getToken']
|
||||||
if (bearerToken) {
|
if (bearerToken) {
|
||||||
config.headers.common['Authorization'] = `Bearer ${bearerToken}`
|
config.headers.common['Authorization'] = `Bearer ${bearerToken}`
|
||||||
}
|
}
|
||||||
|
@ -17,9 +32,79 @@ export default function ({ $axios, store, $config }) {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
$axios.onError((error) => {
|
$axios.onError(async (error) => {
|
||||||
|
const originalRequest = error.config
|
||||||
const code = parseInt(error.response && error.response.status)
|
const code = parseInt(error.response && error.response.status)
|
||||||
const message = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
|
const message = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
|
||||||
|
|
||||||
console.error('Axios error', code, message)
|
console.error('Axios error', code, message)
|
||||||
|
|
||||||
|
// Handle 401 Unauthorized (token expired)
|
||||||
|
if (code === 401 && !originalRequest._retry) {
|
||||||
|
// Skip refresh for auth endpoints to prevent infinite loops
|
||||||
|
if (originalRequest.url === '/auth/refresh' || originalRequest.url === '/login') {
|
||||||
|
// Refresh failed or login failed, redirect to login
|
||||||
|
store.commit('user/setUser', null)
|
||||||
|
store.commit('user/setAccessToken', null)
|
||||||
|
app.router.push('/login')
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRefreshing) {
|
||||||
|
// If already refreshing, queue this request
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
failedQueue.push({ resolve, reject })
|
||||||
|
})
|
||||||
|
.then((token) => {
|
||||||
|
if (!originalRequest.headers) {
|
||||||
|
originalRequest.headers = {}
|
||||||
|
}
|
||||||
|
originalRequest.headers['Authorization'] = `Bearer ${token}`
|
||||||
|
return $axios(originalRequest)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
return Promise.reject(err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
originalRequest._retry = true
|
||||||
|
isRefreshing = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Attempt to refresh the token
|
||||||
|
// Updates store if successful, otherwise clears store and throw error
|
||||||
|
const newAccessToken = await store.dispatch('user/refreshToken')
|
||||||
|
if (!newAccessToken) {
|
||||||
|
console.error('No new access token received')
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the original request with new token
|
||||||
|
if (!originalRequest.headers) {
|
||||||
|
originalRequest.headers = {}
|
||||||
|
}
|
||||||
|
originalRequest.headers['Authorization'] = `Bearer ${newAccessToken}`
|
||||||
|
|
||||||
|
// Process any queued requests
|
||||||
|
processQueue(null, newAccessToken)
|
||||||
|
|
||||||
|
// Retry the original request
|
||||||
|
return $axios(originalRequest)
|
||||||
|
} catch (refreshError) {
|
||||||
|
console.error('Token refresh failed:', refreshError)
|
||||||
|
|
||||||
|
// Process queued requests with error
|
||||||
|
processQueue(refreshError, null)
|
||||||
|
|
||||||
|
// Redirect to login
|
||||||
|
app.router.push('/login')
|
||||||
|
|
||||||
|
return Promise.reject(refreshError)
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -37,6 +37,48 @@ Vue.prototype.$elapsedPretty = (seconds, useFullNames = false, useMilliseconds =
|
||||||
return `${hours} ${useFullNames ? `hour${hours === 1 ? '' : 's'}` : 'hr'} ${minutes} ${useFullNames ? `minute${minutes === 1 ? '' : 's'}` : 'min'}`
|
return `${hours} ${useFullNames ? `hour${hours === 1 ? '' : 's'}` : 'hr'} ${minutes} ${useFullNames ? `minute${minutes === 1 ? '' : 's'}` : 'min'}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Vue.prototype.$elapsedPrettyLocalized = (seconds, useFullNames = false, useMilliseconds = false) => {
|
||||||
|
if (isNaN(seconds) || seconds === null) return ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const df = new Intl.DurationFormat(Vue.prototype.$languageCodes.current, {
|
||||||
|
style: useFullNames ? 'long' : 'short'
|
||||||
|
})
|
||||||
|
|
||||||
|
const duration = {}
|
||||||
|
|
||||||
|
if (seconds < 60) {
|
||||||
|
if (useMilliseconds && seconds < 1) {
|
||||||
|
duration.milliseconds = Math.floor(seconds * 1000)
|
||||||
|
} else {
|
||||||
|
duration.seconds = Math.floor(seconds)
|
||||||
|
}
|
||||||
|
} else if (seconds < 3600) {
|
||||||
|
// 1 hour
|
||||||
|
duration.minutes = Math.floor(seconds / 60)
|
||||||
|
} else if (seconds < 86400) {
|
||||||
|
// 1 day
|
||||||
|
duration.hours = Math.floor(seconds / 3600)
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60)
|
||||||
|
if (minutes > 0) {
|
||||||
|
duration.minutes = minutes
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
duration.days = Math.floor(seconds / 86400)
|
||||||
|
const hours = Math.floor((seconds % 86400) / 3600)
|
||||||
|
if (hours > 0) {
|
||||||
|
duration.hours = hours
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return df.format(duration)
|
||||||
|
} catch (error) {
|
||||||
|
// Handle not supported
|
||||||
|
console.warn('Intl.DurationFormat not supported, not localizing duration')
|
||||||
|
return Vue.prototype.$elapsedPretty(seconds, useFullNames, useMilliseconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Vue.prototype.$secondsToTimestamp = (seconds, includeMs = false, alwaysIncludeHours = false) => {
|
Vue.prototype.$secondsToTimestamp = (seconds, includeMs = false, alwaysIncludeHours = false) => {
|
||||||
if (!seconds) {
|
if (!seconds) {
|
||||||
return alwaysIncludeHours ? '00:00:00' : '0:00'
|
return alwaysIncludeHours ? '00:00:00' : '0:00'
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
export const state = () => ({
|
export const state = () => ({
|
||||||
user: null,
|
user: null,
|
||||||
|
accessToken: null,
|
||||||
settings: {
|
settings: {
|
||||||
orderBy: 'media.metadata.title',
|
orderBy: 'media.metadata.title',
|
||||||
orderDesc: false,
|
orderDesc: false,
|
||||||
|
@ -25,19 +26,19 @@ export const getters = {
|
||||||
getIsRoot: (state) => state.user && state.user.type === 'root',
|
getIsRoot: (state) => state.user && state.user.type === 'root',
|
||||||
getIsAdminOrUp: (state) => state.user && (state.user.type === 'admin' || state.user.type === 'root'),
|
getIsAdminOrUp: (state) => state.user && (state.user.type === 'admin' || state.user.type === 'root'),
|
||||||
getToken: (state) => {
|
getToken: (state) => {
|
||||||
return state.user?.token || null
|
return state.accessToken || null
|
||||||
},
|
},
|
||||||
getUserMediaProgress:
|
getUserMediaProgress:
|
||||||
(state) =>
|
(state) =>
|
||||||
(libraryItemId, episodeId = null) => {
|
(libraryItemId, episodeId = null) => {
|
||||||
if (!state.user.mediaProgress) return null
|
if (!state.user?.mediaProgress) return null
|
||||||
return state.user.mediaProgress.find((li) => {
|
return state.user.mediaProgress.find((li) => {
|
||||||
if (episodeId && li.episodeId !== episodeId) return false
|
if (episodeId && li.episodeId !== episodeId) return false
|
||||||
return li.libraryItemId == libraryItemId
|
return li.libraryItemId == libraryItemId
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
getUserBookmarksForItem: (state) => (libraryItemId) => {
|
getUserBookmarksForItem: (state) => (libraryItemId) => {
|
||||||
if (!state.user.bookmarks) return []
|
if (!state.user?.bookmarks) return []
|
||||||
return state.user.bookmarks.filter((bm) => bm.libraryItemId === libraryItemId)
|
return state.user.bookmarks.filter((bm) => bm.libraryItemId === libraryItemId)
|
||||||
},
|
},
|
||||||
getUserSetting: (state) => (key) => {
|
getUserSetting: (state) => (key) => {
|
||||||
|
@ -58,6 +59,9 @@ export const getters = {
|
||||||
getUserCanAccessAllLibraries: (state) => {
|
getUserCanAccessAllLibraries: (state) => {
|
||||||
return !!state.user?.permissions?.accessAllLibraries
|
return !!state.user?.permissions?.accessAllLibraries
|
||||||
},
|
},
|
||||||
|
getUserCanAccessExplicitContent: (state) => {
|
||||||
|
return !!state.user?.permissions?.accessExplicitContent
|
||||||
|
},
|
||||||
getLibrariesAccessible: (state, getters) => {
|
getLibrariesAccessible: (state, getters) => {
|
||||||
if (!state.user) return []
|
if (!state.user) return []
|
||||||
if (getters.getUserCanAccessAllLibraries) return []
|
if (getters.getUserCanAccessAllLibraries) return []
|
||||||
|
@ -142,21 +146,42 @@ export const actions = {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load userSettings from local storage', error)
|
console.error('Failed to load userSettings from local storage', error)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
refreshToken({ state, commit }) {
|
||||||
|
return this.$axios
|
||||||
|
.$post('/auth/refresh')
|
||||||
|
.then(async (response) => {
|
||||||
|
const newAccessToken = response.user.accessToken
|
||||||
|
commit('setUser', response.user)
|
||||||
|
commit('setAccessToken', newAccessToken)
|
||||||
|
// Emit event used to re-authenticate socket in default.vue since $root is not available here
|
||||||
|
if (this.$eventBus) {
|
||||||
|
this.$eventBus.$emit('token_refreshed', newAccessToken)
|
||||||
|
}
|
||||||
|
return newAccessToken
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to refresh token', error)
|
||||||
|
commit('setUser', null)
|
||||||
|
commit('setAccessToken', null)
|
||||||
|
// Calling function handles redirect to login
|
||||||
|
throw error
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const mutations = {
|
export const mutations = {
|
||||||
setUser(state, user) {
|
setUser(state, user) {
|
||||||
state.user = user
|
state.user = user
|
||||||
if (user) {
|
|
||||||
if (user.token) localStorage.setItem('token', user.token)
|
|
||||||
} else {
|
|
||||||
localStorage.removeItem('token')
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
setUserToken(state, token) {
|
setAccessToken(state, token) {
|
||||||
state.user.token = token
|
if (!token) {
|
||||||
localStorage.setItem('token', token)
|
localStorage.removeItem('token')
|
||||||
|
state.accessToken = null
|
||||||
|
} else {
|
||||||
|
state.accessToken = token
|
||||||
|
localStorage.setItem('token', token)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
updateMediaProgress(state, { id, data }) {
|
updateMediaProgress(state, { id, data }) {
|
||||||
if (!state.user) return
|
if (!state.user) return
|
||||||
|
|
|
@ -514,7 +514,7 @@
|
||||||
"LabelPublishers": "الناشرون",
|
"LabelPublishers": "الناشرون",
|
||||||
"LabelRSSFeedCustomOwnerEmail": "البريد الالكتروني المخصص للمالك",
|
"LabelRSSFeedCustomOwnerEmail": "البريد الالكتروني المخصص للمالك",
|
||||||
"LabelRSSFeedCustomOwnerName": "الاسم المخصص للمالك",
|
"LabelRSSFeedCustomOwnerName": "الاسم المخصص للمالك",
|
||||||
"LabelRSSFeedOpen": "فتح تغذية RSS",
|
"LabelRSSFeedOpen": "موجز RSS مفتوح",
|
||||||
"LabelRSSFeedPreventIndexing": "منع الفهرسة",
|
"LabelRSSFeedPreventIndexing": "منع الفهرسة",
|
||||||
"LabelRSSFeedSlug": "اسم تعريف تغذية RSS",
|
"LabelRSSFeedSlug": "اسم تعريف تغذية RSS",
|
||||||
"LabelRSSFeedURL": "رابط تغذية RSS",
|
"LabelRSSFeedURL": "رابط تغذية RSS",
|
||||||
|
@ -918,6 +918,8 @@
|
||||||
"NotificationOnBackupCompletedDescription": "يتم تشغيله عند اكتمال النسخ الاحتياطي",
|
"NotificationOnBackupCompletedDescription": "يتم تشغيله عند اكتمال النسخ الاحتياطي",
|
||||||
"NotificationOnBackupFailedDescription": "يتم تشغيله عند فشل النسخ الاحتياطي",
|
"NotificationOnBackupFailedDescription": "يتم تشغيله عند فشل النسخ الاحتياطي",
|
||||||
"NotificationOnEpisodeDownloadedDescription": "يتم تشغيله عند تنزيل حلقة بودكاست تلقائيًا",
|
"NotificationOnEpisodeDownloadedDescription": "يتم تشغيله عند تنزيل حلقة بودكاست تلقائيًا",
|
||||||
|
"NotificationOnRSSFeedDisabledDescription": "يتم تشغيله عندما يتم تعطيل تنزيلات الحلقة التلقائية بسبب الكثير من المحاولات الفاشلة",
|
||||||
|
"NotificationOnRSSFeedFailedDescription": "يتم تشغيله عند فشل طلب تغذية RSS في تنزيل حلقة تلقائية",
|
||||||
"NotificationOnTestDescription": "حدث لاختبار نظام الإشعارات",
|
"NotificationOnTestDescription": "حدث لاختبار نظام الإشعارات",
|
||||||
"PlaceholderNewCollection": "اسم المجموعة الجديدة",
|
"PlaceholderNewCollection": "اسم المجموعة الجديدة",
|
||||||
"PlaceholderNewFolderPath": "مسار المجلد الجديد",
|
"PlaceholderNewFolderPath": "مسار المجلد الجديد",
|
||||||
|
|
|
@ -154,7 +154,7 @@
|
||||||
"HeaderListeningSessions": "Poslechové relace",
|
"HeaderListeningSessions": "Poslechové relace",
|
||||||
"HeaderListeningStats": "Statistiky poslechu",
|
"HeaderListeningStats": "Statistiky poslechu",
|
||||||
"HeaderLogin": "Přihlásit",
|
"HeaderLogin": "Přihlásit",
|
||||||
"HeaderLogs": "Záznamy",
|
"HeaderLogs": "Logy",
|
||||||
"HeaderManageGenres": "Spravovat žánry",
|
"HeaderManageGenres": "Spravovat žánry",
|
||||||
"HeaderManageTags": "Spravovat štítky",
|
"HeaderManageTags": "Spravovat štítky",
|
||||||
"HeaderMapDetails": "Podrobnosti mapování",
|
"HeaderMapDetails": "Podrobnosti mapování",
|
||||||
|
@ -177,6 +177,7 @@
|
||||||
"HeaderPlaylist": "Seznam skladeb",
|
"HeaderPlaylist": "Seznam skladeb",
|
||||||
"HeaderPlaylistItems": "Položky seznamu přehrávání",
|
"HeaderPlaylistItems": "Položky seznamu přehrávání",
|
||||||
"HeaderPodcastsToAdd": "Podcasty k přidání",
|
"HeaderPodcastsToAdd": "Podcasty k přidání",
|
||||||
|
"HeaderPresets": "Předvolba",
|
||||||
"HeaderPreviewCover": "Náhled obálky",
|
"HeaderPreviewCover": "Náhled obálky",
|
||||||
"HeaderRSSFeedGeneral": "Podrobnosti o RSS",
|
"HeaderRSSFeedGeneral": "Podrobnosti o RSS",
|
||||||
"HeaderRSSFeedIsOpen": "Informační kanál RSS je otevřený",
|
"HeaderRSSFeedIsOpen": "Informační kanál RSS je otevřený",
|
||||||
|
@ -345,11 +346,11 @@
|
||||||
"LabelExample": "Příklad",
|
"LabelExample": "Příklad",
|
||||||
"LabelExpandSeries": "Rozbalit série",
|
"LabelExpandSeries": "Rozbalit série",
|
||||||
"LabelExpandSubSeries": "Rozbalit podsérie",
|
"LabelExpandSubSeries": "Rozbalit podsérie",
|
||||||
"LabelExplicit": "Explicitní",
|
"LabelExplicit": "Explicitně",
|
||||||
"LabelExplicitChecked": "Explicitní (zaškrtnuto)",
|
"LabelExplicitChecked": "Explicitní (zaškrtnuto)",
|
||||||
"LabelExplicitUnchecked": "Není explicitní (nezaškrtnuto)",
|
"LabelExplicitUnchecked": "Není explicitní (nezaškrtnuto)",
|
||||||
"LabelExportOPML": "Export OPML",
|
"LabelExportOPML": "Export OPML",
|
||||||
"LabelFeedURL": "URL zdroje",
|
"LabelFeedURL": "URL kanálu",
|
||||||
"LabelFetchingMetadata": "Získávání metadat",
|
"LabelFetchingMetadata": "Získávání metadat",
|
||||||
"LabelFile": "Soubor",
|
"LabelFile": "Soubor",
|
||||||
"LabelFileBirthtime": "Čas vzniku souboru",
|
"LabelFileBirthtime": "Čas vzniku souboru",
|
||||||
|
@ -513,9 +514,9 @@
|
||||||
"LabelPublishers": "Vydavatelé",
|
"LabelPublishers": "Vydavatelé",
|
||||||
"LabelRSSFeedCustomOwnerEmail": "Vlastní e-mail vlastníka",
|
"LabelRSSFeedCustomOwnerEmail": "Vlastní e-mail vlastníka",
|
||||||
"LabelRSSFeedCustomOwnerName": "Vlastní jméno vlastníka",
|
"LabelRSSFeedCustomOwnerName": "Vlastní jméno vlastníka",
|
||||||
"LabelRSSFeedOpen": "Otevření RSS kanálu",
|
"LabelRSSFeedOpen": "RSS kanál otevřen",
|
||||||
"LabelRSSFeedPreventIndexing": "Zabránit indexování",
|
"LabelRSSFeedPreventIndexing": "Zabránit indexování",
|
||||||
"LabelRSSFeedSlug": "RSS kanál Slug",
|
"LabelRSSFeedSlug": "Klíčové slovo kanálu RSS",
|
||||||
"LabelRSSFeedURL": "URL RSS kanálu",
|
"LabelRSSFeedURL": "URL RSS kanálu",
|
||||||
"LabelRandomly": "Náhodně",
|
"LabelRandomly": "Náhodně",
|
||||||
"LabelReAddSeriesToContinueListening": "Znovu přidat sérii k pokračování poslechu",
|
"LabelReAddSeriesToContinueListening": "Znovu přidat sérii k pokračování poslechu",
|
||||||
|
@ -530,6 +531,7 @@
|
||||||
"LabelReleaseDate": "Datum vydání",
|
"LabelReleaseDate": "Datum vydání",
|
||||||
"LabelRemoveAllMetadataAbs": "Odebrat všechny soubory metadata.abs",
|
"LabelRemoveAllMetadataAbs": "Odebrat všechny soubory metadata.abs",
|
||||||
"LabelRemoveAllMetadataJson": "Smazat všechny soubory metadata.json",
|
"LabelRemoveAllMetadataJson": "Smazat všechny soubory metadata.json",
|
||||||
|
"LabelRemoveAudibleBranding": "Odebrat úvod a závěr Audible z kapitol",
|
||||||
"LabelRemoveCover": "Odstranit obálku",
|
"LabelRemoveCover": "Odstranit obálku",
|
||||||
"LabelRemoveMetadataFile": "Odstranit soubory metadat ve složkách položek knihovny",
|
"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}.",
|
"LabelRemoveMetadataFileHelp": "Odstraníte všechny soubory metadata.json a metadata.abs ve svých složkách {0}.",
|
||||||
|
@ -549,7 +551,7 @@
|
||||||
"LabelSeries": "Série",
|
"LabelSeries": "Série",
|
||||||
"LabelSeriesName": "Název série",
|
"LabelSeriesName": "Název série",
|
||||||
"LabelSeriesProgress": "Průběh série",
|
"LabelSeriesProgress": "Průběh série",
|
||||||
"LabelServerLogLevel": "Úroveň protokolu serveru",
|
"LabelServerLogLevel": "Úroveň Logování serveru",
|
||||||
"LabelServerYearReview": "Přehled roku na serveru ({0})",
|
"LabelServerYearReview": "Přehled roku na serveru ({0})",
|
||||||
"LabelSetEbookAsPrimary": "Nastavit jako primární",
|
"LabelSetEbookAsPrimary": "Nastavit jako primární",
|
||||||
"LabelSetEbookAsSupplementary": "Nastavit jako doplňkové",
|
"LabelSetEbookAsSupplementary": "Nastavit jako doplňkové",
|
||||||
|
@ -706,6 +708,7 @@
|
||||||
"MessageAddToPlayerQueue": "Přidat do fronty přehrávače",
|
"MessageAddToPlayerQueue": "Přidat do fronty přehrávače",
|
||||||
"MessageAppriseDescription": "Abyste mohli používat tuto funkci, musíte mít spuštěnou instanci <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> nebo API, které bude zpracovávat stejné požadavky. <br />Adresa URL API Apprise by měla být úplná URL cesta pro odeslání oznámení, např. pokud je vaše instance API obsluhována na adrese <code>http://192.168.1.1:8337</code> pak byste měli zadat <code>http://192.168.1.1:8337/notify</code>.",
|
"MessageAppriseDescription": "Abyste mohli používat tuto funkci, musíte mít spuštěnou instanci <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> nebo API, které bude zpracovávat stejné požadavky. <br />Adresa URL API Apprise by měla být úplná URL cesta pro odeslání oznámení, např. pokud je vaše instance API obsluhována na adrese <code>http://192.168.1.1:8337</code> pak byste měli zadat <code>http://192.168.1.1:8337/notify</code>.",
|
||||||
"MessageAsinCheck": "Ujistěte se, že používáte ASIN ze správného regionu Audible a ne z Amazonu.",
|
"MessageAsinCheck": "Ujistěte se, že používáte ASIN ze správného regionu Audible a ne z Amazonu.",
|
||||||
|
"MessageAuthenticationOIDCChangesRestart": "Po uložení restartujte server, aby se změny OIDC použily.",
|
||||||
"MessageBackupsDescription": "Zálohy zahrnují uživatele, průběh uživatele, podrobnosti o položkách knihovny, nastavení serveru a obrázky uložené v <code>/metadata/items</code> a <code>/metadata/authors</code>. Zálohy <strong>ne</strong> zahrnují všechny soubory uložené ve složkách knihovny.",
|
"MessageBackupsDescription": "Zálohy zahrnují uživatele, průběh uživatele, podrobnosti o položkách knihovny, nastavení serveru a obrázky uložené v <code>/metadata/items</code> a <code>/metadata/authors</code>. Zálohy <strong>ne</strong> zahrnují všechny soubory uložené ve složkách knihovny.",
|
||||||
"MessageBackupsLocationEditNote": "Poznámka: Změna umístění záloh nepřesune ani nezmění existující zálohy",
|
"MessageBackupsLocationEditNote": "Poznámka: Změna umístění záloh nepřesune ani nezmění existující zálohy",
|
||||||
"MessageBackupsLocationNoEditNote": "Poznámka: Umístění záloh je nastavené z proměnných prostředí a nelze zde změnit.",
|
"MessageBackupsLocationNoEditNote": "Poznámka: Umístění záloh je nastavené z proměnných prostředí a nelze zde změnit.",
|
||||||
|
@ -754,6 +757,7 @@
|
||||||
"MessageConfirmRemoveAuthor": "Opravdu chcete odstranit autora \"{0}\"?",
|
"MessageConfirmRemoveAuthor": "Opravdu chcete odstranit autora \"{0}\"?",
|
||||||
"MessageConfirmRemoveCollection": "Opravdu chcete odstranit kolekci \"{0}\"?",
|
"MessageConfirmRemoveCollection": "Opravdu chcete odstranit kolekci \"{0}\"?",
|
||||||
"MessageConfirmRemoveEpisode": "Opravdu chcete odstranit epizodu \"{0}\"?",
|
"MessageConfirmRemoveEpisode": "Opravdu chcete odstranit epizodu \"{0}\"?",
|
||||||
|
"MessageConfirmRemoveEpisodeNote": "Poznámka: Tím se zvukový soubor neodstraní, pokud nepřepnete volbu “Tvrdé odstranění souboru“",
|
||||||
"MessageConfirmRemoveEpisodes": "Opravdu chcete odstranit {0} epizody?",
|
"MessageConfirmRemoveEpisodes": "Opravdu chcete odstranit {0} epizody?",
|
||||||
"MessageConfirmRemoveListeningSessions": "Opravdu chcete odebrat {0} poslechových relací?",
|
"MessageConfirmRemoveListeningSessions": "Opravdu chcete odebrat {0} poslechových relací?",
|
||||||
"MessageConfirmRemoveMetadataFiles": "Jste si jisti, že chcete odstranit všechny metadata.{0} soubory ve složkách s položkami ve vaší knihovně?",
|
"MessageConfirmRemoveMetadataFiles": "Jste si jisti, že chcete odstranit všechny metadata.{0} soubory ve složkách s položkami ve vaší knihovně?",
|
||||||
|
@ -787,7 +791,7 @@
|
||||||
"MessageJoinUsOn": "Přidejte se k nám",
|
"MessageJoinUsOn": "Přidejte se k nám",
|
||||||
"MessageLoading": "Načítá se...",
|
"MessageLoading": "Načítá se...",
|
||||||
"MessageLoadingFolders": "Načítám složky...",
|
"MessageLoadingFolders": "Načítám složky...",
|
||||||
"MessageLogsDescription": "Protokoly se ukládají do souborů JSON v <code>/metadata/logs</code>. Protokoly o pádech jsou uloženy v <code>/metadata/logs/crash_logs.txt</code>.",
|
"MessageLogsDescription": "Logy se ukládají do souborů JSON v <code>/metadata/logs</code>. Logy o pádech jsou uloženy v <code>/metadata/logs/crash_logs.txt</code>.",
|
||||||
"MessageM4BFailed": "M4B se nezdařil!",
|
"MessageM4BFailed": "M4B se nezdařil!",
|
||||||
"MessageM4BFinished": "M4B dokončen!",
|
"MessageM4BFinished": "M4B dokončen!",
|
||||||
"MessageMapChapterTitles": "Mapování názvů kapitol ke stávajícím kapitolám audioknihy bez úpravy časových razítek",
|
"MessageMapChapterTitles": "Mapování názvů kapitol ke stávajícím kapitolám audioknihy bez úpravy časových razítek",
|
||||||
|
@ -811,11 +815,11 @@
|
||||||
"MessageNoEpisodes": "Žádné epizody",
|
"MessageNoEpisodes": "Žádné epizody",
|
||||||
"MessageNoFoldersAvailable": "Nejsou k dispozici žádné složky",
|
"MessageNoFoldersAvailable": "Nejsou k dispozici žádné složky",
|
||||||
"MessageNoGenres": "Žádné žánry",
|
"MessageNoGenres": "Žádné žánry",
|
||||||
"MessageNoIssues": "Žádné výtisk",
|
"MessageNoIssues": "Žádné problémy",
|
||||||
"MessageNoItems": "Žádné položky",
|
"MessageNoItems": "Žádné položky",
|
||||||
"MessageNoItemsFound": "Nebyly nalezeny žádné položky",
|
"MessageNoItemsFound": "Nebyly nalezeny žádné položky",
|
||||||
"MessageNoListeningSessions": "Žádné poslechové relace",
|
"MessageNoListeningSessions": "Žádné poslechové relace",
|
||||||
"MessageNoLogs": "Žádné protokoly",
|
"MessageNoLogs": "Žádné logy",
|
||||||
"MessageNoMediaProgress": "Žádný průběh médií",
|
"MessageNoMediaProgress": "Žádný průběh médií",
|
||||||
"MessageNoNotifications": "Žádná oznámení",
|
"MessageNoNotifications": "Žádná oznámení",
|
||||||
"MessageNoPodcastFeed": "Neplatný podcast: Žádný kanál",
|
"MessageNoPodcastFeed": "Neplatný podcast: Žádný kanál",
|
||||||
|
@ -853,6 +857,7 @@
|
||||||
"MessageScheduleRunEveryWeekdayAtTime": "Spusť každý {0} v {1}",
|
"MessageScheduleRunEveryWeekdayAtTime": "Spusť každý {0} v {1}",
|
||||||
"MessageSearchResultsFor": "Výsledky hledání pro",
|
"MessageSearchResultsFor": "Výsledky hledání pro",
|
||||||
"MessageSelected": "{0} vybráno",
|
"MessageSelected": "{0} vybráno",
|
||||||
|
"MessageSeriesSequenceCannotContainSpaces": "Sekvence série nesmí obsahovat mezery",
|
||||||
"MessageServerCouldNotBeReached": "Server je nedostupný",
|
"MessageServerCouldNotBeReached": "Server je nedostupný",
|
||||||
"MessageSetChaptersFromTracksDescription": "Nastavit kapitoly jako kapitolu a název kapitoly jako název zvukového souboru",
|
"MessageSetChaptersFromTracksDescription": "Nastavit kapitoly jako kapitolu a název kapitoly jako název zvukového souboru",
|
||||||
"MessageShareExpirationWillBe": "Expiruje <strong>{0}</strong>",
|
"MessageShareExpirationWillBe": "Expiruje <strong>{0}</strong>",
|
||||||
|
@ -914,6 +919,8 @@
|
||||||
"NotificationOnBackupCompletedDescription": "Spuštěno po dokončení zálohování",
|
"NotificationOnBackupCompletedDescription": "Spuštěno po dokončení zálohování",
|
||||||
"NotificationOnBackupFailedDescription": "Spuštěno pokud zálohování selže",
|
"NotificationOnBackupFailedDescription": "Spuštěno pokud zálohování selže",
|
||||||
"NotificationOnEpisodeDownloadedDescription": "Spuštěno při automatickém stažení epizody podcastu",
|
"NotificationOnEpisodeDownloadedDescription": "Spuštěno při automatickém stažení epizody podcastu",
|
||||||
|
"NotificationOnRSSFeedDisabledDescription": "Aktivováno když je automatické stahování pozastaveno z důvodu příliš mnoho neůspěšných pokusů",
|
||||||
|
"NotificationOnRSSFeedFailedDescription": "Aktivováno když selže RSS kanál pro stahování epizod",
|
||||||
"NotificationOnTestDescription": "Akce pro otestování upozorňovacího systému",
|
"NotificationOnTestDescription": "Akce pro otestování upozorňovacího systému",
|
||||||
"PlaceholderNewCollection": "Nový název kolekce",
|
"PlaceholderNewCollection": "Nový název kolekce",
|
||||||
"PlaceholderNewFolderPath": "Nová cesta ke složce",
|
"PlaceholderNewFolderPath": "Nová cesta ke složce",
|
||||||
|
@ -958,7 +965,7 @@
|
||||||
"ToastBackupRestoreFailed": "Nepodařilo se obnovit zálohu",
|
"ToastBackupRestoreFailed": "Nepodařilo se obnovit zálohu",
|
||||||
"ToastBackupUploadFailed": "Nepodařilo se nahrát zálohu",
|
"ToastBackupUploadFailed": "Nepodařilo se nahrát zálohu",
|
||||||
"ToastBackupUploadSuccess": "Záloha nahrána",
|
"ToastBackupUploadSuccess": "Záloha nahrána",
|
||||||
"ToastBatchApplyDetailsToItemsSuccess": "Detaily aplikované na položky",
|
"ToastBatchApplyDetailsToItemsSuccess": "Detaily byly aplikované na položky",
|
||||||
"ToastBatchDeleteFailed": "Hromadné smazání selhalo",
|
"ToastBatchDeleteFailed": "Hromadné smazání selhalo",
|
||||||
"ToastBatchDeleteSuccess": "Hromadné smazání proběhlo úspěšně",
|
"ToastBatchDeleteSuccess": "Hromadné smazání proběhlo úspěšně",
|
||||||
"ToastBatchQuickMatchFailed": "Rychlá schoda dávky se nezdařila!",
|
"ToastBatchQuickMatchFailed": "Rychlá schoda dávky se nezdařila!",
|
||||||
|
@ -971,6 +978,8 @@
|
||||||
"ToastCachePurgeFailed": "Nepodařilo se vyčistit mezipaměť",
|
"ToastCachePurgeFailed": "Nepodařilo se vyčistit mezipaměť",
|
||||||
"ToastCachePurgeSuccess": "Vyrovnávací paměť úspěšně vyčištěna",
|
"ToastCachePurgeSuccess": "Vyrovnávací paměť úspěšně vyčištěna",
|
||||||
"ToastChaptersHaveErrors": "Kapitoly obsahují chyby",
|
"ToastChaptersHaveErrors": "Kapitoly obsahují chyby",
|
||||||
|
"ToastChaptersInvalidShiftAmountLast": "Nesprávná délka posunu. Čas začátku poslední kapitoly by přesáhl dobu trvání této audioknihy.",
|
||||||
|
"ToastChaptersInvalidShiftAmountStart": "Nesprávná délka posunu. První kapitola by měla nulovou nebo zápornou délku a byla by přepsána druhou kapitolou. Zvětšete čas začátku druhé kapitoly.",
|
||||||
"ToastChaptersMustHaveTitles": "Kapitoly musí mít názvy",
|
"ToastChaptersMustHaveTitles": "Kapitoly musí mít názvy",
|
||||||
"ToastChaptersRemoved": "Kapitoly odstraněny",
|
"ToastChaptersRemoved": "Kapitoly odstraněny",
|
||||||
"ToastChaptersUpdated": "Kapitola aktualizována",
|
"ToastChaptersUpdated": "Kapitola aktualizována",
|
||||||
|
@ -1091,7 +1100,7 @@
|
||||||
"ToastUnlinkOpenIdFailed": "Chyba při odpárování uživatele z OpenID",
|
"ToastUnlinkOpenIdFailed": "Chyba při odpárování uživatele z OpenID",
|
||||||
"ToastUnlinkOpenIdSuccess": "Uživatel odpárován z uživatele z OpenID",
|
"ToastUnlinkOpenIdSuccess": "Uživatel odpárován z uživatele z OpenID",
|
||||||
"ToastUploaderFilepathExistsError": "Soubor \"{0}\" na serveru již existuje",
|
"ToastUploaderFilepathExistsError": "Soubor \"{0}\" na serveru již existuje",
|
||||||
"ToastUploaderItemExistsInSubdirectoryError": "Položka \"{0}\" používá podsložku nahrávané cesty.",
|
"ToastUploaderItemExistsInSubdirectoryError": "Položka \"{0}\" používá podadresář cesty pro nahrání.",
|
||||||
"ToastUserDeleteFailed": "Nepodařilo se smazat uživatele",
|
"ToastUserDeleteFailed": "Nepodařilo se smazat uživatele",
|
||||||
"ToastUserDeleteSuccess": "Uživatel smazán",
|
"ToastUserDeleteSuccess": "Uživatel smazán",
|
||||||
"ToastUserPasswordChangeSuccess": "Heslo bylo změněno úspěšně",
|
"ToastUserPasswordChangeSuccess": "Heslo bylo změněno úspěšně",
|
||||||
|
|
|
@ -177,6 +177,7 @@
|
||||||
"HeaderPlaylist": "Afspilningsliste",
|
"HeaderPlaylist": "Afspilningsliste",
|
||||||
"HeaderPlaylistItems": "Afspilningsliste Elementer",
|
"HeaderPlaylistItems": "Afspilningsliste Elementer",
|
||||||
"HeaderPodcastsToAdd": "Podcasts til Tilføjelse",
|
"HeaderPodcastsToAdd": "Podcasts til Tilføjelse",
|
||||||
|
"HeaderPresets": "Forudindstillinger",
|
||||||
"HeaderPreviewCover": "Forhåndsvis Omslag",
|
"HeaderPreviewCover": "Forhåndsvis Omslag",
|
||||||
"HeaderRSSFeedGeneral": "RSS Detaljer",
|
"HeaderRSSFeedGeneral": "RSS Detaljer",
|
||||||
"HeaderRSSFeedIsOpen": "RSS Feed er Åben",
|
"HeaderRSSFeedIsOpen": "RSS Feed er Åben",
|
||||||
|
@ -513,7 +514,7 @@
|
||||||
"LabelPublishers": "Forlag",
|
"LabelPublishers": "Forlag",
|
||||||
"LabelRSSFeedCustomOwnerEmail": "Brugerdefineret ejerens e-mail",
|
"LabelRSSFeedCustomOwnerEmail": "Brugerdefineret ejerens e-mail",
|
||||||
"LabelRSSFeedCustomOwnerName": "Brugerdefineret ejerens navn",
|
"LabelRSSFeedCustomOwnerName": "Brugerdefineret ejerens navn",
|
||||||
"LabelRSSFeedOpen": "Åben RSS-feed",
|
"LabelRSSFeedOpen": "RSS-feed åbent",
|
||||||
"LabelRSSFeedPreventIndexing": "Forhindrer indeksering",
|
"LabelRSSFeedPreventIndexing": "Forhindrer indeksering",
|
||||||
"LabelRSSFeedSlug": "RSS-feed-slug",
|
"LabelRSSFeedSlug": "RSS-feed-slug",
|
||||||
"LabelRSSFeedURL": "RSS-feed-URL",
|
"LabelRSSFeedURL": "RSS-feed-URL",
|
||||||
|
@ -530,6 +531,7 @@
|
||||||
"LabelReleaseDate": "Udgivelsesdato",
|
"LabelReleaseDate": "Udgivelsesdato",
|
||||||
"LabelRemoveAllMetadataAbs": "Fjern alle metadata.abs filer",
|
"LabelRemoveAllMetadataAbs": "Fjern alle metadata.abs filer",
|
||||||
"LabelRemoveAllMetadataJson": "Fjern alle metadata.json filer",
|
"LabelRemoveAllMetadataJson": "Fjern alle metadata.json filer",
|
||||||
|
"LabelRemoveAudibleBranding": "Fjern Audible intro og outro fra kapitler",
|
||||||
"LabelRemoveCover": "Fjern omslag",
|
"LabelRemoveCover": "Fjern omslag",
|
||||||
"LabelRemoveMetadataFile": "Fjern alle metadata filer i biblioteksmapper",
|
"LabelRemoveMetadataFile": "Fjern alle metadata filer i biblioteksmapper",
|
||||||
"LabelRemoveMetadataFileHelp": "Fjern alle metadata.json og metadata.abs filer i dine {0} mapper.",
|
"LabelRemoveMetadataFileHelp": "Fjern alle metadata.json og metadata.abs filer i dine {0} mapper.",
|
||||||
|
@ -604,6 +606,7 @@
|
||||||
"LabelSlug": "Snegl",
|
"LabelSlug": "Snegl",
|
||||||
"LabelSortAscending": "Stigende",
|
"LabelSortAscending": "Stigende",
|
||||||
"LabelSortDescending": "Faldende",
|
"LabelSortDescending": "Faldende",
|
||||||
|
"LabelSortPubDate": "Sortér Pub Dato",
|
||||||
"LabelStart": "Start",
|
"LabelStart": "Start",
|
||||||
"LabelStartTime": "Starttid",
|
"LabelStartTime": "Starttid",
|
||||||
"LabelStarted": "Startet",
|
"LabelStarted": "Startet",
|
||||||
|
@ -704,6 +707,8 @@
|
||||||
"LabelYourProgress": "Din fremgang",
|
"LabelYourProgress": "Din fremgang",
|
||||||
"MessageAddToPlayerQueue": "Tilføj til afspilningskø",
|
"MessageAddToPlayerQueue": "Tilføj til afspilningskø",
|
||||||
"MessageAppriseDescription": "For at bruge denne funktion skal du have en instans af <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> kørende eller en API, der håndterer de samme anmodninger. <br /> Apprise API-webadressen skal være den fulde URL-sti for at sende underretningen, f.eks. hvis din API-instans er tilgængelig på <code>http://192.168.1.1:8337</code>, så skal du bruge <code>http://192.168.1.1:8337/notify</code>.",
|
"MessageAppriseDescription": "For at bruge denne funktion skal du have en instans af <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> kørende eller en API, der håndterer de samme anmodninger. <br /> Apprise API-webadressen skal være den fulde URL-sti for at sende underretningen, f.eks. hvis din API-instans er tilgængelig på <code>http://192.168.1.1:8337</code>, så skal du bruge <code>http://192.168.1.1:8337/notify</code>.",
|
||||||
|
"MessageAsinCheck": "Sikr dig at du bruger ASIN fra den korrekte Audible region, ikke Amazon.",
|
||||||
|
"MessageAuthenticationOIDCChangesRestart": "Genstart sin server efter du har gemt for at bekræfte OIDC ændringer.",
|
||||||
"MessageBackupsDescription": "Backups inkluderer brugere, brugerfremskridt, biblioteksvareoplysninger, serverindstillinger og billeder gemt i <code>/metadata/items</code> og <code>/metadata/authors</code>. Backups inkluderer <strong>ikke</strong> nogen filer gemt i dine biblioteksmapper.",
|
"MessageBackupsDescription": "Backups inkluderer brugere, brugerfremskridt, biblioteksvareoplysninger, serverindstillinger og billeder gemt i <code>/metadata/items</code> og <code>/metadata/authors</code>. Backups inkluderer <strong>ikke</strong> nogen filer gemt i dine biblioteksmapper.",
|
||||||
"MessageBackupsLocationEditNote": "Note: Opdatering af backup sti vil ikke fjerne eller modificere eksisterende backups",
|
"MessageBackupsLocationEditNote": "Note: Opdatering af backup sti vil ikke fjerne eller modificere eksisterende backups",
|
||||||
"MessageBackupsLocationNoEditNote": "Note: Backup sti er sat igennem miljøvariabel og kan ikke ændres her.",
|
"MessageBackupsLocationNoEditNote": "Note: Backup sti er sat igennem miljøvariabel og kan ikke ændres her.",
|
||||||
|
@ -722,6 +727,7 @@
|
||||||
"MessageChapterErrorStartGteDuration": "Ugyldig starttid skal være mindre end lydbogens varighed",
|
"MessageChapterErrorStartGteDuration": "Ugyldig starttid skal være mindre end lydbogens varighed",
|
||||||
"MessageChapterErrorStartLtPrev": "Ugyldig starttid skal være større end eller lig med den foregående kapitels starttid",
|
"MessageChapterErrorStartLtPrev": "Ugyldig starttid skal være større end eller lig med den foregående kapitels starttid",
|
||||||
"MessageChapterStartIsAfter": "Kapitelstarten er efter slutningen af din lydbog",
|
"MessageChapterStartIsAfter": "Kapitelstarten er efter slutningen af din lydbog",
|
||||||
|
"MessageChaptersNotFound": "Kapitler ikke fundet",
|
||||||
"MessageCheckingCron": "Tjekker cron...",
|
"MessageCheckingCron": "Tjekker cron...",
|
||||||
"MessageConfirmCloseFeed": "Er du sikker på, at du vil lukke dette feed?",
|
"MessageConfirmCloseFeed": "Er du sikker på, at du vil lukke dette feed?",
|
||||||
"MessageConfirmDeleteBackup": "Er du sikker på, at du vil slette backup for {0}?",
|
"MessageConfirmDeleteBackup": "Er du sikker på, at du vil slette backup for {0}?",
|
||||||
|
@ -778,6 +784,7 @@
|
||||||
"MessageForceReScanDescription": "vil scanne alle filer igen som en frisk scanning. Lydfilens ID3-tags, OPF-filer og tekstfiler scannes som nye.",
|
"MessageForceReScanDescription": "vil scanne alle filer igen som en frisk scanning. Lydfilens ID3-tags, OPF-filer og tekstfiler scannes som nye.",
|
||||||
"MessageImportantNotice": "Vigtig besked!",
|
"MessageImportantNotice": "Vigtig besked!",
|
||||||
"MessageInsertChapterBelow": "Indsæt kapitel nedenfor",
|
"MessageInsertChapterBelow": "Indsæt kapitel nedenfor",
|
||||||
|
"MessageInvalidAsin": "Ugyldig ASIN",
|
||||||
"MessageItemsSelected": "{0} elementer valgt",
|
"MessageItemsSelected": "{0} elementer valgt",
|
||||||
"MessageItemsUpdated": "{0} elementer opdateret",
|
"MessageItemsUpdated": "{0} elementer opdateret",
|
||||||
"MessageJoinUsOn": "Deltag i os på",
|
"MessageJoinUsOn": "Deltag i os på",
|
||||||
|
@ -849,6 +856,7 @@
|
||||||
"MessageScheduleRunEveryWeekdayAtTime": "Kør hvert {0} af {1}",
|
"MessageScheduleRunEveryWeekdayAtTime": "Kør hvert {0} af {1}",
|
||||||
"MessageSearchResultsFor": "Søgeresultater for",
|
"MessageSearchResultsFor": "Søgeresultater for",
|
||||||
"MessageSelected": "{0} valgt",
|
"MessageSelected": "{0} valgt",
|
||||||
|
"MessageSeriesSequenceCannotContainSpaces": "Serie sekvens kan ikke indeholde mellemrum",
|
||||||
"MessageServerCouldNotBeReached": "Serveren kunne ikke nås",
|
"MessageServerCouldNotBeReached": "Serveren kunne ikke nås",
|
||||||
"MessageSetChaptersFromTracksDescription": "Indstil kapitler ved at bruge hver lydfil som et kapitel og kapiteloverskrift som lydfilnavn",
|
"MessageSetChaptersFromTracksDescription": "Indstil kapitler ved at bruge hver lydfil som et kapitel og kapiteloverskrift som lydfilnavn",
|
||||||
"MessageShareExpirationWillBe": "Udløb vil være <strong>{0}</strong>",
|
"MessageShareExpirationWillBe": "Udløb vil være <strong>{0}</strong>",
|
||||||
|
@ -910,6 +918,8 @@
|
||||||
"NotificationOnBackupCompletedDescription": "Udløst når backup er færdig",
|
"NotificationOnBackupCompletedDescription": "Udløst når backup er færdig",
|
||||||
"NotificationOnBackupFailedDescription": "Udløst når backup fejler",
|
"NotificationOnBackupFailedDescription": "Udløst når backup fejler",
|
||||||
"NotificationOnEpisodeDownloadedDescription": "Udløst når et podcast afsnit er automatisk downloadet",
|
"NotificationOnEpisodeDownloadedDescription": "Udløst når et podcast afsnit er automatisk downloadet",
|
||||||
|
"NotificationOnRSSFeedDisabledDescription": "Aktiveret når automatiske episode-downloads er slået fra, på grund af for mange forsøg",
|
||||||
|
"NotificationOnRSSFeedFailedDescription": "Aktiveret når anmodning om RSS-feedet fejler for en automatisk episode-download",
|
||||||
"NotificationOnTestDescription": "Event for test af notifikationssystemet",
|
"NotificationOnTestDescription": "Event for test af notifikationssystemet",
|
||||||
"PlaceholderNewCollection": "Nyt samlingnavn",
|
"PlaceholderNewCollection": "Nyt samlingnavn",
|
||||||
"PlaceholderNewFolderPath": "Ny mappes sti",
|
"PlaceholderNewFolderPath": "Ny mappes sti",
|
||||||
|
@ -954,6 +964,7 @@
|
||||||
"ToastBackupRestoreFailed": "Mislykkedes gendannelse af sikkerhedskopi",
|
"ToastBackupRestoreFailed": "Mislykkedes gendannelse af sikkerhedskopi",
|
||||||
"ToastBackupUploadFailed": "Mislykkedes upload af sikkerhedskopi",
|
"ToastBackupUploadFailed": "Mislykkedes upload af sikkerhedskopi",
|
||||||
"ToastBackupUploadSuccess": "Sikkerhedskopi uploadet",
|
"ToastBackupUploadSuccess": "Sikkerhedskopi uploadet",
|
||||||
|
"ToastBatchApplyDetailsToItemsSuccess": "Detaljer bekræftet på element",
|
||||||
"ToastBatchDeleteFailed": "Batch slet fejlede",
|
"ToastBatchDeleteFailed": "Batch slet fejlede",
|
||||||
"ToastBatchDeleteSuccess": "Batch slet succes",
|
"ToastBatchDeleteSuccess": "Batch slet succes",
|
||||||
"ToastBatchQuickMatchFailed": "Batch Hurtig Match fejlede!",
|
"ToastBatchQuickMatchFailed": "Batch Hurtig Match fejlede!",
|
||||||
|
|
|
@ -32,7 +32,7 @@
|
||||||
"ButtonEditChapters": "Kapitel bearbeiten",
|
"ButtonEditChapters": "Kapitel bearbeiten",
|
||||||
"ButtonEditPodcast": "Podcast bearbeiten",
|
"ButtonEditPodcast": "Podcast bearbeiten",
|
||||||
"ButtonEnable": "Aktivieren",
|
"ButtonEnable": "Aktivieren",
|
||||||
"ButtonFireAndFail": "Abfeuern und versagen",
|
"ButtonFireAndFail": "Abschicken und fehlschlagen",
|
||||||
"ButtonFireOnTest": "Test-Event abfeuern",
|
"ButtonFireOnTest": "Test-Event abfeuern",
|
||||||
"ButtonForceReScan": "Komplett-Scan (alle Medien)",
|
"ButtonForceReScan": "Komplett-Scan (alle Medien)",
|
||||||
"ButtonFullPath": "Vollständiger Pfad",
|
"ButtonFullPath": "Vollständiger Pfad",
|
||||||
|
@ -53,7 +53,7 @@
|
||||||
"ButtonNext": "Vor",
|
"ButtonNext": "Vor",
|
||||||
"ButtonNextChapter": "Nächstes Kapitel",
|
"ButtonNextChapter": "Nächstes Kapitel",
|
||||||
"ButtonNextItemInQueue": "Das nächste Element in der Warteschlange",
|
"ButtonNextItemInQueue": "Das nächste Element in der Warteschlange",
|
||||||
"ButtonOk": "Einverstanden",
|
"ButtonOk": "Ok",
|
||||||
"ButtonOpenFeed": "Feed öffnen",
|
"ButtonOpenFeed": "Feed öffnen",
|
||||||
"ButtonOpenManager": "Manager öffnen",
|
"ButtonOpenManager": "Manager öffnen",
|
||||||
"ButtonPause": "Pausieren",
|
"ButtonPause": "Pausieren",
|
||||||
|
@ -708,7 +708,7 @@
|
||||||
"MessageAddToPlayerQueue": "Zur Abspielwarteliste hinzufügen",
|
"MessageAddToPlayerQueue": "Zur Abspielwarteliste hinzufügen",
|
||||||
"MessageAppriseDescription": "Um diese Funktion nutzen zu können, musst du eine Instanz von <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> laufen haben oder eine API verwenden welche dieselbe Anfragen bearbeiten kann. <br />Die Apprise API Url muss der vollständige URL-Pfad sein, an den die Benachrichtigung gesendet werden soll, z.B. wenn Ihre API-Instanz unter <code>http://192.168.1.1:8337</code> läuft, würdest du <code>http://192.168.1.1:8337/notify</code> eingeben.",
|
"MessageAppriseDescription": "Um diese Funktion nutzen zu können, musst du eine Instanz von <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> laufen haben oder eine API verwenden welche dieselbe Anfragen bearbeiten kann. <br />Die Apprise API Url muss der vollständige URL-Pfad sein, an den die Benachrichtigung gesendet werden soll, z.B. wenn Ihre API-Instanz unter <code>http://192.168.1.1:8337</code> läuft, würdest du <code>http://192.168.1.1:8337/notify</code> eingeben.",
|
||||||
"MessageAsinCheck": "Stellen Sie sicher, dass Sie die ASIN aus der richtigen Audible Region verwenden, nicht Amazon.",
|
"MessageAsinCheck": "Stellen Sie sicher, dass Sie die ASIN aus der richtigen Audible Region verwenden, nicht Amazon.",
|
||||||
"MessageAuthenticationOIDCChangesRestart": "Nach dem Speichern muß der Server neugestartet werden um die OIDC Änderungen zu übernehmen.",
|
"MessageAuthenticationOIDCChangesRestart": "Nach dem Speichern muss der Server neugestartet werden um die OIDC Änderungen zu übernehmen.",
|
||||||
"MessageBackupsDescription": "In einer Sicherung werden Benutzer, Benutzerfortschritte, Details zu den Bibliotheksobjekten, Servereinstellungen und Bilder welche in <code>/metadata/items</code> & <code>/metadata/authors</code> gespeichert sind gespeichert. Sicherungen enthalten keine Dateien welche in den einzelnen Bibliotheksordnern (Medien-Ordnern) gespeichert sind.",
|
"MessageBackupsDescription": "In einer Sicherung werden Benutzer, Benutzerfortschritte, Details zu den Bibliotheksobjekten, Servereinstellungen und Bilder welche in <code>/metadata/items</code> & <code>/metadata/authors</code> gespeichert sind gespeichert. Sicherungen enthalten keine Dateien welche in den einzelnen Bibliotheksordnern (Medien-Ordnern) gespeichert sind.",
|
||||||
"MessageBackupsLocationEditNote": "Hinweis: Durch das Aktualisieren des Backup-Speicherorts werden vorhandene Sicherungen nicht verschoben oder geändert",
|
"MessageBackupsLocationEditNote": "Hinweis: Durch das Aktualisieren des Backup-Speicherorts werden vorhandene Sicherungen nicht verschoben oder geändert",
|
||||||
"MessageBackupsLocationNoEditNote": "Hinweis: Der Sicherungsspeicherort wird über eine Umgebungsvariable festgelegt und kann hier nicht geändert werden.",
|
"MessageBackupsLocationNoEditNote": "Hinweis: Der Sicherungsspeicherort wird über eine Umgebungsvariable festgelegt und kann hier nicht geändert werden.",
|
||||||
|
@ -757,6 +757,7 @@
|
||||||
"MessageConfirmRemoveAuthor": "Autor \"{0}\" wird enfernt! Bist du dir sicher?",
|
"MessageConfirmRemoveAuthor": "Autor \"{0}\" wird enfernt! Bist du dir sicher?",
|
||||||
"MessageConfirmRemoveCollection": "Sammlung \"{0}\" wird entfernt! Bist du dir sicher?",
|
"MessageConfirmRemoveCollection": "Sammlung \"{0}\" wird entfernt! Bist du dir sicher?",
|
||||||
"MessageConfirmRemoveEpisode": "Episode \"{0}\" wird entfernt! Bist du dir sicher?",
|
"MessageConfirmRemoveEpisode": "Episode \"{0}\" wird entfernt! Bist du dir sicher?",
|
||||||
|
"MessageConfirmRemoveEpisodeNote": "Hinweis: Die Audiodatei wird nicht gelöscht, es sei denn \"Datei dauerhaft löschen\" ist aktiviert",
|
||||||
"MessageConfirmRemoveEpisodes": "{0} Episoden werden entfernt! Bist du dir sicher?",
|
"MessageConfirmRemoveEpisodes": "{0} Episoden werden entfernt! Bist du dir sicher?",
|
||||||
"MessageConfirmRemoveListeningSessions": "Bist du dir sicher, dass du {0} Hörsitzungen enfernen möchtest?",
|
"MessageConfirmRemoveListeningSessions": "Bist du dir sicher, dass du {0} Hörsitzungen enfernen möchtest?",
|
||||||
"MessageConfirmRemoveMetadataFiles": "Bist du sicher, dass du alle metadata.{0} Dateien in deinen Bibliotheksordnern löschen willst?",
|
"MessageConfirmRemoveMetadataFiles": "Bist du sicher, dass du alle metadata.{0} Dateien in deinen Bibliotheksordnern löschen willst?",
|
||||||
|
@ -852,13 +853,13 @@
|
||||||
"MessageResetChaptersConfirm": "Kapitel und vorgenommenen Änderungen werden zurückgesetzt und rückgängig gemacht! Bist du dir sicher?",
|
"MessageResetChaptersConfirm": "Kapitel und vorgenommenen Änderungen werden zurückgesetzt und rückgängig gemacht! Bist du dir sicher?",
|
||||||
"MessageRestoreBackupConfirm": "Bist du dir sicher, dass du die Sicherung wiederherstellen willst, welche am",
|
"MessageRestoreBackupConfirm": "Bist du dir sicher, dass du die Sicherung wiederherstellen willst, welche am",
|
||||||
"MessageRestoreBackupWarning": "Bei der Wiederherstellung einer Sicherung wird die gesamte Datenbank unter /config und die Titelbilder in /metadata/items und /metadata/authors überschrieben.<br /><br />Bei der Sicherung werden keine Dateien in deinen Bibliotheksordnern verändert. Wenn du die Servereinstellungen aktiviert hast, um Cover und Metadaten in deinen Bibliotheksordnern zu speichern, werden diese nicht gesichert oder überschrieben.<br /><br />Alle Clients, die Ihren Server nutzen, werden automatisch aktualisiert.",
|
"MessageRestoreBackupWarning": "Bei der Wiederherstellung einer Sicherung wird die gesamte Datenbank unter /config und die Titelbilder in /metadata/items und /metadata/authors überschrieben.<br /><br />Bei der Sicherung werden keine Dateien in deinen Bibliotheksordnern verändert. Wenn du die Servereinstellungen aktiviert hast, um Cover und Metadaten in deinen Bibliotheksordnern zu speichern, werden diese nicht gesichert oder überschrieben.<br /><br />Alle Clients, die Ihren Server nutzen, werden automatisch aktualisiert.",
|
||||||
"MessageScheduleLibraryScanNote": "Für die meisten Nutzer wird empfohlen, diese Funktion deaktiviert zu lassen und stattdessen die Ordnerüberwachung aktiviert zu lassen. Die Ordnerüberwachung erkennt automatisch Änderungen in deinen Bibliotheksordnern. Da die Ordnerüberwachung jedoch nicht mit jedem Dateisystem (z.B. NFS) funktioniert, können alternativ hier geplante Bibliotheks-Scans aktiviert werden.",
|
"MessageScheduleLibraryScanNote": "Für die meisten Anwender wird empfohlen, diese Funktion deaktiviert und die Ordnerüberwachung aktiviert zu lassen. Die Ordnerüberwachung wird Änderungen in den Bibliotheksordnern automatisch erkennen. Die Ordnerüberwachung funktioniert nicht mit allen Dateisystemen (wie NFS), hier kann stattdessen die automatischen Bibliothekssuchen verwendet werden.",
|
||||||
"MessageScheduleRunEveryWeekdayAtTime": "Immer {0} um {1} ausführen",
|
"MessageScheduleRunEveryWeekdayAtTime": "Immer {0} um {1} ausführen",
|
||||||
"MessageSearchResultsFor": "Suchergebnisse für",
|
"MessageSearchResultsFor": "Suchergebnisse für",
|
||||||
"MessageSelected": "{0} ausgewählt",
|
"MessageSelected": "{0} ausgewählt",
|
||||||
"MessageSeriesSequenceCannotContainSpaces": "Serie Abfolge kann keine Leerzeichen enthalten",
|
"MessageSeriesSequenceCannotContainSpaces": "Serie Abfolge kann keine Leerzeichen enthalten",
|
||||||
"MessageServerCouldNotBeReached": "Server kann nicht erreicht werden",
|
"MessageServerCouldNotBeReached": "Server kann nicht erreicht werden",
|
||||||
"MessageSetChaptersFromTracksDescription": "Kaitelerstellung basiert auf den existierenden einzelnen Audiodateien. Pro existierende Audiodatei wird 1 Kapitel erstellt, wobei deren Kapitelname aus dem Audiodateinamen extrahiert wird",
|
"MessageSetChaptersFromTracksDescription": "Kapitelerstellung basiert auf den existierenden einzelnen Audiodateien. Pro existierende Audiodatei wird 1 Kapitel erstellt, wobei deren Kapitelname aus dem Audiodateinamen extrahiert wird",
|
||||||
"MessageShareExpirationWillBe": "Läuft am <strong>{0}</strong> ab",
|
"MessageShareExpirationWillBe": "Läuft am <strong>{0}</strong> ab",
|
||||||
"MessageShareExpiresIn": "Läuft in {0} ab",
|
"MessageShareExpiresIn": "Läuft in {0} ab",
|
||||||
"MessageShareURLWillBe": "Der Freigabe Link wird <strong>{0}</strong> sein",
|
"MessageShareURLWillBe": "Der Freigabe Link wird <strong>{0}</strong> sein",
|
||||||
|
@ -918,6 +919,8 @@
|
||||||
"NotificationOnBackupCompletedDescription": "Wird ausgeführt wenn ein Backup erstellt wurde",
|
"NotificationOnBackupCompletedDescription": "Wird ausgeführt wenn ein Backup erstellt wurde",
|
||||||
"NotificationOnBackupFailedDescription": "Wird ausgeführt wenn ein Backup fehlgeschlagen ist",
|
"NotificationOnBackupFailedDescription": "Wird ausgeführt wenn ein Backup fehlgeschlagen ist",
|
||||||
"NotificationOnEpisodeDownloadedDescription": "Wird ausgeführt wenn eine Podcast Folge automatisch heruntergeladen wird",
|
"NotificationOnEpisodeDownloadedDescription": "Wird ausgeführt wenn eine Podcast Folge automatisch heruntergeladen wird",
|
||||||
|
"NotificationOnRSSFeedDisabledDescription": "Wird ausgeführt wenn automatische Downloads von Episoden wegen zu vielen fehlgeschlagenen Versuchen deaktiviert sind",
|
||||||
|
"NotificationOnRSSFeedFailedDescription": "Wird ausgelöst, wenn die RSS-Feed-Anforderung für einen automatischen Episoden-Download fehlschlägt",
|
||||||
"NotificationOnTestDescription": "Wird ausgeführt wenn das Benachrichtigungssystem getestet wird",
|
"NotificationOnTestDescription": "Wird ausgeführt wenn das Benachrichtigungssystem getestet wird",
|
||||||
"PlaceholderNewCollection": "Neuer Sammlungsname",
|
"PlaceholderNewCollection": "Neuer Sammlungsname",
|
||||||
"PlaceholderNewFolderPath": "Neuer Ordnerpfad",
|
"PlaceholderNewFolderPath": "Neuer Ordnerpfad",
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
{
|
{
|
||||||
"ButtonAdd": "Add",
|
"ButtonAdd": "Add",
|
||||||
|
"ButtonAddApiKey": "Add API Key",
|
||||||
"ButtonAddChapters": "Add Chapters",
|
"ButtonAddChapters": "Add Chapters",
|
||||||
"ButtonAddDevice": "Add Device",
|
"ButtonAddDevice": "Add Device",
|
||||||
"ButtonAddLibrary": "Add Library",
|
"ButtonAddLibrary": "Add Library",
|
||||||
|
@ -20,6 +21,7 @@
|
||||||
"ButtonChooseAFolder": "Choose a folder",
|
"ButtonChooseAFolder": "Choose a folder",
|
||||||
"ButtonChooseFiles": "Choose files",
|
"ButtonChooseFiles": "Choose files",
|
||||||
"ButtonClearFilter": "Clear Filter",
|
"ButtonClearFilter": "Clear Filter",
|
||||||
|
"ButtonClose": "Close",
|
||||||
"ButtonCloseFeed": "Close Feed",
|
"ButtonCloseFeed": "Close Feed",
|
||||||
"ButtonCloseSession": "Close Open Session",
|
"ButtonCloseSession": "Close Open Session",
|
||||||
"ButtonCollections": "Collections",
|
"ButtonCollections": "Collections",
|
||||||
|
@ -119,6 +121,7 @@
|
||||||
"HeaderAccount": "Account",
|
"HeaderAccount": "Account",
|
||||||
"HeaderAddCustomMetadataProvider": "Add Custom Metadata Provider",
|
"HeaderAddCustomMetadataProvider": "Add Custom Metadata Provider",
|
||||||
"HeaderAdvanced": "Advanced",
|
"HeaderAdvanced": "Advanced",
|
||||||
|
"HeaderApiKeys": "API Keys",
|
||||||
"HeaderAppriseNotificationSettings": "Apprise Notification Settings",
|
"HeaderAppriseNotificationSettings": "Apprise Notification Settings",
|
||||||
"HeaderAudioTracks": "Audio Tracks",
|
"HeaderAudioTracks": "Audio Tracks",
|
||||||
"HeaderAudiobookTools": "Audiobook File Management Tools",
|
"HeaderAudiobookTools": "Audiobook File Management Tools",
|
||||||
|
@ -162,6 +165,7 @@
|
||||||
"HeaderMetadataOrderOfPrecedence": "Metadata order of precedence",
|
"HeaderMetadataOrderOfPrecedence": "Metadata order of precedence",
|
||||||
"HeaderMetadataToEmbed": "Metadata to embed",
|
"HeaderMetadataToEmbed": "Metadata to embed",
|
||||||
"HeaderNewAccount": "New Account",
|
"HeaderNewAccount": "New Account",
|
||||||
|
"HeaderNewApiKey": "New API Key",
|
||||||
"HeaderNewLibrary": "New Library",
|
"HeaderNewLibrary": "New Library",
|
||||||
"HeaderNotificationCreate": "Create Notification",
|
"HeaderNotificationCreate": "Create Notification",
|
||||||
"HeaderNotificationUpdate": "Update Notification",
|
"HeaderNotificationUpdate": "Update Notification",
|
||||||
|
@ -206,6 +210,7 @@
|
||||||
"HeaderTableOfContents": "Table of Contents",
|
"HeaderTableOfContents": "Table of Contents",
|
||||||
"HeaderTools": "Tools",
|
"HeaderTools": "Tools",
|
||||||
"HeaderUpdateAccount": "Update Account",
|
"HeaderUpdateAccount": "Update Account",
|
||||||
|
"HeaderUpdateApiKey": "Update API Key",
|
||||||
"HeaderUpdateAuthor": "Update Author",
|
"HeaderUpdateAuthor": "Update Author",
|
||||||
"HeaderUpdateDetails": "Update Details",
|
"HeaderUpdateDetails": "Update Details",
|
||||||
"HeaderUpdateLibrary": "Update Library",
|
"HeaderUpdateLibrary": "Update Library",
|
||||||
|
@ -235,6 +240,10 @@
|
||||||
"LabelAllUsersExcludingGuests": "All users excluding guests",
|
"LabelAllUsersExcludingGuests": "All users excluding guests",
|
||||||
"LabelAllUsersIncludingGuests": "All users including guests",
|
"LabelAllUsersIncludingGuests": "All users including guests",
|
||||||
"LabelAlreadyInYourLibrary": "Already in your library",
|
"LabelAlreadyInYourLibrary": "Already in your library",
|
||||||
|
"LabelApiKeyCreated": "API Key \"{0}\" created successfully.",
|
||||||
|
"LabelApiKeyCreatedDescription": "Make sure to copy the API key now as you will not be able to see this again.",
|
||||||
|
"LabelApiKeyUser": "Act on behalf of user",
|
||||||
|
"LabelApiKeyUserDescription": "This API key will have the same permissions as the user it is acting on behalf of. This will appear the same in logs as if the user was making the request.",
|
||||||
"LabelApiToken": "API Token",
|
"LabelApiToken": "API Token",
|
||||||
"LabelAppend": "Append",
|
"LabelAppend": "Append",
|
||||||
"LabelAudioBitrate": "Audio Bitrate (e.g. 128k)",
|
"LabelAudioBitrate": "Audio Bitrate (e.g. 128k)",
|
||||||
|
@ -346,6 +355,10 @@
|
||||||
"LabelExample": "Example",
|
"LabelExample": "Example",
|
||||||
"LabelExpandSeries": "Expand Series",
|
"LabelExpandSeries": "Expand Series",
|
||||||
"LabelExpandSubSeries": "Expand Sub Series",
|
"LabelExpandSubSeries": "Expand Sub Series",
|
||||||
|
"LabelExpired": "Expired",
|
||||||
|
"LabelExpiresAt": "Expires At",
|
||||||
|
"LabelExpiresInSeconds": "Expires in (seconds)",
|
||||||
|
"LabelExpiresNever": "Never",
|
||||||
"LabelExplicit": "Explicit",
|
"LabelExplicit": "Explicit",
|
||||||
"LabelExplicitChecked": "Explicit (checked)",
|
"LabelExplicitChecked": "Explicit (checked)",
|
||||||
"LabelExplicitUnchecked": "Not Explicit (unchecked)",
|
"LabelExplicitUnchecked": "Not Explicit (unchecked)",
|
||||||
|
@ -455,6 +468,7 @@
|
||||||
"LabelNewestEpisodes": "Newest Episodes",
|
"LabelNewestEpisodes": "Newest Episodes",
|
||||||
"LabelNextBackupDate": "Next backup date",
|
"LabelNextBackupDate": "Next backup date",
|
||||||
"LabelNextScheduledRun": "Next scheduled run",
|
"LabelNextScheduledRun": "Next scheduled run",
|
||||||
|
"LabelNoApiKeys": "No API keys",
|
||||||
"LabelNoCustomMetadataProviders": "No custom metadata providers",
|
"LabelNoCustomMetadataProviders": "No custom metadata providers",
|
||||||
"LabelNoEpisodesSelected": "No episodes selected",
|
"LabelNoEpisodesSelected": "No episodes selected",
|
||||||
"LabelNotFinished": "Not Finished",
|
"LabelNotFinished": "Not Finished",
|
||||||
|
@ -544,6 +558,7 @@
|
||||||
"LabelSelectAll": "Select all",
|
"LabelSelectAll": "Select all",
|
||||||
"LabelSelectAllEpisodes": "Select all episodes",
|
"LabelSelectAllEpisodes": "Select all episodes",
|
||||||
"LabelSelectEpisodesShowing": "Select {0} episodes showing",
|
"LabelSelectEpisodesShowing": "Select {0} episodes showing",
|
||||||
|
"LabelSelectUser": "Select user",
|
||||||
"LabelSelectUsers": "Select users",
|
"LabelSelectUsers": "Select users",
|
||||||
"LabelSendEbookToDevice": "Send Ebook to...",
|
"LabelSendEbookToDevice": "Send Ebook to...",
|
||||||
"LabelSequence": "Sequence",
|
"LabelSequence": "Sequence",
|
||||||
|
@ -709,6 +724,7 @@
|
||||||
"MessageAppriseDescription": "To use this feature you will need to have an instance of <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> running or an api that will handle those same requests. <br />The Apprise API Url should be the full URL path to send the notification, e.g., if your API instance is served at <code>http://192.168.1.1:8337</code> then you would put <code>http://192.168.1.1:8337/notify</code>.",
|
"MessageAppriseDescription": "To use this feature you will need to have an instance of <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> running or an api that will handle those same requests. <br />The Apprise API Url should be the full URL path to send the notification, e.g., if your API instance is served at <code>http://192.168.1.1:8337</code> then you would put <code>http://192.168.1.1:8337/notify</code>.",
|
||||||
"MessageAsinCheck": "Ensure you are using the ASIN from the correct Audible region, not Amazon.",
|
"MessageAsinCheck": "Ensure you are using the ASIN from the correct Audible region, not Amazon.",
|
||||||
"MessageAuthenticationOIDCChangesRestart": "Restart your server after saving to apply OIDC changes.",
|
"MessageAuthenticationOIDCChangesRestart": "Restart your server after saving to apply OIDC changes.",
|
||||||
|
"MessageAuthenticationSecurityMessage": "Authentication has been improved for security. All users are required to re-login.",
|
||||||
"MessageBackupsDescription": "Backups include users, user progress, library item details, server settings, and images stored in <code>/metadata/items</code> & <code>/metadata/authors</code>. Backups <strong>do not</strong> include any files stored in your library folders.",
|
"MessageBackupsDescription": "Backups include users, user progress, library item details, server settings, and images stored in <code>/metadata/items</code> & <code>/metadata/authors</code>. Backups <strong>do not</strong> include any files stored in your library folders.",
|
||||||
"MessageBackupsLocationEditNote": "Note: Updating the backup location will not move or modify existing backups",
|
"MessageBackupsLocationEditNote": "Note: Updating the backup location will not move or modify existing backups",
|
||||||
"MessageBackupsLocationNoEditNote": "Note: The backup location is set through an environment variable and cannot be changed here.",
|
"MessageBackupsLocationNoEditNote": "Note: The backup location is set through an environment variable and cannot be changed here.",
|
||||||
|
@ -730,6 +746,7 @@
|
||||||
"MessageChaptersNotFound": "Chapters not found",
|
"MessageChaptersNotFound": "Chapters not found",
|
||||||
"MessageCheckingCron": "Checking cron...",
|
"MessageCheckingCron": "Checking cron...",
|
||||||
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
|
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
|
||||||
|
"MessageConfirmDeleteApiKey": "Are you sure you want to delete API key \"{0}\"?",
|
||||||
"MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?",
|
"MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?",
|
||||||
"MessageConfirmDeleteDevice": "Are you sure you want to delete e-reader device \"{0}\"?",
|
"MessageConfirmDeleteDevice": "Are you sure you want to delete e-reader device \"{0}\"?",
|
||||||
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
|
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
|
||||||
|
@ -757,6 +774,7 @@
|
||||||
"MessageConfirmRemoveAuthor": "Are you sure you want to remove author \"{0}\"?",
|
"MessageConfirmRemoveAuthor": "Are you sure you want to remove author \"{0}\"?",
|
||||||
"MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?",
|
"MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?",
|
||||||
"MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?",
|
"MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?",
|
||||||
|
"MessageConfirmRemoveEpisodeNote": "Note: This does not delete the audio file unless toggling \"Hard delete file\"",
|
||||||
"MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?",
|
"MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?",
|
||||||
"MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?",
|
"MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?",
|
||||||
"MessageConfirmRemoveMetadataFiles": "Are you sure you want to remove all metadata.{0} files in your library item folders?",
|
"MessageConfirmRemoveMetadataFiles": "Are you sure you want to remove all metadata.{0} files in your library item folders?",
|
||||||
|
@ -918,6 +936,8 @@
|
||||||
"NotificationOnBackupCompletedDescription": "Triggered when a backup is completed",
|
"NotificationOnBackupCompletedDescription": "Triggered when a backup is completed",
|
||||||
"NotificationOnBackupFailedDescription": "Triggered when a backup fails",
|
"NotificationOnBackupFailedDescription": "Triggered when a backup fails",
|
||||||
"NotificationOnEpisodeDownloadedDescription": "Triggered when a podcast episode is auto-downloaded",
|
"NotificationOnEpisodeDownloadedDescription": "Triggered when a podcast episode is auto-downloaded",
|
||||||
|
"NotificationOnRSSFeedDisabledDescription": "Triggered when automatic episode downloads are disabled due to too many failed attempts",
|
||||||
|
"NotificationOnRSSFeedFailedDescription": "Triggered when the RSS feed request fails for an automatic episode download",
|
||||||
"NotificationOnTestDescription": "Event for testing the notification system",
|
"NotificationOnTestDescription": "Event for testing the notification system",
|
||||||
"PlaceholderNewCollection": "New collection name",
|
"PlaceholderNewCollection": "New collection name",
|
||||||
"PlaceholderNewFolderPath": "New folder path",
|
"PlaceholderNewFolderPath": "New folder path",
|
||||||
|
@ -998,6 +1018,8 @@
|
||||||
"ToastEpisodeDownloadQueueClearSuccess": "Episode download queue cleared",
|
"ToastEpisodeDownloadQueueClearSuccess": "Episode download queue cleared",
|
||||||
"ToastEpisodeUpdateSuccess": "{0} episodes updated",
|
"ToastEpisodeUpdateSuccess": "{0} episodes updated",
|
||||||
"ToastErrorCannotShare": "Cannot share natively on this device",
|
"ToastErrorCannotShare": "Cannot share natively on this device",
|
||||||
|
"ToastFailedToCreate": "Failed to create",
|
||||||
|
"ToastFailedToDelete": "Failed to delete",
|
||||||
"ToastFailedToLoadData": "Failed to load data",
|
"ToastFailedToLoadData": "Failed to load data",
|
||||||
"ToastFailedToMatch": "Failed to match",
|
"ToastFailedToMatch": "Failed to match",
|
||||||
"ToastFailedToShare": "Failed to share",
|
"ToastFailedToShare": "Failed to share",
|
||||||
|
@ -1029,6 +1051,7 @@
|
||||||
"ToastMustHaveAtLeastOnePath": "Must have at least one path",
|
"ToastMustHaveAtLeastOnePath": "Must have at least one path",
|
||||||
"ToastNameEmailRequired": "Name and email are required",
|
"ToastNameEmailRequired": "Name and email are required",
|
||||||
"ToastNameRequired": "Name is required",
|
"ToastNameRequired": "Name is required",
|
||||||
|
"ToastNewApiKeyUserError": "Must select a user",
|
||||||
"ToastNewEpisodesFound": "{0} new episodes found",
|
"ToastNewEpisodesFound": "{0} new episodes found",
|
||||||
"ToastNewUserCreatedFailed": "Failed to create account: \"{0}\"",
|
"ToastNewUserCreatedFailed": "Failed to create account: \"{0}\"",
|
||||||
"ToastNewUserCreatedSuccess": "New account created",
|
"ToastNewUserCreatedSuccess": "New account created",
|
||||||
|
|
|
@ -9,6 +9,9 @@
|
||||||
"ButtonApply": "લાગુ કરો",
|
"ButtonApply": "લાગુ કરો",
|
||||||
"ButtonApplyChapters": "પ્રકરણો લાગુ કરો",
|
"ButtonApplyChapters": "પ્રકરણો લાગુ કરો",
|
||||||
"ButtonAuthors": "લેખકો",
|
"ButtonAuthors": "લેખકો",
|
||||||
|
"ButtonBack": "પાછા",
|
||||||
|
"ButtonBatchEditPopulateFromExisting": "હાલની માહિતીમાંથી ભરો",
|
||||||
|
"ButtonBatchEditPopulateMapDetails": "નકશાની વિગત ભરો",
|
||||||
"ButtonBrowseForFolder": "ફોલ્ડર માટે જુઓ",
|
"ButtonBrowseForFolder": "ફોલ્ડર માટે જુઓ",
|
||||||
"ButtonCancel": "રદ કરો",
|
"ButtonCancel": "રદ કરો",
|
||||||
"ButtonCancelEncode": "એન્કોડ રદ કરો",
|
"ButtonCancelEncode": "એન્કોડ રદ કરો",
|
||||||
|
@ -27,11 +30,14 @@
|
||||||
"ButtonEdit": "સંપાદિત કરો",
|
"ButtonEdit": "સંપાદિત કરો",
|
||||||
"ButtonEditChapters": "પ્રકરણો સંપાદિત કરો",
|
"ButtonEditChapters": "પ્રકરણો સંપાદિત કરો",
|
||||||
"ButtonEditPodcast": "પોડકાસ્ટ સંપાદિત કરો",
|
"ButtonEditPodcast": "પોડકાસ્ટ સંપાદિત કરો",
|
||||||
|
"ButtonEnable": "સક્રિય કરો",
|
||||||
"ButtonForceReScan": "બળપૂર્વક ફરીથી સ્કેન કરો",
|
"ButtonForceReScan": "બળપૂર્વક ફરીથી સ્કેન કરો",
|
||||||
"ButtonFullPath": "સંપૂર્ણ પથ",
|
"ButtonFullPath": "સંપૂર્ણ પથ",
|
||||||
"ButtonHide": "છુપાવો",
|
"ButtonHide": "છુપાવો",
|
||||||
"ButtonHome": "ઘર",
|
"ButtonHome": "ઘર",
|
||||||
"ButtonIssues": "સમસ્યાઓ",
|
"ButtonIssues": "સમસ્યાઓ",
|
||||||
|
"ButtonJumpBackward": "પાછળ જાવો",
|
||||||
|
"ButtonJumpForward": "આગળ જાવો",
|
||||||
"ButtonLatest": "નવીનતમ",
|
"ButtonLatest": "નવીનતમ",
|
||||||
"ButtonLibrary": "પુસ્તકાલય",
|
"ButtonLibrary": "પુસ્તકાલય",
|
||||||
"ButtonLogout": "લૉગ આઉટ",
|
"ButtonLogout": "લૉગ આઉટ",
|
||||||
|
@ -41,19 +47,32 @@
|
||||||
"ButtonMatchAllAuthors": "બધા મેળ ખાતા લેખકો શોધો",
|
"ButtonMatchAllAuthors": "બધા મેળ ખાતા લેખકો શોધો",
|
||||||
"ButtonMatchBooks": "મેળ ખાતી પુસ્તકો શોધો",
|
"ButtonMatchBooks": "મેળ ખાતી પુસ્તકો શોધો",
|
||||||
"ButtonNevermind": "કંઈ વાંધો નહીં",
|
"ButtonNevermind": "કંઈ વાંધો નહીં",
|
||||||
|
"ButtonNext": "આગળ જાઓ",
|
||||||
|
"ButtonNextChapter": "આગળનું અધ્યાય",
|
||||||
|
"ButtonNextItemInQueue": "કતારમાં આવતું આગળનું અધ્યાય",
|
||||||
"ButtonOk": "ઓકે",
|
"ButtonOk": "ઓકે",
|
||||||
"ButtonOpenFeed": "ફીડ ખોલો",
|
"ButtonOpenFeed": "ફીડ ખોલો",
|
||||||
"ButtonOpenManager": "મેનેજર ખોલો",
|
"ButtonOpenManager": "મેનેજર ખોલો",
|
||||||
|
"ButtonPause": "વિરામ",
|
||||||
"ButtonPlay": "ચલાવો",
|
"ButtonPlay": "ચલાવો",
|
||||||
|
"ButtonPlayAll": "બધું ચલાવો",
|
||||||
"ButtonPlaying": "ચલાવી રહ્યું છે",
|
"ButtonPlaying": "ચલાવી રહ્યું છે",
|
||||||
"ButtonPlaylists": "પ્લેલિસ્ટ",
|
"ButtonPlaylists": "પ્લેલિસ્ટ",
|
||||||
|
"ButtonPrevious": "પાછળનું",
|
||||||
|
"ButtonPreviousChapter": "પાછળનું અધ્યાય",
|
||||||
|
"ButtonProbeAudioFile": "ઑડિયો ફાઇલ તપાસો",
|
||||||
"ButtonPurgeAllCache": "બધો Cache કાઢી નાખો",
|
"ButtonPurgeAllCache": "બધો Cache કાઢી નાખો",
|
||||||
"ButtonPurgeItemsCache": "વસ્તુઓનો Cache કાઢી નાખો",
|
"ButtonPurgeItemsCache": "વસ્તુઓનો Cache કાઢી નાખો",
|
||||||
"ButtonQueueAddItem": "કતારમાં ઉમેરો",
|
"ButtonQueueAddItem": "કતારમાં ઉમેરો",
|
||||||
"ButtonQueueRemoveItem": "કતારથી કાઢી નાખો",
|
"ButtonQueueRemoveItem": "કતારથી કાઢી નાખો",
|
||||||
|
"ButtonQuickEmbed": "ઝડપથી સમાવેશ કરો",
|
||||||
|
"ButtonQuickEmbedMetadata": "ઝડપથી મેટાડેટા સમાવવો",
|
||||||
"ButtonQuickMatch": "ઝડપી મેળ ખવડાવો",
|
"ButtonQuickMatch": "ઝડપી મેળ ખવડાવો",
|
||||||
"ButtonReScan": "ફરીથી સ્કેન કરો",
|
"ButtonReScan": "ફરીથી સ્કેન કરો",
|
||||||
"ButtonRead": "વાંચો",
|
"ButtonRead": "વાંચો",
|
||||||
|
"ButtonReadLess": "ઓછું વાંચો",
|
||||||
|
"ButtonReadMore": "વધારે વાંચો",
|
||||||
|
"ButtonRefresh": "તાજું કરો",
|
||||||
"ButtonRemove": "કાઢી નાખો",
|
"ButtonRemove": "કાઢી નાખો",
|
||||||
"ButtonRemoveAll": "બધું કાઢી નાખો",
|
"ButtonRemoveAll": "બધું કાઢી નાખો",
|
||||||
"ButtonRemoveAllLibraryItems": "બધું પુસ્તકાલય વસ્તુઓ કાઢી નાખો",
|
"ButtonRemoveAllLibraryItems": "બધું પુસ્તકાલય વસ્તુઓ કાઢી નાખો",
|
||||||
|
@ -68,16 +87,21 @@
|
||||||
"ButtonSaveTracklist": "ટ્રેક યાદી સાચવો",
|
"ButtonSaveTracklist": "ટ્રેક યાદી સાચવો",
|
||||||
"ButtonScan": "સ્કેન કરો",
|
"ButtonScan": "સ્કેન કરો",
|
||||||
"ButtonScanLibrary": "પુસ્તકાલય સ્કેન કરો",
|
"ButtonScanLibrary": "પુસ્તકાલય સ્કેન કરો",
|
||||||
|
"ButtonScrollLeft": "ડાબે",
|
||||||
|
"ButtonScrollRight": "જમણે",
|
||||||
"ButtonSearch": "શોધો",
|
"ButtonSearch": "શોધો",
|
||||||
"ButtonSelectFolderPath": "ફોલ્ડર પથ પસંદ કરો",
|
"ButtonSelectFolderPath": "ફોલ્ડર પથ પસંદ કરો",
|
||||||
"ButtonSeries": "સિરીઝ",
|
"ButtonSeries": "સિરીઝ",
|
||||||
"ButtonSetChaptersFromTracks": "ટ્રેક્સથી પ્રકરણો સેટ કરો",
|
"ButtonSetChaptersFromTracks": "ટ્રેક્સથી પ્રકરણો સેટ કરો",
|
||||||
|
"ButtonShare": "શેર કરો",
|
||||||
"ButtonShiftTimes": "સમય શિફ્ટ કરો",
|
"ButtonShiftTimes": "સમય શિફ્ટ કરો",
|
||||||
"ButtonShow": "બતાવો",
|
"ButtonShow": "બતાવો",
|
||||||
"ButtonStartM4BEncode": "M4B એન્કોડ શરૂ કરો",
|
"ButtonStartM4BEncode": "M4B એન્કોડ શરૂ કરો",
|
||||||
"ButtonStartMetadataEmbed": "મેટાડેટા એમ્બેડ શરૂ કરો",
|
"ButtonStartMetadataEmbed": "મેટાડેટા એમ્બેડ શરૂ કરો",
|
||||||
|
"ButtonStats": "આંકડા",
|
||||||
"ButtonSubmit": "સબમિટ કરો",
|
"ButtonSubmit": "સબમિટ કરો",
|
||||||
"ButtonTest": "પરખ કરો",
|
"ButtonTest": "પરખ કરો",
|
||||||
|
"ButtonUnlinkOpenId": "OpenID દૂર કરો",
|
||||||
"ButtonUpload": "અપલોડ કરો",
|
"ButtonUpload": "અપલોડ કરો",
|
||||||
"ButtonUploadBackup": "બેકઅપ અપલોડ કરો",
|
"ButtonUploadBackup": "બેકઅપ અપલોડ કરો",
|
||||||
"ButtonUploadCover": "કવર અપલોડ કરો",
|
"ButtonUploadCover": "કવર અપલોડ કરો",
|
||||||
|
@ -86,11 +110,16 @@
|
||||||
"ButtonUserEdit": "વપરાશકર્તા {0} સંપાદિત કરો",
|
"ButtonUserEdit": "વપરાશકર્તા {0} સંપાદિત કરો",
|
||||||
"ButtonViewAll": "બધું જુઓ",
|
"ButtonViewAll": "બધું જુઓ",
|
||||||
"ButtonYes": "હા",
|
"ButtonYes": "હા",
|
||||||
|
"ErrorUploadFetchMetadataAPI": "મેટાડેટા મેળવવામાં તકલીફ આવી",
|
||||||
|
"ErrorUploadFetchMetadataNoResults": "મેટાડેટા મેળવી શક્યા નહીં – કૃપા કરીને શીર્ષક અને/અથવા લેખકનું નામ અપડેટ કરવાનો પ્રયત્ન કરો",
|
||||||
|
"ErrorUploadLacksTitle": "શીર્ષક હોવું આવશ્યક છે",
|
||||||
"HeaderAccount": "એકાઉન્ટ",
|
"HeaderAccount": "એકાઉન્ટ",
|
||||||
|
"HeaderAddCustomMetadataProvider": "કસ્ટમ મેટાડેટા પ્રોવાઇડર ઉમેરો",
|
||||||
"HeaderAdvanced": "અડ્વાન્સડ",
|
"HeaderAdvanced": "અડ્વાન્સડ",
|
||||||
"HeaderAppriseNotificationSettings": "Apprise સૂચના સેટિંગ્સ",
|
"HeaderAppriseNotificationSettings": "Apprise સૂચના સેટિંગ્સ",
|
||||||
"HeaderAudioTracks": "ઓડિયો ટ્રેક્સ",
|
"HeaderAudioTracks": "ઓડિયો ટ્રેક્સ",
|
||||||
"HeaderAudiobookTools": "ઓડિયોબુક ફાઇલ વ્યવસ્થાપન ટૂલ્સ",
|
"HeaderAudiobookTools": "ઓડિયોબુક ફાઇલ વ્યવસ્થાપન ટૂલ્સ",
|
||||||
|
"HeaderAuthentication": "પ્રમાણીકરણ",
|
||||||
"HeaderBackups": "બેકઅપ્સ",
|
"HeaderBackups": "બેકઅપ્સ",
|
||||||
"HeaderChangePassword": "પાસવર્ડ બદલો",
|
"HeaderChangePassword": "પાસવર્ડ બદલો",
|
||||||
"HeaderChapters": "પ્રકરણો",
|
"HeaderChapters": "પ્રકરણો",
|
||||||
|
@ -99,6 +128,7 @@
|
||||||
"HeaderCollectionItems": "સંગ્રહ વસ્તુઓ",
|
"HeaderCollectionItems": "સંગ્રહ વસ્તુઓ",
|
||||||
"HeaderCover": "આવરણ",
|
"HeaderCover": "આવરણ",
|
||||||
"HeaderCurrentDownloads": "વર્તમાન ડાઉનલોડ્સ",
|
"HeaderCurrentDownloads": "વર્તમાન ડાઉનલોડ્સ",
|
||||||
|
"HeaderCustomMetadataProviders": "કસ્ટમ મેટાડેટા પ્રોવાઇડર્સ",
|
||||||
"HeaderDetails": "વિગતો",
|
"HeaderDetails": "વિગતો",
|
||||||
"HeaderDownloadQueue": "ડાઉનલોડ કતાર",
|
"HeaderDownloadQueue": "ડાઉનલોડ કતાર",
|
||||||
"HeaderEbookFiles": "ઇબુક ફાઇલો",
|
"HeaderEbookFiles": "ઇબુક ફાઇલો",
|
||||||
|
@ -129,6 +159,7 @@
|
||||||
"HeaderMetadataToEmbed": "એમ્બેડ કરવા માટે મેટાડેટા",
|
"HeaderMetadataToEmbed": "એમ્બેડ કરવા માટે મેટાડેટા",
|
||||||
"HeaderNewAccount": "નવું એકાઉન્ટ",
|
"HeaderNewAccount": "નવું એકાઉન્ટ",
|
||||||
"HeaderNewLibrary": "નવી પુસ્તકાલય",
|
"HeaderNewLibrary": "નવી પુસ્તકાલય",
|
||||||
|
"HeaderNotificationCreate": "સૂચના બનાવો",
|
||||||
"HeaderNotifications": "સૂચનાઓ",
|
"HeaderNotifications": "સૂચનાઓ",
|
||||||
"HeaderOpenRSSFeed": "RSS ફીડ ખોલો",
|
"HeaderOpenRSSFeed": "RSS ફીડ ખોલો",
|
||||||
"HeaderOtherFiles": "અન્ય ફાઇલો",
|
"HeaderOtherFiles": "અન્ય ફાઇલો",
|
||||||
|
|
|
@ -918,6 +918,8 @@
|
||||||
"NotificationOnBackupCompletedDescription": "Pokreće se po završetku sigurnosnog kopiranja",
|
"NotificationOnBackupCompletedDescription": "Pokreće se po završetku sigurnosnog kopiranja",
|
||||||
"NotificationOnBackupFailedDescription": "Pokreće se kada sigurnosno kopiranje ne uspije",
|
"NotificationOnBackupFailedDescription": "Pokreće se kada sigurnosno kopiranje ne uspije",
|
||||||
"NotificationOnEpisodeDownloadedDescription": "Pokreće se kada se nastavak podcasta automatski preuzme",
|
"NotificationOnEpisodeDownloadedDescription": "Pokreće se kada se nastavak podcasta automatski preuzme",
|
||||||
|
"NotificationOnRSSFeedDisabledDescription": "Pokreće se kada su automatska preuzimanja nastavaka onemogućena zbog previše neuspjelih pokušaja",
|
||||||
|
"NotificationOnRSSFeedFailedDescription": "Pokreće se u slučaju pogreške pri pokušaju automatskog preuzimanja nastavka s RSS izvora",
|
||||||
"NotificationOnTestDescription": "Događaj za testiranje sustava obavijesti",
|
"NotificationOnTestDescription": "Događaj za testiranje sustava obavijesti",
|
||||||
"PlaceholderNewCollection": "Ime nove zbirke",
|
"PlaceholderNewCollection": "Ime nove zbirke",
|
||||||
"PlaceholderNewFolderPath": "Nova putanja mape",
|
"PlaceholderNewFolderPath": "Nova putanja mape",
|
||||||
|
|
|
@ -11,7 +11,7 @@
|
||||||
"ButtonAuthors": "Szerzők",
|
"ButtonAuthors": "Szerzők",
|
||||||
"ButtonBack": "Vissza",
|
"ButtonBack": "Vissza",
|
||||||
"ButtonBatchEditPopulateFromExisting": "Létezőből feltöltés",
|
"ButtonBatchEditPopulateFromExisting": "Létezőből feltöltés",
|
||||||
"ButtonBatchEditPopulateMapDetails": "",
|
"ButtonBatchEditPopulateMapDetails": "A térkép részleteinek feltöltése",
|
||||||
"ButtonBrowseForFolder": "Mappa keresése",
|
"ButtonBrowseForFolder": "Mappa keresése",
|
||||||
"ButtonCancel": "Mégse",
|
"ButtonCancel": "Mégse",
|
||||||
"ButtonCancelEncode": "Kódolás megszakítása",
|
"ButtonCancelEncode": "Kódolás megszakítása",
|
||||||
|
@ -177,6 +177,7 @@
|
||||||
"HeaderPlaylist": "Lejátszási lista",
|
"HeaderPlaylist": "Lejátszási lista",
|
||||||
"HeaderPlaylistItems": "Lejátszási lista elemek",
|
"HeaderPlaylistItems": "Lejátszási lista elemek",
|
||||||
"HeaderPodcastsToAdd": "Hozzáadandó podcastok",
|
"HeaderPodcastsToAdd": "Hozzáadandó podcastok",
|
||||||
|
"HeaderPresets": "Alapbeállítások",
|
||||||
"HeaderPreviewCover": "Borító előnézete",
|
"HeaderPreviewCover": "Borító előnézete",
|
||||||
"HeaderRSSFeedGeneral": "RSS részletek",
|
"HeaderRSSFeedGeneral": "RSS részletek",
|
||||||
"HeaderRSSFeedIsOpen": "RSS hírcsatorna nyitva van",
|
"HeaderRSSFeedIsOpen": "RSS hírcsatorna nyitva van",
|
||||||
|
@ -219,6 +220,7 @@
|
||||||
"LabelAccountTypeAdmin": "Adminisztrátor",
|
"LabelAccountTypeAdmin": "Adminisztrátor",
|
||||||
"LabelAccountTypeGuest": "Vendég",
|
"LabelAccountTypeGuest": "Vendég",
|
||||||
"LabelAccountTypeUser": "Felhasználó",
|
"LabelAccountTypeUser": "Felhasználó",
|
||||||
|
"LabelActivities": "Tevékenységek",
|
||||||
"LabelActivity": "Tevékenység",
|
"LabelActivity": "Tevékenység",
|
||||||
"LabelAddToCollection": "Hozzáadás a gyűjteményhez",
|
"LabelAddToCollection": "Hozzáadás a gyűjteményhez",
|
||||||
"LabelAddToCollectionBatch": "{0} könyv hozzáadása a gyűjteményhez",
|
"LabelAddToCollectionBatch": "{0} könyv hozzáadása a gyűjteményhez",
|
||||||
|
@ -228,6 +230,7 @@
|
||||||
"LabelAddedDate": "{0} Hozzáadva",
|
"LabelAddedDate": "{0} Hozzáadva",
|
||||||
"LabelAdminUsersOnly": "Csak admin felhasználók",
|
"LabelAdminUsersOnly": "Csak admin felhasználók",
|
||||||
"LabelAll": "Összes",
|
"LabelAll": "Összes",
|
||||||
|
"LabelAllEpisodesDownloaded": "Minden epizód letöltve",
|
||||||
"LabelAllUsers": "Minden felhasználó",
|
"LabelAllUsers": "Minden felhasználó",
|
||||||
"LabelAllUsersExcludingGuests": "Minden felhasználó, vendégek kivételével",
|
"LabelAllUsersExcludingGuests": "Minden felhasználó, vendégek kivételével",
|
||||||
"LabelAllUsersIncludingGuests": "Minden felhasználó, beleértve a vendégeket is",
|
"LabelAllUsersIncludingGuests": "Minden felhasználó, beleértve a vendégeket is",
|
||||||
|
@ -251,7 +254,7 @@
|
||||||
"LabelBackToUser": "Vissza a felhasználóhoz",
|
"LabelBackToUser": "Vissza a felhasználóhoz",
|
||||||
"LabelBackupAudioFiles": "Audiófájlok biztonsági mentése",
|
"LabelBackupAudioFiles": "Audiófájlok biztonsági mentése",
|
||||||
"LabelBackupLocation": "Biztonsági másolat helye",
|
"LabelBackupLocation": "Biztonsági másolat helye",
|
||||||
"LabelBackupsEnableAutomaticBackups": "Automatikus biztonsági másolatok engedélyezése",
|
"LabelBackupsEnableAutomaticBackups": "Automatikus biztonsági másolatok",
|
||||||
"LabelBackupsEnableAutomaticBackupsHelp": "Biztonsági másolatok mentése a /metadata/backups mappába",
|
"LabelBackupsEnableAutomaticBackupsHelp": "Biztonsági másolatok mentése a /metadata/backups mappába",
|
||||||
"LabelBackupsMaxBackupSize": "Maximális biztonsági másolat méret (GB-ban) (0-tól végtelenig)",
|
"LabelBackupsMaxBackupSize": "Maximális biztonsági másolat méret (GB-ban) (0-tól végtelenig)",
|
||||||
"LabelBackupsMaxBackupSizeHelp": "A rossz konfiguráció elleni védelem érdekében a biztonsági másolatok meghiúsulnak, ha meghaladják a beállított méretet.",
|
"LabelBackupsMaxBackupSizeHelp": "A rossz konfiguráció elleni védelem érdekében a biztonsági másolatok meghiúsulnak, ha meghaladják a beállított méretet.",
|
||||||
|
@ -275,7 +278,7 @@
|
||||||
"LabelCollapseSeries": "Sorozat összecsukása",
|
"LabelCollapseSeries": "Sorozat összecsukása",
|
||||||
"LabelCollapseSubSeries": "Alszéria összecsukása",
|
"LabelCollapseSubSeries": "Alszéria összecsukása",
|
||||||
"LabelCollection": "Gyűjtemény",
|
"LabelCollection": "Gyűjtemény",
|
||||||
"LabelCollections": "Gyűjtemény",
|
"LabelCollections": "Gyűjtemények",
|
||||||
"LabelComplete": "Kész",
|
"LabelComplete": "Kész",
|
||||||
"LabelConfirmPassword": "Jelszó megerősítése",
|
"LabelConfirmPassword": "Jelszó megerősítése",
|
||||||
"LabelContinueListening": "Hallgatás folytatása",
|
"LabelContinueListening": "Hallgatás folytatása",
|
||||||
|
@ -283,6 +286,7 @@
|
||||||
"LabelContinueSeries": "Sorozat folytatása",
|
"LabelContinueSeries": "Sorozat folytatása",
|
||||||
"LabelCover": "Borító",
|
"LabelCover": "Borító",
|
||||||
"LabelCoverImageURL": "Borítókép URL",
|
"LabelCoverImageURL": "Borítókép URL",
|
||||||
|
"LabelCoverProvider": "Borító Szolgáltató",
|
||||||
"LabelCreatedAt": "Létrehozás ideje",
|
"LabelCreatedAt": "Létrehozás ideje",
|
||||||
"LabelCronExpression": "Cron kifejezés",
|
"LabelCronExpression": "Cron kifejezés",
|
||||||
"LabelCurrent": "Jelenlegi",
|
"LabelCurrent": "Jelenlegi",
|
||||||
|
@ -391,7 +395,8 @@
|
||||||
"LabelIntervalEvery6Hours": "Minden 6 órában",
|
"LabelIntervalEvery6Hours": "Minden 6 órában",
|
||||||
"LabelIntervalEveryDay": "Minden nap",
|
"LabelIntervalEveryDay": "Minden nap",
|
||||||
"LabelIntervalEveryHour": "Minden órában",
|
"LabelIntervalEveryHour": "Minden órában",
|
||||||
"LabelInvert": "Megfordítás",
|
"LabelIntervalEveryMinute": "Minden percben",
|
||||||
|
"LabelInvert": "Inverz",
|
||||||
"LabelItem": "Elem",
|
"LabelItem": "Elem",
|
||||||
"LabelJumpBackwardAmount": "Visszafelé ugrás mennyisége",
|
"LabelJumpBackwardAmount": "Visszafelé ugrás mennyisége",
|
||||||
"LabelJumpForwardAmount": "Előre ugrás mennyisége",
|
"LabelJumpForwardAmount": "Előre ugrás mennyisége",
|
||||||
|
@ -486,6 +491,7 @@
|
||||||
"LabelPersonalYearReview": "Az éved összefoglalása ({0})",
|
"LabelPersonalYearReview": "Az éved összefoglalása ({0})",
|
||||||
"LabelPhotoPathURL": "Fénykép útvonal/URL",
|
"LabelPhotoPathURL": "Fénykép útvonal/URL",
|
||||||
"LabelPlayMethod": "Lejátszási módszer",
|
"LabelPlayMethod": "Lejátszási módszer",
|
||||||
|
"LabelPlaybackRateIncrementDecrement": "Lejátszási sebesség növelés/csökkentés értéke",
|
||||||
"LabelPlayerChapterNumberMarker": "{0} a {1} -ből",
|
"LabelPlayerChapterNumberMarker": "{0} a {1} -ből",
|
||||||
"LabelPlaylists": "Lejátszási listák",
|
"LabelPlaylists": "Lejátszási listák",
|
||||||
"LabelPodcast": "Podcast",
|
"LabelPodcast": "Podcast",
|
||||||
|
@ -508,7 +514,7 @@
|
||||||
"LabelPublishers": "Kiadók",
|
"LabelPublishers": "Kiadók",
|
||||||
"LabelRSSFeedCustomOwnerEmail": "Egyéni tulajdonos e-mail",
|
"LabelRSSFeedCustomOwnerEmail": "Egyéni tulajdonos e-mail",
|
||||||
"LabelRSSFeedCustomOwnerName": "Egyéni tulajdonos neve",
|
"LabelRSSFeedCustomOwnerName": "Egyéni tulajdonos neve",
|
||||||
"LabelRSSFeedOpen": "RSS hírcsatorna nyitva",
|
"LabelRSSFeedOpen": "RSS-hírcsatorna nyitva",
|
||||||
"LabelRSSFeedPreventIndexing": "Indexelés megakadályozása",
|
"LabelRSSFeedPreventIndexing": "Indexelés megakadályozása",
|
||||||
"LabelRSSFeedSlug": "RSS hírcsatorna slug",
|
"LabelRSSFeedSlug": "RSS hírcsatorna slug",
|
||||||
"LabelRSSFeedURL": "RSS hírcsatorna URL",
|
"LabelRSSFeedURL": "RSS hírcsatorna URL",
|
||||||
|
@ -525,6 +531,7 @@
|
||||||
"LabelReleaseDate": "Megjelenés dátuma",
|
"LabelReleaseDate": "Megjelenés dátuma",
|
||||||
"LabelRemoveAllMetadataAbs": "Az összes metadata.abs fájl eltávolítása",
|
"LabelRemoveAllMetadataAbs": "Az összes metadata.abs fájl eltávolítása",
|
||||||
"LabelRemoveAllMetadataJson": "Az összes metadata.json fájl eltávolítása",
|
"LabelRemoveAllMetadataJson": "Az összes metadata.json fájl eltávolítása",
|
||||||
|
"LabelRemoveAudibleBranding": "Audible intro és outro eltávolítása a fejezetekből",
|
||||||
"LabelRemoveCover": "Borító eltávolítása",
|
"LabelRemoveCover": "Borító eltávolítása",
|
||||||
"LabelRemoveMetadataFile": "Metaadatfájlok eltávolítása a könyvtár elemek mappáiból",
|
"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.",
|
"LabelRemoveMetadataFileHelp": "A metadata.json és metadata.abs fájlokat eltávolítása a {0} mappáidból.",
|
||||||
|
@ -554,6 +561,8 @@
|
||||||
"LabelSettingsBookshelfViewHelp": "Skeuomorfikus dizájn fa polcokkal",
|
"LabelSettingsBookshelfViewHelp": "Skeuomorfikus dizájn fa polcokkal",
|
||||||
"LabelSettingsChromecastSupport": "Chromecast támogatás",
|
"LabelSettingsChromecastSupport": "Chromecast támogatás",
|
||||||
"LabelSettingsDateFormat": "Dátumformátum",
|
"LabelSettingsDateFormat": "Dátumformátum",
|
||||||
|
"LabelSettingsEnableWatcher": "Változások automatikus vizsgálata a könyvtárakban",
|
||||||
|
"LabelSettingsEnableWatcherForLibrary": "Változások automatikus vizsgálata a könyvtárban",
|
||||||
"LabelSettingsEnableWatcherHelp": "Engedélyezi az automatikus elem hozzáadás/frissítés funkciót, amikor fájlváltozásokat észlel. *Szerver újraindítása szükséges",
|
"LabelSettingsEnableWatcherHelp": "Engedélyezi az automatikus elem hozzáadás/frissítés funkciót, amikor fájlváltozásokat észlel. *Szerver újraindítása szükséges",
|
||||||
"LabelSettingsEpubsAllowScriptedContent": "Szkriptelt tartalmak engedélyezése epub-okban",
|
"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.",
|
"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.",
|
||||||
|
@ -597,6 +606,7 @@
|
||||||
"LabelSlug": "Rövid cím",
|
"LabelSlug": "Rövid cím",
|
||||||
"LabelSortAscending": "Emelkedő",
|
"LabelSortAscending": "Emelkedő",
|
||||||
"LabelSortDescending": "Csökkenő",
|
"LabelSortDescending": "Csökkenő",
|
||||||
|
"LabelSortPubDate": "Rendezés megjelenés dátuma szerint",
|
||||||
"LabelStart": "Kezdés",
|
"LabelStart": "Kezdés",
|
||||||
"LabelStartTime": "Kezdési idő",
|
"LabelStartTime": "Kezdési idő",
|
||||||
"LabelStarted": "Elkezdődött",
|
"LabelStarted": "Elkezdődött",
|
||||||
|
@ -697,12 +707,17 @@
|
||||||
"LabelYourProgress": "Haladásod",
|
"LabelYourProgress": "Haladásod",
|
||||||
"MessageAddToPlayerQueue": "Hozzáadás a lejátszó sorhoz",
|
"MessageAddToPlayerQueue": "Hozzáadás a lejátszó sorhoz",
|
||||||
"MessageAppriseDescription": "Ennek a funkció használatához futtatnia kell egy <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> példányt vagy egy olyan API-t, amely kezeli ezeket a kéréseket. <br />Az Apprise API URL-nek a teljes URL útvonalat kell tartalmaznia az értesítés elküldéséhez, például, ha az API példánya a <code>http://192.168.1.1:8337</code> címen szolgáltatva, akkor <code>http://192.168.1.1:8337/notify</code> értéket kell megadnia.",
|
"MessageAppriseDescription": "Ennek a funkció használatához futtatnia kell egy <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> példányt vagy egy olyan API-t, amely kezeli ezeket a kéréseket. <br />Az Apprise API URL-nek a teljes URL útvonalat kell tartalmaznia az értesítés elküldéséhez, például, ha az API példánya a <code>http://192.168.1.1:8337</code> címen szolgáltatva, akkor <code>http://192.168.1.1:8337/notify</code> értéket kell megadnia.",
|
||||||
|
"MessageAsinCheck": "Győződjön meg róla, hogy az ASIN-t a megfelelő Audible régióból használja, nem az Amazonból.",
|
||||||
|
"MessageAuthenticationOIDCChangesRestart": "A mentés után indítsa újra a szervert az OIDC módosítások alkalmazásához.",
|
||||||
"MessageBackupsDescription": "A biztonsági másolatok tartalmazzák a felhasználókat, a felhasználói haladást, a könyvtári elem részleteit, a szerver beállításait és a képeket, amelyek a <code>/metadata/items</code> és <code>/metadata/authors</code> mappákban vannak tárolva. A biztonsági másolatok <strong>nem</strong> tartalmazzák a könyvtári mappákban tárolt fájlokat.",
|
"MessageBackupsDescription": "A biztonsági másolatok tartalmazzák a felhasználókat, a felhasználói haladást, a könyvtári elem részleteit, a szerver beállításait és a képeket, amelyek a <code>/metadata/items</code> és <code>/metadata/authors</code> mappákban vannak tárolva. A biztonsági másolatok <strong>nem</strong> tartalmazzák a könyvtári mappákban tárolt fájlokat.",
|
||||||
"MessageBackupsLocationEditNote": "Megjegyzés: A biztonsági mentés helyének frissítése nem mozgatja vagy módosítja a meglévő biztonsági mentéseket",
|
"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ó.",
|
"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",
|
"MessageBackupsLocationPathEmpty": "A biztonsági mentés helyének elérési útvonala nem lehet üres",
|
||||||
|
"MessageBatchEditPopulateMapDetailsAllHelp": "Az engedélyezett mezők feltöltése az összes elem adatával. A több értéket tartalmazó mezők összevonásra kerülnek",
|
||||||
|
"MessageBatchEditPopulateMapDetailsItemHelp": "A térkép engedélyezett adatmezőinek feltöltése ezen elem adataival",
|
||||||
"MessageBatchQuickMatchDescription": "A Gyors egyeztetés megpróbálja hozzáadni a hiányzó borítókat és metaadatokat a kiválasztott elemekhez. Engedélyezze az alábbi opciókat, hogy a Gyors egyeztetés felülírhassa a meglévő borítókat és/vagy metaadatokat.",
|
"MessageBatchQuickMatchDescription": "A Gyors egyeztetés megpróbálja hozzáadni a hiányzó borítókat és metaadatokat a kiválasztott elemekhez. Engedélyezze az alábbi opciókat, hogy a Gyors egyeztetés felülírhassa a meglévő borítókat és/vagy metaadatokat.",
|
||||||
"MessageBookshelfNoCollections": "Még nem készített gyűjteményeket",
|
"MessageBookshelfNoCollections": "Még nem készített gyűjteményeket",
|
||||||
|
"MessageBookshelfNoCollectionsHelp": "A gyűjtemények nyilvánosak. Minden, a könyvtárhoz hozzáféréssel rendelkező felhasználó láthatja őket.",
|
||||||
"MessageBookshelfNoRSSFeeds": "Nincsenek nyitott RSS hírcsatornák",
|
"MessageBookshelfNoRSSFeeds": "Nincsenek nyitott RSS hírcsatornák",
|
||||||
"MessageBookshelfNoResultsForFilter": "Nincs eredmény a \"{0}: {1}\" szűrőre",
|
"MessageBookshelfNoResultsForFilter": "Nincs eredmény a \"{0}: {1}\" szűrőre",
|
||||||
"MessageBookshelfNoResultsForQuery": "Nincs eredmény a lekérdezéshez",
|
"MessageBookshelfNoResultsForQuery": "Nincs eredmény a lekérdezéshez",
|
||||||
|
@ -712,6 +727,7 @@
|
||||||
"MessageChapterErrorStartGteDuration": "Érvénytelen kezdési idő, kevesebbnek kell lennie, mint a hangoskönyv időtartama",
|
"MessageChapterErrorStartGteDuration": "Érvénytelen kezdési idő, kevesebbnek kell lennie, mint a hangoskönyv időtartama",
|
||||||
"MessageChapterErrorStartLtPrev": "Érvénytelen kezdési idő, nagyobbnak kell lennie, mint az előző fejezet kezdési ideje",
|
"MessageChapterErrorStartLtPrev": "Érvénytelen kezdési idő, nagyobbnak kell lennie, mint az előző fejezet kezdési ideje",
|
||||||
"MessageChapterStartIsAfter": "A fejezet kezdete a hangoskönyv végét követi",
|
"MessageChapterStartIsAfter": "A fejezet kezdete a hangoskönyv végét követi",
|
||||||
|
"MessageChaptersNotFound": "Fejezetek nem találhatók",
|
||||||
"MessageCheckingCron": "Cron ellenőrzése...",
|
"MessageCheckingCron": "Cron ellenőrzése...",
|
||||||
"MessageConfirmCloseFeed": "Biztosan be szeretné zárni ezt a hírcsatornát?",
|
"MessageConfirmCloseFeed": "Biztosan be szeretné zárni ezt a hírcsatornát?",
|
||||||
"MessageConfirmDeleteBackup": "Biztosan törölni szeretné a(z) {0} biztonsági másolatot?",
|
"MessageConfirmDeleteBackup": "Biztosan törölni szeretné a(z) {0} biztonsági másolatot?",
|
||||||
|
@ -741,6 +757,7 @@
|
||||||
"MessageConfirmRemoveAuthor": "Biztosan eltávolítja a(z) \"{0}\" szerzőt?",
|
"MessageConfirmRemoveAuthor": "Biztosan eltávolítja a(z) \"{0}\" szerzőt?",
|
||||||
"MessageConfirmRemoveCollection": "Biztosan eltávolítja a(z) \"{0}\" gyűjteményt?",
|
"MessageConfirmRemoveCollection": "Biztosan eltávolítja a(z) \"{0}\" gyűjteményt?",
|
||||||
"MessageConfirmRemoveEpisode": "Biztosan eltávolítja a(z) \"{0}\" epizódot?",
|
"MessageConfirmRemoveEpisode": "Biztosan eltávolítja a(z) \"{0}\" epizódot?",
|
||||||
|
"MessageConfirmRemoveEpisodeNote": "Megjegyzés: Ez nem törli a hangfájlt, kivéve, ha a \"Hangfájl végleges törlése\" be van kapcsolva",
|
||||||
"MessageConfirmRemoveEpisodes": "Biztosan eltávolítja a(z) {0} epizódot?",
|
"MessageConfirmRemoveEpisodes": "Biztosan eltávolítja a(z) {0} epizódot?",
|
||||||
"MessageConfirmRemoveListeningSessions": "Biztosan eltávolítja a(z) {0} hallgatási munkamenetet?",
|
"MessageConfirmRemoveListeningSessions": "Biztosan eltávolítja a(z) {0} hallgatási munkamenetet?",
|
||||||
"MessageConfirmRemoveMetadataFiles": "Biztos, hogy az összes metaadatot el akarja távolítani {0} fájl van könyvtár mappáiban?",
|
"MessageConfirmRemoveMetadataFiles": "Biztos, hogy az összes metaadatot el akarja távolítani {0} fájl van könyvtár mappáiban?",
|
||||||
|
@ -768,6 +785,7 @@
|
||||||
"MessageForceReScanDescription": "minden fájlt újra szkennel, mint egy friss szkennelés. Az audiofájlok ID3 címkéi, OPF fájlok és szövegfájlok újként lesznek szkennelve.",
|
"MessageForceReScanDescription": "minden fájlt újra szkennel, mint egy friss szkennelés. Az audiofájlok ID3 címkéi, OPF fájlok és szövegfájlok újként lesznek szkennelve.",
|
||||||
"MessageImportantNotice": "Fontos közlemény!",
|
"MessageImportantNotice": "Fontos közlemény!",
|
||||||
"MessageInsertChapterBelow": "Fejezet beszúrása alulra",
|
"MessageInsertChapterBelow": "Fejezet beszúrása alulra",
|
||||||
|
"MessageInvalidAsin": "Érvénytelen ASIN",
|
||||||
"MessageItemsSelected": "{0} kiválasztott elem",
|
"MessageItemsSelected": "{0} kiválasztott elem",
|
||||||
"MessageItemsUpdated": "{0} frissített elem",
|
"MessageItemsUpdated": "{0} frissített elem",
|
||||||
"MessageJoinUsOn": "Csatlakozzon hozzánk a",
|
"MessageJoinUsOn": "Csatlakozzon hozzánk a",
|
||||||
|
@ -813,6 +831,7 @@
|
||||||
"MessageNoTasksRunning": "Nincsenek futó feladatok",
|
"MessageNoTasksRunning": "Nincsenek futó feladatok",
|
||||||
"MessageNoUpdatesWereNecessary": "Nem volt szükség frissítésekre",
|
"MessageNoUpdatesWereNecessary": "Nem volt szükség frissítésekre",
|
||||||
"MessageNoUserPlaylists": "Nincsenek felhasználói lejátszási listák",
|
"MessageNoUserPlaylists": "Nincsenek felhasználói lejátszási listák",
|
||||||
|
"MessageNoUserPlaylistsHelp": "A lejátszási listák személyesek. Csak az a felhasználó láthatja őket, aki létrehozta őket.",
|
||||||
"MessageNotYetImplemented": "Még nem implementált",
|
"MessageNotYetImplemented": "Még nem implementált",
|
||||||
"MessageOpmlPreviewNote": "Megjegyzés: Ez egy előnézeti kép az elemzett OPML fájlról. A podcast tényleges címe az RSS hírcsatornából származik.",
|
"MessageOpmlPreviewNote": "Megjegyzés: Ez egy előnézeti kép az elemzett OPML fájlról. A podcast tényleges címe az RSS hírcsatornából származik.",
|
||||||
"MessageOr": "vagy",
|
"MessageOr": "vagy",
|
||||||
|
@ -835,8 +854,10 @@
|
||||||
"MessageRestoreBackupConfirm": "Biztosan vissza szeretné állítani a biztonsági másolatot, amely ekkor készült:",
|
"MessageRestoreBackupConfirm": "Biztosan vissza szeretné állítani a biztonsági másolatot, amely ekkor készült:",
|
||||||
"MessageRestoreBackupWarning": "A biztonsági mentés visszaállítása felülírja az egész adatbázist, amely a /config mappában található, valamint a borítóképeket a /metadata/items és /metadata/authors mappákban.<br /><br />A biztonsági mentések nem módosítják a könyvtár mappáiban található fájlokat. Ha engedélyezte a szerverbeállításokat a borítóképek és a metaadatok könyvtármappákban való tárolására, akkor ezek nem kerülnek biztonsági mentésre vagy felülírásra.<br /><br />A szerver használó összes kliens automatikusan frissül.",
|
"MessageRestoreBackupWarning": "A biztonsági mentés visszaállítása felülírja az egész adatbázist, amely a /config mappában található, valamint a borítóképeket a /metadata/items és /metadata/authors mappákban.<br /><br />A biztonsági mentések nem módosítják a könyvtár mappáiban található fájlokat. Ha engedélyezte a szerverbeállításokat a borítóképek és a metaadatok könyvtármappákban való tárolására, akkor ezek nem kerülnek biztonsági mentésre vagy felülírásra.<br /><br />A szerver használó összes kliens automatikusan frissül.",
|
||||||
"MessageScheduleLibraryScanNote": "A legtöbb felhasználó számára ajánlott ezt a funkciót kikapcsolva hagyni, és engedélyezni a mappafigyelő beállítást. A mappafigyelő automatikusan észleli a könyvtári mappák változásait. A mappafigyelő nem működik minden fájlrendszernél (mint például az NFS), ezért helyette ütemezett könyvtárellenőrzéseket lehet használni.",
|
"MessageScheduleLibraryScanNote": "A legtöbb felhasználó számára ajánlott ezt a funkciót kikapcsolva hagyni, és engedélyezni a mappafigyelő beállítást. A mappafigyelő automatikusan észleli a könyvtári mappák változásait. A mappafigyelő nem működik minden fájlrendszernél (mint például az NFS), ezért helyette ütemezett könyvtárellenőrzéseket lehet használni.",
|
||||||
|
"MessageScheduleRunEveryWeekdayAtTime": "Futás minden {1} óra {0}-kor",
|
||||||
"MessageSearchResultsFor": "Keresési eredmények",
|
"MessageSearchResultsFor": "Keresési eredmények",
|
||||||
"MessageSelected": "{0} kiválasztva",
|
"MessageSelected": "{0} kiválasztva",
|
||||||
|
"MessageSeriesSequenceCannotContainSpaces": "Sorozat sorrend nem tartalmazhat szóközt",
|
||||||
"MessageServerCouldNotBeReached": "A szervert nem lehet elérni",
|
"MessageServerCouldNotBeReached": "A szervert nem lehet elérni",
|
||||||
"MessageSetChaptersFromTracksDescription": "Fejezetek beállítása minden egyes hangfájlt egy fejezetként használva, és a fejezet címét a hangfájl neveként",
|
"MessageSetChaptersFromTracksDescription": "Fejezetek beállítása minden egyes hangfájlt egy fejezetként használva, és a fejezet címét a hangfájl neveként",
|
||||||
"MessageShareExpirationWillBe": "A lejárat: <strong>{0}</strong>",
|
"MessageShareExpirationWillBe": "A lejárat: <strong>{0}</strong>",
|
||||||
|
@ -861,6 +882,7 @@
|
||||||
"MessageTaskNoFilesToScan": "Nincs beolvasandó fájl",
|
"MessageTaskNoFilesToScan": "Nincs beolvasandó fájl",
|
||||||
"MessageTaskOpmlImport": "OPML import",
|
"MessageTaskOpmlImport": "OPML import",
|
||||||
"MessageTaskOpmlImportDescription": "Podcastok létrehozása {0} RSS hírcsatornából",
|
"MessageTaskOpmlImportDescription": "Podcastok létrehozása {0} RSS hírcsatornából",
|
||||||
|
"MessageTaskOpmlImportFeed": "OPML import hírcsatorna",
|
||||||
"MessageTaskOpmlImportFeedDescription": "RSS feed „{0}” importálása",
|
"MessageTaskOpmlImportFeedDescription": "RSS feed „{0}” importálása",
|
||||||
"MessageTaskOpmlImportFeedFailed": "Nem sikerült letölteni a podcast feedet",
|
"MessageTaskOpmlImportFeedFailed": "Nem sikerült letölteni a podcast feedet",
|
||||||
"MessageTaskOpmlImportFeedPodcastDescription": "„{0}” podcast létrehozása",
|
"MessageTaskOpmlImportFeedPodcastDescription": "„{0}” podcast létrehozása",
|
||||||
|
@ -869,6 +891,7 @@
|
||||||
"MessageTaskOpmlImportFinished": "{0} podcast hozzáadva",
|
"MessageTaskOpmlImportFinished": "{0} podcast hozzáadva",
|
||||||
"MessageTaskOpmlParseFailed": "Az OPML fájl elemzése nem sikerült",
|
"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",
|
"MessageTaskOpmlParseFastFail": "Érvénytelen OPML fájl: <opml> tag nem található VAGY nem találtak <outline> taget",
|
||||||
|
"MessageTaskOpmlParseNoneFound": "Nem található feed az OPML fájlban",
|
||||||
"MessageTaskScanItemsAdded": "{0} hozzáadva",
|
"MessageTaskScanItemsAdded": "{0} hozzáadva",
|
||||||
"MessageTaskScanItemsMissing": "{0} hiányzik",
|
"MessageTaskScanItemsMissing": "{0} hiányzik",
|
||||||
"MessageTaskScanItemsUpdated": "{0} frissítve",
|
"MessageTaskScanItemsUpdated": "{0} frissítve",
|
||||||
|
@ -896,6 +919,8 @@
|
||||||
"NotificationOnBackupCompletedDescription": "A biztonsági mentés befejezésekor aktiválódik",
|
"NotificationOnBackupCompletedDescription": "A biztonsági mentés befejezésekor aktiválódik",
|
||||||
"NotificationOnBackupFailedDescription": "A biztonsági mentés sikertelensége esetén 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",
|
"NotificationOnEpisodeDownloadedDescription": "Egy podcast epizód automatikus letöltésekor aktiválódik",
|
||||||
|
"NotificationOnRSSFeedDisabledDescription": "Akkor lép működésbe, ha az automatikus epizódletöltés a túl sok sikertelen próbálkozás miatt letiltásra kerül",
|
||||||
|
"NotificationOnRSSFeedFailedDescription": "Akkor aktiválódik, ha az RSS feed kérés sikertelen az automatikus epizódletöltésnél",
|
||||||
"NotificationOnTestDescription": "Esemény az értesítési rendszer teszteléséhez",
|
"NotificationOnTestDescription": "Esemény az értesítési rendszer teszteléséhez",
|
||||||
"PlaceholderNewCollection": "Új gyűjtemény neve",
|
"PlaceholderNewCollection": "Új gyűjtemény neve",
|
||||||
"PlaceholderNewFolderPath": "Új mappa útvonala",
|
"PlaceholderNewFolderPath": "Új mappa útvonala",
|
||||||
|
@ -940,8 +965,11 @@
|
||||||
"ToastBackupRestoreFailed": "A biztonsági mentés visszaállítása sikertelen",
|
"ToastBackupRestoreFailed": "A biztonsági mentés visszaállítása sikertelen",
|
||||||
"ToastBackupUploadFailed": "A biztonsági mentés feltöltése sikertelen",
|
"ToastBackupUploadFailed": "A biztonsági mentés feltöltése sikertelen",
|
||||||
"ToastBackupUploadSuccess": "Biztonsági mentés feltöltve",
|
"ToastBackupUploadSuccess": "Biztonsági mentés feltöltve",
|
||||||
|
"ToastBatchApplyDetailsToItemsSuccess": "Tételekre alkalmazott részletek",
|
||||||
"ToastBatchDeleteFailed": "A tömeges törlés nem sikerült",
|
"ToastBatchDeleteFailed": "A tömeges törlés nem sikerült",
|
||||||
"ToastBatchDeleteSuccess": "Sikeres tömeges törlés",
|
"ToastBatchDeleteSuccess": "Sikeres tömeges törlés",
|
||||||
|
"ToastBatchQuickMatchFailed": "Tömeges Gyors Egyeztetés sikertelen!",
|
||||||
|
"ToastBatchQuickMatchStarted": "{0} könyv Tömeges Gyors Egyeztetése elkezdődött!",
|
||||||
"ToastBatchUpdateFailed": "Kötegelt frissítés sikertelen",
|
"ToastBatchUpdateFailed": "Kötegelt frissítés sikertelen",
|
||||||
"ToastBatchUpdateSuccess": "Kötegelt frissítés sikeres",
|
"ToastBatchUpdateSuccess": "Kötegelt frissítés sikeres",
|
||||||
"ToastBookmarkCreateFailed": "Könyvjelző létrehozása sikertelen",
|
"ToastBookmarkCreateFailed": "Könyvjelző létrehozása sikertelen",
|
||||||
|
@ -950,9 +978,12 @@
|
||||||
"ToastCachePurgeFailed": "A gyorsítótár törlése sikertelen",
|
"ToastCachePurgeFailed": "A gyorsítótár törlése sikertelen",
|
||||||
"ToastCachePurgeSuccess": "A gyorsítótár sikeresen törölve",
|
"ToastCachePurgeSuccess": "A gyorsítótár sikeresen törölve",
|
||||||
"ToastChaptersHaveErrors": "A fejezetek hibákat tartalmaznak",
|
"ToastChaptersHaveErrors": "A fejezetek hibákat tartalmaznak",
|
||||||
|
"ToastChaptersInvalidShiftAmountLast": "Érvénytelen eltolási érték. Az utolsó fejezet kezdési időpontja túlnyúlna a hangoskönyv időtartamán.",
|
||||||
|
"ToastChaptersInvalidShiftAmountStart": "Érvénytelen eltolási érték. Az első fejezet hossza nulla vagy negatív lenne, és a második fejezet felülírná. Növelje a második fejezet kezdő időtartamát.",
|
||||||
"ToastChaptersMustHaveTitles": "A fejezeteknek címekkel kell rendelkezniük",
|
"ToastChaptersMustHaveTitles": "A fejezeteknek címekkel kell rendelkezniük",
|
||||||
"ToastChaptersRemoved": "Fejezetek eltávolítva",
|
"ToastChaptersRemoved": "Fejezetek eltávolítva",
|
||||||
"ToastChaptersUpdated": "Fejezetek frissítve",
|
"ToastChaptersUpdated": "Fejezetek frissítve",
|
||||||
|
"ToastCollectionItemsAddFailed": "A tétel(ek) hozzáadása gyűjteményhez sikertelen",
|
||||||
"ToastCollectionRemoveSuccess": "Gyűjtemény eltávolítva",
|
"ToastCollectionRemoveSuccess": "Gyűjtemény eltávolítva",
|
||||||
"ToastCollectionUpdateSuccess": "Gyűjtemény frissítve",
|
"ToastCollectionUpdateSuccess": "Gyűjtemény frissítve",
|
||||||
"ToastCoverUpdateFailed": "A borító frissítése nem sikerült",
|
"ToastCoverUpdateFailed": "A borító frissítése nem sikerült",
|
||||||
|
@ -967,6 +998,7 @@
|
||||||
"ToastEncodeCancelFailed": "A kódolás törlése sikertelen volt",
|
"ToastEncodeCancelFailed": "A kódolás törlése sikertelen volt",
|
||||||
"ToastEncodeCancelSucces": "Kódolás törölve",
|
"ToastEncodeCancelSucces": "Kódolás törölve",
|
||||||
"ToastEpisodeDownloadQueueClearFailed": "Nem sikerült törölni a várólistát",
|
"ToastEpisodeDownloadQueueClearFailed": "Nem sikerült törölni a várólistát",
|
||||||
|
"ToastEpisodeDownloadQueueClearSuccess": "Epizód letöltési várólista törölve",
|
||||||
"ToastEpisodeUpdateSuccess": "{0} epizód frissítve",
|
"ToastEpisodeUpdateSuccess": "{0} epizód frissítve",
|
||||||
"ToastErrorCannotShare": "Ezen az eszközön nem lehet natívan megosztani",
|
"ToastErrorCannotShare": "Ezen az eszközön nem lehet natívan megosztani",
|
||||||
"ToastFailedToLoadData": "Sikertelen adatbetöltés",
|
"ToastFailedToLoadData": "Sikertelen adatbetöltés",
|
||||||
|
@ -974,6 +1006,7 @@
|
||||||
"ToastFailedToShare": "Nem sikerült megosztani",
|
"ToastFailedToShare": "Nem sikerült megosztani",
|
||||||
"ToastFailedToUpdate": "Nem sikerült frissíteni",
|
"ToastFailedToUpdate": "Nem sikerült frissíteni",
|
||||||
"ToastInvalidImageUrl": "Érvénytelen a kép URL címe",
|
"ToastInvalidImageUrl": "Érvénytelen a kép URL címe",
|
||||||
|
"ToastInvalidMaxEpisodesToDownload": "A letölthető epizódok száma érvénytelen",
|
||||||
"ToastInvalidUrl": "Érvénytelen URL",
|
"ToastInvalidUrl": "Érvénytelen URL",
|
||||||
"ToastItemCoverUpdateSuccess": "Elem borítója frissítve",
|
"ToastItemCoverUpdateSuccess": "Elem borítója frissítve",
|
||||||
"ToastItemDeletedFailed": "Nem sikerült törölni az elemet",
|
"ToastItemDeletedFailed": "Nem sikerült törölni az elemet",
|
||||||
|
@ -1011,8 +1044,11 @@
|
||||||
"ToastNoUpdatesNecessary": "Nincs szükség frissítésre",
|
"ToastNoUpdatesNecessary": "Nincs szükség frissítésre",
|
||||||
"ToastNotificationCreateFailed": "Értesítés létrehozása sikertelen",
|
"ToastNotificationCreateFailed": "Értesítés létrehozása sikertelen",
|
||||||
"ToastNotificationDeleteFailed": "Értesítés törlése sikertelen",
|
"ToastNotificationDeleteFailed": "Értesítés törlése sikertelen",
|
||||||
|
"ToastNotificationFailedMaximum": "A sikertelen kísérletek maximális száma >= 0 kell, hogy legyen",
|
||||||
|
"ToastNotificationQueueMaximum": "Az értesítési sor maximális száma >= 0 kell, hogy legyen",
|
||||||
"ToastNotificationSettingsUpdateSuccess": "Értesítési beállítások frissítve",
|
"ToastNotificationSettingsUpdateSuccess": "Értesítési beállítások frissítve",
|
||||||
"ToastNotificationTestTriggerFailed": "Nem sikerült a tesztértesítést elindítani",
|
"ToastNotificationTestTriggerFailed": "Nem sikerült a tesztértesítést elindítani",
|
||||||
|
"ToastNotificationTestTriggerSuccess": "Kiváltott tesztértesítés",
|
||||||
"ToastNotificationUpdateSuccess": "Értesítés frissítve",
|
"ToastNotificationUpdateSuccess": "Értesítés frissítve",
|
||||||
"ToastPlaylistCreateFailed": "Lejátszási lista létrehozása sikertelen",
|
"ToastPlaylistCreateFailed": "Lejátszási lista létrehozása sikertelen",
|
||||||
"ToastPlaylistCreateSuccess": "Lejátszási lista létrehozva",
|
"ToastPlaylistCreateSuccess": "Lejátszási lista létrehozva",
|
||||||
|
@ -1020,6 +1056,7 @@
|
||||||
"ToastPlaylistUpdateSuccess": "Lejátszási lista frissítve",
|
"ToastPlaylistUpdateSuccess": "Lejátszási lista frissítve",
|
||||||
"ToastPodcastCreateFailed": "Podcast létrehozása sikertelen",
|
"ToastPodcastCreateFailed": "Podcast létrehozása sikertelen",
|
||||||
"ToastPodcastCreateSuccess": "A podcast sikeresen létrehozva",
|
"ToastPodcastCreateSuccess": "A podcast sikeresen létrehozva",
|
||||||
|
"ToastPodcastGetFeedFailed": "Nem sikerült podcast feedet kapni",
|
||||||
"ToastPodcastNoEpisodesInFeed": "Nincsenek epizódok az RSS hírcsatornában",
|
"ToastPodcastNoEpisodesInFeed": "Nincsenek epizódok az RSS hírcsatornában",
|
||||||
"ToastPodcastNoRssFeed": "A podcastnak nincs RSS-hírcsatornája",
|
"ToastPodcastNoRssFeed": "A podcastnak nincs RSS-hírcsatornája",
|
||||||
"ToastProgressIsNotBeingSynced": "Az előrehaladás nem szinkronizálódik, a lejátszás újraindul",
|
"ToastProgressIsNotBeingSynced": "Az előrehaladás nem szinkronizálódik, a lejátszás újraindul",
|
||||||
|
@ -1032,10 +1069,18 @@
|
||||||
"ToastRemoveFailed": "Sikertelen eltávolítás",
|
"ToastRemoveFailed": "Sikertelen eltávolítás",
|
||||||
"ToastRemoveItemFromCollectionFailed": "Tétel eltávolítása a gyűjteményből sikertelen",
|
"ToastRemoveItemFromCollectionFailed": "Tétel eltávolítása a gyűjteményből sikertelen",
|
||||||
"ToastRemoveItemFromCollectionSuccess": "Tétel eltávolítva a gyűjteményből",
|
"ToastRemoveItemFromCollectionSuccess": "Tétel eltávolítva a gyűjteményből",
|
||||||
|
"ToastRemoveItemsWithIssuesFailed": "Nem sikerült eltávolítani a hibás könyvtárelemeket",
|
||||||
|
"ToastRemoveItemsWithIssuesSuccess": "Hibás könyvtárelemek eltávolítva",
|
||||||
"ToastRenameFailed": "Sikertelen átnevezés",
|
"ToastRenameFailed": "Sikertelen átnevezés",
|
||||||
|
"ToastRescanFailed": "Sikertelen újrakeresés a következőnél: {0}",
|
||||||
|
"ToastRescanRemoved": "A teljes újrabeolvasás befejezve, elem eltávolítva",
|
||||||
|
"ToastRescanUpToDate": "A teljes újrabeolvasás befejezve, elem naprakész volt",
|
||||||
|
"ToastRescanUpdated": "A teljes újrabeolvasás befejezve, elem frissítve",
|
||||||
|
"ToastScanFailed": "Nem sikerült beolvasni a könyvtárelemet",
|
||||||
"ToastSelectAtLeastOneUser": "Válasszon legalább egy felhasználót",
|
"ToastSelectAtLeastOneUser": "Válasszon legalább egy felhasználót",
|
||||||
"ToastSendEbookToDeviceFailed": "E-könyv küldése az eszközre sikertelen",
|
"ToastSendEbookToDeviceFailed": "E-könyv küldése az eszközre sikertelen",
|
||||||
"ToastSendEbookToDeviceSuccess": "E-könyv elküldve az eszközre \"{0}\"",
|
"ToastSendEbookToDeviceSuccess": "E-könyv elküldve az eszközre \"{0}\"",
|
||||||
|
"ToastSeriesSubmitFailedSameName": "Nem lehet két azonos nevű sorozatot hozzáadni",
|
||||||
"ToastSeriesUpdateFailed": "Sorozat frissítése sikertelen",
|
"ToastSeriesUpdateFailed": "Sorozat frissítése sikertelen",
|
||||||
"ToastSeriesUpdateSuccess": "Sorozat frissítése sikeres",
|
"ToastSeriesUpdateSuccess": "Sorozat frissítése sikeres",
|
||||||
"ToastServerSettingsUpdateSuccess": "Szerver beállítások frissítve",
|
"ToastServerSettingsUpdateSuccess": "Szerver beállítások frissítve",
|
||||||
|
@ -1043,6 +1088,8 @@
|
||||||
"ToastSessionDeleteFailed": "Munkamenet törlése sikertelen",
|
"ToastSessionDeleteFailed": "Munkamenet törlése sikertelen",
|
||||||
"ToastSessionDeleteSuccess": "Munkamenet törölve",
|
"ToastSessionDeleteSuccess": "Munkamenet törölve",
|
||||||
"ToastSleepTimerDone": "Alvásidőzítő kész... zZzzZZz",
|
"ToastSleepTimerDone": "Alvásidőzítő kész... zZzzZZz",
|
||||||
|
"ToastSlugMustChange": "A Slug érvénytelen karaktereket tartalmaz",
|
||||||
|
"ToastSlugRequired": "Slug szükséges",
|
||||||
"ToastSocketConnected": "Socket csatlakoztatva",
|
"ToastSocketConnected": "Socket csatlakoztatva",
|
||||||
"ToastSocketDisconnected": "Socket lecsatlakoztatva",
|
"ToastSocketDisconnected": "Socket lecsatlakoztatva",
|
||||||
"ToastSocketFailedToConnect": "A Socket csatlakoztatása sikertelen",
|
"ToastSocketFailedToConnect": "A Socket csatlakoztatása sikertelen",
|
||||||
|
@ -1050,9 +1097,14 @@
|
||||||
"ToastSortingPrefixesUpdateSuccess": "Rendezési előtagok frissítése ({0} elem)",
|
"ToastSortingPrefixesUpdateSuccess": "Rendezési előtagok frissítése ({0} elem)",
|
||||||
"ToastTitleRequired": "A cím kötelező",
|
"ToastTitleRequired": "A cím kötelező",
|
||||||
"ToastUnknownError": "Ismeretlen hiba",
|
"ToastUnknownError": "Ismeretlen hiba",
|
||||||
|
"ToastUnlinkOpenIdFailed": "Nem sikerült leválasztani a felhasználót az OpenID-ről",
|
||||||
|
"ToastUnlinkOpenIdSuccess": "Felhasználó leválasztva az OpenID-ről",
|
||||||
|
"ToastUploaderFilepathExistsError": "A \"{0}\" fájl elérési útja már létezik a szerveren",
|
||||||
|
"ToastUploaderItemExistsInSubdirectoryError": "A „{0}” elem a feltöltési útvonal egy alkönyvtárát használja.",
|
||||||
"ToastUserDeleteFailed": "Felhasználó törlése sikertelen",
|
"ToastUserDeleteFailed": "Felhasználó törlése sikertelen",
|
||||||
"ToastUserDeleteSuccess": "Felhasználó törölve",
|
"ToastUserDeleteSuccess": "Felhasználó törölve",
|
||||||
"ToastUserPasswordChangeSuccess": "Jelszó sikeresen megváltoztatva",
|
"ToastUserPasswordChangeSuccess": "Jelszó sikeresen megváltoztatva",
|
||||||
|
"ToastUserPasswordMismatch": "A jelszavak nem egyeznek",
|
||||||
"ToastUserPasswordMustChange": "Az új jelszó nem egyezik a régi jelszóval",
|
"ToastUserPasswordMustChange": "Az új jelszó nem egyezik a régi jelszóval",
|
||||||
"ToastUserRootRequireName": "Egy root felhasználónevet kell megadnia"
|
"ToastUserRootRequireName": "Egy root felhasználónevet kell megadnia"
|
||||||
}
|
}
|
||||||
|
|
|
@ -514,7 +514,7 @@
|
||||||
"LabelPublishers": "Editori",
|
"LabelPublishers": "Editori",
|
||||||
"LabelRSSFeedCustomOwnerEmail": "E-mail del proprietario personalizzato",
|
"LabelRSSFeedCustomOwnerEmail": "E-mail del proprietario personalizzato",
|
||||||
"LabelRSSFeedCustomOwnerName": "Nome del proprietario personalizzato",
|
"LabelRSSFeedCustomOwnerName": "Nome del proprietario personalizzato",
|
||||||
"LabelRSSFeedOpen": "Flusso RSS aperto",
|
"LabelRSSFeedOpen": "Feed RSS aperto",
|
||||||
"LabelRSSFeedPreventIndexing": "Impedisci l'indicizzazione",
|
"LabelRSSFeedPreventIndexing": "Impedisci l'indicizzazione",
|
||||||
"LabelRSSFeedSlug": "Parole chiave del flusso RSS",
|
"LabelRSSFeedSlug": "Parole chiave del flusso RSS",
|
||||||
"LabelRSSFeedURL": "URL del flusso RSS",
|
"LabelRSSFeedURL": "URL del flusso RSS",
|
||||||
|
@ -918,6 +918,8 @@
|
||||||
"NotificationOnBackupCompletedDescription": "Attivato al completamento di un backup",
|
"NotificationOnBackupCompletedDescription": "Attivato al completamento di un backup",
|
||||||
"NotificationOnBackupFailedDescription": "Attivato quando un backup fallisce",
|
"NotificationOnBackupFailedDescription": "Attivato quando un backup fallisce",
|
||||||
"NotificationOnEpisodeDownloadedDescription": "Attivato quando un episodio di podcast viene scaricato automaticamente",
|
"NotificationOnEpisodeDownloadedDescription": "Attivato quando un episodio di podcast viene scaricato automaticamente",
|
||||||
|
"NotificationOnRSSFeedDisabledDescription": "Attivato quando i download automatici degli episodi vengono disabilitati a causa di troppi tentativi falliti",
|
||||||
|
"NotificationOnRSSFeedFailedDescription": "Attivato quando la richiesta del feed RSS per il download automatico di un episodio fallisce",
|
||||||
"NotificationOnTestDescription": "test il sistema di notifica",
|
"NotificationOnTestDescription": "test il sistema di notifica",
|
||||||
"PlaceholderNewCollection": "Nome Nuova Raccolta",
|
"PlaceholderNewCollection": "Nome Nuova Raccolta",
|
||||||
"PlaceholderNewFolderPath": "Nuovo Percorso Cartella",
|
"PlaceholderNewFolderPath": "Nuovo Percorso Cartella",
|
||||||
|
|
|
@ -24,7 +24,7 @@
|
||||||
"ButtonCloseSession": "Sluit Sessie",
|
"ButtonCloseSession": "Sluit Sessie",
|
||||||
"ButtonCollections": "Collecties",
|
"ButtonCollections": "Collecties",
|
||||||
"ButtonConfigureScanner": "Configureer scanner",
|
"ButtonConfigureScanner": "Configureer scanner",
|
||||||
"ButtonCreate": "Creëer",
|
"ButtonCreate": "Aanmaken",
|
||||||
"ButtonCreateBackup": "Maak back-up",
|
"ButtonCreateBackup": "Maak back-up",
|
||||||
"ButtonDelete": "Verwijder",
|
"ButtonDelete": "Verwijder",
|
||||||
"ButtonDownloadQueue": "Wachtrij",
|
"ButtonDownloadQueue": "Wachtrij",
|
||||||
|
@ -43,9 +43,9 @@
|
||||||
"ButtonJumpForward": "Spring vooruit",
|
"ButtonJumpForward": "Spring vooruit",
|
||||||
"ButtonLatest": "Meest recent",
|
"ButtonLatest": "Meest recent",
|
||||||
"ButtonLibrary": "Bibliotheek",
|
"ButtonLibrary": "Bibliotheek",
|
||||||
"ButtonLogout": "Log uit",
|
"ButtonLogout": "Uitloggen",
|
||||||
"ButtonLookup": "Zoeken",
|
"ButtonLookup": "Zoeken",
|
||||||
"ButtonManageTracks": "Beheer tracks",
|
"ButtonManageTracks": "Tracks beheren",
|
||||||
"ButtonMapChapterTitles": "Hoofdstuktitels mappen",
|
"ButtonMapChapterTitles": "Hoofdstuktitels mappen",
|
||||||
"ButtonMatchAllAuthors": "Alle auteurs matchen",
|
"ButtonMatchAllAuthors": "Alle auteurs matchen",
|
||||||
"ButtonMatchBooks": "Alle boeken matchen",
|
"ButtonMatchBooks": "Alle boeken matchen",
|
||||||
|
@ -72,7 +72,7 @@
|
||||||
"ButtonQuickEmbedMetadata": "Snel Metadata Insluiten",
|
"ButtonQuickEmbedMetadata": "Snel Metadata Insluiten",
|
||||||
"ButtonQuickMatch": "Snelle match",
|
"ButtonQuickMatch": "Snelle match",
|
||||||
"ButtonReScan": "Nieuwe scan",
|
"ButtonReScan": "Nieuwe scan",
|
||||||
"ButtonRead": "Lees",
|
"ButtonRead": "Lezen",
|
||||||
"ButtonReadLess": "Lees minder",
|
"ButtonReadLess": "Lees minder",
|
||||||
"ButtonReadMore": "Lees meer",
|
"ButtonReadMore": "Lees meer",
|
||||||
"ButtonRefresh": "Verversen",
|
"ButtonRefresh": "Verversen",
|
||||||
|
@ -107,7 +107,7 @@
|
||||||
"ButtonUnlinkOpenId": "OpenID Ontkoppelen",
|
"ButtonUnlinkOpenId": "OpenID Ontkoppelen",
|
||||||
"ButtonUpload": "Upload",
|
"ButtonUpload": "Upload",
|
||||||
"ButtonUploadBackup": "Upload back-up",
|
"ButtonUploadBackup": "Upload back-up",
|
||||||
"ButtonUploadCover": "Upload cover",
|
"ButtonUploadCover": "Omslag uploaden",
|
||||||
"ButtonUploadOPMLFile": "Upload OPML-bestand",
|
"ButtonUploadOPMLFile": "Upload OPML-bestand",
|
||||||
"ButtonUserDelete": "Verwijder gebruiker {0}",
|
"ButtonUserDelete": "Verwijder gebruiker {0}",
|
||||||
"ButtonUserEdit": "Wijzig gebruiker {0}",
|
"ButtonUserEdit": "Wijzig gebruiker {0}",
|
||||||
|
@ -177,7 +177,8 @@
|
||||||
"HeaderPlaylist": "Afspeellijst",
|
"HeaderPlaylist": "Afspeellijst",
|
||||||
"HeaderPlaylistItems": "Onderdelen in afspeellijst",
|
"HeaderPlaylistItems": "Onderdelen in afspeellijst",
|
||||||
"HeaderPodcastsToAdd": "Toe te voegen podcasts",
|
"HeaderPodcastsToAdd": "Toe te voegen podcasts",
|
||||||
"HeaderPreviewCover": "Preview cover",
|
"HeaderPresets": "Voorinstellingen",
|
||||||
|
"HeaderPreviewCover": "Voorbeeld omslag",
|
||||||
"HeaderRSSFeedGeneral": "RSS-details",
|
"HeaderRSSFeedGeneral": "RSS-details",
|
||||||
"HeaderRSSFeedIsOpen": "RSS-feed is open",
|
"HeaderRSSFeedIsOpen": "RSS-feed is open",
|
||||||
"HeaderRSSFeeds": "RSS-feeds",
|
"HeaderRSSFeeds": "RSS-feeds",
|
||||||
|
@ -284,7 +285,7 @@
|
||||||
"LabelContinueReading": "Verder lezen",
|
"LabelContinueReading": "Verder lezen",
|
||||||
"LabelContinueSeries": "Doorgaan met Serie",
|
"LabelContinueSeries": "Doorgaan met Serie",
|
||||||
"LabelCover": "Omslag",
|
"LabelCover": "Omslag",
|
||||||
"LabelCoverImageURL": "Coverafbeelding URL",
|
"LabelCoverImageURL": "Omslagafbeelding-URL",
|
||||||
"LabelCoverProvider": "Omslag bron",
|
"LabelCoverProvider": "Omslag bron",
|
||||||
"LabelCreatedAt": "Gecreëerd op",
|
"LabelCreatedAt": "Gecreëerd op",
|
||||||
"LabelCronExpression": "Cron-uitdrukking",
|
"LabelCronExpression": "Cron-uitdrukking",
|
||||||
|
@ -321,7 +322,7 @@
|
||||||
"LabelEmailSettingsSecure": "Veilig",
|
"LabelEmailSettingsSecure": "Veilig",
|
||||||
"LabelEmailSettingsSecureHelp": "Als 'waar', dan gebruikt de verbinding TLS om met de server te verbinden. Als 'onwaar', dan wordt TLS gebruikt als de server de STARTTLS-extensie ondersteunt. In de meeste gevallen kies je voor 'waar' verbindt met poort 465. Voo poort 587 of 25, laat op 'onwaar'. (van nodemailer.com/smtp/#authentication)",
|
"LabelEmailSettingsSecureHelp": "Als 'waar', dan gebruikt de verbinding TLS om met de server te verbinden. Als 'onwaar', dan wordt TLS gebruikt als de server de STARTTLS-extensie ondersteunt. In de meeste gevallen kies je voor 'waar' verbindt met poort 465. Voo poort 587 of 25, laat op 'onwaar'. (van nodemailer.com/smtp/#authentication)",
|
||||||
"LabelEmailSettingsTestAddress": "Test-adres",
|
"LabelEmailSettingsTestAddress": "Test-adres",
|
||||||
"LabelEmbeddedCover": "Ingesloten cover",
|
"LabelEmbeddedCover": "Omslag in bestand",
|
||||||
"LabelEnable": "Inschakelen",
|
"LabelEnable": "Inschakelen",
|
||||||
"LabelEncodingBackupLocation": "Er wordt een back-up van uw originele audiobestanden opgeslagen in:",
|
"LabelEncodingBackupLocation": "Er wordt een back-up van uw originele audiobestanden opgeslagen in:",
|
||||||
"LabelEncodingChaptersNotEmbedded": "Hoofdstukken zijn niet ingesloten in audioboeken met meerdere sporen.",
|
"LabelEncodingChaptersNotEmbedded": "Hoofdstukken zijn niet ingesloten in audioboeken met meerdere sporen.",
|
||||||
|
@ -330,7 +331,7 @@
|
||||||
"LabelEncodingInfoEmbedded": "Metagegevens worden ingesloten in de audiotracks in uw audioboekmap.",
|
"LabelEncodingInfoEmbedded": "Metagegevens worden ingesloten in de audiotracks in uw audioboekmap.",
|
||||||
"LabelEncodingStartedNavigation": "Eenmaal de taak is gestart kan u weg navigeren van deze pagina.",
|
"LabelEncodingStartedNavigation": "Eenmaal de taak is gestart kan u weg navigeren van deze pagina.",
|
||||||
"LabelEncodingTimeWarning": "Encoding kan tot 30 minuten duren.",
|
"LabelEncodingTimeWarning": "Encoding kan tot 30 minuten duren.",
|
||||||
"LabelEncodingWarningAdvancedSettings": "Waarschuwing: update deze instellingen niet tenzij u bekend bent met de coderingsopties van ffmpeg.",
|
"LabelEncodingWarningAdvancedSettings": "Waarschuwing: pas deze instellingen niet aan tenzij u bekend bent met de coderingsopties van ffmpeg.",
|
||||||
"LabelEncodingWatcherDisabled": "Als u de watcher hebt uitgeschakeld, moet u het audioboek daarna opnieuw scannen.",
|
"LabelEncodingWatcherDisabled": "Als u de watcher hebt uitgeschakeld, moet u het audioboek daarna opnieuw scannen.",
|
||||||
"LabelEnd": "Einde",
|
"LabelEnd": "Einde",
|
||||||
"LabelEndOfChapter": "Einde van het Hoofdstuk",
|
"LabelEndOfChapter": "Einde van het Hoofdstuk",
|
||||||
|
@ -372,7 +373,7 @@
|
||||||
"LabelFull": "Vol",
|
"LabelFull": "Vol",
|
||||||
"LabelGenre": "Genre",
|
"LabelGenre": "Genre",
|
||||||
"LabelGenres": "Genres",
|
"LabelGenres": "Genres",
|
||||||
"LabelHardDeleteFile": "Hard-delete bestand",
|
"LabelHardDeleteFile": "Bestand permanent verwijderen",
|
||||||
"LabelHasEbook": "Heeft Ebook",
|
"LabelHasEbook": "Heeft Ebook",
|
||||||
"LabelHasSupplementaryEbook": "Heeft aanvullend Ebook",
|
"LabelHasSupplementaryEbook": "Heeft aanvullend Ebook",
|
||||||
"LabelHideSubtitles": "Ondertitels Verstoppen",
|
"LabelHideSubtitles": "Ondertitels Verstoppen",
|
||||||
|
@ -394,6 +395,7 @@
|
||||||
"LabelIntervalEvery6Hours": "Iedere 6 uur",
|
"LabelIntervalEvery6Hours": "Iedere 6 uur",
|
||||||
"LabelIntervalEveryDay": "Iedere dag",
|
"LabelIntervalEveryDay": "Iedere dag",
|
||||||
"LabelIntervalEveryHour": "Ieder uur",
|
"LabelIntervalEveryHour": "Ieder uur",
|
||||||
|
"LabelIntervalEveryMinute": "Elke minuut",
|
||||||
"LabelInvert": "Omdraaien",
|
"LabelInvert": "Omdraaien",
|
||||||
"LabelItem": "Onderdeel",
|
"LabelItem": "Onderdeel",
|
||||||
"LabelJumpBackwardAmount": "Terugspoelen hoeveelheid",
|
"LabelJumpBackwardAmount": "Terugspoelen hoeveelheid",
|
||||||
|
@ -405,7 +407,7 @@
|
||||||
"LabelLastBookUpdated": "Laatst bijgewerkte boek",
|
"LabelLastBookUpdated": "Laatst bijgewerkte boek",
|
||||||
"LabelLastSeen": "Laatst gezien",
|
"LabelLastSeen": "Laatst gezien",
|
||||||
"LabelLastTime": "Laatste keer",
|
"LabelLastTime": "Laatste keer",
|
||||||
"LabelLastUpdate": "Laatste update",
|
"LabelLastUpdate": "Laatste wijziging",
|
||||||
"LabelLayout": "Layout",
|
"LabelLayout": "Layout",
|
||||||
"LabelLayoutSinglePage": "Enkele pagina",
|
"LabelLayoutSinglePage": "Enkele pagina",
|
||||||
"LabelLayoutSplitPage": "Gesplitste pagina",
|
"LabelLayoutSplitPage": "Gesplitste pagina",
|
||||||
|
@ -424,7 +426,7 @@
|
||||||
"LabelLookForNewEpisodesAfterDate": "Zoek naar nieuwe afleveringen na deze datum",
|
"LabelLookForNewEpisodesAfterDate": "Zoek naar nieuwe afleveringen na deze datum",
|
||||||
"LabelLowestPriority": "Laagste Prioriteit",
|
"LabelLowestPriority": "Laagste Prioriteit",
|
||||||
"LabelMatchExistingUsersBy": "Bestaande gebruikers matchen op",
|
"LabelMatchExistingUsersBy": "Bestaande gebruikers matchen op",
|
||||||
"LabelMatchExistingUsersByDescription": "Wordt gebruikt om bestaande gebruikers te verbinden. Zodra ze verbonden zijn, worden gebruikers gekoppeld aan een unieke id van uw SSO-provider.",
|
"LabelMatchExistingUsersByDescription": "Wordt gebruikt om bestaande gebruikers te verbinden. Zodra ze verbonden zijn, worden gebruikers gekoppeld aan een unieke id van uw SSO-provider",
|
||||||
"LabelMaxEpisodesToDownload": "Maximale # afleveringen om te downloaden. Gebruik 0 voor ongelimiteerd.",
|
"LabelMaxEpisodesToDownload": "Maximale # afleveringen om te downloaden. Gebruik 0 voor ongelimiteerd.",
|
||||||
"LabelMaxEpisodesToDownloadPerCheck": "Maximale # nieuwe afleveringen om te downloaden per check",
|
"LabelMaxEpisodesToDownloadPerCheck": "Maximale # nieuwe afleveringen om te downloaden per check",
|
||||||
"LabelMaxEpisodesToKeep": "Maximale # afleveringen om te houden",
|
"LabelMaxEpisodesToKeep": "Maximale # afleveringen om te houden",
|
||||||
|
@ -512,7 +514,7 @@
|
||||||
"LabelPublishers": "Uitgevers",
|
"LabelPublishers": "Uitgevers",
|
||||||
"LabelRSSFeedCustomOwnerEmail": "Aangepast e-mailadres eigenaar",
|
"LabelRSSFeedCustomOwnerEmail": "Aangepast e-mailadres eigenaar",
|
||||||
"LabelRSSFeedCustomOwnerName": "Aangepaste naam eigenaar",
|
"LabelRSSFeedCustomOwnerName": "Aangepaste naam eigenaar",
|
||||||
"LabelRSSFeedOpen": "RSS-feed open",
|
"LabelRSSFeedOpen": "RSS Feed Open",
|
||||||
"LabelRSSFeedPreventIndexing": "Voorkom indexering",
|
"LabelRSSFeedPreventIndexing": "Voorkom indexering",
|
||||||
"LabelRSSFeedSlug": "RSS-feed slug",
|
"LabelRSSFeedSlug": "RSS-feed slug",
|
||||||
"LabelRSSFeedURL": "RSS-feed URL",
|
"LabelRSSFeedURL": "RSS-feed URL",
|
||||||
|
@ -529,7 +531,8 @@
|
||||||
"LabelReleaseDate": "Verschijningsdatum",
|
"LabelReleaseDate": "Verschijningsdatum",
|
||||||
"LabelRemoveAllMetadataAbs": "Verwijder alle metadata.abs bestanden",
|
"LabelRemoveAllMetadataAbs": "Verwijder alle metadata.abs bestanden",
|
||||||
"LabelRemoveAllMetadataJson": "Verwijder alle metadata.json bestanden",
|
"LabelRemoveAllMetadataJson": "Verwijder alle metadata.json bestanden",
|
||||||
"LabelRemoveCover": "Verwijder cover",
|
"LabelRemoveAudibleBranding": "Verwijder Audible intro en outro uit hoofdstukken",
|
||||||
|
"LabelRemoveCover": "Omslag verwijderen",
|
||||||
"LabelRemoveMetadataFile": "Verwijder metadata bestanden in bibliotheek item folders",
|
"LabelRemoveMetadataFile": "Verwijder metadata bestanden in bibliotheek item folders",
|
||||||
"LabelRemoveMetadataFileHelp": "Verwijder alle metadata.json en metadata.abs bestanden in uw {0} folders.",
|
"LabelRemoveMetadataFileHelp": "Verwijder alle metadata.json en metadata.abs bestanden in uw {0} folders.",
|
||||||
"LabelRowsPerPage": "Rijen per pagina",
|
"LabelRowsPerPage": "Rijen per pagina",
|
||||||
|
@ -557,14 +560,16 @@
|
||||||
"LabelSettingsAudiobooksOnlyHelp": "Deze instelling inschakelen zorgt ervoor dat ebook-bestanden genegeerd worden tenzij ze in een audiobook-map staan, in welk geval ze worden ingesteld als supplementaire ebooks",
|
"LabelSettingsAudiobooksOnlyHelp": "Deze instelling inschakelen zorgt ervoor dat ebook-bestanden genegeerd worden tenzij ze in een audiobook-map staan, in welk geval ze worden ingesteld als supplementaire ebooks",
|
||||||
"LabelSettingsBookshelfViewHelp": "Skeumorphisch design met houten planken",
|
"LabelSettingsBookshelfViewHelp": "Skeumorphisch design met houten planken",
|
||||||
"LabelSettingsChromecastSupport": "Chromecast ondersteuning",
|
"LabelSettingsChromecastSupport": "Chromecast ondersteuning",
|
||||||
"LabelSettingsDateFormat": "Datum format",
|
"LabelSettingsDateFormat": "Datumnotatie",
|
||||||
|
"LabelSettingsEnableWatcher": "Bibliotheken automatisch scannen op wijzigingen",
|
||||||
|
"LabelSettingsEnableWatcherForLibrary": "Bibliotheek automatisch scannen op wijzigingen",
|
||||||
"LabelSettingsEnableWatcherHelp": "Zorgt voor het automatisch toevoegen/bijwerken van onderdelen als bestandswijzigingen worden gedetecteerd. *Vereist herstarten van server",
|
"LabelSettingsEnableWatcherHelp": "Zorgt voor het automatisch toevoegen/bijwerken van onderdelen als bestandswijzigingen worden gedetecteerd. *Vereist herstarten van server",
|
||||||
"LabelSettingsEpubsAllowScriptedContent": "Sta scripted content toe in epubs",
|
"LabelSettingsEpubsAllowScriptedContent": "Sta scripted content toe in epubs",
|
||||||
"LabelSettingsEpubsAllowScriptedContentHelp": "Sta toe dat epub-bestanden scripts uitvoeren. Het wordt aanbevolen om deze instelling uitgeschakeld te houden, tenzij u de bron van de epub-bestanden vertrouwt.",
|
"LabelSettingsEpubsAllowScriptedContentHelp": "Sta toe dat epub-bestanden scripts uitvoeren. Het wordt aanbevolen om deze instelling uitgeschakeld te houden, tenzij u de bron van de epub-bestanden vertrouwt.",
|
||||||
"LabelSettingsExperimentalFeatures": "Experimentele functies",
|
"LabelSettingsExperimentalFeatures": "Experimentele functies",
|
||||||
"LabelSettingsExperimentalFeaturesHelp": "Functies in ontwikkeling die je feedback en testing kunnen gebruiken. Klik om de Github-discussie te openen.",
|
"LabelSettingsExperimentalFeaturesHelp": "Functies in ontwikkeling die je feedback en testing kunnen gebruiken. Klik om de Github-discussie te openen.",
|
||||||
"LabelSettingsFindCovers": "Zoek covers",
|
"LabelSettingsFindCovers": "Omslagen zoeken",
|
||||||
"LabelSettingsFindCoversHelp": "Als je audioboek geen ingesloten cover of cover in de map heeft, zal de scanner proberen een cover te vinden.<br>Opmerking: Dit zal de scan-duur verlengen",
|
"LabelSettingsFindCoversHelp": "Als je audioboek geen omslag in het bestand of in de map heeft, zal de scanner automatisch proberen een omslag te vinden.<br>Opmerking: Dit kan de scantijd verlengen",
|
||||||
"LabelSettingsHideSingleBookSeries": "Verberg series met een enkel boek",
|
"LabelSettingsHideSingleBookSeries": "Verberg series met een enkel boek",
|
||||||
"LabelSettingsHideSingleBookSeriesHelp": "Series die slechts een enkel boek bevatten worden verborgen op de seriespagina en de homepagina-planken.",
|
"LabelSettingsHideSingleBookSeriesHelp": "Series die slechts een enkel boek bevatten worden verborgen op de seriespagina en de homepagina-planken.",
|
||||||
"LabelSettingsHomePageBookshelfView": "Boekenplank-view voor homepagina",
|
"LabelSettingsHomePageBookshelfView": "Boekenplank-view voor homepagina",
|
||||||
|
@ -574,18 +579,18 @@
|
||||||
"LabelSettingsLibraryMarkAsFinishedWhen": "Markeer media item wanneer voltooid",
|
"LabelSettingsLibraryMarkAsFinishedWhen": "Markeer media item wanneer voltooid",
|
||||||
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Sla eedere boeken in Serie Verderzetten over",
|
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Sla eedere boeken in Serie Verderzetten over",
|
||||||
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "De Continue Series home page shelf toont het eerste boek dat nog niet is begonnen in series waarvan er minstens één is voltooid en er geen boeken in uitvoering zijn. Als u deze instelling inschakelt, wordt de serie voortgezet vanaf het boek dat het verst is voltooid in plaats van het eerste boek dat nog niet is begonnen.",
|
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "De Continue Series home page shelf toont het eerste boek dat nog niet is begonnen in series waarvan er minstens één is voltooid en er geen boeken in uitvoering zijn. Als u deze instelling inschakelt, wordt de serie voortgezet vanaf het boek dat het verst is voltooid in plaats van het eerste boek dat nog niet is begonnen.",
|
||||||
"LabelSettingsParseSubtitles": "Parseer subtitel",
|
"LabelSettingsParseSubtitles": "Subtitel afleiden uit foldernaam",
|
||||||
"LabelSettingsParseSubtitlesHelp": "Haal subtitels uit mapnaam van audioboek.<br>Subtitel moet gescheiden zijn met \" - \"<br>b.v. \"Boektitel - Een Subtitel Hier\" heeft als subtitel \"Een Subtitel Hier\"",
|
"LabelSettingsParseSubtitlesHelp": "Haal subtitels uit mapnaam van audioboek.<br>Subtitel moet gescheiden zijn met \" - \"<br>b.v. \"Boektitel - Een Subtitel Hier\" heeft als subtitel \"Een Subtitel Hier\"",
|
||||||
"LabelSettingsPreferMatchedMetadata": "Prefereer gematchte metadata",
|
"LabelSettingsPreferMatchedMetadata": "Geef voorkeur aan gematchte metadata",
|
||||||
"LabelSettingsPreferMatchedMetadataHelp": "Gematchte data zal onderdeeldetails overschrijven bij gebruik van Quick Match. Standaard vult Quick Match uitsluitend ontbrekende details aan.",
|
"LabelSettingsPreferMatchedMetadataHelp": "Gematchte data zal onderdeeldetails overschrijven bij gebruik van Quick Match. Standaard vult Quick Match uitsluitend ontbrekende details aan.",
|
||||||
"LabelSettingsSkipMatchingBooksWithASIN": "Sla matchen van boeken over die al over een ASIN beschikken",
|
"LabelSettingsSkipMatchingBooksWithASIN": "Sla matchen van boeken over die al over een ASIN beschikken",
|
||||||
"LabelSettingsSkipMatchingBooksWithISBN": "Sla matchen van boeken over die al over een ISBN beschikken",
|
"LabelSettingsSkipMatchingBooksWithISBN": "Sla matchen van boeken over die al over een ISBN beschikken",
|
||||||
"LabelSettingsSortingIgnorePrefixes": "Negeer voorvoegsels bij sorteren",
|
"LabelSettingsSortingIgnorePrefixes": "Negeer voorvoegsels bij sorteren",
|
||||||
"LabelSettingsSortingIgnorePrefixesHelp": "b.v. voor voorvoegsel \"The\" wordt titel \"The Title\" dan gesorteerd als \"Title, The\"",
|
"LabelSettingsSortingIgnorePrefixesHelp": "b.v. voor voorvoegsel \"The\" wordt titel \"The Title\" dan gesorteerd als \"Title, The\"",
|
||||||
"LabelSettingsSquareBookCovers": "Gebruik vierkante boekcovers",
|
"LabelSettingsSquareBookCovers": "Gebruik vierkante boekomslagen",
|
||||||
"LabelSettingsSquareBookCoversHelp": "Prefereer gebruik van vierkante covers boven standaard 1.6:1 boekcovers",
|
"LabelSettingsSquareBookCoversHelp": "Gebruik vierkante boekomslagen in plaats van standaard 1,6:1",
|
||||||
"LabelSettingsStoreCoversWithItem": "Bewaar covers bij onderdeel",
|
"LabelSettingsStoreCoversWithItem": "Bewaar omslagen bij onderdeel",
|
||||||
"LabelSettingsStoreCoversWithItemHelp": "Standaard worden covers bewaard in /metadata/items, door deze instelling in te schakelen zullen covers in de map van je bibliotheekonderdeel bewaard worden. Slechts een bestand genaamd \"cover\" zal worden bewaard",
|
"LabelSettingsStoreCoversWithItemHelp": "Omslagen worden standaard in /metadata/items opgeslagen. Bij inschakelen worden ze in de map van het bibliotheekitem zelf opgeslagen. Slechts een bestand genaamd \"cover\" zal worden bewaard",
|
||||||
"LabelSettingsStoreMetadataWithItem": "Bewaar metadata bij onderdeel",
|
"LabelSettingsStoreMetadataWithItem": "Bewaar metadata bij onderdeel",
|
||||||
"LabelSettingsStoreMetadataWithItemHelp": "Standaard worden metadata-bestanden bewaard in /metadata/items, door deze instelling in te schakelen zullen metadata bestanden in de map van je bibliotheekonderdeel bewaard worden",
|
"LabelSettingsStoreMetadataWithItemHelp": "Standaard worden metadata-bestanden bewaard in /metadata/items, door deze instelling in te schakelen zullen metadata bestanden in de map van je bibliotheekonderdeel bewaard worden",
|
||||||
"LabelSettingsTimeFormat": "Tijdformat",
|
"LabelSettingsTimeFormat": "Tijdformat",
|
||||||
|
@ -601,6 +606,7 @@
|
||||||
"LabelSlug": "Slak",
|
"LabelSlug": "Slak",
|
||||||
"LabelSortAscending": "Oplopend",
|
"LabelSortAscending": "Oplopend",
|
||||||
"LabelSortDescending": "Aflopend",
|
"LabelSortDescending": "Aflopend",
|
||||||
|
"LabelSortPubDate": "Sorteer Pub Datum",
|
||||||
"LabelStart": "Start",
|
"LabelStart": "Start",
|
||||||
"LabelStartTime": "Starttijd",
|
"LabelStartTime": "Starttijd",
|
||||||
"LabelStarted": "Gestart",
|
"LabelStarted": "Gestart",
|
||||||
|
@ -646,12 +652,12 @@
|
||||||
"LabelTimeToShift": "Tijd op te schuiven in seconden",
|
"LabelTimeToShift": "Tijd op te schuiven in seconden",
|
||||||
"LabelTitle": "Titel",
|
"LabelTitle": "Titel",
|
||||||
"LabelToolsEmbedMetadata": "Metadata insluiten",
|
"LabelToolsEmbedMetadata": "Metadata insluiten",
|
||||||
"LabelToolsEmbedMetadataDescription": "Metadata insluiten in audiobestanden, inclusief coverafbeelding en hoofdstukken.",
|
"LabelToolsEmbedMetadataDescription": "Metadata insluiten in audiobestanden, inclusief omslagafbeelding en hoofdstukken.",
|
||||||
"LabelToolsM4bEncoder": "M4B Encoder",
|
"LabelToolsM4bEncoder": "M4B Encoder",
|
||||||
"LabelToolsMakeM4b": "Maak M4B-audioboekbestand",
|
"LabelToolsMakeM4b": "Maak M4B-audioboekbestand",
|
||||||
"LabelToolsMakeM4bDescription": "Genereer een .M4B-audioboekbestand met ingesloten metadata, coverafbeelding en hoofdstukken.",
|
"LabelToolsMakeM4bDescription": "Genereer een .M4B-audioboekbestand met ingesloten metadata, omslagafbeelding en hoofdstukken.",
|
||||||
"LabelToolsSplitM4b": "Splitst M4B in MP3's",
|
"LabelToolsSplitM4b": "Splitst M4B in MP3's",
|
||||||
"LabelToolsSplitM4bDescription": "Maak MP3's van een M4B, gesplitst per hoofdstuk met ingesloten metadata, coverafbeelding en hoofdstukken.",
|
"LabelToolsSplitM4bDescription": "Maak MP3's van een M4B, gesplitst per hoofdstuk met ingesloten metadata, omslagafbeelding en hoofdstukken.",
|
||||||
"LabelTotalDuration": "Totale duur",
|
"LabelTotalDuration": "Totale duur",
|
||||||
"LabelTotalTimeListened": "Totale tijd geluisterd",
|
"LabelTotalTimeListened": "Totale tijd geluisterd",
|
||||||
"LabelTrackFromFilename": "Track vanuit bestandsnaam",
|
"LabelTrackFromFilename": "Track vanuit bestandsnaam",
|
||||||
|
@ -666,8 +672,8 @@
|
||||||
"LabelUndo": "Ongedaan maken",
|
"LabelUndo": "Ongedaan maken",
|
||||||
"LabelUnknown": "Onbekend",
|
"LabelUnknown": "Onbekend",
|
||||||
"LabelUnknownPublishDate": "Onbekende uitgeefdatum",
|
"LabelUnknownPublishDate": "Onbekende uitgeefdatum",
|
||||||
"LabelUpdateCover": "Cover bijwerken",
|
"LabelUpdateCover": "Omslag bijwerken",
|
||||||
"LabelUpdateCoverHelp": "Sta overschrijven van bestaande covers toe voor de geselecteerde boeken wanneer een match is gevonden",
|
"LabelUpdateCoverHelp": "Sta overschrijven van bestaande omslagen toe voor de geselecteerde boeken wanneer een match is gevonden",
|
||||||
"LabelUpdateDetails": "Details bijwerken",
|
"LabelUpdateDetails": "Details bijwerken",
|
||||||
"LabelUpdateDetailsHelp": "Sta overschrijven van bestaande details toe voor de geselecteerde boeken wanneer een match is gevonden",
|
"LabelUpdateDetailsHelp": "Sta overschrijven van bestaande details toe voor de geselecteerde boeken wanneer een match is gevonden",
|
||||||
"LabelUpdatedAt": "Bijgewerkt op",
|
"LabelUpdatedAt": "Bijgewerkt op",
|
||||||
|
@ -701,13 +707,15 @@
|
||||||
"LabelYourProgress": "Je voortgang",
|
"LabelYourProgress": "Je voortgang",
|
||||||
"MessageAddToPlayerQueue": "Toevoegen aan wachtrij",
|
"MessageAddToPlayerQueue": "Toevoegen aan wachtrij",
|
||||||
"MessageAppriseDescription": "Om deze functie te gebruiken heb je een draaiende instantie van <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> nodig of een api die dezelfde requests afhandelt. <br />De Apprise API Url moet het volledige URL-pad zijn om de notificatie te verzenden, b.v., als je API-instantie draait op <code>http://192.168.1.1:8337</code> dan zou je <code>http://192.168.1.1:8337/notify</code> gebruiken.",
|
"MessageAppriseDescription": "Om deze functie te gebruiken heb je een draaiende instantie van <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> nodig of een api die dezelfde requests afhandelt. <br />De Apprise API Url moet het volledige URL-pad zijn om de notificatie te verzenden, b.v., als je API-instantie draait op <code>http://192.168.1.1:8337</code> dan zou je <code>http://192.168.1.1:8337/notify</code> gebruiken.",
|
||||||
|
"MessageAsinCheck": "Zorg ervoor dat u de ASIN van de juiste Audible-regio gebruikt, niet die van Amazon.",
|
||||||
|
"MessageAuthenticationOIDCChangesRestart": "Start uw server opnieuw op nadat u het opslaan hebt uitgevoerd, om de OIDC-wijzigingen toe te passen.",
|
||||||
"MessageBackupsDescription": "Back-ups omvatten gebruikers, gebruikers' voortgang, bibliotheekonderdeeldetails, serverinstellingen en afbeeldingen bewaard in <code>/metadata/items</code> & <code>/metadata/authors</code>. Back-ups <strong>bevatten niet</strong> de bestanden bewaard in je bibliotheekmappen.",
|
"MessageBackupsDescription": "Back-ups omvatten gebruikers, gebruikers' voortgang, bibliotheekonderdeeldetails, serverinstellingen en afbeeldingen bewaard in <code>/metadata/items</code> & <code>/metadata/authors</code>. Back-ups <strong>bevatten niet</strong> de bestanden bewaard in je bibliotheekmappen.",
|
||||||
"MessageBackupsLocationEditNote": "Let op: het bijwerken van de back-uplocatie zal bestaande back-ups niet verplaatsen of wijzigen",
|
"MessageBackupsLocationEditNote": "Let op: het bijwerken van de back-uplocatie zal bestaande back-ups niet verplaatsen of wijzigen",
|
||||||
"MessageBackupsLocationNoEditNote": "Let op: De back-uplocatie wordt ingesteld via een omgevingsvariabele en kan hier niet worden gewijzigd.",
|
"MessageBackupsLocationNoEditNote": "Let op: De back-uplocatie wordt ingesteld via een omgevingsvariabele en kan hier niet worden gewijzigd.",
|
||||||
"MessageBackupsLocationPathEmpty": "Backup locatie pad kan niet leeg zijn",
|
"MessageBackupsLocationPathEmpty": "Backup locatie pad kan niet leeg zijn",
|
||||||
"MessageBatchEditPopulateMapDetailsAllHelp": "Vul actieve velden in met data van alle items. Velden met meerdere waarden zullen worden samengevoegd",
|
"MessageBatchEditPopulateMapDetailsAllHelp": "Vul actieve velden in met data van alle items. Velden met meerdere waarden zullen worden samengevoegd",
|
||||||
"MessageBatchEditPopulateMapDetailsItemHelp": "Vul actieve folder detail velden met de data van dit item",
|
"MessageBatchEditPopulateMapDetailsItemHelp": "Vul actieve folder detail velden met de data van dit item",
|
||||||
"MessageBatchQuickMatchDescription": "Quick Match zal proberen ontbrekende covers en metadata voor de geselecteerde onderdelen te matchten. Schakel de opties hieronder in om Quick Match toe te staan bestaande covers en/of metadata te overschrijven.",
|
"MessageBatchQuickMatchDescription": "Quick Match probeert ontbrekende omslagen en metadata toe te voegen aan de geselecteerde items. Schakel de opties hieronder in om Quick Match bestaande omslagen en/of metadata te laten overschrijven.",
|
||||||
"MessageBookshelfNoCollections": "Je hebt nog geen collecties gemaakt",
|
"MessageBookshelfNoCollections": "Je hebt nog geen collecties gemaakt",
|
||||||
"MessageBookshelfNoCollectionsHelp": "Collecties zijn publiekelijk. Alle gebruikers met toegang tot de bibliotheek kunnen ze zien.",
|
"MessageBookshelfNoCollectionsHelp": "Collecties zijn publiekelijk. Alle gebruikers met toegang tot de bibliotheek kunnen ze zien.",
|
||||||
"MessageBookshelfNoRSSFeeds": "Geen RSS-feeds geopend",
|
"MessageBookshelfNoRSSFeeds": "Geen RSS-feeds geopend",
|
||||||
|
@ -719,6 +727,7 @@
|
||||||
"MessageChapterErrorStartGteDuration": "Ongeldig: starttijd moet kleiner zijn dan duur van audioboek",
|
"MessageChapterErrorStartGteDuration": "Ongeldig: starttijd moet kleiner zijn dan duur van audioboek",
|
||||||
"MessageChapterErrorStartLtPrev": "Ongeldig: starttijd moet be groter zijn dan of equal aan starttijd van vorig hoofdstuk",
|
"MessageChapterErrorStartLtPrev": "Ongeldig: starttijd moet be groter zijn dan of equal aan starttijd van vorig hoofdstuk",
|
||||||
"MessageChapterStartIsAfter": "Start van hoofdstuk is na het einde van je audioboek",
|
"MessageChapterStartIsAfter": "Start van hoofdstuk is na het einde van je audioboek",
|
||||||
|
"MessageChaptersNotFound": "Hoofdstukken niet gevonden",
|
||||||
"MessageCheckingCron": "Cron aan het checken...",
|
"MessageCheckingCron": "Cron aan het checken...",
|
||||||
"MessageConfirmCloseFeed": "Ben je zeker dat je deze feed wil sluiten?",
|
"MessageConfirmCloseFeed": "Ben je zeker dat je deze feed wil sluiten?",
|
||||||
"MessageConfirmDeleteBackup": "Weet je zeker dat je de backup voor {0} wil verwijderen?",
|
"MessageConfirmDeleteBackup": "Weet je zeker dat je de backup voor {0} wil verwijderen?",
|
||||||
|
@ -748,6 +757,7 @@
|
||||||
"MessageConfirmRemoveAuthor": "Weet je zeker dat je auteur \"{0}\" wil verwijderen?",
|
"MessageConfirmRemoveAuthor": "Weet je zeker dat je auteur \"{0}\" wil verwijderen?",
|
||||||
"MessageConfirmRemoveCollection": "Weet je zeker dat je de collectie \"{0}\" wil verwijderen?",
|
"MessageConfirmRemoveCollection": "Weet je zeker dat je de collectie \"{0}\" wil verwijderen?",
|
||||||
"MessageConfirmRemoveEpisode": "Weet je zeker dat je de aflevering \"{0}\" wil verwijderen?",
|
"MessageConfirmRemoveEpisode": "Weet je zeker dat je de aflevering \"{0}\" wil verwijderen?",
|
||||||
|
"MessageConfirmRemoveEpisodeNote": "Let op: Het audiobestand wordt niet verwijderd, tenzij je ‘Bestand permanent verwijderen’ inschakelt",
|
||||||
"MessageConfirmRemoveEpisodes": "Weet je zeker dat je {0} afleveringen wil verwijderen?",
|
"MessageConfirmRemoveEpisodes": "Weet je zeker dat je {0} afleveringen wil verwijderen?",
|
||||||
"MessageConfirmRemoveListeningSessions": "Weet je zeker dat je {0} luistersessies wilt verwijderen?",
|
"MessageConfirmRemoveListeningSessions": "Weet je zeker dat je {0} luistersessies wilt verwijderen?",
|
||||||
"MessageConfirmRemoveMetadataFiles": "Bent u zeker dat u alle metadata wil verwijderen. {0} bestanden in uw bibliotheel item folders?",
|
"MessageConfirmRemoveMetadataFiles": "Bent u zeker dat u alle metadata wil verwijderen. {0} bestanden in uw bibliotheel item folders?",
|
||||||
|
@ -775,8 +785,9 @@
|
||||||
"MessageForceReScanDescription": "zal alle bestanden opnieuw scannen als een verse scan. Audiobestanden ID3-tags, OPF-bestanden en textbestanden zullen als nieuw worden gescand.",
|
"MessageForceReScanDescription": "zal alle bestanden opnieuw scannen als een verse scan. Audiobestanden ID3-tags, OPF-bestanden en textbestanden zullen als nieuw worden gescand.",
|
||||||
"MessageImportantNotice": "Belangrijke opmerking!",
|
"MessageImportantNotice": "Belangrijke opmerking!",
|
||||||
"MessageInsertChapterBelow": "Hoofdstuk hieronder invoegen",
|
"MessageInsertChapterBelow": "Hoofdstuk hieronder invoegen",
|
||||||
"MessageItemsSelected": "{0} onderdelen geselecteerd",
|
"MessageInvalidAsin": "Ongeldige ASIN",
|
||||||
"MessageItemsUpdated": "{0} onderdelen bijgewerkt",
|
"MessageItemsSelected": "{0} items geselecteerd",
|
||||||
|
"MessageItemsUpdated": "{0} items bijgewerkt",
|
||||||
"MessageJoinUsOn": "Doe mee op",
|
"MessageJoinUsOn": "Doe mee op",
|
||||||
"MessageLoading": "Aan het laden...",
|
"MessageLoading": "Aan het laden...",
|
||||||
"MessageLoadingFolders": "Mappen aan het laden...",
|
"MessageLoadingFolders": "Mappen aan het laden...",
|
||||||
|
@ -788,14 +799,14 @@
|
||||||
"MessageMarkAllEpisodesNotFinished": "Markeer alle afleveringen als niet voltooid",
|
"MessageMarkAllEpisodesNotFinished": "Markeer alle afleveringen als niet voltooid",
|
||||||
"MessageMarkAsFinished": "Markeer als Voltooid",
|
"MessageMarkAsFinished": "Markeer als Voltooid",
|
||||||
"MessageMarkAsNotFinished": "Markeer als Niet Voltooid",
|
"MessageMarkAsNotFinished": "Markeer als Niet Voltooid",
|
||||||
"MessageMatchBooksDescription": "zal proberen boeken in de bibliotheek te matchen met een boek uit de geselecteerde bron en lege details en coverafbeelding te vullen. Overschrijft details niet.",
|
"MessageMatchBooksDescription": "zal proberen boeken in de bibliotheek te koppelen aan een boek uit de geselecteerde bron en ontbrekende gegevens en een omslag toe te voegen. Overschrijft geen bestaande gegevens.",
|
||||||
"MessageNoAudioTracks": "Geen audiotracks",
|
"MessageNoAudioTracks": "Geen audiotracks",
|
||||||
"MessageNoAuthors": "Geen auteurs",
|
"MessageNoAuthors": "Geen auteurs",
|
||||||
"MessageNoBackups": "Geen back-ups",
|
"MessageNoBackups": "Geen back-ups",
|
||||||
"MessageNoBookmarks": "Geen boekwijzers",
|
"MessageNoBookmarks": "Geen boekwijzers",
|
||||||
"MessageNoChapters": "Geen hoofdstukken",
|
"MessageNoChapters": "Geen hoofdstukken",
|
||||||
"MessageNoCollections": "Geen collecties",
|
"MessageNoCollections": "Geen collecties",
|
||||||
"MessageNoCoversFound": "Geen covers gevonden",
|
"MessageNoCoversFound": "Geen omslagen gevonden",
|
||||||
"MessageNoDescription": "Geen beschrijving",
|
"MessageNoDescription": "Geen beschrijving",
|
||||||
"MessageNoDevices": "Geen Apparaten",
|
"MessageNoDevices": "Geen Apparaten",
|
||||||
"MessageNoDownloadsInProgress": "Geen downloads bezig op dit moment",
|
"MessageNoDownloadsInProgress": "Geen downloads bezig op dit moment",
|
||||||
|
@ -808,7 +819,7 @@
|
||||||
"MessageNoItems": "Geen onderdelen",
|
"MessageNoItems": "Geen onderdelen",
|
||||||
"MessageNoItemsFound": "Geen onderdelen gevonden",
|
"MessageNoItemsFound": "Geen onderdelen gevonden",
|
||||||
"MessageNoListeningSessions": "Geen luistersessies",
|
"MessageNoListeningSessions": "Geen luistersessies",
|
||||||
"MessageNoLogs": "Geen logs",
|
"MessageNoLogs": "Geen logbestanden",
|
||||||
"MessageNoMediaProgress": "Geen mediavoortgang",
|
"MessageNoMediaProgress": "Geen mediavoortgang",
|
||||||
"MessageNoNotifications": "Geen notificaties",
|
"MessageNoNotifications": "Geen notificaties",
|
||||||
"MessageNoPodcastFeed": "Ongeldige podcast: Geen Feed",
|
"MessageNoPodcastFeed": "Ongeldige podcast: Geen Feed",
|
||||||
|
@ -833,7 +844,7 @@
|
||||||
"MessageQuickEmbedInProgress": "Snelle inbedding in uitvoering",
|
"MessageQuickEmbedInProgress": "Snelle inbedding in uitvoering",
|
||||||
"MessageQuickEmbedQueue": "In de wachtrij voor snelle insluiting ({0} in wachtrij)",
|
"MessageQuickEmbedQueue": "In de wachtrij voor snelle insluiting ({0} in wachtrij)",
|
||||||
"MessageQuickMatchAllEpisodes": "Alle Afleveringen Snel Matchen",
|
"MessageQuickMatchAllEpisodes": "Alle Afleveringen Snel Matchen",
|
||||||
"MessageQuickMatchDescription": "Vul lege onderdeeldetails & cover met eerste matchresultaat van '{0}'. Overschrijft geen details tenzij 'Prefereer gematchte metadata' serverinstelling is ingeschakeld.",
|
"MessageQuickMatchDescription": "Vult ontbrekende gegevens & omslag met eerste matchresultaat van '{0}'. Overschrijft gegevens alleen als de serverinstelling ‘Geef voorkeur aan gematchte metadata’ is ingeschakeld.",
|
||||||
"MessageRemoveChapter": "Verwijder hoofdstuk",
|
"MessageRemoveChapter": "Verwijder hoofdstuk",
|
||||||
"MessageRemoveEpisodes": "Verwijder {0} aflevering(en)",
|
"MessageRemoveEpisodes": "Verwijder {0} aflevering(en)",
|
||||||
"MessageRemoveFromPlayerQueue": "Verwijder uit afspeelwachtrij",
|
"MessageRemoveFromPlayerQueue": "Verwijder uit afspeelwachtrij",
|
||||||
|
@ -841,10 +852,12 @@
|
||||||
"MessageReportBugsAndContribute": "Rapporteer bugs, vraag functionaliteiten aan en draag bij op",
|
"MessageReportBugsAndContribute": "Rapporteer bugs, vraag functionaliteiten aan en draag bij op",
|
||||||
"MessageResetChaptersConfirm": "Weet je zeker dat je de hoofdstukken wil resetten en de wijzigingen die je gemaakt hebt ongedaan wil maken?",
|
"MessageResetChaptersConfirm": "Weet je zeker dat je de hoofdstukken wil resetten en de wijzigingen die je gemaakt hebt ongedaan wil maken?",
|
||||||
"MessageRestoreBackupConfirm": "Weet je zeker dat je wil herstellen met behulp van de back-up gemaakt op",
|
"MessageRestoreBackupConfirm": "Weet je zeker dat je wil herstellen met behulp van de back-up gemaakt op",
|
||||||
"MessageRestoreBackupWarning": "Herstellen met een back-up zal de volledige database in /config en de covers in /metadata/items & /metadata/authors overschrijven.<br /><br />Back-ups wijzigen geen bestanden in je bibliotheekmappen. Als je de serverinstelling gebruikt om covers en metadata in je bibliotheekmappen te bewaren dan worden deze niet geback-upt of overschreven.<br /><br />Alle clients die van je server gebruik maken zullen automatisch worden ververst.",
|
"MessageRestoreBackupWarning": "Een back-up herstellen zal de volledige database in /config en de omslagen in /metadata/items & /metadata/authors overschrijven.<br /><br />Back-ups wijzigen geen bestanden in je bibliotheekmappen. Als je de serverinstelling gebruikt om omslagen en metadata in je bibliotheekmappen te bewaren dan worden deze niet geback-upt of overschreven.<br /><br />Alle apparaten die je server gebruiken, worden automatisch ververst.",
|
||||||
"MessageScheduleLibraryScanNote": "Voor de meeste gebruikers is het raadzaam om deze functie uitgeschakeld te laten en de folder watcher-instelling ingeschakeld te houden. De folder watcher detecteert automatisch wijzigingen in uw bibliotheekmappen. De folder watcher werkt niet voor elk bestandssysteem (zoals NFS), dus geplande bibliotheekscans kunnen in plaats daarvan worden gebruikt.",
|
"MessageScheduleLibraryScanNote": "Voor de meeste gebruikers is het raadzaam om deze functie uitgeschakeld te laten en de folder watcher-instelling ingeschakeld te houden. De folder watcher detecteert automatisch wijzigingen in uw bibliotheekmappen. De folder watcher werkt niet voor elk bestandssysteem (zoals NFS), dus geplande bibliotheekscans kunnen in plaats daarvan worden gebruikt.",
|
||||||
|
"MessageScheduleRunEveryWeekdayAtTime": "Elke {0} uitvoeren op {1}",
|
||||||
"MessageSearchResultsFor": "Zoekresultaten voor",
|
"MessageSearchResultsFor": "Zoekresultaten voor",
|
||||||
"MessageSelected": "{0} geselecteerd",
|
"MessageSelected": "{0} geselecteerd",
|
||||||
|
"MessageSeriesSequenceCannotContainSpaces": "Serievolgorde mag geen spaties bevatten",
|
||||||
"MessageServerCouldNotBeReached": "Server niet bereikbaar",
|
"MessageServerCouldNotBeReached": "Server niet bereikbaar",
|
||||||
"MessageSetChaptersFromTracksDescription": "Stel hoofdstukken in met ieder audiobestand als een hoofdstuk en de audiobestandsnaam als hoofdstuktitel",
|
"MessageSetChaptersFromTracksDescription": "Stel hoofdstukken in met ieder audiobestand als een hoofdstuk en de audiobestandsnaam als hoofdstuktitel",
|
||||||
"MessageShareExpirationWillBe": "Vervaldatum is <strong>{0}</strong>",
|
"MessageShareExpirationWillBe": "Vervaldatum is <strong>{0}</strong>",
|
||||||
|
@ -906,6 +919,8 @@
|
||||||
"NotificationOnBackupCompletedDescription": "Wordt geactiveerd wanneer een back-up is voltooid",
|
"NotificationOnBackupCompletedDescription": "Wordt geactiveerd wanneer een back-up is voltooid",
|
||||||
"NotificationOnBackupFailedDescription": "Wordt geactiveerd wanneer een back-up mislukt",
|
"NotificationOnBackupFailedDescription": "Wordt geactiveerd wanneer een back-up mislukt",
|
||||||
"NotificationOnEpisodeDownloadedDescription": "Wordt geactiveerd wanneer een podcastaflevering automatisch wordt gedownload",
|
"NotificationOnEpisodeDownloadedDescription": "Wordt geactiveerd wanneer een podcastaflevering automatisch wordt gedownload",
|
||||||
|
"NotificationOnRSSFeedDisabledDescription": "Wordt geactiveerd wanneer automatische afleveringsdownloads zijn uitgeschakeld vanwege te veel mislukte pogingen",
|
||||||
|
"NotificationOnRSSFeedFailedDescription": "Getriggerd wanneer de RSS feed aanvraag faalt voor een automatische aflevering download",
|
||||||
"NotificationOnTestDescription": "Event voor het testen van het notificatiesysteem",
|
"NotificationOnTestDescription": "Event voor het testen van het notificatiesysteem",
|
||||||
"PlaceholderNewCollection": "Nieuwe naam collectie",
|
"PlaceholderNewCollection": "Nieuwe naam collectie",
|
||||||
"PlaceholderNewFolderPath": "Nieuwe locatie map",
|
"PlaceholderNewFolderPath": "Nieuwe locatie map",
|
||||||
|
@ -950,6 +965,7 @@
|
||||||
"ToastBackupRestoreFailed": "Herstellen back-up mislukt",
|
"ToastBackupRestoreFailed": "Herstellen back-up mislukt",
|
||||||
"ToastBackupUploadFailed": "Uploaden back-up mislukt",
|
"ToastBackupUploadFailed": "Uploaden back-up mislukt",
|
||||||
"ToastBackupUploadSuccess": "Back-up geüpload",
|
"ToastBackupUploadSuccess": "Back-up geüpload",
|
||||||
|
"ToastBatchApplyDetailsToItemsSuccess": "Details toegepast op items",
|
||||||
"ToastBatchDeleteFailed": "Batch verwijderen mislukt",
|
"ToastBatchDeleteFailed": "Batch verwijderen mislukt",
|
||||||
"ToastBatchDeleteSuccess": "Batch verwijderen gelukt",
|
"ToastBatchDeleteSuccess": "Batch verwijderen gelukt",
|
||||||
"ToastBatchQuickMatchFailed": "Batch Snel Vergelijken mislukt!",
|
"ToastBatchQuickMatchFailed": "Batch Snel Vergelijken mislukt!",
|
||||||
|
@ -962,13 +978,15 @@
|
||||||
"ToastCachePurgeFailed": "Cache wissen is mislukt",
|
"ToastCachePurgeFailed": "Cache wissen is mislukt",
|
||||||
"ToastCachePurgeSuccess": "Cache succesvol verwijderd",
|
"ToastCachePurgeSuccess": "Cache succesvol verwijderd",
|
||||||
"ToastChaptersHaveErrors": "Hoofdstukken bevatten fouten",
|
"ToastChaptersHaveErrors": "Hoofdstukken bevatten fouten",
|
||||||
|
"ToastChaptersInvalidShiftAmountLast": "Ongeldige shift-tijd. De starttijd van het laatste hoofdstuk zou langer zijn dan de duur van dit audioboek.",
|
||||||
|
"ToastChaptersInvalidShiftAmountStart": "Ongeldige shift-lengte. Het eerste hoofdstuk zou nul of een negatieve lengte hebben en zou worden overschreven door het tweede hoofdstuk. Verleng de startduur van het tweede hoofdstuk.",
|
||||||
"ToastChaptersMustHaveTitles": "Hoofdstukken moeten titels hebben",
|
"ToastChaptersMustHaveTitles": "Hoofdstukken moeten titels hebben",
|
||||||
"ToastChaptersRemoved": "Hoofdstukken verwijderd",
|
"ToastChaptersRemoved": "Hoofdstukken verwijderd",
|
||||||
"ToastChaptersUpdated": "Hoofdstukken bijgewerkt",
|
"ToastChaptersUpdated": "Hoofdstukken bijgewerkt",
|
||||||
"ToastCollectionItemsAddFailed": "Item(s) toegevoegd aan collectie mislukt",
|
"ToastCollectionItemsAddFailed": "Item(s) toegevoegd aan collectie mislukt",
|
||||||
"ToastCollectionRemoveSuccess": "Collectie verwijderd",
|
"ToastCollectionRemoveSuccess": "Collectie verwijderd",
|
||||||
"ToastCollectionUpdateSuccess": "Collectie bijgewerkt",
|
"ToastCollectionUpdateSuccess": "Collectie bijgewerkt",
|
||||||
"ToastCoverUpdateFailed": "Cover update mislukt",
|
"ToastCoverUpdateFailed": "Omslag bijwerken mislukt",
|
||||||
"ToastDateTimeInvalidOrIncomplete": "Datum en tijd ongeldig of onvolledig",
|
"ToastDateTimeInvalidOrIncomplete": "Datum en tijd ongeldig of onvolledig",
|
||||||
"ToastDeleteFileFailed": "Bestand verwijderen mislukt",
|
"ToastDeleteFileFailed": "Bestand verwijderen mislukt",
|
||||||
"ToastDeleteFileSuccess": "Bestand verwijderd",
|
"ToastDeleteFileSuccess": "Bestand verwijderd",
|
||||||
|
@ -990,7 +1008,7 @@
|
||||||
"ToastInvalidImageUrl": "Ongeldige afbeeldings-URL",
|
"ToastInvalidImageUrl": "Ongeldige afbeeldings-URL",
|
||||||
"ToastInvalidMaxEpisodesToDownload": "Ongeldig maximum aantal afleveringen om te downloaden",
|
"ToastInvalidMaxEpisodesToDownload": "Ongeldig maximum aantal afleveringen om te downloaden",
|
||||||
"ToastInvalidUrl": "Ongeldige URL",
|
"ToastInvalidUrl": "Ongeldige URL",
|
||||||
"ToastItemCoverUpdateSuccess": "Cover onderdeel bijgewerkt",
|
"ToastItemCoverUpdateSuccess": "Omslag bijgewerkt",
|
||||||
"ToastItemDeletedFailed": "Item verwijderen mislukt",
|
"ToastItemDeletedFailed": "Item verwijderen mislukt",
|
||||||
"ToastItemDeletedSuccess": "Verwijderd item",
|
"ToastItemDeletedSuccess": "Verwijderd item",
|
||||||
"ToastItemDetailsUpdateSuccess": "Details onderdeel bijgewerkt",
|
"ToastItemDetailsUpdateSuccess": "Details onderdeel bijgewerkt",
|
||||||
|
@ -1062,6 +1080,7 @@
|
||||||
"ToastSelectAtLeastOneUser": "Selecteer ten minste een gebruiker",
|
"ToastSelectAtLeastOneUser": "Selecteer ten minste een gebruiker",
|
||||||
"ToastSendEbookToDeviceFailed": "Ebook naar apparaat sturen mislukt",
|
"ToastSendEbookToDeviceFailed": "Ebook naar apparaat sturen mislukt",
|
||||||
"ToastSendEbookToDeviceSuccess": "Ebook verstuurd naar apparaat \"{0}\"",
|
"ToastSendEbookToDeviceSuccess": "Ebook verstuurd naar apparaat \"{0}\"",
|
||||||
|
"ToastSeriesSubmitFailedSameName": "Kan niet twee series met dezelfde naam toevoegen",
|
||||||
"ToastSeriesUpdateFailed": "Bijwerken serie mislukt",
|
"ToastSeriesUpdateFailed": "Bijwerken serie mislukt",
|
||||||
"ToastSeriesUpdateSuccess": "Bijwerken serie gelukt",
|
"ToastSeriesUpdateSuccess": "Bijwerken serie gelukt",
|
||||||
"ToastServerSettingsUpdateSuccess": "Server instellingen bijgewerkt",
|
"ToastServerSettingsUpdateSuccess": "Server instellingen bijgewerkt",
|
||||||
|
@ -1080,6 +1099,8 @@
|
||||||
"ToastUnknownError": "Onbekende fout",
|
"ToastUnknownError": "Onbekende fout",
|
||||||
"ToastUnlinkOpenIdFailed": "Gebruiker ontkoppelen van OpenID mislukt",
|
"ToastUnlinkOpenIdFailed": "Gebruiker ontkoppelen van OpenID mislukt",
|
||||||
"ToastUnlinkOpenIdSuccess": "Gebruiker ontkoppeld van OpenID",
|
"ToastUnlinkOpenIdSuccess": "Gebruiker ontkoppeld van OpenID",
|
||||||
|
"ToastUploaderFilepathExistsError": "Bestandspad \"{0}\" bestaat al op de server",
|
||||||
|
"ToastUploaderItemExistsInSubdirectoryError": "Item \"{0}\" gebruikt een submap van het uploadpad.",
|
||||||
"ToastUserDeleteFailed": "Verwijderen gebruiker mislukt",
|
"ToastUserDeleteFailed": "Verwijderen gebruiker mislukt",
|
||||||
"ToastUserDeleteSuccess": "Gebruiker verwijderd",
|
"ToastUserDeleteSuccess": "Gebruiker verwijderd",
|
||||||
"ToastUserPasswordChangeSuccess": "Wachtwoord succesvol gewijzigd",
|
"ToastUserPasswordChangeSuccess": "Wachtwoord succesvol gewijzigd",
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
"ButtonAddYourFirstLibrary": "Legg til ditt første bibliotek",
|
"ButtonAddYourFirstLibrary": "Legg til ditt første bibliotek",
|
||||||
"ButtonApply": "Bruk",
|
"ButtonApply": "Bruk",
|
||||||
"ButtonApplyChapters": "Bruk kapittel",
|
"ButtonApplyChapters": "Bruk kapittel",
|
||||||
"ButtonAuthors": "Forfatter",
|
"ButtonAuthors": "Forfattere",
|
||||||
"ButtonBack": "Tilbake",
|
"ButtonBack": "Tilbake",
|
||||||
"ButtonBrowseForFolder": "Bla gjennom mappe",
|
"ButtonBrowseForFolder": "Bla gjennom mappe",
|
||||||
"ButtonCancel": "Avbryt",
|
"ButtonCancel": "Avbryt",
|
||||||
|
@ -175,6 +175,7 @@
|
||||||
"HeaderPlaylist": "Spilleliste",
|
"HeaderPlaylist": "Spilleliste",
|
||||||
"HeaderPlaylistItems": "Spillelisteelement",
|
"HeaderPlaylistItems": "Spillelisteelement",
|
||||||
"HeaderPodcastsToAdd": "Podcaster å legge til",
|
"HeaderPodcastsToAdd": "Podcaster å legge til",
|
||||||
|
"HeaderPresets": "Forhåndsinnstillinger",
|
||||||
"HeaderPreviewCover": "Forhåndsvis omslag",
|
"HeaderPreviewCover": "Forhåndsvis omslag",
|
||||||
"HeaderRSSFeedGeneral": "RSS Detailer",
|
"HeaderRSSFeedGeneral": "RSS Detailer",
|
||||||
"HeaderRSSFeedIsOpen": "RSS Feed er åpen",
|
"HeaderRSSFeedIsOpen": "RSS Feed er åpen",
|
||||||
|
@ -217,6 +218,7 @@
|
||||||
"LabelAccountTypeAdmin": "Administrator",
|
"LabelAccountTypeAdmin": "Administrator",
|
||||||
"LabelAccountTypeGuest": "Gjest",
|
"LabelAccountTypeGuest": "Gjest",
|
||||||
"LabelAccountTypeUser": "Bruker",
|
"LabelAccountTypeUser": "Bruker",
|
||||||
|
"LabelActivities": "Aktiviteter",
|
||||||
"LabelActivity": "Aktivitet",
|
"LabelActivity": "Aktivitet",
|
||||||
"LabelAddToCollection": "Legg til i samling",
|
"LabelAddToCollection": "Legg til i samling",
|
||||||
"LabelAddToCollectionBatch": "Legg {0} bøker til samling",
|
"LabelAddToCollectionBatch": "Legg {0} bøker til samling",
|
||||||
|
@ -226,6 +228,7 @@
|
||||||
"LabelAddedDate": "La til {0}",
|
"LabelAddedDate": "La til {0}",
|
||||||
"LabelAdminUsersOnly": "Kun administratorer",
|
"LabelAdminUsersOnly": "Kun administratorer",
|
||||||
"LabelAll": "Alle",
|
"LabelAll": "Alle",
|
||||||
|
"LabelAllEpisodesDownloaded": "Alle nedlastede episoder",
|
||||||
"LabelAllUsers": "Alle brukere",
|
"LabelAllUsers": "Alle brukere",
|
||||||
"LabelAllUsersExcludingGuests": "Alle brukere bortsett fra gjester",
|
"LabelAllUsersExcludingGuests": "Alle brukere bortsett fra gjester",
|
||||||
"LabelAllUsersIncludingGuests": "Alle brukere inkludert gjester",
|
"LabelAllUsersIncludingGuests": "Alle brukere inkludert gjester",
|
||||||
|
@ -281,6 +284,7 @@
|
||||||
"LabelContinueSeries": "Fortsett serier",
|
"LabelContinueSeries": "Fortsett serier",
|
||||||
"LabelCover": "Omslag",
|
"LabelCover": "Omslag",
|
||||||
"LabelCoverImageURL": "Omslagsbilde URL",
|
"LabelCoverImageURL": "Omslagsbilde URL",
|
||||||
|
"LabelCoverProvider": "Tilbyder av omslagsbilde",
|
||||||
"LabelCreatedAt": "Dato opprettet",
|
"LabelCreatedAt": "Dato opprettet",
|
||||||
"LabelCronExpression": "Cron uttrykk",
|
"LabelCronExpression": "Cron uttrykk",
|
||||||
"LabelCurrent": "Nåværende",
|
"LabelCurrent": "Nåværende",
|
||||||
|
@ -389,6 +393,7 @@
|
||||||
"LabelIntervalEvery6Hours": "Hver 6. timer",
|
"LabelIntervalEvery6Hours": "Hver 6. timer",
|
||||||
"LabelIntervalEveryDay": "Hver dag",
|
"LabelIntervalEveryDay": "Hver dag",
|
||||||
"LabelIntervalEveryHour": "Hver time",
|
"LabelIntervalEveryHour": "Hver time",
|
||||||
|
"LabelIntervalEveryMinute": "Hvert minutt",
|
||||||
"LabelInvert": "Inverter",
|
"LabelInvert": "Inverter",
|
||||||
"LabelItem": "Enhet",
|
"LabelItem": "Enhet",
|
||||||
"LabelJumpBackwardAmount": "Hopp bakover med",
|
"LabelJumpBackwardAmount": "Hopp bakover med",
|
||||||
|
@ -464,6 +469,7 @@
|
||||||
"LabelNotificationsMaxQueueSizeHelp": "Hendelser er begrenset til avfyre én gang per sekund. Hendelser blir ignorert om køen er full. Dette forhindrer overflod av varslinger.",
|
"LabelNotificationsMaxQueueSizeHelp": "Hendelser er begrenset til avfyre én gang per sekund. Hendelser blir ignorert om køen er full. Dette forhindrer overflod av varslinger.",
|
||||||
"LabelNumberOfBooks": "Antall bøker",
|
"LabelNumberOfBooks": "Antall bøker",
|
||||||
"LabelNumberOfEpisodes": "Antall episoder",
|
"LabelNumberOfEpisodes": "Antall episoder",
|
||||||
|
"LabelOpenIDAdvancedPermsClaimDescription": "Navnet på OpenID claim'et som inneholder avanserte tilganger for brukerhandlinger i applikasjonen som vil brukes for ikke-administratorroller (<b>hvis konfigurert</b>). Hvis claim'et mangler fra responsen, nektes tilgang til ABS. Hvis en enkelt opsjon mangler, blir behandlet som <code>false</code>. Påse at identitetstilbyderens claim stemmer overens med den forventede strukturen:",
|
||||||
"LabelOpenIDClaims": "La følge valg være tomme for å slå av avanserte gruppe og tillatelser. Gruppen \"Bruker\" vil da også automatisk legges til.",
|
"LabelOpenIDClaims": "La følge valg være tomme for å slå av avanserte gruppe og tillatelser. Gruppen \"Bruker\" vil da også automatisk legges til.",
|
||||||
"LabelOpenRSSFeed": "Åpne RSS Feed",
|
"LabelOpenRSSFeed": "Åpne RSS Feed",
|
||||||
"LabelOverwrite": "Overskriv",
|
"LabelOverwrite": "Overskriv",
|
||||||
|
@ -521,6 +527,7 @@
|
||||||
"LabelReleaseDate": "Utgivelsesdato",
|
"LabelReleaseDate": "Utgivelsesdato",
|
||||||
"LabelRemoveAllMetadataAbs": "Fjern alle metadata.abs filer",
|
"LabelRemoveAllMetadataAbs": "Fjern alle metadata.abs filer",
|
||||||
"LabelRemoveAllMetadataJson": "Fjern alle metadata.json filer",
|
"LabelRemoveAllMetadataJson": "Fjern alle metadata.json filer",
|
||||||
|
"LabelRemoveAudibleBranding": "Fjern Audible inn- og utledning fra kapitler",
|
||||||
"LabelRemoveCover": "Fjern omslag",
|
"LabelRemoveCover": "Fjern omslag",
|
||||||
"LabelRemoveMetadataFile": "Fjern metadata-filer fra biblioteks-mapper",
|
"LabelRemoveMetadataFile": "Fjern metadata-filer fra biblioteks-mapper",
|
||||||
"LabelRemoveMetadataFileHelp": "Fjern alle metadata.json og metadata.abs i alle {0} mappene.",
|
"LabelRemoveMetadataFileHelp": "Fjern alle metadata.json og metadata.abs i alle {0} mappene.",
|
||||||
|
@ -550,6 +557,8 @@
|
||||||
"LabelSettingsBookshelfViewHelp": "Skeuomorf design med hyller av ved",
|
"LabelSettingsBookshelfViewHelp": "Skeuomorf design med hyller av ved",
|
||||||
"LabelSettingsChromecastSupport": "Chromecast støtte",
|
"LabelSettingsChromecastSupport": "Chromecast støtte",
|
||||||
"LabelSettingsDateFormat": "Dato Format",
|
"LabelSettingsDateFormat": "Dato Format",
|
||||||
|
"LabelSettingsEnableWatcher": "Skann biblioteker automatisk for endringer",
|
||||||
|
"LabelSettingsEnableWatcherForLibrary": "Skann bibliotek automatisk for endringer",
|
||||||
"LabelSettingsEnableWatcherHelp": "Aktiverer automatisk opprettelse/oppdatering av enheter når filendringer er oppdaget. *Krever restart av server*",
|
"LabelSettingsEnableWatcherHelp": "Aktiverer automatisk opprettelse/oppdatering av enheter når filendringer er oppdaget. *Krever restart av server*",
|
||||||
"LabelSettingsEpubsAllowScriptedContent": "Tillat scripting i innholdet i ebub-bøker",
|
"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.",
|
"LabelSettingsEpubsAllowScriptedContentHelp": "Tillat epub-filer å kjøre script. Det er anbefalt å slå av denne innstillingen med mindre du stoler på kilden til epub-filene.",
|
||||||
|
@ -593,6 +602,7 @@
|
||||||
"LabelSlug": "Slug",
|
"LabelSlug": "Slug",
|
||||||
"LabelSortAscending": "Stigende",
|
"LabelSortAscending": "Stigende",
|
||||||
"LabelSortDescending": "Synkende",
|
"LabelSortDescending": "Synkende",
|
||||||
|
"LabelSortPubDate": "Sorter etter publiseringsdato",
|
||||||
"LabelStart": "Start",
|
"LabelStart": "Start",
|
||||||
"LabelStartTime": "Start Tid",
|
"LabelStartTime": "Start Tid",
|
||||||
"LabelStarted": "Startet",
|
"LabelStarted": "Startet",
|
||||||
|
@ -693,6 +703,8 @@
|
||||||
"LabelYourProgress": "Din fremgang",
|
"LabelYourProgress": "Din fremgang",
|
||||||
"MessageAddToPlayerQueue": "Legg til i kø",
|
"MessageAddToPlayerQueue": "Legg til i kø",
|
||||||
"MessageAppriseDescription": "For å bruke denne funksjonen trenger du en instans av <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> kjørende eller et API som håndterer disse forespørslene. <br />Apprise API URL skal være hele URL-en til varslingen, f.eks., hvis din API-instans er på <code>http://192.168.1.1:8337</code> så skal du bruke <code>http://192.168.1.1:8337/notify</code>.",
|
"MessageAppriseDescription": "For å bruke denne funksjonen trenger du en instans av <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> kjørende eller et API som håndterer disse forespørslene. <br />Apprise API URL skal være hele URL-en til varslingen, f.eks., hvis din API-instans er på <code>http://192.168.1.1:8337</code> så skal du bruke <code>http://192.168.1.1:8337/notify</code>.",
|
||||||
|
"MessageAsinCheck": "Påse at du bruker ASIN fra den riktige Audible-regionen, ikke Amazon.",
|
||||||
|
"MessageAuthenticationOIDCChangesRestart": "Etter å ha lagret, start serveren din på nytt for at OIDC-endringene skal tre i kraft.",
|
||||||
"MessageBackupsDescription": "Sikkerhetskopier inkluderer, brukerfremgang, detaljer om bibliotekgjenstander, tjener instillinger og bilder lagret under <code>/metadata/items</code> og <code>/metadata/authors</code>. Sikkerhetskopier <strong>vil ikke</strong> inkludere filer som er lagret i bibliotek mappene.",
|
"MessageBackupsDescription": "Sikkerhetskopier inkluderer, brukerfremgang, detaljer om bibliotekgjenstander, tjener instillinger og bilder lagret under <code>/metadata/items</code> og <code>/metadata/authors</code>. Sikkerhetskopier <strong>vil ikke</strong> inkludere filer som er lagret i bibliotek mappene.",
|
||||||
"MessageBackupsLocationEditNote": "Viktig: Endring av mappen for sikkerhetskopi hverken endrer eller flytter eksisterende sikkerhetskopier!",
|
"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.",
|
"MessageBackupsLocationNoEditNote": "NB: Mappen for sikkerhetskopi settes i en miljøvariabel og kan ikke endres her.",
|
||||||
|
|
|
@ -329,7 +329,9 @@
|
||||||
"LabelEpisode": "Odcinek",
|
"LabelEpisode": "Odcinek",
|
||||||
"LabelEpisodeTitle": "Tytuł odcinka",
|
"LabelEpisodeTitle": "Tytuł odcinka",
|
||||||
"LabelEpisodeType": "Typ odcinka",
|
"LabelEpisodeType": "Typ odcinka",
|
||||||
|
"LabelEpisodeUrlFromRssFeed": "Adres URL odcinka z kanału RSS",
|
||||||
"LabelEpisodes": "Epizody",
|
"LabelEpisodes": "Epizody",
|
||||||
|
"LabelEpisodic": "Epizodyczny",
|
||||||
"LabelExample": "Przykład",
|
"LabelExample": "Przykład",
|
||||||
"LabelExpandSeries": "Rozwiń serie",
|
"LabelExpandSeries": "Rozwiń serie",
|
||||||
"LabelExpandSubSeries": "Rozwiń podserie",
|
"LabelExpandSubSeries": "Rozwiń podserie",
|
||||||
|
@ -357,6 +359,7 @@
|
||||||
"LabelFontScale": "Rozmiar czcionki",
|
"LabelFontScale": "Rozmiar czcionki",
|
||||||
"LabelFontStrikethrough": "Przekreślony",
|
"LabelFontStrikethrough": "Przekreślony",
|
||||||
"LabelFormat": "Format",
|
"LabelFormat": "Format",
|
||||||
|
"LabelFull": "Pełny",
|
||||||
"LabelGenre": "Gatunek",
|
"LabelGenre": "Gatunek",
|
||||||
"LabelGenres": "Gatunki",
|
"LabelGenres": "Gatunki",
|
||||||
"LabelHardDeleteFile": "Usuń trwale plik",
|
"LabelHardDeleteFile": "Usuń trwale plik",
|
||||||
|
@ -381,6 +384,7 @@
|
||||||
"LabelIntervalEvery6Hours": "Co 6 godzin",
|
"LabelIntervalEvery6Hours": "Co 6 godzin",
|
||||||
"LabelIntervalEveryDay": "Każdego dnia",
|
"LabelIntervalEveryDay": "Każdego dnia",
|
||||||
"LabelIntervalEveryHour": "Każdej godziny",
|
"LabelIntervalEveryHour": "Każdej godziny",
|
||||||
|
"LabelIntervalEveryMinute": "Co minutę",
|
||||||
"LabelInvert": "Inversja",
|
"LabelInvert": "Inversja",
|
||||||
"LabelItem": "Pozycja",
|
"LabelItem": "Pozycja",
|
||||||
"LabelJumpBackwardAmount": "Przeskocz do tyłu o:",
|
"LabelJumpBackwardAmount": "Przeskocz do tyłu o:",
|
||||||
|
@ -412,6 +416,9 @@
|
||||||
"LabelLowestPriority": "Najniższy priorytet",
|
"LabelLowestPriority": "Najniższy priorytet",
|
||||||
"LabelMatchExistingUsersBy": "Dopasuje istniejących użytkowników poprzez",
|
"LabelMatchExistingUsersBy": "Dopasuje istniejących użytkowników poprzez",
|
||||||
"LabelMatchExistingUsersByDescription": "Służy do łączenia istniejących użytkowników. Po połączeniu użytkownicy zostaną dopasowani za pomocą unikalnego identyfikatora od dostawcy SSO",
|
"LabelMatchExistingUsersByDescription": "Służy do łączenia istniejących użytkowników. Po połączeniu użytkownicy zostaną dopasowani za pomocą unikalnego identyfikatora od dostawcy SSO",
|
||||||
|
"LabelMaxEpisodesToDownload": "Maksymalna liczba odcinków do pobrania. Użyj 0, aby wyłączyć ograniczenie.",
|
||||||
|
"LabelMaxEpisodesToKeep": "Maksymalna liczba odcinków do zachowania",
|
||||||
|
"LabelMaxEpisodesToKeepHelp": "Wartość 0 wyłącza maksymalny limit. Po automatycznym pobraniu nowego odcinka, najstarszy odcinek zostanie usunięty, jeśli masz ich więcej niż X. Spowoduje to usunięcie tylko 1 odcinka na nowe pobieranie.",
|
||||||
"LabelMediaPlayer": "Odtwarzacz",
|
"LabelMediaPlayer": "Odtwarzacz",
|
||||||
"LabelMediaType": "Typ mediów",
|
"LabelMediaType": "Typ mediów",
|
||||||
"LabelMetaTag": "Tag",
|
"LabelMetaTag": "Tag",
|
||||||
|
@ -424,6 +431,7 @@
|
||||||
"LabelMissingEbook": "Nie posiada ebooka",
|
"LabelMissingEbook": "Nie posiada ebooka",
|
||||||
"LabelMissingSupplementaryEbook": "Nie posiada dodatkowego ebooka",
|
"LabelMissingSupplementaryEbook": "Nie posiada dodatkowego ebooka",
|
||||||
"LabelMobileRedirectURIs": "Dozwolone URI przekierowań mobilnych",
|
"LabelMobileRedirectURIs": "Dozwolone URI przekierowań mobilnych",
|
||||||
|
"LabelMobileRedirectURIsDescription": "To jest biała lista prawidłowych adresów URI przekierowań dla aplikacji mobilnych. Domyślny adres to <code>audiobookshelf://oauth</code>, który można usunąć lub dodać inne adresy URI w celu integracji z aplikacjami innych firm. Użycie gwiazdki (<code>*</code>) jako jedynego wpisu zezwala na dowolny URI.",
|
||||||
"LabelMore": "Więcej",
|
"LabelMore": "Więcej",
|
||||||
"LabelMoreInfo": "Więcej informacji",
|
"LabelMoreInfo": "Więcej informacji",
|
||||||
"LabelName": "Nazwa",
|
"LabelName": "Nazwa",
|
||||||
|
@ -453,12 +461,14 @@
|
||||||
"LabelNumberOfEpisodes": "# Odcinków",
|
"LabelNumberOfEpisodes": "# Odcinków",
|
||||||
"LabelOpenRSSFeed": "Otwórz kanał RSS",
|
"LabelOpenRSSFeed": "Otwórz kanał RSS",
|
||||||
"LabelOverwrite": "Nadpisz",
|
"LabelOverwrite": "Nadpisz",
|
||||||
|
"LabelPaginationPageXOfY": "Strona {0} z {1}",
|
||||||
"LabelPassword": "Hasło",
|
"LabelPassword": "Hasło",
|
||||||
"LabelPath": "Ścieżka",
|
"LabelPath": "Ścieżka",
|
||||||
"LabelPermanent": "Stałe",
|
"LabelPermanent": "Stałe",
|
||||||
"LabelPermissionsAccessAllLibraries": "Ma dostęp do wszystkich bibliotek",
|
"LabelPermissionsAccessAllLibraries": "Ma dostęp do wszystkich bibliotek",
|
||||||
"LabelPermissionsAccessAllTags": "Ma dostęp do wszystkich tagów",
|
"LabelPermissionsAccessAllTags": "Ma dostęp do wszystkich tagów",
|
||||||
"LabelPermissionsAccessExplicitContent": "Ma dostęp do treści oznacznych jako nieprzyzwoite",
|
"LabelPermissionsAccessExplicitContent": "Ma dostęp do treści oznacznych jako nieprzyzwoite",
|
||||||
|
"LabelPermissionsCreateEreader": "Możliwość stworzenia czytnika e-booków",
|
||||||
"LabelPermissionsDelete": "Ma możliwość usuwania",
|
"LabelPermissionsDelete": "Ma możliwość usuwania",
|
||||||
"LabelPermissionsDownload": "Ma możliwość pobierania",
|
"LabelPermissionsDownload": "Ma możliwość pobierania",
|
||||||
"LabelPermissionsUpdate": "Ma możliwość aktualizowania",
|
"LabelPermissionsUpdate": "Ma możliwość aktualizowania",
|
||||||
|
@ -466,19 +476,25 @@
|
||||||
"LabelPersonalYearReview": "Podsumowanie twojego roku ({0})",
|
"LabelPersonalYearReview": "Podsumowanie twojego roku ({0})",
|
||||||
"LabelPhotoPathURL": "Scieżka/URL do zdjęcia",
|
"LabelPhotoPathURL": "Scieżka/URL do zdjęcia",
|
||||||
"LabelPlayMethod": "Metoda odtwarzania",
|
"LabelPlayMethod": "Metoda odtwarzania",
|
||||||
|
"LabelPlayerChapterNumberMarker": "{0} z {1}",
|
||||||
"LabelPlaylists": "Listy odtwarzania",
|
"LabelPlaylists": "Listy odtwarzania",
|
||||||
|
"LabelPodcast": "Podcast",
|
||||||
"LabelPodcastSearchRegion": "Obszar wyszukiwania podcastów",
|
"LabelPodcastSearchRegion": "Obszar wyszukiwania podcastów",
|
||||||
|
"LabelPodcastType": "Typ podcastu",
|
||||||
"LabelPodcasts": "Podcasty",
|
"LabelPodcasts": "Podcasty",
|
||||||
|
"LabelPort": "Port",
|
||||||
"LabelPrefixesToIgnore": "Ignorowane prefiksy (wielkość liter nie ma znaczenia)",
|
"LabelPrefixesToIgnore": "Ignorowane prefiksy (wielkość liter nie ma znaczenia)",
|
||||||
"LabelPreventIndexing": "Zapobiega indeksowaniu przez iTunes i Google",
|
"LabelPreventIndexing": "Zapobiega indeksowaniu przez iTunes i Google",
|
||||||
"LabelPrimaryEbook": "Główny ebook",
|
"LabelPrimaryEbook": "Główny ebook",
|
||||||
"LabelProgress": "Postęp",
|
"LabelProgress": "Postęp",
|
||||||
"LabelProvider": "Dostawca",
|
"LabelProvider": "Dostawca",
|
||||||
|
"LabelProviderAuthorizationValue": "Wartość nagłówka autoryzacji",
|
||||||
"LabelPubDate": "Data publikacji",
|
"LabelPubDate": "Data publikacji",
|
||||||
"LabelPublishYear": "Rok publikacji",
|
"LabelPublishYear": "Rok publikacji",
|
||||||
|
"LabelPublishedDate": "Opublikowano {0}",
|
||||||
"LabelPublisher": "Wydawca",
|
"LabelPublisher": "Wydawca",
|
||||||
"LabelPublishers": "Wydawcy",
|
"LabelPublishers": "Wydawcy",
|
||||||
"LabelRSSFeedOpen": "RSS Feed otwarty",
|
"LabelRSSFeedOpen": "Otwarty Kanał RSS",
|
||||||
"LabelRSSFeedPreventIndexing": "Zapobiegaj indeksowaniu",
|
"LabelRSSFeedPreventIndexing": "Zapobiegaj indeksowaniu",
|
||||||
"LabelRSSFeedURL": "URL kanały RSS",
|
"LabelRSSFeedURL": "URL kanały RSS",
|
||||||
"LabelRandomly": "Losowo",
|
"LabelRandomly": "Losowo",
|
||||||
|
@ -490,15 +506,22 @@
|
||||||
"LabelRecentlyAdded": "Niedawno dodane",
|
"LabelRecentlyAdded": "Niedawno dodane",
|
||||||
"LabelRecommended": "Polecane",
|
"LabelRecommended": "Polecane",
|
||||||
"LabelRedo": "Wycofaj",
|
"LabelRedo": "Wycofaj",
|
||||||
|
"LabelRegion": "Region",
|
||||||
"LabelReleaseDate": "Data wydania",
|
"LabelReleaseDate": "Data wydania",
|
||||||
|
"LabelRemoveAllMetadataAbs": "Usuń wszystkie pliki metadata.abs",
|
||||||
|
"LabelRemoveAllMetadataJson": "Usuń wszystkie pliki metadata.json",
|
||||||
"LabelRemoveCover": "Usuń okładkę",
|
"LabelRemoveCover": "Usuń okładkę",
|
||||||
|
"LabelRemoveMetadataFile": "Usuń pliki metadanych z folderów biblioteki",
|
||||||
|
"LabelRemoveMetadataFileHelp": "Usuń wszystkie pliki metadata.json i metadata.abs z {0} folderów.",
|
||||||
"LabelRowsPerPage": "Wierszy na stronę",
|
"LabelRowsPerPage": "Wierszy na stronę",
|
||||||
"LabelSearchTerm": "Wyszukiwanie frazy",
|
"LabelSearchTerm": "Wyszukiwanie frazy",
|
||||||
"LabelSearchTitle": "Wyszukaj tytuł",
|
"LabelSearchTitle": "Wyszukaj tytuł",
|
||||||
"LabelSearchTitleOrASIN": "Szukaj tytuł lub ASIN",
|
"LabelSearchTitleOrASIN": "Szukaj tytuł lub ASIN",
|
||||||
"LabelSeason": "Sezon",
|
"LabelSeason": "Sezon",
|
||||||
|
"LabelSeasonNumber": "Sezon #{0}",
|
||||||
"LabelSelectAll": "Wybierz wszystko",
|
"LabelSelectAll": "Wybierz wszystko",
|
||||||
"LabelSelectAllEpisodes": "Wybierz wszystkie odcinki",
|
"LabelSelectAllEpisodes": "Wybierz wszystkie odcinki",
|
||||||
|
"LabelSelectEpisodesShowing": "Wybierz {0} wyświetlanych odcinków",
|
||||||
"LabelSelectUsers": "Wybór użytkowników",
|
"LabelSelectUsers": "Wybór użytkowników",
|
||||||
"LabelSendEbookToDevice": "Wyślij ebook do...",
|
"LabelSendEbookToDevice": "Wyślij ebook do...",
|
||||||
"LabelSequence": "Kolejność",
|
"LabelSequence": "Kolejność",
|
||||||
|
@ -513,6 +536,8 @@
|
||||||
"LabelSettingsBookshelfViewHelp": "Widok półki z książkami",
|
"LabelSettingsBookshelfViewHelp": "Widok półki z książkami",
|
||||||
"LabelSettingsChromecastSupport": "Wsparcie Chromecast",
|
"LabelSettingsChromecastSupport": "Wsparcie Chromecast",
|
||||||
"LabelSettingsDateFormat": "Format daty",
|
"LabelSettingsDateFormat": "Format daty",
|
||||||
|
"LabelSettingsEnableWatcher": "Automatyczne skanowanie bibliotek w poszukiwaniu zmian",
|
||||||
|
"LabelSettingsEnableWatcherForLibrary": "Automatyczne skanowanie biblioteki w poszukiwaniu zmian",
|
||||||
"LabelSettingsEnableWatcherHelp": "Włącza automatyczne dodawanie/aktualizację pozycji gdy wykryte zostaną zmiany w plikach. Wymaga restartu serwera",
|
"LabelSettingsEnableWatcherHelp": "Włącza automatyczne dodawanie/aktualizację pozycji gdy wykryte zostaną zmiany w plikach. Wymaga restartu serwera",
|
||||||
"LabelSettingsEpubsAllowScriptedContent": "Zezwalanie na skrypty w plikach epub",
|
"LabelSettingsEpubsAllowScriptedContent": "Zezwalanie na skrypty w plikach epub",
|
||||||
"LabelSettingsEpubsAllowScriptedContentHelp": "Zezwala plikom epub na wykonywanie skryptów. Zaleca się mieć to ustawienie wyłączone, chyba że ma się zaufanie do źródła plików epub.",
|
"LabelSettingsEpubsAllowScriptedContentHelp": "Zezwala plikom epub na wykonywanie skryptów. Zaleca się mieć to ustawienie wyłączone, chyba że ma się zaufanie do źródła plików epub.",
|
||||||
|
@ -524,6 +549,8 @@
|
||||||
"LabelSettingsHideSingleBookSeriesHelp": "Serie, które posiadają tylko jedną książkę, nie będą pokazywane na stronie z seriami i na stronie domowej z półkami.",
|
"LabelSettingsHideSingleBookSeriesHelp": "Serie, które posiadają tylko jedną książkę, nie będą pokazywane na stronie z seriami i na stronie domowej z półkami.",
|
||||||
"LabelSettingsHomePageBookshelfView": "Widok półki z książkami na stronie głównej",
|
"LabelSettingsHomePageBookshelfView": "Widok półki z książkami na stronie głównej",
|
||||||
"LabelSettingsLibraryBookshelfView": "Widok półki z książkami na stronie biblioteki",
|
"LabelSettingsLibraryBookshelfView": "Widok półki z książkami na stronie biblioteki",
|
||||||
|
"LabelSettingsLibraryMarkAsFinishedWhen": "Oznacz element multimedialny jako ukończony, gdy",
|
||||||
|
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Pomiń poprzednie książki przy kontynuacji serii",
|
||||||
"LabelSettingsParseSubtitles": "Przetwarzaj podtytuły",
|
"LabelSettingsParseSubtitles": "Przetwarzaj podtytuły",
|
||||||
"LabelSettingsParseSubtitlesHelp": "Opcja pozwala na pobranie podtytułu z nazwy folderu z audiobookiem. <br>Podtytuł musi być rozdzielony za pomocą separatora \" - \"<br>Przykład: \"Book Title - A Subtitle Here\" podtytuł \"A Subtitle Here\"",
|
"LabelSettingsParseSubtitlesHelp": "Opcja pozwala na pobranie podtytułu z nazwy folderu z audiobookiem. <br>Podtytuł musi być rozdzielony za pomocą separatora \" - \"<br>Przykład: \"Book Title - A Subtitle Here\" podtytuł \"A Subtitle Here\"",
|
||||||
"LabelSettingsPreferMatchedMetadata": "Preferowanie dopasowanych metadanych",
|
"LabelSettingsPreferMatchedMetadata": "Preferowanie dopasowanych metadanych",
|
||||||
|
@ -547,6 +574,9 @@
|
||||||
"LabelShowSubtitles": "Pokaż Napisy",
|
"LabelShowSubtitles": "Pokaż Napisy",
|
||||||
"LabelSize": "Rozmiar",
|
"LabelSize": "Rozmiar",
|
||||||
"LabelSleepTimer": "Wyłącznik czasowy",
|
"LabelSleepTimer": "Wyłącznik czasowy",
|
||||||
|
"LabelSortAscending": "Rosnąco",
|
||||||
|
"LabelSortDescending": "Malejąco",
|
||||||
|
"LabelSortPubDate": "Sortuj według daty publikacji",
|
||||||
"LabelStart": "Rozpocznij",
|
"LabelStart": "Rozpocznij",
|
||||||
"LabelStartTime": "Czas rozpoczęcia",
|
"LabelStartTime": "Czas rozpoczęcia",
|
||||||
"LabelStarted": "Rozpoczęty",
|
"LabelStarted": "Rozpoczęty",
|
||||||
|
@ -568,14 +598,21 @@
|
||||||
"LabelStatsWeekListening": "Tydzień słuchania",
|
"LabelStatsWeekListening": "Tydzień słuchania",
|
||||||
"LabelSubtitle": "Podtytuł",
|
"LabelSubtitle": "Podtytuł",
|
||||||
"LabelSupportedFileTypes": "Obsługiwane typy plików",
|
"LabelSupportedFileTypes": "Obsługiwane typy plików",
|
||||||
|
"LabelTag": "Znacznik",
|
||||||
"LabelTags": "Tagi",
|
"LabelTags": "Tagi",
|
||||||
"LabelTagsAccessibleToUser": "Tagi dostępne dla użytkownika",
|
"LabelTagsAccessibleToUser": "Tagi dostępne dla użytkownika",
|
||||||
|
"LabelTagsNotAccessibleToUser": "Znaczniki niedostępne dla użytkownika",
|
||||||
|
"LabelTasks": "Uruchomione zadania",
|
||||||
|
"LabelTextEditorLink": "Link",
|
||||||
|
"LabelTextEditorNumberedList": "Lista numerowana",
|
||||||
|
"LabelTextEditorUnlink": "Usuń link",
|
||||||
"LabelThemeDark": "Ciemny",
|
"LabelThemeDark": "Ciemny",
|
||||||
"LabelThemeLight": "Jasny",
|
"LabelThemeLight": "Jasny",
|
||||||
"LabelTimeDurationXHours": "{0} godzin",
|
"LabelTimeDurationXHours": "{0} godzin",
|
||||||
"LabelTimeDurationXMinutes": "{0} minuty",
|
"LabelTimeDurationXMinutes": "{0} minuty",
|
||||||
"LabelTimeDurationXSeconds": "{0} sekundy",
|
"LabelTimeDurationXSeconds": "{0} sekundy",
|
||||||
"LabelTimeInMinutes": "Czas w minutach",
|
"LabelTimeInMinutes": "Czas w minutach",
|
||||||
|
"LabelTimeLeft": "pozostało {0}",
|
||||||
"LabelTimeListened": "Czas odtwarzania",
|
"LabelTimeListened": "Czas odtwarzania",
|
||||||
"LabelTimeListenedToday": "Czas odtwarzania dzisiaj",
|
"LabelTimeListenedToday": "Czas odtwarzania dzisiaj",
|
||||||
"LabelTimeRemaining": "Pozostało {0}",
|
"LabelTimeRemaining": "Pozostało {0}",
|
||||||
|
@ -583,6 +620,7 @@
|
||||||
"LabelTitle": "Tytuł",
|
"LabelTitle": "Tytuł",
|
||||||
"LabelToolsEmbedMetadata": "Załącz metadane",
|
"LabelToolsEmbedMetadata": "Załącz metadane",
|
||||||
"LabelToolsEmbedMetadataDescription": "Załącz metadane do plików audio (okładkę oraz znaczniki rozdziałów).",
|
"LabelToolsEmbedMetadataDescription": "Załącz metadane do plików audio (okładkę oraz znaczniki rozdziałów).",
|
||||||
|
"LabelToolsM4bEncoder": "Enkoder M4B",
|
||||||
"LabelToolsMakeM4b": "Generuj plik M4B",
|
"LabelToolsMakeM4b": "Generuj plik M4B",
|
||||||
"LabelToolsMakeM4bDescription": "Tworzy plik w formacie .M4B, który zawiera metadane, okładkę oraz rozdziały.",
|
"LabelToolsMakeM4bDescription": "Tworzy plik w formacie .M4B, który zawiera metadane, okładkę oraz rozdziały.",
|
||||||
"LabelToolsSplitM4b": "Podziel plik .M4B na pliki .MP3",
|
"LabelToolsSplitM4b": "Podziel plik .M4B na pliki .MP3",
|
||||||
|
@ -595,12 +633,14 @@
|
||||||
"LabelType": "Typ",
|
"LabelType": "Typ",
|
||||||
"LabelUndo": "Wycofaj",
|
"LabelUndo": "Wycofaj",
|
||||||
"LabelUnknown": "Nieznany",
|
"LabelUnknown": "Nieznany",
|
||||||
|
"LabelUnknownPublishDate": "Nieznana data publikacji",
|
||||||
"LabelUpdateCover": "Zaktalizuj odkładkę",
|
"LabelUpdateCover": "Zaktalizuj odkładkę",
|
||||||
"LabelUpdateCoverHelp": "Umożliwienie nadpisania istniejących okładek dla wybranych książek w przypadku znalezienia dopasowania",
|
"LabelUpdateCoverHelp": "Umożliwienie nadpisania istniejących okładek dla wybranych książek w przypadku znalezienia dopasowania",
|
||||||
"LabelUpdateDetails": "Zaktualizuj szczegóły",
|
"LabelUpdateDetails": "Zaktualizuj szczegóły",
|
||||||
"LabelUpdateDetailsHelp": "Umożliwienie nadpisania istniejących szczegółów dla wybranych książek w przypadku znalezienia dopasowania",
|
"LabelUpdateDetailsHelp": "Umożliwienie nadpisania istniejących szczegółów dla wybranych książek w przypadku znalezienia dopasowania",
|
||||||
"LabelUpdatedAt": "Zaktualizowano",
|
"LabelUpdatedAt": "Zaktualizowano",
|
||||||
"LabelUploaderDragAndDrop": "Przeciągnij i puść foldery lub pliki",
|
"LabelUploaderDragAndDrop": "Przeciągnij i puść foldery lub pliki",
|
||||||
|
"LabelUploaderDragAndDropFilesOnly": "Przeciągnij i upuść pliki",
|
||||||
"LabelUploaderDropFiles": "Puść pliki",
|
"LabelUploaderDropFiles": "Puść pliki",
|
||||||
"LabelUploaderItemFetchMetadataHelp": "Automatycznie pobierz tytuł, autora i serie",
|
"LabelUploaderItemFetchMetadataHelp": "Automatycznie pobierz tytuł, autora i serie",
|
||||||
"LabelUseChapterTrack": "Użyj ścieżki rozdziału",
|
"LabelUseChapterTrack": "Użyj ścieżki rozdziału",
|
||||||
|
|
|
@ -348,7 +348,7 @@
|
||||||
"LabelExpandSubSeries": "Развернуть подсерию",
|
"LabelExpandSubSeries": "Развернуть подсерию",
|
||||||
"LabelExplicit": "18+",
|
"LabelExplicit": "18+",
|
||||||
"LabelExplicitChecked": "18+ (отмечено)",
|
"LabelExplicitChecked": "18+ (отмечено)",
|
||||||
"LabelExplicitUnchecked": "Не явно (не отмечено)",
|
"LabelExplicitUnchecked": "+18 (не отмечено)",
|
||||||
"LabelExportOPML": "Экспорт OPML",
|
"LabelExportOPML": "Экспорт OPML",
|
||||||
"LabelFeedURL": "URL канала",
|
"LabelFeedURL": "URL канала",
|
||||||
"LabelFetchingMetadata": "Извлечение метаданных",
|
"LabelFetchingMetadata": "Извлечение метаданных",
|
||||||
|
@ -514,7 +514,7 @@
|
||||||
"LabelPublishers": "Издатели",
|
"LabelPublishers": "Издатели",
|
||||||
"LabelRSSFeedCustomOwnerEmail": "Пользовательский Email владельца",
|
"LabelRSSFeedCustomOwnerEmail": "Пользовательский Email владельца",
|
||||||
"LabelRSSFeedCustomOwnerName": "Пользовательское Имя владельца",
|
"LabelRSSFeedCustomOwnerName": "Пользовательское Имя владельца",
|
||||||
"LabelRSSFeedOpen": "Открыть RSS-канал",
|
"LabelRSSFeedOpen": "Открыть RSS-ленту",
|
||||||
"LabelRSSFeedPreventIndexing": "Запретить индексирование",
|
"LabelRSSFeedPreventIndexing": "Запретить индексирование",
|
||||||
"LabelRSSFeedSlug": "Встроить RSS-канал",
|
"LabelRSSFeedSlug": "Встроить RSS-канал",
|
||||||
"LabelRSSFeedURL": "URL RSS-канала",
|
"LabelRSSFeedURL": "URL RSS-канала",
|
||||||
|
@ -918,6 +918,8 @@
|
||||||
"NotificationOnBackupCompletedDescription": "Запускается при завершении резервного копирования",
|
"NotificationOnBackupCompletedDescription": "Запускается при завершении резервного копирования",
|
||||||
"NotificationOnBackupFailedDescription": "Срабатывает при сбое резервного копирования",
|
"NotificationOnBackupFailedDescription": "Срабатывает при сбое резервного копирования",
|
||||||
"NotificationOnEpisodeDownloadedDescription": "Запускается при автоматической загрузке эпизода подкаста",
|
"NotificationOnEpisodeDownloadedDescription": "Запускается при автоматической загрузке эпизода подкаста",
|
||||||
|
"NotificationOnRSSFeedDisabledDescription": "Срабатывает, когда автоматическая загрузка эпизодов отключена из-за слишком большого количества неудачных попыток",
|
||||||
|
"NotificationOnRSSFeedFailedDescription": "Срабатывает при сбое запроса RSS-канала на автоматическую загрузку эпизода",
|
||||||
"NotificationOnTestDescription": "Событие для тестирования системы оповещения",
|
"NotificationOnTestDescription": "Событие для тестирования системы оповещения",
|
||||||
"PlaceholderNewCollection": "Новое имя коллекции",
|
"PlaceholderNewCollection": "Новое имя коллекции",
|
||||||
"PlaceholderNewFolderPath": "Путь к новой папке",
|
"PlaceholderNewFolderPath": "Путь к новой папке",
|
||||||
|
|
|
@ -346,7 +346,7 @@
|
||||||
"LabelExample": "Príklad",
|
"LabelExample": "Príklad",
|
||||||
"LabelExpandSeries": "Rozbaliť série",
|
"LabelExpandSeries": "Rozbaliť série",
|
||||||
"LabelExpandSubSeries": "Rozbaliť podsérie",
|
"LabelExpandSubSeries": "Rozbaliť podsérie",
|
||||||
"LabelExplicit": "Explicitné",
|
"LabelExplicit": "Explicitný obsah",
|
||||||
"LabelExplicitChecked": "Explicitné (zaškrtnuté)",
|
"LabelExplicitChecked": "Explicitné (zaškrtnuté)",
|
||||||
"LabelExplicitUnchecked": "Ne-explicitné (nezaškrtnuté)",
|
"LabelExplicitUnchecked": "Ne-explicitné (nezaškrtnuté)",
|
||||||
"LabelExportOPML": "Exportovať OPML",
|
"LabelExportOPML": "Exportovať OPML",
|
||||||
|
@ -757,6 +757,7 @@
|
||||||
"MessageConfirmRemoveAuthor": "Ste si istý, že chcete odstrániť autora \"{0}\"?",
|
"MessageConfirmRemoveAuthor": "Ste si istý, že chcete odstrániť autora \"{0}\"?",
|
||||||
"MessageConfirmRemoveCollection": "Ste si istý, že chcete odstrániť zbierku \"{0}\"?",
|
"MessageConfirmRemoveCollection": "Ste si istý, že chcete odstrániť zbierku \"{0}\"?",
|
||||||
"MessageConfirmRemoveEpisode": "Ste si istý, že chcete odstrániť epizódu \"{0}\"?",
|
"MessageConfirmRemoveEpisode": "Ste si istý, že chcete odstrániť epizódu \"{0}\"?",
|
||||||
|
"MessageConfirmRemoveEpisodeNote": "Poznámka: Tento krok neodstráni zvukový súbor, pokiaľ nezaškrtnete voľbu \"Nezvratné zmazanie súborov\"",
|
||||||
"MessageConfirmRemoveEpisodes": "Ste si istý, že chcete odstrániť {0} epizód?",
|
"MessageConfirmRemoveEpisodes": "Ste si istý, že chcete odstrániť {0} epizód?",
|
||||||
"MessageConfirmRemoveListeningSessions": "Ste si istý, že chcete odstrániť týchto {0} relácií?",
|
"MessageConfirmRemoveListeningSessions": "Ste si istý, že chcete odstrániť týchto {0} relácií?",
|
||||||
"MessageConfirmRemoveMetadataFiles": "Ste si istý, že chcete odstrániť všetky súbory metadata.{0} z priečinkov položiek vašej knižnice?",
|
"MessageConfirmRemoveMetadataFiles": "Ste si istý, že chcete odstrániť všetky súbory metadata.{0} z priečinkov položiek vašej knižnice?",
|
||||||
|
@ -918,6 +919,8 @@
|
||||||
"NotificationOnBackupCompletedDescription": "Spustené po dokončení zálohovania",
|
"NotificationOnBackupCompletedDescription": "Spustené po dokončení zálohovania",
|
||||||
"NotificationOnBackupFailedDescription": "Spustené pri zlyhaní zálohovania",
|
"NotificationOnBackupFailedDescription": "Spustené pri zlyhaní zálohovania",
|
||||||
"NotificationOnEpisodeDownloadedDescription": "Spustené po automatickom stiahnutí epizódy podcastu",
|
"NotificationOnEpisodeDownloadedDescription": "Spustené po automatickom stiahnutí epizódy podcastu",
|
||||||
|
"NotificationOnRSSFeedDisabledDescription": "Spustí sa, keď je automatické sťahovanie epizód pozastavené z dôvodu veľkého počtu zlyhaní",
|
||||||
|
"NotificationOnRSSFeedFailedDescription": "Spustí sa v prípade, keď zlyhá požiadavka RSS zdroja na automatické stiahnutie epizódy",
|
||||||
"NotificationOnTestDescription": "Udalosť určená na testovanie systému notifikácií",
|
"NotificationOnTestDescription": "Udalosť určená na testovanie systému notifikácií",
|
||||||
"PlaceholderNewCollection": "Názov novej zbierky",
|
"PlaceholderNewCollection": "Názov novej zbierky",
|
||||||
"PlaceholderNewFolderPath": "Umiestnenie nového priečinka",
|
"PlaceholderNewFolderPath": "Umiestnenie nového priečinka",
|
||||||
|
|
|
@ -514,7 +514,7 @@
|
||||||
"LabelPublishers": "Izdajatelji",
|
"LabelPublishers": "Izdajatelji",
|
||||||
"LabelRSSFeedCustomOwnerEmail": "E-pošta lastnika po meri",
|
"LabelRSSFeedCustomOwnerEmail": "E-pošta lastnika po meri",
|
||||||
"LabelRSSFeedCustomOwnerName": "Ime lastnika po meri",
|
"LabelRSSFeedCustomOwnerName": "Ime lastnika po meri",
|
||||||
"LabelRSSFeedOpen": "Odprt vir RSS",
|
"LabelRSSFeedOpen": "RSS vir je odprt",
|
||||||
"LabelRSSFeedPreventIndexing": "Prepreči indeksiranje",
|
"LabelRSSFeedPreventIndexing": "Prepreči indeksiranje",
|
||||||
"LabelRSSFeedSlug": "Slug RSS vira",
|
"LabelRSSFeedSlug": "Slug RSS vira",
|
||||||
"LabelRSSFeedURL": "URL vira RSS",
|
"LabelRSSFeedURL": "URL vira RSS",
|
||||||
|
@ -757,6 +757,7 @@
|
||||||
"MessageConfirmRemoveAuthor": "Ali ste prepričani, da želite odstraniti avtorja \"{0}\"?",
|
"MessageConfirmRemoveAuthor": "Ali ste prepričani, da želite odstraniti avtorja \"{0}\"?",
|
||||||
"MessageConfirmRemoveCollection": "Ali ste prepričani, da želite odstraniti zbirko \"{0}\"?",
|
"MessageConfirmRemoveCollection": "Ali ste prepričani, da želite odstraniti zbirko \"{0}\"?",
|
||||||
"MessageConfirmRemoveEpisode": "Ali ste prepričani, da želite odstraniti epizodo \"{0}\"?",
|
"MessageConfirmRemoveEpisode": "Ali ste prepričani, da želite odstraniti epizodo \"{0}\"?",
|
||||||
|
"MessageConfirmRemoveEpisodeNote": "Opomba: S tem se zvočna datoteka ne izbriše, razen če vklopite možnost \"Trdo brisanje datoteke\"",
|
||||||
"MessageConfirmRemoveEpisodes": "Ali ste prepričani, da želite odstraniti {0} epizod?",
|
"MessageConfirmRemoveEpisodes": "Ali ste prepričani, da želite odstraniti {0} epizod?",
|
||||||
"MessageConfirmRemoveListeningSessions": "Ali ste prepričani, da želite odstraniti {0} sej poslušanja?",
|
"MessageConfirmRemoveListeningSessions": "Ali ste prepričani, da želite odstraniti {0} sej poslušanja?",
|
||||||
"MessageConfirmRemoveMetadataFiles": "Ali ste prepričani, da želite odstraniti vse metapodatke.{0} v mapah elementov knjižnice?",
|
"MessageConfirmRemoveMetadataFiles": "Ali ste prepričani, da želite odstraniti vse metapodatke.{0} v mapah elementov knjižnice?",
|
||||||
|
@ -918,6 +919,8 @@
|
||||||
"NotificationOnBackupCompletedDescription": "Sproži se, ko je varnostno kopiranje končano",
|
"NotificationOnBackupCompletedDescription": "Sproži se, ko je varnostno kopiranje končano",
|
||||||
"NotificationOnBackupFailedDescription": "Sproži se, ko varnostno kopiranje ne uspe",
|
"NotificationOnBackupFailedDescription": "Sproži se, ko varnostno kopiranje ne uspe",
|
||||||
"NotificationOnEpisodeDownloadedDescription": "Sproži se, ko se epizoda podcasta samodejno prenese",
|
"NotificationOnEpisodeDownloadedDescription": "Sproži se, ko se epizoda podcasta samodejno prenese",
|
||||||
|
"NotificationOnRSSFeedDisabledDescription": "Sproži se, ko so samodejni prenosi epizod onemogočeni zaradi preveč neuspelih poskusov",
|
||||||
|
"NotificationOnRSSFeedFailedDescription": "Sproži se, ko zahteva za vir RSS za samodejni prenos epizode ne uspe",
|
||||||
"NotificationOnTestDescription": "Dogodek za testiranje sistema obveščanja",
|
"NotificationOnTestDescription": "Dogodek za testiranje sistema obveščanja",
|
||||||
"PlaceholderNewCollection": "Novo ime zbirke",
|
"PlaceholderNewCollection": "Novo ime zbirke",
|
||||||
"PlaceholderNewFolderPath": "Pot nove mape",
|
"PlaceholderNewFolderPath": "Pot nove mape",
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
"ButtonAddChapters": "Додати глави",
|
"ButtonAddChapters": "Додати глави",
|
||||||
"ButtonAddDevice": "Додати пристрій",
|
"ButtonAddDevice": "Додати пристрій",
|
||||||
"ButtonAddLibrary": "Додати бібліотеку",
|
"ButtonAddLibrary": "Додати бібліотеку",
|
||||||
"ButtonAddPodcasts": "Додати подкаст",
|
"ButtonAddPodcasts": "Додати подкасти",
|
||||||
"ButtonAddUser": "Додати користувача",
|
"ButtonAddUser": "Додати користувача",
|
||||||
"ButtonAddYourFirstLibrary": "Додайте вашу першу бібліотеку",
|
"ButtonAddYourFirstLibrary": "Додайте вашу першу бібліотеку",
|
||||||
"ButtonApply": "Застосувати",
|
"ButtonApply": "Застосувати",
|
||||||
|
@ -16,7 +16,7 @@
|
||||||
"ButtonCancel": "Скасувати",
|
"ButtonCancel": "Скасувати",
|
||||||
"ButtonCancelEncode": "Скасувати кодування",
|
"ButtonCancelEncode": "Скасувати кодування",
|
||||||
"ButtonChangeRootPassword": "Змінити кореневий пароль",
|
"ButtonChangeRootPassword": "Змінити кореневий пароль",
|
||||||
"ButtonCheckAndDownloadNewEpisodes": "Перевірити та завантажити нові епізоди",
|
"ButtonCheckAndDownloadNewEpisodes": "Перевірити та скачати нові епізоди",
|
||||||
"ButtonChooseAFolder": "Обрати теку",
|
"ButtonChooseAFolder": "Обрати теку",
|
||||||
"ButtonChooseFiles": "Обрати файли",
|
"ButtonChooseFiles": "Обрати файли",
|
||||||
"ButtonClearFilter": "Очистити фільтр",
|
"ButtonClearFilter": "Очистити фільтр",
|
||||||
|
@ -32,8 +32,8 @@
|
||||||
"ButtonEditChapters": "Редагувати глави",
|
"ButtonEditChapters": "Редагувати глави",
|
||||||
"ButtonEditPodcast": "Редагувати подкаст",
|
"ButtonEditPodcast": "Редагувати подкаст",
|
||||||
"ButtonEnable": "Увімкнути",
|
"ButtonEnable": "Увімкнути",
|
||||||
"ButtonFireAndFail": "Вогонь і невдача",
|
"ButtonFireAndFail": "Виконати і завершити з помилкою",
|
||||||
"ButtonFireOnTest": "Випробування на вогнестійкість",
|
"ButtonFireOnTest": "Виконати подію onTest",
|
||||||
"ButtonForceReScan": "Примусово сканувати",
|
"ButtonForceReScan": "Примусово сканувати",
|
||||||
"ButtonFullPath": "Повний шлях",
|
"ButtonFullPath": "Повний шлях",
|
||||||
"ButtonHide": "Приховати",
|
"ButtonHide": "Приховати",
|
||||||
|
@ -44,7 +44,7 @@
|
||||||
"ButtonLatest": "Останні",
|
"ButtonLatest": "Останні",
|
||||||
"ButtonLibrary": "Бібліотека",
|
"ButtonLibrary": "Бібліотека",
|
||||||
"ButtonLogout": "Вийти",
|
"ButtonLogout": "Вийти",
|
||||||
"ButtonLookup": "Пошук",
|
"ButtonLookup": "Пошуки",
|
||||||
"ButtonManageTracks": "Керувати доріжками",
|
"ButtonManageTracks": "Керувати доріжками",
|
||||||
"ButtonMapChapterTitles": "Призначити назви глав",
|
"ButtonMapChapterTitles": "Призначити назви глав",
|
||||||
"ButtonMatchAllAuthors": "Віднайти усіх авторів",
|
"ButtonMatchAllAuthors": "Віднайти усіх авторів",
|
||||||
|
@ -57,7 +57,7 @@
|
||||||
"ButtonOpenFeed": "Відкрити стрічку",
|
"ButtonOpenFeed": "Відкрити стрічку",
|
||||||
"ButtonOpenManager": "Відкрити менеджер",
|
"ButtonOpenManager": "Відкрити менеджер",
|
||||||
"ButtonPause": "Пауза",
|
"ButtonPause": "Пауза",
|
||||||
"ButtonPlay": "Слухати",
|
"ButtonPlay": "Відтворити",
|
||||||
"ButtonPlayAll": "Відтворити все",
|
"ButtonPlayAll": "Відтворити все",
|
||||||
"ButtonPlaying": "Відтворюється",
|
"ButtonPlaying": "Відтворюється",
|
||||||
"ButtonPlaylists": "Списки відтворення",
|
"ButtonPlaylists": "Списки відтворення",
|
||||||
|
@ -86,7 +86,7 @@
|
||||||
"ButtonResetToDefault": "Скинути до стандартних",
|
"ButtonResetToDefault": "Скинути до стандартних",
|
||||||
"ButtonRestore": "Відновити",
|
"ButtonRestore": "Відновити",
|
||||||
"ButtonSave": "Зберегти",
|
"ButtonSave": "Зберегти",
|
||||||
"ButtonSaveAndClose": "Зберегти та закрити",
|
"ButtonSaveAndClose": "Зберегти і закрити",
|
||||||
"ButtonSaveTracklist": "Зберегти порядок",
|
"ButtonSaveTracklist": "Зберегти порядок",
|
||||||
"ButtonScan": "Сканувати",
|
"ButtonScan": "Сканувати",
|
||||||
"ButtonScanLibrary": "Сканувати бібліотеку",
|
"ButtonScanLibrary": "Сканувати бібліотеку",
|
||||||
|
@ -103,7 +103,7 @@
|
||||||
"ButtonStartMetadataEmbed": "Почати вбудування метаданих",
|
"ButtonStartMetadataEmbed": "Почати вбудування метаданих",
|
||||||
"ButtonStats": "Статистика",
|
"ButtonStats": "Статистика",
|
||||||
"ButtonSubmit": "Надіслати",
|
"ButtonSubmit": "Надіслати",
|
||||||
"ButtonTest": "Перевірити",
|
"ButtonTest": "Тест",
|
||||||
"ButtonUnlinkOpenId": "Вимкнути OpenID",
|
"ButtonUnlinkOpenId": "Вимкнути OpenID",
|
||||||
"ButtonUpload": "Завантажити",
|
"ButtonUpload": "Завантажити",
|
||||||
"ButtonUploadBackup": "Завантажити резервну копію",
|
"ButtonUploadBackup": "Завантажити резервну копію",
|
||||||
|
@ -115,7 +115,7 @@
|
||||||
"ButtonYes": "Так",
|
"ButtonYes": "Так",
|
||||||
"ErrorUploadFetchMetadataAPI": "Помилка при отриманні метаданих",
|
"ErrorUploadFetchMetadataAPI": "Помилка при отриманні метаданих",
|
||||||
"ErrorUploadFetchMetadataNoResults": "Не вдалося отримати метадані — спробуйте оновити заголовок та/або автора",
|
"ErrorUploadFetchMetadataNoResults": "Не вдалося отримати метадані — спробуйте оновити заголовок та/або автора",
|
||||||
"ErrorUploadLacksTitle": "Назва обов'язкова",
|
"ErrorUploadLacksTitle": "Потрібна назва",
|
||||||
"HeaderAccount": "Профіль",
|
"HeaderAccount": "Профіль",
|
||||||
"HeaderAddCustomMetadataProvider": "Додати користувацький постачальник метаданих",
|
"HeaderAddCustomMetadataProvider": "Додати користувацький постачальник метаданих",
|
||||||
"HeaderAdvanced": "Розширені",
|
"HeaderAdvanced": "Розширені",
|
||||||
|
@ -130,11 +130,11 @@
|
||||||
"HeaderCollection": "Добірка",
|
"HeaderCollection": "Добірка",
|
||||||
"HeaderCollectionItems": "Елементи добірки",
|
"HeaderCollectionItems": "Елементи добірки",
|
||||||
"HeaderCover": "Обкладинка",
|
"HeaderCover": "Обкладинка",
|
||||||
"HeaderCurrentDownloads": "Поточні завантаження",
|
"HeaderCurrentDownloads": "Поточні скачування",
|
||||||
"HeaderCustomMessageOnLogin": "Повідомлення при вході",
|
"HeaderCustomMessageOnLogin": "Повідомлення при вході",
|
||||||
"HeaderCustomMetadataProviders": "Постачальники метаданих",
|
"HeaderCustomMetadataProviders": "Постачальники метаданих",
|
||||||
"HeaderDetails": "Подробиці",
|
"HeaderDetails": "Подробиці",
|
||||||
"HeaderDownloadQueue": "Черга завантажень",
|
"HeaderDownloadQueue": "Черга скачувань",
|
||||||
"HeaderEbookFiles": "Файли електронних книг",
|
"HeaderEbookFiles": "Файли електронних книг",
|
||||||
"HeaderEmail": "Електронна пошта",
|
"HeaderEmail": "Електронна пошта",
|
||||||
"HeaderEmailSettings": "Налаштування електронної пошти",
|
"HeaderEmailSettings": "Налаштування електронної пошти",
|
||||||
|
@ -152,13 +152,13 @@
|
||||||
"HeaderLibraryFiles": "Файли бібліотеки",
|
"HeaderLibraryFiles": "Файли бібліотеки",
|
||||||
"HeaderLibraryStats": "Статистика бібліотеки",
|
"HeaderLibraryStats": "Статистика бібліотеки",
|
||||||
"HeaderListeningSessions": "Сеанси прослуховування",
|
"HeaderListeningSessions": "Сеанси прослуховування",
|
||||||
"HeaderListeningStats": "Статистика відтворення",
|
"HeaderListeningStats": "Статистика прослуховування",
|
||||||
"HeaderLogin": "Вхід",
|
"HeaderLogin": "Вхід",
|
||||||
"HeaderLogs": "Журнал",
|
"HeaderLogs": "Журнал",
|
||||||
"HeaderManageGenres": "Керувати жанрами",
|
"HeaderManageGenres": "Керувати жанрами",
|
||||||
"HeaderManageTags": "Керувати мітками",
|
"HeaderManageTags": "Керувати мітками",
|
||||||
"HeaderMapDetails": "Призначити подробиці",
|
"HeaderMapDetails": "Призначити подробиці",
|
||||||
"HeaderMatch": "Пошук",
|
"HeaderMatch": "Допасуй",
|
||||||
"HeaderMetadataOrderOfPrecedence": "Порядок метаданих",
|
"HeaderMetadataOrderOfPrecedence": "Порядок метаданих",
|
||||||
"HeaderMetadataToEmbed": "Вбудувати метадані",
|
"HeaderMetadataToEmbed": "Вбудувати метадані",
|
||||||
"HeaderNewAccount": "Новий профіль",
|
"HeaderNewAccount": "Новий профіль",
|
||||||
|
@ -176,7 +176,7 @@
|
||||||
"HeaderPlayerSettings": "Налаштування програвача",
|
"HeaderPlayerSettings": "Налаштування програвача",
|
||||||
"HeaderPlaylist": "Список відтворення",
|
"HeaderPlaylist": "Список відтворення",
|
||||||
"HeaderPlaylistItems": "Елементи списку відтворення",
|
"HeaderPlaylistItems": "Елементи списку відтворення",
|
||||||
"HeaderPodcastsToAdd": "Додати подкасти",
|
"HeaderPodcastsToAdd": "Подкасти для додання",
|
||||||
"HeaderPresets": "Пресети",
|
"HeaderPresets": "Пресети",
|
||||||
"HeaderPreviewCover": "Попередній перегляд",
|
"HeaderPreviewCover": "Попередній перегляд",
|
||||||
"HeaderRSSFeedGeneral": "Подробиці RSS",
|
"HeaderRSSFeedGeneral": "Подробиці RSS",
|
||||||
|
@ -186,7 +186,7 @@
|
||||||
"HeaderRemoveEpisodes": "Видалити епізодів: {0}",
|
"HeaderRemoveEpisodes": "Видалити епізодів: {0}",
|
||||||
"HeaderSavedMediaProgress": "Збережений прогрес медіа",
|
"HeaderSavedMediaProgress": "Збережений прогрес медіа",
|
||||||
"HeaderSchedule": "Розклад",
|
"HeaderSchedule": "Розклад",
|
||||||
"HeaderScheduleEpisodeDownloads": "Запланувати автоматичне завантаження епізодів",
|
"HeaderScheduleEpisodeDownloads": "Запланувати автоматичне скачування епізодів",
|
||||||
"HeaderScheduleLibraryScans": "Розклад автосканування бібліотеки",
|
"HeaderScheduleLibraryScans": "Розклад автосканування бібліотеки",
|
||||||
"HeaderSession": "Сеанс",
|
"HeaderSession": "Сеанс",
|
||||||
"HeaderSetBackupSchedule": "Встановити розклад резервного копіювання",
|
"HeaderSetBackupSchedule": "Встановити розклад резервного копіювання",
|
||||||
|
@ -223,21 +223,21 @@
|
||||||
"LabelActivities": "Діяльність",
|
"LabelActivities": "Діяльність",
|
||||||
"LabelActivity": "Активність",
|
"LabelActivity": "Активність",
|
||||||
"LabelAddToCollection": "Додати у добірку",
|
"LabelAddToCollection": "Додати у добірку",
|
||||||
"LabelAddToCollectionBatch": "Додати книги до добірки: {0}",
|
"LabelAddToCollectionBatch": "Додати {0} книг до добірки",
|
||||||
"LabelAddToPlaylist": "Додати до списку відтворення",
|
"LabelAddToPlaylist": "Додати до списку відтворення",
|
||||||
"LabelAddToPlaylistBatch": "Додано елементів у список відтворення: {0}",
|
"LabelAddToPlaylistBatch": "Додати {0} елементів до списку відтворення",
|
||||||
"LabelAddedAt": "Дата додавання",
|
"LabelAddedAt": "Дата додавання",
|
||||||
"LabelAddedDate": "Додано {0}",
|
"LabelAddedDate": "Додано {0}",
|
||||||
"LabelAdminUsersOnly": "Тільки для адміністраторів",
|
"LabelAdminUsersOnly": "Тільки для адміністраторів",
|
||||||
"LabelAll": "Усе",
|
"LabelAll": "Усе",
|
||||||
"LabelAllEpisodesDownloaded": "Усі серії завантажено",
|
"LabelAllEpisodesDownloaded": "Усі епізоди скачано",
|
||||||
"LabelAllUsers": "Усі користувачі",
|
"LabelAllUsers": "Усі користувачі",
|
||||||
"LabelAllUsersExcludingGuests": "Усі, крім гостей",
|
"LabelAllUsersExcludingGuests": "Усі, крім гостей",
|
||||||
"LabelAllUsersIncludingGuests": "Усі, включно з гостями",
|
"LabelAllUsersIncludingGuests": "Усі, включно з гостями",
|
||||||
"LabelAlreadyInYourLibrary": "Вже у вашій бібліотеці",
|
"LabelAlreadyInYourLibrary": "Вже у вашій бібліотеці",
|
||||||
"LabelApiToken": "Токен API",
|
"LabelApiToken": "Токен API",
|
||||||
"LabelAppend": "Додати",
|
"LabelAppend": "Додати",
|
||||||
"LabelAudioBitrate": "Бітрейт аудіо (напр. 128k)",
|
"LabelAudioBitrate": "Бітрейт аудіо (наприклад, 128k)",
|
||||||
"LabelAudioChannels": "Канали аудіо (1 або 2)",
|
"LabelAudioChannels": "Канали аудіо (1 або 2)",
|
||||||
"LabelAudioCodec": "Аудіокодек",
|
"LabelAudioCodec": "Аудіокодек",
|
||||||
"LabelAuthor": "Автор",
|
"LabelAuthor": "Автор",
|
||||||
|
@ -256,18 +256,18 @@
|
||||||
"LabelBackupLocation": "Розташування резервних копій",
|
"LabelBackupLocation": "Розташування резервних копій",
|
||||||
"LabelBackupsEnableAutomaticBackups": "Автоматичне резервне копіювання",
|
"LabelBackupsEnableAutomaticBackups": "Автоматичне резервне копіювання",
|
||||||
"LabelBackupsEnableAutomaticBackupsHelp": "Резервні копії збережено у /metadata/backups",
|
"LabelBackupsEnableAutomaticBackupsHelp": "Резервні копії збережено у /metadata/backups",
|
||||||
"LabelBackupsMaxBackupSize": "Максимальний розмір резервної копії (у ГБ) (0 — необмежене)",
|
"LabelBackupsMaxBackupSize": "Максимальний розмір резервної копії (у ГБ) (0 — без обмежень)",
|
||||||
"LabelBackupsMaxBackupSizeHelp": "У якості захисту від неправильного налаштування, резервну копію не буде збережено, якщо її розмір перевищуватиме вказаний.",
|
"LabelBackupsMaxBackupSizeHelp": "У якості захисту від неправильного налаштування, резервну копію не буде збережено, якщо її розмір перевищуватиме вказаний.",
|
||||||
"LabelBackupsNumberToKeep": "Кількість резервних копій",
|
"LabelBackupsNumberToKeep": "Кількість резервних копій",
|
||||||
"LabelBackupsNumberToKeepHelp": "Лиш 1 резервну копію буде видалено за раз, тож якщо їх багато, то вам варто видалити їх вручну.",
|
"LabelBackupsNumberToKeepHelp": "Видаляється лише 1 резервна копія за раз, тому якщо у вас більше копій, видаліть їх вручну.",
|
||||||
"LabelBitrate": "Бітрейт",
|
"LabelBitrate": "Бітрейт",
|
||||||
"LabelBonus": "Бонус",
|
"LabelBonus": "Бонус",
|
||||||
"LabelBooks": "Книги",
|
"LabelBooks": "Книг",
|
||||||
"LabelButtonText": "Текст кнопки",
|
"LabelButtonText": "Текст кнопки",
|
||||||
"LabelByAuthor": "від {0}",
|
"LabelByAuthor": "від {0}",
|
||||||
"LabelChangePassword": "Змінити пароль",
|
"LabelChangePassword": "Змінити пароль",
|
||||||
"LabelChannels": "Канали",
|
"LabelChannels": "Канали",
|
||||||
"LabelChapterCount": "{0} Глав",
|
"LabelChapterCount": "{0} глав",
|
||||||
"LabelChapterTitle": "Назва глави",
|
"LabelChapterTitle": "Назва глави",
|
||||||
"LabelChapters": "Глави",
|
"LabelChapters": "Глави",
|
||||||
"LabelChaptersFound": "глав знайдено",
|
"LabelChaptersFound": "глав знайдено",
|
||||||
|
@ -304,9 +304,9 @@
|
||||||
"LabelDiscFromFilename": "Диск за назвою файлу",
|
"LabelDiscFromFilename": "Диск за назвою файлу",
|
||||||
"LabelDiscFromMetadata": "Диск за метаданими",
|
"LabelDiscFromMetadata": "Диск за метаданими",
|
||||||
"LabelDiscover": "Огляд",
|
"LabelDiscover": "Огляд",
|
||||||
"LabelDownload": "Завантажити",
|
"LabelDownload": "Скачати",
|
||||||
"LabelDownloadNEpisodes": "Завантажити епізодів: {0}",
|
"LabelDownloadNEpisodes": "Скачати {0} епізодів",
|
||||||
"LabelDownloadable": "Можна завантажити",
|
"LabelDownloadable": "Можна скачати",
|
||||||
"LabelDuration": "Тривалість",
|
"LabelDuration": "Тривалість",
|
||||||
"LabelDurationComparisonExactMatch": "(повний збіг)",
|
"LabelDurationComparisonExactMatch": "(повний збіг)",
|
||||||
"LabelDurationComparisonLonger": "(на {0} довше)",
|
"LabelDurationComparisonLonger": "(на {0} довше)",
|
||||||
|
@ -346,16 +346,16 @@
|
||||||
"LabelExample": "Приклад",
|
"LabelExample": "Приклад",
|
||||||
"LabelExpandSeries": "Розгорнути серії",
|
"LabelExpandSeries": "Розгорнути серії",
|
||||||
"LabelExpandSubSeries": "Розгорнути підсерії",
|
"LabelExpandSubSeries": "Розгорнути підсерії",
|
||||||
"LabelExplicit": "Відверта",
|
"LabelExplicit": "Відвертий",
|
||||||
"LabelExplicitChecked": "Відверта (з прапорцем)",
|
"LabelExplicitChecked": "Відверта (з прапорцем)",
|
||||||
"LabelExplicitUnchecked": "Не відверта (без прапорця)",
|
"LabelExplicitUnchecked": "Не відверта (без прапорця)",
|
||||||
"LabelExportOPML": "Експорт OPML",
|
"LabelExportOPML": "Експорт OPML",
|
||||||
"LabelFeedURL": "Адреса стрічки",
|
"LabelFeedURL": "Адреса стрічки",
|
||||||
"LabelFetchingMetadata": "Отримання метаданих",
|
"LabelFetchingMetadata": "Отримання метаданих",
|
||||||
"LabelFile": "Файл",
|
"LabelFile": "Файл",
|
||||||
"LabelFileBirthtime": "Дата створення",
|
"LabelFileBirthtime": "Дата створення файлу",
|
||||||
"LabelFileBornDate": "Народився {0}",
|
"LabelFileBornDate": "Народився {0}",
|
||||||
"LabelFileModified": "Дата змінення",
|
"LabelFileModified": "Дата зміни файлу",
|
||||||
"LabelFileModifiedDate": "Змінено {0}",
|
"LabelFileModifiedDate": "Змінено {0}",
|
||||||
"LabelFilename": "Ім'я файлу",
|
"LabelFilename": "Ім'я файлу",
|
||||||
"LabelFilterByUser": "Фільтрувати за користувачем",
|
"LabelFilterByUser": "Фільтрувати за користувачем",
|
||||||
|
@ -395,7 +395,7 @@
|
||||||
"LabelIntervalEvery6Hours": "Кожні 6 годин",
|
"LabelIntervalEvery6Hours": "Кожні 6 годин",
|
||||||
"LabelIntervalEveryDay": "Щодня",
|
"LabelIntervalEveryDay": "Щодня",
|
||||||
"LabelIntervalEveryHour": "Щогодини",
|
"LabelIntervalEveryHour": "Щогодини",
|
||||||
"LabelIntervalEveryMinute": "Кожну хвилину",
|
"LabelIntervalEveryMinute": "Щохвилини",
|
||||||
"LabelInvert": "Інвертувати",
|
"LabelInvert": "Інвертувати",
|
||||||
"LabelItem": "Елемент",
|
"LabelItem": "Елемент",
|
||||||
"LabelJumpBackwardAmount": "Час переходу назад",
|
"LabelJumpBackwardAmount": "Час переходу назад",
|
||||||
|
@ -427,10 +427,10 @@
|
||||||
"LabelLowestPriority": "Найнижчий пріоритет",
|
"LabelLowestPriority": "Найнижчий пріоритет",
|
||||||
"LabelMatchExistingUsersBy": "Шукати наявних користувачів за",
|
"LabelMatchExistingUsersBy": "Шукати наявних користувачів за",
|
||||||
"LabelMatchExistingUsersByDescription": "Використовується для підключення наявних користувачів. Після підключення користувач отримає унікальний id від вашого сервісу SSO",
|
"LabelMatchExistingUsersByDescription": "Використовується для підключення наявних користувачів. Після підключення користувач отримає унікальний id від вашого сервісу SSO",
|
||||||
"LabelMaxEpisodesToDownload": "Максимальна кількість епізодів для завантаження. Використовуйте 0 для необмеженої кількості.",
|
"LabelMaxEpisodesToDownload": "Максимальна кількість епізодів для скачування. Використовуйте 0 для необмеженої кількості.",
|
||||||
"LabelMaxEpisodesToDownloadPerCheck": "Максимальна кількість нових епізодів для завантаження за перевірку",
|
"LabelMaxEpisodesToDownloadPerCheck": "Максимальна кількість нових епізодів для скачування за перевірку",
|
||||||
"LabelMaxEpisodesToKeep": "Максимальна кількість епізодів для зберігання",
|
"LabelMaxEpisodesToKeep": "Максимальна кількість епізодів для зберігання",
|
||||||
"LabelMaxEpisodesToKeepHelp": "Значення 0 не встановлює обмеження. Після автоматичного завантаження нового епізоду, буде видалено найстаріший епізод, якщо у вас більше ніж X епізодів. Видаляється лише 1 епізод за одне нове завантаження.",
|
"LabelMaxEpisodesToKeepHelp": "Значення 0 — без обмежень. Після автоматичного завантаження нового епізоду буде видалено найстаріший, якщо їх більше X. Видаляється лише 1 епізод за одне нове завантаження.",
|
||||||
"LabelMediaPlayer": "Програвач медіа",
|
"LabelMediaPlayer": "Програвач медіа",
|
||||||
"LabelMediaType": "Тип медіа",
|
"LabelMediaType": "Тип медіа",
|
||||||
"LabelMetaTag": "Метатег",
|
"LabelMetaTag": "Метатег",
|
||||||
|
@ -485,7 +485,7 @@
|
||||||
"LabelPermissionsAccessExplicitContent": "Доступ до відвертого вмісту",
|
"LabelPermissionsAccessExplicitContent": "Доступ до відвертого вмісту",
|
||||||
"LabelPermissionsCreateEreader": "Можна створити читалку",
|
"LabelPermissionsCreateEreader": "Можна створити читалку",
|
||||||
"LabelPermissionsDelete": "Може видаляти",
|
"LabelPermissionsDelete": "Може видаляти",
|
||||||
"LabelPermissionsDownload": "Може завантажувати",
|
"LabelPermissionsDownload": "Може скачувати",
|
||||||
"LabelPermissionsUpdate": "Може оновлювати",
|
"LabelPermissionsUpdate": "Може оновлювати",
|
||||||
"LabelPermissionsUpload": "Може завантажувати",
|
"LabelPermissionsUpload": "Може завантажувати",
|
||||||
"LabelPersonalYearReview": "Ваші підсумки року ({0})",
|
"LabelPersonalYearReview": "Ваші підсумки року ({0})",
|
||||||
|
@ -514,7 +514,7 @@
|
||||||
"LabelPublishers": "Видавці",
|
"LabelPublishers": "Видавці",
|
||||||
"LabelRSSFeedCustomOwnerEmail": "Користувацька електронна адреса власника",
|
"LabelRSSFeedCustomOwnerEmail": "Користувацька електронна адреса власника",
|
||||||
"LabelRSSFeedCustomOwnerName": "Користувацьке ім'я власника",
|
"LabelRSSFeedCustomOwnerName": "Користувацьке ім'я власника",
|
||||||
"LabelRSSFeedOpen": "RSS-канал відкрито",
|
"LabelRSSFeedOpen": "RSS-канал відкритий",
|
||||||
"LabelRSSFeedPreventIndexing": "Запобігати індексації",
|
"LabelRSSFeedPreventIndexing": "Запобігати індексації",
|
||||||
"LabelRSSFeedSlug": "Назва RSS-каналу",
|
"LabelRSSFeedSlug": "Назва RSS-каналу",
|
||||||
"LabelRSSFeedURL": "Адреса RSS-каналу",
|
"LabelRSSFeedURL": "Адреса RSS-каналу",
|
||||||
|
@ -542,8 +542,8 @@
|
||||||
"LabelSeason": "Сезон",
|
"LabelSeason": "Сезон",
|
||||||
"LabelSeasonNumber": "Сезон #{0}",
|
"LabelSeasonNumber": "Сезон #{0}",
|
||||||
"LabelSelectAll": "Вибрати все",
|
"LabelSelectAll": "Вибрати все",
|
||||||
"LabelSelectAllEpisodes": "Вибрати всі серії",
|
"LabelSelectAllEpisodes": "Вибрати всі епізоди",
|
||||||
"LabelSelectEpisodesShowing": "Обрати показані епізоди: {0}",
|
"LabelSelectEpisodesShowing": "Вибрати {0} показаних епізодів",
|
||||||
"LabelSelectUsers": "Вибрати користувачів",
|
"LabelSelectUsers": "Вибрати користувачів",
|
||||||
"LabelSendEbookToDevice": "Надіслати електронну книгу на...",
|
"LabelSendEbookToDevice": "Надіслати електронну книгу на...",
|
||||||
"LabelSequence": "Послідовність",
|
"LabelSequence": "Послідовність",
|
||||||
|
@ -595,7 +595,7 @@
|
||||||
"LabelSettingsStoreMetadataWithItemHelp": "За замовчуванням файли метаданих зберігаються у /metadata/items. Цей параметр увімкне збереження метаданих у теці елемента бібліотеки",
|
"LabelSettingsStoreMetadataWithItemHelp": "За замовчуванням файли метаданих зберігаються у /metadata/items. Цей параметр увімкне збереження метаданих у теці елемента бібліотеки",
|
||||||
"LabelSettingsTimeFormat": "Формат часу",
|
"LabelSettingsTimeFormat": "Формат часу",
|
||||||
"LabelShare": "Поділитися",
|
"LabelShare": "Поділитися",
|
||||||
"LabelShareDownloadableHelp": "Дозволяє користувачам із посиланням для спільного доступу завантажувати zip-файл елемента бібліотеки.",
|
"LabelShareDownloadableHelp": "Дозволяє користувачам із посиланням для спільного доступу скачування zip-файлу елемента бібліотеки.",
|
||||||
"LabelShareOpen": "Поділитися відкрито",
|
"LabelShareOpen": "Поділитися відкрито",
|
||||||
"LabelShareURL": "Поділитися URL",
|
"LabelShareURL": "Поділитися URL",
|
||||||
"LabelShowAll": "Показати все",
|
"LabelShowAll": "Показати все",
|
||||||
|
@ -714,19 +714,19 @@
|
||||||
"MessageBackupsLocationNoEditNote": "Примітка: розташування резервної копії встановлюється за допомогою змінної середовища та не може бути змінене тут.",
|
"MessageBackupsLocationNoEditNote": "Примітка: розташування резервної копії встановлюється за допомогою змінної середовища та не може бути змінене тут.",
|
||||||
"MessageBackupsLocationPathEmpty": "Шлях розташування резервної копії не може бути порожнім",
|
"MessageBackupsLocationPathEmpty": "Шлях розташування резервної копії не може бути порожнім",
|
||||||
"MessageBatchEditPopulateMapDetailsAllHelp": "Заповнити увімкнені поля даними з усіх елементів. Поля з кількома значеннями буде об’єднано",
|
"MessageBatchEditPopulateMapDetailsAllHelp": "Заповнити увімкнені поля даними з усіх елементів. Поля з кількома значеннями буде об’єднано",
|
||||||
"MessageBatchEditPopulateMapDetailsItemHelp": "Заповніть увімкнені поля деталей карти даними з цього елемента",
|
"MessageBatchEditPopulateMapDetailsItemHelp": "Заповнити увімкнені поля деталізації даними з цього елемента",
|
||||||
"MessageBatchQuickMatchDescription": "Швидкий пошук спробує знайти відсутні обкладинки та метадані обраних елементів. Увімкніть налаштування нижче, аби дозволити заміну наявних обкладинок та/або метаданих під час швидкого пошуку.",
|
"MessageBatchQuickMatchDescription": "Швидкий пошук спробує знайти відсутні обкладинки та метадані обраних елементів. Увімкніть налаштування нижче, аби дозволити заміну наявних обкладинок та/або метаданих під час швидкого пошуку.",
|
||||||
"MessageBookshelfNoCollections": "Ви не створили жодної добірки",
|
"MessageBookshelfNoCollections": "Ви ще не створили жодної добірки",
|
||||||
"MessageBookshelfNoCollectionsHelp": "Колекції публічні. Їх можуть бачити всі користувачі, які мають доступ до бібліотеки.",
|
"MessageBookshelfNoCollectionsHelp": "Колекції публічні. Їх можуть бачити всі користувачі, які мають доступ до бібліотеки.",
|
||||||
"MessageBookshelfNoRSSFeeds": "Немає відкритих RSS-каналів",
|
"MessageBookshelfNoRSSFeeds": "Немає відкритих RSS-каналів",
|
||||||
"MessageBookshelfNoResultsForFilter": "Немає результатів з фільтром \"{0}: {1}\"",
|
"MessageBookshelfNoResultsForFilter": "Немає результатів з фільтром \"{0}: {1}\"",
|
||||||
"MessageBookshelfNoResultsForQuery": "Немає результатів за запитом",
|
"MessageBookshelfNoResultsForQuery": "Немає результатів за запитом",
|
||||||
"MessageBookshelfNoSeries": "Серії відсутні",
|
"MessageBookshelfNoSeries": "У вас немає серій",
|
||||||
"MessageChapterEndIsAfter": "Кінець глави знаходиться після закінчення книги",
|
"MessageChapterEndIsAfter": "Кінець глави після завершення аудіокниги",
|
||||||
"MessageChapterErrorFirstNotZero": "Перша глава мусить починатися з 0",
|
"MessageChapterErrorFirstNotZero": "Перша глава повинна починатися з 0",
|
||||||
"MessageChapterErrorStartGteDuration": "Час початку мусить бути меншим за тривалість аудіокниги",
|
"MessageChapterErrorStartGteDuration": "Час початку має бути меншим за тривалість аудіокниги",
|
||||||
"MessageChapterErrorStartLtPrev": "Неприпустимий час початку, має бути більшим за час початку попередньої глави",
|
"MessageChapterErrorStartLtPrev": "Час початку має бути більшим або рівним часу початку попередньої глави",
|
||||||
"MessageChapterStartIsAfter": "Початок глави знаходиться після закінчення книги",
|
"MessageChapterStartIsAfter": "Початок глави після завершення аудіокниги",
|
||||||
"MessageChaptersNotFound": "Розділи не знайдені",
|
"MessageChaptersNotFound": "Розділи не знайдені",
|
||||||
"MessageCheckingCron": "Перевірка планувальника...",
|
"MessageCheckingCron": "Перевірка планувальника...",
|
||||||
"MessageConfirmCloseFeed": "Ви дійсно бажаєте закрити цей канал?",
|
"MessageConfirmCloseFeed": "Ви дійсно бажаєте закрити цей канал?",
|
||||||
|
@ -734,71 +734,72 @@
|
||||||
"MessageConfirmDeleteDevice": "Ви впевнені, що хочете видалити пристрій для читання \"{0}\"?",
|
"MessageConfirmDeleteDevice": "Ви впевнені, що хочете видалити пристрій для читання \"{0}\"?",
|
||||||
"MessageConfirmDeleteFile": "Файл буде видалено з вашої файлової системи. Ви впевнені?",
|
"MessageConfirmDeleteFile": "Файл буде видалено з вашої файлової системи. Ви впевнені?",
|
||||||
"MessageConfirmDeleteLibrary": "Ви дійсно бажаєте назавжди видалити бібліотеку \"{0}\"?",
|
"MessageConfirmDeleteLibrary": "Ви дійсно бажаєте назавжди видалити бібліотеку \"{0}\"?",
|
||||||
"MessageConfirmDeleteLibraryItem": "Елемент бібліотеки буде видалено з бази даних та вашої файлової системи. Ви впевнені?",
|
"MessageConfirmDeleteLibraryItem": "Елемент бібліотеки буде видалено з бази даних і файлової системи. Ви впевнені?",
|
||||||
"MessageConfirmDeleteLibraryItems": "З бази даних та вашої файлової системи будуть видалені елементи бібліотеки: {0}. Ви впевнені?",
|
"MessageConfirmDeleteLibraryItems": "Буде видалено {0} елементів бібліотеки з бази даних і файлової системи. Ви впевнені?",
|
||||||
"MessageConfirmDeleteMetadataProvider": "Ви впевнені, що хочете видалити користувацького постачальника метаданих \"{0}\"?",
|
"MessageConfirmDeleteMetadataProvider": "Ви впевнені, що хочете видалити користувацького постачальника метаданих \"{0}\"?",
|
||||||
"MessageConfirmDeleteNotification": "Ви впевнені, що хочете видалити це сповіщення?",
|
"MessageConfirmDeleteNotification": "Ви впевнені, що хочете видалити це сповіщення?",
|
||||||
"MessageConfirmDeleteSession": "Ви дійсно бажаєте видалити цей сеанс?",
|
"MessageConfirmDeleteSession": "Ви дійсно бажаєте видалити цей сеанс?",
|
||||||
"MessageConfirmEmbedMetadataInAudioFiles": "Ви впевнені, що хочете вставити метадані в {0} аудіофайлів?",
|
"MessageConfirmEmbedMetadataInAudioFiles": "Ви впевнені, що хочете вбудувати метадані у {0} аудіофайлів?",
|
||||||
"MessageConfirmForceReScan": "Ви дійсно бажаєте примусово пересканувати?",
|
"MessageConfirmForceReScan": "Ви дійсно бажаєте примусово пересканувати?",
|
||||||
"MessageConfirmMarkAllEpisodesFinished": "Ви дійсно бажаєте позначити усі епізоди завершеними?",
|
"MessageConfirmMarkAllEpisodesFinished": "Ви впевнені, що хочете позначити всі епізоди завершеними?",
|
||||||
"MessageConfirmMarkAllEpisodesNotFinished": "Ви дійсно бажаєте позначити усі епізоди незавершеними?",
|
"MessageConfirmMarkAllEpisodesNotFinished": "Ви впевнені, що хочете позначити всі епізоди незавершеними?",
|
||||||
"MessageConfirmMarkItemFinished": "Ви впевнені, що хочете позначити \"{0}\" як завершене?",
|
"MessageConfirmMarkItemFinished": "Ви впевнені, що хочете позначити \"{0}\" як завершене?",
|
||||||
"MessageConfirmMarkItemNotFinished": "Ви впевнені, що хочете позначити \"{0}\" як незавершене?",
|
"MessageConfirmMarkItemNotFinished": "Ви впевнені, що хочете позначити \"{0}\" як незавершене?",
|
||||||
"MessageConfirmMarkSeriesFinished": "Ви дійсно бажаєте позначити усі книги серії завершеними?",
|
"MessageConfirmMarkSeriesFinished": "Ви дійсно бажаєте позначити усі книги серії завершеними?",
|
||||||
"MessageConfirmMarkSeriesNotFinished": "Ви дійсно бажаєте позначити всі книги серії незавершеними?",
|
"MessageConfirmMarkSeriesNotFinished": "Ви дійсно бажаєте позначити всі книги серії незавершеними?",
|
||||||
"MessageConfirmNotificationTestTrigger": "Активувати це сповіщення з тестовими даними?",
|
"MessageConfirmNotificationTestTrigger": "Активувати це сповіщення з тестовими даними?",
|
||||||
"MessageConfirmPurgeCache": "Очищення кешу видалить усю теку <code>/metadata/cache</code>. <br /><br />Ви дійсно бажаєте видалити теку кешу?",
|
"MessageConfirmPurgeCache": "Очищення кешу видалить всю теку <code>/metadata/cache</code>. <br /><br />Ви впевнені, що хочете видалити теку кешу?",
|
||||||
"MessageConfirmPurgeItemsCache": "Очищення кешу елементів видалить усю теку <code>/metadata/cache/items</code>. <br />Ви певні?",
|
"MessageConfirmPurgeItemsCache": "Очищення кешу елементів видалить всю теку <code>/metadata/cache/items</code>.<br />Ви впевнені?",
|
||||||
"MessageConfirmQuickEmbed": "Увага! Швидке вбудування не створює резервних копій ваших аудіо. Переконайтеся, що маєте копію ваших файлів.<br><br>Продовжити?",
|
"MessageConfirmQuickEmbed": "Увага! Швидке вбудовування не створює резервних копій ваших аудіофайлів. Переконайтеся, що маєте резервну копію. <br><br>Продовжити?",
|
||||||
"MessageConfirmQuickMatchEpisodes": "При виявленні співпадінь інформація про епізоди швидкого пошуку буде перезаписана. Будуть оновлені тільки несуперечливі епізоди. Ви впевнені?",
|
"MessageConfirmQuickMatchEpisodes": "Швидке співставлення епізодів перезапише подробиці, якщо знайдено відповідність. Оновлюються лише невідповідні епізоди. Ви впевнені?",
|
||||||
"MessageConfirmReScanLibraryItems": "Ви дійсно бажаєте пересканувати елементи: {0}?",
|
"MessageConfirmReScanLibraryItems": "Ви впевнені, що хочете пересканувати {0} елементів?",
|
||||||
"MessageConfirmRemoveAllChapters": "Ви дійсно бажаєте видалити усі глави?",
|
"MessageConfirmRemoveAllChapters": "Ви дійсно бажаєте видалити усі глави?",
|
||||||
"MessageConfirmRemoveAuthor": "Ви дійсно бажаєте видалити автора \"{0}\"?",
|
"MessageConfirmRemoveAuthor": "Ви дійсно бажаєте видалити автора \"{0}\"?",
|
||||||
"MessageConfirmRemoveCollection": "Ви дійсно бажаєте видалити добірку \"{0}\"?",
|
"MessageConfirmRemoveCollection": "Ви дійсно бажаєте видалити добірку \"{0}\"?",
|
||||||
"MessageConfirmRemoveEpisode": "Ви дійсно бажаєте видалити епізод \"{0}\"?",
|
"MessageConfirmRemoveEpisode": "Ви дійсно бажаєте видалити епізод \"{0}\"?",
|
||||||
|
"MessageConfirmRemoveEpisodeNote": "Примітка: Це не видаляє аудіофайл, якщо не перемикає \"файл жорсткого видалення\"",
|
||||||
"MessageConfirmRemoveEpisodes": "Ви дійсно бажаєте видалити епізодів: {0}?",
|
"MessageConfirmRemoveEpisodes": "Ви дійсно бажаєте видалити епізодів: {0}?",
|
||||||
"MessageConfirmRemoveListeningSessions": "Ви дійсно бажаєте видалити сеанси прослуховування: {0}?",
|
"MessageConfirmRemoveListeningSessions": "Ви дійсно бажаєте видалити сеанси прослуховування: {0}?",
|
||||||
"MessageConfirmRemoveMetadataFiles": "Ви впевнені, що хочете видалити всі файли metadata.{0} у папках елементів вашої бібліотеки?",
|
"MessageConfirmRemoveMetadataFiles": "Ви впевнені, що хочете видалити всі файли metadata.{0} у папках елементів вашої бібліотеки?",
|
||||||
"MessageConfirmRemoveNarrator": "Ви дійсно бажаєте видалити читця \"{0}\"?",
|
"MessageConfirmRemoveNarrator": "Ви дійсно бажаєте видалити читця \"{0}\"?",
|
||||||
"MessageConfirmRemovePlaylist": "Ви дійсно бажаєте видалити список відтворення \"{0}\"?",
|
"MessageConfirmRemovePlaylist": "Ви дійсно бажаєте видалити ваш список відтворення \"{0}\"?",
|
||||||
"MessageConfirmRenameGenre": "Ви дійсно бажаєте замінити жанр \"{0}\" на \"{1}\" для усіх елементів?",
|
"MessageConfirmRenameGenre": "Ви впевнені, що хочете перейменувати жанр \"{0}\" на \"{1}\" для всіх елементів?",
|
||||||
"MessageConfirmRenameGenreMergeNote": "Примітка: такий жанр вже існує, тож їх буде об'єднано.",
|
"MessageConfirmRenameGenreMergeNote": "Примітка: Такий жанр вже існує, тому вони будуть об'єднані.",
|
||||||
"MessageConfirmRenameGenreWarning": "Увага! Вже існує схожий жанр у іншому регістрі \"{0}\".",
|
"MessageConfirmRenameGenreWarning": "Увага! Схожий жанр з іншом регістром вже існує \"{0}\".",
|
||||||
"MessageConfirmRenameTag": "Ви дійсно бажаєте замінити мітку \"{0}\" на \"{1}\" для усіх елементів?",
|
"MessageConfirmRenameTag": "Ви впевнені, що хочете перейменувати мітку \"{0}\" на \"{1}\" для всіх елементів?",
|
||||||
"MessageConfirmRenameTagMergeNote": "Примітка: така мітка вже існує, тож їх буде об'єднано.",
|
"MessageConfirmRenameTagMergeNote": "Примітка: Така мітка вже існує, тому вони будуть об'єднані.",
|
||||||
"MessageConfirmRenameTagWarning": "Увага! Вже існує схожа мітка у іншому регістрі \"{0}\".",
|
"MessageConfirmRenameTagWarning": "Увага! Схожа мітка з іншою регістром вже існує \"{0}\".",
|
||||||
"MessageConfirmResetProgress": "Ви впевнені, що хочете скинути свій прогрес?",
|
"MessageConfirmResetProgress": "Ви впевнені, що хочете скинути свій прогрес?",
|
||||||
"MessageConfirmSendEbookToDevice": "Ви дійсно хочете відправити на пристрій \"{2}\" електроні книги: {0}, \"{1}\"?",
|
"MessageConfirmSendEbookToDevice": "Ви дійсно хочете відправити на пристрій \"{2}\" електроні книги: {0}, \"{1}\"?",
|
||||||
"MessageConfirmUnlinkOpenId": "Ви впевнені, що хочете відв'язати цього користувача від OpenID?",
|
"MessageConfirmUnlinkOpenId": "Ви впевнені, що хочете відв'язати цього користувача від OpenID?",
|
||||||
"MessageDaysListenedInTheLastYear": "{0} днів, прослуханих за останній рік",
|
"MessageDaysListenedInTheLastYear": "{0} днів, прослуханих за останній рік",
|
||||||
"MessageDownloadingEpisode": "Завантаження епізоду",
|
"MessageDownloadingEpisode": "Скачування епізоду",
|
||||||
"MessageDragFilesIntoTrackOrder": "Перетягніть файли до правильного порядку",
|
"MessageDragFilesIntoTrackOrder": "Перетягніть файли до правильного порядку",
|
||||||
"MessageEmbedFailed": "Не вдалося вбудувати!",
|
"MessageEmbedFailed": "Не вдалося вбудувати!",
|
||||||
"MessageEmbedFinished": "Вбудовано!",
|
"MessageEmbedFinished": "Вбудовування завершено!",
|
||||||
"MessageEmbedQueue": "В черзі на вбудовування метаданих ({0} в черзі)",
|
"MessageEmbedQueue": "У черзі на вбудовування метаданих ({0} у черзі)",
|
||||||
"MessageEpisodesQueuedForDownload": "Епізодів у черзі завантаження: {0}",
|
"MessageEpisodesQueuedForDownload": "{0} епізод(ів) у черзі на завантаження",
|
||||||
"MessageEreaderDevices": "Аби гарантувати отримання електронних книг, вам може знадобитися додати вказану вище адресу електронної пошти як правильного відправника на кожному з пристроїв зі списку нижче.",
|
"MessageEreaderDevices": "Аби гарантувати отримання електронних книг, вам може знадобитися додати вказану вище адресу електронної пошти як правильного відправника на кожному з пристроїв зі списку нижче.",
|
||||||
"MessageFeedURLWillBe": "URL-адреса каналу буде {0}",
|
"MessageFeedURLWillBe": "URL-адреса каналу буде {0}",
|
||||||
"MessageFetching": "Отримання...",
|
"MessageFetching": "Отримання...",
|
||||||
"MessageForceReScanDescription": "Просканує усі файли заново, неначе вперше. ID3-мітки, файли OPF та текстові файли будуть проскановані як нові.",
|
"MessageForceReScanDescription": "Просканує всі файли заново, як при першому скануванні. ID3-мітки, OPF-файли та текстові файли будуть проскановані як нові.",
|
||||||
"MessageImportantNotice": "Важливе повідомлення!",
|
"MessageImportantNotice": "Важливе повідомлення!",
|
||||||
"MessageInsertChapterBelow": "Введіть главу нижче",
|
"MessageInsertChapterBelow": "Введіть главу нижче",
|
||||||
"MessageInvalidAsin": "Невірний ASIN",
|
"MessageInvalidAsin": "Невірний ASIN",
|
||||||
"MessageItemsSelected": "Вибрано елементів: {0}",
|
"MessageItemsSelected": "Вибрано {0} елементів",
|
||||||
"MessageItemsUpdated": "Оновлено елементів: {0}",
|
"MessageItemsUpdated": "Оновлено {0} елементів",
|
||||||
"MessageJoinUsOn": "Приєднуйтесь до",
|
"MessageJoinUsOn": "Приєднуйтесь до",
|
||||||
"MessageLoading": "Завантаження...",
|
"MessageLoading": "Завантаження...",
|
||||||
"MessageLoadingFolders": "Завантаження тек...",
|
"MessageLoadingFolders": "Завантаження папок...",
|
||||||
"MessageLogsDescription": "Журнали зберігаються у <code>/metadata/logs</code> як JSON-файли. Журнали збоїв зберігаються у <code>/metadata/logs/crash_logs.txt</code>.",
|
"MessageLogsDescription": "Журнали зберігаються у <code>/metadata/logs</code> як JSON-файли. Журнали збоїв зберігаються у <code>/metadata/logs/crash_logs.txt</code>.",
|
||||||
"MessageM4BFailed": "Помилка M4B!",
|
"MessageM4BFailed": "Помилка M4B!",
|
||||||
"MessageM4BFinished": "M4B створено!",
|
"MessageM4BFinished": "M4B створено!",
|
||||||
"MessageMapChapterTitles": "Встановіть назви глав вашої аудіокниги без визначення налаштувань тривалості",
|
"MessageMapChapterTitles": "Встановіть назви глав вашої аудіокниги без зміни часових міток",
|
||||||
"MessageMarkAllEpisodesFinished": "Позначити всі епізоди завершеними",
|
"MessageMarkAllEpisodesFinished": "Позначити всі епізоди завершеними",
|
||||||
"MessageMarkAllEpisodesNotFinished": "Позначити всі епізоди незавершеними",
|
"MessageMarkAllEpisodesNotFinished": "Позначити всі епізоди незавершеними",
|
||||||
"MessageMarkAsFinished": "Позначити завершеним",
|
"MessageMarkAsFinished": "Позначити як завершене",
|
||||||
"MessageMarkAsNotFinished": "Позначити незавершеним",
|
"MessageMarkAsNotFinished": "Позначити як незавершене",
|
||||||
"MessageMatchBooksDescription": "Спробує віднайти книгу у вказаному джерелі пошуку та встановити подробиці та обкладинку, яких бракує. Не перезаписує подробиці.",
|
"MessageMatchBooksDescription": "Спробує знайти книги у бібліотеці у вибраному джерелі пошуку та заповнити порожні подробиці й обкладинку. Не перезаписує подробиці.",
|
||||||
"MessageNoAudioTracks": "Аудіодоріжки відсутні",
|
"MessageNoAudioTracks": "Аудіодоріжки відсутні",
|
||||||
"MessageNoAuthors": "Автори відсутні",
|
"MessageNoAuthors": "Автори відсутні",
|
||||||
"MessageNoBackups": "Резервні копії відсутні",
|
"MessageNoBackups": "Резервні копії відсутні",
|
||||||
|
@ -808,8 +809,8 @@
|
||||||
"MessageNoCoversFound": "Обкладинок не знайдено",
|
"MessageNoCoversFound": "Обкладинок не знайдено",
|
||||||
"MessageNoDescription": "Без опису",
|
"MessageNoDescription": "Без опису",
|
||||||
"MessageNoDevices": "Немає пристроїв",
|
"MessageNoDevices": "Немає пристроїв",
|
||||||
"MessageNoDownloadsInProgress": "Немає активних завантажень",
|
"MessageNoDownloadsInProgress": "Немає активних скачувань",
|
||||||
"MessageNoDownloadsQueued": "Немає завантажень у черзі",
|
"MessageNoDownloadsQueued": "Немає скачувань у черзі",
|
||||||
"MessageNoEpisodeMatchesFound": "Відповідних епізодів не знайдено",
|
"MessageNoEpisodeMatchesFound": "Відповідних епізодів не знайдено",
|
||||||
"MessageNoEpisodes": "Епізоди відсутні",
|
"MessageNoEpisodes": "Епізоди відсутні",
|
||||||
"MessageNoFoldersAvailable": "Немає доступних тек",
|
"MessageNoFoldersAvailable": "Немає доступних тек",
|
||||||
|
@ -821,18 +822,18 @@
|
||||||
"MessageNoLogs": "Немає журнали",
|
"MessageNoLogs": "Немає журнали",
|
||||||
"MessageNoMediaProgress": "Прогрес відсутній",
|
"MessageNoMediaProgress": "Прогрес відсутній",
|
||||||
"MessageNoNotifications": "Сповіщення відсутні",
|
"MessageNoNotifications": "Сповіщення відсутні",
|
||||||
"MessageNoPodcastFeed": "Невірний подкаст: Немає каналу",
|
"MessageNoPodcastFeed": "Некоректний подкаст: немає каналу",
|
||||||
"MessageNoPodcastsFound": "Подкастів не знайдено",
|
"MessageNoPodcastsFound": "Подкастів не знайдено",
|
||||||
"MessageNoResults": "Немає результатів",
|
"MessageNoResults": "Немає результатів",
|
||||||
"MessageNoSearchResultsFor": "Немає результатів пошуку для \"{0}\"",
|
"MessageNoSearchResultsFor": "Немає результатів пошуку для \"{0}\"",
|
||||||
"MessageNoSeries": "Без серії",
|
"MessageNoSeries": "Немає серій",
|
||||||
"MessageNoTags": "Без міток",
|
"MessageNoTags": "Немає міток",
|
||||||
"MessageNoTasksRunning": "Немає активних завдань",
|
"MessageNoTasksRunning": "Немає активних завдань",
|
||||||
"MessageNoUpdatesWereNecessary": "Оновлень не потрібно",
|
"MessageNoUpdatesWereNecessary": "Оновлення не потрібні",
|
||||||
"MessageNoUserPlaylists": "У вас немає списків відтворення",
|
"MessageNoUserPlaylists": "У вас немає списків відтворення",
|
||||||
"MessageNoUserPlaylistsHelp": "Списки відтворення приватні. Лише користувач, який їх створює, може бачити їх.",
|
"MessageNoUserPlaylistsHelp": "Списки відтворення приватні. Лише користувач, який їх створив, може їх бачити.",
|
||||||
"MessageNotYetImplemented": "Ще не реалізовано",
|
"MessageNotYetImplemented": "Ще не реалізовано",
|
||||||
"MessageOpmlPreviewNote": "Примітка: це попередній перегляд OPML-файлу. Актуальна назва подкасту буде завантажена з RSS-каналу.",
|
"MessageOpmlPreviewNote": "Примітка: це попередній перегляд OPML-файлу. Актуальна назва подкасту буде взята з RSS-каналу.",
|
||||||
"MessageOr": "або",
|
"MessageOr": "або",
|
||||||
"MessagePauseChapter": "Призупинити відтворення глави",
|
"MessagePauseChapter": "Призупинити відтворення глави",
|
||||||
"MessagePlayChapter": "Слухати початок глави",
|
"MessagePlayChapter": "Слухати початок глави",
|
||||||
|
@ -841,7 +842,7 @@
|
||||||
"MessagePodcastHasNoRSSFeedForMatching": "Подкаст не має RSS-каналу для пошуку",
|
"MessagePodcastHasNoRSSFeedForMatching": "Подкаст не має RSS-каналу для пошуку",
|
||||||
"MessagePodcastSearchField": "Введіть пошуковий запит або URL RSS-стрічки",
|
"MessagePodcastSearchField": "Введіть пошуковий запит або URL RSS-стрічки",
|
||||||
"MessageQuickEmbedInProgress": "Швидке вбудовування в процесі",
|
"MessageQuickEmbedInProgress": "Швидке вбудовування в процесі",
|
||||||
"MessageQuickEmbedQueue": "В черзі на швидке вбудовування ({0} в черзі)",
|
"MessageQuickEmbedQueue": "У черзі на швидке вбудовування ({0} в черзі)",
|
||||||
"MessageQuickMatchAllEpisodes": "Швидке співставлення всіх епізодів",
|
"MessageQuickMatchAllEpisodes": "Швидке співставлення всіх епізодів",
|
||||||
"MessageQuickMatchDescription": "Заповнити відсутні подробиці та обкладинку першим результатом пошуку '{0}'. Не перезаписує подробиці, якщо не увімкнено параметр \"Надавати перевагу віднайденим метаданим\".",
|
"MessageQuickMatchDescription": "Заповнити відсутні подробиці та обкладинку першим результатом пошуку '{0}'. Не перезаписує подробиці, якщо не увімкнено параметр \"Надавати перевагу віднайденим метаданим\".",
|
||||||
"MessageRemoveChapter": "Видалити главу",
|
"MessageRemoveChapter": "Видалити главу",
|
||||||
|
@ -849,9 +850,9 @@
|
||||||
"MessageRemoveFromPlayerQueue": "Вилучити з черги відтворення",
|
"MessageRemoveFromPlayerQueue": "Вилучити з черги відтворення",
|
||||||
"MessageRemoveUserWarning": "Ви дійсно бажаєте назавжди видалити користувача \"{0}\"?",
|
"MessageRemoveUserWarning": "Ви дійсно бажаєте назавжди видалити користувача \"{0}\"?",
|
||||||
"MessageReportBugsAndContribute": "Повідомляйте про помилки, пропонуйте функції та долучайтеся на",
|
"MessageReportBugsAndContribute": "Повідомляйте про помилки, пропонуйте функції та долучайтеся на",
|
||||||
"MessageResetChaptersConfirm": "Ви дійсно бажаєте скинути глави та скасувати внесені зміни?",
|
"MessageResetChaptersConfirm": "Ви впевнені, що хочете скинути глави та скасувати внесені зміни?",
|
||||||
"MessageRestoreBackupConfirm": "Ви дійсно бажаєте відновити резервну копію від",
|
"MessageRestoreBackupConfirm": "Ви впевнені, що хочете відновити резервну копію, створену",
|
||||||
"MessageRestoreBackupWarning": "Відновлення резервної копії перезапише всю базу даних, розташовану в /config, і зображення обкладинок в /metadata/items та /metadata/authors.<br /><br />Резервні копії не змінюють жодних файлів у теках бібліотеки. Якщо у налаштуваннях сервера увімкнено збереження обкладинок і метаданих у теках бібліотеки, вони не створюються під час резервного копіювання і не перезаписуються..<br /><br />Всі клієнти, що користуються вашим сервером, будуть автоматично оновлені.",
|
"MessageRestoreBackupWarning": "Відновлення резервної копії перезапише всю базу даних у /config і зображення обкладинок у /metadata/items та /metadata/authors.<br /><br />Резервні копії не змінюють файли у теках бібліотеки. Якщо у налаштуваннях сервера увімкнено збереження обкладинок і метаданих у теках бібліотеки, вони не створюються під час резервного копіювання і не перезаписуються.<br /><br />Всі клієнти, що користуються вашим сервером, будуть автоматично оновлені.",
|
||||||
"MessageScheduleLibraryScanNote": "Для більшості користувачів рекомендується залишити цю функцію вимкненою та залишити параметр перегляду папок увімкненим. Засіб спостереження за папками автоматично виявить зміни в папках вашої бібліотеки. Засіб спостереження за папками не працює для кожної файлової системи (наприклад, NFS), тому замість нього можна використовувати сканування бібліотек за розкладом.",
|
"MessageScheduleLibraryScanNote": "Для більшості користувачів рекомендується залишити цю функцію вимкненою та залишити параметр перегляду папок увімкненим. Засіб спостереження за папками автоматично виявить зміни в папках вашої бібліотеки. Засіб спостереження за папками не працює для кожної файлової системи (наприклад, NFS), тому замість нього можна використовувати сканування бібліотек за розкладом.",
|
||||||
"MessageScheduleRunEveryWeekdayAtTime": "Запуск кожні {0} о {1}",
|
"MessageScheduleRunEveryWeekdayAtTime": "Запуск кожні {0} о {1}",
|
||||||
"MessageSearchResultsFor": "Результати пошуку для",
|
"MessageSearchResultsFor": "Результати пошуку для",
|
||||||
|
@ -860,12 +861,12 @@
|
||||||
"MessageServerCouldNotBeReached": "Не вдалося підключитися до сервера",
|
"MessageServerCouldNotBeReached": "Не вдалося підключитися до сервера",
|
||||||
"MessageSetChaptersFromTracksDescription": "Створити глави з аудіодоріжок, встановивши назви файлів за заголовки",
|
"MessageSetChaptersFromTracksDescription": "Створити глави з аудіодоріжок, встановивши назви файлів за заголовки",
|
||||||
"MessageShareExpirationWillBe": "Термін сплине за <strong>{0}</strong>",
|
"MessageShareExpirationWillBe": "Термін сплине за <strong>{0}</strong>",
|
||||||
"MessageShareExpiresIn": "Сплине за {0}",
|
"MessageShareExpiresIn": "Спливає через {0}",
|
||||||
"MessageShareURLWillBe": "Поширюваний URL - <strong>{0}</strong>",
|
"MessageShareURLWillBe": "URL для спільного доступу — <strong>{0}</strong>",
|
||||||
"MessageStartPlaybackAtTime": "Почати відтворення \"{0}\" з {1}?",
|
"MessageStartPlaybackAtTime": "Почати відтворення \"{0}\" з {1}?",
|
||||||
"MessageTaskAudioFileNotWritable": "Аудіофайл \"{0}\" недоступний для запису",
|
"MessageTaskAudioFileNotWritable": "Аудіофайл \"{0}\" недоступний для запису",
|
||||||
"MessageTaskCanceledByUser": "Задача скасована користувачем",
|
"MessageTaskCanceledByUser": "Завдання скасовано користувачем",
|
||||||
"MessageTaskDownloadingEpisodeDescription": "Завантаження епізоду \"{0}\"",
|
"MessageTaskDownloadingEpisodeDescription": "Скачування епізоду \"{0}\"",
|
||||||
"MessageTaskEmbeddingMetadata": "Вбудовування метаданих",
|
"MessageTaskEmbeddingMetadata": "Вбудовування метаданих",
|
||||||
"MessageTaskEmbeddingMetadataDescription": "Вбудовування метаданих у аудіокнигу \"{0}\"",
|
"MessageTaskEmbeddingMetadataDescription": "Вбудовування метаданих у аудіокнигу \"{0}\"",
|
||||||
"MessageTaskEncodingM4b": "Кодування M4B",
|
"MessageTaskEncodingM4b": "Кодування M4B",
|
||||||
|
@ -880,19 +881,19 @@
|
||||||
"MessageTaskMatchingBooksInLibrary": "Відповідність книг у бібліотеці \"{0}\"",
|
"MessageTaskMatchingBooksInLibrary": "Відповідність книг у бібліотеці \"{0}\"",
|
||||||
"MessageTaskNoFilesToScan": "Немає файлів для сканування",
|
"MessageTaskNoFilesToScan": "Немає файлів для сканування",
|
||||||
"MessageTaskOpmlImport": "Імпорт OPML",
|
"MessageTaskOpmlImport": "Імпорт OPML",
|
||||||
"MessageTaskOpmlImportDescription": "Створення подкастів з {0} RSS-стрічок",
|
"MessageTaskOpmlImportDescription": "Створення подкастів з {0} RSS-каналів",
|
||||||
"MessageTaskOpmlImportFeed": "Канал імпорту OPML",
|
"MessageTaskOpmlImportFeed": "Імпорт RSS-каналу OPML",
|
||||||
"MessageTaskOpmlImportFeedDescription": "Імпорт RSS-каналу \"{0}\"",
|
"MessageTaskOpmlImportFeedDescription": "Імпорт RSS-каналу \"{0}\"",
|
||||||
"MessageTaskOpmlImportFeedFailed": "Не вдалося отримати подкаст-стрічку",
|
"MessageTaskOpmlImportFeedFailed": "Не вдалося отримати подкаст-канал",
|
||||||
"MessageTaskOpmlImportFeedPodcastDescription": "Створення подкасту \"{0}\"",
|
"MessageTaskOpmlImportFeedPodcastDescription": "Створення подкасту \"{0}\"",
|
||||||
"MessageTaskOpmlImportFeedPodcastExists": "Подкаст вже існує за цим шляхом",
|
"MessageTaskOpmlImportFeedPodcastExists": "Подкаст вже існує за цим шляхом",
|
||||||
"MessageTaskOpmlImportFeedPodcastFailed": "Не вдалося створити подкаст",
|
"MessageTaskOpmlImportFeedPodcastFailed": "Не вдалося створити подкаст",
|
||||||
"MessageTaskOpmlImportFinished": "Додано {0} подкастів",
|
"MessageTaskOpmlImportFinished": "Додано {0} подкастів",
|
||||||
"MessageTaskOpmlParseFailed": "Не вдалося розібрати файл OPML",
|
"MessageTaskOpmlParseFailed": "Не вдалося розібрати OPML-файл",
|
||||||
"MessageTaskOpmlParseFastFail": "Невірний файл OPML: не знайдено тег <opml> або тег <outline>",
|
"MessageTaskOpmlParseFastFail": "Некоректний OPML-файл: не знайдено тег <opml> або <outline>",
|
||||||
"MessageTaskOpmlParseNoneFound": "У файлі OPML не знайдено жодного канала",
|
"MessageTaskOpmlParseNoneFound": "У OPML-файлі не знайдено жодного каналу",
|
||||||
"MessageTaskScanItemsAdded": "{0} додано",
|
"MessageTaskScanItemsAdded": "{0} додано",
|
||||||
"MessageTaskScanItemsMissing": "{0} відсутній",
|
"MessageTaskScanItemsMissing": "{0} відсутні",
|
||||||
"MessageTaskScanItemsUpdated": "{0} оновлено",
|
"MessageTaskScanItemsUpdated": "{0} оновлено",
|
||||||
"MessageTaskScanNoChangesNeeded": "Змін не потрібно",
|
"MessageTaskScanNoChangesNeeded": "Змін не потрібно",
|
||||||
"MessageTaskScanningFileChanges": "Сканування змін файлів у \"{0}\"",
|
"MessageTaskScanningFileChanges": "Сканування змін файлів у \"{0}\"",
|
||||||
|
@ -902,22 +903,24 @@
|
||||||
"MessageUploaderItemFailed": "Не вдалося завантажити",
|
"MessageUploaderItemFailed": "Не вдалося завантажити",
|
||||||
"MessageUploaderItemSuccess": "Успішно завантажено!",
|
"MessageUploaderItemSuccess": "Успішно завантажено!",
|
||||||
"MessageUploading": "Завантаження...",
|
"MessageUploading": "Завантаження...",
|
||||||
"MessageValidCronExpression": "Допустима команда cron",
|
"MessageValidCronExpression": "Коректний cron-вираз",
|
||||||
"MessageWatcherIsDisabledGlobally": "Спостерігача вимкнено в налаштуваннях сервера",
|
"MessageWatcherIsDisabledGlobally": "Спостерігача вимкнено у глобальних налаштуваннях сервера",
|
||||||
"MessageXLibraryIsEmpty": "Бібліотека {0} порожня!",
|
"MessageXLibraryIsEmpty": "Бібліотека {0} порожня!",
|
||||||
"MessageYourAudiobookDurationIsLonger": "Тривалість вашої аудіокниги довша за віднайдену",
|
"MessageYourAudiobookDurationIsLonger": "Тривалість вашої аудіокниги більша за знайдену",
|
||||||
"MessageYourAudiobookDurationIsShorter": "Тривалість вашої аудіокниги коротша за віднайдену",
|
"MessageYourAudiobookDurationIsShorter": "Тривалість вашої аудіокниги менша за знайдену",
|
||||||
"NoteChangeRootPassword": "Тільки користувач root — єдиний, хто може мати порожній пароль",
|
"NoteChangeRootPassword": "Тільки користувач root може мати порожній пароль",
|
||||||
"NoteChapterEditorTimes": "Примітка: Перша глава мусить починатися з 0:00, а час початку останньої глави не може бути більшим за зазначену тривалість аудіокниги.",
|
"NoteChapterEditorTimes": "Примітка: Перша глава повинна починатися з 0:00, а час початку останньої глави не може перевищувати тривалість цієї аудіокниги.",
|
||||||
"NoteFolderPicker": "Примітка: вже обрані теки не буде показано",
|
"NoteFolderPicker": "Примітка: вже додані папки не відображаються",
|
||||||
"NoteRSSFeedPodcastAppsHttps": "Попередження: Більшість додатків подкастів вимагатимуть використання протоколу HTTPS від RSS-каналу",
|
"NoteRSSFeedPodcastAppsHttps": "Попередження: більшість додатків подкастів вимагають використання HTTPS для RSS-каналу",
|
||||||
"NoteRSSFeedPodcastAppsPubDate": "Попередження: 1 або більше ваших епізодів не мають дати публікації. Деякі додатки подкастів вимагають це.",
|
"NoteRSSFeedPodcastAppsPubDate": "Попередження: один або більше ваших епізодів не мають дати публікації. Деякі додатки подкастів цього вимагають.",
|
||||||
"NoteUploaderFoldersWithMediaFiles": "Теки з медіафайлами буде оброблено як окремі елементи бібліотеки.",
|
"NoteUploaderFoldersWithMediaFiles": "Теки з медіафайлами обробляються як окремі елементи бібліотеки.",
|
||||||
"NoteUploaderOnlyAudioFiles": "Якщо завантажувати лише аудіофайли, то кожен файл буде оброблено як окрему книгу.",
|
"NoteUploaderOnlyAudioFiles": "Якщо завантажувати лише аудіофайли, кожен файл буде окремою аудіокнигою.",
|
||||||
"NoteUploaderUnsupportedFiles": "Непідтримувані файли пропущено. Під час вибору або перетягування теки, файли, що знаходяться поза текою, пропускаються.",
|
"NoteUploaderUnsupportedFiles": "Непідтримувані файли ігноруються. При виборі або перетягуванні теки, файли поза теками елементів ігноруються.",
|
||||||
"NotificationOnBackupCompletedDescription": "Запускається після завершення резервного копіювання",
|
"NotificationOnBackupCompletedDescription": "Виконується після завершення резервного копіювання",
|
||||||
"NotificationOnBackupFailedDescription": "Срабатывает при збої резервного копіювання",
|
"NotificationOnBackupFailedDescription": "Виконується при помилці резервного копіювання",
|
||||||
"NotificationOnEpisodeDownloadedDescription": "Запускається при автоматичному завантаженні епізоду подкасту",
|
"NotificationOnEpisodeDownloadedDescription": "Виконується при автоматичному завантаженні епізоду подкасту",
|
||||||
|
"NotificationOnRSSFeedDisabledDescription": "Виконується, коли автоматичне завантаження епізодів вимкнено через забагато невдалих спроб",
|
||||||
|
"NotificationOnRSSFeedFailedDescription": "Виконується, коли запит RSS-каналу не вдається для автоматичного завантаження епізоду",
|
||||||
"NotificationOnTestDescription": "Подія для тестування системи сповіщень",
|
"NotificationOnTestDescription": "Подія для тестування системи сповіщень",
|
||||||
"PlaceholderNewCollection": "Нова назва добірки",
|
"PlaceholderNewCollection": "Нова назва добірки",
|
||||||
"PlaceholderNewFolderPath": "Новий шлях до теки",
|
"PlaceholderNewFolderPath": "Новий шлях до теки",
|
||||||
|
@ -995,7 +998,7 @@
|
||||||
"ToastEncodeCancelFailed": "Не вдалося скасувати кодування",
|
"ToastEncodeCancelFailed": "Не вдалося скасувати кодування",
|
||||||
"ToastEncodeCancelSucces": "Кодування скасовано",
|
"ToastEncodeCancelSucces": "Кодування скасовано",
|
||||||
"ToastEpisodeDownloadQueueClearFailed": "Не вдалося очистити чергу",
|
"ToastEpisodeDownloadQueueClearFailed": "Не вдалося очистити чергу",
|
||||||
"ToastEpisodeDownloadQueueClearSuccess": "Чергу на завантаження епізодів очищено",
|
"ToastEpisodeDownloadQueueClearSuccess": "Чергу на скачування епізодів очищено",
|
||||||
"ToastEpisodeUpdateSuccess": "{0} епізодів оновлено",
|
"ToastEpisodeUpdateSuccess": "{0} епізодів оновлено",
|
||||||
"ToastErrorCannotShare": "Не можна типово поширити на цей пристрій",
|
"ToastErrorCannotShare": "Не можна типово поширити на цей пристрій",
|
||||||
"ToastFailedToLoadData": "Не вдалося завантажити дані",
|
"ToastFailedToLoadData": "Не вдалося завантажити дані",
|
||||||
|
@ -1003,7 +1006,7 @@
|
||||||
"ToastFailedToShare": "Не вдалося поділитися",
|
"ToastFailedToShare": "Не вдалося поділитися",
|
||||||
"ToastFailedToUpdate": "Не вдалося оновити",
|
"ToastFailedToUpdate": "Не вдалося оновити",
|
||||||
"ToastInvalidImageUrl": "Невірний URL зображення",
|
"ToastInvalidImageUrl": "Невірний URL зображення",
|
||||||
"ToastInvalidMaxEpisodesToDownload": "Невірна кількість епізодів для завантаження",
|
"ToastInvalidMaxEpisodesToDownload": "Невірна кількість епізодів для скачування",
|
||||||
"ToastInvalidUrl": "Невірний URL",
|
"ToastInvalidUrl": "Невірний URL",
|
||||||
"ToastItemCoverUpdateSuccess": "Обкладинку елемента оновлено",
|
"ToastItemCoverUpdateSuccess": "Обкладинку елемента оновлено",
|
||||||
"ToastItemDeletedFailed": "Не вдалося видалити елемент",
|
"ToastItemDeletedFailed": "Не вдалося видалити елемент",
|
||||||
|
|
|
@ -11,7 +11,7 @@
|
||||||
"ButtonAuthors": "作者",
|
"ButtonAuthors": "作者",
|
||||||
"ButtonBack": "返回",
|
"ButtonBack": "返回",
|
||||||
"ButtonBatchEditPopulateFromExisting": "用现有内容填充",
|
"ButtonBatchEditPopulateFromExisting": "用现有内容填充",
|
||||||
"ButtonBatchEditPopulateMapDetails": "填充地图详细信息",
|
"ButtonBatchEditPopulateMapDetails": "填入此项详情",
|
||||||
"ButtonBrowseForFolder": "浏览文件夹",
|
"ButtonBrowseForFolder": "浏览文件夹",
|
||||||
"ButtonCancel": "取消",
|
"ButtonCancel": "取消",
|
||||||
"ButtonCancelEncode": "取消编码",
|
"ButtonCancelEncode": "取消编码",
|
||||||
|
@ -73,7 +73,7 @@
|
||||||
"ButtonQuickMatch": "快速匹配",
|
"ButtonQuickMatch": "快速匹配",
|
||||||
"ButtonReScan": "重新扫描",
|
"ButtonReScan": "重新扫描",
|
||||||
"ButtonRead": "读取",
|
"ButtonRead": "读取",
|
||||||
"ButtonReadLess": "阅读较少",
|
"ButtonReadLess": "收起",
|
||||||
"ButtonReadMore": "阅读更多",
|
"ButtonReadMore": "阅读更多",
|
||||||
"ButtonRefresh": "刷新",
|
"ButtonRefresh": "刷新",
|
||||||
"ButtonRemove": "移除",
|
"ButtonRemove": "移除",
|
||||||
|
@ -212,7 +212,7 @@
|
||||||
"HeaderUsers": "用户",
|
"HeaderUsers": "用户",
|
||||||
"HeaderYearReview": "{0} 年回顾",
|
"HeaderYearReview": "{0} 年回顾",
|
||||||
"HeaderYourStats": "你的统计数据",
|
"HeaderYourStats": "你的统计数据",
|
||||||
"LabelAbridged": "概要",
|
"LabelAbridged": "删节版",
|
||||||
"LabelAbridgedChecked": "删节版 (已勾选)",
|
"LabelAbridgedChecked": "删节版 (已勾选)",
|
||||||
"LabelAbridgedUnchecked": "未删节版 (未勾选)",
|
"LabelAbridgedUnchecked": "未删节版 (未勾选)",
|
||||||
"LabelAccessibleBy": "可访问",
|
"LabelAccessibleBy": "可访问",
|
||||||
|
@ -320,7 +320,7 @@
|
||||||
"LabelEmailSettingsRejectUnauthorized": "拒绝未经授权的证书",
|
"LabelEmailSettingsRejectUnauthorized": "拒绝未经授权的证书",
|
||||||
"LabelEmailSettingsRejectUnauthorizedHelp": "禁用SSL证书验证可能会使你的连接面临安全风险, 例如中间人攻击. 只有当你了解其中的含义并信任所连接的邮件服务器时, 才能禁用此选项.",
|
"LabelEmailSettingsRejectUnauthorizedHelp": "禁用SSL证书验证可能会使你的连接面临安全风险, 例如中间人攻击. 只有当你了解其中的含义并信任所连接的邮件服务器时, 才能禁用此选项.",
|
||||||
"LabelEmailSettingsSecure": "安全",
|
"LabelEmailSettingsSecure": "安全",
|
||||||
"LabelEmailSettingsSecureHelp": "如果选是, 则连接将在连接到服务器时使用TLS. 如果选否, 则若服务器支持STARTTLS扩展, 则使用TLS. 在大多数情况下, 如果连接到端口465, 请将该值设置为是. 对于端口587或25, 请保持为否. (来自nodemailer.com/smtp/#authentication)",
|
"LabelEmailSettingsSecureHelp": "开启此选项时,将始终通过TLS连接服务器。关闭此选项时,仅在服务器支持STARTTLS扩展时使用TLS。在大多数情况下,如果连接到端口465,请将此项设为开启。如果连接到端口587或25,请将此设置保持为关闭。(来自nodemailer.com/smtp/#authentication)",
|
||||||
"LabelEmailSettingsTestAddress": "测试地址",
|
"LabelEmailSettingsTestAddress": "测试地址",
|
||||||
"LabelEmbeddedCover": "嵌入封面",
|
"LabelEmbeddedCover": "嵌入封面",
|
||||||
"LabelEnable": "启用",
|
"LabelEnable": "启用",
|
||||||
|
@ -346,15 +346,15 @@
|
||||||
"LabelExample": "示例",
|
"LabelExample": "示例",
|
||||||
"LabelExpandSeries": "展开系列",
|
"LabelExpandSeries": "展开系列",
|
||||||
"LabelExpandSubSeries": "展开子系列",
|
"LabelExpandSubSeries": "展开子系列",
|
||||||
"LabelExplicit": "信息准确",
|
"LabelExplicit": "含成人内容",
|
||||||
"LabelExplicitChecked": "明确(已选中)",
|
"LabelExplicitChecked": "成人内容(已核实)",
|
||||||
"LabelExplicitUnchecked": "不明确 (未选中)",
|
"LabelExplicitUnchecked": "无成人内容 (未核实)",
|
||||||
"LabelExportOPML": "导出 OPML",
|
"LabelExportOPML": "导出 OPML",
|
||||||
"LabelFeedURL": "源 URL",
|
"LabelFeedURL": "源 URL",
|
||||||
"LabelFetchingMetadata": "正在获取元数据",
|
"LabelFetchingMetadata": "正在获取元数据",
|
||||||
"LabelFile": "文件",
|
"LabelFile": "文件",
|
||||||
"LabelFileBirthtime": "文件创建时间",
|
"LabelFileBirthtime": "文件创建时间",
|
||||||
"LabelFileBornDate": "生于 {0}",
|
"LabelFileBornDate": "添加于 {0}",
|
||||||
"LabelFileModified": "文件修改时间",
|
"LabelFileModified": "文件修改时间",
|
||||||
"LabelFileModifiedDate": "已修改 {0}",
|
"LabelFileModifiedDate": "已修改 {0}",
|
||||||
"LabelFilename": "文件名",
|
"LabelFilename": "文件名",
|
||||||
|
@ -482,7 +482,7 @@
|
||||||
"LabelPermanent": "永久的",
|
"LabelPermanent": "永久的",
|
||||||
"LabelPermissionsAccessAllLibraries": "可以访问所有媒体库",
|
"LabelPermissionsAccessAllLibraries": "可以访问所有媒体库",
|
||||||
"LabelPermissionsAccessAllTags": "可以访问所有标签",
|
"LabelPermissionsAccessAllTags": "可以访问所有标签",
|
||||||
"LabelPermissionsAccessExplicitContent": "可以访问显式内容",
|
"LabelPermissionsAccessExplicitContent": "可以访问成人内容",
|
||||||
"LabelPermissionsCreateEreader": "可以创建电子阅读器",
|
"LabelPermissionsCreateEreader": "可以创建电子阅读器",
|
||||||
"LabelPermissionsDelete": "可以删除",
|
"LabelPermissionsDelete": "可以删除",
|
||||||
"LabelPermissionsDownload": "可以下载",
|
"LabelPermissionsDownload": "可以下载",
|
||||||
|
@ -613,12 +613,12 @@
|
||||||
"LabelStartedAt": "从这开始",
|
"LabelStartedAt": "从这开始",
|
||||||
"LabelStatsAudioTracks": "音轨",
|
"LabelStatsAudioTracks": "音轨",
|
||||||
"LabelStatsAuthors": "作者",
|
"LabelStatsAuthors": "作者",
|
||||||
"LabelStatsBestDay": "最好的一天",
|
"LabelStatsBestDay": "单日最高",
|
||||||
"LabelStatsDailyAverage": "每日平均值",
|
"LabelStatsDailyAverage": "每日平均值",
|
||||||
"LabelStatsDays": "天",
|
"LabelStatsDays": "连续收听",
|
||||||
"LabelStatsDaysListened": "收听天数",
|
"LabelStatsDaysListened": "收听天数",
|
||||||
"LabelStatsHours": "小时",
|
"LabelStatsHours": "小时",
|
||||||
"LabelStatsInARow": "在一行",
|
"LabelStatsInARow": "天",
|
||||||
"LabelStatsItemsFinished": "已完成的项目",
|
"LabelStatsItemsFinished": "已完成的项目",
|
||||||
"LabelStatsItemsInLibrary": "媒体库中的项目",
|
"LabelStatsItemsInLibrary": "媒体库中的项目",
|
||||||
"LabelStatsMinutes": "分钟",
|
"LabelStatsMinutes": "分钟",
|
||||||
|
@ -714,7 +714,7 @@
|
||||||
"MessageBackupsLocationNoEditNote": "注意: 备份位置是通过环境变量设置的, 不能在此处更改.",
|
"MessageBackupsLocationNoEditNote": "注意: 备份位置是通过环境变量设置的, 不能在此处更改.",
|
||||||
"MessageBackupsLocationPathEmpty": "备份位置路径不能为空",
|
"MessageBackupsLocationPathEmpty": "备份位置路径不能为空",
|
||||||
"MessageBatchEditPopulateMapDetailsAllHelp": "使用所有项目的数据填充已启用的字段. 具有多个值的字段将被合并",
|
"MessageBatchEditPopulateMapDetailsAllHelp": "使用所有项目的数据填充已启用的字段. 具有多个值的字段将被合并",
|
||||||
"MessageBatchEditPopulateMapDetailsItemHelp": "使用此项目的数据填充已启用的地图详细信息字段",
|
"MessageBatchEditPopulateMapDetailsItemHelp": "提取此项目的信息,填入上方所有勾选的编辑框中",
|
||||||
"MessageBatchQuickMatchDescription": "快速匹配将尝试为所选项目添加缺少的封面和元数据. 启用以下选项以允许快速匹配覆盖现有封面和或元数据.",
|
"MessageBatchQuickMatchDescription": "快速匹配将尝试为所选项目添加缺少的封面和元数据. 启用以下选项以允许快速匹配覆盖现有封面和或元数据.",
|
||||||
"MessageBookshelfNoCollections": "你尚未进行任何收藏",
|
"MessageBookshelfNoCollections": "你尚未进行任何收藏",
|
||||||
"MessageBookshelfNoCollectionsHelp": "收藏是公开的. 所有有权访问图书馆的用户都可以看到它们.",
|
"MessageBookshelfNoCollectionsHelp": "收藏是公开的. 所有有权访问图书馆的用户都可以看到它们.",
|
||||||
|
@ -757,6 +757,7 @@
|
||||||
"MessageConfirmRemoveAuthor": "你确定要删除作者 \"{0}\"?",
|
"MessageConfirmRemoveAuthor": "你确定要删除作者 \"{0}\"?",
|
||||||
"MessageConfirmRemoveCollection": "你确定要移除收藏 \"{0}\"?",
|
"MessageConfirmRemoveCollection": "你确定要移除收藏 \"{0}\"?",
|
||||||
"MessageConfirmRemoveEpisode": "你确定要移除剧集 \"{0}\"?",
|
"MessageConfirmRemoveEpisode": "你确定要移除剧集 \"{0}\"?",
|
||||||
|
"MessageConfirmRemoveEpisodeNote": "注意:此操作不会删除音频文件,除非勾选“完全删除文件”选项",
|
||||||
"MessageConfirmRemoveEpisodes": "你确定要移除 {0} 剧集?",
|
"MessageConfirmRemoveEpisodes": "你确定要移除 {0} 剧集?",
|
||||||
"MessageConfirmRemoveListeningSessions": "你确定要移除 {0} 收听会话吗?",
|
"MessageConfirmRemoveListeningSessions": "你确定要移除 {0} 收听会话吗?",
|
||||||
"MessageConfirmRemoveMetadataFiles": "你确实要删除库项目文件夹中的所有 metadata.{0} 文件吗?",
|
"MessageConfirmRemoveMetadataFiles": "你确实要删除库项目文件夹中的所有 metadata.{0} 文件吗?",
|
||||||
|
@ -917,7 +918,9 @@
|
||||||
"NoteUploaderUnsupportedFiles": "不支持的文件将被忽略. 选择或删除文件夹时, 将忽略不在项目文件夹中的其他文件.",
|
"NoteUploaderUnsupportedFiles": "不支持的文件将被忽略. 选择或删除文件夹时, 将忽略不在项目文件夹中的其他文件.",
|
||||||
"NotificationOnBackupCompletedDescription": "备份完成时触发",
|
"NotificationOnBackupCompletedDescription": "备份完成时触发",
|
||||||
"NotificationOnBackupFailedDescription": "备份失败时触发",
|
"NotificationOnBackupFailedDescription": "备份失败时触发",
|
||||||
"NotificationOnEpisodeDownloadedDescription": "当播客节目自动下载时触发",
|
"NotificationOnEpisodeDownloadedDescription": "当播客节目自动下载完成时触发",
|
||||||
|
"NotificationOnRSSFeedDisabledDescription": "由于尝试失败次数过多而导致剧集自动下载被禁用时触发",
|
||||||
|
"NotificationOnRSSFeedFailedDescription": "当用于自动下载剧集的 RSS 源请求失败时触发",
|
||||||
"NotificationOnTestDescription": "测试通知系统的事件",
|
"NotificationOnTestDescription": "测试通知系统的事件",
|
||||||
"PlaceholderNewCollection": "输入收藏夹名称",
|
"PlaceholderNewCollection": "输入收藏夹名称",
|
||||||
"PlaceholderNewFolderPath": "输入文件夹路径",
|
"PlaceholderNewFolderPath": "输入文件夹路径",
|
||||||
|
|
7
index.js
7
index.js
|
@ -4,7 +4,9 @@ const optionDefinitions = [
|
||||||
{ name: 'port', alias: 'p', type: String },
|
{ name: 'port', alias: 'p', type: String },
|
||||||
{ name: 'host', alias: 'h', type: String },
|
{ name: 'host', alias: 'h', type: String },
|
||||||
{ name: 'source', alias: 's', type: String },
|
{ name: 'source', alias: 's', type: String },
|
||||||
{ name: 'dev', alias: 'd', type: Boolean }
|
{ name: 'dev', alias: 'd', type: Boolean },
|
||||||
|
// Run in production mode and use dev.js config
|
||||||
|
{ name: 'prod-with-dev-env', alias: 'r', type: Boolean }
|
||||||
]
|
]
|
||||||
|
|
||||||
const commandLineArgs = require('./server/libs/commandLineArgs')
|
const commandLineArgs = require('./server/libs/commandLineArgs')
|
||||||
|
@ -17,7 +19,7 @@ const server = require('./server/Server')
|
||||||
global.appRoot = __dirname
|
global.appRoot = __dirname
|
||||||
|
|
||||||
const isDev = process.env.NODE_ENV !== 'production'
|
const isDev = process.env.NODE_ENV !== 'production'
|
||||||
if (isDev) {
|
if (isDev || options['prod-with-dev-env']) {
|
||||||
const devEnv = require('./dev').config
|
const devEnv = require('./dev').config
|
||||||
if (devEnv.Port) process.env.PORT = devEnv.Port
|
if (devEnv.Port) process.env.PORT = devEnv.Port
|
||||||
if (devEnv.ConfigPath) process.env.CONFIG_PATH = devEnv.ConfigPath
|
if (devEnv.ConfigPath) process.env.CONFIG_PATH = devEnv.ConfigPath
|
||||||
|
@ -28,6 +30,7 @@ if (isDev) {
|
||||||
if (devEnv.SkipBinariesCheck) process.env.SKIP_BINARIES_CHECK = '1'
|
if (devEnv.SkipBinariesCheck) process.env.SKIP_BINARIES_CHECK = '1'
|
||||||
if (devEnv.AllowIframe) process.env.ALLOW_IFRAME = '1'
|
if (devEnv.AllowIframe) process.env.ALLOW_IFRAME = '1'
|
||||||
if (devEnv.BackupPath) process.env.BACKUP_PATH = devEnv.BackupPath
|
if (devEnv.BackupPath) process.env.BACKUP_PATH = devEnv.BackupPath
|
||||||
|
if (devEnv.ReactClientPath) process.env.REACT_CLIENT_PATH = devEnv.ReactClientPath
|
||||||
process.env.SOURCE = 'local'
|
process.env.SOURCE = 'local'
|
||||||
process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath ?? '/audiobookshelf'
|
process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath ?? '/audiobookshelf'
|
||||||
}
|
}
|
||||||
|
|
20
package-lock.json
generated
20
package-lock.json
generated
|
@ -1,17 +1,18 @@
|
||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.24.0",
|
"version": "2.26.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.24.0",
|
"version": "2.26.0",
|
||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^0.27.2",
|
"axios": "^0.27.2",
|
||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.6",
|
||||||
"express": "^4.17.1",
|
"express": "^4.17.1",
|
||||||
|
"express-rate-limit": "^7.5.1",
|
||||||
"express-session": "^1.17.3",
|
"express-session": "^1.17.3",
|
||||||
"graceful-fs": "^4.2.10",
|
"graceful-fs": "^4.2.10",
|
||||||
"htmlparser2": "^8.0.1",
|
"htmlparser2": "^8.0.1",
|
||||||
|
@ -1893,6 +1894,21 @@
|
||||||
"node": ">= 0.10.0"
|
"node": ">= 0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/express-rate-limit": {
|
||||||
|
"version": "7.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
|
||||||
|
"integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/express-rate-limit"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"express": ">= 4.11"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/express-session": {
|
"node_modules/express-session": {
|
||||||
"version": "1.17.3",
|
"version": "1.17.3",
|
||||||
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.17.3.tgz",
|
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.17.3.tgz",
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.24.0",
|
"version": "2.26.0",
|
||||||
"buildNumber": 1,
|
"buildNumber": 1,
|
||||||
"description": "Self-hosted audiobook and podcast server",
|
"description": "Self-hosted audiobook and podcast server",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "nodemon --watch server index.js -- --dev",
|
"dev": "nodemon --watch server index.js -- --dev",
|
||||||
"start": "node index.js",
|
"start": "node index.js",
|
||||||
|
"start-dev": "node index.js --prod-with-dev-env",
|
||||||
"client": "cd client && npm ci && npm run generate",
|
"client": "cd client && npm ci && npm run generate",
|
||||||
"prod": "npm run client && npm ci && node index.js",
|
"prod": "npm run client && npm ci && node index.js",
|
||||||
"build-win": "npm run client && pkg -t node20-win-x64 -o ./dist/win/audiobookshelf -C GZip .",
|
"build-win": "npm run client && pkg -t node20-win-x64 -o ./dist/win/audiobookshelf -C GZip .",
|
||||||
|
@ -39,6 +40,7 @@
|
||||||
"axios": "^0.27.2",
|
"axios": "^0.27.2",
|
||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.6",
|
||||||
"express": "^4.17.1",
|
"express": "^4.17.1",
|
||||||
|
"express-rate-limit": "^7.5.1",
|
||||||
"express-session": "^1.17.3",
|
"express-session": "^1.17.3",
|
||||||
"graceful-fs": "^4.2.10",
|
"graceful-fs": "^4.2.10",
|
||||||
"htmlparser2": "^8.0.1",
|
"htmlparser2": "^8.0.1",
|
||||||
|
|
906
server/Auth.js
906
server/Auth.js
File diff suppressed because it is too large
Load diff
|
@ -42,6 +42,16 @@ class Database {
|
||||||
return this.models.user
|
return this.models.user
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @type {typeof import('./models/Session')} */
|
||||||
|
get sessionModel() {
|
||||||
|
return this.models.session
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {typeof import('./models/ApiKey')} */
|
||||||
|
get apiKeyModel() {
|
||||||
|
return this.models.apiKey
|
||||||
|
}
|
||||||
|
|
||||||
/** @type {typeof import('./models/Library')} */
|
/** @type {typeof import('./models/Library')} */
|
||||||
get libraryModel() {
|
get libraryModel() {
|
||||||
return this.models.library
|
return this.models.library
|
||||||
|
@ -311,6 +321,8 @@ class Database {
|
||||||
|
|
||||||
buildModels(force = false) {
|
buildModels(force = false) {
|
||||||
require('./models/User').init(this.sequelize)
|
require('./models/User').init(this.sequelize)
|
||||||
|
require('./models/Session').init(this.sequelize)
|
||||||
|
require('./models/ApiKey').init(this.sequelize)
|
||||||
require('./models/Library').init(this.sequelize)
|
require('./models/Library').init(this.sequelize)
|
||||||
require('./models/LibraryFolder').init(this.sequelize)
|
require('./models/LibraryFolder').init(this.sequelize)
|
||||||
require('./models/Book').init(this.sequelize)
|
require('./models/Book').init(this.sequelize)
|
||||||
|
@ -656,6 +668,9 @@ class Database {
|
||||||
* Series should have atleast one Book
|
* Series should have atleast one Book
|
||||||
* Book and Podcast must have an associated LibraryItem (and vice versa)
|
* Book and Podcast must have an associated LibraryItem (and vice versa)
|
||||||
* Remove playback sessions that are 3 seconds or less
|
* Remove playback sessions that are 3 seconds or less
|
||||||
|
* Remove duplicate mediaProgresses
|
||||||
|
* Remove expired auth sessions
|
||||||
|
* Deactivate expired api keys
|
||||||
*/
|
*/
|
||||||
async cleanDatabase() {
|
async cleanDatabase() {
|
||||||
// Remove invalid Podcast records
|
// Remove invalid Podcast records
|
||||||
|
@ -765,6 +780,60 @@ class Database {
|
||||||
if (badSessionsRemoved > 0) {
|
if (badSessionsRemoved > 0) {
|
||||||
Logger.warn(`Removed ${badSessionsRemoved} sessions that were 3 seconds or less`)
|
Logger.warn(`Removed ${badSessionsRemoved} sessions that were 3 seconds or less`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove mediaProgresses with duplicate mediaItemId (remove the oldest updatedAt or if updatedAt is the same, remove arbitrary one)
|
||||||
|
const [duplicateMediaProgresses] = await this.sequelize.query(`SELECT mp1.id, mp1.mediaItemId
|
||||||
|
FROM mediaProgresses mp1
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM mediaProgresses mp2
|
||||||
|
WHERE mp2.mediaItemId = mp1.mediaItemId
|
||||||
|
AND mp2.userId = mp1.userId
|
||||||
|
AND (
|
||||||
|
mp2.updatedAt > mp1.updatedAt
|
||||||
|
OR (mp2.updatedAt = mp1.updatedAt AND mp2.id < mp1.id)
|
||||||
|
)
|
||||||
|
)`)
|
||||||
|
for (const duplicateMediaProgress of duplicateMediaProgresses) {
|
||||||
|
Logger.warn(`Found duplicate mediaProgress for mediaItem "${duplicateMediaProgress.mediaItemId}" - removing it`)
|
||||||
|
await this.mediaProgressModel.destroy({
|
||||||
|
where: { id: duplicateMediaProgress.id }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove expired Session records
|
||||||
|
await this.cleanupExpiredSessions()
|
||||||
|
|
||||||
|
// Deactivate expired api keys
|
||||||
|
await this.deactivateExpiredApiKeys()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deactivate expired api keys
|
||||||
|
*/
|
||||||
|
async deactivateExpiredApiKeys() {
|
||||||
|
try {
|
||||||
|
const affectedCount = await this.apiKeyModel.deactivateExpiredApiKeys()
|
||||||
|
if (affectedCount > 0) {
|
||||||
|
Logger.info(`[Database] Deactivated ${affectedCount} expired api keys`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`[Database] Error deactivating expired api keys: ${error.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up expired sessions from the database
|
||||||
|
*/
|
||||||
|
async cleanupExpiredSessions() {
|
||||||
|
try {
|
||||||
|
const deletedCount = await this.sessionModel.cleanupExpiredSessions()
|
||||||
|
if (deletedCount > 0) {
|
||||||
|
Logger.info(`[Database] Cleaned up ${deletedCount} expired sessions`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`[Database] Error cleaning up expired sessions: ${error.message}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async createTextSearchQuery(query) {
|
async createTextSearchQuery(query) {
|
||||||
|
|
|
@ -12,6 +12,7 @@ const { version } = require('../package.json')
|
||||||
|
|
||||||
// Utils
|
// Utils
|
||||||
const fileUtils = require('./utils/fileUtils')
|
const fileUtils = require('./utils/fileUtils')
|
||||||
|
const { toNumber } = require('./utils/index')
|
||||||
const Logger = require('./Logger')
|
const Logger = require('./Logger')
|
||||||
|
|
||||||
const Auth = require('./Auth')
|
const Auth = require('./Auth')
|
||||||
|
@ -84,12 +85,8 @@ class Server {
|
||||||
global.DisableSsrfRequestFilter = (url) => whitelistedUrls.includes(new URL(url).hostname)
|
global.DisableSsrfRequestFilter = (url) => whitelistedUrls.includes(new URL(url).hostname)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
global.PodcastDownloadTimeout = toNumber(process.env.PODCAST_DOWNLOAD_TIMEOUT, 30000)
|
||||||
if (process.env.PODCAST_DOWNLOAD_TIMEOUT) {
|
global.MaxFailedEpisodeChecks = toNumber(process.env.MAX_FAILED_EPISODE_CHECKS, 24)
|
||||||
global.PodcastDownloadTimeout = process.env.PODCAST_DOWNLOAD_TIMEOUT
|
|
||||||
} else {
|
|
||||||
global.PodcastDownloadTimeout = 30000
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fs.pathExistsSync(global.ConfigPath)) {
|
if (!fs.pathExistsSync(global.ConfigPath)) {
|
||||||
fs.mkdirSync(global.ConfigPath)
|
fs.mkdirSync(global.ConfigPath)
|
||||||
|
@ -159,14 +156,11 @@ class Server {
|
||||||
}
|
}
|
||||||
|
|
||||||
await Database.init(false)
|
await Database.init(false)
|
||||||
|
// Create or set JWT secret in token manager
|
||||||
|
await this.auth.tokenManager.initTokenSecret()
|
||||||
|
|
||||||
await Logger.logManager.init()
|
await Logger.logManager.init()
|
||||||
|
|
||||||
// Create token secret if does not exist (Added v2.1.0)
|
|
||||||
if (!Database.serverSettings.tokenSecret) {
|
|
||||||
await this.auth.initTokenSecret()
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.cleanUserData() // Remove invalid user item progress
|
await this.cleanUserData() // Remove invalid user item progress
|
||||||
await CacheManager.ensureCachePaths()
|
await CacheManager.ensureCachePaths()
|
||||||
|
|
||||||
|
@ -223,6 +217,7 @@ class Server {
|
||||||
|
|
||||||
async start() {
|
async start() {
|
||||||
Logger.info('=== Starting Server ===')
|
Logger.info('=== Starting Server ===')
|
||||||
|
|
||||||
this.initProcessEventListeners()
|
this.initProcessEventListeners()
|
||||||
await this.init()
|
await this.init()
|
||||||
|
|
||||||
|
@ -266,7 +261,7 @@ class Server {
|
||||||
// enable express-session
|
// enable express-session
|
||||||
app.use(
|
app.use(
|
||||||
expressSession({
|
expressSession({
|
||||||
secret: global.ServerSettings.tokenSecret,
|
secret: this.auth.tokenManager.TokenSecret,
|
||||||
resave: false,
|
resave: false,
|
||||||
saveUninitialized: false,
|
saveUninitialized: false,
|
||||||
cookie: {
|
cookie: {
|
||||||
|
@ -284,6 +279,7 @@ class Server {
|
||||||
await this.auth.initPassportJs()
|
await this.auth.initPassportJs()
|
||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
// if RouterBasePath is set, modify all requests to include the base path
|
// if RouterBasePath is set, modify all requests to include the base path
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
const urlStartsWithRouterBasePath = req.url.startsWith(global.RouterBasePath)
|
const urlStartsWithRouterBasePath = req.url.startsWith(global.RouterBasePath)
|
||||||
|
@ -310,16 +306,14 @@ class Server {
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
router.use(express.urlencoded({ extended: true, limit: '5mb' }))
|
router.use(express.urlencoded({ extended: true, limit: '5mb' }))
|
||||||
router.use(express.json({ limit: '10mb' }))
|
|
||||||
|
// Skip JSON parsing for internal-api routes
|
||||||
|
router.use(/^(?!\/internal-api).*/, express.json({ limit: '10mb' }))
|
||||||
|
|
||||||
router.use('/api', this.auth.ifAuthNeeded(this.authMiddleware.bind(this)), this.apiRouter.router)
|
router.use('/api', this.auth.ifAuthNeeded(this.authMiddleware.bind(this)), this.apiRouter.router)
|
||||||
router.use('/hls', this.hlsRouter.router)
|
router.use('/hls', this.hlsRouter.router)
|
||||||
router.use('/public', this.publicRouter.router)
|
router.use('/public', this.publicRouter.router)
|
||||||
|
|
||||||
// Static path to generated nuxt
|
|
||||||
const distPath = Path.join(global.appRoot, '/client/dist')
|
|
||||||
router.use(express.static(distPath))
|
|
||||||
|
|
||||||
// Static folder
|
// Static folder
|
||||||
router.use(express.static(Path.join(global.appRoot, 'static')))
|
router.use(express.static(Path.join(global.appRoot, 'static')))
|
||||||
|
|
||||||
|
@ -339,32 +333,6 @@ class Server {
|
||||||
// Auth routes
|
// Auth routes
|
||||||
await this.auth.initAuthRoutes(router)
|
await this.auth.initAuthRoutes(router)
|
||||||
|
|
||||||
// Client dynamic routes
|
|
||||||
const dynamicRoutes = [
|
|
||||||
'/item/:id',
|
|
||||||
'/author/:id',
|
|
||||||
'/audiobook/:id/chapters',
|
|
||||||
'/audiobook/:id/edit',
|
|
||||||
'/audiobook/:id/manage',
|
|
||||||
'/library/:library',
|
|
||||||
'/library/:library/search',
|
|
||||||
'/library/:library/bookshelf/:id?',
|
|
||||||
'/library/:library/authors',
|
|
||||||
'/library/:library/narrators',
|
|
||||||
'/library/:library/stats',
|
|
||||||
'/library/:library/series/:id?',
|
|
||||||
'/library/:library/podcast/search',
|
|
||||||
'/library/:library/podcast/latest',
|
|
||||||
'/library/:library/podcast/download-queue',
|
|
||||||
'/config/users/:id',
|
|
||||||
'/config/users/:id/sessions',
|
|
||||||
'/config/item-metadata-utils/:id',
|
|
||||||
'/collection/:id',
|
|
||||||
'/playlist/:id',
|
|
||||||
'/share/:slug'
|
|
||||||
]
|
|
||||||
dynamicRoutes.forEach((route) => router.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html'))))
|
|
||||||
|
|
||||||
router.post('/init', (req, res) => {
|
router.post('/init', (req, res) => {
|
||||||
if (Database.hasRootUser) {
|
if (Database.hasRootUser) {
|
||||||
Logger.error(`[Server] attempt to init server when server already has a root user`)
|
Logger.error(`[Server] attempt to init server when server already has a root user`)
|
||||||
|
@ -395,6 +363,49 @@ class Server {
|
||||||
})
|
})
|
||||||
router.get('/healthcheck', (req, res) => res.sendStatus(200))
|
router.get('/healthcheck', (req, res) => res.sendStatus(200))
|
||||||
|
|
||||||
|
const ReactClientPath = process.env.REACT_CLIENT_PATH
|
||||||
|
if (!ReactClientPath) {
|
||||||
|
// Static path to generated nuxt
|
||||||
|
const distPath = Path.join(global.appRoot, '/client/dist')
|
||||||
|
router.use(express.static(distPath))
|
||||||
|
|
||||||
|
// Client dynamic routes
|
||||||
|
const dynamicRoutes = [
|
||||||
|
'/item/:id',
|
||||||
|
'/author/:id',
|
||||||
|
'/audiobook/:id/chapters',
|
||||||
|
'/audiobook/:id/edit',
|
||||||
|
'/audiobook/:id/manage',
|
||||||
|
'/library/:library',
|
||||||
|
'/library/:library/search',
|
||||||
|
'/library/:library/bookshelf/:id?',
|
||||||
|
'/library/:library/authors',
|
||||||
|
'/library/:library/narrators',
|
||||||
|
'/library/:library/stats',
|
||||||
|
'/library/:library/series/:id?',
|
||||||
|
'/library/:library/podcast/search',
|
||||||
|
'/library/:library/podcast/latest',
|
||||||
|
'/library/:library/podcast/download-queue',
|
||||||
|
'/config/users/:id',
|
||||||
|
'/config/users/:id/sessions',
|
||||||
|
'/config/item-metadata-utils/:id',
|
||||||
|
'/collection/:id',
|
||||||
|
'/playlist/:id',
|
||||||
|
'/share/:slug'
|
||||||
|
]
|
||||||
|
dynamicRoutes.forEach((route) => router.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html'))))
|
||||||
|
} else {
|
||||||
|
// This is for using the experimental Next.js client
|
||||||
|
Logger.info(`Using React client at ${ReactClientPath}`)
|
||||||
|
const nextPath = Path.join(ReactClientPath, 'node_modules/next')
|
||||||
|
const next = require(nextPath)
|
||||||
|
const nextApp = next({ dev: Logger.isDev, dir: ReactClientPath })
|
||||||
|
const handle = nextApp.getRequestHandler()
|
||||||
|
await nextApp.prepare()
|
||||||
|
router.get('*', (req, res) => handle(req, res))
|
||||||
|
router.post('/internal-api/*', (req, res) => handle(req, res))
|
||||||
|
}
|
||||||
|
|
||||||
const unixSocketPrefix = 'unix/'
|
const unixSocketPrefix = 'unix/'
|
||||||
if (this.Host?.startsWith(unixSocketPrefix)) {
|
if (this.Host?.startsWith(unixSocketPrefix)) {
|
||||||
const sockPath = this.Host.slice(unixSocketPrefix.length)
|
const sockPath = this.Host.slice(unixSocketPrefix.length)
|
||||||
|
@ -417,7 +428,7 @@ class Server {
|
||||||
Logger.info(`[Server] Initializing new server`)
|
Logger.info(`[Server] Initializing new server`)
|
||||||
const newRoot = req.body.newRoot
|
const newRoot = req.body.newRoot
|
||||||
const rootUsername = newRoot.username || 'root'
|
const rootUsername = newRoot.username || 'root'
|
||||||
const rootPash = newRoot.password ? await this.auth.hashPass(newRoot.password) : ''
|
const rootPash = newRoot.password ? await this.auth.localAuthStrategy.hashPassword(newRoot.password) : ''
|
||||||
if (!rootPash) Logger.warn(`[Server] Creating root user with no password`)
|
if (!rootPash) Logger.warn(`[Server] Creating root user with no password`)
|
||||||
await Database.createRootUser(rootUsername, rootPash, this.auth)
|
await Database.createRootUser(rootUsername, rootPash, this.auth)
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
const SocketIO = require('socket.io')
|
const SocketIO = require('socket.io')
|
||||||
const Logger = require('./Logger')
|
const Logger = require('./Logger')
|
||||||
const Database = require('./Database')
|
const Database = require('./Database')
|
||||||
const Auth = require('./Auth')
|
const TokenManager = require('./auth/TokenManager')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef SocketClient
|
* @typedef SocketClient
|
||||||
|
@ -231,18 +231,22 @@ class SocketAuthority {
|
||||||
* When setting up a socket connection the user needs to be associated with a socket id
|
* When setting up a socket connection the user needs to be associated with a socket id
|
||||||
* for this the client will send a 'auth' event that includes the users API token
|
* for this the client will send a 'auth' event that includes the users API token
|
||||||
*
|
*
|
||||||
|
* Sends event 'init' to the socket. For admins this contains an array of users online.
|
||||||
|
* For failed authentication it sends event 'auth_failed' with a message
|
||||||
|
*
|
||||||
* @param {SocketIO.Socket} socket
|
* @param {SocketIO.Socket} socket
|
||||||
* @param {string} token JWT
|
* @param {string} token JWT
|
||||||
*/
|
*/
|
||||||
async authenticateSocket(socket, token) {
|
async authenticateSocket(socket, token) {
|
||||||
// we don't use passport to authenticate the jwt we get over the socket connection.
|
// we don't use passport to authenticate the jwt we get over the socket connection.
|
||||||
// it's easier to directly verify/decode it.
|
// it's easier to directly verify/decode it.
|
||||||
const token_data = Auth.validateAccessToken(token)
|
// TODO: Support API keys for web socket connections
|
||||||
|
const token_data = TokenManager.validateAccessToken(token)
|
||||||
|
|
||||||
if (!token_data?.userId) {
|
if (!token_data?.userId) {
|
||||||
// Token invalid
|
// Token invalid
|
||||||
Logger.error('Cannot validate socket - invalid token')
|
Logger.error('Cannot validate socket - invalid token')
|
||||||
return socket.emit('invalid_token')
|
return socket.emit('auth_failed', { message: 'Invalid token' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// get the user via the id from the decoded jwt.
|
// get the user via the id from the decoded jwt.
|
||||||
|
@ -250,7 +254,11 @@ class SocketAuthority {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
// user not found
|
// user not found
|
||||||
Logger.error('Cannot validate socket - invalid token')
|
Logger.error('Cannot validate socket - invalid token')
|
||||||
return socket.emit('invalid_token')
|
return socket.emit('auth_failed', { message: 'Invalid token' })
|
||||||
|
}
|
||||||
|
if (!user.isActive) {
|
||||||
|
Logger.error('Cannot validate socket - user is not active')
|
||||||
|
return socket.emit('auth_failed', { message: 'Invalid user' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = this.clients[socket.id]
|
const client = this.clients[socket.id]
|
||||||
|
@ -260,13 +268,18 @@ class SocketAuthority {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (client.user !== undefined) {
|
if (client.user !== undefined) {
|
||||||
Logger.debug(`[SocketAuthority] Authenticating socket client already has user`, client.user.username)
|
if (client.user.id === user.id) {
|
||||||
|
// Allow re-authentication of a socket to the same user
|
||||||
|
Logger.info(`[SocketAuthority] Authenticating socket already associated to user "${client.user.username}"`)
|
||||||
|
} else {
|
||||||
|
// Allow re-authentication of a socket to a different user but shouldn't happen
|
||||||
|
Logger.warn(`[SocketAuthority] Authenticating socket to user "${user.username}", but is already associated with a different user "${client.user.username}"`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Logger.debug(`[SocketAuthority] Authenticating socket to user "${user.username}"`)
|
||||||
}
|
}
|
||||||
|
|
||||||
client.user = user
|
client.user = user
|
||||||
|
|
||||||
Logger.debug(`[SocketAuthority] User Online ${client.user.username}`)
|
|
||||||
|
|
||||||
this.adminEmitter('user_online', client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions))
|
this.adminEmitter('user_online', client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions))
|
||||||
|
|
||||||
// Update user lastSeen without firing sequelize bulk update hooks
|
// Update user lastSeen without firing sequelize bulk update hooks
|
||||||
|
|
186
server/auth/LocalAuthStrategy.js
Normal file
186
server/auth/LocalAuthStrategy.js
Normal file
|
@ -0,0 +1,186 @@
|
||||||
|
const passport = require('passport')
|
||||||
|
const LocalStrategy = require('../libs/passportLocal')
|
||||||
|
const Database = require('../Database')
|
||||||
|
const Logger = require('../Logger')
|
||||||
|
|
||||||
|
const bcrypt = require('../libs/bcryptjs')
|
||||||
|
const requestIp = require('../libs/requestIp')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local authentication strategy using username/password
|
||||||
|
*/
|
||||||
|
class LocalAuthStrategy {
|
||||||
|
constructor() {
|
||||||
|
this.name = 'local'
|
||||||
|
this.strategy = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the passport strategy instance
|
||||||
|
* @returns {LocalStrategy}
|
||||||
|
*/
|
||||||
|
getStrategy() {
|
||||||
|
if (!this.strategy) {
|
||||||
|
this.strategy = new LocalStrategy({ passReqToCallback: true }, this.verifyCredentials.bind(this))
|
||||||
|
}
|
||||||
|
return this.strategy
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the strategy with passport
|
||||||
|
*/
|
||||||
|
init() {
|
||||||
|
passport.use(this.name, this.getStrategy())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the strategy from passport
|
||||||
|
*/
|
||||||
|
unuse() {
|
||||||
|
passport.unuse(this.name)
|
||||||
|
this.strategy = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify user credentials
|
||||||
|
* @param {import('express').Request} req
|
||||||
|
* @param {string} username
|
||||||
|
* @param {string} password
|
||||||
|
* @param {Function} done - Passport callback
|
||||||
|
*/
|
||||||
|
async verifyCredentials(req, username, password, done) {
|
||||||
|
// Load the user given it's username
|
||||||
|
const user = await Database.userModel.getUserByUsername(username.toLowerCase())
|
||||||
|
|
||||||
|
if (!user?.isActive) {
|
||||||
|
if (user) {
|
||||||
|
this.logFailedLoginAttempt(req, user.username, 'User is not active')
|
||||||
|
} else {
|
||||||
|
this.logFailedLoginAttempt(req, username, 'User not found')
|
||||||
|
}
|
||||||
|
done(null, null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check passwordless root user
|
||||||
|
if (user.type === 'root' && !user.pash) {
|
||||||
|
if (password) {
|
||||||
|
// deny login
|
||||||
|
this.logFailedLoginAttempt(req, user.username, 'Root user has no password set')
|
||||||
|
done(null, null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// approve login
|
||||||
|
Logger.info(`[LocalAuth] User "${user.username}" logged in from ip ${requestIp.getClientIp(req)}`)
|
||||||
|
|
||||||
|
done(null, user)
|
||||||
|
return
|
||||||
|
} else if (!user.pash) {
|
||||||
|
this.logFailedLoginAttempt(req, user.username, 'User has no password set. Might have been created with OpenID')
|
||||||
|
done(null, null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check password match
|
||||||
|
const compare = await bcrypt.compare(password, user.pash)
|
||||||
|
if (compare) {
|
||||||
|
// approve login
|
||||||
|
Logger.info(`[LocalAuth] User "${user.username}" logged in from ip ${requestIp.getClientIp(req)}`)
|
||||||
|
|
||||||
|
done(null, user)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// deny login
|
||||||
|
this.logFailedLoginAttempt(req, user.username, 'Invalid password')
|
||||||
|
done(null, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log failed login attempts
|
||||||
|
* @param {import('express').Request} req
|
||||||
|
* @param {string} username
|
||||||
|
* @param {string} message
|
||||||
|
*/
|
||||||
|
logFailedLoginAttempt(req, username, message) {
|
||||||
|
if (!req || !username || !message) return
|
||||||
|
Logger.error(`[LocalAuth] Failed login attempt for username "${username}" from ip ${requestIp.getClientIp(req)} (${message})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hash a password with bcrypt
|
||||||
|
* @param {string} password
|
||||||
|
* @returns {Promise<string>} hash
|
||||||
|
*/
|
||||||
|
hashPassword(password) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
bcrypt.hash(password, 8, (err, hash) => {
|
||||||
|
if (err) {
|
||||||
|
resolve(null)
|
||||||
|
} else {
|
||||||
|
resolve(hash)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare password with user's hashed password
|
||||||
|
* @param {string} password
|
||||||
|
* @param {import('../models/User')} user
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
comparePassword(password, user) {
|
||||||
|
if (user.type === 'root' && !password && !user.pash) return true
|
||||||
|
if (!password || !user.pash) return false
|
||||||
|
return bcrypt.compare(password, user.pash)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change user password
|
||||||
|
* @param {import('../models/User')} user
|
||||||
|
* @param {string} password
|
||||||
|
* @param {string} newPassword
|
||||||
|
*/
|
||||||
|
async changePassword(user, password, newPassword) {
|
||||||
|
// Only root can have an empty password
|
||||||
|
if (user.type !== 'root' && !newPassword) {
|
||||||
|
return {
|
||||||
|
error: 'Invalid new password - Only root can have an empty password'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check password match
|
||||||
|
const compare = await this.comparePassword(password, user)
|
||||||
|
if (!compare) {
|
||||||
|
return {
|
||||||
|
error: 'Invalid password'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let pw = ''
|
||||||
|
if (newPassword) {
|
||||||
|
pw = await this.hashPassword(newPassword)
|
||||||
|
if (!pw) {
|
||||||
|
return {
|
||||||
|
error: 'Hash failed'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await user.update({ pash: pw })
|
||||||
|
Logger.info(`[LocalAuth] User "${user.username}" changed password`)
|
||||||
|
return {
|
||||||
|
success: true
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`[LocalAuth] User "${user.username}" failed to change password`, error)
|
||||||
|
return {
|
||||||
|
error: 'Unknown error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = LocalAuthStrategy
|
488
server/auth/OidcAuthStrategy.js
Normal file
488
server/auth/OidcAuthStrategy.js
Normal file
|
@ -0,0 +1,488 @@
|
||||||
|
const { Request, Response } = require('express')
|
||||||
|
const passport = require('passport')
|
||||||
|
const OpenIDClient = require('openid-client')
|
||||||
|
const axios = require('axios')
|
||||||
|
const Database = require('../Database')
|
||||||
|
const Logger = require('../Logger')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenID Connect authentication strategy
|
||||||
|
*/
|
||||||
|
class OidcAuthStrategy {
|
||||||
|
constructor() {
|
||||||
|
this.name = 'openid-client'
|
||||||
|
this.strategy = null
|
||||||
|
this.client = null
|
||||||
|
// Map of openId sessions indexed by oauth2 state-variable
|
||||||
|
this.openIdAuthSession = new Map()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the passport strategy instance
|
||||||
|
* @returns {OpenIDClient.Strategy}
|
||||||
|
*/
|
||||||
|
getStrategy() {
|
||||||
|
if (!this.strategy) {
|
||||||
|
this.strategy = new OpenIDClient.Strategy(
|
||||||
|
{
|
||||||
|
client: this.getClient(),
|
||||||
|
params: {
|
||||||
|
redirect_uri: `${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`,
|
||||||
|
scope: this.getScope()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
this.verifyCallback.bind(this)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return this.strategy
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the OpenID Connect client
|
||||||
|
* @returns {OpenIDClient.Client}
|
||||||
|
*/
|
||||||
|
getClient() {
|
||||||
|
if (!this.client) {
|
||||||
|
if (!Database.serverSettings.isOpenIDAuthSettingsValid) {
|
||||||
|
throw new Error('OpenID Connect settings are not valid')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom req timeout see: https://github.com/panva/node-openid-client/blob/main/docs/README.md#customizing
|
||||||
|
OpenIDClient.custom.setHttpOptionsDefaults({ timeout: 10000 })
|
||||||
|
|
||||||
|
const openIdIssuerClient = new OpenIDClient.Issuer({
|
||||||
|
issuer: global.ServerSettings.authOpenIDIssuerURL,
|
||||||
|
authorization_endpoint: global.ServerSettings.authOpenIDAuthorizationURL,
|
||||||
|
token_endpoint: global.ServerSettings.authOpenIDTokenURL,
|
||||||
|
userinfo_endpoint: global.ServerSettings.authOpenIDUserInfoURL,
|
||||||
|
jwks_uri: global.ServerSettings.authOpenIDJwksURL,
|
||||||
|
end_session_endpoint: global.ServerSettings.authOpenIDLogoutURL
|
||||||
|
}).Client
|
||||||
|
|
||||||
|
this.client = new openIdIssuerClient({
|
||||||
|
client_id: global.ServerSettings.authOpenIDClientID,
|
||||||
|
client_secret: global.ServerSettings.authOpenIDClientSecret,
|
||||||
|
id_token_signed_response_alg: global.ServerSettings.authOpenIDTokenSigningAlgorithm
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return this.client
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the scope string for the OpenID Connect request
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
getScope() {
|
||||||
|
let scope = 'openid profile email'
|
||||||
|
if (global.ServerSettings.authOpenIDGroupClaim) {
|
||||||
|
scope += ' ' + global.ServerSettings.authOpenIDGroupClaim
|
||||||
|
}
|
||||||
|
if (global.ServerSettings.authOpenIDAdvancedPermsClaim) {
|
||||||
|
scope += ' ' + global.ServerSettings.authOpenIDAdvancedPermsClaim
|
||||||
|
}
|
||||||
|
return scope
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the strategy with passport
|
||||||
|
*/
|
||||||
|
init() {
|
||||||
|
if (!Database.serverSettings.isOpenIDAuthSettingsValid) {
|
||||||
|
Logger.error(`[OidcAuth] Cannot init openid auth strategy - invalid settings`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
passport.use(this.name, this.getStrategy())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the strategy from passport
|
||||||
|
*/
|
||||||
|
unuse() {
|
||||||
|
passport.unuse(this.name)
|
||||||
|
this.strategy = null
|
||||||
|
this.client = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify callback for OpenID Connect authentication
|
||||||
|
* @param {Object} tokenset
|
||||||
|
* @param {Object} userinfo
|
||||||
|
* @param {Function} done - Passport callback
|
||||||
|
*/
|
||||||
|
async verifyCallback(tokenset, userinfo, done) {
|
||||||
|
try {
|
||||||
|
Logger.debug(`[OidcAuth] openid callback userinfo=`, JSON.stringify(userinfo, null, 2))
|
||||||
|
|
||||||
|
if (!userinfo.sub) {
|
||||||
|
throw new Error('Invalid userinfo, no sub')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.validateGroupClaim(userinfo)) {
|
||||||
|
throw new Error(`Group claim ${Database.serverSettings.authOpenIDGroupClaim} not found or empty in userinfo`)
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = await Database.userModel.findOrCreateUserFromOpenIdUserInfo(userinfo)
|
||||||
|
|
||||||
|
if (!user?.isActive) {
|
||||||
|
throw new Error('User not active or not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.setUserGroup(user, userinfo)
|
||||||
|
await this.updateUserPermissions(user, userinfo)
|
||||||
|
|
||||||
|
// We also have to save the id_token for later (used for logout) because we cannot set cookies here
|
||||||
|
user.openid_id_token = tokenset.id_token
|
||||||
|
|
||||||
|
return done(null, user)
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`[OidcAuth] openid callback error: ${error?.message}\n${error?.stack}`)
|
||||||
|
return done(null, null, 'Unauthorized')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the presence and content of the group claim in userinfo.
|
||||||
|
* @param {Object} userinfo
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
validateGroupClaim(userinfo) {
|
||||||
|
const groupClaimName = Database.serverSettings.authOpenIDGroupClaim
|
||||||
|
if (!groupClaimName)
|
||||||
|
// Allow no group claim when configured like this
|
||||||
|
return true
|
||||||
|
|
||||||
|
// If configured it must exist in userinfo
|
||||||
|
if (!userinfo[groupClaimName]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the user group based on group claim in userinfo.
|
||||||
|
* @param {import('../models/User')} user
|
||||||
|
* @param {Object} userinfo
|
||||||
|
*/
|
||||||
|
async setUserGroup(user, userinfo) {
|
||||||
|
const groupClaimName = Database.serverSettings.authOpenIDGroupClaim
|
||||||
|
if (!groupClaimName)
|
||||||
|
// No group claim configured, don't set anything
|
||||||
|
return
|
||||||
|
|
||||||
|
if (!userinfo[groupClaimName]) throw new Error(`Group claim ${groupClaimName} not found in userinfo`)
|
||||||
|
|
||||||
|
const groupsList = userinfo[groupClaimName].map((group) => group.toLowerCase())
|
||||||
|
const rolesInOrderOfPriority = ['admin', 'user', 'guest']
|
||||||
|
|
||||||
|
let userType = rolesInOrderOfPriority.find((role) => groupsList.includes(role))
|
||||||
|
if (userType) {
|
||||||
|
if (user.type === 'root') {
|
||||||
|
// Check OpenID Group
|
||||||
|
if (userType !== 'admin') {
|
||||||
|
throw new Error(`Root user "${user.username}" cannot be downgraded to ${userType}. Denying login.`)
|
||||||
|
} else {
|
||||||
|
// If root user is logging in via OpenID, we will not change the type
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.type !== userType) {
|
||||||
|
Logger.info(`[OidcAuth] openid callback: Updating user "${user.username}" type to "${userType}" from "${user.type}"`)
|
||||||
|
user.type = userType
|
||||||
|
await user.save()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(`No valid group found in userinfo: ${JSON.stringify(userinfo[groupClaimName], null, 2)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates user permissions based on the advanced permissions claim.
|
||||||
|
* @param {import('../models/User')} user
|
||||||
|
* @param {Object} userinfo
|
||||||
|
*/
|
||||||
|
async updateUserPermissions(user, userinfo) {
|
||||||
|
const absPermissionsClaim = Database.serverSettings.authOpenIDAdvancedPermsClaim
|
||||||
|
if (!absPermissionsClaim)
|
||||||
|
// No advanced permissions claim configured, don't set anything
|
||||||
|
return
|
||||||
|
|
||||||
|
if (user.type === 'admin' || user.type === 'root') return
|
||||||
|
|
||||||
|
const absPermissions = userinfo[absPermissionsClaim]
|
||||||
|
if (!absPermissions) throw new Error(`Advanced permissions claim ${absPermissionsClaim} not found in userinfo`)
|
||||||
|
|
||||||
|
if (await user.updatePermissionsFromExternalJSON(absPermissions)) {
|
||||||
|
Logger.info(`[OidcAuth] openid callback: Updating advanced perms for user "${user.username}" using "${JSON.stringify(absPermissions)}"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate PKCE parameters for the authorization request
|
||||||
|
* @param {Request} req
|
||||||
|
* @param {boolean} isMobileFlow
|
||||||
|
* @returns {Object|{error: string}}
|
||||||
|
*/
|
||||||
|
generatePkce(req, isMobileFlow) {
|
||||||
|
if (isMobileFlow) {
|
||||||
|
if (!req.query.code_challenge) {
|
||||||
|
return {
|
||||||
|
error: 'code_challenge required for mobile flow (PKCE)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (req.query.code_challenge_method && req.query.code_challenge_method !== 'S256') {
|
||||||
|
return {
|
||||||
|
error: 'Only S256 code_challenge_method method supported'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
code_challenge: req.query.code_challenge,
|
||||||
|
code_challenge_method: req.query.code_challenge_method || 'S256'
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const code_verifier = OpenIDClient.generators.codeVerifier()
|
||||||
|
const code_challenge = OpenIDClient.generators.codeChallenge(code_verifier)
|
||||||
|
return { code_challenge, code_challenge_method: 'S256', code_verifier }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a redirect URI is valid
|
||||||
|
* @param {string} uri
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
isValidRedirectUri(uri) {
|
||||||
|
// Check if the redirect_uri is in the whitelist
|
||||||
|
return Database.serverSettings.authOpenIDMobileRedirectURIs.includes(uri) || (Database.serverSettings.authOpenIDMobileRedirectURIs.length === 1 && Database.serverSettings.authOpenIDMobileRedirectURIs[0] === '*')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the authorization URL for OpenID Connect
|
||||||
|
* Calls client manually because the strategy does not support forwarding the code challenge for the mobile flow
|
||||||
|
* @param {Request} req
|
||||||
|
* @returns {{ authorizationUrl: string }|{status: number, error: string}}
|
||||||
|
*/
|
||||||
|
getAuthorizationUrl(req) {
|
||||||
|
const client = this.getClient()
|
||||||
|
const strategy = this.getStrategy()
|
||||||
|
const sessionKey = strategy._key
|
||||||
|
|
||||||
|
try {
|
||||||
|
const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
|
||||||
|
const hostUrl = new URL(`${protocol}://${req.get('host')}`)
|
||||||
|
const isMobileFlow = req.query.response_type === 'code' || req.query.redirect_uri || req.query.code_challenge
|
||||||
|
|
||||||
|
// Only allow code flow (for mobile clients)
|
||||||
|
if (req.query.response_type && req.query.response_type !== 'code') {
|
||||||
|
Logger.debug(`[OidcAuth] OIDC Invalid response_type=${req.query.response_type}`)
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
error: 'Invalid response_type, only code supported'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a state on web flow or if no state supplied
|
||||||
|
const state = !isMobileFlow || !req.query.state ? OpenIDClient.generators.random() : req.query.state
|
||||||
|
|
||||||
|
// Redirect URL for the SSO provider
|
||||||
|
let redirectUri
|
||||||
|
if (isMobileFlow) {
|
||||||
|
// Mobile required redirect uri
|
||||||
|
// If it is in the whitelist, we will save into this.openIdAuthSession and set the redirect uri to /auth/openid/mobile-redirect
|
||||||
|
// where we will handle the redirect to it
|
||||||
|
if (!req.query.redirect_uri || !this.isValidRedirectUri(req.query.redirect_uri)) {
|
||||||
|
Logger.debug(`[OidcAuth] Invalid redirect_uri=${req.query.redirect_uri}`)
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
error: 'Invalid redirect_uri'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// We cannot save the supplied redirect_uri in the session, because it the mobile client uses browser instead of the API
|
||||||
|
// for the request to mobile-redirect and as such the session is not shared
|
||||||
|
this.openIdAuthSession.set(state, { mobile_redirect_uri: req.query.redirect_uri })
|
||||||
|
|
||||||
|
redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/mobile-redirect`, hostUrl).toString()
|
||||||
|
} else {
|
||||||
|
redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`, hostUrl).toString()
|
||||||
|
|
||||||
|
if (req.query.state) {
|
||||||
|
Logger.debug(`[OidcAuth] Invalid state - not allowed on web openid flow`)
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
error: 'Invalid state, not allowed on web flow'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the strategy's redirect_uri for this request
|
||||||
|
strategy._params.redirect_uri = redirectUri
|
||||||
|
Logger.debug(`[OidcAuth] OIDC redirect_uri=${redirectUri}`)
|
||||||
|
|
||||||
|
const pkceData = this.generatePkce(req, isMobileFlow)
|
||||||
|
if (pkceData.error) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
error: pkceData.error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req.session[sessionKey] = {
|
||||||
|
...req.session[sessionKey],
|
||||||
|
state: state,
|
||||||
|
max_age: strategy._params.max_age,
|
||||||
|
response_type: 'code',
|
||||||
|
code_verifier: pkceData.code_verifier, // not null if web flow
|
||||||
|
mobile: req.query.redirect_uri, // Used in the abs callback later, set mobile if redirect_uri is filled out
|
||||||
|
sso_redirect_uri: redirectUri // Save the redirect_uri (for the SSO Provider) for the callback
|
||||||
|
}
|
||||||
|
|
||||||
|
const authorizationUrl = client.authorizationUrl({
|
||||||
|
...strategy._params,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
state: state,
|
||||||
|
response_type: 'code',
|
||||||
|
scope: this.getScope(),
|
||||||
|
code_challenge: pkceData.code_challenge,
|
||||||
|
code_challenge_method: pkceData.code_challenge_method
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
authorizationUrl,
|
||||||
|
isMobileFlow
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`[OidcAuth] Error generating authorization URL: ${error}\n${error?.stack}`)
|
||||||
|
return {
|
||||||
|
status: 500,
|
||||||
|
error: error.message || 'Unknown error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the end session URL for logout
|
||||||
|
* @param {Request} req
|
||||||
|
* @param {string} idToken
|
||||||
|
* @param {string} authMethod
|
||||||
|
* @returns {string|null}
|
||||||
|
*/
|
||||||
|
getEndSessionUrl(req, idToken, authMethod) {
|
||||||
|
const client = this.getClient()
|
||||||
|
|
||||||
|
if (client.issuer.end_session_endpoint && client.issuer.end_session_endpoint.length > 0) {
|
||||||
|
let postLogoutRedirectUri = null
|
||||||
|
|
||||||
|
if (authMethod === 'openid') {
|
||||||
|
const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
|
||||||
|
const host = req.get('host')
|
||||||
|
// TODO: ABS does currently not support subfolders for installation
|
||||||
|
// If we want to support it we need to include a config for the serverurl
|
||||||
|
postLogoutRedirectUri = `${protocol}://${host}${global.RouterBasePath}/login`
|
||||||
|
}
|
||||||
|
// else for openid-mobile we keep postLogoutRedirectUri on null
|
||||||
|
// nice would be to redirect to the app here, but for example Authentik does not implement
|
||||||
|
// the post_logout_redirect_uri parameter at all and for other providers
|
||||||
|
// we would also need again to implement (and even before get to know somehow for 3rd party apps)
|
||||||
|
// the correct app link like audiobookshelf://login (and maybe also provide a redirect like mobile-redirect).
|
||||||
|
// Instead because its null (and this way the parameter will be omitted completly), the client/app can simply append something like
|
||||||
|
// &post_logout_redirect_uri=audiobookshelf://login to the received logout url by itself which is the simplest solution
|
||||||
|
// (The URL needs to be whitelisted in the config of the SSO/ID provider)
|
||||||
|
|
||||||
|
return client.endSessionUrl({
|
||||||
|
id_token_hint: idToken,
|
||||||
|
post_logout_redirect_uri: postLogoutRedirectUri
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} OpenIdIssuerConfig
|
||||||
|
* @property {string} issuer
|
||||||
|
* @property {string} authorization_endpoint
|
||||||
|
* @property {string} token_endpoint
|
||||||
|
* @property {string} userinfo_endpoint
|
||||||
|
* @property {string} end_session_endpoint
|
||||||
|
* @property {string} jwks_uri
|
||||||
|
* @property {string} id_token_signing_alg_values_supported
|
||||||
|
*
|
||||||
|
* Get OpenID Connect configuration from an issuer URL
|
||||||
|
* @param {string} issuerUrl
|
||||||
|
* @returns {Promise<OpenIdIssuerConfig|{status: number, error: string}>}
|
||||||
|
*/
|
||||||
|
async getIssuerConfig(issuerUrl) {
|
||||||
|
// Strip trailing slash
|
||||||
|
if (issuerUrl.endsWith('/')) issuerUrl = issuerUrl.slice(0, -1)
|
||||||
|
|
||||||
|
// Append config pathname and validate URL
|
||||||
|
let configUrl = null
|
||||||
|
try {
|
||||||
|
configUrl = new URL(`${issuerUrl}/.well-known/openid-configuration`)
|
||||||
|
if (!configUrl.pathname.endsWith('/.well-known/openid-configuration')) {
|
||||||
|
throw new Error('Invalid pathname')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`[OidcAuth] Failed to get openid configuration. Invalid URL "${configUrl}"`, error)
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
error: "Invalid request. Query param 'issuer' is invalid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await axios.get(configUrl.toString())
|
||||||
|
return {
|
||||||
|
issuer: data.issuer,
|
||||||
|
authorization_endpoint: data.authorization_endpoint,
|
||||||
|
token_endpoint: data.token_endpoint,
|
||||||
|
userinfo_endpoint: data.userinfo_endpoint,
|
||||||
|
end_session_endpoint: data.end_session_endpoint,
|
||||||
|
jwks_uri: data.jwks_uri,
|
||||||
|
id_token_signing_alg_values_supported: data.id_token_signing_alg_values_supported
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`[OidcAuth] Failed to get openid configuration at "${configUrl}"`, error)
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
error: 'Failed to get openid configuration'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle mobile redirect for OAuth2 callback
|
||||||
|
* @param {Request} req
|
||||||
|
* @param {Response} res
|
||||||
|
*/
|
||||||
|
handleMobileRedirect(req, res) {
|
||||||
|
try {
|
||||||
|
// Extract the state parameter from the request
|
||||||
|
const { state, code } = req.query
|
||||||
|
|
||||||
|
// Check if the state provided is in our list
|
||||||
|
if (!state || !this.openIdAuthSession.has(state)) {
|
||||||
|
Logger.error('[OidcAuth] /auth/openid/mobile-redirect route: State parameter mismatch')
|
||||||
|
return res.status(400).send('State parameter mismatch')
|
||||||
|
}
|
||||||
|
|
||||||
|
let mobile_redirect_uri = this.openIdAuthSession.get(state).mobile_redirect_uri
|
||||||
|
|
||||||
|
if (!mobile_redirect_uri) {
|
||||||
|
Logger.error('[OidcAuth] No redirect URI')
|
||||||
|
return res.status(400).send('No redirect URI')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.openIdAuthSession.delete(state)
|
||||||
|
|
||||||
|
const redirectUri = `${mobile_redirect_uri}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`
|
||||||
|
// Redirect to the overwrite URI saved in the map
|
||||||
|
res.redirect(redirectUri)
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`[OidcAuth] Error in /auth/openid/mobile-redirect route: ${error}\n${error?.stack}`)
|
||||||
|
res.status(500).send('Internal Server Error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = OidcAuthStrategy
|
418
server/auth/TokenManager.js
Normal file
418
server/auth/TokenManager.js
Normal file
|
@ -0,0 +1,418 @@
|
||||||
|
const { Op } = require('sequelize')
|
||||||
|
|
||||||
|
const Database = require('../Database')
|
||||||
|
const Logger = require('../Logger')
|
||||||
|
|
||||||
|
const requestIp = require('../libs/requestIp')
|
||||||
|
const jwt = require('../libs/jsonwebtoken')
|
||||||
|
|
||||||
|
class TokenManager {
|
||||||
|
/** @type {string} JWT secret key */
|
||||||
|
static TokenSecret = null
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
/** @type {number} Refresh token expiry in seconds */
|
||||||
|
this.RefreshTokenExpiry = parseInt(process.env.REFRESH_TOKEN_EXPIRY) || 7 * 24 * 60 * 60 // 7 days
|
||||||
|
/** @type {number} Access token expiry in seconds */
|
||||||
|
this.AccessTokenExpiry = parseInt(process.env.ACCESS_TOKEN_EXPIRY) || 12 * 60 * 60 // 12 hours
|
||||||
|
|
||||||
|
if (parseInt(process.env.REFRESH_TOKEN_EXPIRY) > 0) {
|
||||||
|
Logger.info(`[TokenManager] Refresh token expiry set from ENV variable to ${this.RefreshTokenExpiry} seconds`)
|
||||||
|
}
|
||||||
|
if (parseInt(process.env.ACCESS_TOKEN_EXPIRY) > 0) {
|
||||||
|
Logger.info(`[TokenManager] Access token expiry set from ENV variable to ${this.AccessTokenExpiry} seconds`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get TokenSecret() {
|
||||||
|
return TokenManager.TokenSecret
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token secret is used to sign and verify JWTs
|
||||||
|
* Set by ENV variable "JWT_SECRET_KEY" or generated and stored on server settings if not set
|
||||||
|
*/
|
||||||
|
async initTokenSecret() {
|
||||||
|
if (process.env.JWT_SECRET_KEY) {
|
||||||
|
// Use user supplied token secret
|
||||||
|
Logger.info('[TokenManager] JWT secret key set from ENV variable')
|
||||||
|
TokenManager.TokenSecret = process.env.JWT_SECRET_KEY
|
||||||
|
} else if (!Database.serverSettings.tokenSecret) {
|
||||||
|
// Generate new token secret and store it on server settings
|
||||||
|
Logger.info('[TokenManager] JWT secret key not found, generating one')
|
||||||
|
TokenManager.TokenSecret = require('crypto').randomBytes(256).toString('base64')
|
||||||
|
Database.serverSettings.tokenSecret = TokenManager.TokenSecret
|
||||||
|
await Database.updateServerSettings()
|
||||||
|
} else {
|
||||||
|
// Use existing token secret from server settings
|
||||||
|
TokenManager.TokenSecret = Database.serverSettings.tokenSecret
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the refresh token cookie
|
||||||
|
* @param {import('express').Request} req
|
||||||
|
* @param {import('express').Response} res
|
||||||
|
* @param {string} refreshToken
|
||||||
|
*/
|
||||||
|
setRefreshTokenCookie(req, res, refreshToken) {
|
||||||
|
res.cookie('refresh_token', refreshToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: req.secure || req.get('x-forwarded-proto') === 'https',
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: this.RefreshTokenExpiry * 1000,
|
||||||
|
path: '/'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to validate a jwt token for a given user
|
||||||
|
* Used to authenticate socket connections
|
||||||
|
* TODO: Support API keys for web socket connections
|
||||||
|
*
|
||||||
|
* @param {string} token
|
||||||
|
* @returns {Object} tokens data
|
||||||
|
*/
|
||||||
|
static validateAccessToken(token) {
|
||||||
|
try {
|
||||||
|
return jwt.verify(token, TokenManager.TokenSecret)
|
||||||
|
} catch (err) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a JWT token for a given user
|
||||||
|
* TODO: Old method with no expiration
|
||||||
|
* @deprecated
|
||||||
|
*
|
||||||
|
* @param {{ id:string, username:string }} user
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
static generateAccessToken(user) {
|
||||||
|
return jwt.sign({ userId: user.id, username: user.username }, TokenManager.TokenSecret)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to generate a jwt token for a given user
|
||||||
|
* TODO: Old method with no expiration
|
||||||
|
* @deprecated
|
||||||
|
*
|
||||||
|
* @param {{ id:string, username:string }} user
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
generateAccessToken(user) {
|
||||||
|
return TokenManager.generateAccessToken(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate access token for a given user
|
||||||
|
*
|
||||||
|
* @param {{ id:string, username:string }} user
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
generateTempAccessToken(user) {
|
||||||
|
const payload = {
|
||||||
|
userId: user.id,
|
||||||
|
username: user.username,
|
||||||
|
type: 'access'
|
||||||
|
}
|
||||||
|
const options = {
|
||||||
|
expiresIn: this.AccessTokenExpiry
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return jwt.sign(payload, TokenManager.TokenSecret, options)
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`[TokenManager] Error generating access token for user ${user.id}: ${error}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate refresh token for a given user
|
||||||
|
*
|
||||||
|
* @param {{ id:string, username:string }} user
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
generateRefreshToken(user) {
|
||||||
|
const payload = {
|
||||||
|
userId: user.id,
|
||||||
|
username: user.username,
|
||||||
|
type: 'refresh'
|
||||||
|
}
|
||||||
|
const options = {
|
||||||
|
expiresIn: this.RefreshTokenExpiry
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return jwt.sign(payload, TokenManager.TokenSecret, options)
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`[TokenManager] Error generating refresh token for user ${user.id}: ${error}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create tokens and session for a given user
|
||||||
|
*
|
||||||
|
* @param {{ id:string, username:string }} user
|
||||||
|
* @param {import('express').Request} req
|
||||||
|
* @returns {Promise<{ accessToken:string, refreshToken:string, session:import('../models/Session') }>}
|
||||||
|
*/
|
||||||
|
async createTokensAndSession(user, req) {
|
||||||
|
const ipAddress = requestIp.getClientIp(req)
|
||||||
|
const userAgent = req.headers['user-agent']
|
||||||
|
const accessToken = this.generateTempAccessToken(user)
|
||||||
|
const refreshToken = this.generateRefreshToken(user)
|
||||||
|
|
||||||
|
// Calculate expiration time for the refresh token
|
||||||
|
const expiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000)
|
||||||
|
|
||||||
|
const session = await Database.sessionModel.createSession(user.id, ipAddress, userAgent, refreshToken, expiresAt)
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
session
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotate tokens for a given session
|
||||||
|
*
|
||||||
|
* @param {import('../models/Session')} session
|
||||||
|
* @param {import('../models/User')} user
|
||||||
|
* @param {import('express').Request} req
|
||||||
|
* @param {import('express').Response} res
|
||||||
|
* @returns {Promise<{ accessToken:string, refreshToken:string }>}
|
||||||
|
*/
|
||||||
|
async rotateTokensForSession(session, user, req, res) {
|
||||||
|
// Generate new tokens
|
||||||
|
const newAccessToken = this.generateTempAccessToken(user)
|
||||||
|
const newRefreshToken = this.generateRefreshToken(user)
|
||||||
|
|
||||||
|
// Calculate new expiration time
|
||||||
|
const newExpiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000)
|
||||||
|
|
||||||
|
// Update the session with the new refresh token and expiration
|
||||||
|
session.refreshToken = newRefreshToken
|
||||||
|
session.expiresAt = newExpiresAt
|
||||||
|
await session.save()
|
||||||
|
|
||||||
|
// Set new refresh token cookie
|
||||||
|
this.setRefreshTokenCookie(req, res, newRefreshToken)
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken: newAccessToken,
|
||||||
|
refreshToken: newRefreshToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the jwt is valid
|
||||||
|
*
|
||||||
|
* @param {Object} jwt_payload
|
||||||
|
* @param {Function} done - passportjs callback
|
||||||
|
*/
|
||||||
|
async jwtAuthCheck(jwt_payload, done) {
|
||||||
|
if (jwt_payload.type === 'api') {
|
||||||
|
// Api key based authentication
|
||||||
|
const apiKey = await Database.apiKeyModel.getById(jwt_payload.keyId)
|
||||||
|
|
||||||
|
if (!apiKey?.isActive) {
|
||||||
|
done(null, null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the api key is expired and deactivate it
|
||||||
|
if (jwt_payload.exp && jwt_payload.exp < Date.now() / 1000) {
|
||||||
|
done(null, null)
|
||||||
|
|
||||||
|
apiKey.isActive = false
|
||||||
|
await apiKey.save()
|
||||||
|
Logger.info(`[TokenManager] API key ${apiKey.id} is expired - deactivated`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await Database.userModel.getUserById(apiKey.userId)
|
||||||
|
done(null, user)
|
||||||
|
} else {
|
||||||
|
// JWT based authentication
|
||||||
|
|
||||||
|
// Check if the jwt is expired
|
||||||
|
if (jwt_payload.exp && jwt_payload.exp < Date.now() / 1000) {
|
||||||
|
done(null, null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// load user by id from the jwt token
|
||||||
|
const user = await Database.userModel.getUserByIdOrOldId(jwt_payload.userId)
|
||||||
|
|
||||||
|
if (!user?.isActive) {
|
||||||
|
// deny login
|
||||||
|
done(null, null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Temporary flag to report old tokens to users
|
||||||
|
// May be a better place for this but here means we dont have to decode the token again
|
||||||
|
if (!jwt_payload.exp && !user.isOldToken) {
|
||||||
|
Logger.debug(`[TokenManager] User ${user.username} is using an access token without an expiration`)
|
||||||
|
user.isOldToken = true
|
||||||
|
} else if (jwt_payload.exp && user.isOldToken !== undefined) {
|
||||||
|
delete user.isOldToken
|
||||||
|
}
|
||||||
|
|
||||||
|
// approve login
|
||||||
|
done(null, user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle refresh token
|
||||||
|
*
|
||||||
|
* @param {string} refreshToken
|
||||||
|
* @param {import('express').Request} req
|
||||||
|
* @param {import('express').Response} res
|
||||||
|
* @returns {Promise<{ accessToken?:string, refreshToken?:string, user?:import('../models/User'), error?:string }>}
|
||||||
|
*/
|
||||||
|
async handleRefreshToken(refreshToken, req, res) {
|
||||||
|
try {
|
||||||
|
// Verify the refresh token
|
||||||
|
const decoded = jwt.verify(refreshToken, TokenManager.TokenSecret)
|
||||||
|
|
||||||
|
if (decoded.type !== 'refresh') {
|
||||||
|
Logger.error(`[TokenManager] Failed to refresh token. Invalid token type: ${decoded.type}`)
|
||||||
|
return {
|
||||||
|
error: 'Invalid token type'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await Database.sessionModel.findOne({
|
||||||
|
where: { refreshToken: refreshToken }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
Logger.error(`[TokenManager] Failed to refresh token. Session not found for refresh token: ${refreshToken}`)
|
||||||
|
return {
|
||||||
|
error: 'Invalid refresh token'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if session is expired in database
|
||||||
|
if (session.expiresAt < new Date()) {
|
||||||
|
Logger.info(`[TokenManager] Session expired in database, cleaning up`)
|
||||||
|
await session.destroy()
|
||||||
|
return {
|
||||||
|
error: 'Refresh token expired'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await Database.userModel.getUserById(decoded.userId)
|
||||||
|
if (!user?.isActive) {
|
||||||
|
Logger.error(`[TokenManager] Failed to refresh token. User not found or inactive for user id: ${decoded.userId}`)
|
||||||
|
return {
|
||||||
|
error: 'User not found or inactive'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTokens = await this.rotateTokensForSession(session, user, req, res)
|
||||||
|
return {
|
||||||
|
accessToken: newTokens.accessToken,
|
||||||
|
refreshToken: newTokens.refreshToken,
|
||||||
|
user
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === 'TokenExpiredError') {
|
||||||
|
Logger.info(`[TokenManager] Refresh token expired, cleaning up session`)
|
||||||
|
|
||||||
|
// Clean up the expired session from database
|
||||||
|
try {
|
||||||
|
await Database.sessionModel.destroy({
|
||||||
|
where: { refreshToken: refreshToken }
|
||||||
|
})
|
||||||
|
Logger.info(`[TokenManager] Expired session cleaned up`)
|
||||||
|
} catch (cleanupError) {
|
||||||
|
Logger.error(`[TokenManager] Error cleaning up expired session: ${cleanupError.message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
error: 'Refresh token expired'
|
||||||
|
}
|
||||||
|
} else if (error.name === 'JsonWebTokenError') {
|
||||||
|
Logger.error(`[TokenManager] Invalid refresh token format: ${error.message}`)
|
||||||
|
return {
|
||||||
|
error: 'Invalid refresh token'
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Logger.error(`[TokenManager] Refresh token error: ${error.message}`)
|
||||||
|
return {
|
||||||
|
error: 'Invalid refresh token'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate all JWT sessions for a given user
|
||||||
|
* If user is current user and refresh token is valid, rotate tokens for the current session
|
||||||
|
*
|
||||||
|
* @param {import('../models/User')} user
|
||||||
|
* @param {import('express').Request} req
|
||||||
|
* @param {import('express').Response} res
|
||||||
|
* @returns {Promise<string>} accessToken only if user is current user and refresh token is valid
|
||||||
|
*/
|
||||||
|
async invalidateJwtSessionsForUser(user, req, res) {
|
||||||
|
const currentRefreshToken = req.cookies.refresh_token
|
||||||
|
if (req.user.id === user.id && currentRefreshToken) {
|
||||||
|
// Current user is the same as the user to invalidate sessions for
|
||||||
|
// So rotate token for current session
|
||||||
|
const currentSession = await Database.sessionModel.findOne({ where: { refreshToken: currentRefreshToken } })
|
||||||
|
if (currentSession) {
|
||||||
|
const newTokens = await this.rotateTokensForSession(currentSession, user, req, res)
|
||||||
|
|
||||||
|
// Invalidate all sessions for the user except the current one
|
||||||
|
await Database.sessionModel.destroy({
|
||||||
|
where: {
|
||||||
|
id: {
|
||||||
|
[Op.ne]: currentSession.id
|
||||||
|
},
|
||||||
|
userId: user.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return newTokens.accessToken
|
||||||
|
} else {
|
||||||
|
Logger.error(`[TokenManager] No session found to rotate tokens for refresh token ${currentRefreshToken}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current user is not the same as the user to invalidate sessions for (or no refresh token)
|
||||||
|
// So invalidate all sessions for the user
|
||||||
|
await Database.sessionModel.destroy({ where: { userId: user.id } })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate a refresh token - used for logout
|
||||||
|
*
|
||||||
|
* @param {string} refreshToken
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async invalidateRefreshToken(refreshToken) {
|
||||||
|
if (!refreshToken) {
|
||||||
|
Logger.error(`[TokenManager] No refresh token provided to invalidate`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const numDeleted = await Database.sessionModel.destroy({ where: { refreshToken: refreshToken } })
|
||||||
|
Logger.info(`[TokenManager] Refresh token ${refreshToken} invalidated, ${numDeleted} sessions deleted`)
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`[TokenManager] Error invalidating refresh token: ${error.message}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = TokenManager
|
207
server/controllers/ApiKeyController.js
Normal file
207
server/controllers/ApiKeyController.js
Normal file
|
@ -0,0 +1,207 @@
|
||||||
|
const { Request, Response, NextFunction } = require('express')
|
||||||
|
const uuidv4 = require('uuid').v4
|
||||||
|
const Logger = require('../Logger')
|
||||||
|
const Database = require('../Database')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef RequestUserObject
|
||||||
|
* @property {import('../models/User')} user
|
||||||
|
*
|
||||||
|
* @typedef {Request & RequestUserObject} RequestWithUser
|
||||||
|
*/
|
||||||
|
|
||||||
|
class ApiKeyController {
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET: /api/api-keys
|
||||||
|
*
|
||||||
|
* @param {RequestWithUser} req
|
||||||
|
* @param {Response} res
|
||||||
|
*/
|
||||||
|
async getAll(req, res) {
|
||||||
|
const apiKeys = await Database.apiKeyModel.findAll({
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Database.userModel,
|
||||||
|
attributes: ['id', 'username', 'type']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: Database.userModel,
|
||||||
|
as: 'createdByUser',
|
||||||
|
attributes: ['id', 'username', 'type']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
apiKeys: apiKeys.map((a) => a.toJSON())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST: /api/api-keys
|
||||||
|
*
|
||||||
|
* @this {import('../routers/ApiRouter')}
|
||||||
|
*
|
||||||
|
* @param {RequestWithUser} req
|
||||||
|
* @param {Response} res
|
||||||
|
*/
|
||||||
|
async create(req, res) {
|
||||||
|
if (!req.body.name || typeof req.body.name !== 'string') {
|
||||||
|
Logger.warn(`[ApiKeyController] create: Invalid name: ${req.body.name}`)
|
||||||
|
return res.sendStatus(400)
|
||||||
|
}
|
||||||
|
if (req.body.expiresIn && (typeof req.body.expiresIn !== 'number' || req.body.expiresIn <= 0)) {
|
||||||
|
Logger.warn(`[ApiKeyController] create: Invalid expiresIn: ${req.body.expiresIn}`)
|
||||||
|
return res.sendStatus(400)
|
||||||
|
}
|
||||||
|
if (!req.body.userId || typeof req.body.userId !== 'string') {
|
||||||
|
Logger.warn(`[ApiKeyController] create: Invalid userId: ${req.body.userId}`)
|
||||||
|
return res.sendStatus(400)
|
||||||
|
}
|
||||||
|
const user = await Database.userModel.getUserById(req.body.userId)
|
||||||
|
if (!user) {
|
||||||
|
Logger.warn(`[ApiKeyController] create: User not found: ${req.body.userId}`)
|
||||||
|
return res.sendStatus(400)
|
||||||
|
}
|
||||||
|
if (user.type === 'root' && !req.user.isRoot) {
|
||||||
|
Logger.warn(`[ApiKeyController] create: Root user API key cannot be created by non-root user`)
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyId = uuidv4() // Generate key id ahead of time to use in JWT
|
||||||
|
const apiKey = await Database.apiKeyModel.generateApiKey(this.auth.tokenManager.TokenSecret, keyId, req.body.name, req.body.expiresIn)
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
Logger.error(`[ApiKeyController] create: Error generating API key`)
|
||||||
|
return res.sendStatus(500)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate expiration time for the api key
|
||||||
|
const expiresAt = req.body.expiresIn ? new Date(Date.now() + req.body.expiresIn * 1000) : null
|
||||||
|
|
||||||
|
const apiKeyInstance = await Database.apiKeyModel.create({
|
||||||
|
id: keyId,
|
||||||
|
name: req.body.name,
|
||||||
|
expiresAt,
|
||||||
|
userId: req.body.userId,
|
||||||
|
isActive: !!req.body.isActive,
|
||||||
|
createdByUserId: req.user.id
|
||||||
|
})
|
||||||
|
apiKeyInstance.dataValues.user = await apiKeyInstance.getUser({
|
||||||
|
attributes: ['id', 'username', 'type']
|
||||||
|
})
|
||||||
|
|
||||||
|
Logger.info(`[ApiKeyController] Created API key "${apiKeyInstance.name}"`)
|
||||||
|
return res.json({
|
||||||
|
apiKey: {
|
||||||
|
apiKey, // Actual key only shown to user on creation
|
||||||
|
...apiKeyInstance.toJSON()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH: /api/api-keys/:id
|
||||||
|
* Only isActive and userId can be updated because name and expiresIn are in the JWT
|
||||||
|
*
|
||||||
|
* @param {RequestWithUser} req
|
||||||
|
* @param {Response} res
|
||||||
|
*/
|
||||||
|
async update(req, res) {
|
||||||
|
const apiKey = await Database.apiKeyModel.findByPk(req.params.id, {
|
||||||
|
include: {
|
||||||
|
model: Database.userModel
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (!apiKey) {
|
||||||
|
return res.sendStatus(404)
|
||||||
|
}
|
||||||
|
// Only root user can update root user API keys
|
||||||
|
if (apiKey.user.type === 'root' && !req.user.isRoot) {
|
||||||
|
Logger.warn(`[ApiKeyController] update: Root user API key cannot be updated by non-root user`)
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasUpdates = false
|
||||||
|
if (req.body.userId !== undefined) {
|
||||||
|
if (typeof req.body.userId !== 'string') {
|
||||||
|
Logger.warn(`[ApiKeyController] update: Invalid userId: ${req.body.userId}`)
|
||||||
|
return res.sendStatus(400)
|
||||||
|
}
|
||||||
|
const user = await Database.userModel.getUserById(req.body.userId)
|
||||||
|
if (!user) {
|
||||||
|
Logger.warn(`[ApiKeyController] update: User not found: ${req.body.userId}`)
|
||||||
|
return res.sendStatus(400)
|
||||||
|
}
|
||||||
|
if (user.type === 'root' && !req.user.isRoot) {
|
||||||
|
Logger.warn(`[ApiKeyController] update: Root user API key cannot be created by non-root user`)
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
|
if (apiKey.userId !== req.body.userId) {
|
||||||
|
apiKey.userId = req.body.userId
|
||||||
|
hasUpdates = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.body.isActive !== undefined) {
|
||||||
|
if (typeof req.body.isActive !== 'boolean') {
|
||||||
|
return res.sendStatus(400)
|
||||||
|
}
|
||||||
|
if (apiKey.isActive !== req.body.isActive) {
|
||||||
|
apiKey.isActive = req.body.isActive
|
||||||
|
hasUpdates = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasUpdates) {
|
||||||
|
await apiKey.save()
|
||||||
|
apiKey.dataValues.user = await apiKey.getUser({
|
||||||
|
attributes: ['id', 'username', 'type']
|
||||||
|
})
|
||||||
|
Logger.info(`[ApiKeyController] Updated API key "${apiKey.name}"`)
|
||||||
|
} else {
|
||||||
|
Logger.info(`[ApiKeyController] No updates needed to API key "${apiKey.name}"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
apiKey: apiKey.toJSON()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE: /api/api-keys/:id
|
||||||
|
*
|
||||||
|
* @param {RequestWithUser} req
|
||||||
|
* @param {Response} res
|
||||||
|
*/
|
||||||
|
async delete(req, res) {
|
||||||
|
const apiKey = await Database.apiKeyModel.findByPk(req.params.id)
|
||||||
|
if (!apiKey) {
|
||||||
|
return res.sendStatus(404)
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiKey.destroy()
|
||||||
|
Logger.info(`[ApiKeyController] Deleted API key "${apiKey.name}"`)
|
||||||
|
|
||||||
|
return res.sendStatus(200)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {RequestWithUser} req
|
||||||
|
* @param {Response} res
|
||||||
|
* @param {NextFunction} next
|
||||||
|
*/
|
||||||
|
middleware(req, res, next) {
|
||||||
|
if (!req.user.isAdminOrUp) {
|
||||||
|
Logger.error(`[ApiKeyController] Non-admin user "${req.user.username}" attempting to access api keys`)
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
|
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new ApiKeyController()
|
|
@ -89,7 +89,6 @@ class FileSystemController {
|
||||||
}
|
}
|
||||||
|
|
||||||
const { directory, folderPath } = req.body
|
const { directory, folderPath } = req.body
|
||||||
|
|
||||||
if (!directory?.length || typeof directory !== 'string' || !folderPath?.length || typeof folderPath !== 'string') {
|
if (!directory?.length || typeof directory !== 'string' || !folderPath?.length || typeof folderPath !== 'string') {
|
||||||
Logger.error(`[FileSystemController] Invalid request body: ${JSON.stringify(req.body)}`)
|
Logger.error(`[FileSystemController] Invalid request body: ${JSON.stringify(req.body)}`)
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
|
@ -109,7 +108,14 @@ class FileSystemController {
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
}
|
}
|
||||||
|
|
||||||
const filepath = Path.posix.join(libraryFolder.path, directory)
|
if (!req.user.checkCanAccessLibrary(libraryFolder.libraryId)) {
|
||||||
|
Logger.error(`[FileSystemController] User "${req.user.username}" attempting to check path exists for library "${libraryFolder.libraryId}" without access`)
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
|
|
||||||
|
let filepath = Path.join(libraryFolder.path, directory)
|
||||||
|
filepath = fileUtils.filePathToPOSIX(filepath)
|
||||||
|
|
||||||
// Ensure filepath is inside library folder (prevents directory traversal)
|
// Ensure filepath is inside library folder (prevents directory traversal)
|
||||||
if (!filepath.startsWith(libraryFolder.path)) {
|
if (!filepath.startsWith(libraryFolder.path)) {
|
||||||
Logger.error(`[FileSystemController] Filepath is not inside library folder: ${filepath}`)
|
Logger.error(`[FileSystemController] Filepath is not inside library folder: ${filepath}`)
|
||||||
|
|
|
@ -273,12 +273,24 @@ class MeController {
|
||||||
* @param {RequestWithUser} req
|
* @param {RequestWithUser} req
|
||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
updatePassword(req, res) {
|
async updatePassword(req, res) {
|
||||||
if (req.user.isGuest) {
|
if (req.user.isGuest) {
|
||||||
Logger.error(`[MeController] Guest user "${req.user.username}" attempted to change password`)
|
Logger.error(`[MeController] Guest user "${req.user.username}" attempted to change password`)
|
||||||
return res.sendStatus(500)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
this.auth.userChangePassword(req, res)
|
|
||||||
|
const { password, newPassword } = req.body
|
||||||
|
if (!password || !newPassword || typeof password !== 'string' || typeof newPassword !== 'string') {
|
||||||
|
return res.status(400).send('Missing or invalid password or new password')
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.auth.localAuthStrategy.changePassword(req.user, password, newPassword)
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
return res.status(400).send(result.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -59,6 +59,12 @@ class MiscController {
|
||||||
if (!library) {
|
if (!library) {
|
||||||
return res.status(404).send('Library not found')
|
return res.status(404).send('Library not found')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!req.user.checkCanAccessLibrary(library.id)) {
|
||||||
|
Logger.error(`[MiscController] User "${req.user.username}" attempting to upload to library "${library.id}" without access`)
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
|
|
||||||
const folder = library.libraryFolders.find((fold) => fold.id === folderId)
|
const folder = library.libraryFolders.find((fold) => fold.id === folderId)
|
||||||
if (!folder) {
|
if (!folder) {
|
||||||
return res.status(404).send('Folder not found')
|
return res.status(404).send('Folder not found')
|
||||||
|
|
|
@ -127,8 +127,8 @@ class UserController {
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = uuidv4()
|
const userId = uuidv4()
|
||||||
const pash = await this.auth.hashPass(req.body.password)
|
const pash = await this.auth.localAuthStrategy.hashPassword(req.body.password)
|
||||||
const token = await this.auth.generateAccessToken({ id: userId, username: req.body.username })
|
const token = this.auth.generateAccessToken({ id: userId, username: req.body.username })
|
||||||
const userType = req.body.type || 'user'
|
const userType = req.body.type || 'user'
|
||||||
|
|
||||||
// librariesAccessible and itemTagsSelected can be on req.body or req.body.permissions
|
// librariesAccessible and itemTagsSelected can be on req.body or req.body.permissions
|
||||||
|
@ -237,6 +237,7 @@ class UserController {
|
||||||
|
|
||||||
let hasUpdates = false
|
let hasUpdates = false
|
||||||
let shouldUpdateToken = false
|
let shouldUpdateToken = false
|
||||||
|
let shouldInvalidateJwtSessions = false
|
||||||
// When changing username create a new API token
|
// When changing username create a new API token
|
||||||
if (updatePayload.username && updatePayload.username !== user.username) {
|
if (updatePayload.username && updatePayload.username !== user.username) {
|
||||||
const usernameExists = await Database.userModel.checkUserExistsWithUsername(updatePayload.username)
|
const usernameExists = await Database.userModel.checkUserExistsWithUsername(updatePayload.username)
|
||||||
|
@ -245,12 +246,13 @@ class UserController {
|
||||||
}
|
}
|
||||||
user.username = updatePayload.username
|
user.username = updatePayload.username
|
||||||
shouldUpdateToken = true
|
shouldUpdateToken = true
|
||||||
|
shouldInvalidateJwtSessions = true
|
||||||
hasUpdates = true
|
hasUpdates = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Updating password
|
// Updating password
|
||||||
if (updatePayload.password) {
|
if (updatePayload.password) {
|
||||||
user.pash = await this.auth.hashPass(updatePayload.password)
|
user.pash = await this.auth.localAuthStrategy.hashPassword(updatePayload.password)
|
||||||
hasUpdates = true
|
hasUpdates = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -325,9 +327,24 @@ class UserController {
|
||||||
|
|
||||||
if (hasUpdates) {
|
if (hasUpdates) {
|
||||||
if (shouldUpdateToken) {
|
if (shouldUpdateToken) {
|
||||||
user.token = await this.auth.generateAccessToken(user)
|
user.token = this.auth.generateAccessToken(user)
|
||||||
Logger.info(`[UserController] User ${user.username} has generated a new api token`)
|
Logger.info(`[UserController] User ${user.username} has generated a new api token`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle JWT session invalidation for username changes
|
||||||
|
if (shouldInvalidateJwtSessions) {
|
||||||
|
const newAccessToken = await this.auth.invalidateJwtSessionsForUser(user, req, res)
|
||||||
|
if (newAccessToken) {
|
||||||
|
user.accessToken = newAccessToken
|
||||||
|
// Refresh tokens are only returned for mobile clients
|
||||||
|
// Mobile apps currently do not use this API endpoint so always set to null
|
||||||
|
user.refreshToken = null
|
||||||
|
Logger.info(`[UserController] Invalidated JWT sessions for user ${user.username} and rotated tokens for current session`)
|
||||||
|
} else {
|
||||||
|
Logger.info(`[UserController] Invalidated JWT sessions for user ${user.username}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await user.save()
|
await user.save()
|
||||||
SocketAuthority.clientEmitter(req.user.id, 'user_updated', user.toOldJSONForBrowser())
|
SocketAuthority.clientEmitter(req.user.id, 'user_updated', user.toOldJSONForBrowser())
|
||||||
}
|
}
|
||||||
|
|
13
server/libs/fusejs/index.js
Normal file
13
server/libs/fusejs/index.js
Normal file
File diff suppressed because one or more lines are too long
|
@ -203,7 +203,15 @@ class AbMergeManager {
|
||||||
// Move library item tracks to cache
|
// Move library item tracks to cache
|
||||||
for (const [index, trackPath] of task.data.originalTrackPaths.entries()) {
|
for (const [index, trackPath] of task.data.originalTrackPaths.entries()) {
|
||||||
const trackFilename = Path.basename(trackPath)
|
const trackFilename = Path.basename(trackPath)
|
||||||
const moveToPath = Path.join(task.data.itemCachePath, trackFilename)
|
let moveToPath = Path.join(task.data.itemCachePath, trackFilename)
|
||||||
|
|
||||||
|
// If the track is the same as the temp file, we need to rename it to avoid overwriting it
|
||||||
|
if (task.data.tempFilepath === moveToPath) {
|
||||||
|
const trackExtname = Path.extname(task.data.tempFilepath)
|
||||||
|
const newTrackFilename = Path.basename(task.data.tempFilepath, trackExtname) + '.backup' + trackExtname
|
||||||
|
moveToPath = Path.join(task.data.itemCachePath, newTrackFilename)
|
||||||
|
}
|
||||||
|
|
||||||
Logger.debug(`[AbMergeManager] Backing up original track "${trackPath}" to ${moveToPath}`)
|
Logger.debug(`[AbMergeManager] Backing up original track "${trackPath}" to ${moveToPath}`)
|
||||||
if (index === 0) {
|
if (index === 0) {
|
||||||
// copy the first track to the cache directory
|
// copy the first track to the cache directory
|
||||||
|
|
|
@ -31,10 +31,12 @@ class CronManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize open session cleanup cron
|
* Initialize open session & auth session cleanup cron
|
||||||
* Runs every day at 00:30
|
* Runs every day at 00:30
|
||||||
* Closes open share sessions that have not been updated in 24 hours
|
* Closes open share sessions that have not been updated in 24 hours
|
||||||
* Closes open playback sessions that have not been updated in 36 hours
|
* Closes open playback sessions that have not been updated in 36 hours
|
||||||
|
* Cleans up expired auth sessions
|
||||||
|
* Deactivates expired api keys
|
||||||
* TODO: Clients should re-open the session if it is closed so that stale sessions can be closed sooner
|
* TODO: Clients should re-open the session if it is closed so that stale sessions can be closed sooner
|
||||||
*/
|
*/
|
||||||
initOpenSessionCleanupCron() {
|
initOpenSessionCleanupCron() {
|
||||||
|
@ -42,6 +44,8 @@ class CronManager {
|
||||||
Logger.debug('[CronManager] Open session cleanup cron executing')
|
Logger.debug('[CronManager] Open session cleanup cron executing')
|
||||||
ShareManager.closeStaleOpenShareSessions()
|
ShareManager.closeStaleOpenShareSessions()
|
||||||
await this.playbackSessionManager.closeStaleOpenSessions()
|
await this.playbackSessionManager.closeStaleOpenSessions()
|
||||||
|
await Database.cleanupExpiredSessions()
|
||||||
|
await Database.deactivateExpiredApiKeys()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -71,6 +71,54 @@ class NotificationManager {
|
||||||
this.triggerNotification('onBackupCompleted', eventData)
|
this.triggerNotification('onBackupCompleted', eventData)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles scheduled episode download RSS feed request failed
|
||||||
|
*
|
||||||
|
* @param {string} feedUrl
|
||||||
|
* @param {number} numFailed
|
||||||
|
* @param {string} title
|
||||||
|
*/
|
||||||
|
async onRSSFeedFailed(feedUrl, numFailed, title) {
|
||||||
|
if (!Database.notificationSettings.isUseable) return
|
||||||
|
|
||||||
|
if (!Database.notificationSettings.getHasActiveNotificationsForEvent('onRSSFeedFailed')) {
|
||||||
|
Logger.debug(`[NotificationManager] onRSSFeedFailed: No active notifications`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.debug(`[NotificationManager] onRSSFeedFailed: RSS feed request failed for ${feedUrl}`)
|
||||||
|
const eventData = {
|
||||||
|
feedUrl: feedUrl,
|
||||||
|
numFailed: numFailed || 0,
|
||||||
|
title: title || 'Unknown Title'
|
||||||
|
}
|
||||||
|
this.triggerNotification('onRSSFeedFailed', eventData)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles scheduled episode downloads disabled due to too many failed attempts
|
||||||
|
*
|
||||||
|
* @param {string} feedUrl
|
||||||
|
* @param {number} numFailed
|
||||||
|
* @param {string} title
|
||||||
|
*/
|
||||||
|
async onRSSFeedDisabled(feedUrl, numFailed, title) {
|
||||||
|
if (!Database.notificationSettings.isUseable) return
|
||||||
|
|
||||||
|
if (!Database.notificationSettings.getHasActiveNotificationsForEvent('onRSSFeedDisabled')) {
|
||||||
|
Logger.debug(`[NotificationManager] onRSSFeedDisabled: No active notifications`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.debug(`[NotificationManager] onRSSFeedDisabled: Podcast scheduled episode download disabled due to ${numFailed} failed requests for ${feedUrl}`)
|
||||||
|
const eventData = {
|
||||||
|
feedUrl: feedUrl,
|
||||||
|
numFailed: numFailed || 0,
|
||||||
|
title: title || 'Unknown Title'
|
||||||
|
}
|
||||||
|
this.triggerNotification('onRSSFeedDisabled', eventData)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {string} errorMsg
|
* @param {string} errorMsg
|
||||||
|
|
|
@ -107,7 +107,7 @@ class PlaybackSessionManager {
|
||||||
|
|
||||||
const syncResults = []
|
const syncResults = []
|
||||||
for (const sessionJson of sessions) {
|
for (const sessionJson of sessions) {
|
||||||
Logger.info(`[PlaybackSessionManager] Syncing local session "${sessionJson.displayTitle}" (${sessionJson.id})`)
|
Logger.info(`[PlaybackSessionManager] Syncing local session "${sessionJson.displayTitle}" (${sessionJson.id}) (updatedAt: ${sessionJson.updatedAt})`)
|
||||||
const result = await this.syncLocalSession(user, sessionJson, deviceInfo)
|
const result = await this.syncLocalSession(user, sessionJson, deviceInfo)
|
||||||
syncResults.push(result)
|
syncResults.push(result)
|
||||||
}
|
}
|
||||||
|
@ -230,9 +230,9 @@ class PlaybackSessionManager {
|
||||||
let userProgressForItem = user.getMediaProgress(mediaItemId)
|
let userProgressForItem = user.getMediaProgress(mediaItemId)
|
||||||
if (userProgressForItem) {
|
if (userProgressForItem) {
|
||||||
if (userProgressForItem.updatedAt.valueOf() > session.updatedAt) {
|
if (userProgressForItem.updatedAt.valueOf() > session.updatedAt) {
|
||||||
Logger.debug(`[PlaybackSessionManager] Not updating progress for "${session.displayTitle}" because it has been updated more recently`)
|
Logger.info(`[PlaybackSessionManager] Not updating progress for "${session.displayTitle}" because it has been updated more recently (${userProgressForItem.updatedAt.valueOf()} > ${session.updatedAt}) (incoming currentTime: ${session.currentTime}) (current currentTime: ${userProgressForItem.currentTime})`)
|
||||||
} else {
|
} else {
|
||||||
Logger.debug(`[PlaybackSessionManager] Updating progress for "${session.displayTitle}" with current time ${session.currentTime} (previously ${userProgressForItem.currentTime})`)
|
Logger.info(`[PlaybackSessionManager] Updating progress for "${session.displayTitle}" with current time ${session.currentTime} (previously ${userProgressForItem.currentTime})`)
|
||||||
const updateResponse = await user.createUpdateMediaProgressFromPayload({
|
const updateResponse = await user.createUpdateMediaProgressFromPayload({
|
||||||
libraryItemId: libraryItem.id,
|
libraryItemId: libraryItem.id,
|
||||||
episodeId: session.episodeId,
|
episodeId: session.episodeId,
|
||||||
|
@ -246,7 +246,7 @@ class PlaybackSessionManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Logger.debug(`[PlaybackSessionManager] Creating new media progress for media item "${session.displayTitle}"`)
|
Logger.info(`[PlaybackSessionManager] Creating new media progress for media item "${session.displayTitle}"`)
|
||||||
const updateResponse = await user.createUpdateMediaProgressFromPayload({
|
const updateResponse = await user.createUpdateMediaProgressFromPayload({
|
||||||
libraryItemId: libraryItem.id,
|
libraryItemId: libraryItem.id,
|
||||||
episodeId: session.episodeId,
|
episodeId: session.episodeId,
|
||||||
|
|
|
@ -30,7 +30,7 @@ class PodcastManager {
|
||||||
this.currentDownload = null
|
this.currentDownload = null
|
||||||
|
|
||||||
this.failedCheckMap = {}
|
this.failedCheckMap = {}
|
||||||
this.MaxFailedEpisodeChecks = 24
|
this.MaxFailedEpisodeChecks = global.MaxFailedEpisodeChecks
|
||||||
}
|
}
|
||||||
|
|
||||||
getEpisodeDownloadsInQueue(libraryItemId) {
|
getEpisodeDownloadsInQueue(libraryItemId) {
|
||||||
|
@ -345,12 +345,14 @@ class PodcastManager {
|
||||||
// Allow up to MaxFailedEpisodeChecks failed attempts before disabling auto download
|
// Allow up to MaxFailedEpisodeChecks failed attempts before disabling auto download
|
||||||
if (!this.failedCheckMap[libraryItem.id]) this.failedCheckMap[libraryItem.id] = 0
|
if (!this.failedCheckMap[libraryItem.id]) this.failedCheckMap[libraryItem.id] = 0
|
||||||
this.failedCheckMap[libraryItem.id]++
|
this.failedCheckMap[libraryItem.id]++
|
||||||
if (this.failedCheckMap[libraryItem.id] >= this.MaxFailedEpisodeChecks) {
|
if (this.MaxFailedEpisodeChecks !== 0 && this.failedCheckMap[libraryItem.id] >= this.MaxFailedEpisodeChecks) {
|
||||||
Logger.error(`[PodcastManager] runEpisodeCheck ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.title}" - disabling auto download`)
|
Logger.error(`[PodcastManager] runEpisodeCheck ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.title}" - disabling auto download`)
|
||||||
|
void NotificationManager.onRSSFeedDisabled(libraryItem.media.feedURL, this.failedCheckMap[libraryItem.id], libraryItem.media.title)
|
||||||
libraryItem.media.autoDownloadEpisodes = false
|
libraryItem.media.autoDownloadEpisodes = false
|
||||||
delete this.failedCheckMap[libraryItem.id]
|
delete this.failedCheckMap[libraryItem.id]
|
||||||
} else {
|
} else {
|
||||||
Logger.warn(`[PodcastManager] runEpisodeCheck ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.title}"`)
|
Logger.warn(`[PodcastManager] runEpisodeCheck ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.title}"`)
|
||||||
|
void NotificationManager.onRSSFeedFailed(libraryItem.media.feedURL, this.failedCheckMap[libraryItem.id], libraryItem.media.title)
|
||||||
}
|
}
|
||||||
} else if (newEpisodes.length) {
|
} else if (newEpisodes.length) {
|
||||||
delete this.failedCheckMap[libraryItem.id]
|
delete this.failedCheckMap[libraryItem.id]
|
||||||
|
@ -384,7 +386,17 @@ class PodcastManager {
|
||||||
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes no feed url for ${podcastLibraryItem.media.title} (ID: ${podcastLibraryItem.id})`)
|
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes no feed url for ${podcastLibraryItem.media.title} (ID: ${podcastLibraryItem.id})`)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const feed = await getPodcastFeed(podcastLibraryItem.media.feedURL)
|
const feed = await Promise.race([
|
||||||
|
getPodcastFeed(podcastLibraryItem.media.feedURL),
|
||||||
|
new Promise((_, reject) =>
|
||||||
|
// The added second is to make sure that axios can fail first and only falls back later
|
||||||
|
setTimeout(() => reject(new Error('Timeout. getPodcastFeed seemed to timeout but not triggering the timeout.')), global.PodcastDownloadTimeout + 1000)
|
||||||
|
)
|
||||||
|
]).catch((error) => {
|
||||||
|
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes failed to fetch feed for ${podcastLibraryItem.media.title} (ID: ${podcastLibraryItem.id}):`, error)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
if (!feed?.episodes) {
|
if (!feed?.episodes) {
|
||||||
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes invalid feed payload for ${podcastLibraryItem.media.title} (ID: ${podcastLibraryItem.id})`, feed)
|
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes invalid feed payload for ${podcastLibraryItem.media.title} (ID: ${podcastLibraryItem.id})`, feed)
|
||||||
return null
|
return null
|
||||||
|
|
163
server/migrations/v2.26.0-create-auth-tables.js
Normal file
163
server/migrations/v2.26.0-create-auth-tables.js
Normal file
|
@ -0,0 +1,163 @@
|
||||||
|
/**
|
||||||
|
* @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.26.0'
|
||||||
|
const migrationName = `${migrationVersion}-create-auth-tables`
|
||||||
|
const loggerPrefix = `[${migrationVersion} migration]`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This upward migration creates a sessions table and apiKeys 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 } }) {
|
||||||
|
// Upwards migration script
|
||||||
|
logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)
|
||||||
|
|
||||||
|
// Check if table exists
|
||||||
|
if (await queryInterface.tableExists('sessions')) {
|
||||||
|
logger.info(`${loggerPrefix} table "sessions" already exists`)
|
||||||
|
} else {
|
||||||
|
// Create table
|
||||||
|
logger.info(`${loggerPrefix} creating table "sessions"`)
|
||||||
|
const DataTypes = queryInterface.sequelize.Sequelize.DataTypes
|
||||||
|
await queryInterface.createTable('sessions', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
ipAddress: DataTypes.STRING,
|
||||||
|
userAgent: DataTypes.STRING,
|
||||||
|
refreshToken: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
expiresAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
userId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
references: {
|
||||||
|
model: {
|
||||||
|
tableName: 'users'
|
||||||
|
},
|
||||||
|
key: 'id'
|
||||||
|
},
|
||||||
|
allowNull: false,
|
||||||
|
onDelete: 'CASCADE'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
logger.info(`${loggerPrefix} created table "sessions"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if table exists
|
||||||
|
if (await queryInterface.tableExists('apiKeys')) {
|
||||||
|
logger.info(`${loggerPrefix} table "apiKeys" already exists`)
|
||||||
|
} else {
|
||||||
|
// Create table
|
||||||
|
logger.info(`${loggerPrefix} creating table "apiKeys"`)
|
||||||
|
const DataTypes = queryInterface.sequelize.Sequelize.DataTypes
|
||||||
|
await queryInterface.createTable('apiKeys', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
description: DataTypes.TEXT,
|
||||||
|
expiresAt: DataTypes.DATE,
|
||||||
|
lastUsedAt: DataTypes.DATE,
|
||||||
|
isActive: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: false
|
||||||
|
},
|
||||||
|
permissions: DataTypes.JSON,
|
||||||
|
createdAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
userId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
references: {
|
||||||
|
model: {
|
||||||
|
tableName: 'users'
|
||||||
|
},
|
||||||
|
key: 'id'
|
||||||
|
},
|
||||||
|
onDelete: 'CASCADE'
|
||||||
|
},
|
||||||
|
createdByUserId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
references: {
|
||||||
|
model: {
|
||||||
|
tableName: 'users',
|
||||||
|
as: 'createdByUser'
|
||||||
|
},
|
||||||
|
key: 'id'
|
||||||
|
},
|
||||||
|
onDelete: 'SET NULL'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
logger.info(`${loggerPrefix} created table "apiKeys"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This downward migration script removes the sessions table and apiKeys 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 } }) {
|
||||||
|
// Downward migration script
|
||||||
|
logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)
|
||||||
|
|
||||||
|
// Check if table exists
|
||||||
|
if (await queryInterface.tableExists('sessions')) {
|
||||||
|
logger.info(`${loggerPrefix} dropping table "sessions"`)
|
||||||
|
// Drop table
|
||||||
|
await queryInterface.dropTable('sessions')
|
||||||
|
logger.info(`${loggerPrefix} dropped table "sessions"`)
|
||||||
|
} else {
|
||||||
|
logger.info(`${loggerPrefix} table "sessions" does not exist`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await queryInterface.tableExists('apiKeys')) {
|
||||||
|
logger.info(`${loggerPrefix} dropping table "apiKeys"`)
|
||||||
|
await queryInterface.dropTable('apiKeys')
|
||||||
|
logger.info(`${loggerPrefix} dropped table "apiKeys"`)
|
||||||
|
} else {
|
||||||
|
logger.info(`${loggerPrefix} table "apiKeys" does not exist`)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { up, down }
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue