mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-11-13 12:23:34 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b67c170ce | ||
|
|
d21aec237c | ||
|
|
632ccd5cbf | ||
|
|
62efd5c583 | ||
|
|
9244f9ea5a | ||
|
|
7e76eed8bb | ||
|
|
e4e212cca7 | ||
|
|
fb43db4f75 | ||
|
|
3b4895b1b0 | ||
|
|
a06639886b | ||
|
|
37d7ee20ed | ||
|
|
eb8ed7f5b6 | ||
|
|
3d62a103ae | ||
|
|
42bf9512b0 | ||
|
|
5cb75a85ec | ||
|
|
3c1bbc7f8e | ||
|
|
b204395b8e | ||
|
|
e4c69fe68b | ||
|
|
def8fca33a | ||
|
|
e8ed987034 | ||
|
|
d5be05b11e | ||
|
|
580f9fb649 | ||
|
|
7aecf129ac | ||
|
|
4056f37390 | ||
|
|
6bc2a89827 | ||
|
|
010a73a16a | ||
|
|
ed6663054f | ||
|
|
01263e9379 | ||
|
|
1ab6120596 | ||
|
|
c097655c5c | ||
|
|
9f541063db | ||
|
|
3f2da7febc | ||
|
|
24c68507e7 | ||
|
|
b325d0ee1d | ||
|
|
5f6fa4adbf | ||
|
|
78130a7823 | ||
|
|
e4556b0307 | ||
|
|
6f57182da0 | ||
|
|
1aef21169c | ||
|
|
e02c859437 | ||
|
|
9f475ef3d5 | ||
|
|
5ad5768308 | ||
|
|
d38bf9e868 | ||
|
|
1027b85e68 | ||
|
|
67992540c0 | ||
|
|
733c795989 | ||
|
|
0a797ab8be | ||
|
|
c0b9110e4e | ||
|
|
13432209ee | ||
|
|
513a0679f5 | ||
|
|
1b46d68027 | ||
|
|
d511612f21 | ||
|
|
6caa1aa2da | ||
|
|
32fae4a7c6 | ||
|
|
96d4021a3c | ||
|
|
49826f5163 | ||
|
|
76e33a2a4e | ||
|
|
1a440811c5 | ||
|
|
529a16b718 | ||
|
|
032f0b6b51 | ||
|
|
f5bfa1edd6 | ||
|
|
4dfad46722 | ||
|
|
c86f336536 | ||
|
|
1876bfbfe7 | ||
|
|
a7b380cd7a | ||
|
|
71c5d27ea5 | ||
|
|
83707e08bf | ||
|
|
e8d1b58bd2 | ||
|
|
aff79b5579 | ||
|
|
321d8bfda1 | ||
|
|
a3ff41f90e | ||
|
|
60f9ac93f7 | ||
|
|
bd61a43458 | ||
|
|
df6ec07f18 | ||
|
|
6d02aa3aa0 | ||
|
|
144fe743c6 | ||
|
|
0851b346d0 | ||
|
|
53e904314e | ||
|
|
84f80f1903 | ||
|
|
98b1f4abad | ||
|
|
ec0d147d6d | ||
|
|
a7825bb461 | ||
|
|
09be7799f6 | ||
|
|
dc6a8a7292 | ||
|
|
0c075674a0 | ||
|
|
5b13820cb4 | ||
|
|
d5b4d57fe3 | ||
|
|
f12b64495a | ||
|
|
43f3150a52 | ||
|
|
65d3a668e3 | ||
|
|
2b5297bee8 | ||
|
|
08caeb5975 | ||
|
|
6c170a5064 | ||
|
|
4b62f90964 | ||
|
|
e9ea511c90 | ||
|
|
c6afeb7dc9 | ||
|
|
35a2c5e87c | ||
|
|
4c71a076e7 | ||
|
|
e56c1099e6 | ||
|
|
dc0b24a03f | ||
|
|
c900676be7 | ||
|
|
a6cde926ee | ||
|
|
e8db14fe33 | ||
|
|
e9b533637f | ||
|
|
8b97da500d | ||
|
|
37b42601ef | ||
|
|
5da4ec51ce | ||
|
|
4c65a87d5c | ||
|
|
0e5618dcd8 | ||
|
|
d063d851e1 | ||
|
|
6458e6d3d2 | ||
|
|
70953d8c82 | ||
|
|
2d9f63963e | ||
|
|
f12abca3fc | ||
|
|
bced308462 | ||
|
|
528d03fef3 | ||
|
|
b93c3133a9 | ||
|
|
8d9db8d103 | ||
|
|
08bbf9cd9d | ||
|
|
95af4812cd | ||
|
|
8ec547e302 | ||
|
|
32e4da8c92 | ||
|
|
0ce37004af | ||
|
|
527745b54e | ||
|
|
3c016314e6 | ||
|
|
59c7d0275c | ||
|
|
4883d0f60c | ||
|
|
9d9ab49664 | ||
|
|
82aec5f60c | ||
|
|
7ee5e1bdf6 | ||
|
|
3bb53d939d | ||
|
|
6f03467f35 | ||
|
|
b2f593f1c4 | ||
|
|
58dcda36a5 | ||
|
|
354e0bf793 | ||
|
|
0784373b61 | ||
|
|
2f219ea3cc | ||
|
|
56e60b8420 | ||
|
|
9b92b5de34 | ||
|
|
3417c0c721 | ||
|
|
cbda0360aa | ||
|
|
036bc081f0 | ||
|
|
42947e2e64 | ||
|
|
c4e7172c2f | ||
|
|
4502d37d4f | ||
|
|
e70e4b9d40 | ||
|
|
aacdcc47ec | ||
|
|
499b52b4dd | ||
|
|
1bad2d9072 | ||
|
|
c009db9f28 | ||
|
|
325469c5a5 | ||
|
|
c97b36e11c | ||
|
|
e944b2a2f5 | ||
|
|
2d0a5462d2 | ||
|
|
72dc75482f | ||
|
|
cac74f3477 | ||
|
|
1ad11b2b9e | ||
|
|
50eeca2e0f | ||
|
|
4f21fc023c | ||
|
|
52a485d135 | ||
|
|
3b025076e8 | ||
|
|
6d5d89429d | ||
|
|
c010f0e1eb | ||
|
|
eee377e081 | ||
|
|
b0aaa24660 | ||
|
|
47ea6b5092 | ||
|
|
4b060febc2 | ||
|
|
40869bcf39 | ||
|
|
3942805129 | ||
|
|
dc446862c1 | ||
|
|
379f6c716a | ||
|
|
cfeb6bd502 | ||
|
|
077b523bd6 | ||
|
|
b8a2d113f0 | ||
|
|
e1ae4f2d31 | ||
|
|
7aa2f84daa | ||
|
|
da0a64daed |
No files matched your search
@@ -0,0 +1,25 @@
|
||||
name: Prepare translation credits
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
release-notes:
|
||||
if: github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, 'weblate')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- id: translator-credits
|
||||
uses: audiobookshelf/get-translator-credits@v1.1.0
|
||||
|
||||
- name: Show translator credits
|
||||
run: |
|
||||
printf '%s\n' "${{ steps.translator-credits.outputs.credits }}"
|
||||
@@ -22,7 +22,7 @@
|
||||
<div v-if="entityName === 'collections' || entityName === 'playlists'" class="flex justify-center mt-4">
|
||||
{{ emptyMessageHelp }}
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/collections" target="_blank" class="inline-flex">
|
||||
<a href="https://audiobookshelf.org/docs/documentation/libraries/common-content/playlists" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<div class="px-6 py-8 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||
<div class="absolute top-0 right-0 p-4">
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/media-item-shares" target="_blank" class="inline-flex">
|
||||
<a href="https://audiobookshelf.org/docs/documentation/libraries/common-content/public-shares#media-item-shares" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<div class="text-sm flex items-center justify-center text-gray-200">
|
||||
<p>{{ $strings.MessageBookshelfNoCollectionsHelp }}</p>
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/collections" target="_blank" class="inline-flex">
|
||||
<a href="https://audiobookshelf.org/docs/documentation/libraries/common-content/playlists" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<div class="flex items-center justify-between md:justify-start mb-4">
|
||||
<p class="text-sm text-gray-300 pr-2">{{ $strings.LabelMetadataOrderOfPrecedenceDescription }}</p>
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex">
|
||||
<a href="https://www.audiobookshelf.org/guides/book-scanner" target="_blank" class="inline-flex">
|
||||
<a href="https://audiobookshelf.org/docs/documentation/libraries/book-library/book-metadata" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<div class="text-sm flex items-center justify-center text-gray-200">
|
||||
<p>{{ $strings.MessageNoUserPlaylistsHelp }}</p>
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/collections" target="_blank" class="inline-flex">
|
||||
<a href="https://audiobookshelf.org/docs/documentation/libraries/common-content/playlists" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.34.0",
|
||||
"version": "2.36.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.34.0",
|
||||
"version": "2.36.1",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@nuxtjs/axios": "^5.13.6",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.34.0",
|
||||
"version": "2.36.1",
|
||||
"buildNumber": 1,
|
||||
"description": "Self-hosted audiobook and podcast client",
|
||||
"main": "index.js",
|
||||
|
||||
+142
-5
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div id="page-wrapper" class="page p-6 overflow-y-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
|
||||
<div class="w-full max-w-xl mx-auto">
|
||||
<div class="w-full max-w-2xl mx-auto">
|
||||
<h1 class="text-2xl">{{ $strings.HeaderAccount }}</h1>
|
||||
|
||||
<div class="my-4">
|
||||
@@ -69,8 +69,52 @@
|
||||
</app-settings-content>
|
||||
</div>
|
||||
|
||||
<div class="py-4 mt-8 flex">
|
||||
<ui-btn color="bg-primary flex items-center text-lg" @click="logout"><span class="material-symbols mr-4 icon-text">logout</span>{{ $strings.ButtonLogout }}</ui-btn>
|
||||
<div v-if="!isGuest">
|
||||
<div class="w-full h-px bg-white/10 my-4" />
|
||||
|
||||
<app-settings-content :header-text="$strings.HeaderSessions">
|
||||
<table v-if="authSessions.length" class="tracksTable mt-4 table-fixed w-full">
|
||||
<tr>
|
||||
<th class="text-left">{{ $strings.LabelDeviceInfo }}</th>
|
||||
<th class="text-left hidden sm:table-cell w-36">{{ $strings.LabelIpAddress }}</th>
|
||||
<th class="text-left w-30 sm:w-40">{{ $strings.LabelLastUpdate }}</th>
|
||||
<th class="w-12"></th>
|
||||
</tr>
|
||||
<tr v-for="session in authSessions" :key="session.id">
|
||||
<td class="max-w-0">
|
||||
<div class="flex items-center gap-0.5 min-w-0">
|
||||
<span class="text-sm text-gray-100 truncate min-w-0" :title="session.userAgent">{{ getSessionDeviceLabel(session) }}</span>
|
||||
<ui-tooltip v-if="session.current" direction="top" :text="$strings.LabelCurrent" class="inline-flex shrink-0">
|
||||
<span class="material-symbols text-success text-sm leading-none">check</span>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</td>
|
||||
<td class="hidden sm:table-cell w-36">
|
||||
<p class="text-sm text-gray-100 truncate" :title="session.ipAddress">{{ session.ipAddress || '-' }}</p>
|
||||
</td>
|
||||
<td class="w-30 sm:w-40">
|
||||
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDatetime(session.updatedAt, dateFormat, timeFormat)">
|
||||
<p class="text-xs sm:text-sm text-gray-100">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
|
||||
</ui-tooltip>
|
||||
</td>
|
||||
<td class="w-12">
|
||||
<div class="flex justify-end items-center h-10">
|
||||
<ui-icon-btn icon="delete" borderless :size="8" icon-font-size="1.1rem" :disabled="deletingSessionId === session.id || loadingAuthSessions" @click="deleteAuthSessionClick(session)" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div v-if="authSessionsNumPages > 1" class="flex items-center justify-end py-1">
|
||||
<ui-icon-btn icon="arrow_back_ios_new" :size="7" icon-font-size="1rem" class="mx-1" :disabled="loadingAuthSessions || authSessionsPage === 0" @click="prevAuthSessionsPage" />
|
||||
<p class="text-sm mx-1">{{ $getString('LabelPaginationPageXOfY', [authSessionsPage + 1, authSessionsNumPages]) }}</p>
|
||||
<ui-icon-btn icon="arrow_forward_ios" :size="7" icon-font-size="1rem" class="mx-1" :disabled="loadingAuthSessions || authSessionsPage >= authSessionsNumPages - 1" @click="nextAuthSessionsPage" />
|
||||
</div>
|
||||
</app-settings-content>
|
||||
</div>
|
||||
|
||||
<div class="py-4 mt-8 flex flex-wrap gap-2">
|
||||
<ui-btn v-if="!isGuest" color="bg-primary flex items-center text-lg" :disabled="loggingOut" @click="logout(true)"> <span class="material-symbols mr-4 icon-text">devices</span>{{ $strings.ButtonLogoutAllDevices }} </ui-btn>
|
||||
<ui-btn color="bg-primary flex items-center text-lg" :disabled="loggingOut" @click="logout(false)"><span class="material-symbols mr-4 icon-text">logout</span>{{ $strings.ButtonLogout }}</ui-btn>
|
||||
</div>
|
||||
|
||||
<modals-emails-user-e-reader-device-modal v-model="showEReaderDeviceModal" :existing-devices="revisedEreaderDevices" :ereader-device="selectedEReaderDevice" @update="ereaderDevicesUpdated" />
|
||||
@@ -87,6 +131,13 @@ export default {
|
||||
newPassword: null,
|
||||
confirmPassword: null,
|
||||
changingPassword: false,
|
||||
loggingOut: false,
|
||||
authSessions: [],
|
||||
authSessionsPage: 0,
|
||||
authSessionsNumPages: 0,
|
||||
authSessionsItemsPerPage: 10,
|
||||
loadingAuthSessions: false,
|
||||
deletingSessionId: null,
|
||||
selectedLanguage: '',
|
||||
newEReaderDevice: {
|
||||
name: '',
|
||||
@@ -129,13 +180,19 @@ export default {
|
||||
},
|
||||
revisedEreaderDevices() {
|
||||
return this.ereaderDevices.filter((device) => device.users?.length === 1)
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.getters['getServerSetting']('dateFormat')
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.getters['getServerSetting']('timeFormat')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateLocalLanguage(lang) {
|
||||
this.$setLanguageCode(lang)
|
||||
},
|
||||
logout() {
|
||||
logout(allDevices = false) {
|
||||
// Disconnect from socket
|
||||
if (this.$root.socket) {
|
||||
console.log('Disconnecting from socket', this.$root.socket.id)
|
||||
@@ -149,8 +206,10 @@ export default {
|
||||
this.$store.commit('libraries/setUserPlaylists', [])
|
||||
this.$store.commit('libraries/setCollections', [])
|
||||
|
||||
this.loggingOut = true
|
||||
const url = allDevices ? '/logout?allDevices=1' : '/logout'
|
||||
this.$axios
|
||||
.$post('/logout')
|
||||
.$post(url)
|
||||
.then((logoutPayload) => {
|
||||
const redirect_url = logoutPayload.redirect_url
|
||||
|
||||
@@ -163,6 +222,9 @@ export default {
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
})
|
||||
.finally(() => {
|
||||
this.loggingOut = false
|
||||
})
|
||||
},
|
||||
resetForm() {
|
||||
this.password = null
|
||||
@@ -238,11 +300,86 @@ export default {
|
||||
},
|
||||
ereaderDevicesUpdated(ereaderDevices) {
|
||||
this.ereaderDevices = ereaderDevices
|
||||
},
|
||||
loadAuthSessions(page = 0) {
|
||||
if (this.loadingAuthSessions) return
|
||||
if (page < 0) return
|
||||
if (this.authSessionsNumPages > 0 && page > this.authSessionsNumPages - 1) return
|
||||
|
||||
this.loadingAuthSessions = true
|
||||
this.$axios
|
||||
.$get(`/api/me/sessions?page=${page}&itemsPerPage=${this.authSessionsItemsPerPage}`)
|
||||
.then((data) => {
|
||||
this.authSessions = data.sessions || []
|
||||
this.authSessionsPage = data.page ?? page
|
||||
this.authSessionsNumPages = data.numPages ?? 0
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load sessions', error)
|
||||
})
|
||||
.finally(() => {
|
||||
this.loadingAuthSessions = false
|
||||
})
|
||||
},
|
||||
prevAuthSessionsPage() {
|
||||
if (this.authSessionsPage <= 0) return
|
||||
this.loadAuthSessions(this.authSessionsPage - 1)
|
||||
},
|
||||
nextAuthSessionsPage() {
|
||||
if (this.authSessionsPage >= this.authSessionsNumPages - 1) return
|
||||
this.loadAuthSessions(this.authSessionsPage + 1)
|
||||
},
|
||||
deleteAuthSessionClick(session) {
|
||||
this.$store.commit('globals/setConfirmPrompt', {
|
||||
message: this.$getString('MessageConfirmLogoutDevice', [this.getSessionDeviceLabel(session)]),
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) this.deleteAuthSession(session)
|
||||
},
|
||||
type: 'yesNo'
|
||||
})
|
||||
},
|
||||
deleteAuthSession(session) {
|
||||
// Call logout instead for current session
|
||||
if (session.current) {
|
||||
this.logout(false)
|
||||
return
|
||||
}
|
||||
|
||||
this.deletingSessionId = session.id
|
||||
this.$axios
|
||||
.$delete(`/api/me/sessions/${session.id}`)
|
||||
.then(() => {
|
||||
if (this.authSessions.length === 1 && this.authSessionsPage > 0) {
|
||||
this.loadAuthSessions(this.authSessionsPage - 1)
|
||||
} else {
|
||||
this.loadAuthSessions(this.authSessionsPage)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to delete session', error)
|
||||
this.$toast.error(this.$strings.ToastFailedToDelete)
|
||||
})
|
||||
.finally(() => {
|
||||
this.deletingSessionId = null
|
||||
})
|
||||
},
|
||||
getSessionDeviceLabel(session) {
|
||||
const deviceInfo = session.deviceInfo
|
||||
if (!deviceInfo) return session.userAgent || '-'
|
||||
|
||||
const parts = []
|
||||
if (deviceInfo.model) parts.push(deviceInfo.model)
|
||||
if (deviceInfo.osName) parts.push(`${deviceInfo.osName}${deviceInfo.osVersion ? ` ${deviceInfo.osVersion}` : ''}`)
|
||||
if (deviceInfo.browserName) parts.push(deviceInfo.browserName)
|
||||
return parts.join(' / ') || session.userAgent || '-'
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.selectedLanguage = this.$languageCodes.current
|
||||
this.ereaderDevices = this.$store.state.libraries.ereaderDevices || []
|
||||
if (!this.isGuest) {
|
||||
this.loadAuthSessions()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -7,7 +7,7 @@
|
||||
</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">
|
||||
<a href="https://audiobookshelf.org/docs/documentation/server-management/api-keys" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<ui-checkbox v-model="enableOpenIDAuth" checkbox-bg="bg" />
|
||||
<p class="text-lg pl-4">{{ $strings.HeaderOpenIDConnectAuthentication }}</p>
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/oidc_authentication" target="_blank" class="inline-flex">
|
||||
<a href="https://audiobookshelf.org/docs/documentation/server-management/oidc-authentication" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<app-settings-content :header-text="$strings.HeaderEmailSettings" :description="''">
|
||||
<template #header-items>
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/send_to_ereader" target="_blank" class="inline-flex">
|
||||
<a href="https://audiobookshelf.org/docs/documentation/libraries/book-library/ebooks" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</template>
|
||||
<template #header-items>
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/custom-metadata-providers" target="_blank" class="inline-flex">
|
||||
<a href="https://audiobookshelf.org/docs/documentation/community/community-providers" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<app-settings-content :header-text="$strings.HeaderLibraries">
|
||||
<template #header-items>
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/library_creation" target="_blank" class="inline-flex">
|
||||
<a href="https://audiobookshelf.org/docs/documentation/libraries/common-content/overview" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<app-settings-content :header-text="$strings.HeaderLogs" :description="$strings.MessageLogsDescription">
|
||||
<template #header-items>
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/server_logs" target="_blank" class="inline-flex">
|
||||
<a href="https://audiobookshelf.org/docs/documentation/server-management/server-logs" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<app-settings-content :header-text="$strings.HeaderRSSFeeds">
|
||||
<template #header-items>
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/rss_feeds" target="_blank" class="inline-flex">
|
||||
<a href="https://audiobookshelf.org/docs/documentation/libraries/common-content/public-shares#rss-feed" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
</div>
|
||||
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/users" target="_blank" class="inline-flex">
|
||||
<a href="https://audiobookshelf.org/docs/documentation/server-management/user-management" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
|
||||
@@ -244,6 +244,8 @@
|
||||
"LabelAlreadyInYourLibrary": "موجود بالفعل في مكتبتك",
|
||||
"LabelApiKeyCreated": "تم إنشاء مفتاح API \"{0}\" بنجاح.",
|
||||
"LabelApiKeyCreatedDescription": "تأكد من نسخ مفتاح API الآن، لن تتمكن من رؤيته مرة أخرى.",
|
||||
"LabelApiKeyUser": "التصرف بالنيابة عن مستخدم",
|
||||
"LabelApiKeyUserDescription": "مفتاح API سيمتلك نفس صلاحيات المستخدم الذي ينوب عنه ، سيظهر بالسجلات وكأن المستخدم قام بالطلب.",
|
||||
"LabelApiToken": "رمز API",
|
||||
"LabelAppend": "إلحاق",
|
||||
"LabelAudioBitrate": "معدل بت الصوت (على سبيل المثال 128 كيلو بايت)",
|
||||
@@ -293,6 +295,7 @@
|
||||
"LabelContinueListening": "استمرار الاستماع",
|
||||
"LabelContinueReading": "استمرار القراءة",
|
||||
"LabelContinueSeries": "استمرار المسلسلات",
|
||||
"LabelCorsAllowed": "CORS Origins مسموح",
|
||||
"LabelCover": "الغلاف",
|
||||
"LabelCoverImageURL": "رابط صورة الغلاف",
|
||||
"LabelCoverProvider": "مزود الغلاف",
|
||||
@@ -426,6 +429,9 @@
|
||||
"LabelLibraryFilterSublistEmpty": "لا يوجد {0}",
|
||||
"LabelLibraryItem": "عنصر المكتبة",
|
||||
"LabelLibraryName": "اسم المكتبة",
|
||||
"LabelLibrarySortByProgress": "المرحلة: الأحدث",
|
||||
"LabelLibrarySortByProgressFinished": "المرحلة: تم الانتهاء",
|
||||
"LabelLibrarySortByProgressStarted": "المرحلة: تم البدء",
|
||||
"LabelLimit": "حد",
|
||||
"LabelLineSpacing": "تباعد الأسطر",
|
||||
"LabelListenAgain": "الاستماع مجدداً",
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"ButtonLatest": "Апошняе",
|
||||
"ButtonLibrary": "Бібліятэка",
|
||||
"ButtonLogout": "Выйсці",
|
||||
"ButtonLogoutAllDevices": "Выйсці з усіх прылад",
|
||||
"ButtonLookup": "Пошук",
|
||||
"ButtonManageTracks": "Кіраванне трэкамі",
|
||||
"ButtonMapChapterTitles": "Супаставіць загалоўкі раздзелаў",
|
||||
@@ -88,7 +89,7 @@
|
||||
"ButtonResetToDefault": "Скінуць да прадвызначаных",
|
||||
"ButtonRestore": "Аднавіць",
|
||||
"ButtonSave": "Захаваць",
|
||||
"ButtonSaveAndClose": "Захаваць і зачыніць",
|
||||
"ButtonSaveAndClose": "Захаваць і закрыць",
|
||||
"ButtonSaveTracklist": "Захаваць спіс трэкаў",
|
||||
"ButtonScan": "Сканаваць",
|
||||
"ButtonScanLibrary": "Сканіраваць бібліятэку",
|
||||
@@ -194,6 +195,7 @@
|
||||
"HeaderScheduleEpisodeDownloads": "Расклад аўтаматычных спампоўванняў выпускаў",
|
||||
"HeaderScheduleLibraryScans": "Расклад аўтаматычнага сканавання бібліятэкі",
|
||||
"HeaderSession": "Сеанс",
|
||||
"HeaderSessions": "Сеансы",
|
||||
"HeaderSetBackupSchedule": "Наладзіць расклад рэзервовага капіравання",
|
||||
"HeaderSettings": "Налады",
|
||||
"HeaderSettingsDisplay": "Выгляд",
|
||||
@@ -284,7 +286,7 @@
|
||||
"LabelChaptersFound": "раздзелаў знойдзена",
|
||||
"LabelClickForMoreInfo": "Націсніце для больш падрабязнай інфармацыі",
|
||||
"LabelClickToUseCurrentValue": "Націсніце, каб выкарыстоўваць бягучае значэнне",
|
||||
"LabelClosePlayer": "Зачыніць прайгравальнік",
|
||||
"LabelClosePlayer": "Закрыць прайгравальнік",
|
||||
"LabelCodec": "Кодэк",
|
||||
"LabelCollapseSeries": "Згарнуць серыі",
|
||||
"LabelCollapseSubSeries": "Згарнуць падсерыі",
|
||||
@@ -415,6 +417,7 @@
|
||||
"LabelIntervalEveryHour": "Кожную гадзіну",
|
||||
"LabelIntervalEveryMinute": "Кожную хвіліну",
|
||||
"LabelInvert": "Інвертаваць",
|
||||
"LabelIpAddress": "IP-адрас",
|
||||
"LabelItem": "Элемент",
|
||||
"LabelJumpBackwardAmount": "Час пераходу назад",
|
||||
"LabelJumpForwardAmount": "Час пераходу наперад",
|
||||
@@ -774,6 +777,7 @@
|
||||
"MessageConfirmDeleteSession": "Вы ўпэўнены, што хочаце выдаліць гэты сеанс?",
|
||||
"MessageConfirmEmbedMetadataInAudioFiles": "Вы ўпэўнены, што хочаце ўбудаваць метаданыя ў {0} аўдыяфайлаў?",
|
||||
"MessageConfirmForceReScan": "Вы ўпэўнены, што хочаце прымусова паўторна сканіраваць?",
|
||||
"MessageConfirmLogoutDevice": "Вы ўпэўнены, што хочаце выйсці з \"{0}\"?",
|
||||
"MessageConfirmMarkAllEpisodesFinished": "Вы ўпэўнены, што хочаце пазначыць усе выпускі як завершаныя?",
|
||||
"MessageConfirmMarkAllEpisodesNotFinished": "Вы ўпэўнены, што хочаце пазначыць усе выпускі як незавершаныя?",
|
||||
"MessageConfirmMarkItemFinished": "Вы ўпэўнены, што хочаце пазначыць \"{0}\" як завершаны?",
|
||||
|
||||
+103
-2
@@ -752,7 +752,7 @@
|
||||
"MessageBookshelfNoRSSFeeds": "Няма отворени RSS feed-ове",
|
||||
"MessageBookshelfNoResultsForFilter": "Няма резултат за филтер \"{0}: {1}\"",
|
||||
"MessageBookshelfNoResultsForQuery": "Няма резултати от заявката",
|
||||
"MessageBookshelfNoSeries": "Нямаш сеЗЙ",
|
||||
"MessageBookshelfNoSeries": "Нямате поредица",
|
||||
"MessageBulkChapterPattern": "Колко глави искате да добавите, използвайки тази схема за номериране?",
|
||||
"MessageChapterEndIsAfter": "Краят на главата е след края на вашата аудиокнига",
|
||||
"MessageChapterErrorFirstNotZero": "Първата глава трябва да започва от 0",
|
||||
@@ -1018,18 +1018,50 @@
|
||||
"ToastChapterStartTimeAdjusted": "Начално време на главате е настоено с {0} секунди",
|
||||
"ToastChaptersAllLocked": "Всички глави са заключени. Оключете някой глави за да преместите техните времена.",
|
||||
"ToastChaptersHaveErrors": "Главите имат грешки",
|
||||
"ToastChaptersInvalidShiftAmountLast": "Невалидно време за преместване. Началният час на последната глава ще превиши общата продължителност на аудиокнигата.",
|
||||
"ToastChaptersInvalidShiftAmountStart": "Невалидно време за преместване. Първата глава ще има нулева или отрицателна дължина и ще бъде презаписана от втората глава. Увеличете началното време на втората глава.",
|
||||
"ToastChaptersMustHaveTitles": "Главите трябва да имат заглавия",
|
||||
"ToastChaptersRemoved": "Главите са премахнати",
|
||||
"ToastChaptersUpdated": "Главите са актуализирани",
|
||||
"ToastCollectionItemsAddFailed": "Неуспешно добавяне на елемент(и) към колекцията",
|
||||
"ToastCollectionRemoveSuccess": "Колекцията е премахната",
|
||||
"ToastCollectionUpdateSuccess": "Колекцията е обновена",
|
||||
"ToastConnectionNotAvailable": "Няма връзка. Моля, опитайте отново по-късно",
|
||||
"ToastCoverSearchFailed": "Търсенето на корица е неуспешно",
|
||||
"ToastCoverUpdateFailed": "Обновяването на корицата е неуспешно",
|
||||
"ToastDateTimeInvalidOrIncomplete": "Датата и часът са невалидни или непълни",
|
||||
"ToastDeleteFileFailed": "Неуспешно изтриване на файла",
|
||||
"ToastDeleteFileSuccess": "Успешно изтриване на файла",
|
||||
"ToastDeviceAddFailed": "Неуспешно добавяне на устройство",
|
||||
"ToastDeviceNameAlreadyExists": "Вече съществува четец с това име",
|
||||
"ToastDeviceTestEmailFailed": "Неуспешно изпращане на тестов имейл",
|
||||
"ToastDeviceTestEmailSuccess": "Тестовият имейл е изпратен",
|
||||
"ToastEmailSettingsUpdateSuccess": "Имейл настройките са актуализирани",
|
||||
"ToastEncodeCancelFailed": "Неуспешно отменяне на кодирането",
|
||||
"ToastEncodeCancelSucces": "Кодирането е отменено",
|
||||
"ToastEpisodeDownloadQueueClearFailed": "Неуспешно изчистване на опашката",
|
||||
"ToastEpisodeDownloadQueueClearSuccess": "Опашката за изтегляне на епизоди е изчистена",
|
||||
"ToastEpisodeUpdateSuccess": "{0} епизода са актуализирани",
|
||||
"ToastErrorCannotShare": "Не може да се споделя директно от това устройство",
|
||||
"ToastFailedToCreate": "Неуспешно създаване",
|
||||
"ToastFailedToDelete": "Неуспешно изтриване",
|
||||
"ToastFailedToLoadData": "Неуспешно зареждане на данни",
|
||||
"ToastFailedToMatch": "Неуспешно съвпадение",
|
||||
"ToastFailedToShare": "Неуспешно споделяне",
|
||||
"ToastFailedToUpdate": "Неуспешно актуализиране",
|
||||
"ToastInvalidImageUrl": "Невалиден URL адрес на изображение",
|
||||
"ToastInvalidMaxEpisodesToDownload": "Невалиден максимален брой епизоди за изтегляне",
|
||||
"ToastInvalidUrl": "Невалиден URL адрес",
|
||||
"ToastInvalidUrls": "Един или повече URL адреси са невалидни",
|
||||
"ToastItemCoverUpdateSuccess": "Корицата на елемента е обновена",
|
||||
"ToastItemDeletedFailed": "Неуспешно изтриване на елемента",
|
||||
"ToastItemDeletedSuccess": "Елементът е изтрит",
|
||||
"ToastItemDetailsUpdateSuccess": "Детайлите на елемента са обновени",
|
||||
"ToastItemMarkedAsFinishedFailed": "Неуспешно маркиране като Завършено",
|
||||
"ToastItemMarkedAsFinishedSuccess": "Елементът е маркиран като завършен",
|
||||
"ToastItemMarkedAsNotFinishedFailed": "Неуспешно маркиране като Незавършено",
|
||||
"ToastItemMarkedAsNotFinishedSuccess": "Елементът е маркиран като незавършен",
|
||||
"ToastItemUpdateSuccess": "Елементът е актуализиран",
|
||||
"ToastLibraryCreateFailed": "Неуспешно създаване на библиотека",
|
||||
"ToastLibraryCreateSuccess": "Библиотеката \"{0}\" е създадена",
|
||||
"ToastLibraryDeleteFailed": "Неуспешно изтриване на библиотека",
|
||||
@@ -1037,28 +1069,97 @@
|
||||
"ToastLibraryScanFailedToStart": "Неуспешно стартиране на сканиране",
|
||||
"ToastLibraryScanStarted": "Сканирането на библиотеката е стартирано",
|
||||
"ToastLibraryUpdateSuccess": "Библиотеката \"{0}\" е обновена",
|
||||
"ToastMatchAllAuthorsFailed": "Неуспешно съвпадение на всички автори",
|
||||
"ToastMetadataFilesRemovedError": "Грешка при премахване на metadata.{0} файлове",
|
||||
"ToastMetadataFilesRemovedNoneFound": "Не са намерени metadata.{0} файлове в библиотеката",
|
||||
"ToastMetadataFilesRemovedNoneRemoved": "Не са премахнати metadata.{0} файлове",
|
||||
"ToastMetadataFilesRemovedSuccess": "Премахнати са {0} файла metadata.{1}",
|
||||
"ToastMustHaveAtLeastOnePath": "Трябва да има поне един път",
|
||||
"ToastNameEmailRequired": "Изискват се име и имейл",
|
||||
"ToastNameRequired": "Изисква се име",
|
||||
"ToastNewApiKeyUserError": "Трябва да изберете потребител",
|
||||
"ToastNewEpisodesFound": "Намерени са {0} нови епизода",
|
||||
"ToastNewUserCreatedFailed": "Неуспешно създаване на акаунт: „{0}“",
|
||||
"ToastNewUserCreatedSuccess": "Създаден е нов акаунт",
|
||||
"ToastNewUserLibraryError": "Трябва да изберете поне една библиотека",
|
||||
"ToastNewUserPasswordError": "Трябва да има парола; само root потребителят може да бъде с празна парола",
|
||||
"ToastNewUserTagError": "Трябва да изберете поне един етикет",
|
||||
"ToastNewUserUsernameError": "Въведете потребителско име",
|
||||
"ToastNoNewEpisodesFound": "Не са намерени нови епизоди",
|
||||
"ToastNoRSSFeed": "Подкастът няма RSS емисия",
|
||||
"ToastNoUpdatesNecessary": "Не са необходими актуализации",
|
||||
"ToastNotificationCreateFailed": "Неуспешно създаване на известие",
|
||||
"ToastNotificationDeleteFailed": "Неуспешно изтриване на известието",
|
||||
"ToastNotificationFailedMaximum": "Максималният брой неуспешни опити трябва да бъде >= 0",
|
||||
"ToastNotificationQueueMaximum": "Максималната опашка за известия трябва да бъде >= 0",
|
||||
"ToastNotificationSettingsUpdateSuccess": "Настройките за известия са актуализирани",
|
||||
"ToastNotificationTestTriggerFailed": "Неуспешно задействане на тестово известие",
|
||||
"ToastNotificationTestTriggerSuccess": "Тестовото известие е задействано",
|
||||
"ToastNotificationUpdateSuccess": "Известието е актуализирано",
|
||||
"ToastPlaylistCreateFailed": "Неуспешно създаване на плейлист",
|
||||
"ToastPlaylistCreateSuccess": "Плейлистът е създаден",
|
||||
"ToastPlaylistRemoveSuccess": "Плейлистът е премахнат",
|
||||
"ToastPlaylistUpdateSuccess": "Плейлистът е обновен",
|
||||
"ToastPodcastCreateFailed": "Неуспешно създаване на подкаст",
|
||||
"ToastPodcastCreateSuccess": "Подкаст успешно създаден",
|
||||
"ToastPodcastEpisodeUpdated": "Епизодът е актуализиран",
|
||||
"ToastPodcastGetFeedFailed": "Неуспешно извличане на емисията на подкаста",
|
||||
"ToastPodcastNoEpisodesInFeed": "Не са намерени епизоди в RSS емисията",
|
||||
"ToastPodcastNoRssFeed": "Подкастът няма RSS емисия",
|
||||
"ToastProgressIsNotBeingSynced": "Напредъкът не се синхронизира, рестартирайте възпроизвеждането",
|
||||
"ToastProviderCreatedFailed": "Неуспешно добавяне на доставчик",
|
||||
"ToastProviderCreatedSuccess": "Добавен е нов доставчик",
|
||||
"ToastProviderNameAndUrlRequired": "Изискват се име и URL адрес",
|
||||
"ToastProviderRemoveSuccess": "Доставчикът е премахнат",
|
||||
"ToastRSSFeedCloseFailed": "Неуспешно затваряне на RSS емисията",
|
||||
"ToastRSSFeedCloseSuccess": "RSS емисията е затворена",
|
||||
"ToastRemoveFailed": "Неуспешно премахване",
|
||||
"ToastRemoveItemFromCollectionFailed": "Неуспешно премахване на елемент от колекция",
|
||||
"ToastRemoveItemFromCollectionSuccess": "Елементът е премахнат от колекция",
|
||||
"ToastRemoveItemsWithIssuesFailed": "Неуспешно премахване на елементите от библиотеката с проблеми",
|
||||
"ToastRemoveItemsWithIssuesSuccess": "Елементите от библиотеката с проблеми са премахнати",
|
||||
"ToastRenameFailed": "Неуспешно преименуване",
|
||||
"ToastRescanFailed": "Повторното сканиране е неуспешно за {0}",
|
||||
"ToastRescanRemoved": "Повторното сканиране завърши: елементът е премахнат",
|
||||
"ToastRescanUpToDate": "Повторното сканиране завърши: елементът вече е актуален",
|
||||
"ToastRescanUpdated": "Повторното сканиране завърши: елементът е актуализиран",
|
||||
"ToastScanFailed": "Неуспешно сканиране на елемент от библиотеката",
|
||||
"ToastSelectAtLeastOneUser": "Изберете поне един потребител",
|
||||
"ToastSendEbookToDeviceFailed": "Неуспешно изпращане на електронна книга до устройство",
|
||||
"ToastSendEbookToDeviceSuccess": "Електронната книга е изпратена до устройство \"{0}\"",
|
||||
"ToastSeriesSubmitFailedSameName": "Не могат да бъдат добавени два сериала с едно и също име",
|
||||
"ToastSeriesUpdateFailed": "Неуспешно обновяване на серия",
|
||||
"ToastSeriesUpdateSuccess": "Серията е обновена",
|
||||
"ToastServerSettingsUpdateSuccess": "Настройките на сървъра са актуализирани",
|
||||
"ToastSessionCloseFailed": "Неуспешно затваряне на сесията",
|
||||
"ToastSessionDeleteFailed": "Неуспешно изтриване на сесия",
|
||||
"ToastSessionDeleteSuccess": "Сесията е изтрита",
|
||||
"ToastSleepTimerDone": "Таймерът за заспиване приключи... zZzzZz",
|
||||
"ToastSlugMustChange": "Краткият URL (slug) съдържа невалидни символи",
|
||||
"ToastSlugRequired": "Изисква се кратък URL (slug)",
|
||||
"ToastSocketConnected": "Свързан сокет",
|
||||
"ToastSocketDisconnected": "Сокетът е прекъснат",
|
||||
"ToastSocketFailedToConnect": "Неуспешно свързване на сокет",
|
||||
"ToastSortingPrefixesEmptyError": "Трябва да има поне 1 префикс за сортиране",
|
||||
"ToastSortingPrefixesUpdateSuccess": "Префиксите за сортиране са актуализирани ({0} елемента)",
|
||||
"ToastTitleRequired": "Изисква се заглавие",
|
||||
"ToastUnknownError": "Неизвестна грешка",
|
||||
"ToastUnlinkOpenIdFailed": "Неуспешно прекъсване на връзката на потребителя с OpenID",
|
||||
"ToastUnlinkOpenIdSuccess": "Връзката на потребителя с OpenID е прекъсната",
|
||||
"ToastUploaderFilepathExistsError": "Файловият път „{0}“ вече съществува на сървъра",
|
||||
"ToastUploaderItemExistsInSubdirectoryError": "Елементът „{0}“ използва поддиректория на пътя за качване.",
|
||||
"ToastUserDeleteFailed": "Неуспешно изтриване на потребител",
|
||||
"ToastUserDeleteSuccess": "Потребителят е изтрит"
|
||||
"ToastUserDeleteSuccess": "Потребителят е изтрит",
|
||||
"ToastUserPasswordChangeSuccess": "Паролата е променена успешно",
|
||||
"ToastUserPasswordMismatch": "Паролите не съвпадат",
|
||||
"ToastUserPasswordMustChange": "Новата парола не може да бъде същата като старата",
|
||||
"ToastUserRootRequireName": "Трябва да въведете root потребителско име",
|
||||
"TooltipAddChapters": "Добавяне на глава(и)",
|
||||
"TooltipAddOneSecond": "Добавяне на 1 секунда",
|
||||
"TooltipAdjustChapterStart": "Кликнете за коригиране на началния час",
|
||||
"TooltipLockAllChapters": "Заключване на всички глави",
|
||||
"TooltipLockChapter": "Заключване на глава (Shift+клик за диапазон)",
|
||||
"TooltipSubtractOneSecond": "Изваждане на 1 секунда",
|
||||
"TooltipUnlockAllChapters": "Отключване на всички глави",
|
||||
"TooltipUnlockChapter": "Отключване на глава (Shift+клик за диапазон)"
|
||||
}
|
||||
+94
-9
@@ -46,6 +46,7 @@
|
||||
"ButtonLatest": "সর্বশেষ",
|
||||
"ButtonLibrary": "লাইব্রেরি",
|
||||
"ButtonLogout": "লগআউট",
|
||||
"ButtonLogoutAllDevices": "সব ডিভাইস থেকে লগআউট করুন",
|
||||
"ButtonLookup": "সন্ধান",
|
||||
"ButtonManageTracks": "ট্র্যাকগুলি পরিচালনা করুন",
|
||||
"ButtonMapChapterTitles": "অধ্যায়ের শিরোনাম ম্যাপ করুন",
|
||||
@@ -76,7 +77,7 @@
|
||||
"ButtonReScan": "পুনরায় স্ক্যান",
|
||||
"ButtonRead": "পড়ুন",
|
||||
"ButtonReadLess": "সংক্ষিপ্ত",
|
||||
"ButtonReadMore": "বিস্তারিত পড়ুন",
|
||||
"ButtonReadMore": "আরও পড়ুন",
|
||||
"ButtonRefresh": "রিফ্রেশ",
|
||||
"ButtonRemove": "মুছে ফেলুন",
|
||||
"ButtonRemoveAll": "সব মুছে ফেলুন",
|
||||
@@ -194,6 +195,7 @@
|
||||
"HeaderScheduleEpisodeDownloads": "স্বয়ংক্রিয় পর্ব ডাউনলোডের সময়সূচী নির্ধারন করুন",
|
||||
"HeaderScheduleLibraryScans": "স্বয়ংক্রিয় লাইব্রেরি স্ক্যানের সময়সূচী",
|
||||
"HeaderSession": "সেশন",
|
||||
"HeaderSessions": "সেশনসমূহ",
|
||||
"HeaderSetBackupSchedule": "ব্যাকআপ সময়সূচী সেট করুন",
|
||||
"HeaderSettings": "সেটিংস",
|
||||
"HeaderSettingsDisplay": "প্রদর্শন",
|
||||
@@ -212,6 +214,7 @@
|
||||
"HeaderTableOfContents": "বিষয়বস্তুর সারণী",
|
||||
"HeaderTools": "টুলস",
|
||||
"HeaderUpdateAccount": "অ্যাকাউন্ট আপডেট করুন",
|
||||
"HeaderUpdateApiKey": "API কী আপডেট করুন",
|
||||
"HeaderUpdateAuthor": "লেখক আপডেট করুন",
|
||||
"HeaderUpdateDetails": "বিশদ আপডেট করুন",
|
||||
"HeaderUpdateLibrary": "লাইব্রেরি আপডেট করুন",
|
||||
@@ -226,6 +229,7 @@
|
||||
"LabelAccountTypeAdmin": "প্রশাসন",
|
||||
"LabelAccountTypeGuest": "অতিথি",
|
||||
"LabelAccountTypeUser": "ব্যবহারকারী",
|
||||
"LabelActivities": "কার্যকলাপ",
|
||||
"LabelActivity": "ক্রিয়াকলাপ",
|
||||
"LabelAddToCollection": "সংগ্রহে যোগ করুন",
|
||||
"LabelAddToCollectionBatch": "সংগ্রহে {0}টি বই যোগ করুন",
|
||||
@@ -235,10 +239,15 @@
|
||||
"LabelAddedDate": "যোগ করা হয়েছে {0}",
|
||||
"LabelAdminUsersOnly": "শুধু অ্যাডমিন ব্যবহারকারী",
|
||||
"LabelAll": "সব",
|
||||
"LabelAllEpisodesDownloaded": "সব পর্ব ডাউনলোড করা হয়েছে",
|
||||
"LabelAllUsers": "সমস্ত ব্যবহারকারী",
|
||||
"LabelAllUsersExcludingGuests": "অতিথি ব্যতীত সকল ব্যবহারকারী",
|
||||
"LabelAllUsersIncludingGuests": "অতিথি সহ সকল ব্যবহারকারী",
|
||||
"LabelAlreadyInYourLibrary": "ইতিমধ্যেই আপনার লাইব্রেরিতে রয়েছে",
|
||||
"LabelApiKeyCreated": "API কী \"{0}\" সফলভাবে তৈরি করা হয়েছে।",
|
||||
"LabelApiKeyCreatedDescription": "API কীটি এখনই কপি করে রাখুন, কারণ এটি পরে আর দেখতে পারবেন না।",
|
||||
"LabelApiKeyUser": "ব্যবহারকারীর পক্ষ থেকে কাজ করুন",
|
||||
"LabelApiKeyUserDescription": "এই API কীটি যে ব্যবহারকারীর পক্ষ থেকে কাজ করবে, সেই ব্যবহারকারীর মতোই একই অনুমতি পাবে। লগে এটি এমনভাবে দেখাবে যেন ব্যবহারকারী নিজেই অনুরোধটি করেছেন।",
|
||||
"LabelApiToken": "API টোকেন",
|
||||
"LabelAppend": "সংযোজন",
|
||||
"LabelAudioBitrate": "অডিও বিটরেট (যেমন- 128k)",
|
||||
@@ -268,7 +277,7 @@
|
||||
"LabelBonus": "উপরিলাভ",
|
||||
"LabelBooks": "বইগুলো",
|
||||
"LabelButtonText": "ঘর পাঠ্য",
|
||||
"LabelByAuthor": "দ্বারা {0}",
|
||||
"LabelByAuthor": "{0} অবশ্যই একদম অপরিবর্তিত রাখবে",
|
||||
"LabelChangePassword": "পাসওয়ার্ড পরিবর্তন করুন",
|
||||
"LabelChannels": "চ্যানেল",
|
||||
"LabelChapterCount": "{0} অধ্যায়",
|
||||
@@ -288,8 +297,10 @@
|
||||
"LabelContinueListening": "শোনা চালিয়ে যান",
|
||||
"LabelContinueReading": "পড়া চালিয়ে যান",
|
||||
"LabelContinueSeries": "সিরিজ চালিয়ে যান",
|
||||
"LabelCorsAllowed": "অনুমোদিত CORS অরিজিনসমূহ",
|
||||
"LabelCover": "কভার",
|
||||
"LabelCoverImageURL": "ছবির কভারের URL",
|
||||
"LabelCoverProvider": "কভার প্রদানকারী",
|
||||
"LabelCreatedAt": "তৈরি করা হয়েছে",
|
||||
"LabelCronExpression": "Cron এক্সপ্রেশন",
|
||||
"LabelCurrent": "বর্তমান",
|
||||
@@ -300,6 +311,7 @@
|
||||
"LabelDeleteFromFileSystemCheckbox": "ফাইল সিস্টেম থেকে মুছে ফেলুন (শুধু ডাটাবেস থেকে সরাতে টিক চিহ্ন মুক্ত করুন)",
|
||||
"LabelDescription": "বিবরণ",
|
||||
"LabelDeselectAll": "সমস্ত অনির্বাচিত করুন",
|
||||
"LabelDetectedPattern": "শনাক্ত করা প্যাটার্ন:",
|
||||
"LabelDevice": "ডিভাইস",
|
||||
"LabelDeviceInfo": "ডিভাইস তথ্য",
|
||||
"LabelDeviceIsAvailableTo": "ডিভাইস এর জন্য উপলব্ধ...",
|
||||
@@ -349,7 +361,11 @@
|
||||
"LabelExample": "উদাহরণ",
|
||||
"LabelExpandSeries": "সিরিজ প্রসারিত করুন",
|
||||
"LabelExpandSubSeries": "সাব সিরিজ প্রসারিত করুন",
|
||||
"LabelExplicit": "বিশদ",
|
||||
"LabelExpired": "মেয়াদ শেষ হয়েছে",
|
||||
"LabelExpiresAt": "মেয়াদ শেষ হওয়ার সময়",
|
||||
"LabelExpiresInSeconds": "মেয়াদ শেষ হবে (সেকেন্ডে)",
|
||||
"LabelExpiresNever": "কখনোই নয়",
|
||||
"LabelExplicit": "অশ্লীল ভাষা",
|
||||
"LabelExplicitChecked": "সুস্পষ্ট (পরীক্ষিত)",
|
||||
"LabelExplicitUnchecked": "অস্পষ্ট (অপরিক্ষীত)",
|
||||
"LabelExportOPML": "OPML এক্সপোর্ট করুন",
|
||||
@@ -364,11 +380,12 @@
|
||||
"LabelFilterByUser": "ব্যবহারকারী দ্বারা ফিল্টারকৃত",
|
||||
"LabelFindEpisodes": "পর্বগুলো খুঁজুন",
|
||||
"LabelFinished": "সমাপ্ত",
|
||||
"LabelFinishedDate": "{0} সম্পন্ন হয়েছে",
|
||||
"LabelFolder": "ফোল্ডার",
|
||||
"LabelFolders": "ফোল্ডারগুলো",
|
||||
"LabelFontBold": "বোল্ড",
|
||||
"LabelFontBoldness": "হরফ বোল্ডনেস",
|
||||
"LabelFontFamily": "হরফ পরিবার",
|
||||
"LabelFontFamily": "ফন্টের ধরন",
|
||||
"LabelFontItalic": "ইটালিক",
|
||||
"LabelFontScale": "ফন্ট স্কেল",
|
||||
"LabelFontStrikethrough": "অবচ্ছেদন রেখা",
|
||||
@@ -398,7 +415,9 @@
|
||||
"LabelIntervalEvery6Hours": "প্রতি ৬ ঘন্টায়",
|
||||
"LabelIntervalEveryDay": "প্রতিদিন",
|
||||
"LabelIntervalEveryHour": "প্রতি ঘন্টা",
|
||||
"LabelIntervalEveryMinute": "প্রতি মিনিটে",
|
||||
"LabelInvert": "উল্টানো",
|
||||
"LabelIpAddress": "IP ঠিকানা",
|
||||
"LabelItem": "আইটেম",
|
||||
"LabelJumpBackwardAmount": "পিছন দিকে ঝাঁপের পরিমাণ",
|
||||
"LabelJumpForwardAmount": "সামনের দিকে ঝাঁপের পরিমাণ",
|
||||
@@ -407,6 +426,7 @@
|
||||
"LabelLanguages": "ভাষাসমূহ",
|
||||
"LabelLastBookAdded": "শেষ বই যোগ করা হয়েছে",
|
||||
"LabelLastBookUpdated": "শেষ বই আপডেট করা হয়েছে",
|
||||
"LabelLastProgressDate": "সর্বশেষ অগ্রগতি: {0}",
|
||||
"LabelLastSeen": "শেষ দেখা",
|
||||
"LabelLastTime": "শেষ বার",
|
||||
"LabelLastUpdate": "শেষ আপডেট",
|
||||
@@ -419,6 +439,9 @@
|
||||
"LabelLibraryFilterSublistEmpty": "না {0}",
|
||||
"LabelLibraryItem": "লাইব্রেরি আইটেম",
|
||||
"LabelLibraryName": "লাইব্রেরির নাম",
|
||||
"LabelLibrarySortByProgress": "অগ্রগতি: সর্বশেষ আপডেট",
|
||||
"LabelLibrarySortByProgressFinished": "অগ্রগতি: সম্পন্ন",
|
||||
"LabelLibrarySortByProgressStarted": "অগ্রগতি: শুরু হয়েছে",
|
||||
"LabelLimit": "সীমা",
|
||||
"LabelLineSpacing": "লাইন স্পেসিং",
|
||||
"LabelListenAgain": "আবার শুনুন",
|
||||
@@ -427,6 +450,7 @@
|
||||
"LabelLogLevelWarn": "সতর্ক",
|
||||
"LabelLookForNewEpisodesAfterDate": "এই তারিখের পরে নতুন পর্বগুলি সন্ধান করুন",
|
||||
"LabelLowestPriority": "সর্বনিম্ন অগ্রাধিকার",
|
||||
"LabelMatchConfidence": "নিশ্চয়তার মাত্রা",
|
||||
"LabelMatchExistingUsersBy": "বিদ্যমান ব্যবহারকারীদের দ্বারা মিলিত করুন",
|
||||
"LabelMatchExistingUsersByDescription": "বিদ্যমান ব্যবহারকারীদের সংযোগ করার জন্য ব্যবহৃত হয়। একবার সংযুক্ত হলে, ব্যবহারকারীদের আপনার SSO প্রদানকারীর থেকে একটি অনন্য আইডি দ্বারা মিলিত হবে",
|
||||
"LabelMaxEpisodesToDownload": "সর্বাধিক # টি পর্ব ডাউনলোড করা হবে। অসীমের জন্য 0 ব্যবহার করুন।",
|
||||
@@ -441,7 +465,7 @@
|
||||
"LabelMetadataProvider": "মেটাডেটা প্রদানকারী",
|
||||
"LabelMinute": "মিনিট",
|
||||
"LabelMinutes": "মিনিটস",
|
||||
"LabelMissing": "নিখোঁজ",
|
||||
"LabelMissing": "অনুপস্থিত",
|
||||
"LabelMissingEbook": "কোনও ই-বই নেই",
|
||||
"LabelMissingSupplementaryEbook": "কোনও সম্পূরক ই-বই নেই",
|
||||
"LabelMobileRedirectURIs": "অনুমোদিত মোবাইল রিডাইরেক্ট URIs",
|
||||
@@ -456,7 +480,9 @@
|
||||
"LabelNewestAuthors": "নতুন লেখক",
|
||||
"LabelNewestEpisodes": "নতুনতম পর্ব",
|
||||
"LabelNextBackupDate": "পরবর্তী ব্যাকআপ তারিখ",
|
||||
"LabelNextChapters": "পরবর্তী অধ্যায়গুলো হবে:",
|
||||
"LabelNextScheduledRun": "পরবর্তী নির্ধারিত দৌড়",
|
||||
"LabelNoApiKeys": "কোনো API কী নেই",
|
||||
"LabelNoCustomMetadataProviders": "কোনো কাস্টম মেটাডেটা প্রদানকারী নেই",
|
||||
"LabelNoEpisodesSelected": "কোন পর্ব নির্বাচন করা হয়নি",
|
||||
"LabelNotFinished": "সমাপ্ত হয়নি",
|
||||
@@ -472,7 +498,8 @@
|
||||
"LabelNotificationsMaxQueueSize": "বিজ্ঞপ্তি ইভেন্টের জন্য সর্বোচ্চ সারির আকার",
|
||||
"LabelNotificationsMaxQueueSizeHelp": "ইভেন্টগুলি প্রতি সেকেন্ডে ১ বার ইন্ধন করার মধ্যে সীমাবদ্ধ। সারি সর্বাধিক আকারে থাকলে ইভেন্টগুলি উপেক্ষা করা হবে। এটি বিজ্ঞপ্তি স্প্যামিং প্রতিরোধ করে।",
|
||||
"LabelNumberOfBooks": "বইয়ের সংখ্যা",
|
||||
"LabelNumberOfEpisodes": "# টি পর্ব",
|
||||
"LabelNumberOfChapters": "অধ্যায়ের সংখ্যা:",
|
||||
"LabelNumberOfEpisodes": "পর্বের সংখ্যা",
|
||||
"LabelOpenIDAdvancedPermsClaimDescription": "ওপেনআইডি দাবির নাম যাতে অ্যাপ্লিকেশনের মধ্যে ব্যবহারকারীর ক্রিয়াকলাপের জন্য উন্নত অনুমতি রয়েছে যা অ-প্রশাসক ভূমিকাগুলিতে প্রযোজ্য হবে (<b>যদি কনফিগার করা হয়</b>)। প্রতিক্রিয়া থেকে দাবিটি অনুপস্থিত থাকলে, অ্যাক্সেস করুন ABS-তে অস্বীকার করা হবে। যদি একটি একক বিকল্প অনুপস্থিত থাকে, তাহলে এটিকে <code>false</code> হিসাবে গণ্য করা হবে। নিশ্চিত করুন যে পরিচয় প্রদানকারীর দাবি প্রত্যাশিত কাঠামোর সাথে মেলে:",
|
||||
"LabelOpenIDClaims": "অ্যাডভান্সড গ্রুপ এবং পারমিশন অ্যাসাইনমেন্ট নিষ্ক্রিয় করতে নিম্নলিখিত বিকল্পগুলিকে খালি ছেড়ে দিন, তারপর স্বয়ংক্রিয়ভাবে 'ব্যবহারকারী' গ্রুপকে বরাদ্দ করা হবে।",
|
||||
"LabelOpenIDGroupClaimDescription": "ওপেনআইডি দাবির নাম যাতে ব্যবহারকারীর গোষ্ঠীর একটি তালিকা থাকে। সাধারণত <code>গ্রুপ</code> হিসাবে উল্লেখ করা হয়। <b>কনফিগার করা থাকলে</b>, অ্যাপ্লিকেশনটি স্বয়ংক্রিয়ভাবে এর উপর ভিত্তি করে ব্যবহারকারীর গোষ্ঠীর সদস্যপদ নির্ধারণ করবে, শর্ত এই যে এই গোষ্ঠীগুলি কেস-অসংবেদনশীলভাবে দাবিতে 'অ্যাডমিন', 'ব্যবহারকারী' বা 'অতিথি' নাম দেওয়া হয়৷ দাবিতে একটি তালিকা থাকা উচিত এবং যদি একজন ব্যবহারকারী একাধিক গোষ্ঠীর অন্তর্গত হয় তবে অ্যাপ্লিকেশনটি বরাদ্দ করবে সর্বোচ্চ স্তরের অ্যাক্সেসের সাথে সঙ্গতিপূর্ণ ভূমিকা৷ যদি কোনও গোষ্ঠীর সাথে মেলে না, তবে অ্যাক্সেস অস্বীকার করা হবে।",
|
||||
@@ -493,6 +520,7 @@
|
||||
"LabelPersonalYearReview": "আপনার বছরের পর্যালোচনা ({0})",
|
||||
"LabelPhotoPathURL": "ছবি পথ/ইউআরএল",
|
||||
"LabelPlayMethod": "প্লে পদ্ধতি",
|
||||
"LabelPlaybackRateIncrementDecrement": "প্লেব্যাকের গতি বাড়ানো/কমানোর পরিমাণ",
|
||||
"LabelPlayerChapterNumberMarker": "{1} এর মধ্যে {0}",
|
||||
"LabelPlaylists": "প্লেলিস্ট",
|
||||
"LabelPodcast": "পডকাস্ট",
|
||||
@@ -515,7 +543,7 @@
|
||||
"LabelPublishers": "প্রকাশকরা",
|
||||
"LabelRSSFeedCustomOwnerEmail": "কাস্টম মালিকের ইমেইল",
|
||||
"LabelRSSFeedCustomOwnerName": "কাস্টম মালিকের নাম",
|
||||
"LabelRSSFeedOpen": "আরএসএস ফিড খুলুন",
|
||||
"LabelRSSFeedOpen": "RSS ফিড খোলা আছে",
|
||||
"LabelRSSFeedPreventIndexing": "সূচীকরণ প্রতিরোধ করুন",
|
||||
"LabelRSSFeedSlug": "আরএসএস ফিড স্লাগ",
|
||||
"LabelRSSFeedURL": "আরএসএস ফিড ইউআরএল",
|
||||
@@ -532,6 +560,7 @@
|
||||
"LabelReleaseDate": "উন্মোচনের তারিখ",
|
||||
"LabelRemoveAllMetadataAbs": "সমস্ত metadata.abs ফাইল সরান",
|
||||
"LabelRemoveAllMetadataJson": "সমস্ত metadata.json ফাইল সরান",
|
||||
"LabelRemoveAudibleBranding": "অধ্যায়গুলো থেকে Audible-এর ইন্ট্রো ও আউট্রো সরান",
|
||||
"LabelRemoveCover": "কভার সরান",
|
||||
"LabelRemoveMetadataFile": "লাইব্রেরি আইটেম ফোল্ডারে মেটাডেটা ফাইল সরান",
|
||||
"LabelRemoveMetadataFileHelp": "আপনার {0} ফোল্ডারের সমস্ত metadata.json এবং metadata.abs ফাইলগুলি সরান।",
|
||||
@@ -544,6 +573,7 @@
|
||||
"LabelSelectAll": "সব নির্বাচন করুন",
|
||||
"LabelSelectAllEpisodes": "সমস্ত পর্ব নির্বাচন করুন",
|
||||
"LabelSelectEpisodesShowing": "দেখানো {0}টি পর্ব নির্বাচন করুন",
|
||||
"LabelSelectUser": "ব্যবহারকারী নির্বাচন করুন",
|
||||
"LabelSelectUsers": "ব্যবহারকারী নির্বাচন করুন",
|
||||
"LabelSendEbookToDevice": "ই-বই পাঠান...",
|
||||
"LabelSequence": "ক্রম",
|
||||
@@ -561,6 +591,8 @@
|
||||
"LabelSettingsBookshelfViewHelp": "কাঠের তাক সহ স্কুমরফিক ডিজাইন",
|
||||
"LabelSettingsChromecastSupport": "ক্রোমকাস্ট সমর্থন",
|
||||
"LabelSettingsDateFormat": "তারিখ বিন্যাস",
|
||||
"LabelSettingsEnableWatcher": "পরিবর্তনের জন্য লাইব্রেরিগুলো স্বয়ংক্রিয়ভাবে পর্যবেক্ষণ করুন",
|
||||
"LabelSettingsEnableWatcherForLibrary": "লাইব্রেরির জন্য ফোল্ডার প্রহরী সক্ষম করুন",
|
||||
"LabelSettingsEnableWatcherHelp": "ফাইলের পরিবর্তন শনাক্ত হলে আইটেমগুলির স্বয়ংক্রিয় যোগ/আপডেট সক্ষম করবে। *সার্ভার পুনরায় চালু করতে হবে",
|
||||
"LabelSettingsEpubsAllowScriptedContent": "ইপাবে স্ক্রিপ্ট করা বিষয়বস্তুর অনুমতি দিন",
|
||||
"LabelSettingsEpubsAllowScriptedContentHelp": "ইপাব ফাইলগুলিকে স্ক্রিপ্ট চালানোর অনুমতি দিন। আপনি ইপাব ফাইলগুলির উৎসকে বিশ্বাস না করলে এই সেটিংটি নিষ্ক্রিয় রাখার সুপারিশ করা হলো।",
|
||||
@@ -578,7 +610,7 @@
|
||||
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "কন্টিনিউ সিরিজে আগের বইগুলো এড়িয়ে যান",
|
||||
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "কন্টিনিউ সিরিজের নীড় পেজ শেল্ফ দেখায় যে সিরিজে শুরু হয়নি এমন প্রথম বই যার অন্তত একটি বই শেষ হয়েছে এবং কোনো বই চলছে না। এই সেটিংটি সক্ষম করলে শুরু না হওয়া প্রথম বইটির পরিবর্তে সবচেয়ে দূরের সম্পূর্ণ বই থেকে সিরিজ চলতে থাকবে।",
|
||||
"LabelSettingsParseSubtitles": "সাবটাইটেল পার্স করুন",
|
||||
"LabelSettingsParseSubtitlesHelp": "অডিওবুক ফোল্ডারের নাম থেকে সাবটাইটেল বের করুন৷<br>সাবটাইটেল অবশ্যই \" - \"<br>অর্থাৎ \"বুকের শিরোনাম - এখানে একটি সাবটাইটেল\" এর সাবটাইটেল আছে \"এখানে একটি সাবটাইটেল\"",
|
||||
"LabelSettingsParseSubtitlesHelp": "অডিওবুক ফোল্ডারের নাম থেকে সাবটাইটেল শনাক্ত করুন।<br>সাবটাইটেল অবশ্যই \" - \" দিয়ে আলাদা করা থাকতে হবে।<br>যেমন, \"বইয়ের নাম - একটি সাবটাইটেল\" হলে সাবটাইটেল হবে \"একটি সাবটাইটেল\"",
|
||||
"LabelSettingsPreferMatchedMetadata": "মিলিত মেটাডেটা পছন্দ করুন",
|
||||
"LabelSettingsPreferMatchedMetadataHelp": "দ্রুত ম্যাচ ব্যবহার করার সময় মিলে যাওয়া ডেটা আইটেমের বিবরণকে ওভাররাইড করবে। ডিফল্টরূপে দ্রুত ম্যাচ শুধুমাত্র অনুপস্থিত বিশদগুলি পূরণ করবে।",
|
||||
"LabelSettingsSkipMatchingBooksWithASIN": "এমন বইগুলি এড়িয়ে যান যেগুলির মধ্যে ইতিমধ্যে একটি ASIN আছে",
|
||||
@@ -604,10 +636,12 @@
|
||||
"LabelSlug": "স্লাগ",
|
||||
"LabelSortAscending": "আরোহী",
|
||||
"LabelSortDescending": "অবরোহী",
|
||||
"LabelSortPubDate": "প্রকাশের তারিখ অনুযায়ী সাজান",
|
||||
"LabelStart": "শুরু",
|
||||
"LabelStartTime": "শুরুর সময়",
|
||||
"LabelStarted": "শুরু হয়েছে",
|
||||
"LabelStartedAt": "এতে শুরু হয়েছে",
|
||||
"LabelStartedDate": "{0} শুরু হয়েছে",
|
||||
"LabelStatsAudioTracks": "অডিও ট্র্যাক",
|
||||
"LabelStatsAuthors": "লেখক",
|
||||
"LabelStatsBestDay": "সেরা দিন",
|
||||
@@ -637,6 +671,7 @@
|
||||
"LabelTheme": "থিম",
|
||||
"LabelThemeDark": "অন্ধকার",
|
||||
"LabelThemeLight": "আলো",
|
||||
"LabelThemeSepia": "সেপিয়া",
|
||||
"LabelTimeBase": "সময় বেস",
|
||||
"LabelTimeDurationXHours": "{0} ঘণ্টা",
|
||||
"LabelTimeDurationXMinutes": "{0} মিনিট",
|
||||
@@ -704,23 +739,33 @@
|
||||
"LabelYourProgress": "আপনার অগ্রগতি",
|
||||
"MessageAddToPlayerQueue": "প্লেয়ার সারিতে যোগ করুন",
|
||||
"MessageAppriseDescription": "এই বৈশিষ্ট্যটি ব্যবহার করার জন্য আপনাকে <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> চালানোর একটি উদাহরণ বা একটি এপিআই পরিচালনা করতে হবে যে একই অনুরোধ পরিচালনা করবে। <br />অ্যাপ্রাইজ এপিআই ইউআরএলটি বিজ্ঞপ্তি পাঠানোর জন্য সম্পূর্ণ ইউআরএল পথ হওয়া উচিত, যেমন, যদি আপনার API ইনস্ট্যান্স <code>http://192.168.1.1:8337</code> এ পরিবেশিত হয় তাহলে আপনি <code> রাখবেন >http://192.168.1.1:8337/notify</code>।",
|
||||
"MessageAsinCheck": "নিশ্চিত করুন যে আপনি Amazon-এর নয়, সঠিক Audible অঞ্চলের ASIN ব্যবহার করছেন।",
|
||||
"MessageAuthenticationLegacyTokenWarning": "পুরোনো API টোকেনগুলো ভবিষ্যতে সরিয়ে দেওয়া হবে। এর পরিবর্তে <a href=\"/config/api-keys\">API কী</a> ব্যবহার করুন।",
|
||||
"MessageAuthenticationOIDCChangesRestart": "OIDC-এর পরিবর্তনগুলো কার্যকর করতে সেভ করার পর সার্ভারটি পুনরায় চালু করুন।",
|
||||
"MessageAuthenticationSecurityMessage": "নিরাপত্তার জন্য অথেনটিকেশন ব্যবস্থা উন্নত করা হয়েছে। সকল ব্যবহারকারীকে আবার লগইন করতে হবে।",
|
||||
"MessageBackupsDescription": "ব্যাকআপের মধ্যে রয়েছে ব্যবহারকারী, ব্যবহারকারীর অগ্রগতি, লাইব্রেরি আইটেমের বিবরণ, সার্ভার সেটিংস এবং <code>/metadata/items</code> & <code>/metadata/authors</code>-এ সংরক্ষিত ছবি। ব্যাকআপগুলি <strong> আপনার লাইব্রেরি ফোল্ডারে সঞ্চিত কোনো ফাইল >অন্তর্ভুক্ত করবেন না</strong>।",
|
||||
"MessageBackupsLocationEditNote": "দ্রষ্টব্য: ব্যাকআপ অবস্থান আপডেট করলে বিদ্যমান ব্যাকআপগুলি সরানো বা সংশোধন করা হবে না",
|
||||
"MessageBackupsLocationNoEditNote": "দ্রষ্টব্য: ব্যাকআপ অবস্থান একটি পরিবেশ পরিবর্তনশীল মাধ্যমে স্থির করা হয়েছে এবং এখানে পরিবর্তন করা যাবে না।",
|
||||
"MessageBackupsLocationPathEmpty": "ব্যাকআপ অবস্থানের পথ খালি থাকতে পারবে না",
|
||||
"MessageBatchEditPopulateMapDetailsAllHelp": "সক্রিয় ফিল্ডগুলো সব আইটেমের তথ্য দিয়ে পূরণ করুন। একাধিক মান থাকা ফিল্ডগুলোর মান একত্রিত করা হবে",
|
||||
"MessageBatchEditPopulateMapDetailsItemHelp": "সক্রিয় ম্যাপের বিস্তারিত ফিল্ডগুলো এই আইটেমের তথ্য দিয়ে পূরণ করুন",
|
||||
"MessageBatchQuickMatchDescription": "কুইক ম্যাচ নির্বাচিত আইটেমগুলির জন্য অনুপস্থিত কভার এবং মেটাডেটা যোগ করার চেষ্টা করবে। বিদ্যমান কভার এবং/অথবা মেটাডেটা ওভাররাইট করার জন্য দ্রুত ম্যাচকে অনুমতি দিতে নীচের বিকল্পগুলি সক্ষম করুন।",
|
||||
"MessageBookshelfNoCollections": "আপনি এখনও কোনো সংগ্রহ করেননি",
|
||||
"MessageBookshelfNoCollectionsHelp": "কালেকশনগুলো সবার জন্য উন্মুক্ত। লাইব্রেরিতে অ্যাক্সেস থাকা সকল ব্যবহারকারী এগুলো দেখতে পারবেন।",
|
||||
"MessageBookshelfNoRSSFeeds": "কোনও RSS ফিড খোলা নেই",
|
||||
"MessageBookshelfNoResultsForFilter": "ফিল্টার \"{0}: {1}\" এর জন্য কোন ফলাফল নেই",
|
||||
"MessageBookshelfNoResultsForQuery": "প্রশ্নের জন্য কোন ফলাফল নেই",
|
||||
"MessageBookshelfNoSeries": "আপনার কোনো সিরিজ নেই",
|
||||
"MessageBulkChapterPattern": "এই নম্বর দেওয়ার ধরন অনুযায়ী আপনি কতটি অধ্যায় যোগ করতে চান?",
|
||||
"MessageChapterEndIsAfter": "অধ্যায়ের সমাপ্তি আপনার অডিওবুকের শেষে",
|
||||
"MessageChapterErrorFirstNotZero": "প্রথম অধ্যায় 0 এ শুরু হতে হবে",
|
||||
"MessageChapterErrorStartGteDuration": "অবৈধ শুরুর সময় অবশ্যই অডিওবুকের সময়কালের কম হতে হবে",
|
||||
"MessageChapterErrorStartLtPrev": "অবৈধ শুরুর সময় অবশ্যই আগের অধ্যায় শুরুর সময়ের চেয়ে বেশি বা সমান হতে হবে",
|
||||
"MessageChapterStartIsAfter": "আপনার অডিওবুক শেষ হওয়ার পরে অধ্যায় শুরু হয়",
|
||||
"MessageChaptersNotFound": "কোনো অধ্যায় পাওয়া যায়নি",
|
||||
"MessageCheckingCron": "ক্রন পরীক্ষা করা হচ্ছে...",
|
||||
"MessageConfirmCloseFeed": "আপনি কি নিশ্চিত যে আপনি এই ফিডটি বন্ধ করতে চান?",
|
||||
"MessageConfirmDeleteApiKey": "আপনি কি নিশ্চিত যে API কী \"{0}\" মুছে ফেলতে চান?",
|
||||
"MessageConfirmDeleteBackup": "আপনি কি নিশ্চিত যে আপনি {0} এর ব্যাকআপ মুছে ফেলতে চান?",
|
||||
"MessageConfirmDeleteDevice": "আপনি কি নিশ্চিতভাবে ই-রিডার ডিভাইস \"{0}\" মুছতে চান?",
|
||||
"MessageConfirmDeleteFile": "এটি আপনার ফাইল সিস্টেম থেকে ফাইলটি মুছে দেবে। আপনি কি নিশ্চিত?",
|
||||
@@ -732,6 +777,7 @@
|
||||
"MessageConfirmDeleteSession": "আপনি কি নিশ্চিত আপনি এই অধিবেশন মুছে দিতে চান?",
|
||||
"MessageConfirmEmbedMetadataInAudioFiles": "আপনি কি {0}টি অডিও ফাইলে মেটাডেটা এম্বেড করার বিষয়ে নিশ্চিত?",
|
||||
"MessageConfirmForceReScan": "আপনি কি নিশ্চিত যে আপনি জোর করে পুনরায় স্ক্যান করতে চান?",
|
||||
"MessageConfirmLogoutDevice": "আপনি কি নিশ্চিত যে \"{0}\" থেকে লগআউট করতে চান?",
|
||||
"MessageConfirmMarkAllEpisodesFinished": "আপনি কি নিশ্চিত যে আপনি সমস্ত পর্ব সমাপ্ত হিসাবে চিহ্নিত করতে চান?",
|
||||
"MessageConfirmMarkAllEpisodesNotFinished": "আপনি কি নিশ্চিত যে আপনি সমস্ত পর্বকে শেষ হয়নি বলে চিহ্নিত করতে চান?",
|
||||
"MessageConfirmMarkItemFinished": "আপনি কি \"{0}\" কে সমাপ্ত হিসাবে চিহ্নিত করার বিষয়ে নিশ্চিত?",
|
||||
@@ -748,6 +794,7 @@
|
||||
"MessageConfirmRemoveAuthor": "আপনি কি নিশ্চিত যে আপনি লেখক \"{0}\" অপসারণ করতে চান?",
|
||||
"MessageConfirmRemoveCollection": "আপনি কি নিশ্চিত যে আপনি সংগ্রহ \"{0}\" সরাতে চান?",
|
||||
"MessageConfirmRemoveEpisode": "আপনি কি নিশ্চিত আপনি \"{0}\" পর্বটি সরাতে চান?",
|
||||
"MessageConfirmRemoveEpisodeNote": "দ্রষ্টব্য: \"Hard delete file\" চালু না করলে অডিও ফাইলটি মুছে ফেলা হবে না",
|
||||
"MessageConfirmRemoveEpisodes": "আপনি কি নিশ্চিত যে আপনি {0}টি পর্ব সরাতে চান?",
|
||||
"MessageConfirmRemoveListeningSessions": "আপনি কি নিশ্চিত যে আপনি {0}টি শোনার সেশন সরাতে চান?",
|
||||
"MessageConfirmRemoveMetadataFiles": "আপনি কি আপনার লাইব্রেরি আইটেম ফোল্ডারে থাকা সমস্ত মেটাডেটা {0} ফাইল মুছে ফেলার বিষয়ে নিশ্চিত?",
|
||||
@@ -762,6 +809,7 @@
|
||||
"MessageConfirmResetProgress": "আপনি কি আপনার অগ্রগতি রিসেট করার বিষয়ে নিশ্চিত?",
|
||||
"MessageConfirmSendEbookToDevice": "আপনি কি নিশ্চিত যে আপনি \"{2}\" ডিভাইসে {0} ইবুক \"{1}\" পাঠাতে চান?",
|
||||
"MessageConfirmUnlinkOpenId": "আপনি কি এই ব্যবহারকারীকে ওপেনআইডি থেকে লিঙ্কমুক্ত করার বিষয়ে নিশ্চিত?",
|
||||
"MessageDaysListenedInTheLastYear": "গত এক বছরে {0} দিন শোনা হয়েছে",
|
||||
"MessageDownloadingEpisode": "ডাউনলোডিং পর্ব",
|
||||
"MessageDragFilesIntoTrackOrder": "সঠিক ট্র্যাক অর্ডারে ফাইল টেনে আনুন",
|
||||
"MessageEmbedFailed": "এম্বেড ব্যর্থ হয়েছে!",
|
||||
@@ -772,8 +820,11 @@
|
||||
"MessageFeedURLWillBe": "ফিড URL হবে {0}",
|
||||
"MessageFetching": "আনয় হচ্ছে.।",
|
||||
"MessageForceReScanDescription": "সকল ফাইল আবার নতুন স্ক্যানের মত স্ক্যান করবে। অডিও ফাইল ID3 ট্যাগ, OPF ফাইল, এবং টেক্সট ফাইলগুলি নতুন হিসাবে স্ক্যান করা হবে।",
|
||||
"MessageHeatmapListeningTimeTooltip": "{1}-এ <strong>{0} জন শুনছেন</strong>",
|
||||
"MessageHeatmapNoListeningSessions": "{0}-এ কোনো শোনার সেশন নেই",
|
||||
"MessageImportantNotice": "গুরুত্বপূর্ণ বিজ্ঞপ্তি!",
|
||||
"MessageInsertChapterBelow": "নীচে অধ্যায় ঢোকান",
|
||||
"MessageInvalidAsin": "অবৈধ ASIN",
|
||||
"MessageItemsSelected": "{0}টি আইটেম নির্বাচিত",
|
||||
"MessageItemsUpdated": "{0}টি আইটেম আপডেট করা হয়েছে",
|
||||
"MessageJoinUsOn": "আমাদের সাথে যোগ দিন",
|
||||
@@ -819,6 +870,7 @@
|
||||
"MessageNoTasksRunning": "কোন টাস্ক চলছে না",
|
||||
"MessageNoUpdatesWereNecessary": "কোন আপডেটের প্রয়োজন ছিল না",
|
||||
"MessageNoUserPlaylists": "আপনার কোনো প্লেলিস্ট নেই",
|
||||
"MessageNoUserPlaylistsHelp": "প্লেলিস্টগুলো ব্যক্তিগত। শুধুমাত্র যে ব্যবহারকারী এগুলো তৈরি করেছেন তিনিই সেগুলো দেখতে পারবেন।",
|
||||
"MessageNotYetImplemented": "এখনও বাস্তবায়িত হয়নি",
|
||||
"MessageOpmlPreviewNote": "দ্রষ্টব্য: এটি পার্স করা OPML ফাইলের একটি পূর্বরূপ। প্রকৃত পডকাস্ট শিরোনাম RSS ফিড থেকে নেওয়া হবে।",
|
||||
"MessageOr": "বা",
|
||||
@@ -840,8 +892,11 @@
|
||||
"MessageResetChaptersConfirm": "আপনি কি নিশ্চিত যে আপনি অধ্যায়গুলি পুনরায় সেট করতে চান এবং আপনার করা পরিবর্তনগুলি পূর্বাবস্থায় ফেরাতে চান?",
|
||||
"MessageRestoreBackupConfirm": "আপনি কি নিশ্চিত যে আপনি তৈরি করা ব্যাকআপ পুনরুদ্ধার করতে চান",
|
||||
"MessageRestoreBackupWarning": "একটি ব্যাকআপ পুনরুদ্ধার করা হলে তা /config-এ অবস্থিত সমগ্র ডাটাবেস ওভাররাইট করবে এবং /metadata/items & /metadata/authors-এ থাকা ছবিগুলিকে কভার করবে৷<br /><br />ব্যাকআপগুলি আপনার লাইব্রেরি ফোল্ডারে কোনো ফাইল পরিবর্তন করে না৷ আপনি যদি আপনার লাইব্রেরি ফোল্ডারে কভার আর্ট এবং মেটাডেটা সংরক্ষণ করতে সার্ভার সেটিংস সক্ষম করে থাকেন তবে সেগুলি ব্যাক আপ বা ওভাররাইট করা হয় না৷<br /><br />আপনার সার্ভার ব্যবহারকারী সমস্ত ক্লায়েন্ট স্বয়ংক্রিয়ভাবে রিফ্রেশ হবে৷",
|
||||
"MessageScheduleLibraryScanNote": "বেশিরভাগ ব্যবহারকারীর জন্য এই ফিচারটি বন্ধ রেখে \"Automatically watch library for changes\" সেটিংটি চালু রাখার পরামর্শ দেওয়া হয়—এটি আপনার লাইব্রেরি ফোল্ডারের পরিবর্তনগুলো স্বয়ংক্রিয়ভাবে শনাক্ত করবে। আপনার ফাইল সিস্টেমে (যেমন NFS) \"Automatically watch library for changes\" কাজ না করলে এই ফিচারটি চালু করুন।",
|
||||
"MessageScheduleRunEveryWeekdayAtTime": "প্রতি {0} অন্তর {1}-এ চালান",
|
||||
"MessageSearchResultsFor": "এর জন্য অনুসন্ধান ফলাফল",
|
||||
"MessageSelected": "{0}টি নির্বাচিত",
|
||||
"MessageSeriesSequenceCannotContainSpaces": "সিরিজের ক্রমে স্পেস থাকতে পারবে না",
|
||||
"MessageServerCouldNotBeReached": "সার্ভারে পৌঁছানো যায়নি",
|
||||
"MessageSetChaptersFromTracksDescription": "প্রতিটি অডিও ফাইলকে অধ্যায় হিসেবে ব্যবহার করে অধ্যায় সেট করুন এবং অডিও ফাইলের নাম হিসেবে অধ্যায়ের শিরোনাম করুন",
|
||||
"MessageShareExpirationWillBe": "মেয়াদ শেষ হবে <strong>{0}</strong>",
|
||||
@@ -903,7 +958,10 @@
|
||||
"NotificationOnBackupCompletedDescription": "ব্যাকআপ সম্পূর্ণ হলে ট্রিগার হবে",
|
||||
"NotificationOnBackupFailedDescription": "ব্যাকআপ ব্যর্থ হলে ট্রিগার হবে",
|
||||
"NotificationOnEpisodeDownloadedDescription": "একটি পডকাস্ট পর্ব স্বয়ংক্রিয়ভাবে ডাউনলোড হলে ট্রিগার হবে",
|
||||
"NotificationOnRSSFeedDisabledDescription": "অনেকবার ডাউনলোডের চেষ্টা ব্যর্থ হওয়ার কারণে স্বয়ংক্রিয় পর্ব ডাউনলোড বন্ধ হয়ে গেলে এটি ট্রিগার হয়",
|
||||
"NotificationOnRSSFeedFailedDescription": "স্বয়ংক্রিয় পর্ব ডাউনলোডের জন্য RSS ফিডের অনুরোধ ব্যর্থ হলে এটি ট্রিগার হয়",
|
||||
"NotificationOnTestDescription": "বিজ্ঞপ্তি সিস্টেম পরীক্ষার জন্য ইভেন্ট",
|
||||
"PlaceholderBulkChapterInput": "অধ্যায়ের শিরোনাম লিখুন অথবা নম্বর ব্যবহার করুন (যেমন, 'পর্ব 1', 'অধ্যায় 10', '1.')",
|
||||
"PlaceholderNewCollection": "নতুন সংগ্রহের নাম",
|
||||
"PlaceholderNewFolderPath": "নতুন ফোল্ডার পথ",
|
||||
"PlaceholderNewPlaylist": "নতুন প্লেলিস্টের নাম",
|
||||
@@ -947,6 +1005,7 @@
|
||||
"ToastBackupRestoreFailed": "ব্যাকআপ পুনরুদ্ধার করতে ব্যর্থ",
|
||||
"ToastBackupUploadFailed": "ব্যাকআপ আপলোড করতে ব্যর্থ",
|
||||
"ToastBackupUploadSuccess": "ব্যাকআপ আপলোড হয়েছে",
|
||||
"ToastBatchApplyDetailsToItemsSuccess": "আইটেমগুলোতে বিস্তারিত তথ্য প্রয়োগ করা হয়েছে",
|
||||
"ToastBatchDeleteFailed": "ব্যাচ মুছে ফেলতে ব্যর্থ হয়েছে",
|
||||
"ToastBatchDeleteSuccess": "ব্যাচ মুছে ফেলা সফল হয়েছে",
|
||||
"ToastBatchQuickMatchFailed": "ব্যাচ কুইক ম্যাচ ব্যর্থ!",
|
||||
@@ -956,16 +1015,25 @@
|
||||
"ToastBookmarkCreateFailed": "বুকমার্ক তৈরি করতে ব্যর্থ",
|
||||
"ToastBookmarkCreateSuccess": "বুকমার্ক যোগ করা হয়েছে",
|
||||
"ToastBookmarkRemoveSuccess": "বুকমার্ক সরানো হয়েছে",
|
||||
"ToastBulkChapterInvalidCount": "১ থেকে ১৫০-এর মধ্যে একটি সংখ্যা লিখুন",
|
||||
"ToastCachePurgeFailed": "ক্যাশে পরিষ্কার করতে ব্যর্থ হয়েছে",
|
||||
"ToastCachePurgeSuccess": "ক্যাশে সফলভাবে পরিষ্কার করা হয়েছে",
|
||||
"ToastChapterLocked": "অধ্যায়টি লক করা আছে।",
|
||||
"ToastChapterStartTimeAdjusted": "অধ্যায়ের শুরুর সময় {0} সেকেন্ড সমন্বয় করা হয়েছে",
|
||||
"ToastChaptersAllLocked": "সব অধ্যায় লক করা আছে। সময় পরিবর্তন করতে কিছু অধ্যায় আনলক করুন।",
|
||||
"ToastChaptersHaveErrors": "অধ্যায়ে ত্রুটি আছে",
|
||||
"ToastChaptersInvalidShiftAmountLast": "সময় সরানোর পরিমাণ সঠিক নয়। এতে শেষ অধ্যায়ের শুরুর সময় এই অডিওবুকের মোট সময়সীমা ছাড়িয়ে যাবে।",
|
||||
"ToastChaptersInvalidShiftAmountStart": "সময় সরানোর পরিমাণ সঠিক নয়। প্রথম অধ্যায়ের দৈর্ঘ্য শূন্য বা ঋণাত্মক হয়ে যাবে এবং দ্বিতীয় অধ্যায়টি এটিকে ওভাররাইট করবে। দ্বিতীয় অধ্যায়ের শুরুর সময় বাড়ান।",
|
||||
"ToastChaptersMustHaveTitles": "অধ্যায়ের শিরোনাম থাকতে হবে",
|
||||
"ToastChaptersRemoved": "অধ্যায়গুলো মুছে ফেলা হয়েছে",
|
||||
"ToastChaptersUpdated": "অধ্যায় আপডেট করা হয়েছে",
|
||||
"ToastCollectionItemsAddFailed": "আইটেম(গুলি) সংগ্রহে যোগ করা ব্যর্থ হয়েছে",
|
||||
"ToastCollectionRemoveSuccess": "সংগ্রহ সরানো হয়েছে",
|
||||
"ToastCollectionUpdateSuccess": "সংগ্রহ আপডেট করা হয়েছে",
|
||||
"ToastConnectionNotAvailable": "সংযোগ পাওয়া যাচ্ছে না। অনুগ্রহ করে পরে আবার চেষ্টা করুন",
|
||||
"ToastCoverSearchFailed": "কভার অনুসন্ধান ব্যর্থ হয়েছে",
|
||||
"ToastCoverUpdateFailed": "কভার আপডেট ব্যর্থ হয়েছে",
|
||||
"ToastDateTimeInvalidOrIncomplete": "তারিখ ও সময় সঠিক নয় অথবা অসম্পূর্ণ",
|
||||
"ToastDeleteFileFailed": "ফাইল মুছে ফেলতে ব্যর্থ হয়েছে",
|
||||
"ToastDeleteFileSuccess": "ফাইল মুছে ফেলা হয়েছে",
|
||||
"ToastDeviceAddFailed": "ডিভাইস যোগ করতে ব্যর্থ হয়েছে",
|
||||
@@ -979,6 +1047,8 @@
|
||||
"ToastEpisodeDownloadQueueClearSuccess": "পর্ব ডাউনলোড সারি পরিষ্কার করা হয়েছে",
|
||||
"ToastEpisodeUpdateSuccess": "{0}টি পর্ব আপডেট করা হয়েছে",
|
||||
"ToastErrorCannotShare": "এই ডিভাইসে স্থানীয়ভাবে শেয়ার করা যাবে না",
|
||||
"ToastFailedToCreate": "তৈরি করা যায়নি",
|
||||
"ToastFailedToDelete": "মুছে ফেলা যায়নি",
|
||||
"ToastFailedToLoadData": "ডেটা লোড করা যায়নি",
|
||||
"ToastFailedToMatch": "মেলাতে ব্যর্থ হয়েছে",
|
||||
"ToastFailedToShare": "শেয়ার করতে ব্যর্থ",
|
||||
@@ -986,6 +1056,7 @@
|
||||
"ToastInvalidImageUrl": "অকার্যকর ছবির ইউআরএল",
|
||||
"ToastInvalidMaxEpisodesToDownload": "ডাউনলোড করার জন্য অবৈধ সর্বোচ্চ পর্ব",
|
||||
"ToastInvalidUrl": "অকার্যকর ইউআরএল",
|
||||
"ToastInvalidUrls": "এক বা একাধিক URL সঠিক নয়",
|
||||
"ToastItemCoverUpdateSuccess": "আইটেম কভার আপডেট করা হয়েছে",
|
||||
"ToastItemDeletedFailed": "আইটেম মুছে ফেলতে ব্যর্থ",
|
||||
"ToastItemDeletedSuccess": "মুছে ফেলা আইটেম",
|
||||
@@ -1010,6 +1081,7 @@
|
||||
"ToastMustHaveAtLeastOnePath": "অন্তত একটি পথ থাকতে হবে",
|
||||
"ToastNameEmailRequired": "নাম এবং ইমেইল আবশ্যক",
|
||||
"ToastNameRequired": "নাম আবশ্যক",
|
||||
"ToastNewApiKeyUserError": "একজন ব্যবহারকারী নির্বাচন করতে হবে",
|
||||
"ToastNewEpisodesFound": "{0}টি নতুন পর্ব পাওয়া গেছে",
|
||||
"ToastNewUserCreatedFailed": "অ্যাকাউন্ট তৈরি করতে ব্যর্থ: \"{0}\"",
|
||||
"ToastNewUserCreatedSuccess": "নতুন একাউন্ট তৈরি হয়েছে",
|
||||
@@ -1018,6 +1090,7 @@
|
||||
"ToastNewUserTagError": "অন্তত একটি ট্যাগ নির্বাচন করতে হবে",
|
||||
"ToastNewUserUsernameError": "একটি ব্যবহারকারীর নাম লিখুন",
|
||||
"ToastNoNewEpisodesFound": "কোন নতুন পর্ব পাওয়া যায়নি",
|
||||
"ToastNoRSSFeed": "ডকাস্টটির কোনো RSS ফিড নেই",
|
||||
"ToastNoUpdatesNecessary": "কোন আপডেটের প্রয়োজন নেই",
|
||||
"ToastNotificationCreateFailed": "বিজ্ঞপ্তি তৈরি করতে ব্যর্থ",
|
||||
"ToastNotificationDeleteFailed": "বিজ্ঞপ্তি মুছে ফেলতে ব্যর্থ",
|
||||
@@ -1033,6 +1106,7 @@
|
||||
"ToastPlaylistUpdateSuccess": "প্লেলিস্ট আপডেট করা হয়েছে",
|
||||
"ToastPodcastCreateFailed": "পডকাস্ট তৈরি করতে ব্যর্থ",
|
||||
"ToastPodcastCreateSuccess": "পডকাস্ট সফলভাবে তৈরি করা হয়েছে",
|
||||
"ToastPodcastEpisodeUpdated": "পর্ব আপডেট করা হয়েছে",
|
||||
"ToastPodcastGetFeedFailed": "পডকাস্ট ফিড পেতে ব্যর্থ হয়েছে",
|
||||
"ToastPodcastNoEpisodesInFeed": "আরএসএস ফিডে কোনো পর্ব পাওয়া যায়নি",
|
||||
"ToastPodcastNoRssFeed": "পডকাস্টের কোন আরএসএস ফিড নেই",
|
||||
@@ -1057,6 +1131,7 @@
|
||||
"ToastSelectAtLeastOneUser": "অন্তত একজন ব্যবহারকারী নির্বাচন করুন",
|
||||
"ToastSendEbookToDeviceFailed": "ডিভাইসে ইবুক পাঠাতে ব্যর্থ",
|
||||
"ToastSendEbookToDeviceSuccess": "ইবুক \"{0}\" ডিভাইসে পাঠানো হয়েছে",
|
||||
"ToastSeriesSubmitFailedSameName": "একই নামে দুটি সিরিজ যোগ করা যাবে না",
|
||||
"ToastSeriesUpdateFailed": "সিরিজ আপডেট ব্যর্থ হয়েছে",
|
||||
"ToastSeriesUpdateSuccess": "সিরিজ আপডেট সাফল্য",
|
||||
"ToastServerSettingsUpdateSuccess": "সার্ভার সেটিংস আপডেট করা হয়েছে",
|
||||
@@ -1075,10 +1150,20 @@
|
||||
"ToastUnknownError": "অজানা ত্রুটি",
|
||||
"ToastUnlinkOpenIdFailed": "OpenID থেকে ব্যবহারকারীকে আনলিঙ্ক করতে ব্যর্থ হয়েছে",
|
||||
"ToastUnlinkOpenIdSuccess": "OpenID থেকে ব্যবহারকারীকে লিঙ্কমুক্ত করা হয়েছে",
|
||||
"ToastUploaderFilepathExistsError": "ফাইল পাথ \"{0}\" ইতিমধ্যে সার্ভারে বিদ্যমান",
|
||||
"ToastUploaderItemExistsInSubdirectoryError": "আইটেম \"{0}\" আপলোড পাথের একটি সাবডিরেক্টরি ব্যবহার করছে।",
|
||||
"ToastUserDeleteFailed": "ব্যবহারকারী মুছতে ব্যর্থ",
|
||||
"ToastUserDeleteSuccess": "ব্যবহারকারী মুছে ফেলা হয়েছে",
|
||||
"ToastUserPasswordChangeSuccess": "পাসওয়ার্ড সফলভাবে পরিবর্তন করা হয়েছে",
|
||||
"ToastUserPasswordMismatch": "পাসওয়ার্ড মিলছে না",
|
||||
"ToastUserPasswordMustChange": "নতুন পাসওয়ার্ড পুরানো পাসওয়ার্ডের সাথে মিলতে পারবে না",
|
||||
"ToastUserRootRequireName": "একটি রুট ব্যবহারকারীর নাম লিখতে হবে"
|
||||
"ToastUserRootRequireName": "একটি রুট ব্যবহারকারীর নাম লিখতে হবে",
|
||||
"TooltipAddChapters": "অধ্যায় যোগ করুন",
|
||||
"TooltipAddOneSecond": "১ সেকেন্ড যোগ করুন",
|
||||
"TooltipAdjustChapterStart": "শুরুর সময় সমন্বয় করতে ক্লিক করুন",
|
||||
"TooltipLockAllChapters": "সব অধ্যায় লক করুন",
|
||||
"TooltipLockChapter": "অধ্যায় লক করুন (একাধিক অধ্যায়ের পরিসর নির্বাচন করতে Shift+ক্লিক করুন)",
|
||||
"TooltipSubtractOneSecond": "১ সেকেন্ড কমান",
|
||||
"TooltipUnlockAllChapters": "সব অধ্যায় আনলক করুন",
|
||||
"TooltipUnlockChapter": "অধ্যায় আনলক করুন (একাধিক অধ্যায়ের পরিসর নির্বাচন করতে Shift+ক্লিক করুন)"
|
||||
}
|
||||
@@ -46,6 +46,7 @@
|
||||
"ButtonLatest": "Nejnovější",
|
||||
"ButtonLibrary": "Knihovna",
|
||||
"ButtonLogout": "Odhlásit",
|
||||
"ButtonLogoutAllDevices": "Odhlásit ze všech zařízení",
|
||||
"ButtonLookup": "Vyhledat",
|
||||
"ButtonManageTracks": "Správa stop",
|
||||
"ButtonMapChapterTitles": "Mapovat názvy kapitol",
|
||||
@@ -194,6 +195,7 @@
|
||||
"HeaderScheduleEpisodeDownloads": "Naplánovat automatické stahování epizod",
|
||||
"HeaderScheduleLibraryScans": "Naplánovat automatické prohledávání knihoven",
|
||||
"HeaderSession": "Relace",
|
||||
"HeaderSessions": "Relace",
|
||||
"HeaderSetBackupSchedule": "Nastavit plán zálohování",
|
||||
"HeaderSettings": "Nastavení",
|
||||
"HeaderSettingsDisplay": "Zobrazit",
|
||||
@@ -415,6 +417,7 @@
|
||||
"LabelIntervalEveryHour": "Každou hodinu",
|
||||
"LabelIntervalEveryMinute": "Každou minutu",
|
||||
"LabelInvert": "Invertovat",
|
||||
"LabelIpAddress": "IP Adresa",
|
||||
"LabelItem": "Položka",
|
||||
"LabelJumpBackwardAmount": "Přeskočit zpět o",
|
||||
"LabelJumpForwardAmount": "Přeskočit dopředu o",
|
||||
@@ -774,6 +777,7 @@
|
||||
"MessageConfirmDeleteSession": "Opravdu chcete smazat tuto relaci?",
|
||||
"MessageConfirmEmbedMetadataInAudioFiles": "Jste si jisti, že chcete vložit metadata do {0} zvukových souborů?",
|
||||
"MessageConfirmForceReScan": "Opravdu chcete vynutit opětovné prohledání?",
|
||||
"MessageConfirmLogoutDevice": "Opravdu se chcete odhlásit z \"{0}\"?",
|
||||
"MessageConfirmMarkAllEpisodesFinished": "Opravdu chcete označit všechny epizody jako dokončené?",
|
||||
"MessageConfirmMarkAllEpisodesNotFinished": "Opravdu chcete označit všechny epizody jako nedokončené?",
|
||||
"MessageConfirmMarkItemFinished": "Opravdu chcete označit \"{0}\" jako dokončené?",
|
||||
@@ -970,7 +974,7 @@
|
||||
"StatsBooksFinishedThisYear": "Některé knihy dokončené tento rok…",
|
||||
"StatsBooksListenedTo": "knih poslechnuto",
|
||||
"StatsCollectionGrewTo": "Vaše kolekce knih se rozrostla na…",
|
||||
"StatsSessions": "sezóna",
|
||||
"StatsSessions": "relace",
|
||||
"StatsSpentListening": "stráveno posloucháním",
|
||||
"StatsTopAuthor": "TOP AUTOR",
|
||||
"StatsTopAuthors": "TOP AUTOŘI",
|
||||
|
||||
+251
-251
@@ -3,7 +3,7 @@
|
||||
"ButtonAddApiKey": "Tilføj API-nøgle",
|
||||
"ButtonAddChapters": "Tilføj kapitler",
|
||||
"ButtonAddDevice": "Tilføj enhed",
|
||||
"ButtonAddLibrary": "Tilføj Bibliotek",
|
||||
"ButtonAddLibrary": "Tilføj bibliotek",
|
||||
"ButtonAddPodcasts": "Tilføj podcasts",
|
||||
"ButtonAddUser": "Tilføj bruger",
|
||||
"ButtonAddYourFirstLibrary": "Tilføj dit første bibliotek",
|
||||
@@ -11,21 +11,21 @@
|
||||
"ButtonApplyChapters": "Anvend kapitler",
|
||||
"ButtonAuthors": "Forfattere",
|
||||
"ButtonBack": "Tilbage",
|
||||
"ButtonBatchEditPopulateFromExisting": "Opret fra eksisterende",
|
||||
"ButtonBatchEditPopulateMapDetails": "Opret fra kortlægnings detaljer",
|
||||
"ButtonBatchEditPopulateFromExisting": "Kopiér fra eksisterende",
|
||||
"ButtonBatchEditPopulateMapDetails": "Kopiér til masseredigering",
|
||||
"ButtonBrowseForFolder": "Gennemse mappe",
|
||||
"ButtonCancel": "Annuller",
|
||||
"ButtonCancelEncode": "Annuller kodning",
|
||||
"ButtonChangeRootPassword": "Ændr rodadgangskode",
|
||||
"ButtonCheckAndDownloadNewEpisodes": "Tjek og download nye episoder",
|
||||
"ButtonCheckAndDownloadNewEpisodes": "Tjek og hent nye afsnit",
|
||||
"ButtonChooseAFolder": "Vælg en mappe",
|
||||
"ButtonChooseFiles": "Vælg filer",
|
||||
"ButtonClearFilter": "Ryd filter",
|
||||
"ButtonClose": "Luk",
|
||||
"ButtonCloseFeed": "Luk feed",
|
||||
"ButtonCloseSession": "Luk Åben Session",
|
||||
"ButtonCloseSession": "Luk åben session",
|
||||
"ButtonCollections": "Samlinger",
|
||||
"ButtonConfigureScanner": "Konfigurer scanner",
|
||||
"ButtonConfigureScanner": "Konfigurer skanner",
|
||||
"ButtonCreate": "Opret",
|
||||
"ButtonCreateBackup": "Opret sikkerhedskopi",
|
||||
"ButtonDelete": "Slet",
|
||||
@@ -33,10 +33,10 @@
|
||||
"ButtonEdit": "Rediger",
|
||||
"ButtonEditChapters": "Rediger kapitler",
|
||||
"ButtonEditPodcast": "Rediger podcast",
|
||||
"ButtonEnable": "Aktiver",
|
||||
"ButtonEnable": "Aktivér",
|
||||
"ButtonFireAndFail": "Affyring Og Fejl",
|
||||
"ButtonFireOnTest": "Affyring vedTest begivenhed",
|
||||
"ButtonForceReScan": "Tving genindlæsning",
|
||||
"ButtonFireOnTest": "Udløs onTest-hændelse",
|
||||
"ButtonForceReScan": "Tving genskanning",
|
||||
"ButtonFullPath": "Fuld sti",
|
||||
"ButtonHide": "Skjul",
|
||||
"ButtonHome": "Hjem",
|
||||
@@ -53,321 +53,321 @@
|
||||
"ButtonMatchBooks": "Match bøger",
|
||||
"ButtonNevermind": "Glem det",
|
||||
"ButtonNext": "Næste",
|
||||
"ButtonNextChapter": "Næste Kapitel",
|
||||
"ButtonNextItemInQueue": "Næste Element i Køen",
|
||||
"ButtonNextChapter": "Næste kapitel",
|
||||
"ButtonNextItemInQueue": "Næste element i køen",
|
||||
"ButtonOk": "Ok",
|
||||
"ButtonOpenFeed": "Åbn feed",
|
||||
"ButtonOpenManager": "Åbn manager",
|
||||
"ButtonPause": "Pause",
|
||||
"ButtonPlay": "Afspil",
|
||||
"ButtonPlayAll": "Afspil Alle",
|
||||
"ButtonPlayAll": "Afspil alle",
|
||||
"ButtonPlaying": "Afspiller",
|
||||
"ButtonPlaylists": "Afspilningslister",
|
||||
"ButtonPrevious": "Sidste",
|
||||
"ButtonPreviousChapter": "Sidste Kapitel",
|
||||
"ButtonProbeAudioFile": "Undersøg Lydfil",
|
||||
"ButtonPurgeAllCache": "Ryd al cache",
|
||||
"ButtonPurgeItemsCache": "Ryd elementcache",
|
||||
"ButtonQueueAddItem": "Tilføj til kø",
|
||||
"ButtonPlaylists": "Playlister",
|
||||
"ButtonPrevious": "Forrige",
|
||||
"ButtonPreviousChapter": "Forrige kapitel",
|
||||
"ButtonProbeAudioFile": "Undersøg lydfil",
|
||||
"ButtonPurgeAllCache": "Ryd alle mellemlagre",
|
||||
"ButtonPurgeItemsCache": "Ryd mediemellemlager",
|
||||
"ButtonQueueAddItem": "Sæt i kø",
|
||||
"ButtonQueueRemoveItem": "Fjern fra kø",
|
||||
"ButtonQuickEmbed": "Hurtig Indlejring",
|
||||
"ButtonQuickEmbedMetadata": "Hurtig Indlejring af Metadata",
|
||||
"ButtonQuickEmbed": "Hurtig indlejring",
|
||||
"ButtonQuickEmbedMetadata": "Hurtig indlejring af metadata",
|
||||
"ButtonQuickMatch": "Hurtig Match",
|
||||
"ButtonReScan": "Gen-scan",
|
||||
"ButtonReScan": "Skan igen",
|
||||
"ButtonRead": "Læs",
|
||||
"ButtonReadLess": "Se mindre",
|
||||
"ButtonReadMore": "Se mere",
|
||||
"ButtonRefresh": "Genindlæs",
|
||||
"ButtonRemove": "Fjern",
|
||||
"ButtonRemoveAll": "Fjern Alle",
|
||||
"ButtonRemoveAllLibraryItems": "Fjern Alle Bibliotekselementer",
|
||||
"ButtonRemoveFromContinueListening": "Fjern fra Fortsæt Lytning",
|
||||
"ButtonRemoveFromContinueReading": "Fjern fra Fortsæt Læsning",
|
||||
"ButtonRemoveSeriesFromContinueSeries": "Fjern Serie fra Fortsæt Serie",
|
||||
"ButtonRemoveAll": "Fjern alle",
|
||||
"ButtonRemoveAllLibraryItems": "Fjern alle bibliotekselementer",
|
||||
"ButtonRemoveFromContinueListening": "Fjern fra »Fortsæt med at lytte«",
|
||||
"ButtonRemoveFromContinueReading": "Fjern fra »Fortsæt med at læse«",
|
||||
"ButtonRemoveSeriesFromContinueSeries": "Fjern Serie fra »Fortsæt serie«",
|
||||
"ButtonReset": "Nulstil",
|
||||
"ButtonResetToDefault": "Nulstil til standard",
|
||||
"ButtonRestore": "Gendan",
|
||||
"ButtonSave": "Gem",
|
||||
"ButtonSaveAndClose": "Gem & Luk",
|
||||
"ButtonSaveTracklist": "Gem Sporliste",
|
||||
"ButtonScan": "Scan",
|
||||
"ButtonScanLibrary": "Scan Bibliotek",
|
||||
"ButtonScrollLeft": "Rul til Venstre",
|
||||
"ButtonScrollRight": "Rul til Højre",
|
||||
"ButtonSaveAndClose": "Gem og luk",
|
||||
"ButtonSaveTracklist": "Gem sporliste",
|
||||
"ButtonScan": "Skan",
|
||||
"ButtonScanLibrary": "Skan bibliotek",
|
||||
"ButtonScrollLeft": "Rul til venstre",
|
||||
"ButtonScrollRight": "Rul til højre",
|
||||
"ButtonSearch": "Søg",
|
||||
"ButtonSelectFolderPath": "Vælg Mappe Sti",
|
||||
"ButtonSelectFolderPath": "Vælg mappesti",
|
||||
"ButtonSeries": "Serier",
|
||||
"ButtonSetChaptersFromTracks": "Sæt kapitler fra spor",
|
||||
"ButtonShare": "Del",
|
||||
"ButtonShiftTimes": "Skift Tider",
|
||||
"ButtonShiftTimes": "Forskyd tider",
|
||||
"ButtonShow": "Vis",
|
||||
"ButtonStartM4BEncode": "Start M4B Kode",
|
||||
"ButtonStartMetadataEmbed": "Start Metadata Indlejring",
|
||||
"ButtonStartM4BEncode": "Start konvertering til M4B",
|
||||
"ButtonStartMetadataEmbed": "Start metadataindlejring",
|
||||
"ButtonStats": "Statistik",
|
||||
"ButtonSubmit": "Send",
|
||||
"ButtonTest": "Test",
|
||||
"ButtonUnlinkOpenId": "Afkobl OpenID",
|
||||
"ButtonUpload": "Upload",
|
||||
"ButtonUploadBackup": "Upload Backup",
|
||||
"ButtonUploadCover": "Upload Omslag",
|
||||
"ButtonUploadOPMLFile": "Upload OPML Fil",
|
||||
"ButtonUpload": "Overfør",
|
||||
"ButtonUploadBackup": "Overfør sikkerhedskopi",
|
||||
"ButtonUploadCover": "Overfør omslag",
|
||||
"ButtonUploadOPMLFile": "Overfør OPML-fil",
|
||||
"ButtonUserDelete": "Slet bruger {0}",
|
||||
"ButtonUserEdit": "Rediger bruger {0}",
|
||||
"ButtonViewAll": "Vis Alle",
|
||||
"ButtonViewAll": "Vis alle",
|
||||
"ButtonYes": "Ja",
|
||||
"ErrorUploadFetchMetadataAPI": "Fejl henter metadata",
|
||||
"ErrorUploadFetchMetadataNoResults": "Kunne ikke hente metadata - prøv at uploade title og/eller forfatter",
|
||||
"ErrorUploadLacksTitle": "Skal have en title",
|
||||
"ErrorUploadFetchMetadataAPI": "Fejl ved hentning af metadata",
|
||||
"ErrorUploadFetchMetadataNoResults": "Kunne ikke hente metadata - prøv at ændre titel og/eller forfatter",
|
||||
"ErrorUploadLacksTitle": "Titel kræves",
|
||||
"HeaderAccount": "Konto",
|
||||
"HeaderAddCustomMetadataProvider": "Tilføj Brugerdefineret Metadataudbyder",
|
||||
"HeaderAddCustomMetadataProvider": "Tilføj brugerdefineret metadataudbyder",
|
||||
"HeaderAdvanced": "Avanceret",
|
||||
"HeaderApiKeys": "API-nøgler",
|
||||
"HeaderAppriseNotificationSettings": "Apprise Notifikationsindstillinger",
|
||||
"HeaderAppriseNotificationSettings": "Apprise-notifikationsindstillinger",
|
||||
"HeaderAudioTracks": "Lydspor",
|
||||
"HeaderAudiobookTools": "Audiobog Filhåndteringsværktøjer",
|
||||
"HeaderAuthentication": "Autentificering",
|
||||
"HeaderAudiobookTools": "Håndtering af lydbogsfiler",
|
||||
"HeaderAuthentication": "Loginindstillinger",
|
||||
"HeaderBackups": "Sikkerhedskopier",
|
||||
"HeaderBulkChapterModal": "Tilføj flere kapitler",
|
||||
"HeaderChangePassword": "Skift Adgangskode",
|
||||
"HeaderChangePassword": "Skift adgangskode",
|
||||
"HeaderChapters": "Kapitler",
|
||||
"HeaderChooseAFolder": "Vælg en Mappe",
|
||||
"HeaderChooseAFolder": "Vælg en mappe",
|
||||
"HeaderCollection": "Samling",
|
||||
"HeaderCollectionItems": "Samlingselementer",
|
||||
"HeaderCollectionItems": "Bøger i samlingen",
|
||||
"HeaderCover": "Omslag",
|
||||
"HeaderCurrentDownloads": "Nuværende Downloads",
|
||||
"HeaderCustomMessageOnLogin": "Brugerdefineret Besked ved Login",
|
||||
"HeaderCustomMetadataProviders": "Brugerdefineret Metadataudbyder",
|
||||
"HeaderCurrentDownloads": "Aktuelle downloads",
|
||||
"HeaderCustomMessageOnLogin": "Brugerdefineret besked ved logind",
|
||||
"HeaderCustomMetadataProviders": "Brugerdefineret metadataudbyder",
|
||||
"HeaderDetails": "Detaljer",
|
||||
"HeaderDownloadQueue": "Download Kø",
|
||||
"HeaderDownloadQueue": "Downloadkø",
|
||||
"HeaderEbookFiles": "E-bogsfiler",
|
||||
"HeaderEmail": "Email",
|
||||
"HeaderEmailSettings": "Email Indstillinger",
|
||||
"HeaderEpisodes": "Episoder",
|
||||
"HeaderEreaderDevices": "E-læser Enheder",
|
||||
"HeaderEreaderSettings": "E-læser Indstillinger",
|
||||
"HeaderEmailSettings": "Emailindstillinger",
|
||||
"HeaderEpisodes": "Afsnit",
|
||||
"HeaderEreaderDevices": "E-bogslæsere",
|
||||
"HeaderEreaderSettings": "Indstillinger for e-bogslæsere",
|
||||
"HeaderFiles": "Filer",
|
||||
"HeaderFindChapters": "Find Kapitler",
|
||||
"HeaderIgnoredFiles": "Ignorerede Filer",
|
||||
"HeaderItemFiles": "Emnefiler",
|
||||
"HeaderItemMetadataUtils": "Emne Metadata Værktøjer",
|
||||
"HeaderLastListeningSession": "Seneste Lyttesession",
|
||||
"HeaderLatestEpisodes": "Seneste episoder",
|
||||
"HeaderFindChapters": "Find kapitler",
|
||||
"HeaderIgnoredFiles": "Ignorerede filer",
|
||||
"HeaderItemFiles": "Filer",
|
||||
"HeaderItemMetadataUtils": "Metadata-værktøjer",
|
||||
"HeaderLastListeningSession": "Seneste lyttesession",
|
||||
"HeaderLatestEpisodes": "Nyeste afsnit",
|
||||
"HeaderLibraries": "Biblioteker",
|
||||
"HeaderLibraryFiles": "Biblioteksfiler",
|
||||
"HeaderLibraryStats": "Biblioteksstatistik",
|
||||
"HeaderListeningSessions": "Lyttesessioner",
|
||||
"HeaderListeningStats": "Lyttestatistik",
|
||||
"HeaderLogin": "Log ind",
|
||||
"HeaderLogs": "Logs",
|
||||
"HeaderManageGenres": "Administrer Genrer",
|
||||
"HeaderManageTags": "Administrer Tags",
|
||||
"HeaderMapDetails": "Kort Detaljer",
|
||||
"HeaderMatch": "Match",
|
||||
"HeaderLogs": "Logfiler",
|
||||
"HeaderManageGenres": "Administrer genrer",
|
||||
"HeaderManageTags": "Administrer emneord",
|
||||
"HeaderMapDetails": "Masseredigering",
|
||||
"HeaderMatch": "Find match",
|
||||
"HeaderMetadataOrderOfPrecedence": "Metadata-prioritet",
|
||||
"HeaderMetadataToEmbed": "Metadata til indlejring",
|
||||
"HeaderNewAccount": "Ny Konto",
|
||||
"HeaderNewAccount": "Ny konto",
|
||||
"HeaderNewApiKey": "Ny API-nøgle",
|
||||
"HeaderNewLibrary": "Nyt Bibliotek",
|
||||
"HeaderNotificationCreate": "Opret Notifikation",
|
||||
"HeaderNewLibrary": "Nyt bibliotek",
|
||||
"HeaderNotificationCreate": "Opret notifikation",
|
||||
"HeaderNotificationUpdate": "Updater Notifikation",
|
||||
"HeaderNotifications": "Meddelelser",
|
||||
"HeaderOpenIDConnectAuthentication": "OpenID Connect-autentificering",
|
||||
"HeaderOpenIDConnectAuthentication": "Log ind med OpenID Connect",
|
||||
"HeaderOpenListeningSessions": "Åbne lyttesessioner",
|
||||
"HeaderOpenRSSFeed": "Åbn RSS Feed",
|
||||
"HeaderOtherFiles": "Andre Filer",
|
||||
"HeaderPasswordAuthentication": "Adgangskodeautentificering",
|
||||
"HeaderOpenRSSFeed": "Åbn RSS-feed",
|
||||
"HeaderOtherFiles": "Andre filer",
|
||||
"HeaderPasswordAuthentication": "Log ind med adgangskode",
|
||||
"HeaderPermissions": "Tilladelser",
|
||||
"HeaderPlayerQueue": "Afspilningskø",
|
||||
"HeaderPlayerSettings": "Afspiller Indstillinger",
|
||||
"HeaderPlaylist": "Afspilningsliste",
|
||||
"HeaderPlaylistItems": "Afspilningsliste Elementer",
|
||||
"HeaderPodcastsToAdd": "Podcasts til Tilføjelse",
|
||||
"HeaderPlayerSettings": "Afspilningsindstillinger",
|
||||
"HeaderPlaylist": "Playliste",
|
||||
"HeaderPlaylistItems": "Elementer i playlisten",
|
||||
"HeaderPodcastsToAdd": "Podcasts som skal tilføjes",
|
||||
"HeaderPresets": "Forudindstillinger",
|
||||
"HeaderPreviewCover": "Forhåndsvis Omslag",
|
||||
"HeaderRSSFeedGeneral": "RSS Detaljer",
|
||||
"HeaderRSSFeedIsOpen": "RSS Feed er Åben",
|
||||
"HeaderRSSFeeds": "RSS-Feeds",
|
||||
"HeaderRemoveEpisode": "Fjern Episode",
|
||||
"HeaderRemoveEpisodes": "Fjern {0} Episoder",
|
||||
"HeaderSavedMediaProgress": "Gemt Medieforløb",
|
||||
"HeaderPreviewCover": "Forhåndsvis omslag",
|
||||
"HeaderRSSFeedGeneral": "RSS-detaljer",
|
||||
"HeaderRSSFeedIsOpen": "RSS-feedet er åbent",
|
||||
"HeaderRSSFeeds": "RSS-feeds",
|
||||
"HeaderRemoveEpisode": "Fjern afsnit",
|
||||
"HeaderRemoveEpisodes": "Fjern {0} afsnit",
|
||||
"HeaderSavedMediaProgress": "Gemt lyttefremgang",
|
||||
"HeaderSchedule": "Planlæg",
|
||||
"HeaderScheduleEpisodeDownloads": "Planlæg Automatisk Episode-Download",
|
||||
"HeaderScheduleLibraryScans": "Planlæg Automatiske Biblioteksscanninger",
|
||||
"HeaderScheduleEpisodeDownloads": "Planlæg automatisk hentning af afsnit",
|
||||
"HeaderScheduleLibraryScans": "Planlæg automatisk biblioteksskanning",
|
||||
"HeaderSession": "Session",
|
||||
"HeaderSetBackupSchedule": "Indstil Sikkerhedskopieringsplan",
|
||||
"HeaderSetBackupSchedule": "Indstil tidsplan for sikkerhedskopiering",
|
||||
"HeaderSettings": "Indstillinger",
|
||||
"HeaderSettingsDisplay": "Skærm",
|
||||
"HeaderSettingsExperimental": "Eksperimentelle Funktioner",
|
||||
"HeaderSettingsDisplay": "Visning",
|
||||
"HeaderSettingsExperimental": "Eksperimentelle funktioner",
|
||||
"HeaderSettingsGeneral": "Generelt",
|
||||
"HeaderSettingsScanner": "Scanner",
|
||||
"HeaderSettingsScanner": "Skanner",
|
||||
"HeaderSettingsSecurity": "Sikkerhed",
|
||||
"HeaderSettingsWebClient": "Webklient",
|
||||
"HeaderSleepTimer": "Søvntimer",
|
||||
"HeaderStatsLargestItems": "Største Elementer",
|
||||
"HeaderStatsLongestItems": "Længste Elementer (timer)",
|
||||
"HeaderStatsMinutesListeningChart": "Minutter Lyttet (sidste 7 dage)",
|
||||
"HeaderStatsRecentSessions": "Seneste Sessions",
|
||||
"HeaderStatsTop10Authors": "Top 10 Forfattere",
|
||||
"HeaderStatsTop5Genres": "Top 5 Genrer",
|
||||
"HeaderSleepTimer": "Sovefunktion",
|
||||
"HeaderStatsLargestItems": "Største bøger",
|
||||
"HeaderStatsLongestItems": "Længste bøger (timer)",
|
||||
"HeaderStatsMinutesListeningChart": "Minutter lyttet (sidste 7 dage)",
|
||||
"HeaderStatsRecentSessions": "Seneste sessioner",
|
||||
"HeaderStatsTop10Authors": "Top 10 forfattere",
|
||||
"HeaderStatsTop5Genres": "Top 5 genrer",
|
||||
"HeaderTableOfContents": "Indholdsfortegnelse",
|
||||
"HeaderTools": "Værktøjer",
|
||||
"HeaderUpdateAccount": "Opdater Konto",
|
||||
"HeaderUpdateAccount": "Opdater konto",
|
||||
"HeaderUpdateApiKey": "Opdater API-nøgle",
|
||||
"HeaderUpdateAuthor": "Opdater Forfatter",
|
||||
"HeaderUpdateDetails": "Opdater Detaljer",
|
||||
"HeaderUpdateLibrary": "Opdater Bibliotek",
|
||||
"HeaderUpdateAuthor": "Opdater forfatter",
|
||||
"HeaderUpdateDetails": "Opdater detaljer",
|
||||
"HeaderUpdateLibrary": "Opdater bibliotek",
|
||||
"HeaderUsers": "Brugere",
|
||||
"HeaderYearReview": "Gennemgang af År {0}",
|
||||
"HeaderYourStats": "Dine Statistikker",
|
||||
"LabelAbridged": "Forkortet",
|
||||
"LabelAbridgedChecked": "Forkortet (kontrolleret)",
|
||||
"LabelAbridgedUnchecked": "Uforkortet (ikke kontrolleret)",
|
||||
"LabelAccessibleBy": "Tilgængelig af",
|
||||
"LabelAccountType": "Brugertype",
|
||||
"HeaderYearReview": "Året {0} opsummeret",
|
||||
"HeaderYourStats": "Din statistik",
|
||||
"LabelAbridged": "Forkortet udgave",
|
||||
"LabelAbridgedChecked": "Forkortet udgave (bekræftet)",
|
||||
"LabelAbridgedUnchecked": "Uforkortet udgave (ikke bekræftet)",
|
||||
"LabelAccessibleBy": "Tilgængelig for",
|
||||
"LabelAccountType": "Kontotype",
|
||||
"LabelAccountTypeAdmin": "Administrator",
|
||||
"LabelAccountTypeGuest": "Gæst",
|
||||
"LabelAccountTypeUser": "Bruger",
|
||||
"LabelActivities": "Aktiviteter",
|
||||
"LabelActivity": "Aktivitet",
|
||||
"LabelAddToCollection": "Tilføj til Samling",
|
||||
"LabelAddToCollectionBatch": "Tilføj {0} Bøger til Samling",
|
||||
"LabelAddToPlaylist": "Tilføj til Afspilningsliste",
|
||||
"LabelAddToPlaylistBatch": "Tilføj {0} Elementer til Afspilningsliste",
|
||||
"LabelAddToCollection": "Tilføj til samling",
|
||||
"LabelAddToCollectionBatch": "Tilføj {0} bøger til samling",
|
||||
"LabelAddToPlaylist": "Tilføj til playliste",
|
||||
"LabelAddToPlaylistBatch": "Tilføj {0} elementer til afspilningsliste",
|
||||
"LabelAddedAt": "Tilføjet",
|
||||
"LabelAddedDate": "Tilføjet {0}",
|
||||
"LabelAdminUsersOnly": "Kun Administratorer",
|
||||
"LabelAddedDate": "Tilføjede {0}",
|
||||
"LabelAdminUsersOnly": "Kun administratorer",
|
||||
"LabelAll": "Alle",
|
||||
"LabelAllEpisodesDownloaded": "Alle episoder hentet",
|
||||
"LabelAllUsers": "Alle Brugere",
|
||||
"LabelAllUsersExcludingGuests": "Alle bruger eksklusiv gæster",
|
||||
"LabelAllUsersIncludingGuests": "Alle bruger inklusiv gæster",
|
||||
"LabelAllEpisodesDownloaded": "Alle afsnit hentet",
|
||||
"LabelAllUsers": "Alle brugere",
|
||||
"LabelAllUsersExcludingGuests": "Alle brugere, bortset fra gæster",
|
||||
"LabelAllUsersIncludingGuests": "Alle brugere, også gæster",
|
||||
"LabelAlreadyInYourLibrary": "Allerede i dit bibliotek",
|
||||
"LabelApiKeyCreated": "API-nøgle\"{0}\" oprettet succesfuldt.",
|
||||
"LabelApiKeyCreated": "API-nøglen \"{0}\" blev oprettet.",
|
||||
"LabelApiKeyCreatedDescription": "Sørg for at kopiere API-nøglen nu, da du ikke vil kunne se den igen.",
|
||||
"LabelApiKeyUser": "Ret på vegne af brugeren",
|
||||
"LabelApiKeyUserDescription": "Denne API-nøgle vil have de samme tilladelser som den bruger, den handler på vegne af. Dette vil fremgå på samme måde i logfiler, som hvis brugeren foretog anmodningen.",
|
||||
"LabelApiToken": "API Token",
|
||||
"LabelAppend": "Tilføj",
|
||||
"LabelApiKeyUser": "Handl på vegne af bruger",
|
||||
"LabelApiKeyUserDescription": "Denne API-nøgle vil have de samme tilladelser som den bruger, den handler på vegne af. Dette vil fremgå på samme måde i logfiler, som hvis brugeren selv foretog anmodningen.",
|
||||
"LabelApiToken": "API-nøgle",
|
||||
"LabelAppend": "Vedhæft",
|
||||
"LabelAudioBitrate": "Lydbitrate (f.eks. 128k)",
|
||||
"LabelAudioChannels": "Lydkanaler (1 eller 2)",
|
||||
"LabelAudioCodec": "Lydkodek",
|
||||
"LabelAudioCodec": "Lyd-codec",
|
||||
"LabelAuthor": "Forfatter",
|
||||
"LabelAuthorFirstLast": "Forfatter (Fornavn Efternavn)",
|
||||
"LabelAuthorLastFirst": "Forfatter (Efternavn, Fornavn)",
|
||||
"LabelAuthors": "Forfattere",
|
||||
"LabelAutoDownloadEpisodes": "Auto Download Episoder",
|
||||
"LabelAutoFetchMetadata": "Automatisk Hent Metadata",
|
||||
"LabelAutoFetchMetadataHelp": "Henter metadata for titler, forfatter og serier for at strømligne uploading. Ekstra metadata har måske brug for at blive matchet efter upload.",
|
||||
"LabelAutoLaunch": "Åben Automatisk",
|
||||
"LabelAutoDownloadEpisodes": "Hent automatisk afsnit",
|
||||
"LabelAutoFetchMetadata": "Hent automatisk metadata",
|
||||
"LabelAutoFetchMetadataHelp": "Henter metadata for titler, forfatter og serie for at strømline uploading. Ekstra metadata har måske brug for at blive matchet efter upload.",
|
||||
"LabelAutoLaunch": "Automatisk start",
|
||||
"LabelAutoLaunchDescription": "Viderestil automatisk til login-udbyderen ved navigation til login-siden (manuel overstyring via <code>/login?autoLaunch=0</code>)",
|
||||
"LabelAutoRegister": "Registrer Automatisk",
|
||||
"LabelAutoRegister": "Registrer automatisk",
|
||||
"LabelAutoRegisterDescription": "Automatisk oprettelse af nye brugere efter login",
|
||||
"LabelBackToUser": "Tilbage til Bruger",
|
||||
"LabelBackupAudioFiles": "Sikkerhedskopier lydfiler",
|
||||
"LabelBackupLocation": "Backup Placering",
|
||||
"LabelBackToUser": "Tilbage til bruger",
|
||||
"LabelBackupAudioFiles": "Sikkerhedskopiér lydfiler",
|
||||
"LabelBackupLocation": "Placering af sikkerhedskopi",
|
||||
"LabelBackupsEnableAutomaticBackups": "Automatisk sikkerhedskopiering",
|
||||
"LabelBackupsEnableAutomaticBackupsHelp": "Sikkerhedskopier gemt i /metadata/backups",
|
||||
"LabelBackupsMaxBackupSize": "Maksimal sikkerhedskopistørrelse (i GB) (0 for ubegrænset)",
|
||||
"LabelBackupsMaxBackupSizeHelp": "Som en beskyttelse mod fejlkonfiguration fejler sikkerhedskopier, hvis de overstiger den konfigurerede størrelse.",
|
||||
"LabelBackupsNumberToKeep": "Antal sikkerhedskopier at beholde",
|
||||
"LabelBackupsNumberToKeepHelp": "Kun 1 sikkerhedskopi fjernes ad gangen, så hvis du allerede har flere sikkerhedskopier end dette, skal du fjerne dem manuelt.",
|
||||
"LabelBackupsNumberToKeep": "Antal sikkerhedskopier som skal beholdes",
|
||||
"LabelBackupsNumberToKeepHelp": "Kun én sikkerhedskopi fjernes ad gangen, så hvis du allerede har flere sikkerhedskopier end dette, skal du fjerne dem manuelt.",
|
||||
"LabelBitrate": "Bitrate",
|
||||
"LabelBonus": "Bonus",
|
||||
"LabelBooks": "Bøger",
|
||||
"LabelButtonText": "Knap tekst",
|
||||
"LabelByAuthor": "Efter Forfatter",
|
||||
"LabelChangePassword": "Ændre Adgangskode",
|
||||
"LabelButtonText": "Tekst på knap",
|
||||
"LabelByAuthor": "af {0}",
|
||||
"LabelChangePassword": "Ændr adgangskode",
|
||||
"LabelChannels": "Kanaler",
|
||||
"LabelChapterCount": "{0} Kapitler",
|
||||
"LabelChapterTitle": "Kapitel Titel",
|
||||
"LabelChapterCount": "{0} kapitler",
|
||||
"LabelChapterTitle": "Kapiteltitel",
|
||||
"LabelChapters": "Kapitler",
|
||||
"LabelChaptersFound": "fundne kapitler",
|
||||
"LabelChaptersFound": "kapitler fundet",
|
||||
"LabelClickForMoreInfo": "Klik for mere info",
|
||||
"LabelClickToUseCurrentValue": "Klik for at bruge nuværende værdi",
|
||||
"LabelClosePlayer": "Luk afspiller",
|
||||
"LabelCodec": "Kodeks",
|
||||
"LabelCollapseSeries": "Fold Serier Sammen",
|
||||
"LabelCodec": "Codec",
|
||||
"LabelCollapseSeries": "Fold serier sammen",
|
||||
"LabelCollapseSubSeries": "Fold underserie sammen",
|
||||
"LabelCollection": "Samling",
|
||||
"LabelCollections": "Samlinger",
|
||||
"LabelComplete": "Fuldfør",
|
||||
"LabelConfirmPassword": "Bekræft Adgangskode",
|
||||
"LabelComplete": "Afsluttet",
|
||||
"LabelConfirmPassword": "Bekræft adgangskode",
|
||||
"LabelContinueListening": "Fortsæt med at lytte",
|
||||
"LabelContinueReading": "Fortsæt med at læse",
|
||||
"LabelContinueSeries": "Fortsæt Serien",
|
||||
"LabelContinueSeries": "Fortsæt serien",
|
||||
"LabelCorsAllowed": "Tilladte CORS-oprindelser",
|
||||
"LabelCover": "Omslag",
|
||||
"LabelCoverImageURL": "Omslagsbillede URL",
|
||||
"LabelCoverProvider": "Cover billede udbyder",
|
||||
"LabelCreatedAt": "Oprettet d.",
|
||||
"LabelCronExpression": "Cron Udtryk",
|
||||
"LabelCoverImageURL": "Omslagsbilledes-URL",
|
||||
"LabelCoverProvider": "Kilde til omslagsbillede",
|
||||
"LabelCreatedAt": "Oprettet",
|
||||
"LabelCronExpression": "Cron-kommando",
|
||||
"LabelCurrent": "Aktuel",
|
||||
"LabelCurrently": "Aktuelt:",
|
||||
"LabelCustomCronExpression": "Brugerdefineret Cron Udtryk:",
|
||||
"LabelDatetime": "Dato og Tid",
|
||||
"LabelCurrently": "Nuværende:",
|
||||
"LabelCustomCronExpression": "Brugerdefineret Cron-kommando:",
|
||||
"LabelDatetime": "Dato og tid",
|
||||
"LabelDays": "Dage",
|
||||
"LabelDeleteFromFileSystemCheckbox": "Slet fra filsystem (afmarker kun for at fjerne fra databasen)",
|
||||
"LabelDeleteFromFileSystemCheckbox": "Slet fra filsystem (afmarkér for udelukkende at fjerne fra databasen)",
|
||||
"LabelDescription": "Beskrivelse",
|
||||
"LabelDeselectAll": "Fravælg Alle",
|
||||
"LabelDeselectAll": "Fravælg alle",
|
||||
"LabelDetectedPattern": "Identificeret mønster:",
|
||||
"LabelDevice": "Enheds",
|
||||
"LabelDevice": "Enhed",
|
||||
"LabelDeviceInfo": "Enhedsinformation",
|
||||
"LabelDeviceIsAvailableTo": "Enhed er tilgængelig for...",
|
||||
"LabelDeviceIsAvailableTo": "Enheden er tilgængelig for...",
|
||||
"LabelDirectory": "Mappe",
|
||||
"LabelDiscFromFilename": "Disk fra Filnavn",
|
||||
"LabelDiscFromMetadata": "Disk fra Metadata",
|
||||
"LabelDiscFromFilename": "Disk fra filnavn",
|
||||
"LabelDiscFromMetadata": "Disk fra metadata",
|
||||
"LabelDiscover": "Opdag",
|
||||
"LabelDownload": "Download",
|
||||
"LabelDownloadNEpisodes": "Download {0} episoder",
|
||||
"LabelDownloadable": "Downloadbar",
|
||||
"LabelDownload": "Hent",
|
||||
"LabelDownloadNEpisodes": "Hent {0} afsnit",
|
||||
"LabelDownloadable": "Kan hentes",
|
||||
"LabelDuration": "Varighed",
|
||||
"LabelDurationComparisonExactMatch": "(præcis match)",
|
||||
"LabelDurationComparisonExactMatch": "(præcist match)",
|
||||
"LabelDurationComparisonLonger": "({0} længere)",
|
||||
"LabelDurationComparisonShorter": "({0} kortere)",
|
||||
"LabelDurationFound": "Fundet varighed:",
|
||||
"LabelEbook": "E-bog",
|
||||
"LabelEbooks": "E-bøger",
|
||||
"LabelEdit": "Rediger",
|
||||
"LabelEmail": "E-mail",
|
||||
"LabelEmailSettingsFromAddress": "Fra Adresse",
|
||||
"LabelEmailSettingsRejectUnauthorized": "Afvis uautoriserede certifikater",
|
||||
"LabelEmailSettingsRejectUnauthorizedHelp": "Deaktivering af SSL certifikat validering kan udsætte din forbindelse for sikkerhedsrisici, eksempelvis man-in-the-middle angreb. Deaktiver kun denne indstilling hvis du forstår de potentielle implikationer og stoler på den mailserver du forbinder til.",
|
||||
"LabelEmailSettingsSecure": "Sikker",
|
||||
"LabelEmailSettingsSecureHelp": "Hvis sandt, vil forbindelsen bruge TLS ved tilslutning til serveren. Hvis falsk, bruges TLS, hvis serveren understøtter STARTTLS-udvidelsen. I de fleste tilfælde skal denne værdi sættes til sandt, hvis du tilslutter til port 465. Til port 587 eller 25 skal du holde det falsk. (fra nodemailer.com/smtp/#authentication)",
|
||||
"LabelEmailSettingsTestAddress": "Test Adresse",
|
||||
"LabelEmbeddedCover": "Indlejret Omslag",
|
||||
"LabelEmail": "Email",
|
||||
"LabelEmailSettingsFromAddress": "Afsenderadresse",
|
||||
"LabelEmailSettingsRejectUnauthorized": "Afvis ukendte sikkerhedscertifikater",
|
||||
"LabelEmailSettingsRejectUnauthorizedHelp": "Hvis du slår SSL-certifikatkontrollen fra, risikerer du at uvedkommende kan opsnappe dine data. Slå kun dette fra, hvis du er helt tryg ved situationen og har fuld tillid til den mailserver, du forbinder til.",
|
||||
"LabelEmailSettingsSecure": "Sikker forbindelse",
|
||||
"LabelEmailSettingsSecureHelp": "Når denne er slået til, bruger forbindelsen altid fuld kryptering (TLS). Hvis den er slået fra, bruges kryptering kun, hvis serveren tillader det (via STARTTLS). Lad dette være slået til, hvis du bruger port 465. Slå det fra, hvis du bruger port 587 eller 25",
|
||||
"LabelEmailSettingsTestAddress": "Modtageradresse til test",
|
||||
"LabelEmbeddedCover": "Indlejret omslag",
|
||||
"LabelEnable": "Aktivér",
|
||||
"LabelEncodingBackupLocation": "En sikkerhedskopi af dine originale lydfiler vil blive gemt under:",
|
||||
"LabelEncodingChaptersNotEmbedded": "Kapitler er ikke indlejret i multi spors lydbøger.",
|
||||
"LabelEncodingClearItemCache": "Sørg for periodisk at rense indholdscachen.",
|
||||
"LabelEncodingFinishedM4B": "Færdiggjort M4B som vil blive placeret i din lydbogsmappe ved:",
|
||||
"LabelEncodingInfoEmbedded": "Metadata vil blive indlejret i lydfiler i lydbogsmappen.",
|
||||
"LabelEncodingStartedNavigation": "Når opgaven er startet kan du navigere væk fra denne side.",
|
||||
"LabelEncodingTimeWarning": "Indkodning kan tage op til 30 minutter.",
|
||||
"LabelEncodingWarningAdvancedSettings": "Advarsel: Opdater ikke disse indstillinger med mindre du kender til ffmpeg indkodningsindstillinger.",
|
||||
"LabelEncodingWatcherDisabled": "Hvis du har watcheren deaktiveret skal du gen-scanne denne lydbog bagefter.",
|
||||
"LabelEncodingBackupLocation": "En sikkerhedskopi af dine originale lydfiler vil blive gemt i mappen:",
|
||||
"LabelEncodingChaptersNotEmbedded": "Kapitler bliver ikke indlejret i lydbøger som består af flere lydfiler.",
|
||||
"LabelEncodingClearItemCache": "Sørg for jævnligt at rense mellemlageret.",
|
||||
"LabelEncodingFinishedM4B": "Den færdige M4B-fil vil blive placeret i lydbogsmappen:",
|
||||
"LabelEncodingInfoEmbedded": "Metadata vil blive indlejret i lydfilerne i lydbogsmappen.",
|
||||
"LabelEncodingStartedNavigation": "Når opgaven er sat i gang kan du navigere væk fra denne side.",
|
||||
"LabelEncodingTimeWarning": "Omkodningen kan tage op til 30 minutter.",
|
||||
"LabelEncodingWarningAdvancedSettings": "Advarsel: Ændr ikke disse indstillinger med mindre du kender til ffmpeg-indkodningsindstillinger.",
|
||||
"LabelEncodingWatcherDisabled": "Hvis du har slået automatisk biblioteksovervågning fra skal du genskanne denne lydbog bagefter.",
|
||||
"LabelEnd": "Slut",
|
||||
"LabelEndOfChapter": "Slutningen af kapitel",
|
||||
"LabelEpisode": "Afsnit",
|
||||
"LabelEpisodeNotLinkedToRssFeed": "Afsnit er ikke koblet til RSS feed",
|
||||
"LabelEpisodeNotLinkedToRssFeed": "Afsnit er ikke koblet til RSS-feed",
|
||||
"LabelEpisodeNumber": "Afsnit #{0}",
|
||||
"LabelEpisodeTitle": "Episodetitel",
|
||||
"LabelEpisodeType": "Episodetype",
|
||||
"LabelEpisodeUrlFromRssFeed": "Afsnit URL fra RSS feed",
|
||||
"LabelEpisodeTitle": "Afsnitstitel",
|
||||
"LabelEpisodeType": "Afsnitstype",
|
||||
"LabelEpisodeUrlFromRssFeed": "Afsnits-URL fra RSS-feed",
|
||||
"LabelEpisodes": "Afsnit",
|
||||
"LabelEpisodic": "Afsnit",
|
||||
"LabelEpisodic": "Episodisk",
|
||||
"LabelExample": "Eksempel",
|
||||
"LabelExpandSeries": "Udfold serie",
|
||||
"LabelExpandSubSeries": "Udfold underserie",
|
||||
"LabelExpandSeries": "Fold serie ud",
|
||||
"LabelExpandSubSeries": "Fold underserie ud",
|
||||
"LabelExpired": "Udløbet",
|
||||
"LabelExpiresAt": "Udløbsdato",
|
||||
"LabelExpiresInSeconds": "Udløber om (seconds)",
|
||||
"LabelExpiresNever": "Aldrig",
|
||||
"LabelExplicit": "Eksplisit",
|
||||
"LabelExplicitChecked": "Eksplicit (markeret)",
|
||||
"LabelExplicitUnchecked": "Ikke eksplicit (ikke markeret)",
|
||||
"LabelExplicit": "Voksenindhold",
|
||||
"LabelExplicitChecked": "Voksenindhold (markeret)",
|
||||
"LabelExplicitUnchecked": "Ikke voksenindhold (ikke markeret)",
|
||||
"LabelExportOPML": "Eksport OPML",
|
||||
"LabelFeedURL": "Feed URL",
|
||||
"LabelFeedURL": "Feed-URL",
|
||||
"LabelFetchingMetadata": "Henter metadata",
|
||||
"LabelFile": "Fil",
|
||||
"LabelFileBirthtime": "Oprettelsestidspunkt for fil",
|
||||
@@ -391,7 +391,7 @@
|
||||
"LabelFull": "Fuld",
|
||||
"LabelGenre": "Genre",
|
||||
"LabelGenres": "Genrer",
|
||||
"LabelHardDeleteFile": "Permanent slet fil",
|
||||
"LabelHardDeleteFile": "Slet fil permanent",
|
||||
"LabelHasEbook": "Har e-bog",
|
||||
"LabelHasSupplementaryEbook": "Har supplerende e-bog",
|
||||
"LabelHideSubtitles": "Skjul undertitler",
|
||||
@@ -400,7 +400,7 @@
|
||||
"LabelHour": "Time",
|
||||
"LabelHours": "Timer",
|
||||
"LabelIcon": "Ikon",
|
||||
"LabelImageURLFromTheWeb": "Billede URL fra nettet",
|
||||
"LabelImageURLFromTheWeb": "Billede-URL fra nettet",
|
||||
"LabelInProgress": "I gang",
|
||||
"LabelIncludeInTracklist": "Inkluder i afspilningsliste",
|
||||
"LabelIncomplete": "Ufuldstændig",
|
||||
@@ -414,22 +414,22 @@
|
||||
"LabelIntervalEveryDay": "Hver dag",
|
||||
"LabelIntervalEveryHour": "Hver time",
|
||||
"LabelIntervalEveryMinute": "Hvert minut",
|
||||
"LabelInvert": "Inverter",
|
||||
"LabelInvert": "Omvendt begrænsning",
|
||||
"LabelItem": "Element",
|
||||
"LabelJumpBackwardAmount": "Spring bagud mængde",
|
||||
"LabelJumpForwardAmount": "Spring fremad mængde",
|
||||
"LabelJumpBackwardAmount": "Tilbagespolingstid",
|
||||
"LabelJumpForwardAmount": "Fremadspolingstid",
|
||||
"LabelLanguage": "Sprog",
|
||||
"LabelLanguageDefaultServer": "Standard server sprog",
|
||||
"LabelLanguageDefaultServer": "Serverens standardsprog",
|
||||
"LabelLanguages": "Sprog",
|
||||
"LabelLastBookAdded": "Senest tilføjede bog",
|
||||
"LabelLastBookUpdated": "Senest opdaterede bog",
|
||||
"LabelLastProgressDate": "Sidste fremgang: {0}",
|
||||
"LabelLastProgressDate": "Seneste fremgang: {0}",
|
||||
"LabelLastSeen": "Sidst set",
|
||||
"LabelLastTime": "Sidste gang",
|
||||
"LabelLastUpdate": "Seneste opdatering",
|
||||
"LabelLayout": "Layout",
|
||||
"LabelLastTime": "Sidst lyttet",
|
||||
"LabelLastUpdate": "Sidst ændret",
|
||||
"LabelLayout": "Udseende",
|
||||
"LabelLayoutSinglePage": "Enkeltside",
|
||||
"LabelLayoutSplitPage": "Opdelt side",
|
||||
"LabelLayoutSplitPage": "Opdelte sider",
|
||||
"LabelLess": "Mindre",
|
||||
"LabelLibrariesAccessibleToUser": "Biblioteker tilgængelige for bruger",
|
||||
"LabelLibrary": "Bibliotek",
|
||||
@@ -445,14 +445,14 @@
|
||||
"LabelLogLevelDebug": "Fejlsøgning",
|
||||
"LabelLogLevelInfo": "Information",
|
||||
"LabelLogLevelWarn": "Advarsel",
|
||||
"LabelLookForNewEpisodesAfterDate": "Søg efter nye episoder efter denne dato",
|
||||
"LabelLookForNewEpisodesAfterDate": "Søg efter nye afsnit efter denne dato",
|
||||
"LabelLowestPriority": "Laveste prioritet",
|
||||
"LabelMatchConfidence": "Confidens",
|
||||
"LabelMatchExistingUsersBy": "Match eksisterende brugere ved",
|
||||
"LabelMatchExistingUsersByDescription": "Anvendt for at forbinde brugere. Når forbundet, brugere vil blive matchet ved unikt id fra din SSO udbyder",
|
||||
"LabelMaxEpisodesToDownload": "Max # afsnit for at downloade. Anvend 0 for ubegrænset.",
|
||||
"LabelMaxEpisodesToDownloadPerCheck": "Max # afsnit til at downloade per check",
|
||||
"LabelMaxEpisodesToKeep": "Max # afsnit at beholde",
|
||||
"LabelMatchConfidence": "Nøjagtighed",
|
||||
"LabelMatchExistingUsersBy": "Forbind eksisterende brugere med",
|
||||
"LabelMatchExistingUsersByDescription": "Anvendes til at tilslutte eksisterende brugere. Ved tilslutningen kobles brugeren til et unikt ID fra din SSO-udbyder",
|
||||
"LabelMaxEpisodesToDownload": "Maksimalt antal afsnit som skal hentes. Anvend 0 for ubegrænset.",
|
||||
"LabelMaxEpisodesToDownloadPerCheck": "Maksimalt antal afsnit som skal hentes per tjek",
|
||||
"LabelMaxEpisodesToKeep": "Maksimalt antal afsnit som skal beholdes",
|
||||
"LabelMaxEpisodesToKeepHelp": "Værdi af 0 sætter intet maks begrænsning. After et nyt afsnit er automatisk downloaded vil det ældste afsnit blive slettet hvis du har mere end X afsnit. Dette vil kun slette 1 afsnit for hvert nye download.",
|
||||
"LabelMediaPlayer": "Medieafspiller",
|
||||
"LabelMediaType": "Medietype",
|
||||
@@ -463,20 +463,20 @@
|
||||
"LabelMinute": "Minut",
|
||||
"LabelMinutes": "Minutter",
|
||||
"LabelMissing": "Mangler",
|
||||
"LabelMissingEbook": "Har ingen ebog",
|
||||
"LabelMissingSupplementaryEbook": "Har ingen tillægsbog",
|
||||
"LabelMissingEbook": "Har ingen e-bog",
|
||||
"LabelMissingSupplementaryEbook": "Har ingen supplerende e-bog",
|
||||
"LabelMobileRedirectURIs": "Godkendte mobil redirect URI'er",
|
||||
"LabelMobileRedirectURIsDescription": "Dete vil whiteliste en gyldig omdirigerings URL for mobile apps. Den standarde er <code>audiobookshelf://oauth</code> som du kan fjerne eller supplere med flere URI'er for tredjeparts app integration. Anvend en stjerne (<code>*</code>) som den eneste indstilling for at tilade en hvilkensomhelst URI.",
|
||||
"LabelMore": "Mere",
|
||||
"LabelMoreInfo": "Mere info",
|
||||
"LabelName": "Navn",
|
||||
"LabelNarrator": "Fortæller",
|
||||
"LabelNarrators": "Fortællere",
|
||||
"LabelNarrator": "Oplæser",
|
||||
"LabelNarrators": "Oplæsere",
|
||||
"LabelNew": "Ny",
|
||||
"LabelNewPassword": "Ny adgangskode",
|
||||
"LabelNewestAuthors": "Nyeste forfattere",
|
||||
"LabelNewestEpisodes": "Nyeste episoder",
|
||||
"LabelNextBackupDate": "Næste sikkerhedskopi dato",
|
||||
"LabelNewestEpisodes": "Nyeste afsnit",
|
||||
"LabelNextBackupDate": "Dato for næste sikkerhedskopi",
|
||||
"LabelNextChapters": "Næste kapitler vil være:",
|
||||
"LabelNextScheduledRun": "Næste planlagte kørsel",
|
||||
"LabelNoApiKeys": "Ingen API-nøgler",
|
||||
@@ -526,28 +526,28 @@
|
||||
"LabelPodcasts": "Podcast",
|
||||
"LabelPort": "Port",
|
||||
"LabelPrefixesToIgnore": "Præfikser der skal ignoreres (skal ikke skelne mellem store og små bogstaver)",
|
||||
"LabelPreventIndexing": "Forhindrer, at dit feed bliver indekseret af iTunes og Google podcastkataloger",
|
||||
"LabelPreventIndexing": "Forhindrer at dit feed bliver indekseret af iTunes- og Google-podcastkataloger",
|
||||
"LabelPrimaryEbook": "Primær e-bog",
|
||||
"LabelProgress": "Fremskridt",
|
||||
"LabelProvider": "Udbyder",
|
||||
"LabelProviderAuthorizationValue": "Authorization Header værdi",
|
||||
"LabelPubDate": "Udgivelsesdato",
|
||||
"LabelPublishYear": "Udgivelsesår",
|
||||
"LabelPublishedDate": "Publiceret {0}",
|
||||
"LabelPublishedDecade": "Publiceret årti",
|
||||
"LabelPublishedDecades": "Publiceret årtier",
|
||||
"LabelPublishedDate": "Udgivet {0}",
|
||||
"LabelPublishedDecade": "Udgivelsesårti",
|
||||
"LabelPublishedDecades": "Udgivelsesårtier",
|
||||
"LabelPublisher": "Forlag",
|
||||
"LabelPublishers": "Forlag",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Brugerdefineret ejerens e-mail",
|
||||
"LabelRSSFeedCustomOwnerName": "Brugerdefineret ejerens navn",
|
||||
"LabelRSSFeedOpen": "RSS-feed åbent",
|
||||
"LabelRSSFeedPreventIndexing": "Forhindrer indeksering",
|
||||
"LabelRSSFeedPreventIndexing": "Forhindr indeksering",
|
||||
"LabelRSSFeedSlug": "RSS-feed-slug",
|
||||
"LabelRSSFeedURL": "RSS-feed-URL",
|
||||
"LabelRandomly": "Tilfældigt",
|
||||
"LabelReAddSeriesToContinueListening": "Gentilføj serier til Fortsæt Lytning",
|
||||
"LabelReAddSeriesToContinueListening": "Gentilføj serier til »Fortsæt med at lytte«",
|
||||
"LabelRead": "Læst",
|
||||
"LabelReadAgain": "Læs Igen",
|
||||
"LabelReadAgain": "Læs igen",
|
||||
"LabelReadEbookWithoutProgress": "Læs e-bog uden at følge fremskridt",
|
||||
"LabelRecentSeries": "Seneste serier",
|
||||
"LabelRecentlyAdded": "Senest tilføjet",
|
||||
@@ -555,9 +555,9 @@
|
||||
"LabelRedo": "Gøre igen",
|
||||
"LabelRegion": "Region",
|
||||
"LabelReleaseDate": "Udgivelsesdato",
|
||||
"LabelRemoveAllMetadataAbs": "Fjern alle metadata.abs filer",
|
||||
"LabelRemoveAllMetadataJson": "Fjern alle metadata.json filer",
|
||||
"LabelRemoveAudibleBranding": "Fjern Audible intro og outro fra kapitler",
|
||||
"LabelRemoveAllMetadataAbs": "Fjern alle \"metadata.abs\"-filer",
|
||||
"LabelRemoveAllMetadataJson": "Fjern alle \"metadata.json\"-filer",
|
||||
"LabelRemoveAudibleBranding": "Fjern Audible-intro og -outro fra kapitler",
|
||||
"LabelRemoveCover": "Fjern omslag",
|
||||
"LabelRemoveMetadataFile": "Fjern alle metadata filer i biblioteksmapper",
|
||||
"LabelRemoveMetadataFileHelp": "Fjern alle metadata.json og metadata.abs filer i dine {0} mapper.",
|
||||
@@ -571,7 +571,7 @@
|
||||
"LabelSelectAllEpisodes": "Vælg alle episoder",
|
||||
"LabelSelectEpisodesShowing": "Vælg {0} episoder vist",
|
||||
"LabelSelectUser": "Vælg bruger",
|
||||
"LabelSelectUsers": "Valgte brugere",
|
||||
"LabelSelectUsers": "Vælg brugere",
|
||||
"LabelSendEbookToDevice": "Send e-bog til...",
|
||||
"LabelSequence": "Sekvens",
|
||||
"LabelSerial": "Seriel",
|
||||
@@ -582,21 +582,21 @@
|
||||
"LabelServerYearReview": "Server år i review ({0})",
|
||||
"LabelSetEbookAsPrimary": "Indstil som primær",
|
||||
"LabelSetEbookAsSupplementary": "Indstil som supplerende",
|
||||
"LabelSettingsAllowIframe": "Tillad embedding i en iframe",
|
||||
"LabelSettingsAllowIframe": "Tillad indlejring i en iframe",
|
||||
"LabelSettingsAudiobooksOnly": "Kun lydbøger",
|
||||
"LabelSettingsAudiobooksOnlyHelp": "Aktivering af denne indstilling vil ignorere e-bogsfiler, medmindre de er inde i en lydbogmappe, hvor de vil blive indstillet som supplerende e-bøger",
|
||||
"LabelSettingsBookshelfViewHelp": "Skeumorfisk design med træhylder",
|
||||
"LabelSettingsChromecastSupport": "Chromecast-understøttelse",
|
||||
"LabelSettingsDateFormat": "Datoformat",
|
||||
"LabelSettingsEnableWatcher": "Automatisk biblioteksovervåger",
|
||||
"LabelSettingsEnableWatcher": "Automatisk biblioteksovervågning",
|
||||
"LabelSettingsEnableWatcherForLibrary": "Automatisk biblioteksovervåger",
|
||||
"LabelSettingsEnableWatcherHelp": "Aktiverer automatisk tilføjelse/opdatering af elementer, når filændringer registreres. *Kræver servergenstart",
|
||||
"LabelSettingsEnableWatcherHelp": "Aktiverer automatisk opdatering af kataloget, når filændringer registreres. *Kræver genstart af serveren",
|
||||
"LabelSettingsEpubsAllowScriptedContent": "Tillad scriptet indhold i epub",
|
||||
"LabelSettingsEpubsAllowScriptedContentHelp": "Tillad epub filer at køre scripts. Det anbefales at holde denne indstilling deaktiveret med mindre du stoler på kilderne af epub filerne.",
|
||||
"LabelSettingsExperimentalFeatures": "Eksperimentelle funktioner",
|
||||
"LabelSettingsExperimentalFeaturesHelp": "Funktioner under udvikling, der kunne bruge din feedback og hjælp til test. Klik for at åbne Github-diskussionen.",
|
||||
"LabelSettingsFindCovers": "Find omslag",
|
||||
"LabelSettingsFindCoversHelp": "Hvis din lydbog ikke har et indlejret omslag eller et omslagsbillede i mappen, vil skanneren forsøge at finde et omslag.<br>Bemærk: Dette vil forlænge scanntiden",
|
||||
"LabelSettingsFindCoversHelp": "Hvis din lydbog ikke har et indlejret omslag eller et omslagsbillede i mappen, vil skanneren forsøge at finde et omslag.<br>Bemærk: Dette vil forlænge skanningens varighed",
|
||||
"LabelSettingsHideSingleBookSeries": "Skjul enkeltbogsserier",
|
||||
"LabelSettingsHideSingleBookSeriesHelp": "Serier med en enkelt bog vil blive skjult fra serie-siden og hjemmesidehylder.",
|
||||
"LabelSettingsHomePageBookshelfView": "Brug bogreolvisning på startside",
|
||||
@@ -607,7 +607,7 @@
|
||||
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Spring tidligere bøger i Fortsæt serie over",
|
||||
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Fortsæt Serien siden viser den første bog som ikke er startet i serier med mindst en bog som ikke er startet og hvor ingen bøger i gang. Aktivering af denne indstilling vil fortsætte serien fra den sidst gennemførte bog i stedet for fra den første ikke startede bog.",
|
||||
"LabelSettingsParseSubtitles": "Fortolk undertitler",
|
||||
"LabelSettingsParseSubtitlesHelp": "Udtræk undertekster fra lydbogsmappenavne.<br>Undertitler skal adskilles af \" - \"<br>f.eks. \"Bogtitel - En undertitel her\" har undertitlen \"En undertitel her\"",
|
||||
"LabelSettingsParseSubtitlesHelp": "Udtræk undertitler fra lydbogsmappenavne.<br>Undertitler skal adskilles af \" - \"<br>f.eks. \"Bogtitel - En undertitel her\" har undertitlen \"En undertitel her\"",
|
||||
"LabelSettingsPreferMatchedMetadata": "Foretræk matchede metadata",
|
||||
"LabelSettingsPreferMatchedMetadataHelp": "Matchede data vil tilsidesætte elementdetaljer ved brug af Hurtig Match. Som standard udfylder Hurtig Match kun manglende detaljer.",
|
||||
"LabelSettingsSkipMatchingBooksWithASIN": "Ignorer matchende bøger, der allerede har en ASIN",
|
||||
@@ -647,14 +647,14 @@
|
||||
"LabelStatsDaysListened": "Dage hørt",
|
||||
"LabelStatsHours": "Timer",
|
||||
"LabelStatsInARow": "i træk",
|
||||
"LabelStatsItemsFinished": "Elementer færdige",
|
||||
"LabelStatsItemsInLibrary": "Elementer i biblioteket",
|
||||
"LabelStatsItemsFinished": "færdiglæste bøger",
|
||||
"LabelStatsItemsInLibrary": "bøger i biblioteket",
|
||||
"LabelStatsMinutes": "minutter",
|
||||
"LabelStatsMinutesListening": "Minutter hørt",
|
||||
"LabelStatsOverallDays": "Samlede dage",
|
||||
"LabelStatsOverallHours": "Samlede timer",
|
||||
"LabelStatsWeekListening": "Ugens lytning",
|
||||
"LabelSubtitle": "Undertekst",
|
||||
"LabelSubtitle": "Undertitel",
|
||||
"LabelSupportedFileTypes": "Understøttede filtyper",
|
||||
"LabelTag": "Mærke",
|
||||
"LabelTags": "Mærker",
|
||||
@@ -663,7 +663,7 @@
|
||||
"LabelTasks": "Kører opgaver",
|
||||
"LabelTextEditorBulletedList": "Punktopstilling",
|
||||
"LabelTextEditorLink": "Link",
|
||||
"LabelTextEditorNumberedList": "Nummeropstilling",
|
||||
"LabelTextEditorNumberedList": "Nummereret liste",
|
||||
"LabelTextEditorUnlink": "Aflink",
|
||||
"LabelTheme": "Tema",
|
||||
"LabelThemeDark": "Mørk",
|
||||
@@ -735,17 +735,17 @@
|
||||
"LabelYourPlaylists": "Dine spillelister",
|
||||
"LabelYourProgress": "Din fremgang",
|
||||
"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 kan håndtere 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.",
|
||||
"MessageAuthenticationLegacyTokenWarning": "Ældre API tokens vil blive fjernet i fremtiden. Brug <a href=\"/config/api-keys\">API-nøgler</a> i stedet.",
|
||||
"MessageAuthenticationOIDCChangesRestart": "Genstart sin server efter du har gemt for at bekræfte OIDC ændringer.",
|
||||
"MessageAuthenticationSecurityMessage": "Autentificeringen er blevet forbedret af sikkerhedsmæssige årsager. Alle brugere skal logge ind igen.",
|
||||
"MessageAuthenticationSecurityMessage": "Brugerlogin er blevet forbedret af sikkerhedsmæssige årsager. Alle brugere skal logge ind igen.",
|
||||
"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",
|
||||
"MessageBackupsLocationNoEditNote": "Note: Backup sti er sat igennem miljøvariabel og kan ikke ændres her.",
|
||||
"MessageBackupsLocationPathEmpty": "Backup sti kan ikke være tom",
|
||||
"MessageBatchEditPopulateMapDetailsAllHelp": "Opret felter slået til med data fra alle genstande. Felter med flere værdier vil blive sammenflettet",
|
||||
"MessageBatchEditPopulateMapDetailsItemHelp": "Opret kort med værdier der er slået til fra felter med data fra denne genstand",
|
||||
"MessageBatchEditPopulateMapDetailsAllHelp": "Udfyld valgte felter med data fra alle bøger. Felter med flere værdier vil blive sammenflettet",
|
||||
"MessageBatchEditPopulateMapDetailsItemHelp": "Udfyld de felter, som er tilvalgt i masseredigering med data fra denne bog",
|
||||
"MessageBatchQuickMatchDescription": "Quick Match vil forsøge at tilføje manglende omslag og metadata til de valgte elementer. Aktivér indstillingerne nedenfor for at tillade Quick Match at overskrive eksisterende omslag og/eller metadata.",
|
||||
"MessageBookshelfNoCollections": "Du har ikke oprettet nogen samlinger endnu",
|
||||
"MessageBookshelfNoCollectionsHelp": "Samlinger er offentlige. Alle brugere med adgang til biblioteket kan se dem.",
|
||||
@@ -888,7 +888,7 @@
|
||||
"MessageResetChaptersConfirm": "Er du sikker på, at du vil nulstille kapitler og annullere ændringerne, du har foretaget?",
|
||||
"MessageRestoreBackupConfirm": "Er du sikker på, at du vil gendanne sikkerhedskopien oprettet den",
|
||||
"MessageRestoreBackupWarning": "Gendannelse af en sikkerhedskopi vil overskrive hele databasen, som er placeret på /config, og omslagsbilleder i /metadata/items & /metadata/authors.<br /><br />Sikkerhedskopier ændrer ikke nogen filer i dine biblioteksmapper. Hvis du har aktiveret serverindstillinger for at gemme omslagskunst og metadata i dine biblioteksmapper, sikkerhedskopieres eller overskrives disse ikke.<br /><br />Alle klienter, der bruger din server, opdateres automatisk.",
|
||||
"MessageScheduleLibraryScanNote": "For de fleste brugere er det anbefalet, at efterlade denne funktion deaktiveret, og lade biblioteksovervågeren være aktiveret - den vil automatisk opdage ændringer i dine biblioteksmapper. Aktiver denne funktion, hvis biblioteksovervågeren ikke virker med dit filsystem (f. eks. NFS).",
|
||||
"MessageScheduleLibraryScanNote": "For de fleste brugere er det anbefalet, at lade denne funktion være deaktiveret, og lade biblioteksovervågning være aktiveret - den vil automatisk opdage ændringer i dine biblioteksmapper. Aktiver denne funktion, hvis biblioteksovervågning ikke virker med dit filsystem (f. eks. NFS).",
|
||||
"MessageScheduleRunEveryWeekdayAtTime": "Kør hvert {0} af {1}",
|
||||
"MessageSearchResultsFor": "Søgeresultater for",
|
||||
"MessageSelected": "{0} valgt",
|
||||
@@ -915,7 +915,7 @@
|
||||
"MessageTaskFailedToWriteMetadataFile": "Fejlede i at skrive metadata fil",
|
||||
"MessageTaskMatchingBooksInLibrary": "Matchede bøger i bibliotek \"{0}\"",
|
||||
"MessageTaskNoFilesToScan": "Ingen filer at scanne",
|
||||
"MessageTaskOpmlImport": "OPML import",
|
||||
"MessageTaskOpmlImport": "OPML-import",
|
||||
"MessageTaskOpmlImportDescription": "Oprettelse af podcasts fra {0} RSS feeds",
|
||||
"MessageTaskOpmlImportFeed": "OPML importering fejlede",
|
||||
"MessageTaskOpmlImportFeedDescription": "Importering af RSS feed \"{0}\"",
|
||||
@@ -939,13 +939,13 @@
|
||||
"MessageUploaderItemSuccess": "Uploadet med succes!",
|
||||
"MessageUploading": "Uploader...",
|
||||
"MessageValidCronExpression": "Gyldigt cron-udtryk",
|
||||
"MessageWatcherIsDisabledGlobally": "Watcher er deaktiveret globalt i serverindstillinger",
|
||||
"MessageWatcherIsDisabledGlobally": "Automatisk biblioteksovervågning er deaktiveret globalt i serverens indstillinger",
|
||||
"MessageXLibraryIsEmpty": "{0} bibliotek er tomt!",
|
||||
"MessageYourAudiobookDurationIsLonger": "Din lydbogsvarighed er længere end den fundne varighed",
|
||||
"MessageYourAudiobookDurationIsShorter": "Din lydbogsvarighed er kortere end den fundne varighed",
|
||||
"NoteChangeRootPassword": "Root-brugeren er den eneste bruger, der kan have en tom adgangskode",
|
||||
"NoteChapterEditorTimes": "Bemærk: Første kapitel starttidspunkt skal forblive kl. 0:00, og det sidste kapitel starttidspunkt må ikke overstige denne lydbogs varighed.",
|
||||
"NoteFolderPicker": "Bemærk: Mapper, der allerede er mappet, vises ikke",
|
||||
"NoteFolderPicker": "Bemærk: Allerede kortlagte mapper vises ikke",
|
||||
"NoteRSSFeedPodcastAppsHttps": "Advarsel: De fleste podcast-apps kræver, at RSS-feedets URL bruger HTTPS",
|
||||
"NoteRSSFeedPodcastAppsPubDate": "Advarsel: En eller flere af dine episoder har ikke en Pub Date. Nogle podcast-apps kræver dette.",
|
||||
"NoteUploaderFoldersWithMediaFiles": "Mapper med mediefiler håndteres som separate bibliotekselementer.",
|
||||
@@ -1018,8 +1018,8 @@
|
||||
"ToastChapterStartTimeAdjusted": "Kapitelstarttid justeret med {0} sekunder",
|
||||
"ToastChaptersAllLocked": "Alle kapitler er låst. Lås op for nogle kapitler for at ændre deres tider.",
|
||||
"ToastChaptersHaveErrors": "Kapitler har fejl",
|
||||
"ToastChaptersInvalidShiftAmountLast": "Ugyldig ændring. Det sidste kapitels starttid ville fortsætte længere end varigheden på denne lydbog.",
|
||||
"ToastChaptersInvalidShiftAmountStart": "Ugyldig ændring. Første kapitel ville have en længde på nul eller negativt og ville blive overskrevet af andet kapitel. Udvid startvarigheden på andet kapitel.",
|
||||
"ToastChaptersInvalidShiftAmountLast": "Forskydningen er ikke mulig. Det sidste kapitels starttidspunkt ville strække sig ud over denne lydbogs varighed.",
|
||||
"ToastChaptersInvalidShiftAmountStart": "Forskydningen er ikke mulig. Det første kapitel ville have en længde på nul eller negativ længde og ville blive overskrevet af det andet kapitel. Øg startvarigheden af det andet kapitel.",
|
||||
"ToastChaptersMustHaveTitles": "Kapitler skal have titler",
|
||||
"ToastChaptersRemoved": "Kapitler fjernet",
|
||||
"ToastChaptersUpdated": "Kapitler opdateret",
|
||||
@@ -1044,7 +1044,7 @@
|
||||
"ToastEpisodeUpdateSuccess": "{0} afsnit opdateret",
|
||||
"ToastErrorCannotShare": "Kan ikke dele på denne enhed",
|
||||
"ToastFailedToCreate": "Oprettelsen mislykkedes",
|
||||
"ToastFailedToDelete": "Sletning fejlede",
|
||||
"ToastFailedToDelete": "Kunne ikke slette",
|
||||
"ToastFailedToLoadData": "Indlæsning af data fejlede",
|
||||
"ToastFailedToMatch": "Fejlet match",
|
||||
"ToastFailedToShare": "Deling fejlede",
|
||||
@@ -1109,7 +1109,7 @@
|
||||
"ToastProgressIsNotBeingSynced": "Fremskridt ikke synkroniseret, genstart afspilning",
|
||||
"ToastProviderCreatedFailed": "Fejlede at tilføje udbyder",
|
||||
"ToastProviderCreatedSuccess": "Ny udbyder tilføjet",
|
||||
"ToastProviderNameAndUrlRequired": "Navn og URL påkrævet",
|
||||
"ToastProviderNameAndUrlRequired": "Navn og URL kræves",
|
||||
"ToastProviderRemoveSuccess": "Udbyder fjernet",
|
||||
"ToastRSSFeedCloseFailed": "Mislykkedes lukning af RSS-feed",
|
||||
"ToastRSSFeedCloseSuccess": "RSS-feed lukket",
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"ButtonLatest": "Neueste",
|
||||
"ButtonLibrary": "Bibliothek",
|
||||
"ButtonLogout": "Abmelden",
|
||||
"ButtonLogoutAllDevices": "Von allen Geräte abmelden",
|
||||
"ButtonLookup": "Online-Suche",
|
||||
"ButtonManageTracks": "Tracks verwalten",
|
||||
"ButtonMapChapterTitles": "Kapitelüberschriften zuordnen",
|
||||
@@ -139,12 +140,12 @@
|
||||
"HeaderCustomMetadataProviders": "Benutzerdefinierte Metadatenanbieter",
|
||||
"HeaderDetails": "Details",
|
||||
"HeaderDownloadQueue": "Download-Warteschlange",
|
||||
"HeaderEbookFiles": "E-Book-Dateien",
|
||||
"HeaderEbookFiles": "Buch-Dateien",
|
||||
"HeaderEmail": "E-Mail",
|
||||
"HeaderEmailSettings": "E-Mail-Einstellungen",
|
||||
"HeaderEpisodes": "Episoden",
|
||||
"HeaderEreaderDevices": "E-Reader Geräte",
|
||||
"HeaderEreaderSettings": "E-Reader-Einstellungen",
|
||||
"HeaderEreaderSettings": "E-Reader Einstellungen",
|
||||
"HeaderFiles": "Dateien",
|
||||
"HeaderFindChapters": "Kapitel suchen",
|
||||
"HeaderIgnoredFiles": "Ignorierte Dateien",
|
||||
@@ -194,6 +195,7 @@
|
||||
"HeaderScheduleEpisodeDownloads": "Automatische Episoden-Downloads planen",
|
||||
"HeaderScheduleLibraryScans": "Automatische Bibliotheksscans",
|
||||
"HeaderSession": "Sitzung",
|
||||
"HeaderSessions": "Sitzungen",
|
||||
"HeaderSetBackupSchedule": "Zeitplan für die Datensicherung festlegen",
|
||||
"HeaderSettings": "Einstellungen",
|
||||
"HeaderSettingsDisplay": "Anzeige",
|
||||
@@ -415,6 +417,7 @@
|
||||
"LabelIntervalEveryHour": "Jede Stunde",
|
||||
"LabelIntervalEveryMinute": "Jede Minute",
|
||||
"LabelInvert": "Umkehren",
|
||||
"LabelIpAddress": "IP-Adresse",
|
||||
"LabelItem": "Medium",
|
||||
"LabelJumpBackwardAmount": "Zurückspringen Zeit",
|
||||
"LabelJumpForwardAmount": "Vorwärtsspringen Zeit",
|
||||
@@ -774,6 +777,7 @@
|
||||
"MessageConfirmDeleteSession": "Sitzung wird gelöscht! Bist du dir sicher?",
|
||||
"MessageConfirmEmbedMetadataInAudioFiles": "Bist du dir sicher, dass die Metadaten in {0} Audiodateien eingebettet werden sollen?",
|
||||
"MessageConfirmForceReScan": "Scanvorgang erzwingen! Bist du dir sicher?",
|
||||
"MessageConfirmLogoutDevice": "Bist du sicher, dass du dich von \"{0}\" abmelden willst?",
|
||||
"MessageConfirmMarkAllEpisodesFinished": "Alle Episoden werden als abgeschlossen markiert! Bist du dir sicher?",
|
||||
"MessageConfirmMarkAllEpisodesNotFinished": "Alle Episoden werden als nicht abgeschlossen markiert! Bist du dir sicher?",
|
||||
"MessageConfirmMarkItemFinished": "Möchtest du \"{0}\" wirklich als fertig markieren?",
|
||||
|
||||
+35
-11
@@ -135,9 +135,9 @@
|
||||
"HeaderCurrentDownloads": "Τρέχουσες Λήψεις",
|
||||
"HeaderDetails": "Λεπτομέρειες",
|
||||
"HeaderDownloadQueue": "Ουρά Λήψης",
|
||||
"HeaderEbookFiles": "Αρχεία Ebook",
|
||||
"HeaderEmail": "Ηλεκτρονικό Ταχυδρομίο",
|
||||
"HeaderEmailSettings": "Ρυθμίσεις Ηλεκτρονικού Ταχυδρομίου",
|
||||
"HeaderEbookFiles": "Αρχεία του βιβλίου",
|
||||
"HeaderEmail": "Ηλεκτρονικό ταχυδρομείο",
|
||||
"HeaderEmailSettings": "Ρυθμίσεις ηλεκτρονικού ταχυδρομείου",
|
||||
"HeaderEpisodes": "Επεισόδια",
|
||||
"HeaderEreaderSettings": "Ρυθμίσεις Ereader",
|
||||
"HeaderFiles": "Αρχεία",
|
||||
@@ -150,6 +150,7 @@
|
||||
"HeaderLibraryStats": "Στατιστικά Βιβλιοθήκης",
|
||||
"HeaderListeningSessions": "Συνεδρίες Ακρόασης",
|
||||
"HeaderListeningStats": "Στατιστικά Ακρόασης",
|
||||
"HeaderLogin": "Σύνδεση",
|
||||
"HeaderMatch": "Ταύτιση",
|
||||
"HeaderNewAccount": "Νέος Λογαριασμός",
|
||||
"HeaderNewApiKey": "Νέο Κλειδί API",
|
||||
@@ -242,35 +243,51 @@
|
||||
"LabelDescription": "Περιγραφή",
|
||||
"LabelDevice": "Συσκευή",
|
||||
"LabelDeviceInfo": "Πληροφορίες Συσκευής",
|
||||
"LabelDiscover": "Ανακάλυψη",
|
||||
"LabelDownload": "Λήψη",
|
||||
"LabelDownloadNEpisodes": "Λήψη {0} επεισοδίων",
|
||||
"LabelDuration": "Διάρκεια",
|
||||
"LabelDurationComparisonExactMatch": "(ακριβής ταύτιση)",
|
||||
"LabelEbook": "Ebook",
|
||||
"LabelEbooks": "Ebooks",
|
||||
"LabelEbook": "Ηλεκτρονικό βιβλίο",
|
||||
"LabelEbooks": "Ηλεκτρονικά βιβλία",
|
||||
"LabelEdit": "Επεξεργασία",
|
||||
"LabelEmail": "Ηλεκτρονικό Ταχυδρομίο",
|
||||
"LabelEmailSettingsFromAddress": "Από Διεύθυνση",
|
||||
"LabelEmail": "Ηλεκτρονικό ταχυδρομείο",
|
||||
"LabelEmailSettingsFromAddress": "Από διεύθυνση",
|
||||
"LabelEmailSettingsSecure": "Ασφαλές",
|
||||
"LabelEmailSettingsTestAddress": "Δοκιμή Διεύθυνσης",
|
||||
"LabelEmailSettingsTestAddress": "Δοκιμή διεύθυνσης",
|
||||
"LabelEmbeddedCover": "Ενσωματωμένο Εξώφυλλο",
|
||||
"LabelEnable": "Ενεργοποίηση",
|
||||
"LabelEnd": "Τέλος",
|
||||
"LabelEndOfChapter": "Τέλος Κεφαλαίου",
|
||||
"LabelEpisode": "Επεισόδιο",
|
||||
"LabelExplicit": "Άσεμνο περιεχόμενο",
|
||||
"LabelFeedURL": "URL ροής",
|
||||
"LabelFile": "Αρχείο",
|
||||
"LabelFileBirthtime": "Ώρα δημιουργίας αρχείου",
|
||||
"LabelFileModified": "Τροποποιημένο αρχείο",
|
||||
"LabelFilename": "Όνομα Αρχείου",
|
||||
"LabelFinished": "Ολοκληρώθηκε",
|
||||
"LabelFolder": "Φάκελος",
|
||||
"LabelFontBoldness": "Ένταση γραμματοσειράς",
|
||||
"LabelFontFamily": "Οικογένεια Γραμματοσειράς",
|
||||
"LabelGenre": "Είδος",
|
||||
"LabelGenres": "Είδη",
|
||||
"LabelFontScale": "Μέγεθος γραμματοσειράς",
|
||||
"LabelGenre": "Λογοτεχνικό Είδος",
|
||||
"LabelGenres": "Λογοτεχνικά Είδη",
|
||||
"LabelHasEbook": "Έχει βιβλίο",
|
||||
"LabelHasSupplementaryEbook": "Διαθέτει συμπληρωματικό ηλεκτρονικό βιβλίο",
|
||||
"LabelHost": "Διακομιστής",
|
||||
"LabelInProgress": "Σε Εξέλιξη",
|
||||
"LabelIncomplete": "Ελειπής",
|
||||
"LabelLanguage": "Γλώσσα",
|
||||
"LabelLayout": "Διάταξη",
|
||||
"LabelLayoutSinglePage": "Μονή Σελίδα",
|
||||
"LabelLibrarySortByProgress": "Πρόοδος: Τελευταία ενημέρωση",
|
||||
"LabelLibrarySortByProgressFinished": "Πρόοδος: Ολοκληρώθηκε",
|
||||
"LabelLibrarySortByProgressStarted": "Πρόοδος: Έναρξη",
|
||||
"LabelLineSpacing": "Διάστημα γραμμών",
|
||||
"LabelListenAgain": "Επανάληψη Ακρόασης",
|
||||
"LabelMediaType": "Τύπος Πολυμέσων",
|
||||
"LabelMissing": "Λείπει η ετικέτα",
|
||||
"LabelMore": "Περισσότερα",
|
||||
"LabelMoreInfo": "Περισσότερες Πληροφορίες",
|
||||
"LabelName": "Όνομα",
|
||||
@@ -278,11 +295,15 @@
|
||||
"LabelNarrators": "Αφηγητές",
|
||||
"LabelNewestAuthors": "Πρόσφατοι Συγγραφείς",
|
||||
"LabelNewestEpisodes": "Πρόσφατα Επεισόδια",
|
||||
"LabelNotFinished": "Δεν έχει ολοκληρωθεί",
|
||||
"LabelNotStarted": "Δεν Έχει Ξεκινήσει",
|
||||
"LabelNumberOfEpisodes": "# Επεισοδίων",
|
||||
"LabelPassword": "Κωδικός Πρόσβασης",
|
||||
"LabelPath": "Διαδρομή",
|
||||
"LabelPodcast": "Πόντκαστ",
|
||||
"LabelPodcasts": "Πόντκαστ",
|
||||
"LabelProgress": "Πρόοδος",
|
||||
"LabelPubDate": "Ημερομηνία έκδοσης",
|
||||
"LabelPublishYear": "Χρονολογία Έκδοσης",
|
||||
"LabelPublishedDate": "Εκδόθηκε {0}",
|
||||
"LabelRandomly": "Τυχαία",
|
||||
@@ -317,8 +338,11 @@
|
||||
"LabelUsername": "Όνομα Χρήστη",
|
||||
"LabelYourProgress": "Η Πρόοδος Σας",
|
||||
"MessageDownloadingEpisode": "Λήψη επεισοδίου",
|
||||
"MessageEreaderDevices": "Για να διασφαλίσετε την παράδοση των ηλεκτρονικών βιβλίων, ίσως χρειαστεί να προσθέσετε την παραπάνω διεύθυνση ηλεκτρονικού ταχυδρομείου ως έγκυρο αποστολέα για κάθε συσκευή που αναγράφεται παρακάτω.",
|
||||
"MessageLoading": "Φόρτωση...",
|
||||
"MessageMarkAsFinished": "Σήμανση ως Ολοκληρωμένο",
|
||||
"MessageNoItemsFound": "Δεν βρέθηκαν αντικείμενα",
|
||||
"MessageNoUserPlaylists": "Δεν έχετε λίστες αναπαραγωγής"
|
||||
"MessageNoUserPlaylists": "Δεν έχετε λίστες αναπαραγωγής",
|
||||
"ToastEmailSettingsUpdateSuccess": "Ενημερώθηκαν οι ρυθμίσεις ηλεκτρονικού ταχυδρομείου",
|
||||
"ToastNameEmailRequired": "Το όνομα και η διεύθυνση ηλεκτρονικού ταχυδρομείου είναι υποχρεωτικά"
|
||||
}
|
||||
@@ -46,6 +46,7 @@
|
||||
"ButtonLatest": "Latest",
|
||||
"ButtonLibrary": "Library",
|
||||
"ButtonLogout": "Logout",
|
||||
"ButtonLogoutAllDevices": "Logout All Devices",
|
||||
"ButtonLookup": "Lookup",
|
||||
"ButtonManageTracks": "Manage Tracks",
|
||||
"ButtonMapChapterTitles": "Map Chapter Titles",
|
||||
@@ -194,6 +195,7 @@
|
||||
"HeaderScheduleEpisodeDownloads": "Schedule Automatic Episode Downloads",
|
||||
"HeaderScheduleLibraryScans": "Schedule Automatic Library Scans",
|
||||
"HeaderSession": "Session",
|
||||
"HeaderSessions": "Sessions",
|
||||
"HeaderSetBackupSchedule": "Set Backup Schedule",
|
||||
"HeaderSettings": "Settings",
|
||||
"HeaderSettingsDisplay": "Display",
|
||||
@@ -415,6 +417,7 @@
|
||||
"LabelIntervalEveryHour": "Every hour",
|
||||
"LabelIntervalEveryMinute": "Every minute",
|
||||
"LabelInvert": "Invert",
|
||||
"LabelIpAddress": "IP Address",
|
||||
"LabelItem": "Item",
|
||||
"LabelJumpBackwardAmount": "Jump backward amount",
|
||||
"LabelJumpForwardAmount": "Jump forward amount",
|
||||
@@ -774,6 +777,7 @@
|
||||
"MessageConfirmDeleteSession": "Are you sure you want to delete this session?",
|
||||
"MessageConfirmEmbedMetadataInAudioFiles": "Are you sure you want to embed metadata in {0} audio files?",
|
||||
"MessageConfirmForceReScan": "Are you sure you want to force re-scan?",
|
||||
"MessageConfirmLogoutDevice": "Are you sure you want to log out of \"{0}\"?",
|
||||
"MessageConfirmMarkAllEpisodesFinished": "Are you sure you want to mark all episodes as finished?",
|
||||
"MessageConfirmMarkAllEpisodesNotFinished": "Are you sure you want to mark all episodes as not finished?",
|
||||
"MessageConfirmMarkItemFinished": "Are you sure you want to mark \"{0}\" as finished?",
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"ButtonLatest": "Viimeisimmät",
|
||||
"ButtonLibrary": "Kirjasto",
|
||||
"ButtonLogout": "Kirjaudu ulos",
|
||||
"ButtonLogoutAllDevices": "Kirjaudu ulos kaikista laitteista",
|
||||
"ButtonLookup": "Hae",
|
||||
"ButtonManageTracks": "Hallitse raitoja",
|
||||
"ButtonMapChapterTitles": "Kartoita lukujen nimet",
|
||||
@@ -194,6 +195,7 @@
|
||||
"HeaderScheduleEpisodeDownloads": "Ajoita automaattiset jaksolataukset",
|
||||
"HeaderScheduleLibraryScans": "Ajoita automaattiset kirjastoskannaukset",
|
||||
"HeaderSession": "Istunto",
|
||||
"HeaderSessions": "Istunnot",
|
||||
"HeaderSetBackupSchedule": "Aseta varmuuskopiointiaikataulu",
|
||||
"HeaderSettings": "Asetukset",
|
||||
"HeaderSettingsDisplay": "Näyttö",
|
||||
@@ -415,6 +417,7 @@
|
||||
"LabelIntervalEveryHour": "Joka tunti",
|
||||
"LabelIntervalEveryMinute": "Joka minuutti",
|
||||
"LabelInvert": "Saa käänteiseksi",
|
||||
"LabelIpAddress": "IP-osoite",
|
||||
"LabelItem": "Kohde",
|
||||
"LabelJumpBackwardAmount": "Taaksepäin-hyppyjen määrä",
|
||||
"LabelJumpForwardAmount": "Eteenpäin-hyppyjen määrä",
|
||||
@@ -774,6 +777,7 @@
|
||||
"MessageConfirmDeleteSession": "Oletko varma, että haluat poistaa tämän istunnon?",
|
||||
"MessageConfirmEmbedMetadataInAudioFiles": "Oletko varma, että haluat upottaa metatiedot {0} äänitiedostoihin?",
|
||||
"MessageConfirmForceReScan": "Oletko varma, että haluat pakottaa uudelleenskannauksen?",
|
||||
"MessageConfirmLogoutDevice": "Haluatko varmasti kirjautua ulos palvelusta \"{0}\"?",
|
||||
"MessageConfirmMarkAllEpisodesFinished": "Oletko varma, että haluat merkitä kaikki jaksot päättyneiksi?",
|
||||
"MessageConfirmMarkAllEpisodesNotFinished": "Oletko varma, että haluat merkitä kaikki jaksot ei-valmiiksi?",
|
||||
"MessageConfirmMarkItemFinished": "Oletko varma, että haluat merkitä \"{0}\":n valmiiksi?",
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"ButtonLatest": "Dernière version",
|
||||
"ButtonLibrary": "Bibliothèque",
|
||||
"ButtonLogout": "Déconnexion",
|
||||
"ButtonLogoutAllDevices": "Se déconnecter de tous les appareils",
|
||||
"ButtonLookup": "Chercher",
|
||||
"ButtonManageTracks": "Gérer les pistes",
|
||||
"ButtonMapChapterTitles": "Correspondance des titres de chapitres",
|
||||
@@ -139,7 +140,7 @@
|
||||
"HeaderCustomMetadataProviders": "Fournisseurs de métadonnées personnalisés",
|
||||
"HeaderDetails": "Détails",
|
||||
"HeaderDownloadQueue": "File d’attente de téléchargements",
|
||||
"HeaderEbookFiles": "Fichiers des livres numériques",
|
||||
"HeaderEbookFiles": "Fichiers du livre",
|
||||
"HeaderEmail": "Courriel",
|
||||
"HeaderEmailSettings": "Configuration de l’envoie des courriels",
|
||||
"HeaderEpisodes": "Épisodes",
|
||||
@@ -194,6 +195,7 @@
|
||||
"HeaderScheduleEpisodeDownloads": "Programmer des téléchargements automatiques d'épisodes",
|
||||
"HeaderScheduleLibraryScans": "Analyse automatique de la bibliothèque",
|
||||
"HeaderSession": "Session",
|
||||
"HeaderSessions": "Sessions",
|
||||
"HeaderSetBackupSchedule": "Activer la sauvegarde automatique",
|
||||
"HeaderSettings": "Paramètres",
|
||||
"HeaderSettingsDisplay": "Affichage",
|
||||
@@ -415,6 +417,7 @@
|
||||
"LabelIntervalEveryHour": "Toutes les heures",
|
||||
"LabelIntervalEveryMinute": "Toutes les minutes",
|
||||
"LabelInvert": "Inverser",
|
||||
"LabelIpAddress": "Adresse IP",
|
||||
"LabelItem": "Élément",
|
||||
"LabelJumpBackwardAmount": "Dans le lecteur, reculer de",
|
||||
"LabelJumpForwardAmount": "Dans le lecteur, avancer de",
|
||||
@@ -538,7 +541,7 @@
|
||||
"LabelPublishedDecades": "Décennies de publication",
|
||||
"LabelPublisher": "Éditeur",
|
||||
"LabelPublishers": "Éditeurs",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Courriel personnalisée du propriétaire",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Courriel personnalisé du propriétaire",
|
||||
"LabelRSSFeedCustomOwnerName": "Nom propriétaire personnalisé",
|
||||
"LabelRSSFeedOpen": "Flux RSS ouvert",
|
||||
"LabelRSSFeedPreventIndexing": "Empêcher l’indexation",
|
||||
@@ -774,6 +777,7 @@
|
||||
"MessageConfirmDeleteSession": "Êtes-vous sûr·e de vouloir supprimer cette session ?",
|
||||
"MessageConfirmEmbedMetadataInAudioFiles": "Êtes-vous sûr·e de vouloir intégrer des métadonnées dans {0} fichiers audio ?",
|
||||
"MessageConfirmForceReScan": "Êtes-vous sûr·e de vouloir lancer une analyse forcée ?",
|
||||
"MessageConfirmLogoutDevice": "Êtes-vous sûr·e de vouloir vous déconnecter de « {0} » ?",
|
||||
"MessageConfirmMarkAllEpisodesFinished": "Êtes-vous sûr·e de marquer tous les épisodes comme terminés ?",
|
||||
"MessageConfirmMarkAllEpisodesNotFinished": "Êtes-vous sûr·e de vouloir marquer tous les épisodes comme non terminés ?",
|
||||
"MessageConfirmMarkItemFinished": "Êtes-vous sûr·e de vouloir marquer {0} comme terminé ?",
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"ButtonLatest": "Najnovije",
|
||||
"ButtonLibrary": "Knjižnica",
|
||||
"ButtonLogout": "Odjavi se",
|
||||
"ButtonLogoutAllDevices": "Odjava sa svih uređaja",
|
||||
"ButtonLookup": "Potraži",
|
||||
"ButtonManageTracks": "Upravljanje zvučnim zapisima",
|
||||
"ButtonMapChapterTitles": "Mapiraj naslove poglavlja",
|
||||
@@ -154,7 +155,7 @@
|
||||
"HeaderLatestEpisodes": "Najnoviji nastavci",
|
||||
"HeaderLibraries": "Knjižnice",
|
||||
"HeaderLibraryFiles": "Datoteke knjižnice",
|
||||
"HeaderLibraryStats": "Statistika knjižnice",
|
||||
"HeaderLibraryStats": "Knjižnična statistika",
|
||||
"HeaderListeningSessions": "Sesije slušanja",
|
||||
"HeaderListeningStats": "Statistika slušanja",
|
||||
"HeaderLogin": "Prijava",
|
||||
@@ -194,6 +195,7 @@
|
||||
"HeaderScheduleEpisodeDownloads": "Zakazivanje automatskog preuzimanja nastavaka",
|
||||
"HeaderScheduleLibraryScans": "Zakaži automatsko skeniranje knjižnice",
|
||||
"HeaderSession": "Sesija",
|
||||
"HeaderSessions": "Sesije",
|
||||
"HeaderSetBackupSchedule": "Zakazivanje sigurnosne pohrane",
|
||||
"HeaderSettings": "Postavke",
|
||||
"HeaderSettingsDisplay": "Prikaz",
|
||||
@@ -415,6 +417,7 @@
|
||||
"LabelIntervalEveryHour": "Svaki sat",
|
||||
"LabelIntervalEveryMinute": "Svaku minutu",
|
||||
"LabelInvert": "Obrni",
|
||||
"LabelIpAddress": "IP adresa",
|
||||
"LabelItem": "Stavka",
|
||||
"LabelJumpBackwardAmount": "Dužina skoka unatrag",
|
||||
"LabelJumpForwardAmount": "Dužina skoka unaprijed",
|
||||
@@ -774,6 +777,7 @@
|
||||
"MessageConfirmDeleteSession": "Sigurno želite izbrisati ovu sesiju?",
|
||||
"MessageConfirmEmbedMetadataInAudioFiles": "Sigurno želite ugraditi meta-podatke u {0} zvučnih datoteka?",
|
||||
"MessageConfirmForceReScan": "Sigurno želite ponovno pokrenuti skeniranje?",
|
||||
"MessageConfirmLogoutDevice": "Sigurno se želiš odjaviti iz \"{0}\"?",
|
||||
"MessageConfirmMarkAllEpisodesFinished": "Sigurno želite označiti sve nastavke kao završene?",
|
||||
"MessageConfirmMarkAllEpisodesNotFinished": "Sigurno želite označiti sve nastavke kao nezavršene?",
|
||||
"MessageConfirmMarkItemFinished": "Sigurno želite označiti stavku \"{0}\" kao završenu?",
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"ButtonLatest": "Legújabb",
|
||||
"ButtonLibrary": "Könyvtár",
|
||||
"ButtonLogout": "Kijelentkezés",
|
||||
"ButtonLogoutAllDevices": "Kijelentkezés az összes eszközről",
|
||||
"ButtonLookup": "Keresés",
|
||||
"ButtonManageTracks": "Sávok kezelése",
|
||||
"ButtonMapChapterTitles": "Fejezetcímek hozzárendelése",
|
||||
@@ -194,6 +195,7 @@
|
||||
"HeaderScheduleEpisodeDownloads": "Automatikus epizódletöltés ütemezése",
|
||||
"HeaderScheduleLibraryScans": "Könyvtárak automatikus szkennelésének ütemezése",
|
||||
"HeaderSession": "Munkamenet",
|
||||
"HeaderSessions": "Munkamenet",
|
||||
"HeaderSetBackupSchedule": "Biztonsági másolatok ütemezésének beállítása",
|
||||
"HeaderSettings": "Beállítások",
|
||||
"HeaderSettingsDisplay": "Kijelző",
|
||||
@@ -415,6 +417,7 @@
|
||||
"LabelIntervalEveryHour": "Minden órában",
|
||||
"LabelIntervalEveryMinute": "Minden percben",
|
||||
"LabelInvert": "Inverz",
|
||||
"LabelIpAddress": "IP-cím",
|
||||
"LabelItem": "Elem",
|
||||
"LabelJumpBackwardAmount": "Visszafelé ugrás mennyisége",
|
||||
"LabelJumpForwardAmount": "Előre ugrás mennyisége",
|
||||
@@ -774,6 +777,7 @@
|
||||
"MessageConfirmDeleteSession": "Biztosan törölni szeretné ezt a munkamenetet?",
|
||||
"MessageConfirmEmbedMetadataInAudioFiles": "Biztos, hogy metaadatokat szeretne beágyazni {0} hangfájlba?",
|
||||
"MessageConfirmForceReScan": "Biztosan kényszeríteni szeretné az újraszkennelést?",
|
||||
"MessageConfirmLogoutDevice": "Biztosan ki szeretne jelentkezni a „{0}”-ból?",
|
||||
"MessageConfirmMarkAllEpisodesFinished": "Biztosan meg szeretné jelölni az összes epizódot befejezettnek?",
|
||||
"MessageConfirmMarkAllEpisodesNotFinished": "Biztosan meg szeretné jelölni az összes epizódot nem befejezettnek?",
|
||||
"MessageConfirmMarkItemFinished": "Biztos, hogy a „{0}”-t befejezettnek akarja jelölni?",
|
||||
|
||||
+32
-1
@@ -1 +1,32 @@
|
||||
{}
|
||||
{
|
||||
"ButtonAdd": "Bæta við",
|
||||
"ButtonAddApiKey": "Bæta við API-lykli",
|
||||
"ButtonAddChapters": "Bæta við köflum",
|
||||
"ButtonAddDevice": "Bæta við tæki",
|
||||
"ButtonAddLibrary": "Bæta við bókasafni",
|
||||
"ButtonAddPodcasts": "Bæta við hlaðvörpum",
|
||||
"ButtonAddUser": "Bæta við notanda",
|
||||
"ButtonAddYourFirstLibrary": "Bættu fyrsta bókasafninu þínu við",
|
||||
"ButtonApply": "Nota",
|
||||
"ButtonApplyChapters": "Skrá kafla",
|
||||
"ButtonAuthors": "Rithöfundar",
|
||||
"ButtonBack": "Bakka",
|
||||
"ButtonBatchEditPopulateFromExisting": "Afrita frá núverandi",
|
||||
"ButtonBatchEditPopulateMapDetails": "Bæta við upplýsingum",
|
||||
"ButtonBrowseForFolder": "Skoða möppu",
|
||||
"ButtonCancel": "Hætta við",
|
||||
"ButtonCancelEncode": "Hætta við Encoding",
|
||||
"ButtonChangeRootPassword": "Breyta Root lykilorði",
|
||||
"ButtonCheckAndDownloadNewEpisodes": "Skoða og sækja nýja þætti",
|
||||
"ButtonChooseAFolder": "Velja möppu",
|
||||
"ButtonChooseFiles": "Velja skrár",
|
||||
"ButtonClearFilter": "Hreinsa síu",
|
||||
"ButtonClose": "Loka",
|
||||
"ButtonCloseSession": "Loka opnu sessioni",
|
||||
"ButtonCollections": "Söfn",
|
||||
"ButtonConfigureScanner": "Stilla skannara",
|
||||
"ButtonCreate": "Stofna",
|
||||
"ButtonCreateBackup": "Búa til backup",
|
||||
"ButtonDelete": "Eyða",
|
||||
"ButtonDownloadQueue": "Röð"
|
||||
}
|
||||
@@ -46,6 +46,7 @@
|
||||
"ButtonLatest": "Ultimi",
|
||||
"ButtonLibrary": "Biblioteca",
|
||||
"ButtonLogout": "Disconnetti",
|
||||
"ButtonLogoutAllDevices": "Disconnetti su tutti i dispositivi",
|
||||
"ButtonLookup": "Consulta",
|
||||
"ButtonManageTracks": "Gestisci le Tracce",
|
||||
"ButtonMapChapterTitles": "Titoli dei Capitoli",
|
||||
@@ -139,7 +140,7 @@
|
||||
"HeaderCustomMetadataProviders": "Metadata Providers Personalizzato",
|
||||
"HeaderDetails": "Dettagli",
|
||||
"HeaderDownloadQueue": "Download coda",
|
||||
"HeaderEbookFiles": "File dei libri",
|
||||
"HeaderEbookFiles": "File del libro",
|
||||
"HeaderEmail": "E-mail",
|
||||
"HeaderEmailSettings": "Impostazioni e-mail",
|
||||
"HeaderEpisodes": "Episodi",
|
||||
@@ -194,6 +195,7 @@
|
||||
"HeaderScheduleEpisodeDownloads": "Imposta il download automatico degli episodi",
|
||||
"HeaderScheduleLibraryScans": "Schedula la scansione della libreria",
|
||||
"HeaderSession": "Sessione",
|
||||
"HeaderSessions": "Sessioni",
|
||||
"HeaderSetBackupSchedule": "Imposta programmazione Backup",
|
||||
"HeaderSettings": "Impostazioni",
|
||||
"HeaderSettingsDisplay": "Visualizzazione",
|
||||
@@ -415,6 +417,7 @@
|
||||
"LabelIntervalEveryHour": "Ogni ora",
|
||||
"LabelIntervalEveryMinute": "Ogni minuto",
|
||||
"LabelInvert": "Inverti",
|
||||
"LabelIpAddress": "Indirizzo Ip",
|
||||
"LabelItem": "Oggetti",
|
||||
"LabelJumpBackwardAmount": "secondi di avvolgimento",
|
||||
"LabelJumpForwardAmount": "Secondi di Avvolgimento",
|
||||
@@ -774,6 +777,7 @@
|
||||
"MessageConfirmDeleteSession": "Sei sicuro di voler eliminare questa sessione?",
|
||||
"MessageConfirmEmbedMetadataInAudioFiles": "Sei sicuro di voler incorporare i metadati nei file audio {0}?",
|
||||
"MessageConfirmForceReScan": "Sei sicuro di voler forzare una nuova scansione?",
|
||||
"MessageConfirmLogoutDevice": "Sei sicuro/a di volerti disconnettere da «{0}»?",
|
||||
"MessageConfirmMarkAllEpisodesFinished": "Sei sicuro di voler contrassegnare tutti gli episodi come finiti?",
|
||||
"MessageConfirmMarkAllEpisodesNotFinished": "Sei sicuro di voler contrassegnare tutti gli episodi come non completati?",
|
||||
"MessageConfirmMarkItemFinished": "Sei sicuro/sicura di voler segnare {0} come finito?",
|
||||
@@ -1075,7 +1079,7 @@
|
||||
"ToastMetadataFilesRemovedNoneRemoved": "Nessun metadato. {0} file rimossi",
|
||||
"ToastMetadataFilesRemovedSuccess": "{0} metadati.{1} file rimossi",
|
||||
"ToastMustHaveAtLeastOnePath": "Deve avere almeno un percorso",
|
||||
"ToastNameEmailRequired": "Nome ed email sono obbligatori",
|
||||
"ToastNameEmailRequired": "Nome ed e-mail sono obbligatori",
|
||||
"ToastNameRequired": "Il nome è obbligatorio",
|
||||
"ToastNewApiKeyUserError": "Deve selezionare un utente",
|
||||
"ToastNewEpisodesFound": "{0} nuovi episodi trovati",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"ButtonApplyChapters": "Taikyti skyrius",
|
||||
"ButtonAuthors": "Autoriai",
|
||||
"ButtonBack": "Atgal",
|
||||
"ButtonBatchEditPopulateFromExisting": "Užpildyti iš esamų",
|
||||
"ButtonBrowseForFolder": "Naršyti aplanko",
|
||||
"ButtonCancel": "Atšaukti",
|
||||
"ButtonCancelEncode": "Atšaukti kodavimą",
|
||||
@@ -42,6 +43,7 @@
|
||||
"ButtonLatest": "Naujausias",
|
||||
"ButtonLibrary": "Biblioteka",
|
||||
"ButtonLogout": "Atsijungti",
|
||||
"ButtonLogoutAllDevices": "Atsijungti visuose įrenginiuose",
|
||||
"ButtonLookup": "Ieškoti",
|
||||
"ButtonManageTracks": "Tvarkyti takelius",
|
||||
"ButtonMapChapterTitles": "Suderinti skyrių pavadinimus",
|
||||
@@ -69,7 +71,7 @@
|
||||
"ButtonReScan": "Iš naujo nuskaityti",
|
||||
"ButtonRead": "Skaityti",
|
||||
"ButtonReadLess": "Mažiau",
|
||||
"ButtonReadMore": "Daugiau",
|
||||
"ButtonReadMore": "Skaityti daugiau",
|
||||
"ButtonRefresh": "Atnaujinti",
|
||||
"ButtonRemove": "Pašalinti",
|
||||
"ButtonRemoveAll": "Pašalinti viską",
|
||||
@@ -230,7 +232,7 @@
|
||||
"LabelCollections": "Kolekcijos",
|
||||
"LabelComplete": "Baigta",
|
||||
"LabelConfirmPassword": "Patvirtinkite slaptažodį",
|
||||
"LabelContinueListening": "Tęsti klausymąsi",
|
||||
"LabelContinueListening": "Klausytis toliau",
|
||||
"LabelContinueReading": "Tęsti skaitymą",
|
||||
"LabelContinueSeries": "Tęsti seriją",
|
||||
"LabelCover": "Viršelis",
|
||||
@@ -248,6 +250,7 @@
|
||||
"LabelDirectory": "Katalogas",
|
||||
"LabelDiscFromFilename": "Diskas pagal failo pavadinimą",
|
||||
"LabelDiscFromMetadata": "Diskas pagal metaduomenis",
|
||||
"LabelDiscover": "Atrask",
|
||||
"LabelDownload": "Atsisiųsti",
|
||||
"LabelDownloadNEpisodes": "Atsisiųsti {0} epizodų",
|
||||
"LabelDuration": "Trukmė",
|
||||
@@ -392,8 +395,8 @@
|
||||
"LabelRead": "Skaityta",
|
||||
"LabelReadAgain": "Skaityti dar kartą",
|
||||
"LabelReadEbookWithoutProgress": "Skaityti e-knygą be pažangos saugojimo",
|
||||
"LabelRecentSeries": "Naujausios serijos",
|
||||
"LabelRecentlyAdded": "Neseniai pridėta",
|
||||
"LabelRecentSeries": "Naujausiais įkeltos serijos",
|
||||
"LabelRecentlyAdded": "Naujausiai įkelta",
|
||||
"LabelRecommended": "Rekomenduojama",
|
||||
"LabelRegion": "Regionas",
|
||||
"LabelReleaseDate": "Išleidimo data",
|
||||
@@ -511,6 +514,8 @@
|
||||
"LabelViewQueue": "Peržiūrėti grotuvo eilę",
|
||||
"LabelVolume": "Garsumas",
|
||||
"LabelWeekdaysToRun": "Dienos, kuriomis vykdyti",
|
||||
"LabelYearReviewHide": "Slėpti metų apžvalgą",
|
||||
"LabelYearReviewShow": "Pamatyti metų apžvalgą",
|
||||
"LabelYourAudiobookDuration": "Jūsų garso knygos trukmė",
|
||||
"LabelYourBookmarks": "Jūsų skirtukai",
|
||||
"LabelYourPlaylists": "Jūsų grojaraščiai",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -46,6 +46,7 @@
|
||||
"ButtonLatest": "Meest recent",
|
||||
"ButtonLibrary": "Bibliotheek",
|
||||
"ButtonLogout": "Uitloggen",
|
||||
"ButtonLogoutAllDevices": "Uitloggen op alle apparaten",
|
||||
"ButtonLookup": "Zoeken",
|
||||
"ButtonManageTracks": "Tracks beheren",
|
||||
"ButtonMapChapterTitles": "Hoofdstuktitels mappen",
|
||||
@@ -194,6 +195,7 @@
|
||||
"HeaderScheduleEpisodeDownloads": "Automatische afleveringsdownloads plannen",
|
||||
"HeaderScheduleLibraryScans": "Schema automatische bibliotheekscans",
|
||||
"HeaderSession": "Sessie",
|
||||
"HeaderSessions": "Sessies",
|
||||
"HeaderSetBackupSchedule": "Kies schema voor back-up",
|
||||
"HeaderSettings": "Instellingen",
|
||||
"HeaderSettingsDisplay": "Toon",
|
||||
@@ -415,6 +417,7 @@
|
||||
"LabelIntervalEveryHour": "Ieder uur",
|
||||
"LabelIntervalEveryMinute": "Elke minuut",
|
||||
"LabelInvert": "Omdraaien",
|
||||
"LabelIpAddress": "IP-adres",
|
||||
"LabelItem": "Onderdeel",
|
||||
"LabelJumpBackwardAmount": "Terugspoelen hoeveelheid",
|
||||
"LabelJumpForwardAmount": "Vooruitspoelen hoeveelheid",
|
||||
@@ -774,6 +777,7 @@
|
||||
"MessageConfirmDeleteSession": "Weet je zeker dat je deze sessie wil verwijderen?",
|
||||
"MessageConfirmEmbedMetadataInAudioFiles": "Weet u zeker dat u metagegevens wilt insluiten in {0} audiobestanden?",
|
||||
"MessageConfirmForceReScan": "Weet je zeker dat je geforceerd opnieuw wil scannen?",
|
||||
"MessageConfirmLogoutDevice": "Weet je zeker dat je wil uitloggen op \"{0}\"?",
|
||||
"MessageConfirmMarkAllEpisodesFinished": "Weet je zeker dat je alle afleveringen als voltooid wil markeren?",
|
||||
"MessageConfirmMarkAllEpisodesNotFinished": "Weet je zeker dat je alle afleveringen als niet-voltooid wil markeren?",
|
||||
"MessageConfirmMarkItemFinished": "Weet u zeker dat u \"{0}\" als voltooid wilt markeren?",
|
||||
|
||||
+54
-2
@@ -46,6 +46,7 @@
|
||||
"ButtonLatest": "Aktualna wersja",
|
||||
"ButtonLibrary": "Biblioteka",
|
||||
"ButtonLogout": "Wyloguj",
|
||||
"ButtonLogoutAllDevices": "Wyloguj z wszystkich urządzeń",
|
||||
"ButtonLookup": "Importuj",
|
||||
"ButtonManageTracks": "Zarządzaj ścieżkami",
|
||||
"ButtonMapChapterTitles": "Mapuj nazwy rozdziałów",
|
||||
@@ -194,6 +195,7 @@
|
||||
"HeaderScheduleEpisodeDownloads": "Planowanie automatycznego ściągania odcinków",
|
||||
"HeaderScheduleLibraryScans": "Zaplanuj automatyczne skanowanie biblioteki",
|
||||
"HeaderSession": "Sesja",
|
||||
"HeaderSessions": "Sesje",
|
||||
"HeaderSetBackupSchedule": "Ustaw harmonogram tworzenia kopii zapasowej",
|
||||
"HeaderSettings": "Ustawienia",
|
||||
"HeaderSettingsDisplay": "Wyświetlanie",
|
||||
@@ -344,7 +346,7 @@
|
||||
"LabelEncodingInfoEmbedded": "Metadane zostaną osadzone w ścieżkach audio w folderze z audiobookiem.",
|
||||
"LabelEncodingStartedNavigation": "Po uruchomieniu zadania możesz opuścić tę stronę.",
|
||||
"LabelEncodingTimeWarning": "Kodowanie może potrwać do 30 minut.",
|
||||
"LabelEncodingWarningAdvancedSettings": "Ostrzeżenie: Nie aktualizuj tych ustawień, jeśli nie jesteś zaznajomiony ze sposobem działania ffmpeg i opcji konwersji.",
|
||||
"LabelEncodingWarningAdvancedSettings": "Ostrzeżenie: Nie aktualizuj tych ustawień, jeśli nie znasz opcji kodowania ffmpeg.",
|
||||
"LabelEncodingWatcherDisabled": "Jeśli monitorowanie folderów jest wyłączone, należy ponownie przeskanować audiobooka.",
|
||||
"LabelEnd": "Zakończ",
|
||||
"LabelEndOfChapter": "Koniec rozdziału",
|
||||
@@ -415,6 +417,7 @@
|
||||
"LabelIntervalEveryHour": "Każdej godziny",
|
||||
"LabelIntervalEveryMinute": "Co minutę",
|
||||
"LabelInvert": "Inversja",
|
||||
"LabelIpAddress": "Adres IP",
|
||||
"LabelItem": "Pozycja",
|
||||
"LabelJumpBackwardAmount": "Przeskocz do tyłu o:",
|
||||
"LabelJumpForwardAmount": "Przeskocz do przodu o:",
|
||||
@@ -774,6 +777,7 @@
|
||||
"MessageConfirmDeleteSession": "Czy na pewno chcesz usunąć tę sesję?",
|
||||
"MessageConfirmEmbedMetadataInAudioFiles": "Czy na pewno chcesz osadzić metadane w {0} plikach audio?",
|
||||
"MessageConfirmForceReScan": "Czy na pewno chcesz wymusić ponowne skanowanie?",
|
||||
"MessageConfirmLogoutDevice": "Czy na pewno chcesz się wylogować z \"{0}\"?",
|
||||
"MessageConfirmMarkAllEpisodesFinished": "Czy na pewno chcesz oznaczyć wszystkie odcinki jako ukończone?",
|
||||
"MessageConfirmMarkAllEpisodesNotFinished": "Czy na pewno chcesz oznaczyć wszystkie odcinki jako nieukończone?",
|
||||
"MessageConfirmMarkItemFinished": "Czy na pewno chcesz oznaczyć \"{0}\" jako zakończone?",
|
||||
@@ -888,7 +892,7 @@
|
||||
"MessageResetChaptersConfirm": "Czy na pewno chcesz zresetować rozdziały i cofnąć wprowadzone zmiany?",
|
||||
"MessageRestoreBackupConfirm": "Czy na pewno chcesz przywrócić kopię zapasową utworzoną w dniu",
|
||||
"MessageRestoreBackupWarning": "Przywrócenie kopii zapasowej spowoduje nadpisanie bazy danych w folderze /config oraz okładek w folderze /metadata/items & /metadata/authors.<br /><br />Kopie zapasowe nie modyfikują żadnego pliku w folderach z plikami audio. Jeśli włączyłeś ustawienia serwera, aby przechowywać okładki i metadane w folderach biblioteki, to nie są one zapisywane w kopii zapasowej lub nadpisywane<br /><br />Wszyscy klienci korzystający z Twojego serwera będą automatycznie odświeżani.",
|
||||
"MessageScheduleLibraryScanNote": "W przypadku większości użytkowników zaleca się pozostawienie tej funkcji wyłączonej i włączenie opcji monitorowania folderów. Monitor folderów automatycznie wykrywa zmiany w folderach biblioteki. Monitor folderów nie działa w przypadku wszystkich systemów plików (np. NFS), dlatego zamiast niego można używać zaplanowanych skanowań biblioteki.",
|
||||
"MessageScheduleLibraryScanNote": "Dla większości użytkowników zaleca się pozostawienie tej funkcji wyłączonej i włączenie ustawienia \"Automatycznie obserwuj bibliotekę pod kątem zmian\" – będzie ono automatycznie wykrywać zmiany w folderach biblioteki. Włącz tę funkcję, jeśli \"Automatycznie obserwuj bibliotekę pod kątem zmian\" nie działa w Twoim systemie plików (np. NFS).",
|
||||
"MessageScheduleRunEveryWeekdayAtTime": "Uruchom w każdy {0} o {1}",
|
||||
"MessageSearchResultsFor": "Wyniki wyszukiwania dla",
|
||||
"MessageSelected": "{0} wybranych",
|
||||
@@ -951,6 +955,11 @@
|
||||
"NoteUploaderFoldersWithMediaFiles": "Foldery z plikami multimedialnymi będą traktowane jako osobne elementy w bibliotece.",
|
||||
"NoteUploaderOnlyAudioFiles": "Jeśli przesyłasz tylko pliki audio, każdy plik audio będzie traktowany jako osobny audiobook.",
|
||||
"NoteUploaderUnsupportedFiles": "Nieobsługiwane pliki są ignorowane. Podczas dodawania folderu, inne pliki, które nie znajdują się w folderze elementu, są ignorowane.",
|
||||
"NotificationOnBackupCompletedDescription": "Wyzwalane po zakończeniu tworzenia kopii zapasowej",
|
||||
"NotificationOnBackupFailedDescription": "Wyzwalane w przypadku gdy stworzenie kopii zapasowej rzuci błąd",
|
||||
"NotificationOnEpisodeDownloadedDescription": "Wyzwalane, gdy odcinek podcastu zostanie automatycznie pobrany",
|
||||
"NotificationOnRSSFeedDisabledDescription": "Wyzwalane, gdy automatyczne pobieranie odcinków jest wyłączone z powodu zbyt wielu nieudanych prób",
|
||||
"NotificationOnRSSFeedFailedDescription": "Wyzwalane, gdy żądanie kanału RSS dotyczące automatycznego pobrania odcinka nie powiedzie się",
|
||||
"NotificationOnTestDescription": "Zdarzenie używane do testowania systemu powiadomień",
|
||||
"PlaceholderBulkChapterInput": "Wpisz tytuł rozdziału lub użyj numeracji (np. „Odcinek 1”, „Rozdział 10”, „1.”)",
|
||||
"PlaceholderNewCollection": "Nowa nazwa kolekcji",
|
||||
@@ -960,6 +969,7 @@
|
||||
"PlaceholderSearchEpisode": "Szukanie odcinka..",
|
||||
"StatsAuthorsAdded": "dodano autorów",
|
||||
"StatsBooksAdded": "dodano książki",
|
||||
"StatsBooksAdditional": "Niektóre dodatki obejmują…",
|
||||
"StatsBooksFinished": "ukończone książki",
|
||||
"StatsBooksFinishedThisYear": "Wybrane książki ukończone w tym roku…",
|
||||
"StatsBooksListenedTo": "książki wysłuchane",
|
||||
@@ -976,6 +986,7 @@
|
||||
"StatsTotalDuration": "O sumarycznej długości…",
|
||||
"StatsYearInReview": "PRZEGLĄD ROKU",
|
||||
"ToastAccountUpdateSuccess": "Zaktualizowano konto",
|
||||
"ToastAppriseUrlRequired": "Należy wprowadzić adres URL Apprise",
|
||||
"ToastAsinRequired": "ASIN jest wymagany",
|
||||
"ToastAuthorImageRemoveSuccess": "Zdjęcie autora usunięte",
|
||||
"ToastAuthorNotFound": "Autor \"{0}\" nie został znaleziony",
|
||||
@@ -994,8 +1005,11 @@
|
||||
"ToastBackupRestoreFailed": "Nie udało się przywrócić kopii zapasowej",
|
||||
"ToastBackupUploadFailed": "Nie udało się przesłać kopii zapasowej",
|
||||
"ToastBackupUploadSuccess": "Kopia zapasowa została przesłana",
|
||||
"ToastBatchApplyDetailsToItemsSuccess": "Szczegóły zastosowane do elementów",
|
||||
"ToastBatchDeleteFailed": "Usuwanie zbiorcze nie powiodło się",
|
||||
"ToastBatchDeleteSuccess": "Usuwanie zbiorcze powiodło się",
|
||||
"ToastBatchQuickMatchFailed": "Szybkie dopasowanie partii nie powiodło się!",
|
||||
"ToastBatchQuickMatchStarted": "Rozpoczęto partię szybkiego dopasowania {0} książek!",
|
||||
"ToastBatchUpdateFailed": "Aktualizacja zbiorcza nie powiodła się",
|
||||
"ToastBatchUpdateSuccess": "Aktualizacja zbiorcza powiodła się",
|
||||
"ToastBookmarkCreateFailed": "Nie udało się utworzyć zakładki",
|
||||
@@ -1033,7 +1047,14 @@
|
||||
"ToastEpisodeDownloadQueueClearSuccess": "Wyczyszczono kolejkę epizodów do ściągnięcia",
|
||||
"ToastEpisodeUpdateSuccess": "Zaktualizowano {0} odcinków",
|
||||
"ToastErrorCannotShare": "Nie można udostępniać natywnie na tym urządzeniu.",
|
||||
"ToastFailedToCreate": "Nie udało się utworzyć",
|
||||
"ToastFailedToDelete": "Nie udało się usunąć",
|
||||
"ToastFailedToLoadData": "Nie udało się załadować danych",
|
||||
"ToastFailedToMatch": "Nie udało się dopasować",
|
||||
"ToastFailedToShare": "Nie udało się udostępnić",
|
||||
"ToastFailedToUpdate": "Nie udało się zaktualizować",
|
||||
"ToastInvalidImageUrl": "Nieprawidłowy URL obrazu",
|
||||
"ToastInvalidMaxEpisodesToDownload": "Nieprawidłowa maksymalna liczba odcinków do pobrania",
|
||||
"ToastInvalidUrl": "Nieprawidłowy URL",
|
||||
"ToastInvalidUrls": "Jeden lub więcej URL-i są nieprawidłowe",
|
||||
"ToastItemCoverUpdateSuccess": "Zaktualizowano okładkę",
|
||||
@@ -1044,6 +1065,7 @@
|
||||
"ToastItemMarkedAsFinishedSuccess": "Pozycja oznaczona jako ukończona",
|
||||
"ToastItemMarkedAsNotFinishedFailed": "Oznaczenie pozycji jako ukończonej nie powiodło się",
|
||||
"ToastItemMarkedAsNotFinishedSuccess": "Pozycja oznaczona jako nieukończona",
|
||||
"ToastItemUpdateSuccess": "Element zaktualizowany",
|
||||
"ToastLibraryCreateFailed": "Nie udało się utworzyć biblioteki",
|
||||
"ToastLibraryCreateSuccess": "Biblioteka \"{0}\" stworzona",
|
||||
"ToastLibraryDeleteFailed": "Nie udało się usunąć biblioteki",
|
||||
@@ -1052,6 +1074,10 @@
|
||||
"ToastLibraryScanStarted": "Rozpoczęto skanowanie biblioteki",
|
||||
"ToastLibraryUpdateSuccess": "Zaktualizowano \"{0}\" pozycji",
|
||||
"ToastMatchAllAuthorsFailed": "Nie udało się dopasować wszystkich autorów",
|
||||
"ToastMetadataFilesRemovedError": "Błąd podczas usuwania metadata.{0} plików",
|
||||
"ToastMetadataFilesRemovedNoneFound": "Nie znaleziono metadata.{0} plików w bibliotece",
|
||||
"ToastMetadataFilesRemovedNoneRemoved": "Nie usunięto żadnego metadata.{0} pliku",
|
||||
"ToastMetadataFilesRemovedSuccess": "{0} metadata.{0} plików usunięto",
|
||||
"ToastMustHaveAtLeastOnePath": "Musi mieć przynajmniej jedną ścieżkę",
|
||||
"ToastNameEmailRequired": "Nazwa i email są wymagane",
|
||||
"ToastNameRequired": "Imię jest wymagane",
|
||||
@@ -1065,7 +1091,15 @@
|
||||
"ToastNewUserUsernameError": "Wprowadź nazwę użytkownika",
|
||||
"ToastNoNewEpisodesFound": "Nie znaleziono nowych odcinków",
|
||||
"ToastNoRSSFeed": "Podcast nie posiada RSS Feed",
|
||||
"ToastNoUpdatesNecessary": "Brak konieczności aktualizacji",
|
||||
"ToastNotificationCreateFailed": "Nie udało się utworzyć powiadomienia",
|
||||
"ToastNotificationDeleteFailed": "Nie udało się usunąć powiadomienia",
|
||||
"ToastNotificationFailedMaximum": "Maks. ilość nieudanych prób musi być >= 0",
|
||||
"ToastNotificationQueueMaximum": "Maksymalna liczba powiadomień w kolejce musi być >= 0",
|
||||
"ToastNotificationSettingsUpdateSuccess": "Zaktualizowano ustawienia powiadomień",
|
||||
"ToastNotificationTestTriggerFailed": "Nie udało się wywołać powiadomienia testowego",
|
||||
"ToastNotificationTestTriggerSuccess": "Wyzwolono powiadomienie testowe",
|
||||
"ToastNotificationUpdateSuccess": "Powiadomienie zaktualizowane",
|
||||
"ToastPlaylistCreateFailed": "Nie udało się utworzyć playlisty",
|
||||
"ToastPlaylistCreateSuccess": "Playlista utworzona",
|
||||
"ToastPlaylistRemoveSuccess": "Playlista usunięta",
|
||||
@@ -1073,8 +1107,17 @@
|
||||
"ToastPodcastCreateFailed": "Nie udało się utworzyć podcastu",
|
||||
"ToastPodcastCreateSuccess": "Podcast został pomyślnie utworzony",
|
||||
"ToastPodcastEpisodeUpdated": "Zaktualizowano odcinki",
|
||||
"ToastPodcastGetFeedFailed": "Nie udało się pobrać kanału podcastu",
|
||||
"ToastPodcastNoEpisodesInFeed": "Nie znaleziono żadnych odcinków w kanale RSS",
|
||||
"ToastPodcastNoRssFeed": "Podcast nie ma kanału RSS",
|
||||
"ToastProgressIsNotBeingSynced": "Postęp nie jest synchronizowany, uruchom ponownie odtwarzanie",
|
||||
"ToastProviderCreatedFailed": "Nie udało się dodać dostawcy",
|
||||
"ToastProviderCreatedSuccess": "Dodano nowego dostawcę",
|
||||
"ToastProviderNameAndUrlRequired": "Wymagane jest podanie nazwy i adresu URL",
|
||||
"ToastProviderRemoveSuccess": "Dostawca usunięty",
|
||||
"ToastRSSFeedCloseFailed": "Zamknięcie kanału RSS nie powiodło się",
|
||||
"ToastRSSFeedCloseSuccess": "Zamknięcie kanału RSS powiodło się",
|
||||
"ToastRemoveFailed": "Nie udało się usunąć",
|
||||
"ToastRemoveItemFromCollectionFailed": "Nie udało się usunąć elementu z kolekcji",
|
||||
"ToastRemoveItemFromCollectionSuccess": "Pozycja usunięta z kolekcji",
|
||||
"ToastRemoveItemsWithIssuesFailed": "Nie udało się usunąć wadliwych elementów z biblioteki",
|
||||
@@ -1096,16 +1139,25 @@
|
||||
"ToastSessionDeleteFailed": "Nie udało się usunąć sesji",
|
||||
"ToastSessionDeleteSuccess": "Sesja usunięta",
|
||||
"ToastSleepTimerDone": "Słodkich snów... zZzzZz",
|
||||
"ToastSlugMustChange": "Slug zawiera nieprawidłowe znaki",
|
||||
"ToastSlugRequired": "Slug jest wymagany",
|
||||
"ToastSocketConnected": "Nawiązano połączenie z serwerem",
|
||||
"ToastSocketDisconnected": "Połączenie z serwerem zostało zamknięte",
|
||||
"ToastSocketFailedToConnect": "Poączenie z serwerem nie powiodło się",
|
||||
"ToastSortingPrefixesEmptyError": "Musi mieć co najmniej 1 prefiks sortowania",
|
||||
"ToastSortingPrefixesUpdateSuccess": "Zaktualizowano prefiksy sortowania ({0} elementów)",
|
||||
"ToastTitleRequired": "Tytuł jest wymagany",
|
||||
"ToastUnknownError": "Nieznany błąd",
|
||||
"ToastUnlinkOpenIdFailed": "Nie udało się odpiąć użytkownika z OpenID",
|
||||
"ToastUnlinkOpenIdSuccess": "Użytkownik odpięty z OpenID",
|
||||
"ToastUploaderFilepathExistsError": "Ścieżka \"{0}\" już istnieje na serwerze",
|
||||
"ToastUploaderItemExistsInSubdirectoryError": "Element \"{0}\" używa podkatalogu ścieżki przesyłania.",
|
||||
"ToastUserDeleteFailed": "Nie udało się usunąć użytkownika",
|
||||
"ToastUserDeleteSuccess": "Użytkownik usunięty",
|
||||
"ToastUserPasswordChangeSuccess": "Hasło zostało pomyślnie zmienione",
|
||||
"ToastUserPasswordMismatch": "Hasła nie są zgodne",
|
||||
"ToastUserPasswordMustChange": "Nowe hasło nie może być takie samo jak stare hasło",
|
||||
"ToastUserRootRequireName": "Należy wprowadzić nazwę użytkownika root",
|
||||
"TooltipAddChapters": "Dodaj rozdział(y)",
|
||||
"TooltipAddOneSecond": "Dodaj sekundę",
|
||||
"TooltipAdjustChapterStart": "Kliknij, aby skorygować czas początkowy",
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"ButtonLatest": "Mais Recentes",
|
||||
"ButtonLibrary": "Biblioteca",
|
||||
"ButtonLogout": "Logout",
|
||||
"ButtonLogoutAllDevices": "Desconectar de todos os dispositivos",
|
||||
"ButtonLookup": "Procurar",
|
||||
"ButtonManageTracks": "Gerenciar Faixas",
|
||||
"ButtonMapChapterTitles": "Designar Títulos de Capítulos",
|
||||
@@ -194,6 +195,7 @@
|
||||
"HeaderScheduleEpisodeDownloads": "Programar Download Automático de Episódios",
|
||||
"HeaderScheduleLibraryScans": "Programar Verificação Automática da Biblioteca",
|
||||
"HeaderSession": "Sessão",
|
||||
"HeaderSessions": "Sessões",
|
||||
"HeaderSetBackupSchedule": "Definir Programação de Backup",
|
||||
"HeaderSettings": "Configurações",
|
||||
"HeaderSettingsDisplay": "Exibição",
|
||||
@@ -325,8 +327,8 @@
|
||||
"LabelDurationComparisonLonger": "({0} maior)",
|
||||
"LabelDurationComparisonShorter": "({0} menor)",
|
||||
"LabelDurationFound": "Duração comprovada:",
|
||||
"LabelEbook": "Ebook",
|
||||
"LabelEbooks": "Ebooks",
|
||||
"LabelEbook": "E-book",
|
||||
"LabelEbooks": "E-books",
|
||||
"LabelEdit": "Editar",
|
||||
"LabelEmail": "Email",
|
||||
"LabelEmailSettingsFromAddress": "Remetente",
|
||||
@@ -415,6 +417,7 @@
|
||||
"LabelIntervalEveryHour": "Toda hora",
|
||||
"LabelIntervalEveryMinute": "A cada minuto",
|
||||
"LabelInvert": "Inverter",
|
||||
"LabelIpAddress": "Endereço IP",
|
||||
"LabelItem": "Item",
|
||||
"LabelJumpBackwardAmount": "Tempo de retrocesso",
|
||||
"LabelJumpForwardAmount": "Tempo de avanço",
|
||||
@@ -774,6 +777,7 @@
|
||||
"MessageConfirmDeleteSession": "Tem certeza de que deseja apagar essa sessão?",
|
||||
"MessageConfirmEmbedMetadataInAudioFiles": "Tem certeza de que deseja incorporar metadados em arquivos de áudio {0}?",
|
||||
"MessageConfirmForceReScan": "Tem certeza de que deseja forçar a nova verificação?",
|
||||
"MessageConfirmLogoutDevice": "Tem certeza de que deseja sair de \"{0}\"?",
|
||||
"MessageConfirmMarkAllEpisodesFinished": "Tem certeza de que deseja marcar todos os episódios como concluídos?",
|
||||
"MessageConfirmMarkAllEpisodesNotFinished": "Tem certeza de que deseja marcar todos os episódios como não concluídos?",
|
||||
"MessageConfirmMarkItemFinished": "Tem certeza de que deseja marcar \"{0}\" como concluído?",
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"ButtonLatest": "Последнее",
|
||||
"ButtonLibrary": "Библиотека",
|
||||
"ButtonLogout": "Выход",
|
||||
"ButtonLogoutAllDevices": "Выйти на всех устройствах",
|
||||
"ButtonLookup": "Найти",
|
||||
"ButtonManageTracks": "Управление треками",
|
||||
"ButtonMapChapterTitles": "Найти названия глав",
|
||||
@@ -194,6 +195,7 @@
|
||||
"HeaderScheduleEpisodeDownloads": "Запланировать автоматическое скачивание выпусков",
|
||||
"HeaderScheduleLibraryScans": "Планировщик автоматического сканирования библиотеки",
|
||||
"HeaderSession": "Сеансы",
|
||||
"HeaderSessions": "Сессии",
|
||||
"HeaderSetBackupSchedule": "Установить планировщик бэкапов",
|
||||
"HeaderSettings": "Настройки",
|
||||
"HeaderSettingsDisplay": "Дисплей",
|
||||
@@ -415,6 +417,7 @@
|
||||
"LabelIntervalEveryHour": "Каждый час",
|
||||
"LabelIntervalEveryMinute": "Каждую минуту",
|
||||
"LabelInvert": "Инвертировать",
|
||||
"LabelIpAddress": "IP-адрес",
|
||||
"LabelItem": "Элемент",
|
||||
"LabelJumpBackwardAmount": "Прыжок назад на величину",
|
||||
"LabelJumpForwardAmount": "Прыжок вперед на величину",
|
||||
@@ -774,6 +777,7 @@
|
||||
"MessageConfirmDeleteSession": "Вы уверены, что хотите удалить этот сеанс?",
|
||||
"MessageConfirmEmbedMetadataInAudioFiles": "Вы уверены, что хотите вставить метаданные в {0} аудиофайлов?",
|
||||
"MessageConfirmForceReScan": "Вы уверены, что хотите принудительно выполнить повторное сканирование?",
|
||||
"MessageConfirmLogoutDevice": "Вы уверены, что хотите выйти из «{0}»?",
|
||||
"MessageConfirmMarkAllEpisodesFinished": "Вы уверены, что хотите отметить все выпуски как прослушанные?",
|
||||
"MessageConfirmMarkAllEpisodesNotFinished": "Вы уверены, что хотите отметить все выпуски как непрослушанные?",
|
||||
"MessageConfirmMarkItemFinished": "Вы уверены, что хотите отметить «{0}» как завершенную?",
|
||||
|
||||
+533
-533
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
||||
{}
|
||||
+261
-257
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"ButtonAdd": "添加",
|
||||
"ButtonAddApiKey": "新增API金鑰",
|
||||
"ButtonAddChapters": "新增章節",
|
||||
"ButtonAddDevice": "新增設備",
|
||||
"ButtonAddLibrary": "新增庫",
|
||||
@@ -10,6 +11,7 @@
|
||||
"ButtonApplyChapters": "應用到章節",
|
||||
"ButtonAuthors": "作者",
|
||||
"ButtonBack": "返回",
|
||||
"ButtonBatchEditPopulateFromExisting": "從現有資料帶入",
|
||||
"ButtonBrowseForFolder": "瀏覽資料夾",
|
||||
"ButtonCancel": "取消",
|
||||
"ButtonCancelEncode": "取消編碼",
|
||||
@@ -48,11 +50,12 @@
|
||||
"ButtonNevermind": "沒關係",
|
||||
"ButtonNext": "下個",
|
||||
"ButtonNextChapter": "下個章節",
|
||||
"ButtonOk": "確定",
|
||||
"ButtonOk": "好",
|
||||
"ButtonOpenFeed": "打開源",
|
||||
"ButtonOpenManager": "打開管理器",
|
||||
"ButtonPause": "暫停",
|
||||
"ButtonPlay": "播放",
|
||||
"ButtonPlayAll": "全部播放",
|
||||
"ButtonPlaying": "正在播放",
|
||||
"ButtonPlaylists": "播放列表",
|
||||
"ButtonPrevious": "上一個",
|
||||
@@ -64,6 +67,8 @@
|
||||
"ButtonQuickMatch": "快速匹配",
|
||||
"ButtonReScan": "重新掃描",
|
||||
"ButtonRead": "讀取",
|
||||
"ButtonReadLess": "顯示較少",
|
||||
"ButtonReadMore": "顯示更多",
|
||||
"ButtonRefresh": "重整",
|
||||
"ButtonRemove": "移除",
|
||||
"ButtonRemoveAll": "移除所有",
|
||||
@@ -83,12 +88,15 @@
|
||||
"ButtonSelectFolderPath": "選擇資料夾路徑",
|
||||
"ButtonSeries": "系列",
|
||||
"ButtonSetChaptersFromTracks": "將音軌設定為章節",
|
||||
"ButtonShare": "分享",
|
||||
"ButtonShiftTimes": "快速調整時間",
|
||||
"ButtonShow": "顯示",
|
||||
"ButtonStartM4BEncode": "開始 M4B 編碼",
|
||||
"ButtonStartMetadataEmbed": "開始嵌入元數據",
|
||||
"ButtonStats": "統計資料",
|
||||
"ButtonSubmit": "提交",
|
||||
"ButtonTest": "測試",
|
||||
"ButtonUnlinkOpenId": "解除連結 OpenID",
|
||||
"ButtonUpload": "上傳",
|
||||
"ButtonUploadBackup": "上傳備份",
|
||||
"ButtonUploadCover": "上傳封面",
|
||||
@@ -145,6 +153,7 @@
|
||||
"HeaderMetadataOrderOfPrecedence": "元數據優先級",
|
||||
"HeaderMetadataToEmbed": "嵌入元數據",
|
||||
"HeaderNewAccount": "新建帳號",
|
||||
"HeaderNewApiKey": "新API權杖",
|
||||
"HeaderNewLibrary": "新建媒體庫",
|
||||
"HeaderNotifications": "通知",
|
||||
"HeaderOpenIDConnectAuthentication": "OpenID 連接身份驗證",
|
||||
@@ -164,6 +173,7 @@
|
||||
"HeaderRemoveEpisodes": "移除 {0} 劇集",
|
||||
"HeaderSavedMediaProgress": "保存媒體進度",
|
||||
"HeaderSchedule": "計劃任務",
|
||||
"HeaderScheduleEpisodeDownloads": "設定自動下載集數",
|
||||
"HeaderScheduleLibraryScans": "自動掃描媒體庫",
|
||||
"HeaderSession": "會話",
|
||||
"HeaderSetBackupSchedule": "設定備份計劃任務",
|
||||
@@ -182,6 +192,7 @@
|
||||
"HeaderTableOfContents": "目錄",
|
||||
"HeaderTools": "工具",
|
||||
"HeaderUpdateAccount": "更新帳號",
|
||||
"HeaderUpdateApiKey": "更新API權杖",
|
||||
"HeaderUpdateAuthor": "更新作者",
|
||||
"HeaderUpdateDetails": "更新詳情",
|
||||
"HeaderUpdateLibrary": "更新媒體庫",
|
||||
@@ -202,12 +213,14 @@
|
||||
"LabelAddToPlaylist": "新增到播放列表",
|
||||
"LabelAddToPlaylistBatch": "新增 {0} 個項目到播放列表",
|
||||
"LabelAddedAt": "新增於",
|
||||
"LabelAddedDate": "新增{0}",
|
||||
"LabelAdminUsersOnly": "僅限管理員使用者",
|
||||
"LabelAll": "全部",
|
||||
"LabelAllUsers": "所有使用者",
|
||||
"LabelAllUsersExcludingGuests": "除訪客外的所有使用者",
|
||||
"LabelAllUsersIncludingGuests": "包括訪客的所有使用者",
|
||||
"LabelAlreadyInYourLibrary": "已存在你的庫中",
|
||||
"LabelApiToken": "API 權杖",
|
||||
"LabelAppend": "附加",
|
||||
"LabelAuthor": "作者",
|
||||
"LabelAuthorFirstLast": "作者 (姓 名)",
|
||||
@@ -229,8 +242,10 @@
|
||||
"LabelBackupsNumberToKeep": "要保留的備份個數",
|
||||
"LabelBackupsNumberToKeepHelp": "一次只能刪除一個備份, 因此如果你已經有超過此數量的備份, 則應手動刪除它們.",
|
||||
"LabelBitrate": "位元率",
|
||||
"LabelBonus": "附贈內容",
|
||||
"LabelBooks": "圖書",
|
||||
"LabelButtonText": "按鈕文本",
|
||||
"LabelByAuthor": "由{0}",
|
||||
"LabelChangePassword": "修改密碼",
|
||||
"LabelChannels": "聲道",
|
||||
"LabelChapterTitle": "章節標題",
|
||||
@@ -255,6 +270,7 @@
|
||||
"LabelCurrently": "當前:",
|
||||
"LabelCustomCronExpression": "自定義計劃任務表達式:",
|
||||
"LabelDatetime": "日期時間",
|
||||
"LabelDays": "天",
|
||||
"LabelDeleteFromFileSystemCheckbox": "從檔案系統刪除 (取消選中僅從資料庫中刪除)",
|
||||
"LabelDescription": "描述",
|
||||
"LabelDeselectAll": "全部取消選擇",
|
||||
@@ -289,8 +305,10 @@
|
||||
"LabelEpisode": "劇集",
|
||||
"LabelEpisodeTitle": "劇集標題",
|
||||
"LabelEpisodeType": "劇集類型",
|
||||
"LabelEpisodes": "集數",
|
||||
"LabelEpisodic": "單集式",
|
||||
"LabelExample": "示例",
|
||||
"LabelExplicit": "信息準確",
|
||||
"LabelExplicit": "限制級",
|
||||
"LabelFeedURL": "源鏈接",
|
||||
"LabelFetchingMetadata": "正在獲取元數據",
|
||||
"LabelFile": "文件",
|
||||
@@ -302,12 +320,14 @@
|
||||
"LabelFinished": "已聽完",
|
||||
"LabelFolder": "資料夾",
|
||||
"LabelFolders": "資料夾",
|
||||
"LabelFontBold": "粗體",
|
||||
"LabelFontBoldness": "字體粗細",
|
||||
"LabelFontFamily": "字體系列",
|
||||
"LabelFontFamily": "字體家族",
|
||||
"LabelFontItalic": "斜體",
|
||||
"LabelFontScale": "字體比例",
|
||||
"LabelFontStrikethrough": "刪除線",
|
||||
"LabelFormat": "編碼格式",
|
||||
"LabelFull": "完整",
|
||||
"LabelGenre": "流派",
|
||||
"LabelGenres": "流派",
|
||||
"LabelHardDeleteFile": "完全刪除檔案",
|
||||
@@ -316,6 +336,7 @@
|
||||
"LabelHighestPriority": "最高優先級",
|
||||
"LabelHost": "主機",
|
||||
"LabelHour": "小時",
|
||||
"LabelHours": "小時",
|
||||
"LabelIcon": "圖標",
|
||||
"LabelImageURLFromTheWeb": "來自 Web 圖像的 URL",
|
||||
"LabelInProgress": "正在聽",
|
||||
@@ -334,6 +355,7 @@
|
||||
"LabelItem": "項目",
|
||||
"LabelLanguage": "語言",
|
||||
"LabelLanguageDefaultServer": "預設伺服器語言",
|
||||
"LabelLanguages": "語言",
|
||||
"LabelLastBookAdded": "最後新增的書",
|
||||
"LabelLastBookUpdated": "最後更新的書",
|
||||
"LabelLastSeen": "上次查看時間",
|
||||
@@ -347,6 +369,9 @@
|
||||
"LabelLibrary": "媒體庫",
|
||||
"LabelLibraryItem": "媒體庫項目",
|
||||
"LabelLibraryName": "媒體庫名稱",
|
||||
"LabelLibrarySortByProgress": "進度:最後更新",
|
||||
"LabelLibrarySortByProgressFinished": "進度:已完成",
|
||||
"LabelLibrarySortByProgressStarted": "進度:已開始",
|
||||
"LabelLimit": "限制",
|
||||
"LabelLineSpacing": "行間距",
|
||||
"LabelListenAgain": "再次收聽",
|
||||
@@ -364,7 +389,8 @@
|
||||
"LabelMetadataOrderOfPrecedenceDescription": "較高優先級的元數據源將覆蓋較低優先級的元數據源",
|
||||
"LabelMetadataProvider": "元數據提供者",
|
||||
"LabelMinute": "分鐘",
|
||||
"LabelMissing": "丟失",
|
||||
"LabelMinutes": "分鐘",
|
||||
"LabelMissing": "缺失",
|
||||
"LabelMobileRedirectURIs": "允許移動應用重定向 URI",
|
||||
"LabelMobileRedirectURIsDescription": "這是移動應用程序的有效重定向 URI 白名單. 預設值為 <code>audiobookshelf://oauth</code>,您可以刪除它或加入其他 URI 以進行第三方應用集成. 使用星號 (<code>*</code>) 作為唯一條目允許任何 URI.",
|
||||
"LabelMore": "更多",
|
||||
@@ -392,11 +418,12 @@
|
||||
"LabelNotificationsMaxQueueSize": "通知事件的最大佇列大小",
|
||||
"LabelNotificationsMaxQueueSizeHelp": "通知事件被限制為每秒觸發 1 個. 如果佇列處於最大大小, 則將忽略事件. 這可以防止通知垃圾郵件.",
|
||||
"LabelNumberOfBooks": "圖書數量",
|
||||
"LabelNumberOfEpisodes": "# 集",
|
||||
"LabelNumberOfEpisodes": "集數",
|
||||
"LabelOpenRSSFeed": "打開 RSS 源",
|
||||
"LabelOverwrite": "覆蓋",
|
||||
"LabelPassword": "密碼",
|
||||
"LabelPath": "路徑",
|
||||
"LabelPermanent": "永久",
|
||||
"LabelPermissionsAccessAllLibraries": "可以存取所有媒體庫",
|
||||
"LabelPermissionsAccessAllTags": "可以存取所有標籤",
|
||||
"LabelPermissionsAccessExplicitContent": "可以存取顯式內容",
|
||||
@@ -420,10 +447,12 @@
|
||||
"LabelProvider": "供應商",
|
||||
"LabelPubDate": "出版日期",
|
||||
"LabelPublishYear": "發布年份",
|
||||
"LabelPublishedDate": "已發布{0}",
|
||||
"LabelPublisher": "出版商",
|
||||
"LabelPublishers": "出版商",
|
||||
"LabelRSSFeedCustomOwnerEmail": "自定義所有者電子郵件",
|
||||
"LabelRSSFeedCustomOwnerName": "自定義所有者名稱",
|
||||
"LabelRSSFeedOpen": "打開 RSS 源",
|
||||
"LabelRSSFeedOpen": "開啟RSS訂閱",
|
||||
"LabelRSSFeedPreventIndexing": "防止索引",
|
||||
"LabelRSSFeedSlug": "RSS 源段",
|
||||
"LabelRSSFeedURL": "RSS 源 URL",
|
||||
@@ -447,6 +476,7 @@
|
||||
"LabelSelectEpisodesShowing": "選擇正在播放的 {0} 劇集",
|
||||
"LabelSendEbookToDevice": "發送電子書到...",
|
||||
"LabelSequence": "序列",
|
||||
"LabelSerial": "連續劇式",
|
||||
"LabelSeries": "系列",
|
||||
"LabelSeriesName": "系列名稱",
|
||||
"LabelSeriesProgress": "系列進度",
|
||||
@@ -482,9 +512,12 @@
|
||||
"LabelSettingsStoreMetadataWithItem": "存儲項目元數據",
|
||||
"LabelSettingsStoreMetadataWithItemHelp": "預設情況下元數據檔案存儲在/metadata/items資料夾中, 啟用此設定將存儲元數據在你媒體項目資料夾中",
|
||||
"LabelSettingsTimeFormat": "時間格式",
|
||||
"LabelShare": "分享",
|
||||
"LabelShowAll": "全部顯示",
|
||||
"LabelShowSeconds": "顯示秒數",
|
||||
"LabelSize": "檔案大小",
|
||||
"LabelSleepTimer": "睡眠定時",
|
||||
"LabelSlug": "網址代稱",
|
||||
"LabelStart": "開始",
|
||||
"LabelStartTime": "開始時間",
|
||||
"LabelStarted": "開始於",
|
||||
@@ -512,6 +545,7 @@
|
||||
"LabelTagsNotAccessibleToUser": "使用者無法存取標籤",
|
||||
"LabelTasks": "正在運行的任務",
|
||||
"LabelTextEditorBulletedList": "項目符號列表",
|
||||
"LabelTextEditorLink": "連結",
|
||||
"LabelTextEditorNumberedList": "編號列表",
|
||||
"LabelTextEditorUnlink": "取消連結",
|
||||
"LabelTheme": "主題",
|
||||
@@ -539,6 +573,7 @@
|
||||
"LabelTracksSingleTrack": "單軌",
|
||||
"LabelType": "類型",
|
||||
"LabelUnabridged": "未刪節",
|
||||
"LabelUndo": "復原",
|
||||
"LabelUnknown": "未知",
|
||||
"LabelUpdateCover": "更新封面",
|
||||
"LabelUpdateCoverHelp": "找到匹配項時允許覆蓋所選書籍存在的封面",
|
||||
@@ -546,6 +581,7 @@
|
||||
"LabelUpdateDetailsHelp": "找到匹配項時允許覆蓋所選書籍存在的詳細信息",
|
||||
"LabelUpdatedAt": "更新時間",
|
||||
"LabelUploaderDragAndDrop": "拖放檔案或資料夾",
|
||||
"LabelUploaderDragAndDropFilesOnly": "拖曳檔案至此",
|
||||
"LabelUploaderDropFiles": "刪除檔案",
|
||||
"LabelUploaderItemFetchMetadataHelp": "自動獲取標題, 作者和系列",
|
||||
"LabelUseChapterTrack": "使用章節音軌",
|
||||
@@ -635,7 +671,7 @@
|
||||
"MessageNoBackups": "沒有備份",
|
||||
"MessageNoBookmarks": "沒有書籤",
|
||||
"MessageNoChapters": "沒有章節",
|
||||
"MessageNoCollections": "沒有收藏",
|
||||
"MessageNoCollections": "沒有合集",
|
||||
"MessageNoCoversFound": "沒有找到封面",
|
||||
"MessageNoDescription": "沒有描述",
|
||||
"MessageNoDownloadsInProgress": "當前沒有正在進行的下載",
|
||||
@@ -665,6 +701,7 @@
|
||||
"MessagePlayChapter": "開始章節播放",
|
||||
"MessagePlaylistCreateFromCollection": "從收藏中創建播放列表",
|
||||
"MessagePodcastHasNoRSSFeedForMatching": "播客沒有可用於匹配 RSS 源的 url",
|
||||
"MessagePodcastSearchField": "輸入搜尋關鍵字或 RSS 訂閱源網址",
|
||||
"MessageQuickMatchDescription": "使用來自 '{0}' 的第一個匹配結果填充空白詳細信息和封面. 除非啟用 '首選匹配元數據' 伺服器設定, 否則不會覆蓋詳細信息.",
|
||||
"MessageRemoveChapter": "移除章節",
|
||||
"MessageRemoveEpisodes": "移除 {0} 劇集",
|
||||
@@ -678,7 +715,11 @@
|
||||
"MessageSelected": "{0} 被選取",
|
||||
"MessageServerCouldNotBeReached": "無法連接伺服器",
|
||||
"MessageSetChaptersFromTracksDescription": "把每個音頻檔設定為章節並將章節標題設定為音頻檔名",
|
||||
"MessageShareExpirationWillBe": "有效期限為 <strong>{0}</strong>",
|
||||
"MessageStartPlaybackAtTime": "開始播放 \"{0}\" 在 {1}?",
|
||||
"MessageTaskEmbeddingMetadata": "正在嵌入詮釋資料",
|
||||
"MessageTaskEncodingM4b": "正在編碼 M4B",
|
||||
"MessageTaskFailed": "失敗",
|
||||
"MessageThinking": "正在查找...",
|
||||
"MessageUploaderItemFailed": "上傳失敗",
|
||||
"MessageUploaderItemSuccess": "上傳成功!",
|
||||
@@ -701,8 +742,11 @@
|
||||
"PlaceholderNewPlaylist": "輸入播放列表名稱",
|
||||
"PlaceholderSearch": "查找..",
|
||||
"PlaceholderSearchEpisode": "搜尋劇集..",
|
||||
"StatsSessions": "收聽時段",
|
||||
"ToastAccountUpdateSuccess": "帳號已更新",
|
||||
"ToastAsinRequired": "需要 ASIN",
|
||||
"ToastAuthorImageRemoveSuccess": "作者圖像已刪除",
|
||||
"ToastAuthorSearchNotFound": "找不到作者",
|
||||
"ToastAuthorUpdateMerged": "作者已合併",
|
||||
"ToastAuthorUpdateSuccess": "作者已更新",
|
||||
"ToastAuthorUpdateSuccessNoImageFound": "作者已更新 (未找到圖像)",
|
||||
@@ -713,6 +757,8 @@
|
||||
"ToastBackupRestoreFailed": "備份還原失敗",
|
||||
"ToastBackupUploadFailed": "上傳備份失敗",
|
||||
"ToastBackupUploadSuccess": "備份已上傳",
|
||||
"ToastBatchDeleteFailed": "批次刪除失敗",
|
||||
"ToastBatchDeleteSuccess": "批次刪除成功",
|
||||
"ToastBatchUpdateFailed": "批量更新失敗",
|
||||
"ToastBatchUpdateSuccess": "批量更新成功",
|
||||
"ToastBookmarkCreateFailed": "創建書簽失敗",
|
||||
@@ -720,8 +766,12 @@
|
||||
"ToastBookmarkRemoveSuccess": "書籤已刪除",
|
||||
"ToastChaptersHaveErrors": "章節有錯誤",
|
||||
"ToastChaptersMustHaveTitles": "章節必須有標題",
|
||||
"ToastChaptersUpdated": "章節已更新",
|
||||
"ToastCollectionRemoveSuccess": "收藏夾已刪除",
|
||||
"ToastCollectionUpdateSuccess": "收藏夾已更新",
|
||||
"ToastCoverUpdateFailed": "封面更新失敗",
|
||||
"ToastFailedToMatch": "比對失敗",
|
||||
"ToastFailedToUpdate": "更新失敗",
|
||||
"ToastItemCoverUpdateSuccess": "項目封面已更新",
|
||||
"ToastItemDetailsUpdateSuccess": "項目詳細信息已更新",
|
||||
"ToastItemMarkedAsFinishedFailed": "標記為聽完失敗",
|
||||
@@ -735,6 +785,7 @@
|
||||
"ToastLibraryScanFailedToStart": "無法啟動掃描",
|
||||
"ToastLibraryScanStarted": "媒體庫掃描已啟動",
|
||||
"ToastLibraryUpdateSuccess": "媒體庫 \"{0}\" 已更新",
|
||||
"ToastNewEpisodesFound": "找到{0}個新單集",
|
||||
"ToastPlaylistCreateFailed": "創建播放列表失敗",
|
||||
"ToastPlaylistCreateSuccess": "已成功創建播放列表",
|
||||
"ToastPlaylistRemoveSuccess": "播放列表已刪除",
|
||||
@@ -745,6 +796,7 @@
|
||||
"ToastRSSFeedCloseSuccess": "RSS 源已關閉",
|
||||
"ToastRemoveItemFromCollectionFailed": "從收藏中刪除項目失敗",
|
||||
"ToastRemoveItemFromCollectionSuccess": "項目已從收藏中刪除",
|
||||
"ToastRenameFailed": "重新命名失敗",
|
||||
"ToastSendEbookToDeviceFailed": "發送電子書到設備失敗",
|
||||
"ToastSendEbookToDeviceSuccess": "電子書已經發送到設備 \"{0}\"",
|
||||
"ToastSeriesUpdateFailed": "更新系列失敗",
|
||||
@@ -754,6 +806,8 @@
|
||||
"ToastSocketConnected": "網路已連接",
|
||||
"ToastSocketDisconnected": "網路已斷開",
|
||||
"ToastSocketFailedToConnect": "網路連接失敗",
|
||||
"ToastUnlinkOpenIdSuccess": "已解除使用者與 OpenID 的連結",
|
||||
"ToastUserDeleteFailed": "刪除使用者失敗",
|
||||
"ToastUserDeleteSuccess": "使用者已刪除"
|
||||
"ToastUserDeleteSuccess": "使用者已刪除",
|
||||
"ToastUserPasswordMismatch": "密碼不符"
|
||||
}
|
||||
@@ -31,6 +31,11 @@ if (isDev || options['prod-with-dev-env']) {
|
||||
if (devEnv.AllowIframe) process.env.ALLOW_IFRAME = '1'
|
||||
if (devEnv.BackupPath) process.env.BACKUP_PATH = devEnv.BackupPath
|
||||
if (devEnv.ReactClientPath) process.env.REACT_CLIENT_PATH = devEnv.ReactClientPath
|
||||
if (devEnv.AllowedDevOrigins) {
|
||||
process.env.ALLOWED_DEV_ORIGINS = Array.isArray(devEnv.AllowedDevOrigins)
|
||||
? devEnv.AllowedDevOrigins.join(',')
|
||||
: String(devEnv.AllowedDevOrigins)
|
||||
}
|
||||
process.env.SOURCE = 'local'
|
||||
process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath ?? '/audiobookshelf'
|
||||
}
|
||||
|
||||
Generated
+17
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.34.0",
|
||||
"version": "2.36.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.34.0",
|
||||
"version": "2.36.1",
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"axios": "^0.27.2",
|
||||
@@ -2113,6 +2113,21 @@
|
||||
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.34.0",
|
||||
"version": "2.36.1",
|
||||
"buildNumber": 1,
|
||||
"description": "Self-hosted audiobook and podcast server",
|
||||
"main": "index.js",
|
||||
@@ -65,5 +65,8 @@
|
||||
"nodemon": "^2.0.20",
|
||||
"nyc": "^15.1.0",
|
||||
"sinon": "^17.0.1"
|
||||
},
|
||||
"allowScripts": {
|
||||
"sqlite3@5.1.7": true
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,14 @@
|
||||
<br />
|
||||
<a href="https://audiobookshelf.org/docs">Documentation</a>
|
||||
·
|
||||
<a href="https://audiobookshelf.org/guides">User Guides</a>
|
||||
·
|
||||
<a href="https://audiobookshelf.org/support">Support</a>
|
||||
·
|
||||
<a href="https://audiobooks.dev/">Demo</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
## ⚠️ Frontend pull requests are not being reviewed or merged for the existing Vue frontend. The frontend is currently being rewritten and migrated to React and should be available soon.
|
||||
|
||||
# About
|
||||
|
||||
Audiobookshelf is a self-hosted audiobook and podcast server.
|
||||
@@ -57,27 +59,23 @@ Try it out on the [Google Play Store](https://play.google.com/store/apps/details
|
||||
|
||||
Using Test Flight: https://testflight.apple.com/join/wiic7QIW **_(beta is full)_**
|
||||
|
||||
### Build your own tools & clients
|
||||
|
||||
Check out the [API documentation](https://api.audiobookshelf.org/)
|
||||
|
||||
<br />
|
||||
|
||||
<img alt="Library Screenshot" src="https://github.com/advplyr/audiobookshelf/raw/master/images/DemoLibrary.png" />
|
||||
|
||||
<br />
|
||||
|
||||
# Organizing your audiobooks
|
||||
# Organizing your media
|
||||
|
||||
#### Directory structure and folder names are important to Audiobookshelf!
|
||||
|
||||
See [documentation](https://audiobookshelf.org/docs#book-directory-structure) for supported directory structure, folder naming conventions, and audio file metadata usage.
|
||||
See [library docs](https://audiobookshelf.org/docs/category/libraries) for supported directory structures, folder naming conventions, and audio file metadata usage.
|
||||
|
||||
<br />
|
||||
|
||||
# Installation
|
||||
|
||||
See [install docs](https://www.audiobookshelf.org/docs)
|
||||
See [install docs](https://audiobookshelf.org/docs/category/installation)
|
||||
|
||||
<br />
|
||||
|
||||
@@ -87,276 +85,24 @@ See [install docs](https://www.audiobookshelf.org/docs)
|
||||
|
||||
#### Note: Using a subfolder is supported with no additional changes but the path must be `/audiobookshelf` (this is not changeable). See [discussion](https://github.com/advplyr/audiobookshelf/discussions/3535)
|
||||
|
||||
### NGINX Proxy Manager
|
||||
See [reverse proxy docs](https://audiobookshelf.org/docs/category/reverse-proxy)
|
||||
|
||||
Toggle websockets support.
|
||||
|
||||
<img alt="NGINX Web socket" src="https://user-images.githubusercontent.com/67830747/153679106-b2a7f5b9-0702-48c6-9740-b26b401986e9.png" />
|
||||
|
||||
### NGINX Reverse Proxy
|
||||
|
||||
Add this to the site config file on your nginx server after you have changed the relevant parts in the <> brackets, and inserted your certificate paths.
|
||||
|
||||
```bash
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name <sub>.<domain>.<tld>;
|
||||
|
||||
access_log /var/log/nginx/audiobookshelf.access.log;
|
||||
error_log /var/log/nginx/audiobookshelf.error.log;
|
||||
|
||||
ssl_certificate /path/to/certificate;
|
||||
ssl_certificate_key /path/to/key;
|
||||
|
||||
location / {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_pass http://<URL_to_forward_to>;
|
||||
proxy_redirect http:// https://;
|
||||
|
||||
# Prevent 413 Request Entity Too Large error
|
||||
# by increasing the maximum allowed size of the client request body
|
||||
# For example, set it to 10 GiB
|
||||
client_max_body_size 10240M;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Apache Reverse Proxy
|
||||
|
||||
Add this to the site config file on your Apache server after you have changed the relevant parts in the <> brackets, and inserted your certificate paths.
|
||||
|
||||
For this to work you must enable at least the following mods using `a2enmod`:
|
||||
|
||||
- `ssl`
|
||||
- `proxy`
|
||||
- `proxy_http`
|
||||
- `proxy_balancer`
|
||||
- `proxy_wstunnel`
|
||||
- `rewrite`
|
||||
|
||||
```bash
|
||||
<IfModule mod_ssl.c>
|
||||
<VirtualHost *:443>
|
||||
ServerName <sub>.<domain>.<tld>
|
||||
|
||||
ErrorLog ${APACHE_LOG_DIR}/error.log
|
||||
CustomLog ${APACHE_LOG_DIR}/access.log combined
|
||||
|
||||
ProxyPreserveHost On
|
||||
ProxyPass / http://localhost:<audiobookshelf_port>/
|
||||
RewriteEngine on
|
||||
RewriteCond %{HTTP:Upgrade} websocket [NC]
|
||||
RewriteCond %{HTTP:Connection} upgrade [NC]
|
||||
RewriteRule ^/?(.*) "ws://localhost:<audiobookshelf_port>/$1" [P,L]
|
||||
|
||||
# unless you're doing something special this should be generated by a
|
||||
# tool like certbot by let's encrypt
|
||||
SSLCertificateFile /path/to/cert/file
|
||||
SSLCertificateKeyFile /path/to/key/file
|
||||
</VirtualHost>
|
||||
</IfModule>
|
||||
```
|
||||
|
||||
If using Apache >= 2.4.47 you can use the following, without having to use any of the `RewriteEngine`, `RewriteCond`, or `RewriteRule` directives. For example:
|
||||
|
||||
```xml
|
||||
<Location /audiobookshelf>
|
||||
ProxyPreserveHost on
|
||||
ProxyPass http://localhost:<audiobookshelf_port>/audiobookshelf upgrade=websocket
|
||||
ProxyPassReverse http://localhost:<audiobookshelf_port>/audiobookshelf
|
||||
</Location>
|
||||
```
|
||||
|
||||
Some SSL certificates like those signed by Let's Encrypt require ACME validation. To allow Let's Encrypt to write and confirm the ACME challenge, edit your VirtualHost definition to prevent proxying traffic that queries `/.well-known` and instead serve that directly:
|
||||
|
||||
```bash
|
||||
<VirtualHost *:443>
|
||||
# ...
|
||||
|
||||
# create the directory structure /.well-known/acme-challenges
|
||||
# within DocumentRoot and give the HTTP user recursive write
|
||||
# access to it.
|
||||
DocumentRoot /path/to/local/directory
|
||||
|
||||
ProxyPreserveHost On
|
||||
ProxyPass /.well-known !
|
||||
ProxyPass / http://localhost:<audiobookshelf_port>/
|
||||
|
||||
# ...
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
### SWAG Reverse Proxy
|
||||
|
||||
[See LinuxServer.io config sample](https://github.com/linuxserver/reverse-proxy-confs/blob/master/audiobookshelf.subdomain.conf.sample)
|
||||
|
||||
### Synology NAS Reverse Proxy Setup (DSM 7+/Quickconnect)
|
||||
|
||||
1. **Open Control Panel**
|
||||
|
||||
- Navigate to `Login Portal > Advanced`.
|
||||
|
||||
2. **General Tab**
|
||||
|
||||
- Click `Reverse Proxy` > `Create`.
|
||||
|
||||
| Setting | Value |
|
||||
| ------------------ | -------------- |
|
||||
| Reverse Proxy Name | audiobookshelf |
|
||||
|
||||
3. **Source Configuration**
|
||||
|
||||
| Setting | Value |
|
||||
| ---------------------- | ---------------------------------------- |
|
||||
| Protocol | HTTPS |
|
||||
| Hostname | `<sub>.<quickconnectdomain>.synology.me` |
|
||||
| Port | 443 |
|
||||
| Access Control Profile | Leave as is |
|
||||
|
||||
- Example Hostname: `audiobookshelf.mydomain.synology.me`
|
||||
|
||||
4. **Destination Configuration**
|
||||
|
||||
| Setting | Value |
|
||||
| -------- | ----------- |
|
||||
| Protocol | HTTP |
|
||||
| Hostname | Your NAS IP |
|
||||
| Port | 13378 |
|
||||
|
||||
5. **Custom Header Tab**
|
||||
|
||||
- Go to `Create > Websocket`.
|
||||
- Configure Headers (leave as is):
|
||||
|
||||
| Header Name | Value |
|
||||
| ----------- | --------------------- |
|
||||
| Upgrade | `$http_upgrade` |
|
||||
| Connection | `$connection_upgrade` |
|
||||
|
||||
6. **Advanced Settings Tab**
|
||||
- Leave as is.
|
||||
|
||||
### [Traefik Reverse Proxy](https://doc.traefik.io/traefik/)
|
||||
|
||||
Middleware relating to CORS will cause the app to report Unknown Error when logging in. To prevent this don't apply any of the following headers to the router for this site:
|
||||
|
||||
<ul>
|
||||
<li>accessControlAllowMethods</li>
|
||||
<li>accessControlAllowOriginList</li>
|
||||
<li>accessControlMaxAge</li>
|
||||
</ul>
|
||||
|
||||
From [@Dondochaka](https://discord.com/channels/942908292873723984/942914154254176257/945074590374318170) and [@BeastleeUK](https://discord.com/channels/942908292873723984/942914154254176257/970366039294611506) <br />
|
||||
|
||||
### Example Caddyfile - [Caddy Reverse Proxy](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy)
|
||||
|
||||
```
|
||||
subdomain.domain.com {
|
||||
encode gzip zstd
|
||||
reverse_proxy <LOCAL_IP>:<PORT>
|
||||
}
|
||||
```
|
||||
|
||||
### HAProxy
|
||||
|
||||
Below is a generic HAProxy config, using `audiobookshelf.YOUR_DOMAIN.COM`.
|
||||
|
||||
To use `http2`, `ssl` is needed.
|
||||
|
||||
```make
|
||||
global
|
||||
# ... (your global settings go here)
|
||||
|
||||
defaults
|
||||
mode http
|
||||
# ... (your default settings go here)
|
||||
|
||||
frontend my_frontend
|
||||
# Bind to port 443, enable SSL, and specify the certificate list file
|
||||
bind :443 name :443 ssl crt-list /path/to/cert.crt_list alpn h2,http/1.1
|
||||
mode http
|
||||
|
||||
# Define an ACL for subdomains starting with "audiobookshelf"
|
||||
acl is_audiobookshelf hdr_beg(host) -i audiobookshelf
|
||||
|
||||
# Use the ACL to route traffic to audiobookshelf_backend if the condition is met,
|
||||
# otherwise, use the default_backend
|
||||
use_backend audiobookshelf_backend if is_audiobookshelf
|
||||
default_backend default_backend
|
||||
|
||||
backend audiobookshelf_backend
|
||||
mode http
|
||||
# ... (backend settings for audiobookshelf go here)
|
||||
|
||||
# Define the server for the audiobookshelf backend
|
||||
server audiobookshelf_server 127.0.0.99:13378
|
||||
|
||||
backend default_backend
|
||||
mode http
|
||||
# ... (default backend settings go here)
|
||||
|
||||
# Define the server for the default backend
|
||||
server default_server 127.0.0.123:8081
|
||||
|
||||
```
|
||||
|
||||
### pfSense and HAProxy
|
||||
|
||||
For pfSense the inputs are graphical, and `Health checking` is enabled.
|
||||
|
||||
#### Frontend, Default backend, access control lists and actions
|
||||
|
||||
##### Access Control lists
|
||||
|
||||
| Name | Expression | CS | Not | Value |
|
||||
| :------------: | :---------------: | :-: | :-: | :-------------: |
|
||||
| audiobookshelf | Host starts with: | | | audiobookshelf. |
|
||||
|
||||
##### Actions
|
||||
|
||||
The `condition acl names` needs to match the name above `audiobookshelf`.
|
||||
|
||||
| Action | Parameters | Condition acl names |
|
||||
| :-----------: | :------------: | :-----------------: |
|
||||
| `Use Backend` | audiobookshelf | audiobookshelf |
|
||||
|
||||
#### Backend
|
||||
|
||||
The `Name` needs to match the `Parameters` above `audiobookshelf`.
|
||||
|
||||
| Name | audiobookshelf |
|
||||
| ---- | -------------- |
|
||||
|
||||
##### Server list:
|
||||
|
||||
| Name | Expression | CS | Not | Value |
|
||||
| :------------: | :---------------: | :-: | :-: | :-------------: |
|
||||
| audiobookshelf | Host starts with: | | | audiobookshelf. |
|
||||
|
||||
##### Health checking:
|
||||
|
||||
Health checking is enabled by default. `Http check method` of `OPTIONS` is not supported on Audiobookshelf. If Health check fails, data will not be forwared. Need to do one of following:
|
||||
|
||||
- To disable: Change `Health check method` to `none`.
|
||||
- To make Health checking function: Change `Http check method` to `HEAD` or `GET`.
|
||||
|
||||
# Run from source
|
||||
<br />
|
||||
|
||||
# Contributing
|
||||
|
||||
This application is built using [NodeJs](https://nodejs.org/).
|
||||
See [contributing docs](https://audiobookshelf.org/docs/contributing/general/)
|
||||
|
||||
### Localization
|
||||
|
||||
Thank you to [Weblate](https://hosted.weblate.org/engage/audiobookshelf/) for hosting our localization infrastructure pro-bono. If you want to see Audiobookshelf in your language, please help us localize. Additional information on helping with the translations [here](https://www.audiobookshelf.org/faq#how-do-i-help-with-translations). <a href="https://hosted.weblate.org/engage/audiobookshelf/"> <img src="https://hosted.weblate.org/widget/audiobookshelf/abs-web-client/multi-auto.svg" alt="Translation status" /> </a>
|
||||
|
||||
<br />
|
||||
|
||||
# Run from source
|
||||
|
||||
This application is built using [NodeJs](https://nodejs.org/).
|
||||
|
||||
### Dev Container Setup
|
||||
|
||||
The easiest way to begin developing this project is to use a dev container. An introduction to dev containers in VSCode can be found [here](https://code.visualstudio.com/docs/devcontainers/containers).
|
||||
@@ -446,7 +192,3 @@ If you are using VSCode, this project includes a couple of pre-defined targets t
|
||||
- `Debug server`—Run the server.
|
||||
- `Debug client (nuxt)`—Run the client with live reload.
|
||||
- `Debug server and client (nuxt)`—Runs both the preceding two debug targets.
|
||||
|
||||
# How to Support
|
||||
|
||||
[See the incomplete "How to Support" page](https://www.audiobookshelf.org/support)
|
||||
+9
-4
@@ -81,7 +81,7 @@ class Auth {
|
||||
* @param {import('./models/User')} user
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @returns {Promise<string>} accessToken only if user is current user and refresh token is valid
|
||||
* @returns {Promise<{ accessToken:string, refreshToken:string }|null>} new tokens for the current session if kept alive
|
||||
*/
|
||||
async invalidateJwtSessionsForUser(user, req, res) {
|
||||
return this.tokenManager.invalidateJwtSessionsForUser(user, req, res)
|
||||
@@ -471,18 +471,23 @@ class Auth {
|
||||
res.json(openIdIssuerConfig)
|
||||
})
|
||||
|
||||
// Logout route
|
||||
/**
|
||||
* Logout route
|
||||
* Use ?allDevices=1 to destroy every session for this user instead of just the current one
|
||||
*/
|
||||
router.post('/logout', async (req, res) => {
|
||||
// Refresh token be alternatively be sent in the header
|
||||
const refreshToken = req.cookies.refresh_token || req.headers['x-refresh-token']
|
||||
const allDevices = req.query.allDevices === '1'
|
||||
|
||||
// Clear refresh token cookie
|
||||
res.clearCookie('refresh_token', {
|
||||
path: '/'
|
||||
})
|
||||
|
||||
// Invalidate the session in database using refresh token
|
||||
if (refreshToken) {
|
||||
if (allDevices) {
|
||||
await this.tokenManager.invalidateAllSessionsForRefreshToken(refreshToken)
|
||||
} else if (refreshToken) {
|
||||
await this.tokenManager.invalidateRefreshToken(refreshToken)
|
||||
} else {
|
||||
Logger.info(`[Auth] logout: No refresh token on request`)
|
||||
|
||||
+8
-10
@@ -514,19 +514,17 @@ class Database {
|
||||
})
|
||||
}
|
||||
|
||||
replaceNarratorInFilterData(oldNarrator, newNarrator) {
|
||||
for (const libraryId in this.libraryFilterData) {
|
||||
const indexOf = this.libraryFilterData[libraryId].narrators.findIndex((n) => n === oldNarrator)
|
||||
if (indexOf >= 0) {
|
||||
this.libraryFilterData[libraryId].narrators.splice(indexOf, 1, newNarrator)
|
||||
}
|
||||
replaceNarratorInFilterData(libraryId, oldNarrator, newNarrator) {
|
||||
if (!this.libraryFilterData[libraryId]) return
|
||||
const indexOf = this.libraryFilterData[libraryId].narrators.findIndex((n) => n === oldNarrator)
|
||||
if (indexOf >= 0) {
|
||||
this.libraryFilterData[libraryId].narrators.splice(indexOf, 1, newNarrator)
|
||||
}
|
||||
}
|
||||
|
||||
removeNarratorFromFilterData(narrator) {
|
||||
for (const libraryId in this.libraryFilterData) {
|
||||
this.libraryFilterData[libraryId].narrators = this.libraryFilterData[libraryId].narrators.filter((n) => n !== narrator)
|
||||
}
|
||||
removeNarratorFromFilterData(libraryId, narrator) {
|
||||
if (!this.libraryFilterData[libraryId]) return
|
||||
this.libraryFilterData[libraryId].narrators = this.libraryFilterData[libraryId].narrators.filter((n) => n !== narrator)
|
||||
}
|
||||
|
||||
addNarratorsToFilterData(libraryId, narrators) {
|
||||
|
||||
+42
-4
@@ -14,6 +14,7 @@ const { version } = require('../package.json')
|
||||
const is = require('./libs/requestIp/isJs')
|
||||
const fileUtils = require('./utils/fileUtils')
|
||||
const { toNumber } = require('./utils/index')
|
||||
const { getRequestOrigin } = require('./utils/requestUtils')
|
||||
const Logger = require('./Logger')
|
||||
|
||||
const Auth = require('./Auth')
|
||||
@@ -45,6 +46,18 @@ const passport = require('passport')
|
||||
const expressSession = require('express-session')
|
||||
const MemoryStore = require('./libs/memorystore')
|
||||
|
||||
/**
|
||||
* `next()` for a custom server is a NextCustomServer wrapper. After prepare(),
|
||||
* `.server` is another NextServer wrapper; the Node server with `nextConfig` is
|
||||
* one more `.server` down. Older react clients have no basePath and must keep
|
||||
* Express-stripped URLs.
|
||||
*/
|
||||
function getPreparedNextBasePath(nextApp) {
|
||||
const nodeServer = nextApp.server?.server
|
||||
const basePath = nodeServer?.nextConfig?.basePath
|
||||
return typeof basePath === 'string' ? basePath : ''
|
||||
}
|
||||
|
||||
class Server {
|
||||
constructor(SOURCE, PORT, HOST, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH) {
|
||||
this.Port = PORT
|
||||
@@ -157,6 +170,12 @@ class Server {
|
||||
}
|
||||
|
||||
await Database.init(false)
|
||||
|
||||
if (typeof global.RouterBasePath !== 'string') {
|
||||
throw new Error('[Server] RouterBasePath must be a string before serving requests')
|
||||
}
|
||||
Logger.info(`[Server] Serving from base path "${global.RouterBasePath || '/'}"`)
|
||||
|
||||
// Create or set JWT secret in token manager
|
||||
await this.auth.tokenManager.initTokenSecret()
|
||||
|
||||
@@ -288,10 +307,9 @@ class Server {
|
||||
// if RouterBasePath is set, modify all requests to include the base path
|
||||
app.use((req, res, next) => {
|
||||
const urlStartsWithRouterBasePath = req.url.startsWith(global.RouterBasePath)
|
||||
const host = req.get('host')
|
||||
const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
|
||||
const { origin } = getRequestOrigin(req)
|
||||
const prefix = urlStartsWithRouterBasePath ? global.RouterBasePath : ''
|
||||
req.originalHostPrefix = `${protocol}://${host}${prefix}`
|
||||
req.originalHostPrefix = `${origin}${prefix}`
|
||||
if (!urlStartsWithRouterBasePath) {
|
||||
req.url = `${global.RouterBasePath}${req.url}`
|
||||
}
|
||||
@@ -302,7 +320,9 @@ class Server {
|
||||
|
||||
this.server = http.createServer(app)
|
||||
|
||||
// Skip file upload parsing for internal-api routes (Next.js proxies read multipart bodies).
|
||||
router.use(
|
||||
/^(?!\/internal-api).*/,
|
||||
fileUpload({
|
||||
defCharset: 'utf8',
|
||||
defParamCharset: 'utf8',
|
||||
@@ -407,7 +427,25 @@ class Server {
|
||||
const nextApp = next({ dev: Logger.isDev, dir: ReactClientPath })
|
||||
const handle = nextApp.getRequestHandler()
|
||||
await nextApp.prepare()
|
||||
router.all('*', (req, res) => handle(req, res))
|
||||
const nextBasePath = getPreparedNextBasePath(nextApp)
|
||||
if (nextBasePath) {
|
||||
Logger.info(`[Server] Next client basePath is "${nextBasePath}"`)
|
||||
} else {
|
||||
Logger.info(`[Server] Next client has no basePath; leaving Express-stripped URLs as-is`)
|
||||
}
|
||||
router.all('*', (req, res) => {
|
||||
// Next is configured with the same base path as this router, but Express strips the mount
|
||||
// prefix from req.url, so put it back. The bare mount path is passed through without a
|
||||
// trailing slash, which Next would otherwise redirect away from on every request.
|
||||
// Skip when Next has no basePath (older client on master).
|
||||
if (nextBasePath && req.baseUrl) {
|
||||
const queryIndex = req.url.indexOf('?')
|
||||
const pathname = queryIndex === -1 ? req.url : req.url.slice(0, queryIndex)
|
||||
const search = queryIndex === -1 ? '' : req.url.slice(queryIndex)
|
||||
req.url = `${req.baseUrl}${pathname === '/' ? '' : pathname}${search}`
|
||||
}
|
||||
handle(req, res)
|
||||
})
|
||||
}
|
||||
|
||||
const unixSocketPrefix = 'unix/'
|
||||
|
||||
@@ -3,6 +3,7 @@ const Logger = require('./Logger')
|
||||
const Database = require('./Database')
|
||||
const TokenManager = require('./auth/TokenManager')
|
||||
const CoverSearchManager = require('./managers/CoverSearchManager')
|
||||
const { LogLevel } = require('./utils/constants')
|
||||
|
||||
/**
|
||||
* @typedef SocketClient
|
||||
@@ -85,6 +86,14 @@ class SocketAuthority {
|
||||
}
|
||||
}
|
||||
|
||||
requireAdminSocket(socket, eventName) {
|
||||
const client = this.clients[socket.id]
|
||||
if (client?.user?.isAdminOrUp) return true
|
||||
|
||||
Logger.warn(`[SocketAuthority] Unauthorized ${eventName} socket event from socket ${socket.id}`)
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits event with library item to all clients that can access the library item
|
||||
* Note: Emits toOldJSONExpanded()
|
||||
@@ -179,14 +188,25 @@ class SocketAuthority {
|
||||
socket.on('auth', (token) => this.authenticateSocket(socket, token))
|
||||
|
||||
// Scanning
|
||||
socket.on('cancel_scan', (libraryId) => this.cancelScan(libraryId))
|
||||
socket.on('cancel_scan', (libraryId) => {
|
||||
if (!this.requireAdminSocket(socket, 'cancel_scan')) return
|
||||
this.cancelScan(libraryId)
|
||||
})
|
||||
|
||||
// Cover search streaming
|
||||
socket.on('search_covers', (payload) => this.handleCoverSearch(socket, payload))
|
||||
socket.on('cancel_cover_search', (requestId) => this.handleCancelCoverSearch(socket, requestId))
|
||||
|
||||
// Logs
|
||||
socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level))
|
||||
socket.on('set_log_listener', (level) => {
|
||||
if (!this.requireAdminSocket(socket, 'set_log_listener')) return
|
||||
|
||||
if (!Number.isInteger(level) || !Object.values(LogLevel).includes(level)) {
|
||||
Logger.warn(`[SocketAuthority] Invalid set_log_listener level from socket ${socket.id}`)
|
||||
return
|
||||
}
|
||||
Logger.addSocketListener(socket, level)
|
||||
})
|
||||
socket.on('remove_log_listener', () => Logger.removeSocketListener(socket.id))
|
||||
|
||||
// Sent automatically from socket.io clients
|
||||
|
||||
@@ -4,6 +4,7 @@ const OpenIDClient = require('openid-client')
|
||||
const axios = require('axios')
|
||||
const Database = require('../Database')
|
||||
const Logger = require('../Logger')
|
||||
const { getRequestOrigin } = require('../utils/requestUtils')
|
||||
|
||||
/**
|
||||
* OpenID Connect authentication strategy
|
||||
@@ -289,8 +290,8 @@ class OidcAuthStrategy {
|
||||
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 { origin } = getRequestOrigin(req)
|
||||
const hostUrl = new URL(origin)
|
||||
const isMobileFlow = req.query.response_type === 'code' || req.query.redirect_uri || req.query.code_challenge
|
||||
|
||||
// Only allow code flow (for mobile clients)
|
||||
@@ -394,11 +395,10 @@ class OidcAuthStrategy {
|
||||
let postLogoutRedirectUri = null
|
||||
|
||||
if (authMethod === 'openid') {
|
||||
const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
|
||||
const host = req.get('host')
|
||||
const { origin } = getRequestOrigin(req)
|
||||
// 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`
|
||||
postLogoutRedirectUri = `${origin}${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
|
||||
@@ -515,42 +515,33 @@ class OidcAuthStrategy {
|
||||
if (!callbackUrl) return false
|
||||
|
||||
try {
|
||||
// Handle relative URLs - these are always safe if they start with router base path
|
||||
if (callbackUrl.startsWith('/')) {
|
||||
// Only allow relative paths that start with the router base path
|
||||
if (callbackUrl.startsWith(global.RouterBasePath + '/')) {
|
||||
return true
|
||||
}
|
||||
// Reject protocol-relative (//host) and backslash-prefixed (/\host) values,
|
||||
// which browsers resolve to a cross-origin absolute URL.
|
||||
if (callbackUrl.startsWith('//') || callbackUrl.startsWith('/\\')) {
|
||||
Logger.warn(`[OidcAuth] Rejected protocol-relative callback URL: ${callbackUrl}`)
|
||||
return false
|
||||
}
|
||||
|
||||
const { origin: serverOrigin } = getRequestOrigin(req)
|
||||
const resolvedUrl = callbackUrl.startsWith('/') ? new URL(callbackUrl, serverOrigin) : new URL(callbackUrl)
|
||||
|
||||
if (resolvedUrl.origin !== serverOrigin) {
|
||||
Logger.warn(`[OidcAuth] Rejected callback URL to different origin: ${callbackUrl} (expected ${serverOrigin})`)
|
||||
return false
|
||||
}
|
||||
|
||||
const pathname = decodeURIComponent(resolvedUrl.pathname)
|
||||
if (pathname.startsWith('//') || pathname.startsWith('/\\')) {
|
||||
Logger.warn(`[OidcAuth] Rejected protocol-relative callback URL path: ${callbackUrl}`)
|
||||
return false
|
||||
}
|
||||
|
||||
if (!resolvedUrl.pathname.startsWith(global.RouterBasePath + '/')) {
|
||||
Logger.warn(`[OidcAuth] Rejected callback URL outside router base path: ${callbackUrl}`)
|
||||
return false
|
||||
}
|
||||
|
||||
// For absolute URLs, ensure they point to the same origin
|
||||
const callbackUrlObj = new URL(callbackUrl)
|
||||
// NPM appends both http and https in x-forwarded-proto sometimes, so we need to check for both
|
||||
const xfp = (req.get('x-forwarded-proto') || '').toLowerCase()
|
||||
const currentProtocol =
|
||||
req.secure ||
|
||||
xfp
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.includes('https')
|
||||
? 'https'
|
||||
: 'http'
|
||||
const currentHost = req.get('host')
|
||||
|
||||
// Check if protocol and host match exactly
|
||||
if (callbackUrlObj.protocol === currentProtocol + ':' && callbackUrlObj.host === currentHost) {
|
||||
// Additional check: ensure path starts with router base path
|
||||
if (callbackUrlObj.pathname.startsWith(global.RouterBasePath + '/')) {
|
||||
return true
|
||||
}
|
||||
Logger.warn(`[OidcAuth] Rejected same-origin callback URL outside router base path: ${callbackUrl}`)
|
||||
return false
|
||||
}
|
||||
|
||||
Logger.warn(`[OidcAuth] Rejected callback URL to different origin: ${callbackUrl} (expected ${currentProtocol}://${currentHost})`)
|
||||
return false
|
||||
return true
|
||||
} catch (error) {
|
||||
Logger.error(`[OidcAuth] Invalid callback URL format: ${callbackUrl}`, error)
|
||||
return false
|
||||
|
||||
+153
-28
@@ -1,10 +1,12 @@
|
||||
const { Op } = require('sequelize')
|
||||
const uuid = require('uuid')
|
||||
|
||||
const Database = require('../Database')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
const requestIp = require('../libs/requestIp')
|
||||
const jwt = require('../libs/jsonwebtoken')
|
||||
const { isRequestSecure } = require('../utils/requestUtils')
|
||||
|
||||
class TokenManager {
|
||||
/** @type {string} JWT secret key */
|
||||
@@ -15,6 +17,8 @@ class TokenManager {
|
||||
this.RefreshTokenExpiry = parseInt(process.env.REFRESH_TOKEN_EXPIRY) || 30 * 24 * 60 * 60 // 30 days
|
||||
/** @type {number} Access token expiry in seconds */
|
||||
this.AccessTokenExpiry = parseInt(process.env.ACCESS_TOKEN_EXPIRY) || 1 * 60 * 60 // 1 hour
|
||||
/** @type {number} Grace period in seconds during which a rotated (old) refresh token is still accepted */
|
||||
this.RefreshTokenGracePeriod = parseInt(process.env.REFRESH_TOKEN_GRACE_PERIOD) || 10 * 60 // 10 minutes
|
||||
|
||||
if (parseInt(process.env.REFRESH_TOKEN_EXPIRY) > 0) {
|
||||
Logger.info(`[TokenManager] Refresh token expiry set from ENV variable to ${this.RefreshTokenExpiry} seconds`)
|
||||
@@ -22,6 +26,9 @@ class TokenManager {
|
||||
if (parseInt(process.env.ACCESS_TOKEN_EXPIRY) > 0) {
|
||||
Logger.info(`[TokenManager] Access token expiry set from ENV variable to ${this.AccessTokenExpiry} seconds`)
|
||||
}
|
||||
if (parseInt(process.env.REFRESH_TOKEN_GRACE_PERIOD) > 0) {
|
||||
Logger.info(`[TokenManager] Refresh token grace period set from ENV variable to ${this.RefreshTokenGracePeriod} seconds`)
|
||||
}
|
||||
}
|
||||
|
||||
get TokenSecret() {
|
||||
@@ -58,13 +65,25 @@ class TokenManager {
|
||||
setRefreshTokenCookie(req, res, refreshToken) {
|
||||
res.cookie('refresh_token', refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: req.secure || req.get('x-forwarded-proto') === 'https',
|
||||
secure: isRequestSecure(req),
|
||||
sameSite: 'lax',
|
||||
maxAge: this.RefreshTokenExpiry * 1000,
|
||||
path: '/'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a decoded JWT payload may authenticate API/socket requests (not refresh-only credentials).
|
||||
*
|
||||
* @param {Object} decoded
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static isBearerAccessTokenPayload(decoded) {
|
||||
if (!decoded?.userId) return false
|
||||
if (decoded.type === 'refresh') return false
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to validate a jwt token for a given user
|
||||
* Used to authenticate socket connections
|
||||
@@ -75,7 +94,9 @@ class TokenManager {
|
||||
*/
|
||||
static validateAccessToken(token) {
|
||||
try {
|
||||
return jwt.verify(token, TokenManager.TokenSecret)
|
||||
const decoded = jwt.verify(token, TokenManager.TokenSecret)
|
||||
if (!TokenManager.isBearerAccessTokenPayload(decoded)) return null
|
||||
return decoded
|
||||
} catch (err) {
|
||||
return null
|
||||
}
|
||||
@@ -115,6 +136,7 @@ class TokenManager {
|
||||
const payload = {
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
jti: uuid.v4(),
|
||||
type: 'access'
|
||||
}
|
||||
const options = {
|
||||
@@ -138,6 +160,7 @@ class TokenManager {
|
||||
const payload = {
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
jti: uuid.v4(),
|
||||
type: 'refresh'
|
||||
}
|
||||
const options = {
|
||||
@@ -183,20 +206,60 @@ class TokenManager {
|
||||
* @param {import('../models/User')} user
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
* @param {boolean} gracePeriod - whether to use the grace period
|
||||
* @returns {Promise<{ accessToken:string, refreshToken:string }>}
|
||||
*/
|
||||
async rotateTokensForSession(session, user, req, res) {
|
||||
// Generate new tokens
|
||||
async rotateTokensForSession(session, user, req, res, gracePeriod = true) {
|
||||
const previousRefreshToken = session.refreshToken
|
||||
const newAccessToken = this.generateTempAccessToken(user)
|
||||
const newRefreshToken = this.generateRefreshToken(user)
|
||||
|
||||
// Calculate new expiration time
|
||||
let newRefreshToken = this.generateRefreshToken(user)
|
||||
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()
|
||||
let lastRefreshToken = null
|
||||
let lastRefreshTokenExpiresAt = null
|
||||
if (gracePeriod) {
|
||||
// Set grace period of old refresh token in case of race condition in token rotation.
|
||||
// During this window a retry with the old refresh token returns the already-rotated
|
||||
// current token instead of failing, so a client that never received the rotation
|
||||
// response (e.g. dropped/suspended mobile request) can still recover the session.
|
||||
// Configurable via REFRESH_TOKEN_GRACE_PERIOD; may need to be longer if fetching the
|
||||
// user data takes longer due to large progress objects.
|
||||
lastRefreshToken = previousRefreshToken
|
||||
lastRefreshTokenExpiresAt = new Date(Date.now() + this.RefreshTokenGracePeriod * 1000)
|
||||
}
|
||||
|
||||
// Only update if this session row still has the refresh token we read
|
||||
const [numUpdated] = await Database.sessionModel.update(
|
||||
{
|
||||
refreshToken: newRefreshToken,
|
||||
expiresAt: newExpiresAt,
|
||||
lastRefreshToken,
|
||||
lastRefreshTokenExpiresAt
|
||||
},
|
||||
{
|
||||
where: {
|
||||
id: session.id,
|
||||
refreshToken: previousRefreshToken
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (numUpdated === 0) {
|
||||
Logger.debug(`[TokenManager] Race condition in rotateTokensForSession for user ${user.id}, getting new token`)
|
||||
|
||||
const updatedSession = await Database.sessionModel.findOne({ where: { id: session.id } })
|
||||
|
||||
newRefreshToken = updatedSession.refreshToken
|
||||
session.refreshToken = updatedSession.refreshToken
|
||||
session.expiresAt = updatedSession.expiresAt
|
||||
session.lastRefreshToken = updatedSession.lastRefreshToken
|
||||
session.lastRefreshTokenExpiresAt = updatedSession.lastRefreshTokenExpiresAt
|
||||
} else {
|
||||
session.refreshToken = newRefreshToken
|
||||
session.expiresAt = newExpiresAt
|
||||
session.lastRefreshToken = lastRefreshToken
|
||||
session.lastRefreshTokenExpiresAt = lastRefreshTokenExpiresAt
|
||||
}
|
||||
|
||||
// Set new refresh token cookie
|
||||
this.setRefreshTokenCookie(req, res, newRefreshToken)
|
||||
@@ -243,7 +306,11 @@ class TokenManager {
|
||||
|
||||
done(null, user)
|
||||
} else {
|
||||
// JWT based authentication
|
||||
// JWT based authentication — refresh tokens are only valid at POST /auth/refresh
|
||||
if (!TokenManager.isBearerAccessTokenPayload(jwt_payload)) {
|
||||
done(null, null)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the jwt is expired
|
||||
if (jwt_payload.exp && jwt_payload.exp < Date.now() / 1000) {
|
||||
@@ -294,23 +361,40 @@ class TokenManager {
|
||||
}
|
||||
}
|
||||
|
||||
const session = await Database.sessionModel.findOne({
|
||||
where: { refreshToken: refreshToken }
|
||||
let session = await Database.sessionModel.findOne({
|
||||
where: {
|
||||
[Op.or]: [{ refreshToken: refreshToken }, { lastRefreshToken: refreshToken }]
|
||||
}
|
||||
})
|
||||
|
||||
if (!session) {
|
||||
Logger.error(`[TokenManager] Failed to refresh token. Session not found for refresh token: ${refreshToken}`)
|
||||
Logger.error(`[TokenManager] Failed to refresh token. Session not found`)
|
||||
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'
|
||||
let isGracePeriod = false
|
||||
if (session.refreshToken !== refreshToken) {
|
||||
// Token matched lastRefreshToken
|
||||
if (session.lastRefreshTokenExpiresAt && session.lastRefreshTokenExpiresAt > new Date()) {
|
||||
isGracePeriod = true
|
||||
Logger.debug(`[TokenManager] Grace period hit for user ${session.userId}`)
|
||||
} else {
|
||||
Logger.debug(`[TokenManager] Grace period expired for user ${session.userId}`)
|
||||
return {
|
||||
error: 'Invalid refresh token'
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Token matched current refreshToken
|
||||
// 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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,6 +406,20 @@ class TokenManager {
|
||||
}
|
||||
}
|
||||
|
||||
if (isGracePeriod) {
|
||||
// Return the already rotated refresh token store in the database,
|
||||
// and generate a new access token without changing the refresh token
|
||||
// again
|
||||
const accessToken = this.generateTempAccessToken(user)
|
||||
this.setRefreshTokenCookie(req, res, session.refreshToken)
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken: session.refreshToken,
|
||||
user
|
||||
}
|
||||
}
|
||||
|
||||
const newTokens = await this.rotateTokensForSession(session, user, req, res)
|
||||
return {
|
||||
accessToken: newTokens.accessToken,
|
||||
@@ -366,16 +464,21 @@ class TokenManager {
|
||||
* @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
|
||||
* @returns {Promise<{ accessToken:string, refreshToken:string }|null>} new tokens for the current session if kept alive
|
||||
*/
|
||||
async invalidateJwtSessionsForUser(user, req, res) {
|
||||
const currentRefreshToken = req.cookies.refresh_token
|
||||
const currentRefreshToken = req.cookies.refresh_token || req.headers['x-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 } })
|
||||
const currentSession = await Database.sessionModel.findOne({
|
||||
where: {
|
||||
userId: user.id,
|
||||
[Op.or]: [{ refreshToken: currentRefreshToken }, { lastRefreshToken: currentRefreshToken }]
|
||||
}
|
||||
})
|
||||
if (currentSession) {
|
||||
const newTokens = await this.rotateTokensForSession(currentSession, user, req, res)
|
||||
const newTokens = await this.rotateTokensForSession(currentSession, user, req, res, false)
|
||||
|
||||
// Invalidate all sessions for the user except the current one
|
||||
await Database.sessionModel.destroy({
|
||||
@@ -387,9 +490,12 @@ class TokenManager {
|
||||
}
|
||||
})
|
||||
|
||||
return newTokens.accessToken
|
||||
return {
|
||||
accessToken: newTokens.accessToken,
|
||||
refreshToken: newTokens.refreshToken
|
||||
}
|
||||
} else {
|
||||
Logger.error(`[TokenManager] No session found to rotate tokens for refresh token ${currentRefreshToken}`)
|
||||
Logger.error(`[TokenManager] No session found to rotate tokens`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,6 +505,25 @@ class TokenManager {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy all JWT sessions for the user that owns this refresh token
|
||||
*
|
||||
* @param {string} refreshToken
|
||||
*/
|
||||
async invalidateAllSessionsForRefreshToken(refreshToken) {
|
||||
if (!refreshToken) return
|
||||
|
||||
const session = await Database.sessionModel.findOne({
|
||||
where: {
|
||||
[Op.or]: [{ refreshToken: refreshToken }, { lastRefreshToken: refreshToken }]
|
||||
}
|
||||
})
|
||||
if (!session) return
|
||||
|
||||
const numDeleted = await Database.sessionModel.destroy({ where: { userId: session.userId } })
|
||||
Logger.info(`[TokenManager] Invalidated all JWT sessions for user ${session.userId}, ${numDeleted} deleted`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate a refresh token - used for logout
|
||||
*
|
||||
@@ -413,7 +538,7 @@ class TokenManager {
|
||||
|
||||
try {
|
||||
const numDeleted = await Database.sessionModel.destroy({ where: { refreshToken: refreshToken } })
|
||||
Logger.info(`[TokenManager] Refresh token ${refreshToken} invalidated, ${numDeleted} sessions deleted`)
|
||||
Logger.info(`[TokenManager] Refresh token invalidated, ${numDeleted} sessions deleted`)
|
||||
return true
|
||||
} catch (error) {
|
||||
Logger.error(`[TokenManager] Error invalidating refresh token: ${error.message}`)
|
||||
|
||||
@@ -149,7 +149,7 @@ class AuthorController {
|
||||
})
|
||||
if (libraryItems.length) {
|
||||
await Database.bookAuthorModel.removeByIds(req.author.id) // Remove all old BookAuthor
|
||||
await Database.bookAuthorModel.bulkCreate(bookAuthorsToCreate) // Create all new BookAuthor
|
||||
await Database.bookAuthorModel.bulkCreate(bookAuthorsToCreate, { ignoreDuplicates: true }) // Create all new unique BookAuthor
|
||||
for (const libraryItem of libraryItems) {
|
||||
await libraryItem.saveMetadataFile()
|
||||
}
|
||||
@@ -427,6 +427,7 @@ class AuthorController {
|
||||
async middleware(req, res, next) {
|
||||
const author = await Database.authorModel.findByPk(req.params.id)
|
||||
if (!author) return res.sendStatus(404)
|
||||
if (!req.user.checkCanAccessLibrary(author.libraryId)) return res.sendStatus(404)
|
||||
|
||||
if (req.method == 'DELETE' && !req.user.canDelete) {
|
||||
Logger.warn(`[AuthorController] User "${req.user.username}" attempted to delete without permission`)
|
||||
|
||||
@@ -1167,11 +1167,11 @@ class LibraryController {
|
||||
}
|
||||
|
||||
// Update filter data
|
||||
Database.replaceNarratorInFilterData(narratorName, updatedName)
|
||||
Database.replaceNarratorInFilterData(req.library.id, narratorName, updatedName)
|
||||
|
||||
const itemsUpdated = []
|
||||
|
||||
const itemsWithNarrator = await libraryItemFilters.getAllLibraryItemsWithNarrators([narratorName])
|
||||
const itemsWithNarrator = await libraryItemFilters.getAllLibraryItemsWithNarrators([narratorName], req.library.id)
|
||||
|
||||
for (const libraryItem of itemsWithNarrator) {
|
||||
libraryItem.media.narrators = libraryItem.media.narrators.filter((n) => n !== narratorName)
|
||||
@@ -1211,11 +1211,11 @@ class LibraryController {
|
||||
const narratorName = libraryFilters.decode(req.params.narratorId)
|
||||
|
||||
// Update filter data
|
||||
Database.removeNarratorFromFilterData(narratorName)
|
||||
Database.removeNarratorFromFilterData(req.library.id, narratorName)
|
||||
|
||||
const itemsUpdated = []
|
||||
|
||||
const itemsWithNarrator = await libraryItemFilters.getAllLibraryItemsWithNarrators([narratorName])
|
||||
const itemsWithNarrator = await libraryItemFilters.getAllLibraryItemsWithNarrators([narratorName], req.library.id)
|
||||
|
||||
for (const libraryItem of itemsWithNarrator) {
|
||||
libraryItem.media.narrators = libraryItem.media.narrators.filter((n) => n !== narratorName)
|
||||
@@ -1432,14 +1432,17 @@ class LibraryController {
|
||||
|
||||
const itemIds = req.query.ids.split(',')
|
||||
|
||||
const libraryItems = await Database.libraryItemModel.findAll({
|
||||
attributes: ['id', 'libraryId', 'path', 'isFile'],
|
||||
where: {
|
||||
id: itemIds,
|
||||
libraryId: req.library.id
|
||||
}
|
||||
const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({
|
||||
id: itemIds,
|
||||
libraryId: req.library.id
|
||||
})
|
||||
|
||||
for (const libraryItem of libraryItems) {
|
||||
if (!req.user.checkCanAccessLibraryItem(libraryItem)) {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
}
|
||||
|
||||
if (libraryItems.length < itemIds.length) {
|
||||
Logger.warn(`[LibraryController] User "${req.user.username}" requested ${itemIds.length} items but only ${libraryItems.length} are in library "${req.library.id}"`)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
const { Request, Response } = require('express')
|
||||
const { Op } = require('sequelize')
|
||||
const Logger = require('../Logger')
|
||||
const SocketAuthority = require('../SocketAuthority')
|
||||
const Database = require('../Database')
|
||||
const { sort } = require('../libs/fastSort')
|
||||
const { toNumber, isNullOrNaN } = require('../utils/index')
|
||||
const { toNumber, isNullOrNaN, isUUID } = require('../utils/index')
|
||||
const userStats = require('../utils/queries/userStats')
|
||||
const parseUserAgent = require('../utils/parsers/parseUserAgent')
|
||||
|
||||
/**
|
||||
* @typedef RequestUserObject
|
||||
@@ -26,6 +28,124 @@ class MeController {
|
||||
res.json(req.user.toOldJSONForBrowser())
|
||||
}
|
||||
|
||||
/**
|
||||
* GET: /api/me/sessions
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async getSessions(req, res) {
|
||||
const page = Math.max(0, toNumber(req.query.page, 0))
|
||||
const itemsPerPage = Math.max(1, toNumber(req.query.itemsPerPage, 10))
|
||||
|
||||
if (req.user.isGuest) {
|
||||
return res.json({ sessions: [], total: 0, numPages: 0, page, itemsPerPage })
|
||||
}
|
||||
|
||||
const refreshToken = req.cookies.refresh_token || req.headers['x-refresh-token']
|
||||
const { rows, count } = await Database.sessionModel.findAndCountAll({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
expiresAt: { [Op.gt]: new Date() }
|
||||
},
|
||||
order: [['updatedAt', 'DESC']],
|
||||
limit: itemsPerPage,
|
||||
offset: itemsPerPage * page
|
||||
})
|
||||
|
||||
res.json({
|
||||
total: count,
|
||||
numPages: Math.ceil(count / itemsPerPage),
|
||||
page,
|
||||
itemsPerPage,
|
||||
sessions: rows.map((session) => ({
|
||||
id: session.id,
|
||||
ipAddress: session.ipAddress,
|
||||
userAgent: session.userAgent,
|
||||
// For display convenience
|
||||
deviceInfo: parseUserAgent(session.userAgent),
|
||||
createdAt: session.createdAt?.valueOf() ?? null,
|
||||
updatedAt: session.updatedAt?.valueOf() ?? null,
|
||||
current: !!refreshToken && (session.refreshToken === refreshToken || session.lastRefreshToken === refreshToken)
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE: /api/me/sessions/:id
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async deleteSession(req, res) {
|
||||
if (req.user.isGuest) {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
if (!isUUID(req.params.id)) {
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
||||
const session = await Database.sessionModel.findOne({
|
||||
where: {
|
||||
id: req.params.id,
|
||||
userId: req.user.id
|
||||
}
|
||||
})
|
||||
|
||||
if (!session) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
await Database.sessionModel.destroy({ where: { id: session.id } })
|
||||
Logger.info(`[MeController] User ${req.user.username} deleted auth session ${session.id}`)
|
||||
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
/**
|
||||
* GET: /api/me/progress
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
getAllMediaProgress(req, res) {
|
||||
const mediaProgress = req.user.mediaProgresses?.map((mp) => mp.getOldMediaProgress()) || []
|
||||
res.json({ mediaProgress })
|
||||
}
|
||||
|
||||
/**
|
||||
* GET: /api/me/bookmarks
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
getAllBookmarks(req, res) {
|
||||
const bookmarks = req.user.bookmarks?.map((bookmark) => ({ ...bookmark })) || []
|
||||
res.json({ bookmarks })
|
||||
}
|
||||
|
||||
/**
|
||||
* GET: /api/me/bookmarks/:libraryItemId
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async getBookmarksForLibraryItem(req, res) {
|
||||
const libraryItem = await Database.libraryItemModel.getExpandedById(req.params.libraryItemId)
|
||||
if (!libraryItem) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
if (!req.user.checkCanAccessLibraryItem(libraryItem)) {
|
||||
Logger.error(`[MeController] User "${req.user.username}" attempted to access bookmarks for library item "${req.params.libraryItemId}" without access`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
const bookmarks = req.user.bookmarks?.filter((bookmark) => bookmark.libraryItemId === libraryItem.id).map((bookmark) => ({ ...bookmark })) || []
|
||||
res.json({ bookmarks })
|
||||
}
|
||||
|
||||
/**
|
||||
* GET: /api/me/listening-sessions
|
||||
*
|
||||
@@ -308,6 +428,8 @@ class MeController {
|
||||
* User change password. Requires current password.
|
||||
* Guest users cannot change password.
|
||||
*
|
||||
* Invalidates all other JWT sessions for the user. If using x-refresh-token, returns new tokens for the current session.
|
||||
*
|
||||
* @this import('../routers/ApiRouter')
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
@@ -330,6 +452,24 @@ class MeController {
|
||||
return res.status(400).send(result.error)
|
||||
}
|
||||
|
||||
const shouldReturnTokens = !!req.headers['x-refresh-token']
|
||||
const newTokens = await this.auth.invalidateJwtSessionsForUser(req.user, req, res)
|
||||
|
||||
if (newTokens?.accessToken) {
|
||||
Logger.info(`[MeController] Invalidated other JWT sessions for user ${req.user.username} after password change`)
|
||||
if (shouldReturnTokens) {
|
||||
return res.json({
|
||||
success: true,
|
||||
user: {
|
||||
accessToken: newTokens.accessToken,
|
||||
refreshToken: newTokens.refreshToken
|
||||
}
|
||||
})
|
||||
}
|
||||
} else {
|
||||
Logger.info(`[MeController] Invalidated all JWT sessions for user ${req.user.username} after password change`)
|
||||
}
|
||||
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,9 @@ const Watcher = require('../Watcher')
|
||||
const libraryItemFilters = require('../utils/queries/libraryItemFilters')
|
||||
const cron = require('../libs/nodeCron')
|
||||
const { isObject, getTitleIgnorePrefix } = require('../utils/index')
|
||||
const ServerSettings = require('../objects/settings/ServerSettings')
|
||||
const { sanitizeFilename } = require('../utils/fileUtils')
|
||||
const { sanitize } = require('../utils/htmlSanitizer')
|
||||
|
||||
const TaskManager = require('../managers/TaskManager')
|
||||
const adminStats = require('../utils/queries/adminStats')
|
||||
@@ -78,7 +80,12 @@ class MiscController {
|
||||
const cleanedOutputDirectoryParts = outputDirectoryParts.filter(Boolean).map((part) => sanitizeFilename(part))
|
||||
const outputDirectory = Path.join(...[folder.path, ...cleanedOutputDirectoryParts])
|
||||
|
||||
await fs.ensureDir(outputDirectory)
|
||||
try {
|
||||
await fs.ensureDir(outputDirectory)
|
||||
} catch (error) {
|
||||
Logger.error('[MiscController] Failed to create upload directory', outputDirectory, error)
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
|
||||
Logger.info(`Uploading ${files.length} files to`, outputDirectory)
|
||||
|
||||
@@ -142,16 +149,23 @@ class MiscController {
|
||||
Logger.warn('Cannot disable iframe when ALLOW_IFRAME is enabled in environment')
|
||||
return res.status(400).send('Cannot disable iframe when ALLOW_IFRAME is enabled in environment')
|
||||
}
|
||||
if (settingsUpdate.allowedOrigins && !Array.isArray(settingsUpdate.allowedOrigins)) {
|
||||
const filteredUpdate = {}
|
||||
for (const key in settingsUpdate) {
|
||||
if (ServerSettings.patchableSettingsKeys.has(key)) {
|
||||
filteredUpdate[key] = settingsUpdate[key]
|
||||
}
|
||||
}
|
||||
|
||||
if (filteredUpdate.allowedOrigins && !Array.isArray(filteredUpdate.allowedOrigins)) {
|
||||
return res.status(400).send('allowedOrigins must be an array')
|
||||
}
|
||||
|
||||
const madeUpdates = Database.serverSettings.update(settingsUpdate)
|
||||
const madeUpdates = Database.serverSettings.update(filteredUpdate)
|
||||
if (madeUpdates) {
|
||||
await Database.updateServerSettings()
|
||||
|
||||
// If backup schedule is updated - update backup manager
|
||||
if (settingsUpdate.backupSchedule !== undefined) {
|
||||
if (filteredUpdate.backupSchedule !== undefined) {
|
||||
this.backupManager.updateCronSchedule()
|
||||
}
|
||||
}
|
||||
@@ -698,6 +712,7 @@ class MiscController {
|
||||
}
|
||||
let updatedValue = settingsUpdate[key]
|
||||
if (updatedValue === '' && key != 'authOpenIDSubfolderForRedirectURLs') updatedValue = null
|
||||
if (key === 'authLoginCustomMessage' && updatedValue) updatedValue = sanitize(updatedValue) || null
|
||||
let currentValue = currentAuthenticationSettings[key]
|
||||
if (currentValue === '' && key != 'authOpenIDSubfolderForRedirectURLs') currentValue = null
|
||||
|
||||
|
||||
@@ -437,6 +437,17 @@ class PodcastController {
|
||||
}
|
||||
|
||||
updatePayload[key] = req.body[key]
|
||||
} else if (key === 'enclosure') {
|
||||
const enclosure = req.body.enclosure
|
||||
if (enclosure === null) {
|
||||
updatePayload.enclosureURL = null
|
||||
updatePayload.enclosureSize = null
|
||||
updatePayload.enclosureType = null
|
||||
} else if (typeof enclosure === 'object' && typeof enclosure.url === 'string') {
|
||||
updatePayload.enclosureURL = enclosure.url
|
||||
updatePayload.enclosureType = typeof enclosure.type === 'string' ? enclosure.type : null
|
||||
updatePayload.enclosureSize = enclosure.length !== undefined && enclosure.length !== null ? enclosure.length : null
|
||||
}
|
||||
} else if (key === 'chapters' && Array.isArray(req.body[key]) && req.body[key].every((ch) => typeof ch === 'object' && ch.title && ch.start)) {
|
||||
updatePayload[key] = req.body[key]
|
||||
} else if (key === 'publishedAt' && typeof req.body[key] === 'number') {
|
||||
|
||||
@@ -146,7 +146,7 @@ class ShareController {
|
||||
|
||||
const mediaItemShare = ShareManager.findBySlug(slug)
|
||||
if (!mediaItemShare) {
|
||||
return res.status(404)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
const playbackSession = ShareManager.findPlaybackSessionBySessionId(req.cookies.share_session_id)
|
||||
@@ -186,7 +186,7 @@ class ShareController {
|
||||
|
||||
const mediaItemShare = ShareManager.findBySlug(slug)
|
||||
if (!mediaItemShare) {
|
||||
return res.status(404)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
const playbackSession = ShareManager.findPlaybackSessionBySessionId(req.cookies.share_session_id)
|
||||
@@ -231,7 +231,7 @@ class ShareController {
|
||||
const { slug } = req.params
|
||||
const mediaItemShare = ShareManager.findBySlug(slug)
|
||||
if (!mediaItemShare) {
|
||||
return res.status(404)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
if (!mediaItemShare.isDownloadable) {
|
||||
return res.status(403).send('Download is not allowed for this item')
|
||||
@@ -295,7 +295,7 @@ class ShareController {
|
||||
|
||||
const mediaItemShare = ShareManager.findBySlug(slug)
|
||||
if (!mediaItemShare) {
|
||||
return res.status(404)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
const playbackSession = ShareManager.findPlaybackSessionBySessionId(req.cookies.share_session_id)
|
||||
|
||||
@@ -253,6 +253,7 @@ class UserController {
|
||||
// Updating password
|
||||
if (updatePayload.password) {
|
||||
user.pash = await this.auth.localAuthStrategy.hashPassword(updatePayload.password)
|
||||
shouldInvalidateJwtSessions = true
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
@@ -331,14 +332,11 @@ class UserController {
|
||||
Logger.info(`[UserController] User ${user.username} has generated a new api token`)
|
||||
}
|
||||
|
||||
// Handle JWT session invalidation for username changes
|
||||
// Handle JWT session invalidation for username/password 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
|
||||
const newTokens = await this.auth.invalidateJwtSessionsForUser(user, req, res)
|
||||
if (newTokens) {
|
||||
// Note: for admin users changing their own password they should use MeController.updatePassword instead. This endpoint does not return tokens
|
||||
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}`)
|
||||
@@ -363,15 +361,16 @@ class UserController {
|
||||
* @param {Response} res
|
||||
*/
|
||||
async delete(req, res) {
|
||||
if (req.params.id === 'root') {
|
||||
Logger.error('[UserController] Attempt to delete root user. Root user cannot be deleted')
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
const user = req.reqUser
|
||||
|
||||
if (req.user.id === req.params.id) {
|
||||
Logger.error(`[UserController] User ${req.user.username} is attempting to delete self`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
const user = req.reqUser
|
||||
if (user.isRoot) {
|
||||
Logger.error(`[UserController] Admin user "${req.user.username}" attempted to delete root user`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
// Todo: check if user is logged in and cancel streams
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ class CacheManager {
|
||||
const width = options.width || 400
|
||||
const height = options.height || null
|
||||
|
||||
if (!['webp', 'jpeg', 'png'].includes(format)) return res.sendStatus(400)
|
||||
res.type(`image/${format}`)
|
||||
|
||||
const cachePath = Path.join(this.CoverCachePath, `${libraryItemId}_${width}${height ? `x${height}` : ''}`) + '.' + format
|
||||
@@ -149,6 +150,7 @@ class CacheManager {
|
||||
const width = options.width || 400
|
||||
const height = options.height || null
|
||||
|
||||
if (!['webp', 'jpeg', 'png'].includes(format)) return res.sendStatus(400)
|
||||
res.type(`image/${format}`)
|
||||
|
||||
var cachePath = Path.join(this.ImageCachePath, `${authorId}_${width}${height ? `x${height}` : ''}`) + '.' + format
|
||||
|
||||
@@ -2,6 +2,7 @@ const { Request, Response } = require('express')
|
||||
const Path = require('path')
|
||||
|
||||
const Logger = require('../Logger')
|
||||
const { getAudioMimeTypeFromExtname } = require('../utils/fileUtils')
|
||||
const SocketAuthority = require('../SocketAuthority')
|
||||
const Database = require('../Database')
|
||||
|
||||
@@ -216,6 +217,11 @@ class RssFeedManager {
|
||||
res.sendStatus(404)
|
||||
return
|
||||
}
|
||||
// Express does not set the correct mimetype for m4b files so use our defined mimetypes if available
|
||||
const audioMimeType = getAudioMimeTypeFromExtname(Path.extname(episodePath))
|
||||
if (audioMimeType) {
|
||||
res.setHeader('Content-Type', audioMimeType)
|
||||
}
|
||||
res.sendFile(episodePath)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* @typedef MigrationContext
|
||||
* @property {import('sequelize').QueryInterface} queryInterface - a Sequelize QueryInterface object.
|
||||
* @property {import('../Logger')} logger - a Logger object.
|
||||
*
|
||||
* @typedef MigrationOptions
|
||||
* @property {MigrationContext} context - an object containing the migration context.
|
||||
*/
|
||||
|
||||
const migrationVersion = '2.35.0'
|
||||
const migrationName = `${migrationVersion}-add-last-refresh-token`
|
||||
const loggerPrefix = `[${migrationVersion} migration]`
|
||||
|
||||
/**
|
||||
* This migration script adds lastRefreshToken and lastRefreshTokenExpiresAt columns to the sessions table.
|
||||
*
|
||||
* @param {MigrationOptions} options - an object containing the migration context.
|
||||
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||
*/
|
||||
async function up({ context: { queryInterface, logger } }) {
|
||||
logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)
|
||||
|
||||
if (await queryInterface.tableExists('sessions')) {
|
||||
const tableDescription = await queryInterface.describeTable('sessions')
|
||||
|
||||
if (!tableDescription.lastRefreshToken) {
|
||||
logger.info(`${loggerPrefix} Adding lastRefreshToken column to sessions table`)
|
||||
await queryInterface.addColumn('sessions', 'lastRefreshToken', {
|
||||
type: queryInterface.sequelize.Sequelize.DataTypes.STRING,
|
||||
allowNull: true
|
||||
})
|
||||
} else {
|
||||
logger.info(`${loggerPrefix} lastRefreshToken column already exists in sessions table`)
|
||||
}
|
||||
|
||||
if (!tableDescription.lastRefreshTokenExpiresAt) {
|
||||
logger.info(`${loggerPrefix} Adding lastRefreshTokenExpiresAt column to sessions table`)
|
||||
await queryInterface.addColumn('sessions', 'lastRefreshTokenExpiresAt', {
|
||||
type: queryInterface.sequelize.Sequelize.DataTypes.DATE,
|
||||
allowNull: true
|
||||
})
|
||||
} else {
|
||||
logger.info(`${loggerPrefix} lastRefreshTokenExpiresAt column already exists in sessions table`)
|
||||
}
|
||||
} else {
|
||||
logger.info(`${loggerPrefix} sessions table does not exist`)
|
||||
}
|
||||
|
||||
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* This migration script removes the lastRefreshToken and lastRefreshTokenExpiresAt columns from the sessions table.
|
||||
*
|
||||
* @param {MigrationOptions} options - an object containing the migration context.
|
||||
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||
*/
|
||||
async function down({ context: { queryInterface, logger } }) {
|
||||
logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)
|
||||
|
||||
if (await queryInterface.tableExists('sessions')) {
|
||||
const tableDescription = await queryInterface.describeTable('sessions')
|
||||
|
||||
if (tableDescription.lastRefreshToken) {
|
||||
logger.info(`${loggerPrefix} Removing lastRefreshToken column from sessions table`)
|
||||
await queryInterface.removeColumn('sessions', 'lastRefreshToken')
|
||||
} else {
|
||||
logger.info(`${loggerPrefix} lastRefreshToken column does not exist in sessions table`)
|
||||
}
|
||||
|
||||
if (tableDescription.lastRefreshTokenExpiresAt) {
|
||||
logger.info(`${loggerPrefix} Removing lastRefreshTokenExpiresAt column from sessions table`)
|
||||
await queryInterface.removeColumn('sessions', 'lastRefreshTokenExpiresAt')
|
||||
} else {
|
||||
logger.info(`${loggerPrefix} lastRefreshTokenExpiresAt column does not exist in sessions table`)
|
||||
}
|
||||
} else {
|
||||
logger.info(`${loggerPrefix} sessions table does not exist`)
|
||||
}
|
||||
|
||||
logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
|
||||
}
|
||||
|
||||
module.exports = { up, down }
|
||||
+13
-21
@@ -422,21 +422,6 @@ class Book extends Model {
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
// TODO: Remove support for updating audioFiles, chapters and ebookFile here
|
||||
const arrayOfObjectsKeys = ['audioFiles', 'chapters']
|
||||
arrayOfObjectsKeys.forEach((key) => {
|
||||
if (Array.isArray(payload[key]) && !payload[key].some((item) => typeof item !== 'object') && JSON.stringify(this[key]) !== JSON.stringify(payload[key])) {
|
||||
this[key] = payload[key]
|
||||
this.changed(key, true)
|
||||
hasUpdates = true
|
||||
}
|
||||
})
|
||||
if (payload.ebookFile && JSON.stringify(this.ebookFile) !== JSON.stringify(payload.ebookFile)) {
|
||||
this.ebookFile = payload.ebookFile
|
||||
this.changed('ebookFile', true)
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
if (hasUpdates) {
|
||||
Logger.debug(`[Book] "${this.title}" changed keys:`, this.changed())
|
||||
await this.save()
|
||||
@@ -647,6 +632,11 @@ class Book extends Model {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Minified book JSON for list/shelf endpoints.
|
||||
* `toOldJSONExpanded()` must be a strict superset: every key here must exist in expanded
|
||||
* with the same value semantics. Only additive changes to expanded; never remove or rename keys.
|
||||
*/
|
||||
toOldJSONMinified() {
|
||||
if (!this.authors) {
|
||||
throw new Error(`[Book] Cannot convert to old JSON because authors are not loaded`)
|
||||
@@ -669,6 +659,12 @@ class Book extends Model {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Expanded book JSON for item detail and socket events.
|
||||
* Must be a strict superset of `toOldJSONMinified()` — built by spreading minified, then adding expanded-only fields.
|
||||
*
|
||||
* @param {string} libraryItemId
|
||||
*/
|
||||
toOldJSONExpanded(libraryItemId) {
|
||||
if (!libraryItemId) {
|
||||
throw new Error(`[Book] Cannot convert to old JSON because libraryItemId is not provided`)
|
||||
@@ -681,16 +677,12 @@ class Book extends Model {
|
||||
}
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
libraryItemId: libraryItemId,
|
||||
...this.toOldJSONMinified(),
|
||||
libraryItemId,
|
||||
metadata: this.oldMetadataToJSONExpanded(),
|
||||
coverPath: this.coverPath,
|
||||
tags: [...(this.tags || [])],
|
||||
audioFiles: structuredClone(this.audioFiles),
|
||||
chapters: structuredClone(this.chapters),
|
||||
ebookFile: structuredClone(this.ebookFile),
|
||||
duration: this.duration,
|
||||
size: this.size,
|
||||
tracks: this.getTracklist(libraryItemId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { DataTypes, Model } = require('sequelize')
|
||||
const { DataTypes, Model, fn, col } = require('sequelize')
|
||||
|
||||
class BookAuthor extends Model {
|
||||
constructor(values, options) {
|
||||
@@ -37,6 +37,32 @@ class BookAuthor extends Model {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get number of books for each author
|
||||
*
|
||||
* @param {string[]} authorIds
|
||||
* @returns {Promise<Record<string, number>>}
|
||||
*/
|
||||
static async getCountsForAuthors(authorIds) {
|
||||
if (!authorIds.length) return {}
|
||||
|
||||
const rows = await this.findAll({
|
||||
attributes: ['authorId', [fn('COUNT', col('id')), 'count']],
|
||||
where: {
|
||||
authorId: authorIds
|
||||
},
|
||||
group: ['authorId'],
|
||||
raw: true
|
||||
})
|
||||
|
||||
/** @type {Record<string, number>} */
|
||||
const counts = {}
|
||||
for (const row of rows) {
|
||||
counts[row.authorId] = Number(row.count)
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize model
|
||||
* @param {import('../Database').sequelize} sequelize
|
||||
|
||||
@@ -1004,6 +1004,11 @@ class LibraryItem extends Model {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Minified library item JSON for list/shelf endpoints.
|
||||
* `toOldJSONExpanded()` must be a strict superset: every key here must exist in expanded
|
||||
* with the same value semantics. Only additive changes to expanded; never remove or rename keys.
|
||||
*/
|
||||
toOldJSONMinified() {
|
||||
if (!this.media) {
|
||||
throw new Error(`[LibraryItem] Cannot convert to old JSON without media for library item "${this.id}"`)
|
||||
@@ -1032,30 +1037,18 @@ class LibraryItem extends Model {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Expanded library item JSON for item detail and socket events.
|
||||
* Must be a strict superset of `toOldJSONMinified()` — built by spreading minified, then adding expanded-only fields.
|
||||
*/
|
||||
toOldJSONExpanded() {
|
||||
return {
|
||||
id: this.id,
|
||||
ino: this.ino,
|
||||
oldLibraryItemId: this.extraData?.oldLibraryItemId || null,
|
||||
libraryId: this.libraryId,
|
||||
folderId: this.libraryFolderId,
|
||||
path: this.path,
|
||||
relPath: this.relPath,
|
||||
isFile: this.isFile,
|
||||
mtimeMs: this.mtime?.valueOf(),
|
||||
ctimeMs: this.ctime?.valueOf(),
|
||||
birthtimeMs: this.birthtime?.valueOf(),
|
||||
addedAt: this.createdAt.valueOf(),
|
||||
updatedAt: this.updatedAt.valueOf(),
|
||||
...this.toOldJSONMinified(),
|
||||
lastScan: this.lastScan?.valueOf(),
|
||||
scanVersion: this.lastScanVersion,
|
||||
isMissing: !!this.isMissing,
|
||||
isInvalid: !!this.isInvalid,
|
||||
mediaType: this.mediaType,
|
||||
media: this.media.toOldJSONExpanded(this.id),
|
||||
// LibraryFile JSON includes a fileType property that may not be saved in libraryFiles column in the database
|
||||
libraryFiles: this.getLibraryFilesJson(),
|
||||
size: this.size
|
||||
libraryFiles: this.getLibraryFilesJson()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+14
-12
@@ -451,6 +451,11 @@ class Podcast extends Model {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Minified podcast JSON for list/shelf endpoints.
|
||||
* `toOldJSONExpanded()` must be a strict superset: every key here must exist in expanded
|
||||
* with the same value semantics. Only additive changes to expanded; never remove or rename keys.
|
||||
*/
|
||||
toOldJSONMinified() {
|
||||
return {
|
||||
id: this.id,
|
||||
@@ -468,6 +473,12 @@ class Podcast extends Model {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Expanded podcast JSON for item detail and socket events.
|
||||
* Must be a strict superset of `toOldJSONMinified()` — built by spreading minified, then adding expanded-only fields.
|
||||
*
|
||||
* @param {string} libraryItemId
|
||||
*/
|
||||
toOldJSONExpanded(libraryItemId) {
|
||||
if (!libraryItemId) {
|
||||
throw new Error(`[Podcast] Cannot convert to old JSON because libraryItemId is not provided`)
|
||||
@@ -477,18 +488,9 @@ class Podcast extends Model {
|
||||
}
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
libraryItemId: libraryItemId,
|
||||
metadata: this.oldMetadataToJSONExpanded(),
|
||||
coverPath: this.coverPath,
|
||||
tags: [...(this.tags || [])],
|
||||
episodes: this.podcastEpisodes.map((e) => e.toOldJSONExpanded(libraryItemId)),
|
||||
autoDownloadEpisodes: this.autoDownloadEpisodes,
|
||||
autoDownloadSchedule: this.autoDownloadSchedule,
|
||||
lastEpisodeCheck: this.lastEpisodeCheck?.valueOf() || null,
|
||||
maxEpisodesToKeep: this.maxEpisodesToKeep,
|
||||
maxNewEpisodesToDownload: this.maxNewEpisodesToDownload,
|
||||
size: this.size
|
||||
...this.toOldJSONMinified(),
|
||||
libraryItemId,
|
||||
episodes: this.podcastEpisodes.map((e) => e.toOldJSONExpanded(libraryItemId))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,10 @@ class Session extends Model {
|
||||
this.userId
|
||||
/** @type {Date} */
|
||||
this.expiresAt
|
||||
/** @type {string} */
|
||||
this.lastRefreshToken
|
||||
/** @type {Date} */
|
||||
this.lastRefreshTokenExpiresAt
|
||||
|
||||
// Expanded properties
|
||||
|
||||
@@ -66,6 +70,14 @@ class Session extends Model {
|
||||
expiresAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
},
|
||||
lastRefreshToken: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true
|
||||
},
|
||||
lastRefreshTokenExpiresAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+10
-11
@@ -352,11 +352,7 @@ class User extends Model {
|
||||
if (cachedUser) return cachedUser
|
||||
|
||||
const user = await this.findOne({
|
||||
where: {
|
||||
username: {
|
||||
[sequelize.Op.like]: username
|
||||
}
|
||||
},
|
||||
where: sequelize.where(sequelize.fn('lower', sequelize.col('username')), username.toLowerCase()),
|
||||
include: this.sequelize.models.mediaProgress
|
||||
})
|
||||
|
||||
@@ -377,11 +373,7 @@ class User extends Model {
|
||||
if (cachedUser) return cachedUser
|
||||
|
||||
const user = await this.findOne({
|
||||
where: {
|
||||
email: {
|
||||
[sequelize.Op.like]: email
|
||||
}
|
||||
},
|
||||
where: sequelize.where(sequelize.fn('lower', sequelize.col('email')), email.toLowerCase()),
|
||||
include: this.sequelize.models.mediaProgress
|
||||
})
|
||||
|
||||
@@ -538,7 +530,14 @@ class User extends Model {
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
modelName: 'user'
|
||||
modelName: 'user',
|
||||
hooks: {
|
||||
beforeDestroy(user) {
|
||||
if (user.type === 'root') {
|
||||
throw new Error('Root user cannot be deleted')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -96,7 +96,12 @@ class DeviceInfo {
|
||||
this.clientVersion = stripAllTags(clientDeviceInfo?.clientVersion) || serverVersion
|
||||
this.manufacturer = stripAllTags(clientDeviceInfo?.manufacturer) || null
|
||||
this.model = stripAllTags(clientDeviceInfo?.model) || null
|
||||
this.sdkVersion = stripAllTags(clientDeviceInfo?.sdkVersion) || null
|
||||
|
||||
if (typeof clientDeviceInfo?.sdkVersion === 'number') {
|
||||
this.sdkVersion = clientDeviceInfo.sdkVersion.toString()
|
||||
} else {
|
||||
this.sdkVersion = stripAllTags(clientDeviceInfo?.sdkVersion) || null
|
||||
}
|
||||
|
||||
this.clientName = stripAllTags(clientDeviceInfo?.clientName) || null
|
||||
if (this.sdkVersion) {
|
||||
|
||||
@@ -5,7 +5,31 @@ const Logger = require('../../Logger')
|
||||
const User = require('../../models/User')
|
||||
const { sanitize } = require('../../utils/htmlSanitizer')
|
||||
|
||||
const PATCHABLE_SETTINGS_KEYS = new Set([
|
||||
'scannerParseSubtitle',
|
||||
'scannerFindCovers',
|
||||
'scannerCoverProvider',
|
||||
'scannerPreferMatchedMetadata',
|
||||
'scannerDisableWatcher',
|
||||
'storeCoverWithItem',
|
||||
'storeMetadataWithItem',
|
||||
'allowIframe',
|
||||
'allowedOrigins',
|
||||
'backupSchedule',
|
||||
'backupsToKeep',
|
||||
'maxBackupSize',
|
||||
'logLevel',
|
||||
'homeBookshelfView',
|
||||
'bookshelfView',
|
||||
'dateFormat',
|
||||
'timeFormat',
|
||||
'language',
|
||||
'chromecastEnabled',
|
||||
'sortingIgnorePrefix'
|
||||
])
|
||||
|
||||
class ServerSettings {
|
||||
static patchableSettingsKeys = PATCHABLE_SETTINGS_KEYS
|
||||
constructor(settings) {
|
||||
this.id = 'server-settings'
|
||||
/** @type {string} JWT secret key ONLY used when JWT_SECRET_KEY is not set in ENV */
|
||||
@@ -260,6 +284,18 @@ class ServerSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Host timezone used by cron schedulers (not persisted in settings)
|
||||
* @returns {string} IANA timezone name, e.g. "America/New_York"
|
||||
*/
|
||||
static getHostTimeZone() {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
|
||||
} catch {
|
||||
return 'UTC'
|
||||
}
|
||||
}
|
||||
|
||||
toJSONForBrowser() {
|
||||
const json = this.toJSON()
|
||||
delete json.tokenSecret
|
||||
@@ -268,6 +304,7 @@ class ServerSettings {
|
||||
delete json.authOpenIDMobileRedirectURIs
|
||||
delete json.authOpenIDGroupClaim
|
||||
delete json.authOpenIDAdvancedPermsClaim
|
||||
json.timeZone = ServerSettings.getHostTimeZone()
|
||||
return json
|
||||
}
|
||||
|
||||
@@ -328,24 +365,9 @@ class ServerSettings {
|
||||
update(payload) {
|
||||
let hasUpdates = false
|
||||
for (const key in payload) {
|
||||
if (key === 'authLoginCustomMessage') {
|
||||
payload[key] = sanitize(payload[key])
|
||||
}
|
||||
if (key === 'sortingPrefixes') {
|
||||
// Sorting prefixes are updated with the /api/sorting-prefixes endpoint
|
||||
continue
|
||||
} else if (key === 'authActiveAuthMethods') {
|
||||
if (!payload[key]?.length) {
|
||||
Logger.error(`[ServerSettings] Invalid authActiveAuthMethods`, payload[key])
|
||||
continue
|
||||
}
|
||||
this.authActiveAuthMethods.sort()
|
||||
payload[key].sort()
|
||||
if (payload[key].join() !== this.authActiveAuthMethods.join()) {
|
||||
this.authActiveAuthMethods = payload[key]
|
||||
hasUpdates = true
|
||||
}
|
||||
} else if (this[key] !== payload[key]) {
|
||||
if (!PATCHABLE_SETTINGS_KEYS.has(key)) continue
|
||||
|
||||
if (this[key] !== payload[key]) {
|
||||
if (key === 'logLevel') {
|
||||
Logger.setLogLevel(payload[key])
|
||||
}
|
||||
|
||||
@@ -171,6 +171,11 @@ class ApiRouter {
|
||||
// Current User Routes (Me)
|
||||
//
|
||||
this.router.get('/me', MeController.getCurrentUser.bind(this))
|
||||
this.router.get('/me/sessions', MeController.getSessions.bind(this))
|
||||
this.router.delete('/me/sessions/:id', MeController.deleteSession.bind(this))
|
||||
this.router.get('/me/progress', MeController.getAllMediaProgress.bind(this))
|
||||
this.router.get('/me/bookmarks', MeController.getAllBookmarks.bind(this))
|
||||
this.router.get('/me/bookmarks/:libraryItemId', MeController.getBookmarksForLibraryItem.bind(this))
|
||||
this.router.get('/me/listening-sessions', MeController.getListeningSessions.bind(this))
|
||||
this.router.get('/me/item/listening-sessions/:libraryItemId/:episodeId?', MeController.getItemListeningSessions.bind(this))
|
||||
this.router.get('/me/listening-stats', MeController.getListeningStats.bind(this))
|
||||
|
||||
@@ -5,15 +5,15 @@ const { LogLevel } = require('../utils/constants')
|
||||
const abmetadataGenerator = require('../utils/generators/abmetadataGenerator')
|
||||
|
||||
class AbsMetadataFileScanner {
|
||||
constructor() { }
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* Check for metadata.json file and set book metadata
|
||||
*
|
||||
* @param {import('./LibraryScan')} libraryScan
|
||||
* @param {import('./LibraryItemScanData')} libraryItemData
|
||||
* @param {Object} bookMetadata
|
||||
* @param {string} [existingLibraryItemId]
|
||||
*
|
||||
* @param {import('./LibraryScan')} libraryScan
|
||||
* @param {import('./LibraryItemScanData')} libraryItemData
|
||||
* @param {Object} bookMetadata
|
||||
* @param {string} [existingLibraryItemId]
|
||||
*/
|
||||
async scanBookMetadataFile(libraryScan, libraryItemData, bookMetadata, existingLibraryItemId = null) {
|
||||
const metadataLibraryFile = libraryItemData.metadataJsonLibraryFile
|
||||
@@ -32,7 +32,8 @@ class AbsMetadataFileScanner {
|
||||
|
||||
if (metadataText) {
|
||||
libraryScan.addLog(LogLevel.INFO, `Found metadata file "${metadataFilePath}"`)
|
||||
const abMetadata = abmetadataGenerator.parseJson(metadataText) || {}
|
||||
const abMetadata = abmetadataGenerator.parseJson(metadataText, 'book') || {}
|
||||
|
||||
for (const key in abMetadata) {
|
||||
// TODO: When to override with null or empty arrays?
|
||||
if (abMetadata[key] === undefined || abMetadata[key] === null) continue
|
||||
@@ -48,11 +49,11 @@ class AbsMetadataFileScanner {
|
||||
|
||||
/**
|
||||
* Check for metadata.json file and set podcast metadata
|
||||
*
|
||||
* @param {import('./LibraryScan')} libraryScan
|
||||
* @param {import('./LibraryItemScanData')} libraryItemData
|
||||
* @param {Object} podcastMetadata
|
||||
* @param {string} [existingLibraryItemId]
|
||||
*
|
||||
* @param {import('./LibraryScan')} libraryScan
|
||||
* @param {import('./LibraryItemScanData')} libraryItemData
|
||||
* @param {Object} podcastMetadata
|
||||
* @param {string} [existingLibraryItemId]
|
||||
*/
|
||||
async scanPodcastMetadataFile(libraryScan, libraryItemData, podcastMetadata, existingLibraryItemId = null) {
|
||||
const metadataLibraryFile = libraryItemData.metadataJsonLibraryFile
|
||||
@@ -71,7 +72,7 @@ class AbsMetadataFileScanner {
|
||||
|
||||
if (metadataText) {
|
||||
libraryScan.addLog(LogLevel.INFO, `Found metadata file "${metadataFilePath}"`)
|
||||
const abMetadata = abmetadataGenerator.parseJson(metadataText) || {}
|
||||
const abMetadata = abmetadataGenerator.parseJson(metadataText, 'podcast') || {}
|
||||
for (const key in abMetadata) {
|
||||
if (abMetadata[key] === undefined || abMetadata[key] === null) continue
|
||||
if (key === 'tags' && !abMetadata.tags?.length) continue
|
||||
@@ -81,4 +82,4 @@ class AbsMetadataFileScanner {
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = new AbsMetadataFileScanner()
|
||||
module.exports = new AbsMetadataFileScanner()
|
||||
@@ -7,6 +7,7 @@ const parseNameString = require('../utils/parsers/parseNameString')
|
||||
const parseEbookMetadata = require('../utils/parsers/parseEbookMetadata')
|
||||
const globals = require('../utils/globals')
|
||||
const { readTextFile, filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils')
|
||||
const htmlSanitizer = require('../utils/htmlSanitizer')
|
||||
|
||||
const AudioFileScanner = require('./AudioFileScanner')
|
||||
const Database = require('../Database')
|
||||
@@ -227,6 +228,7 @@ class BookScanner {
|
||||
bookId: media.id,
|
||||
authorId: existingAuthorId
|
||||
})
|
||||
libraryScan.authorsNumBooksChangedIds.add(existingAuthorId)
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" added author "${authorName}"`)
|
||||
authorsUpdated = true
|
||||
} else {
|
||||
@@ -237,6 +239,7 @@ class BookScanner {
|
||||
})
|
||||
await media.addAuthor(newAuthor)
|
||||
Database.addAuthorToFilterData(libraryItemData.libraryId, newAuthor.name, newAuthor.id)
|
||||
SocketAuthority.emitter('author_added', newAuthor.toOldJSON())
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" added new author "${authorName}"`)
|
||||
authorsUpdated = true
|
||||
}
|
||||
@@ -246,6 +249,7 @@ class BookScanner {
|
||||
for (const author of media.authors) {
|
||||
if (!bookMetadata.authors.includes(author.name)) {
|
||||
await author.bookAuthor.destroy()
|
||||
libraryScan.authorsNumBooksChangedIds.add(author.id)
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" removed author "${author.name}"`)
|
||||
authorsUpdated = true
|
||||
bookAuthorsRemoved.push(author.id)
|
||||
@@ -477,6 +481,7 @@ class BookScanner {
|
||||
}
|
||||
|
||||
const createdAtTimestamp = new Date().getTime()
|
||||
const newAuthorNames = new Set()
|
||||
if (bookMetadata.authors.length) {
|
||||
for (const authorName of bookMetadata.authors) {
|
||||
const matchingAuthorId = await Database.getAuthorIdByName(libraryItemData.libraryId, authorName)
|
||||
@@ -484,6 +489,8 @@ class BookScanner {
|
||||
bookObject.bookAuthors.push({
|
||||
authorId: matchingAuthorId
|
||||
})
|
||||
// Existing author — only numBooks changes; batch emit at scan end
|
||||
libraryScan.authorsNumBooksChangedIds.add(matchingAuthorId)
|
||||
} else {
|
||||
// New author
|
||||
bookObject.bookAuthors.push({
|
||||
@@ -495,6 +502,7 @@ class BookScanner {
|
||||
lastFirst: Database.authorModel.getLastFirst(authorName)
|
||||
}
|
||||
})
|
||||
newAuthorNames.add(authorName)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -592,6 +600,9 @@ class BookScanner {
|
||||
for (const ba of libraryItem.book.bookAuthors) {
|
||||
if (ba.author) {
|
||||
Database.addAuthorToFilterData(libraryItemData.libraryId, ba.author.name, ba.author.id)
|
||||
if (newAuthorNames.has(ba.author.name)) {
|
||||
SocketAuthority.emitter('author_added', ba.author.toOldJSON())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -688,6 +699,10 @@ class BookScanner {
|
||||
|
||||
bookMetadata.titleIgnorePrefix = getTitleIgnorePrefix(bookMetadata.title)
|
||||
|
||||
if (typeof bookMetadata.description === 'string' && bookMetadata.description) {
|
||||
bookMetadata.description = htmlSanitizer.sanitize(bookMetadata.description)
|
||||
}
|
||||
|
||||
return bookMetadata
|
||||
}
|
||||
|
||||
@@ -820,6 +835,9 @@ class BookScanner {
|
||||
|
||||
const metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`)
|
||||
|
||||
/**
|
||||
* Keys must match abmetadataGenerator.js
|
||||
*/
|
||||
const jsonObject = {
|
||||
tags: libraryItem.media.tags || [],
|
||||
chapters: libraryItem.media.chapters?.map((c) => ({ ...c })) || [],
|
||||
@@ -883,6 +901,34 @@ class BookScanner {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit authors_num_books_updated for authors whose book count changed during a scan (linked or unlinked).
|
||||
* Authors with no book links are omitted — orphans are handled by author_removed.
|
||||
* @param {string} libraryId
|
||||
* @param {import('./LibraryScan')|import('./ScanLogger')} scanLogger
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async emitAuthorsNumBooksUpdated(libraryId, scanLogger) {
|
||||
if (!scanLogger.authorsNumBooksChangedIds?.size) return
|
||||
|
||||
const authorIds = [...scanLogger.authorsNumBooksChangedIds]
|
||||
scanLogger.authorsNumBooksChangedIds.clear()
|
||||
|
||||
const countsByAuthorId = await Database.bookAuthorModel.getCountsForAuthors(authorIds)
|
||||
|
||||
const authors = Object.entries(countsByAuthorId).map(([id, numBooks]) => ({
|
||||
id,
|
||||
numBooks
|
||||
}))
|
||||
|
||||
if (!authors.length) return
|
||||
|
||||
SocketAuthority.emitter('authors_num_books_updated', {
|
||||
libraryId,
|
||||
authors
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check authors that were removed from a book and remove them if they no longer have any books
|
||||
* keep authors without books that have a asin, description or imagePath
|
||||
|
||||
@@ -66,7 +66,7 @@ class LibraryItemScanner {
|
||||
if (libraryItemDataUpdated || wasUpdated) {
|
||||
SocketAuthority.libraryItemEmitter('item_updated', expandedLibraryItem)
|
||||
|
||||
await this.checkAuthorsAndSeriesRemovedFromBooks(library.id, scanLogger)
|
||||
await this.finalizeAuthorsAndSeriesFromScan(library.id, scanLogger)
|
||||
|
||||
return ScanResult.UPDATED
|
||||
}
|
||||
@@ -76,12 +76,13 @@ class LibraryItemScanner {
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove empty authors and series
|
||||
* Emit author book count updates, then remove empty authors/series left after the scan
|
||||
* @param {string} libraryId
|
||||
* @param {ScanLogger} scanLogger
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async checkAuthorsAndSeriesRemovedFromBooks(libraryId, scanLogger) {
|
||||
async finalizeAuthorsAndSeriesFromScan(libraryId, scanLogger) {
|
||||
await BookScanner.emitAuthorsNumBooksUpdated(libraryId, scanLogger)
|
||||
if (scanLogger.authorsRemovedFromBooks.length) {
|
||||
await BookScanner.checkAuthorsRemovedFromBooks(libraryId, scanLogger)
|
||||
}
|
||||
@@ -215,7 +216,10 @@ class LibraryItemScanner {
|
||||
scanLogger.verbose = true
|
||||
scanLogger.setData('libraryItem', libraryItemScanData.relPath)
|
||||
|
||||
return this.scanNewLibraryItem(libraryItemScanData, library.settings, scanLogger)
|
||||
const newLibraryItem = await this.scanNewLibraryItem(libraryItemScanData, library.settings, scanLogger)
|
||||
// Watcher path does not go through full-library scan cleanup — emit author book count updates here
|
||||
await BookScanner.emitAuthorsNumBooksUpdated(library.id, scanLogger)
|
||||
return newLibraryItem
|
||||
}
|
||||
}
|
||||
module.exports = new LibraryItemScanner()
|
||||
@@ -24,6 +24,8 @@ class LibraryScan {
|
||||
|
||||
/** @type {string[]} */
|
||||
this.authorsRemovedFromBooks = []
|
||||
/** @type {Set<string>} */
|
||||
this.authorsNumBooksChangedIds = new Set()
|
||||
/** @type {string[]} */
|
||||
this.seriesRemovedFromBooks = []
|
||||
|
||||
|
||||
@@ -234,8 +234,7 @@ class LibraryScanner {
|
||||
SocketAuthority.libraryItemsEmitter('items_updated', libraryItemsUpdated)
|
||||
}
|
||||
|
||||
// Authors and series that were removed from books should be removed if they are now empty
|
||||
await LibraryItemScanner.checkAuthorsAndSeriesRemovedFromBooks(libraryScan.libraryId, libraryScan)
|
||||
if (this.shouldCancelScan(libraryScan)) return true
|
||||
|
||||
// Update missing library items
|
||||
if (libraryItemIdsMissing.length) {
|
||||
@@ -281,6 +280,9 @@ class LibraryScanner {
|
||||
}
|
||||
}
|
||||
|
||||
// Author book count updates and empty authors/series cleanup after all items are processed
|
||||
await LibraryItemScanner.finalizeAuthorsAndSeriesFromScan(libraryScan.libraryId, libraryScan)
|
||||
|
||||
libraryScan.addLog(LogLevel.INFO, `Scan completed. ${libraryScan.resultStats}`)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ const LibraryFile = require('../objects/files/LibraryFile')
|
||||
const fsExtra = require('../libs/fsExtra')
|
||||
const PodcastEpisode = require('../models/PodcastEpisode')
|
||||
const AbsMetadataFileScanner = require('./AbsMetadataFileScanner')
|
||||
const htmlSanitizer = require('../utils/htmlSanitizer')
|
||||
|
||||
/**
|
||||
* Metadata for podcasts pulled from files
|
||||
@@ -57,8 +58,12 @@ class PodcastScanner {
|
||||
/** @type {AudioFile[]} */
|
||||
let newAudioFiles = []
|
||||
|
||||
// Track episode add/remove/update so item_updated includes the current episode list
|
||||
let hasEpisodeChanges = false
|
||||
|
||||
if (libraryItemData.hasAudioFileChanges || libraryItemData.audioLibraryFiles.length !== existingPodcastEpisodes.length) {
|
||||
// Filter out and destroy episodes that were removed
|
||||
// Filter out and destroy episodes that were removed.
|
||||
// filter() returns a new array — reassign to media.podcastEpisodes before emit.
|
||||
const episodesToRemove = []
|
||||
existingPodcastEpisodes = existingPodcastEpisodes.filter((ep) => {
|
||||
if (libraryItemData.checkAudioFileRemoved(ep.audioFile)) {
|
||||
@@ -69,6 +74,7 @@ class PodcastScanner {
|
||||
})
|
||||
|
||||
if (episodesToRemove.length) {
|
||||
hasEpisodeChanges = true
|
||||
// Remove episodes from playlists and media progress
|
||||
const episodeIds = episodesToRemove.map((ep) => ep.id)
|
||||
await Database.playlistModel.removeMediaItemsFromPlaylists(episodeIds)
|
||||
@@ -115,6 +121,7 @@ class PodcastScanner {
|
||||
AudioFileScanner.setPodcastEpisodeMetadataFromAudioMetaTags(podcastEpisode, libraryScan)
|
||||
libraryScan.addLog(LogLevel.INFO, `Podcast episode "${podcastEpisode.title}" keys changed [${podcastEpisode.changed()?.join(', ')}]`)
|
||||
await podcastEpisode.save()
|
||||
hasEpisodeChanges = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,9 +161,13 @@ class PodcastScanner {
|
||||
libraryScan.addLog(LogLevel.INFO, `New Podcast episode "${newPodcastEpisode.title}" added`)
|
||||
await newPodcastEpisode.save()
|
||||
existingPodcastEpisodes.push(newPodcastEpisode)
|
||||
hasEpisodeChanges = true
|
||||
}
|
||||
}
|
||||
|
||||
// Keep association in sync for toOldJSONExpanded() / item_updated socket payload
|
||||
media.podcastEpisodes = existingPodcastEpisodes
|
||||
|
||||
let hasMediaChanges = false
|
||||
if (existingPodcastEpisodes.length !== media.numEpisodes) {
|
||||
media.numEpisodes = existingPodcastEpisodes.length
|
||||
@@ -249,7 +260,7 @@ class PodcastScanner {
|
||||
|
||||
return {
|
||||
libraryItem: existingLibraryItem,
|
||||
wasUpdated: hasMediaChanges || libraryItemUpdated
|
||||
wasUpdated: hasMediaChanges || libraryItemUpdated || hasEpisodeChanges
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,6 +409,10 @@ class PodcastScanner {
|
||||
|
||||
podcastMetadata.titleIgnorePrefix = getTitleIgnorePrefix(podcastMetadata.title)
|
||||
|
||||
if (typeof podcastMetadata.description === 'string' && podcastMetadata.description) {
|
||||
podcastMetadata.description = htmlSanitizer.sanitize(podcastMetadata.description)
|
||||
}
|
||||
|
||||
return podcastMetadata
|
||||
}
|
||||
|
||||
@@ -420,6 +435,9 @@ class PodcastScanner {
|
||||
|
||||
const metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`)
|
||||
|
||||
/**
|
||||
* Keys must match abmetadataGenerator.js
|
||||
*/
|
||||
const jsonObject = {
|
||||
tags: libraryItem.media.tags || [],
|
||||
title: libraryItem.media.title,
|
||||
|
||||
@@ -14,6 +14,8 @@ class ScanLogger {
|
||||
|
||||
/** @type {string[]} */
|
||||
this.authorsRemovedFromBooks = []
|
||||
/** @type {Set<string>} */
|
||||
this.authorsNumBooksChangedIds = new Set()
|
||||
/** @type {string[]} */
|
||||
this.seriesRemovedFromBooks = []
|
||||
|
||||
|
||||
@@ -6,6 +6,21 @@ const fs = require('../libs/fsExtra')
|
||||
const StreamZip = require('../libs/nodeStreamZip')
|
||||
const Archive = require('../libs/libarchive/archive')
|
||||
const { isWritable } = require('./fileUtils')
|
||||
const { sanitizePath } = require('../libs/archiver/archiverUtils')
|
||||
|
||||
/**
|
||||
* Sanitize a path from an archive
|
||||
*
|
||||
* @param {string} filename
|
||||
* @returns {string}
|
||||
*/
|
||||
function sanitizeArchivePath(filename) {
|
||||
const sanitizedPath = Path.normalize(sanitizePath(filename))
|
||||
if (!sanitizedPath || sanitizedPath === '.' || sanitizedPath === '..' || sanitizedPath.startsWith(`..${Path.sep}`) || Path.isAbsolute(sanitizedPath)) {
|
||||
throw new Error(`[CbrComicBookExtractor] Unsafe archive path "${filename}"`)
|
||||
}
|
||||
return sanitizedPath
|
||||
}
|
||||
|
||||
class AbstractComicBookExtractor {
|
||||
constructor(comicPath) {
|
||||
@@ -57,7 +72,11 @@ class CbrComicBookExtractor extends AbstractComicBookExtractor {
|
||||
this.tmpDir = global.MetadataPath ? Path.join(global.MetadataPath, 'tmp') : os.tmpdir()
|
||||
await fs.ensureDir(this.tmpDir)
|
||||
if (!(await isWritable(this.tmpDir))) throw new Error(`[CbrComicBookExtractor] Temp directory "${this.tmpDir}" is not writable`)
|
||||
this.archive = await unrar.createExtractorFromFile({ filepath: this.comicPath, targetPath: this.tmpDir })
|
||||
this.archive = await unrar.createExtractorFromFile({
|
||||
filepath: this.comicPath,
|
||||
targetPath: this.tmpDir,
|
||||
filenameTransform: sanitizeArchivePath
|
||||
})
|
||||
Logger.debug(`[CbrComicBookExtractor] Opened comic book "${this.comicPath}". Using temp directory "${this.tmpDir}" for extraction.`)
|
||||
}
|
||||
|
||||
@@ -81,14 +100,22 @@ class CbrComicBookExtractor extends AbstractComicBookExtractor {
|
||||
}
|
||||
}
|
||||
|
||||
getExtractedFilePath(file) {
|
||||
const sanitizedFile = sanitizeArchivePath(file)
|
||||
return {
|
||||
filePath: Path.join(this.tmpDir, sanitizedFile),
|
||||
relativePath: sanitizedFile
|
||||
}
|
||||
}
|
||||
|
||||
async extractToBuffer(file) {
|
||||
if (!this.archive) return null
|
||||
const extracted = this.archive.extract({ files: [file] })
|
||||
const files = [...extracted.files]
|
||||
const filePath = Path.join(this.tmpDir, files[0].fileHeader.name)
|
||||
const { filePath, relativePath } = this.getExtractedFilePath(files[0].fileHeader.name)
|
||||
const fileData = await fs.readFile(filePath)
|
||||
await fs.remove(filePath)
|
||||
await this.removeEmptyParentDirs(files[0].fileHeader.name)
|
||||
await this.removeEmptyParentDirs(relativePath)
|
||||
Logger.debug(`[CbrComicBookExtractor] Extracted file "${file}" from comic book "${this.comicPath}" to buffer, size: ${fileData.length}`)
|
||||
return fileData
|
||||
}
|
||||
@@ -97,9 +124,9 @@ class CbrComicBookExtractor extends AbstractComicBookExtractor {
|
||||
if (!this.archive) return false
|
||||
const extracted = this.archive.extract({ files: [file] })
|
||||
const files = [...extracted.files]
|
||||
const extractedFilePath = Path.join(this.tmpDir, files[0].fileHeader.name)
|
||||
const { filePath: extractedFilePath, relativePath } = this.getExtractedFilePath(files[0].fileHeader.name)
|
||||
await fs.move(extractedFilePath, outputFilePath, { overwrite: true })
|
||||
await this.removeEmptyParentDirs(files[0].fileHeader.name)
|
||||
await this.removeEmptyParentDirs(relativePath)
|
||||
Logger.debug(`[CbrComicBookExtractor] Extracted file "${file}" from comic book "${this.comicPath}" to "${outputFilePath}"`)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,7 +1,51 @@
|
||||
const Logger = require('../../Logger')
|
||||
const parseSeriesString = require('../parsers/parseSeriesString')
|
||||
|
||||
function parseJsonMetadataText(text) {
|
||||
const mediaTypeKeys = {
|
||||
book: {
|
||||
tags: 'stringArray',
|
||||
title: 'string',
|
||||
subtitle: 'string',
|
||||
authors: 'stringArray',
|
||||
narrators: 'stringArray',
|
||||
series: 'stringArray',
|
||||
genres: 'stringArray',
|
||||
publishedYear: 'string',
|
||||
publishedDate: 'string',
|
||||
publisher: 'string',
|
||||
description: 'string',
|
||||
isbn: 'string',
|
||||
asin: 'string',
|
||||
language: 'string',
|
||||
explicit: 'boolean',
|
||||
abridged: 'boolean'
|
||||
},
|
||||
podcast: {
|
||||
tags: 'stringArray',
|
||||
title: 'string',
|
||||
author: 'string',
|
||||
description: 'string',
|
||||
releaseDate: 'string',
|
||||
genres: 'stringArray',
|
||||
feedURL: 'string',
|
||||
imageURL: 'string',
|
||||
itunesPageURL: 'string',
|
||||
itunesId: 'string',
|
||||
itunesArtistId: 'string',
|
||||
asin: 'string',
|
||||
language: 'string',
|
||||
explicit: 'boolean',
|
||||
podcastType: 'string'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} text
|
||||
* @param {"book" | "podcast"} mediaType
|
||||
* @returns {Object}
|
||||
*/
|
||||
function parseJsonMetadataText(text, mediaType) {
|
||||
try {
|
||||
const abmetadataData = JSON.parse(text)
|
||||
|
||||
@@ -19,28 +63,41 @@ function parseJsonMetadataText(text) {
|
||||
}
|
||||
delete abmetadataData.metadata
|
||||
|
||||
if (abmetadataData.series?.length) {
|
||||
abmetadataData.series = [...new Set(abmetadataData.series.map((t) => t?.trim()).filter((t) => t))]
|
||||
abmetadataData.series = abmetadataData.series.map((series) => parseSeriesString.parse(series))
|
||||
const expectedKeys = mediaTypeKeys[mediaType]
|
||||
if (!expectedKeys) {
|
||||
Logger.error(`[abmetadataGenerator] Invalid media type "${mediaType}"`)
|
||||
return null
|
||||
}
|
||||
// clean tags & remove dupes
|
||||
if (abmetadataData.tags?.length) {
|
||||
abmetadataData.tags = [...new Set(abmetadataData.tags.map((t) => t?.trim()).filter((t) => t))]
|
||||
|
||||
const validated = {}
|
||||
for (const key in expectedKeys) {
|
||||
const expectedType = expectedKeys[key]
|
||||
if (!(key in abmetadataData)) continue
|
||||
|
||||
const validatedValue = validateMetadataValue(key, abmetadataData[key], expectedType)
|
||||
if (validatedValue !== undefined) {
|
||||
validated[key] = validatedValue
|
||||
}
|
||||
}
|
||||
if (abmetadataData.chapters?.length) {
|
||||
abmetadataData.chapters = cleanChaptersArray(abmetadataData.chapters, abmetadataData.title)
|
||||
|
||||
if (validated.series?.length) {
|
||||
validated.series = validated.series.map((series) => parseSeriesString.parse(series)).filter(Boolean)
|
||||
}
|
||||
// clean remove dupes
|
||||
if (abmetadataData.authors?.length) {
|
||||
abmetadataData.authors = [...new Set(abmetadataData.authors.map((t) => t?.trim()).filter((t) => t))]
|
||||
|
||||
if (mediaType === 'book' && 'chapters' in abmetadataData) {
|
||||
if (abmetadataData.chapters === null) {
|
||||
validated.chapters = []
|
||||
} else if (Array.isArray(abmetadataData.chapters)) {
|
||||
const cleanedChapters = cleanChaptersArray(abmetadataData.chapters, validated.title ?? abmetadataData.title)
|
||||
if (cleanedChapters) {
|
||||
validated.chapters = cleanedChapters
|
||||
}
|
||||
} else {
|
||||
Logger.warn(`[abmetadataGenerator] Invalid metadata key "chapters" expected array, got ${typeof abmetadataData.chapters}`)
|
||||
}
|
||||
}
|
||||
if (abmetadataData.narrators?.length) {
|
||||
abmetadataData.narrators = [...new Set(abmetadataData.narrators.map((t) => t?.trim()).filter((t) => t))]
|
||||
}
|
||||
if (abmetadataData.genres?.length) {
|
||||
abmetadataData.genres = [...new Set(abmetadataData.genres.map((t) => t?.trim()).filter((t) => t))]
|
||||
}
|
||||
return abmetadataData
|
||||
|
||||
return validated
|
||||
} catch (error) {
|
||||
Logger.error(`[abmetadataGenerator] Invalid metadata.json JSON`, error)
|
||||
return null
|
||||
@@ -48,6 +105,54 @@ function parseJsonMetadataText(text) {
|
||||
}
|
||||
module.exports.parseJson = parseJsonMetadataText
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @param {*} value
|
||||
* @param {string} expectedType
|
||||
* @returns {*|undefined} undefined excludes the key
|
||||
*/
|
||||
function validateMetadataValue(key, value, expectedType) {
|
||||
if (expectedType === 'string') {
|
||||
if (value === null) return null
|
||||
if (typeof value === 'number') return String(value)
|
||||
if (typeof value === 'string') return value
|
||||
Logger.warn(`[abmetadataGenerator] Invalid metadata key "${key}" expected string, got ${typeof value}`)
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (expectedType === 'boolean') {
|
||||
if (value === null) return null
|
||||
if (typeof value === 'boolean') return value
|
||||
if (typeof value === 'string') {
|
||||
const lower = value.toLowerCase()
|
||||
if (lower === 'true') return true
|
||||
if (lower === 'false') return false
|
||||
}
|
||||
Logger.warn(`[abmetadataGenerator] Invalid metadata key "${key}" expected boolean, got ${typeof value}`)
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Filter empty strings and deduplicate
|
||||
if (expectedType === 'stringArray') {
|
||||
if (value === null) return []
|
||||
if (!Array.isArray(value)) {
|
||||
Logger.warn(`[abmetadataGenerator] Invalid metadata key "${key}" expected string array, got ${typeof value}`)
|
||||
return undefined
|
||||
}
|
||||
|
||||
const cleanedArray = value.filter((t) => typeof t === 'string')
|
||||
return [...new Set(cleanedArray.map((t) => t.trim()).filter((t) => t))]
|
||||
}
|
||||
|
||||
Logger.warn(`[abmetadataGenerator] Unknown expected type "${expectedType}" for key "${key}"`)
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object[]} chaptersArray
|
||||
* @param {string} mediaTitle
|
||||
* @returns {Object[]}
|
||||
*/
|
||||
function cleanChaptersArray(chaptersArray, mediaTitle) {
|
||||
const chapters = []
|
||||
let index = 0
|
||||
|
||||
@@ -100,7 +100,7 @@ module.exports.notificationData = {
|
||||
variables: ['version'],
|
||||
defaults: {
|
||||
title: 'Test Notification on Abs {{version}}',
|
||||
body: 'Test notificataion body for abs {{version}}.'
|
||||
body: 'Test notification body for abs {{version}}.'
|
||||
},
|
||||
testData: {
|
||||
version: 'v' + version
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
const uaParserJs = require('../../libs/uaParser')
|
||||
|
||||
/**
|
||||
* @param {string|null|undefined} userAgent
|
||||
* @returns {{ browserName?:string, browserVersion?:string, osName?:string, osVersion?:string, deviceType?:string, model?:string, vendor?:string }|null}
|
||||
*/
|
||||
function parseUserAgent(userAgent) {
|
||||
if (!userAgent) return null
|
||||
|
||||
const ua = uaParserJs(userAgent)
|
||||
const deviceInfo = {
|
||||
browserName: ua?.browser?.name || undefined,
|
||||
browserVersion: ua?.browser?.version || undefined,
|
||||
osName: ua?.os?.name || undefined,
|
||||
osVersion: ua?.os?.version || undefined,
|
||||
deviceType: ua?.device?.type || undefined,
|
||||
model: ua?.device?.model || undefined,
|
||||
vendor: ua?.device?.vendor || undefined
|
||||
}
|
||||
|
||||
for (const key in deviceInfo) {
|
||||
if (deviceInfo[key] === undefined) delete deviceInfo[key]
|
||||
}
|
||||
|
||||
return Object.keys(deviceInfo).length ? deviceInfo : null
|
||||
}
|
||||
|
||||
module.exports = parseUserAgent
|
||||
@@ -133,9 +133,10 @@ module.exports = {
|
||||
/**
|
||||
* Get all library items that have narrators
|
||||
* @param {string[]} narrators
|
||||
* @param {string} libraryId
|
||||
* @returns {Promise<import('../../models/LibraryItem')[]>}
|
||||
*/
|
||||
async getAllLibraryItemsWithNarrators(narrators) {
|
||||
async getAllLibraryItemsWithNarrators(narrators, libraryId) {
|
||||
const libraryItems = []
|
||||
const booksWithGenre = await Database.bookModel.findAll({
|
||||
where: Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(narrators) WHERE json_valid(narrators) AND json_each.value IN (:narrators))`), {
|
||||
@@ -146,7 +147,8 @@ module.exports = {
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: Database.libraryItemModel
|
||||
model: Database.libraryItemModel,
|
||||
where: { libraryId }
|
||||
},
|
||||
{
|
||||
model: Database.authorModel,
|
||||
|
||||
@@ -309,11 +309,12 @@ module.exports = {
|
||||
* @param {Sequelize.WhereOptions} seriesWhere
|
||||
* @returns {object} { booksToExclude, bookSeriesToInclude }
|
||||
*/
|
||||
async getCollapseSeriesBooksToExclude(bookFindOptions, seriesWhere) {
|
||||
async getCollapseSeriesBooksToExclude(bookFindOptions, seriesWhere, replacements = {}) {
|
||||
const allSeries = await Database.seriesModel.findAll({
|
||||
attributes: ['id', 'name', [Sequelize.literal('(SELECT count(*) FROM bookSeries bs WHERE bs.seriesId = series.id)'), 'numBooks']],
|
||||
distinct: true,
|
||||
subQuery: false,
|
||||
replacements,
|
||||
where: seriesWhere,
|
||||
include: [
|
||||
{
|
||||
@@ -581,7 +582,7 @@ module.exports = {
|
||||
...bookIncludes
|
||||
]
|
||||
}
|
||||
const { booksToExclude, bookSeriesToInclude } = await this.getCollapseSeriesBooksToExclude(bookFindOptions, seriesWhere)
|
||||
const { booksToExclude, bookSeriesToInclude } = await this.getCollapseSeriesBooksToExclude(bookFindOptions, seriesWhere, replacements)
|
||||
if (booksToExclude.length) {
|
||||
bookWhere.push({
|
||||
id: {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Whether the request was made over HTTPS.
|
||||
* Uses Express `req.secure` and `x-forwarded-proto`
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isRequestSecure(req) {
|
||||
if (req.secure) return true
|
||||
const xfp = (req.get('x-forwarded-proto') || '').toLowerCase()
|
||||
// Nginx Proxy Manager sends "http, https"; see https://github.com/advplyr/audiobookshelf/pull/4635
|
||||
return (
|
||||
xfp === 'https' ||
|
||||
xfp
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.includes('https')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('express').Request} req
|
||||
* @returns {'https' | 'http'}
|
||||
*/
|
||||
function getRequestProtocol(req) {
|
||||
return isRequestSecure(req) ? 'https' : 'http'
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('express').Request} req
|
||||
* @returns {{ protocol: 'https' | 'http', host: string, origin: string }}
|
||||
*/
|
||||
function getRequestOrigin(req) {
|
||||
const protocol = getRequestProtocol(req)
|
||||
const host = req.get('host')
|
||||
return { protocol, host, origin: `${protocol}://${host}` }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isRequestSecure,
|
||||
getRequestProtocol,
|
||||
getRequestOrigin
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
const { expect } = require('chai')
|
||||
const sinon = require('sinon')
|
||||
|
||||
// Load Database first so Auth resolves OidcAuthStrategy before the circular require completes.
|
||||
require('../../../server/Database')
|
||||
const OidcAuthStrategy = require('../../../server/auth/OidcAuthStrategy')
|
||||
const Logger = require('../../../server/Logger')
|
||||
|
||||
describe('OidcAuthStrategy - isValidWebCallbackUrl', () => {
|
||||
/** @type {OidcAuthStrategy} */
|
||||
let strategy
|
||||
|
||||
beforeEach(() => {
|
||||
global.RouterBasePath = ''
|
||||
strategy = new OidcAuthStrategy()
|
||||
sinon.stub(Logger, 'warn')
|
||||
sinon.stub(Logger, 'error')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
function mockReq({ secure = false, host = 'books.example.com', xForwardedProto = null } = {}) {
|
||||
return {
|
||||
secure,
|
||||
get(header) {
|
||||
if (header === 'host') return host
|
||||
if (header === 'x-forwarded-proto') return xForwardedProto
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('accepts a same-origin relative path when router base path is empty', () => {
|
||||
expect(strategy.isValidWebCallbackUrl('/library', mockReq())).to.equal(true)
|
||||
})
|
||||
|
||||
it('accepts a same-origin absolute https URL', () => {
|
||||
const req = mockReq({ secure: true })
|
||||
expect(strategy.isValidWebCallbackUrl('https://books.example.com/library', req)).to.equal(true)
|
||||
})
|
||||
|
||||
it('rejects protocol-relative URLs', () => {
|
||||
expect(strategy.isValidWebCallbackUrl('//evil.example/capture', mockReq())).to.equal(false)
|
||||
})
|
||||
|
||||
it('rejects backslash-prefixed URLs', () => {
|
||||
expect(strategy.isValidWebCallbackUrl('/\\evil.example/capture', mockReq())).to.equal(false)
|
||||
})
|
||||
|
||||
it('rejects absolute external URLs', () => {
|
||||
expect(strategy.isValidWebCallbackUrl('http://evil.example/capture', mockReq())).to.equal(false)
|
||||
})
|
||||
|
||||
it('rejects encoded protocol-relative path segments', () => {
|
||||
expect(strategy.isValidWebCallbackUrl('/%2F%2Fevil.example/capture', mockReq())).to.equal(false)
|
||||
})
|
||||
|
||||
it('rejects same-origin URLs outside router base path', () => {
|
||||
global.RouterBasePath = '/audiobookshelf'
|
||||
expect(strategy.isValidWebCallbackUrl('/login', mockReq())).to.equal(false)
|
||||
expect(strategy.isValidWebCallbackUrl('/audiobookshelf/login', mockReq())).to.equal(true)
|
||||
})
|
||||
|
||||
it('rejects empty and malformed callback URLs', () => {
|
||||
expect(strategy.isValidWebCallbackUrl('', mockReq())).to.equal(false)
|
||||
expect(strategy.isValidWebCallbackUrl(null, mockReq())).to.equal(false)
|
||||
expect(strategy.isValidWebCallbackUrl('not a url', mockReq())).to.equal(false)
|
||||
})
|
||||
|
||||
it('uses x-forwarded-proto when determining same-origin https URLs', () => {
|
||||
const req = mockReq({ xForwardedProto: 'https' })
|
||||
expect(strategy.isValidWebCallbackUrl('https://books.example.com/login', req)).to.equal(true)
|
||||
expect(strategy.isValidWebCallbackUrl('http://books.example.com/login', req)).to.equal(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
const { expect } = require('chai')
|
||||
const sinon = require('sinon')
|
||||
const { Op } = require('sequelize')
|
||||
|
||||
const Database = require('../../../server/Database')
|
||||
const jwt = require('../../../server/libs/jsonwebtoken')
|
||||
|
||||
// Database → Auth → TokenManager circular require can leave TokenManager with a partial Database reference; reload before each test
|
||||
function loadTokenManager() {
|
||||
delete require.cache[require.resolve('../../../server/auth/TokenManager')]
|
||||
return require('../../../server/auth/TokenManager')
|
||||
}
|
||||
|
||||
describe('TokenManager', () => {
|
||||
const secret = 'test-jwt-secret'
|
||||
const userId = 'user-uuid-1'
|
||||
let TokenManager
|
||||
let tokenManager
|
||||
|
||||
beforeEach(() => {
|
||||
TokenManager = loadTokenManager()
|
||||
TokenManager.TokenSecret = secret
|
||||
tokenManager = new TokenManager()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
describe('validateAccessToken', () => {
|
||||
it('rejects refresh tokens', () => {
|
||||
const refreshToken = jwt.sign({ userId, type: 'refresh' }, secret, { expiresIn: 3600 })
|
||||
expect(TokenManager.validateAccessToken(refreshToken)).to.equal(null)
|
||||
})
|
||||
|
||||
it('accepts access tokens', () => {
|
||||
const accessToken = jwt.sign({ userId, type: 'access' }, secret, { expiresIn: 3600 })
|
||||
const decoded = TokenManager.validateAccessToken(accessToken)
|
||||
expect(decoded.userId).to.equal(userId)
|
||||
expect(decoded.type).to.equal('access')
|
||||
})
|
||||
})
|
||||
|
||||
describe('jwtAuthCheck', () => {
|
||||
const user = { id: userId, username: 'testuser', isActive: true }
|
||||
|
||||
it('rejects refresh tokens for API auth', async () => {
|
||||
const refreshToken = tokenManager.generateRefreshToken(user)
|
||||
const decoded = jwt.verify(refreshToken, secret)
|
||||
const done = sinon.spy()
|
||||
|
||||
await tokenManager.jwtAuthCheck(decoded, done)
|
||||
|
||||
expect(done.calledWith(null, null)).to.be.true
|
||||
})
|
||||
|
||||
it('allows access tokens for active users', async () => {
|
||||
sinon.stub(Database, 'userModel').get(() => ({
|
||||
getUserByIdOrOldId: sinon.stub().resolves(user)
|
||||
}))
|
||||
const decoded = jwt.verify(tokenManager.generateTempAccessToken(user), secret)
|
||||
const done = sinon.spy()
|
||||
|
||||
await tokenManager.jwtAuthCheck(decoded, done)
|
||||
|
||||
expect(done.calledWith(null, user)).to.be.true
|
||||
})
|
||||
})
|
||||
|
||||
describe('invalidateJwtSessionsForUser', () => {
|
||||
const targetUser = { id: userId, username: 'testuser' }
|
||||
const currentSession = {
|
||||
id: 'session-current',
|
||||
userId,
|
||||
refreshToken: 'refresh-current'
|
||||
}
|
||||
|
||||
/** Minimal req/res for session invalidation (ApiRouter provides auth on real requests). */
|
||||
function makeReq({ requestUserId = userId, refreshToken = null, cookieRefreshToken = null } = {}) {
|
||||
return {
|
||||
user: { id: requestUserId },
|
||||
cookies: cookieRefreshToken ? { refresh_token: cookieRefreshToken } : {},
|
||||
headers: refreshToken ? { 'x-refresh-token': refreshToken } : {}
|
||||
}
|
||||
}
|
||||
|
||||
let sessionFindOne
|
||||
let sessionDestroy
|
||||
let rotateStub
|
||||
|
||||
beforeEach(() => {
|
||||
sessionFindOne = sinon.stub().resolves(currentSession)
|
||||
sessionDestroy = sinon.stub().resolves(1)
|
||||
sinon.stub(Database, 'sessionModel').get(() => ({
|
||||
findOne: sessionFindOne,
|
||||
destroy: sessionDestroy
|
||||
}))
|
||||
rotateStub = sinon.stub(tokenManager, 'rotateTokensForSession').resolves({
|
||||
accessToken: 'access-new',
|
||||
refreshToken: 'refresh-new'
|
||||
})
|
||||
})
|
||||
|
||||
it('self password change: keeps current session, deletes others', async () => {
|
||||
const req = makeReq({ refreshToken: 'refresh-current' })
|
||||
const res = { cookie: sinon.spy() }
|
||||
|
||||
const result = await tokenManager.invalidateJwtSessionsForUser(targetUser, req, res)
|
||||
|
||||
// Found this device's session using the x-refresh-token header
|
||||
expect(sessionFindOne.calledOnce).to.be.true
|
||||
const findWhere = sessionFindOne.firstCall.args[0].where
|
||||
expect(findWhere.userId).to.equal(userId)
|
||||
expect(findWhere[Op.or]).to.deep.equal([{ refreshToken: 'refresh-current' }, { lastRefreshToken: 'refresh-current' }])
|
||||
|
||||
// Rotated in place (no grace period) so the caller keeps a valid session
|
||||
expect(rotateStub.calledOnceWith(currentSession, targetUser, req, res, false)).to.be.true
|
||||
|
||||
// Deleted all other sessions, but not this one
|
||||
expect(sessionDestroy.calledOnce).to.be.true
|
||||
const destroyWhere = sessionDestroy.firstCall.args[0].where
|
||||
expect(destroyWhere.userId).to.equal(userId)
|
||||
expect(destroyWhere.id[Op.ne]).to.equal(currentSession.id)
|
||||
|
||||
expect(result).to.deep.equal({ accessToken: 'access-new', refreshToken: 'refresh-new' })
|
||||
})
|
||||
|
||||
it('admin password reset: deletes all target sessions', async () => {
|
||||
const req = makeReq({ requestUserId: 'admin-id', refreshToken: 'refresh-current' })
|
||||
const res = { cookie: sinon.spy() }
|
||||
|
||||
const result = await tokenManager.invalidateJwtSessionsForUser(targetUser, req, res)
|
||||
|
||||
// Token rotation did not happen because target is a different user
|
||||
expect(sessionFindOne.called).to.be.false
|
||||
expect(rotateStub.called).to.be.false
|
||||
|
||||
expect(sessionDestroy.calledOnceWith(sinon.match({ where: { userId } }))).to.be.true
|
||||
expect(result).to.equal(null)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,178 @@
|
||||
const { expect } = require('chai')
|
||||
const { Sequelize } = require('sequelize')
|
||||
const sinon = require('sinon')
|
||||
|
||||
const Database = require('../../../server/Database')
|
||||
const LibraryController = require('../../../server/controllers/LibraryController')
|
||||
const zipHelpers = require('../../../server/utils/zipHelpers')
|
||||
const Logger = require('../../../server/Logger')
|
||||
|
||||
describe('LibraryController.downloadMultiple', () => {
|
||||
let library
|
||||
let libraryFolder
|
||||
let allowedItemId
|
||||
let explicitItemId
|
||||
let taggedItemId
|
||||
let restrictedUser
|
||||
let libraryRecord
|
||||
|
||||
beforeEach(async () => {
|
||||
global.ServerSettings = {}
|
||||
Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
|
||||
Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '')
|
||||
await Database.buildModels()
|
||||
|
||||
library = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' })
|
||||
libraryFolder = await Database.libraryFolderModel.create({ path: '/test-lib', libraryId: library.id })
|
||||
libraryRecord = await Database.libraryModel.findByIdWithFolders(library.id)
|
||||
|
||||
const allowedBook = await Database.bookModel.create({
|
||||
title: 'Allowed Book',
|
||||
explicit: false,
|
||||
audioFiles: [],
|
||||
tags: ['allowed-tag'],
|
||||
narrators: [],
|
||||
genres: [],
|
||||
chapters: []
|
||||
})
|
||||
const allowedItem = await Database.libraryItemModel.create({
|
||||
path: '/test-lib/allowed',
|
||||
isFile: false,
|
||||
libraryFiles: [],
|
||||
mediaId: allowedBook.id,
|
||||
mediaType: 'book',
|
||||
libraryId: library.id,
|
||||
libraryFolderId: libraryFolder.id
|
||||
})
|
||||
allowedItemId = allowedItem.id
|
||||
|
||||
const explicitBook = await Database.bookModel.create({
|
||||
title: 'Explicit Book',
|
||||
explicit: true,
|
||||
audioFiles: [],
|
||||
tags: [],
|
||||
narrators: [],
|
||||
genres: [],
|
||||
chapters: []
|
||||
})
|
||||
const explicitItem = await Database.libraryItemModel.create({
|
||||
path: '/test-lib/explicit',
|
||||
isFile: false,
|
||||
libraryFiles: [],
|
||||
mediaId: explicitBook.id,
|
||||
mediaType: 'book',
|
||||
libraryId: library.id,
|
||||
libraryFolderId: libraryFolder.id
|
||||
})
|
||||
explicitItemId = explicitItem.id
|
||||
|
||||
const taggedBook = await Database.bookModel.create({
|
||||
title: 'Tagged Book',
|
||||
explicit: false,
|
||||
audioFiles: [],
|
||||
tags: ['restricted-tag'],
|
||||
narrators: [],
|
||||
genres: [],
|
||||
chapters: []
|
||||
})
|
||||
const taggedItem = await Database.libraryItemModel.create({
|
||||
path: '/test-lib/tagged',
|
||||
isFile: false,
|
||||
libraryFiles: [],
|
||||
mediaId: taggedBook.id,
|
||||
mediaType: 'book',
|
||||
libraryId: library.id,
|
||||
libraryFolderId: libraryFolder.id
|
||||
})
|
||||
taggedItemId = taggedItem.id
|
||||
|
||||
const permissions = Database.userModel.getDefaultPermissionsForUserType('user')
|
||||
permissions.download = true
|
||||
permissions.accessExplicitContent = false
|
||||
permissions.accessAllLibraries = false
|
||||
permissions.accessAllTags = false
|
||||
permissions.librariesAccessible = [library.id]
|
||||
permissions.itemTagsSelected = ['allowed-tag']
|
||||
permissions.selectedTagsNotAccessible = false
|
||||
|
||||
restrictedUser = await Database.userModel.create({
|
||||
username: 'restricted',
|
||||
pash: 'hash',
|
||||
token: 'token',
|
||||
type: 'user',
|
||||
isActive: true,
|
||||
permissions,
|
||||
bookmarks: [],
|
||||
extraData: {}
|
||||
})
|
||||
|
||||
sinon.stub(Logger, 'info')
|
||||
sinon.stub(Logger, 'warn')
|
||||
sinon.stub(Logger, 'error')
|
||||
sinon.stub(zipHelpers, 'zipDirectoriesPipe').resolves()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
sinon.restore()
|
||||
await Database.sequelize.sync({ force: true })
|
||||
})
|
||||
|
||||
function makeReq(ids) {
|
||||
return {
|
||||
query: { ids: ids.join(',') },
|
||||
user: restrictedUser,
|
||||
library: libraryRecord
|
||||
}
|
||||
}
|
||||
|
||||
function makeRes() {
|
||||
return {
|
||||
sendStatus: sinon.spy(),
|
||||
status: sinon.stub().returnsThis(),
|
||||
send: sinon.spy()
|
||||
}
|
||||
}
|
||||
|
||||
it('returns 403 for bulk download of an explicit item', async () => {
|
||||
const req = makeReq([explicitItemId])
|
||||
const res = makeRes()
|
||||
|
||||
await LibraryController.downloadMultiple(req, res)
|
||||
|
||||
expect(res.sendStatus.calledWith(403)).to.be.true
|
||||
expect(zipHelpers.zipDirectoriesPipe.called).to.be.false
|
||||
})
|
||||
|
||||
it('returns 403 for bulk download of a tag-restricted item', async () => {
|
||||
const req = makeReq([taggedItemId])
|
||||
const res = makeRes()
|
||||
|
||||
await LibraryController.downloadMultiple(req, res)
|
||||
|
||||
expect(res.sendStatus.calledWith(403)).to.be.true
|
||||
expect(zipHelpers.zipDirectoriesPipe.called).to.be.false
|
||||
})
|
||||
|
||||
it('returns 403 when bulk download includes both allowed and forbidden items', async () => {
|
||||
const req = makeReq([allowedItemId, explicitItemId])
|
||||
const res = makeRes()
|
||||
|
||||
await LibraryController.downloadMultiple(req, res)
|
||||
|
||||
expect(res.sendStatus.calledWith(403)).to.be.true
|
||||
expect(zipHelpers.zipDirectoriesPipe.called).to.be.false
|
||||
})
|
||||
|
||||
it('starts zip download for allowed items only', async () => {
|
||||
const req = makeReq([allowedItemId])
|
||||
const res = makeRes()
|
||||
|
||||
await LibraryController.downloadMultiple(req, res)
|
||||
|
||||
expect(res.sendStatus.called).to.be.false
|
||||
expect(zipHelpers.zipDirectoriesPipe.calledOnce).to.be.true
|
||||
const pathObjects = zipHelpers.zipDirectoriesPipe.firstCall.args[0]
|
||||
expect(pathObjects).to.have.length(1)
|
||||
expect(pathObjects[0].path).to.equal('/test-lib/allowed')
|
||||
})
|
||||
})
|
||||
@@ -1,636 +0,0 @@
|
||||
const { expect } = require('chai')
|
||||
const { Sequelize } = require('sequelize')
|
||||
const sinon = require('sinon')
|
||||
|
||||
const Database = require('../../../server/Database')
|
||||
const ApiRouter = require('../../../server/routers/ApiRouter')
|
||||
const MeController = require('../../../server/controllers/MeController')
|
||||
const Auth = require('../../../server/Auth')
|
||||
const Logger = require('../../../server/Logger')
|
||||
const SocketAuthority = require('../../../server/SocketAuthority')
|
||||
|
||||
describe('MeController - IDOR Security Tests', () => {
|
||||
/** @type {ApiRouter} */
|
||||
let apiRouter
|
||||
|
||||
beforeEach(async () => {
|
||||
global.ServerSettings = {}
|
||||
Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
|
||||
Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '')
|
||||
await Database.buildModels()
|
||||
|
||||
// Create mock server object with required dependencies
|
||||
const mockServer = {
|
||||
auth: new Auth(),
|
||||
playbackSessionManager: { sessions: [] },
|
||||
abMergeManager: {},
|
||||
backupManager: {},
|
||||
podcastManager: {},
|
||||
audioMetadataManager: {},
|
||||
cronManager: {},
|
||||
emailManager: {},
|
||||
apiCacheManager: { middleware: (req, res, next) => next() }
|
||||
}
|
||||
|
||||
apiRouter = new ApiRouter(mockServer)
|
||||
|
||||
sinon.stub(Logger, 'info')
|
||||
sinon.stub(Logger, 'error')
|
||||
sinon.stub(SocketAuthority, 'clientEmitter')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
sinon.restore()
|
||||
|
||||
// Clear all tables
|
||||
await Database.sequelize.sync({ force: true })
|
||||
})
|
||||
|
||||
describe('removeMediaProgress - IDOR Protection', () => {
|
||||
let user1, user2
|
||||
let mediaProgress1, mediaProgress2
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create two users
|
||||
user1 = await Database.userModel.create({
|
||||
username: 'user1',
|
||||
pash: 'hashed_password_1',
|
||||
type: 'user',
|
||||
isActive: true
|
||||
})
|
||||
|
||||
user2 = await Database.userModel.create({
|
||||
username: 'user2',
|
||||
pash: 'hashed_password_2',
|
||||
type: 'user',
|
||||
isActive: true
|
||||
})
|
||||
|
||||
// Create library and book
|
||||
const library = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' })
|
||||
const libraryFolder = await Database.libraryFolderModel.create({ path: '/test', libraryId: library.id })
|
||||
const book = await Database.bookModel.create({ title: 'Test Book', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
|
||||
const libraryItem = await Database.libraryItemModel.create({
|
||||
libraryFiles: [],
|
||||
mediaId: book.id,
|
||||
mediaType: 'book',
|
||||
libraryId: library.id,
|
||||
libraryFolderId: libraryFolder.id
|
||||
})
|
||||
|
||||
// Create media progress for each user
|
||||
mediaProgress1 = await Database.mediaProgressModel.create({
|
||||
userId: user1.id,
|
||||
mediaItemId: book.id,
|
||||
mediaItemType: 'book',
|
||||
duration: 1000,
|
||||
currentTime: 500,
|
||||
isFinished: false
|
||||
})
|
||||
|
||||
mediaProgress2 = await Database.mediaProgressModel.create({
|
||||
userId: user2.id,
|
||||
mediaItemId: book.id,
|
||||
mediaItemType: 'book',
|
||||
duration: 1000,
|
||||
currentTime: 300,
|
||||
isFinished: false
|
||||
})
|
||||
|
||||
// Load media progresses into users
|
||||
user1.mediaProgresses = await user1.getMediaProgresses()
|
||||
user2.mediaProgresses = await user2.getMediaProgresses()
|
||||
})
|
||||
|
||||
it('should allow user to delete their own media progress', async () => {
|
||||
const fakeReq = {
|
||||
user: user1,
|
||||
params: { id: mediaProgress1.id }
|
||||
}
|
||||
const fakeRes = {
|
||||
sendStatus: sinon.spy(),
|
||||
status: sinon.stub().returnsThis(),
|
||||
send: sinon.spy()
|
||||
}
|
||||
|
||||
await MeController.removeMediaProgress(fakeReq, fakeRes)
|
||||
|
||||
expect(fakeRes.sendStatus.calledWith(200)).to.be.true
|
||||
|
||||
// Verify media progress was deleted
|
||||
const deletedProgress = await Database.mediaProgressModel.findByPk(mediaProgress1.id)
|
||||
expect(deletedProgress).to.be.null
|
||||
})
|
||||
|
||||
it('should prevent user from deleting another users media progress (IDOR)', async () => {
|
||||
const fakeReq = {
|
||||
user: user1,
|
||||
params: { id: mediaProgress2.id } // Trying to delete user2's progress
|
||||
}
|
||||
const fakeRes = {
|
||||
sendStatus: sinon.spy(),
|
||||
status: sinon.stub().returnsThis(),
|
||||
send: sinon.spy()
|
||||
}
|
||||
|
||||
await MeController.removeMediaProgress(fakeReq, fakeRes)
|
||||
|
||||
expect(fakeRes.sendStatus.calledWith(404)).to.be.true
|
||||
|
||||
// Verify media progress was NOT deleted
|
||||
const existingProgress = await Database.mediaProgressModel.findByPk(mediaProgress2.id)
|
||||
expect(existingProgress).to.not.be.null
|
||||
expect(existingProgress.userId).to.equal(user2.id)
|
||||
})
|
||||
|
||||
it('should return 404 for non-existent media progress', async () => {
|
||||
const fakeReq = {
|
||||
user: user1,
|
||||
params: { id: 'non-existent-id' }
|
||||
}
|
||||
const fakeRes = {
|
||||
sendStatus: sinon.spy(),
|
||||
status: sinon.stub().returnsThis(),
|
||||
send: sinon.spy()
|
||||
}
|
||||
|
||||
await MeController.removeMediaProgress(fakeReq, fakeRes)
|
||||
|
||||
expect(fakeRes.sendStatus.calledWith(404)).to.be.true
|
||||
})
|
||||
})
|
||||
|
||||
describe('Bookmark Operations - Authorization Checks', () => {
|
||||
let user1, user2
|
||||
let library1, library2
|
||||
let libraryItem1, libraryItem2
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create two users with different library access
|
||||
user1 = await Database.userModel.create({
|
||||
username: 'user1',
|
||||
pash: 'hashed_password_1',
|
||||
type: 'user',
|
||||
isActive: true,
|
||||
librariesAccessible: null // Access to all libraries
|
||||
})
|
||||
|
||||
user2 = await Database.userModel.create({
|
||||
username: 'user2',
|
||||
pash: 'hashed_password_2',
|
||||
type: 'user',
|
||||
isActive: true,
|
||||
librariesAccessible: [] // Will be set to specific library
|
||||
})
|
||||
|
||||
// Create two libraries
|
||||
library1 = await Database.libraryModel.create({ name: 'Library 1', mediaType: 'book' })
|
||||
library2 = await Database.libraryModel.create({ name: 'Library 2', mediaType: 'book' })
|
||||
|
||||
// User2 only has access to library1
|
||||
user2.librariesAccessible = [library1.id]
|
||||
await user2.save()
|
||||
|
||||
const libraryFolder1 = await Database.libraryFolderModel.create({ path: '/test1', libraryId: library1.id })
|
||||
const libraryFolder2 = await Database.libraryFolderModel.create({ path: '/test2', libraryId: library2.id })
|
||||
|
||||
const book1 = await Database.bookModel.create({ title: 'Book 1', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
|
||||
const book2 = await Database.bookModel.create({ title: 'Book 2', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
|
||||
|
||||
libraryItem1 = await Database.libraryItemModel.create({
|
||||
libraryFiles: [],
|
||||
mediaId: book1.id,
|
||||
mediaType: 'book',
|
||||
libraryId: library1.id,
|
||||
libraryFolderId: libraryFolder1.id
|
||||
})
|
||||
|
||||
libraryItem2 = await Database.libraryItemModel.create({
|
||||
libraryFiles: [],
|
||||
mediaId: book2.id,
|
||||
mediaType: 'book',
|
||||
libraryId: library2.id,
|
||||
libraryFolderId: libraryFolder2.id
|
||||
})
|
||||
|
||||
// Initialize bookmarks
|
||||
user1.bookmarks = []
|
||||
user2.bookmarks = []
|
||||
})
|
||||
|
||||
describe('createBookmark', () => {
|
||||
it('should allow user to create bookmark for accessible library item', async () => {
|
||||
const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem1.id)
|
||||
|
||||
const bookmark = { libraryItemId: libraryItem1.id, time: 100, title: 'Test Bookmark', createdAt: Date.now() }
|
||||
|
||||
const fakeReq = {
|
||||
user: {
|
||||
...user2.toJSON(),
|
||||
id: user2.id,
|
||||
username: user2.username,
|
||||
checkCanAccessLibraryItem: () => true,
|
||||
createBookmark: sinon.stub().resolves(bookmark),
|
||||
toOldJSONForBrowser: () => ({ id: user2.id, username: user2.username })
|
||||
},
|
||||
params: { id: libraryItem1.id },
|
||||
body: { time: 100, title: 'Test Bookmark' }
|
||||
}
|
||||
const fakeRes = {
|
||||
sendStatus: sinon.spy(),
|
||||
status: sinon.stub().returnsThis(),
|
||||
send: sinon.spy(),
|
||||
json: sinon.spy()
|
||||
}
|
||||
|
||||
sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem)
|
||||
|
||||
await MeController.createBookmark(fakeReq, fakeRes)
|
||||
|
||||
expect(fakeRes.json.calledOnce).to.be.true
|
||||
expect(fakeRes.json.calledWith(bookmark)).to.be.true
|
||||
|
||||
Database.libraryItemModel.getExpandedById.restore()
|
||||
})
|
||||
|
||||
it('should prevent user from creating bookmark for inaccessible library item (IDOR)', async () => {
|
||||
const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem2.id)
|
||||
|
||||
const fakeReq = {
|
||||
user: user2, // user2 doesn't have access to library2
|
||||
params: { id: libraryItem2.id },
|
||||
body: { time: 100, title: 'Test Bookmark' }
|
||||
}
|
||||
const fakeRes = {
|
||||
sendStatus: sinon.spy(),
|
||||
status: sinon.stub().returnsThis(),
|
||||
send: sinon.spy(),
|
||||
json: sinon.spy()
|
||||
}
|
||||
|
||||
// Mock getExpandedById
|
||||
sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem)
|
||||
|
||||
await MeController.createBookmark(fakeReq, fakeRes)
|
||||
|
||||
expect(fakeRes.sendStatus.calledWith(403)).to.be.true
|
||||
expect(fakeRes.json.called).to.be.false
|
||||
|
||||
Database.libraryItemModel.getExpandedById.restore()
|
||||
})
|
||||
|
||||
it('should return 404 for non-existent library item', async () => {
|
||||
const fakeReq = {
|
||||
user: user1,
|
||||
params: { id: 'non-existent-id' },
|
||||
body: { time: 100, title: 'Test Bookmark' }
|
||||
}
|
||||
const fakeRes = {
|
||||
sendStatus: sinon.spy(),
|
||||
status: sinon.stub().returnsThis(),
|
||||
send: sinon.spy(),
|
||||
json: sinon.spy()
|
||||
}
|
||||
|
||||
// Mock getExpandedById to return null
|
||||
sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(null)
|
||||
|
||||
await MeController.createBookmark(fakeReq, fakeRes)
|
||||
|
||||
expect(fakeRes.sendStatus.calledWith(404)).to.be.true
|
||||
|
||||
Database.libraryItemModel.getExpandedById.restore()
|
||||
})
|
||||
|
||||
it('should validate bookmark time parameter', async () => {
|
||||
const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem1.id)
|
||||
|
||||
const fakeReq = {
|
||||
user: {
|
||||
...user1.toJSON(),
|
||||
id: user1.id,
|
||||
username: user1.username,
|
||||
checkCanAccessLibraryItem: () => true
|
||||
},
|
||||
params: { id: libraryItem1.id },
|
||||
body: { time: null, title: 'Test Bookmark' } // null time is invalid
|
||||
}
|
||||
const fakeRes = {
|
||||
sendStatus: sinon.spy(),
|
||||
status: sinon.stub().returnsThis(),
|
||||
send: sinon.spy(),
|
||||
json: sinon.spy()
|
||||
}
|
||||
|
||||
sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem)
|
||||
|
||||
await MeController.createBookmark(fakeReq, fakeRes)
|
||||
|
||||
expect(fakeRes.status.calledWith(400)).to.be.true
|
||||
expect(fakeRes.send.calledWith('Invalid time')).to.be.true
|
||||
|
||||
Database.libraryItemModel.getExpandedById.restore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateBookmark', () => {
|
||||
beforeEach(async () => {
|
||||
// Add existing bookmark to user1
|
||||
user1.bookmarks = [{ libraryItemId: libraryItem1.id, time: 100, title: 'Original Title' }]
|
||||
await user1.save()
|
||||
})
|
||||
|
||||
it('should allow user to update bookmark for accessible library item', async () => {
|
||||
const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem1.id)
|
||||
|
||||
const bookmark = { libraryItemId: libraryItem1.id, time: 100, title: 'Updated Title' }
|
||||
|
||||
const fakeReq = {
|
||||
user: {
|
||||
...user1.toJSON(),
|
||||
id: user1.id,
|
||||
username: user1.username,
|
||||
checkCanAccessLibraryItem: () => true,
|
||||
updateBookmark: sinon.stub().resolves(bookmark),
|
||||
toOldJSONForBrowser: () => ({ id: user1.id, username: user1.username })
|
||||
},
|
||||
params: { id: libraryItem1.id },
|
||||
body: { time: 100, title: 'Updated Title' }
|
||||
}
|
||||
const fakeRes = {
|
||||
sendStatus: sinon.spy(),
|
||||
status: sinon.stub().returnsThis(),
|
||||
send: sinon.spy(),
|
||||
json: sinon.spy()
|
||||
}
|
||||
|
||||
sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem)
|
||||
|
||||
await MeController.updateBookmark(fakeReq, fakeRes)
|
||||
|
||||
expect(fakeRes.json.calledOnce).to.be.true
|
||||
expect(fakeRes.json.calledWith(bookmark)).to.be.true
|
||||
|
||||
Database.libraryItemModel.getExpandedById.restore()
|
||||
})
|
||||
|
||||
it('should prevent user from updating bookmark for inaccessible library item (IDOR)', async () => {
|
||||
const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem2.id)
|
||||
|
||||
const fakeReq = {
|
||||
user: user2, // user2 doesn't have access to library2
|
||||
params: { id: libraryItem2.id },
|
||||
body: { time: 100, title: 'Updated Title' }
|
||||
}
|
||||
const fakeRes = {
|
||||
sendStatus: sinon.spy(),
|
||||
status: sinon.stub().returnsThis(),
|
||||
send: sinon.spy(),
|
||||
json: sinon.spy()
|
||||
}
|
||||
|
||||
sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem)
|
||||
|
||||
await MeController.updateBookmark(fakeReq, fakeRes)
|
||||
|
||||
expect(fakeRes.sendStatus.calledWith(403)).to.be.true
|
||||
|
||||
Database.libraryItemModel.getExpandedById.restore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeBookmark', () => {
|
||||
beforeEach(async () => {
|
||||
// Add existing bookmark to user1
|
||||
user1.bookmarks = [{ libraryItemId: libraryItem1.id, time: 100, title: 'Test Bookmark' }]
|
||||
await user1.save()
|
||||
})
|
||||
|
||||
it('should allow user to remove bookmark for accessible library item', async () => {
|
||||
const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem1.id)
|
||||
|
||||
const fakeReq = {
|
||||
user: {
|
||||
...user1.toJSON(),
|
||||
id: user1.id,
|
||||
username: user1.username,
|
||||
checkCanAccessLibraryItem: () => true,
|
||||
findBookmark: sinon.stub().returns({ libraryItemId: libraryItem1.id, time: 100, title: 'Test Bookmark' }),
|
||||
removeBookmark: sinon.stub().resolves(true),
|
||||
toOldJSONForBrowser: () => ({ id: user1.id, username: user1.username })
|
||||
},
|
||||
params: { id: libraryItem1.id, time: '100' }
|
||||
}
|
||||
const fakeRes = {
|
||||
sendStatus: sinon.spy(),
|
||||
status: sinon.stub().returnsThis(),
|
||||
send: sinon.spy()
|
||||
}
|
||||
|
||||
sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem)
|
||||
|
||||
await MeController.removeBookmark(fakeReq, fakeRes)
|
||||
|
||||
expect(fakeRes.sendStatus.calledWith(200)).to.be.true
|
||||
|
||||
Database.libraryItemModel.getExpandedById.restore()
|
||||
})
|
||||
|
||||
it('should prevent user from removing bookmark for inaccessible library item (IDOR)', async () => {
|
||||
const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem2.id)
|
||||
|
||||
const fakeReq = {
|
||||
user: user2, // user2 doesn't have access to library2
|
||||
params: { id: libraryItem2.id, time: '100' }
|
||||
}
|
||||
const fakeRes = {
|
||||
sendStatus: sinon.spy(),
|
||||
status: sinon.stub().returnsThis(),
|
||||
send: sinon.spy()
|
||||
}
|
||||
|
||||
sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem)
|
||||
|
||||
await MeController.removeBookmark(fakeReq, fakeRes)
|
||||
|
||||
expect(fakeRes.sendStatus.calledWith(403)).to.be.true
|
||||
|
||||
Database.libraryItemModel.getExpandedById.restore()
|
||||
})
|
||||
|
||||
it('should validate time parameter is a number', async () => {
|
||||
const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem1.id)
|
||||
|
||||
const fakeReq = {
|
||||
user: {
|
||||
...user1.toJSON(),
|
||||
id: user1.id,
|
||||
username: user1.username,
|
||||
checkCanAccessLibraryItem: () => true
|
||||
},
|
||||
params: { id: libraryItem1.id, time: 'not-a-number' }
|
||||
}
|
||||
const fakeRes = {
|
||||
sendStatus: sinon.spy(),
|
||||
status: sinon.stub().returnsThis(),
|
||||
send: sinon.spy()
|
||||
}
|
||||
|
||||
sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem)
|
||||
|
||||
await MeController.removeBookmark(fakeReq, fakeRes)
|
||||
|
||||
expect(fakeRes.status.calledWith(400)).to.be.true
|
||||
expect(fakeRes.send.calledWith('Invalid time')).to.be.true
|
||||
|
||||
Database.libraryItemModel.getExpandedById.restore()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getItemListeningSessions - Authorization Check', () => {
|
||||
let user1, user2
|
||||
let library1, library2
|
||||
let libraryItem1, libraryItem2
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create two users with different library access
|
||||
user1 = await Database.userModel.create({
|
||||
username: 'user1',
|
||||
pash: 'hashed_password_1',
|
||||
type: 'user',
|
||||
isActive: true,
|
||||
librariesAccessible: null // Access to all libraries
|
||||
})
|
||||
|
||||
user2 = await Database.userModel.create({
|
||||
username: 'user2',
|
||||
pash: 'hashed_password_2',
|
||||
type: 'user',
|
||||
isActive: true,
|
||||
librariesAccessible: [] // Will be set to specific library
|
||||
})
|
||||
|
||||
// Create two libraries
|
||||
library1 = await Database.libraryModel.create({ name: 'Library 1', mediaType: 'book' })
|
||||
library2 = await Database.libraryModel.create({ name: 'Library 2', mediaType: 'book' })
|
||||
|
||||
// User2 only has access to library1
|
||||
user2.librariesAccessible = [library1.id]
|
||||
await user2.save()
|
||||
|
||||
const libraryFolder1 = await Database.libraryFolderModel.create({ path: '/test1', libraryId: library1.id })
|
||||
const libraryFolder2 = await Database.libraryFolderModel.create({ path: '/test2', libraryId: library2.id })
|
||||
|
||||
const book1 = await Database.bookModel.create({ title: 'Book 1', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
|
||||
const book2 = await Database.bookModel.create({ title: 'Book 2', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
|
||||
|
||||
libraryItem1 = await Database.libraryItemModel.create({
|
||||
libraryFiles: [],
|
||||
mediaId: book1.id,
|
||||
mediaType: 'book',
|
||||
libraryId: library1.id,
|
||||
libraryFolderId: libraryFolder1.id
|
||||
})
|
||||
|
||||
libraryItem2 = await Database.libraryItemModel.create({
|
||||
libraryFiles: [],
|
||||
mediaId: book2.id,
|
||||
mediaType: 'book',
|
||||
libraryId: library2.id,
|
||||
libraryFolderId: libraryFolder2.id
|
||||
})
|
||||
})
|
||||
|
||||
it('should allow user to view listening sessions for accessible library item', async () => {
|
||||
const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem1.id)
|
||||
|
||||
// Create mock context with getUserItemListeningSessionsHelper
|
||||
const mockContext = {
|
||||
getUserItemListeningSessionsHelper: sinon.stub().resolves([{ id: 'session1', timeListening: 300, startedAt: Date.now() }])
|
||||
}
|
||||
|
||||
const fakeReq = {
|
||||
user: {
|
||||
...user1.toJSON(),
|
||||
id: user1.id,
|
||||
username: user1.username,
|
||||
checkCanAccessLibraryItem: () => true
|
||||
},
|
||||
params: { libraryItemId: libraryItem1.id },
|
||||
query: {}
|
||||
}
|
||||
const fakeRes = {
|
||||
sendStatus: sinon.spy(),
|
||||
status: sinon.stub().returnsThis(),
|
||||
send: sinon.spy(),
|
||||
json: sinon.spy()
|
||||
}
|
||||
|
||||
sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem)
|
||||
sinon.stub(Database.podcastEpisodeModel, 'findByPk').resolves(null)
|
||||
|
||||
await MeController.getItemListeningSessions.bind(mockContext)(fakeReq, fakeRes)
|
||||
|
||||
expect(fakeRes.json.calledOnce).to.be.true
|
||||
expect(fakeRes.sendStatus.called).to.be.false
|
||||
|
||||
// Verify the payload structure
|
||||
const payload = fakeRes.json.firstCall.args[0]
|
||||
expect(payload).to.have.property('total')
|
||||
expect(payload).to.have.property('sessions')
|
||||
|
||||
Database.libraryItemModel.getExpandedById.restore()
|
||||
Database.podcastEpisodeModel.findByPk.restore()
|
||||
})
|
||||
|
||||
it('should prevent user from viewing listening sessions for inaccessible library item (IDOR)', async () => {
|
||||
const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem2.id)
|
||||
|
||||
const fakeReq = {
|
||||
user: user2, // user2 doesn't have access to library2
|
||||
params: { libraryItemId: libraryItem2.id },
|
||||
query: {}
|
||||
}
|
||||
const fakeRes = {
|
||||
sendStatus: sinon.spy(),
|
||||
status: sinon.stub().returnsThis(),
|
||||
send: sinon.spy(),
|
||||
json: sinon.spy()
|
||||
}
|
||||
|
||||
sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem)
|
||||
sinon.stub(Database.podcastEpisodeModel, 'findByPk').resolves(null)
|
||||
|
||||
await MeController.getItemListeningSessions.bind(apiRouter)(fakeReq, fakeRes)
|
||||
|
||||
expect(fakeRes.sendStatus.calledWith(403)).to.be.true
|
||||
expect(fakeRes.json.called).to.be.false
|
||||
|
||||
Database.libraryItemModel.getExpandedById.restore()
|
||||
Database.podcastEpisodeModel.findByPk.restore()
|
||||
})
|
||||
|
||||
it('should return 404 for non-existent library item', async () => {
|
||||
const fakeReq = {
|
||||
user: user1,
|
||||
params: { libraryItemId: 'non-existent-id' },
|
||||
query: {}
|
||||
}
|
||||
const fakeRes = {
|
||||
sendStatus: sinon.spy(),
|
||||
status: sinon.stub().returnsThis(),
|
||||
send: sinon.spy(),
|
||||
json: sinon.spy()
|
||||
}
|
||||
|
||||
sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(null)
|
||||
|
||||
await MeController.getItemListeningSessions.bind(apiRouter)(fakeReq, fakeRes)
|
||||
|
||||
expect(fakeRes.sendStatus.calledWith(404)).to.be.true
|
||||
|
||||
Database.libraryItemModel.getExpandedById.restore()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
const { expect } = require('chai')
|
||||
const sinon = require('sinon')
|
||||
|
||||
const UserController = require('../../../server/controllers/UserController')
|
||||
const Logger = require('../../../server/Logger')
|
||||
|
||||
describe('UserController - delete', () => {
|
||||
beforeEach(() => {
|
||||
sinon.stub(Logger, 'error')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
it('rejects deleting the root user by UUID', async () => {
|
||||
const rootUser = { id: 'root-uuid', isRoot: true, username: 'root' }
|
||||
const adminUser = { id: 'admin-uuid', isRoot: false, username: 'admin' }
|
||||
const fakeRes = { sendStatus: sinon.spy(), json: sinon.spy() }
|
||||
|
||||
await UserController.delete({ user: adminUser, reqUser: rootUser, params: { id: rootUser.id } }, fakeRes)
|
||||
|
||||
expect(fakeRes.sendStatus.calledWith(403)).to.be.true
|
||||
expect(fakeRes.json.called).to.be.false
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
const { expect } = require('chai')
|
||||
const { Sequelize } = require('sequelize')
|
||||
|
||||
const Database = require('../../../server/Database')
|
||||
|
||||
/**
|
||||
* Assert that `expanded` contains every key from `minified` with matching values.
|
||||
* Used to enforce the contract: toOldJSONExpanded() is a strict superset of toOldJSONMinified().
|
||||
*
|
||||
* @param {object} minified
|
||||
* @param {object} expanded
|
||||
* @param {string} [path]
|
||||
*/
|
||||
function assertExpandedSuperset(minified, expanded, path = '') {
|
||||
for (const key of Object.keys(minified)) {
|
||||
const keyPath = path ? `${path}.${key}` : key
|
||||
expect(expanded, `missing key ${keyPath}`).to.have.property(key)
|
||||
|
||||
const minifiedValue = minified[key]
|
||||
const expandedValue = expanded[key]
|
||||
|
||||
if (minifiedValue !== null && typeof minifiedValue === 'object' && !Array.isArray(minifiedValue)) {
|
||||
assertExpandedSuperset(minifiedValue, expandedValue, keyPath)
|
||||
} else if (Array.isArray(minifiedValue)) {
|
||||
expect(expandedValue, `expected array at ${keyPath}`).to.be.an('array')
|
||||
expect(expandedValue).to.deep.equal(minifiedValue)
|
||||
} else {
|
||||
expect(expandedValue, `value mismatch at ${keyPath}`).to.equal(minifiedValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('old JSON serialization', () => {
|
||||
let bookLibraryItemId
|
||||
let podcastLibraryItemId
|
||||
|
||||
beforeEach(async () => {
|
||||
global.ServerSettings = {}
|
||||
Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
|
||||
Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '')
|
||||
await Database.buildModels()
|
||||
|
||||
const bookLibrary = await Database.libraryModel.create({ name: 'Book Library', mediaType: 'book' })
|
||||
const bookLibraryFolder = await Database.libraryFolderModel.create({ path: '/books', libraryId: bookLibrary.id })
|
||||
|
||||
const book = await Database.bookModel.create({
|
||||
title: 'Test Book',
|
||||
audioFiles: [{ index: 1, ino: '1', metadata: { filename: 'track.mp3', ext: '.mp3', path: '/track.mp3', relPath: 'track.mp3', size: 1000 } }],
|
||||
tags: ['fiction'],
|
||||
narrators: ['Narrator One'],
|
||||
genres: ['Fantasy'],
|
||||
chapters: [{ id: 0, start: 0, end: 100, title: 'Chapter 1' }],
|
||||
ebookFile: { ino: '2', metadata: { filename: 'book.epub', ext: '.epub', path: '/book.epub', relPath: 'book.epub', size: 500 }, ebookFormat: 'epub' }
|
||||
})
|
||||
const bookLibraryItem = await Database.libraryItemModel.create({
|
||||
libraryFiles: [{ ino: '1', metadata: { filename: 'track.mp3', ext: '.mp3', path: '/track.mp3', relPath: 'track.mp3' } }],
|
||||
mediaId: book.id,
|
||||
mediaType: 'book',
|
||||
libraryId: bookLibrary.id,
|
||||
libraryFolderId: bookLibraryFolder.id
|
||||
})
|
||||
bookLibraryItemId = bookLibraryItem.id
|
||||
|
||||
const author = await Database.authorModel.create({ name: 'Test Author', libraryId: bookLibrary.id })
|
||||
await Database.bookAuthorModel.create({ bookId: book.id, authorId: author.id })
|
||||
|
||||
const series = await Database.seriesModel.create({ name: 'Test Series', libraryId: bookLibrary.id })
|
||||
await Database.bookSeriesModel.create({ bookId: book.id, seriesId: series.id, sequence: '2' })
|
||||
|
||||
const podcastLibrary = await Database.libraryModel.create({ name: 'Podcast Library', mediaType: 'podcast' })
|
||||
const podcastLibraryFolder = await Database.libraryFolderModel.create({ path: '/podcasts', libraryId: podcastLibrary.id })
|
||||
|
||||
const podcast = await Database.podcastModel.create({
|
||||
title: 'Test Podcast',
|
||||
tags: ['news'],
|
||||
genres: ['Technology'],
|
||||
autoDownloadEpisodes: false
|
||||
})
|
||||
const podcastLibraryItem = await Database.libraryItemModel.create({
|
||||
libraryFiles: [],
|
||||
mediaId: podcast.id,
|
||||
mediaType: 'podcast',
|
||||
libraryId: podcastLibrary.id,
|
||||
libraryFolderId: podcastLibraryFolder.id
|
||||
})
|
||||
podcastLibraryItemId = podcastLibraryItem.id
|
||||
|
||||
await Database.podcastEpisodeModel.create({
|
||||
podcastId: podcast.id,
|
||||
title: 'Episode 1',
|
||||
index: 1,
|
||||
audioFile: { ino: '3', metadata: { filename: 'ep1.mp3', ext: '.mp3', path: '/ep1.mp3', relPath: 'ep1.mp3' } }
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await Database.sequelize.sync({ force: true })
|
||||
})
|
||||
|
||||
describe('Book', () => {
|
||||
it('toOldJSONExpanded is a strict superset of toOldJSONMinified', async () => {
|
||||
const libraryItem = await Database.libraryItemModel.getExpandedById(bookLibraryItemId)
|
||||
const minified = libraryItem.media.toOldJSONMinified()
|
||||
const expanded = libraryItem.media.toOldJSONExpanded(libraryItem.id)
|
||||
|
||||
assertExpandedSuperset(minified, expanded)
|
||||
|
||||
expect(expanded).to.have.property('libraryItemId', libraryItem.id)
|
||||
expect(expanded).to.have.property('audioFiles')
|
||||
expect(expanded).to.have.property('chapters')
|
||||
expect(expanded).to.have.property('tracks')
|
||||
expect(expanded.numTracks).to.equal(expanded.tracks.length)
|
||||
expect(expanded.numAudioFiles).to.equal(expanded.audioFiles.length)
|
||||
expect(expanded.numChapters).to.equal(expanded.chapters.length)
|
||||
expect(expanded.ebookFormat).to.equal('epub')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Podcast', () => {
|
||||
it('toOldJSONExpanded is a strict superset of toOldJSONMinified', async () => {
|
||||
const libraryItem = await Database.libraryItemModel.getExpandedById(podcastLibraryItemId)
|
||||
const minified = libraryItem.media.toOldJSONMinified()
|
||||
const expanded = libraryItem.media.toOldJSONExpanded(libraryItem.id)
|
||||
|
||||
assertExpandedSuperset(minified, expanded)
|
||||
|
||||
expect(expanded).to.have.property('libraryItemId', libraryItem.id)
|
||||
expect(expanded).to.have.property('episodes')
|
||||
expect(expanded.numEpisodes).to.equal(expanded.episodes.length)
|
||||
})
|
||||
})
|
||||
|
||||
describe('LibraryItem', () => {
|
||||
it('book library item expanded is a strict superset of minified', async () => {
|
||||
const libraryItem = await Database.libraryItemModel.getExpandedById(bookLibraryItemId)
|
||||
const minified = libraryItem.toOldJSONMinified()
|
||||
const expanded = libraryItem.toOldJSONExpanded()
|
||||
|
||||
assertExpandedSuperset(minified, expanded)
|
||||
|
||||
expect(expanded).to.have.property('libraryFiles')
|
||||
expect(expanded).to.have.property('lastScan')
|
||||
expect(expanded.numFiles).to.equal(expanded.libraryFiles.length)
|
||||
expect(expanded.media.numTracks).to.equal(expanded.media.tracks.length)
|
||||
})
|
||||
|
||||
it('podcast library item expanded is a strict superset of minified', async () => {
|
||||
const libraryItem = await Database.libraryItemModel.getExpandedById(podcastLibraryItemId)
|
||||
const minified = libraryItem.toOldJSONMinified()
|
||||
const expanded = libraryItem.toOldJSONExpanded()
|
||||
|
||||
assertExpandedSuperset(minified, expanded)
|
||||
|
||||
expect(expanded).to.have.property('libraryFiles')
|
||||
expect(expanded.media.numEpisodes).to.equal(expanded.media.episodes.length)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
const { expect } = require('chai')
|
||||
|
||||
const { isRequestSecure, getRequestProtocol, getRequestOrigin } = require('../../../server/utils/requestUtils')
|
||||
|
||||
function mockReq({ secure = false, host = 'books.example.com', xForwardedProto = null } = {}) {
|
||||
return {
|
||||
secure,
|
||||
get(header) {
|
||||
if (header === 'host') return host
|
||||
if (header === 'x-forwarded-proto') return xForwardedProto
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('requestUtils', () => {
|
||||
it('isRequestSecure uses req.secure', () => {
|
||||
expect(isRequestSecure(mockReq({ secure: true }))).to.equal(true)
|
||||
expect(isRequestSecure(mockReq({ secure: false }))).to.equal(false)
|
||||
})
|
||||
|
||||
it('isRequestSecure uses x-forwarded-proto', () => {
|
||||
expect(isRequestSecure(mockReq({ xForwardedProto: 'https' }))).to.equal(true)
|
||||
expect(isRequestSecure(mockReq({ xForwardedProto: 'http' }))).to.equal(false)
|
||||
expect(isRequestSecure(mockReq({ xForwardedProto: 'http, https' }))).to.equal(true)
|
||||
})
|
||||
|
||||
it('getRequestProtocol returns https or http', () => {
|
||||
expect(getRequestProtocol(mockReq({ secure: true }))).to.equal('https')
|
||||
expect(getRequestProtocol(mockReq())).to.equal('http')
|
||||
})
|
||||
|
||||
it('getRequestOrigin builds origin from protocol and host', () => {
|
||||
expect(getRequestOrigin(mockReq({ secure: true }))).to.deep.equal({
|
||||
protocol: 'https',
|
||||
host: 'books.example.com',
|
||||
origin: 'https://books.example.com'
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user