mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-06-04 09:50:42 +02:00
Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 890b0b949e | |||
| b19e360bbb | |||
| 1ff7952074 | |||
| 259d93d882 | |||
| 14f60a593b | |||
| 7334580c8c | |||
| f467c44543 | |||
| 867354e59d | |||
| 67952cc577 | |||
| 079a15541c | |||
| 658ac04268 | |||
| cbee6d8f5e | |||
| 68413ae2f6 | |||
| 252a233282 | |||
| c35185fff7 | |||
| 9774b2cfa5 | |||
| 344890fb45 | |||
| 5fa0897ad7 | |||
| 95c80a5b18 | |||
| 0f1b64b883 | |||
| 615ed26f0f | |||
| 84803cef82 | |||
| 605bd73c11 | |||
| cc89db059b | |||
| a03146e09c | |||
| 33aa4f1952 | |||
| c03f18b90a | |||
| 0dedb09a07 | |||
| 2b5484243b | |||
| c496db7c95 | |||
| ea4d5ff665 | |||
| 468a547864 | |||
| cd9999d192 | |||
| 31e302ea59 | |||
| 1ff1ba66fd | |||
| a5457d7e22 | |||
| ddcbfd4500 | |||
| 293e530297 | |||
| 7278ad4ee7 | |||
| 0449fb5ef9 | |||
| d2c28fc69c | |||
| 60ba0163af | |||
| 02ca926d88 | |||
| 4b52f31d58 | |||
| 9917f2d358 | |||
| 8c3ba67583 | |||
| 6d8720b404 | |||
| 843dd0b1b2 | |||
| 70f466d03c | |||
| ef82e8b0d0 | |||
| c643d4cec8 | |||
| 718d8b5999 | |||
| 2ba0f9157d | |||
| 53fdb5273c | |||
| fabdfd5517 | |||
| 950993f652 | |||
| 5a968b002a | |||
| 3acd29fab3 | |||
| 315b21db00 | |||
| f9aaeb3a34 | |||
| d19bb909b3 |
@@ -53,7 +53,6 @@
|
|||||||
@showBookmarks="showBookmarks"
|
@showBookmarks="showBookmarks"
|
||||||
@showSleepTimer="showSleepTimerModal = true"
|
@showSleepTimer="showSleepTimerModal = true"
|
||||||
@showPlayerQueueItems="showPlayerQueueItemsModal = true"
|
@showPlayerQueueItems="showPlayerQueueItemsModal = true"
|
||||||
@showPlayerSettings="showPlayerSettingsModal = true"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :current-time="bookmarkCurrentTime" :library-item-id="libraryItemId" @select="selectBookmark" />
|
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :current-time="bookmarkCurrentTime" :library-item-id="libraryItemId" @select="selectBookmark" />
|
||||||
@@ -61,8 +60,6 @@
|
|||||||
<modals-sleep-timer-modal v-model="showSleepTimerModal" :timer-set="sleepTimerSet" :timer-type="sleepTimerType" :remaining="sleepTimerRemaining" :has-chapters="!!chapters.length" @set="setSleepTimer" @cancel="cancelSleepTimer" @increment="incrementSleepTimer" @decrement="decrementSleepTimer" />
|
<modals-sleep-timer-modal v-model="showSleepTimerModal" :timer-set="sleepTimerSet" :timer-type="sleepTimerType" :remaining="sleepTimerRemaining" :has-chapters="!!chapters.length" @set="setSleepTimer" @cancel="cancelSleepTimer" @increment="incrementSleepTimer" @decrement="decrementSleepTimer" />
|
||||||
|
|
||||||
<modals-player-queue-items-modal v-model="showPlayerQueueItemsModal" />
|
<modals-player-queue-items-modal v-model="showPlayerQueueItemsModal" />
|
||||||
|
|
||||||
<modals-player-settings-modal v-model="showPlayerSettingsModal" />
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -81,7 +78,6 @@ export default {
|
|||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
showSleepTimerModal: false,
|
showSleepTimerModal: false,
|
||||||
showPlayerQueueItemsModal: false,
|
showPlayerQueueItemsModal: false,
|
||||||
showPlayerSettingsModal: false,
|
|
||||||
sleepTimerSet: false,
|
sleepTimerSet: false,
|
||||||
sleepTimerRemaining: 0,
|
sleepTimerRemaining: 0,
|
||||||
sleepTimerType: null,
|
sleepTimerType: null,
|
||||||
|
|||||||
@@ -59,12 +59,19 @@ export default {
|
|||||||
setJumpBackwardAmount(val) {
|
setJumpBackwardAmount(val) {
|
||||||
this.jumpBackwardAmount = val
|
this.jumpBackwardAmount = val
|
||||||
this.$store.dispatch('user/updateUserSettings', { jumpBackwardAmount: val })
|
this.$store.dispatch('user/updateUserSettings', { jumpBackwardAmount: val })
|
||||||
|
},
|
||||||
|
settingsUpdated() {
|
||||||
|
this.useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack')
|
||||||
|
this.jumpForwardAmount = this.$store.getters['user/getUserSetting']('jumpForwardAmount')
|
||||||
|
this.jumpBackwardAmount = this.$store.getters['user/getUserSetting']('jumpBackwardAmount')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack')
|
this.settingsUpdated()
|
||||||
this.jumpForwardAmount = this.$store.getters['user/getUserSetting']('jumpForwardAmount')
|
this.$eventBus.$on('user-settings', this.settingsUpdated)
|
||||||
this.jumpBackwardAmount = this.$store.getters['user/getUserSetting']('jumpBackwardAmount')
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
this.$eventBus.$off('user-settings', this.settingsUpdated)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -18,6 +18,23 @@
|
|||||||
<p dir="auto" class="text-lg font-semibold mb-6">{{ title }}</p>
|
<p dir="auto" class="text-lg font-semibold mb-6">{{ title }}</p>
|
||||||
<div v-if="description" dir="auto" class="default-style" v-html="description" />
|
<div v-if="description" dir="auto" class="default-style" v-html="description" />
|
||||||
<p v-else class="mb-2">{{ $strings.MessageNoDescription }}</p>
|
<p v-else class="mb-2">{{ $strings.MessageNoDescription }}</p>
|
||||||
|
|
||||||
|
<div class="w-full h-px bg-white/5 my-4" />
|
||||||
|
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-grow">
|
||||||
|
<p class="font-semibold text-xs mb-1">{{ $strings.LabelFilename }}</p>
|
||||||
|
<p class="mb-2 text-xs">
|
||||||
|
{{ audioFileFilename }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow">
|
||||||
|
<p class="font-semibold text-xs mb-1">{{ $strings.LabelSize }}</p>
|
||||||
|
<p class="mb-2 text-xs">
|
||||||
|
{{ audioFileSize }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</modals-modal>
|
</modals-modal>
|
||||||
</template>
|
</template>
|
||||||
@@ -54,7 +71,7 @@ export default {
|
|||||||
return this.episode.description || ''
|
return this.episode.description || ''
|
||||||
},
|
},
|
||||||
media() {
|
media() {
|
||||||
return this.libraryItem ? this.libraryItem.media || {} : {}
|
return this.libraryItem?.media || {}
|
||||||
},
|
},
|
||||||
mediaMetadata() {
|
mediaMetadata() {
|
||||||
return this.media.metadata || {}
|
return this.media.metadata || {}
|
||||||
@@ -65,6 +82,14 @@ export default {
|
|||||||
podcastAuthor() {
|
podcastAuthor() {
|
||||||
return this.mediaMetadata.author
|
return this.mediaMetadata.author
|
||||||
},
|
},
|
||||||
|
audioFileFilename() {
|
||||||
|
return this.episode.audioFile?.metadata?.filename || ''
|
||||||
|
},
|
||||||
|
audioFileSize() {
|
||||||
|
const size = this.episode.audioFile?.metadata?.size || 0
|
||||||
|
|
||||||
|
return this.$bytesPretty(size)
|
||||||
|
},
|
||||||
bookCoverAspectRatio() {
|
bookCoverAspectRatio() {
|
||||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|
||||||
<ui-tooltip direction="top" :text="$strings.LabelViewPlayerSettings">
|
<ui-tooltip direction="top" :text="$strings.LabelViewPlayerSettings">
|
||||||
<button :aria-label="$strings.LabelViewPlayerSettings" class="outline-none text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showPlayerSettings')">
|
<button :aria-label="$strings.LabelViewPlayerSettings" class="outline-none text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="showPlayerSettings">
|
||||||
<span class="material-symbols text-2xl sm:text-2.5xl">settings_slow_motion</span>
|
<span class="material-symbols text-2xl sm:text-2.5xl">settings_slow_motion</span>
|
||||||
</button>
|
</button>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
@@ -64,6 +64,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :playback-rate="playbackRate" :chapters="chapters" @select="selectChapter" />
|
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :playback-rate="playbackRate" :chapters="chapters" @select="selectChapter" />
|
||||||
|
|
||||||
|
<modals-player-settings-modal v-model="showPlayerSettingsModal" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -96,6 +98,7 @@ export default {
|
|||||||
audioEl: null,
|
audioEl: null,
|
||||||
seekLoading: false,
|
seekLoading: false,
|
||||||
showChaptersModal: false,
|
showChaptersModal: false,
|
||||||
|
showPlayerSettingsModal: false,
|
||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
duration: 0
|
duration: 0
|
||||||
}
|
}
|
||||||
@@ -315,6 +318,9 @@ export default {
|
|||||||
if (!this.chapters.length) return
|
if (!this.chapters.length) return
|
||||||
this.showChaptersModal = !this.showChaptersModal
|
this.showChaptersModal = !this.showChaptersModal
|
||||||
},
|
},
|
||||||
|
showPlayerSettings() {
|
||||||
|
this.showPlayerSettingsModal = !this.showPlayerSettingsModal
|
||||||
|
},
|
||||||
init() {
|
init() {
|
||||||
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
|
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
|
||||||
|
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ export default {
|
|||||||
this.users = res.users.sort((a, b) => {
|
this.users = res.users.sort((a, b) => {
|
||||||
return a.createdAt - b.createdAt
|
return a.createdAt - b.createdAt
|
||||||
})
|
})
|
||||||
|
this.$emit('numUsers', this.users.length)
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.17.2",
|
"version": "2.17.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.17.2",
|
"version": "2.17.4",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/axios": "^5.13.6",
|
"@nuxtjs/axios": "^5.13.6",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.17.2",
|
"version": "2.17.4",
|
||||||
"buildNumber": 1,
|
"buildNumber": 1,
|
||||||
"description": "Self-hosted audiobook and podcast client",
|
"description": "Self-hosted audiobook and podcast client",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
|
|||||||
@@ -64,6 +64,20 @@
|
|||||||
<ui-multi-select ref="redirectUris" v-model="newAuthSettings.authOpenIDMobileRedirectURIs" :items="newAuthSettings.authOpenIDMobileRedirectURIs" :label="$strings.LabelMobileRedirectURIs" class="mb-2" :menuDisabled="true" :disabled="savingSettings" />
|
<ui-multi-select ref="redirectUris" v-model="newAuthSettings.authOpenIDMobileRedirectURIs" :items="newAuthSettings.authOpenIDMobileRedirectURIs" :label="$strings.LabelMobileRedirectURIs" class="mb-2" :menuDisabled="true" :disabled="savingSettings" />
|
||||||
<p class="sm:pl-4 text-sm text-gray-300 mb-2" v-html="$strings.LabelMobileRedirectURIsDescription" />
|
<p class="sm:pl-4 text-sm text-gray-300 mb-2" v-html="$strings.LabelMobileRedirectURIsDescription" />
|
||||||
|
|
||||||
|
<div class="flex sm:items-center flex-col sm:flex-row pt-1 mb-2">
|
||||||
|
<div class="w-44">
|
||||||
|
<ui-dropdown v-model="newAuthSettings.authOpenIDSubfolderForRedirectURLs" small :items="subfolderOptions" :label="$strings.LabelWebRedirectURLsSubfolder" :disabled="savingSettings" />
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 sm:mt-5">
|
||||||
|
<p class="sm:pl-4 text-sm text-gray-300">{{ $strings.LabelWebRedirectURLsDescription }}</p>
|
||||||
|
<p class="sm:pl-4 text-sm text-gray-300 mb-2">
|
||||||
|
<code>{{ webCallbackURL }}</code>
|
||||||
|
<br />
|
||||||
|
<code>{{ mobileAppCallbackURL }}</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ui-text-input-with-label ref="buttonTextInput" v-model="newAuthSettings.authOpenIDButtonText" :disabled="savingSettings" :label="$strings.LabelButtonText" class="mb-2" />
|
<ui-text-input-with-label ref="buttonTextInput" v-model="newAuthSettings.authOpenIDButtonText" :disabled="savingSettings" :label="$strings.LabelButtonText" class="mb-2" />
|
||||||
|
|
||||||
<div class="flex sm:items-center flex-col sm:flex-row pt-1 mb-2">
|
<div class="flex sm:items-center flex-col sm:flex-row pt-1 mb-2">
|
||||||
@@ -164,6 +178,27 @@ export default {
|
|||||||
value: 'username'
|
value: 'username'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
subfolderOptions() {
|
||||||
|
const options = [
|
||||||
|
{
|
||||||
|
text: 'None',
|
||||||
|
value: ''
|
||||||
|
}
|
||||||
|
]
|
||||||
|
if (this.$config.routerBasePath) {
|
||||||
|
options.push({
|
||||||
|
text: this.$config.routerBasePath,
|
||||||
|
value: this.$config.routerBasePath
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
},
|
||||||
|
webCallbackURL() {
|
||||||
|
return `https://<your.server.com>${this.newAuthSettings.authOpenIDSubfolderForRedirectURLs ? this.newAuthSettings.authOpenIDSubfolderForRedirectURLs : ''}/auth/openid/callback`
|
||||||
|
},
|
||||||
|
mobileAppCallbackURL() {
|
||||||
|
return `https://<your.server.com>${this.newAuthSettings.authOpenIDSubfolderForRedirectURLs ? this.newAuthSettings.authOpenIDSubfolderForRedirectURLs : ''}/auth/openid/mobile-redirect`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -325,7 +360,8 @@ export default {
|
|||||||
},
|
},
|
||||||
init() {
|
init() {
|
||||||
this.newAuthSettings = {
|
this.newAuthSettings = {
|
||||||
...this.authSettings
|
...this.authSettings,
|
||||||
|
authOpenIDSubfolderForRedirectURLs: this.authSettings.authOpenIDSubfolderForRedirectURLs === undefined ? this.$config.routerBasePath : this.authSettings.authOpenIDSubfolderForRedirectURLs
|
||||||
}
|
}
|
||||||
this.enableLocalAuth = this.authMethods.includes('local')
|
this.enableLocalAuth = this.authMethods.includes('local')
|
||||||
this.enableOpenIDAuth = this.authMethods.includes('openid')
|
this.enableOpenIDAuth = this.authMethods.includes('openid')
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
<div>
|
<div>
|
||||||
<app-settings-content :header-text="$strings.HeaderUsers">
|
<app-settings-content :header-text="$strings.HeaderUsers">
|
||||||
<template #header-items>
|
<template #header-items>
|
||||||
|
<div v-if="numUsers" class="mx-2 px-1.5 rounded-lg bg-primary/50 text-gray-300/90 text-sm inline-flex items-center justify-center">
|
||||||
|
<span>{{ numUsers }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
<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://www.audiobookshelf.org/guides/users" target="_blank" class="inline-flex">
|
||||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||||
@@ -13,7 +17,7 @@
|
|||||||
<ui-btn color="primary" small @click="setShowUserModal()">{{ $strings.ButtonAddUser }}</ui-btn>
|
<ui-btn color="primary" small @click="setShowUserModal()">{{ $strings.ButtonAddUser }}</ui-btn>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<tables-users-table class="pt-2" @edit="setShowUserModal" />
|
<tables-users-table class="pt-2" @edit="setShowUserModal" @numUsers="(count) => (numUsers = count)" />
|
||||||
</app-settings-content>
|
</app-settings-content>
|
||||||
<modals-account-modal ref="accountModal" v-model="showAccountModal" :account="selectedAccount" />
|
<modals-account-modal ref="accountModal" v-model="showAccountModal" :account="selectedAccount" />
|
||||||
</div>
|
</div>
|
||||||
@@ -29,7 +33,8 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
selectedAccount: null,
|
selectedAccount: null,
|
||||||
showAccountModal: false
|
showAccountModal: false,
|
||||||
|
numUsers: 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {},
|
computed: {},
|
||||||
|
|||||||
@@ -126,12 +126,14 @@ export default {
|
|||||||
if (!this.localAudioPlayer || !this.hasLoaded) return
|
if (!this.localAudioPlayer || !this.hasLoaded) return
|
||||||
const currentTime = this.localAudioPlayer.getCurrentTime()
|
const currentTime = this.localAudioPlayer.getCurrentTime()
|
||||||
const duration = this.localAudioPlayer.getDuration()
|
const duration = this.localAudioPlayer.getDuration()
|
||||||
this.seek(Math.min(currentTime + 10, duration))
|
const jumpForwardAmount = this.$store.getters['user/getUserSetting']('jumpForwardAmount') || 10
|
||||||
|
this.seek(Math.min(currentTime + jumpForwardAmount, duration))
|
||||||
},
|
},
|
||||||
jumpBackward() {
|
jumpBackward() {
|
||||||
if (!this.localAudioPlayer || !this.hasLoaded) return
|
if (!this.localAudioPlayer || !this.hasLoaded) return
|
||||||
const currentTime = this.localAudioPlayer.getCurrentTime()
|
const currentTime = this.localAudioPlayer.getCurrentTime()
|
||||||
this.seek(Math.max(currentTime - 10, 0))
|
const jumpBackwardAmount = this.$store.getters['user/getUserSetting']('jumpBackwardAmount') || 10
|
||||||
|
this.seek(Math.max(currentTime - jumpBackwardAmount, 0))
|
||||||
},
|
},
|
||||||
setVolume(volume) {
|
setVolume(volume) {
|
||||||
if (!this.localAudioPlayer || !this.hasLoaded) return
|
if (!this.localAudioPlayer || !this.hasLoaded) return
|
||||||
@@ -248,6 +250,8 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
this.$store.dispatch('user/loadUserSettings')
|
||||||
|
|
||||||
this.resize()
|
this.resize()
|
||||||
window.addEventListener('resize', this.resize)
|
window.addEventListener('resize', this.resize)
|
||||||
window.addEventListener('keydown', this.keyDown)
|
window.addEventListener('keydown', this.keyDown)
|
||||||
|
|||||||
@@ -66,6 +66,7 @@
|
|||||||
"ButtonPurgeItemsCache": "আইটেম ক্যাশে পরিষ্কার করুন",
|
"ButtonPurgeItemsCache": "আইটেম ক্যাশে পরিষ্কার করুন",
|
||||||
"ButtonQueueAddItem": "সারিতে যোগ করুন",
|
"ButtonQueueAddItem": "সারিতে যোগ করুন",
|
||||||
"ButtonQueueRemoveItem": "সারি থেকে মুছে ফেলুন",
|
"ButtonQueueRemoveItem": "সারি থেকে মুছে ফেলুন",
|
||||||
|
"ButtonQuickEmbed": "দ্রুত এম্বেড করুন",
|
||||||
"ButtonQuickEmbedMetadata": "মেটাডেটা দ্রুত এম্বেড করুন",
|
"ButtonQuickEmbedMetadata": "মেটাডেটা দ্রুত এম্বেড করুন",
|
||||||
"ButtonQuickMatch": "দ্রুত ম্যাচ",
|
"ButtonQuickMatch": "দ্রুত ম্যাচ",
|
||||||
"ButtonReScan": "পুনরায় স্ক্যান",
|
"ButtonReScan": "পুনরায় স্ক্যান",
|
||||||
@@ -162,6 +163,7 @@
|
|||||||
"HeaderNotificationUpdate": "বিজ্ঞপ্তি আপডেট করুন",
|
"HeaderNotificationUpdate": "বিজ্ঞপ্তি আপডেট করুন",
|
||||||
"HeaderNotifications": "বিজ্ঞপ্তি",
|
"HeaderNotifications": "বিজ্ঞপ্তি",
|
||||||
"HeaderOpenIDConnectAuthentication": "ওপেনআইডি সংযোগ প্রমাণীকরণ",
|
"HeaderOpenIDConnectAuthentication": "ওপেনআইডি সংযোগ প্রমাণীকরণ",
|
||||||
|
"HeaderOpenListeningSessions": "শোনার সেশন খুলুন",
|
||||||
"HeaderOpenRSSFeed": "আরএসএস ফিড খুলুন",
|
"HeaderOpenRSSFeed": "আরএসএস ফিড খুলুন",
|
||||||
"HeaderOtherFiles": "অন্যান্য ফাইল",
|
"HeaderOtherFiles": "অন্যান্য ফাইল",
|
||||||
"HeaderPasswordAuthentication": "পাসওয়ার্ড প্রমাণীকরণ",
|
"HeaderPasswordAuthentication": "পাসওয়ার্ড প্রমাণীকরণ",
|
||||||
@@ -179,6 +181,7 @@
|
|||||||
"HeaderRemoveEpisodes": "{0}টি পর্ব সরান",
|
"HeaderRemoveEpisodes": "{0}টি পর্ব সরান",
|
||||||
"HeaderSavedMediaProgress": "মিডিয়া সংরক্ষণের অগ্রগতি",
|
"HeaderSavedMediaProgress": "মিডিয়া সংরক্ষণের অগ্রগতি",
|
||||||
"HeaderSchedule": "সময়সূচী",
|
"HeaderSchedule": "সময়সূচী",
|
||||||
|
"HeaderScheduleEpisodeDownloads": "স্বয়ংক্রিয় পর্ব ডাউনলোডের সময়সূচী নির্ধারন করুন",
|
||||||
"HeaderScheduleLibraryScans": "স্বয়ংক্রিয় লাইব্রেরি স্ক্যানের সময়সূচী",
|
"HeaderScheduleLibraryScans": "স্বয়ংক্রিয় লাইব্রেরি স্ক্যানের সময়সূচী",
|
||||||
"HeaderSession": "সেশন",
|
"HeaderSession": "সেশন",
|
||||||
"HeaderSetBackupSchedule": "ব্যাকআপ সময়সূচী সেট করুন",
|
"HeaderSetBackupSchedule": "ব্যাকআপ সময়সূচী সেট করুন",
|
||||||
@@ -224,7 +227,11 @@
|
|||||||
"LabelAllUsersExcludingGuests": "অতিথি ব্যতীত সকল ব্যবহারকারী",
|
"LabelAllUsersExcludingGuests": "অতিথি ব্যতীত সকল ব্যবহারকারী",
|
||||||
"LabelAllUsersIncludingGuests": "অতিথি সহ সকল ব্যবহারকারী",
|
"LabelAllUsersIncludingGuests": "অতিথি সহ সকল ব্যবহারকারী",
|
||||||
"LabelAlreadyInYourLibrary": "ইতিমধ্যেই আপনার লাইব্রেরিতে রয়েছে",
|
"LabelAlreadyInYourLibrary": "ইতিমধ্যেই আপনার লাইব্রেরিতে রয়েছে",
|
||||||
|
"LabelApiToken": "API টোকেন",
|
||||||
"LabelAppend": "সংযোজন",
|
"LabelAppend": "সংযোজন",
|
||||||
|
"LabelAudioBitrate": "অডিও বিটরেট (যেমন- 128k)",
|
||||||
|
"LabelAudioChannels": "অডিও চ্যানেল (১ বা ২)",
|
||||||
|
"LabelAudioCodec": "অডিও কোডেক",
|
||||||
"LabelAuthor": "লেখক",
|
"LabelAuthor": "লেখক",
|
||||||
"LabelAuthorFirstLast": "লেখক (প্রথম শেষ)",
|
"LabelAuthorFirstLast": "লেখক (প্রথম শেষ)",
|
||||||
"LabelAuthorLastFirst": "লেখক (শেষ, প্রথম)",
|
"LabelAuthorLastFirst": "লেখক (শেষ, প্রথম)",
|
||||||
@@ -237,6 +244,7 @@
|
|||||||
"LabelAutoRegister": "স্বয়ংক্রিয় নিবন্ধন",
|
"LabelAutoRegister": "স্বয়ংক্রিয় নিবন্ধন",
|
||||||
"LabelAutoRegisterDescription": "লগ ইন করার পর স্বয়ংক্রিয়ভাবে নতুন ব্যবহারকারী তৈরি করুন",
|
"LabelAutoRegisterDescription": "লগ ইন করার পর স্বয়ংক্রিয়ভাবে নতুন ব্যবহারকারী তৈরি করুন",
|
||||||
"LabelBackToUser": "ব্যবহারকারীর কাছে ফিরে যান",
|
"LabelBackToUser": "ব্যবহারকারীর কাছে ফিরে যান",
|
||||||
|
"LabelBackupAudioFiles": "অডিও ফাইলগুলো ব্যাকআপ",
|
||||||
"LabelBackupLocation": "ব্যাকআপ অবস্থান",
|
"LabelBackupLocation": "ব্যাকআপ অবস্থান",
|
||||||
"LabelBackupsEnableAutomaticBackups": "স্বয়ংক্রিয় ব্যাকআপ সক্ষম করুন",
|
"LabelBackupsEnableAutomaticBackups": "স্বয়ংক্রিয় ব্যাকআপ সক্ষম করুন",
|
||||||
"LabelBackupsEnableAutomaticBackupsHelp": "ব্যাকআপগুলি /মেটাডাটা/ব্যাকআপে সংরক্ষিত",
|
"LabelBackupsEnableAutomaticBackupsHelp": "ব্যাকআপগুলি /মেটাডাটা/ব্যাকআপে সংরক্ষিত",
|
||||||
@@ -245,15 +253,18 @@
|
|||||||
"LabelBackupsNumberToKeep": "ব্যাকআপের সংখ্যা রাখুন",
|
"LabelBackupsNumberToKeep": "ব্যাকআপের সংখ্যা রাখুন",
|
||||||
"LabelBackupsNumberToKeepHelp": "এক সময়ে শুধুমাত্র ১ টি ব্যাকআপ সরানো হবে তাই যদি আপনার কাছে ইতিমধ্যে এর চেয়ে বেশি ব্যাকআপ থাকে তাহলে আপনাকে ম্যানুয়ালি সেগুলি সরিয়ে ফেলতে হবে।",
|
"LabelBackupsNumberToKeepHelp": "এক সময়ে শুধুমাত্র ১ টি ব্যাকআপ সরানো হবে তাই যদি আপনার কাছে ইতিমধ্যে এর চেয়ে বেশি ব্যাকআপ থাকে তাহলে আপনাকে ম্যানুয়ালি সেগুলি সরিয়ে ফেলতে হবে।",
|
||||||
"LabelBitrate": "বিটরেট",
|
"LabelBitrate": "বিটরেট",
|
||||||
|
"LabelBonus": "উপরিলাভ",
|
||||||
"LabelBooks": "বইগুলো",
|
"LabelBooks": "বইগুলো",
|
||||||
"LabelButtonText": "ঘর পাঠ্য",
|
"LabelButtonText": "ঘর পাঠ্য",
|
||||||
"LabelByAuthor": "দ্বারা {0}",
|
"LabelByAuthor": "দ্বারা {0}",
|
||||||
"LabelChangePassword": "পাসওয়ার্ড পরিবর্তন করুন",
|
"LabelChangePassword": "পাসওয়ার্ড পরিবর্তন করুন",
|
||||||
"LabelChannels": "চ্যানেল",
|
"LabelChannels": "চ্যানেল",
|
||||||
|
"LabelChapterCount": "{0} অধ্যায়",
|
||||||
"LabelChapterTitle": "অধ্যায়ের শিরোনাম",
|
"LabelChapterTitle": "অধ্যায়ের শিরোনাম",
|
||||||
"LabelChapters": "অধ্যায়",
|
"LabelChapters": "অধ্যায়",
|
||||||
"LabelChaptersFound": "অধ্যায় পাওয়া গেছে",
|
"LabelChaptersFound": "অধ্যায় পাওয়া গেছে",
|
||||||
"LabelClickForMoreInfo": "আরো তথ্যের জন্য ক্লিক করুন",
|
"LabelClickForMoreInfo": "আরো তথ্যের জন্য ক্লিক করুন",
|
||||||
|
"LabelClickToUseCurrentValue": "বর্তমান মান ব্যবহার করতে ক্লিক করুন",
|
||||||
"LabelClosePlayer": "প্লেয়ার বন্ধ করুন",
|
"LabelClosePlayer": "প্লেয়ার বন্ধ করুন",
|
||||||
"LabelCodec": "কোডেক",
|
"LabelCodec": "কোডেক",
|
||||||
"LabelCollapseSeries": "সিরিজ সঙ্কুচিত করুন",
|
"LabelCollapseSeries": "সিরিজ সঙ্কুচিত করুন",
|
||||||
@@ -303,12 +314,25 @@
|
|||||||
"LabelEmailSettingsTestAddress": "পরীক্ষার ঠিকানা",
|
"LabelEmailSettingsTestAddress": "পরীক্ষার ঠিকানা",
|
||||||
"LabelEmbeddedCover": "এম্বেডেড কভার",
|
"LabelEmbeddedCover": "এম্বেডেড কভার",
|
||||||
"LabelEnable": "সক্ষম করুন",
|
"LabelEnable": "সক্ষম করুন",
|
||||||
|
"LabelEncodingBackupLocation": "আপনার আসল অডিও ফাইলগুলোর একটি ব্যাকআপ এখানে সংরক্ষণ করা হবে:",
|
||||||
|
"LabelEncodingChaptersNotEmbedded": "মাল্টি-ট্র্যাক অডিওবুকগুলোতে অধ্যায় এম্বেড করা হয় না।",
|
||||||
|
"LabelEncodingClearItemCache": "পর্যায়ক্রমে আইটেম ক্যাশে পরিষ্কার করতে ভুলবেন না।",
|
||||||
|
"LabelEncodingFinishedM4B": "সমাপ্ত হওয়া M4B-গুলো আপনার অডিওবুক ফোল্ডারে এখানে রাখা হবে:",
|
||||||
|
"LabelEncodingInfoEmbedded": "আপনার অডিওবুক ফোল্ডারের ভিতরে অডিও ট্র্যাকগুলোতে মেটাডেটা এমবেড করা হবে।",
|
||||||
|
"LabelEncodingStartedNavigation": "একবার টাস্ক শুরু হলে আপনি এই পৃষ্ঠা থেকে অন্যত্র যেতে পারেন।",
|
||||||
|
"LabelEncodingTimeWarning": "এনকোডিং ৩০ মিনিট পর্যন্ত সময় নিতে পারে।",
|
||||||
|
"LabelEncodingWarningAdvancedSettings": "সতর্কতা: এই সেটিংস আপডেট করবেন না, যদি না আপনি ffmpeg এনকোডিং বিকল্পগুলোর সাথে পরিচিত হন।",
|
||||||
|
"LabelEncodingWatcherDisabled": "আপনার যদি পর্যবেক্ষক অক্ষম থাকে তবে আপনাকে পরে এই অডিওবুকটি পুনরায় স্ক্যান করতে হবে।",
|
||||||
"LabelEnd": "সমাপ্ত",
|
"LabelEnd": "সমাপ্ত",
|
||||||
"LabelEndOfChapter": "অধ্যায়ের সমাপ্তি",
|
"LabelEndOfChapter": "অধ্যায়ের সমাপ্তি",
|
||||||
"LabelEpisode": "পর্ব",
|
"LabelEpisode": "পর্ব",
|
||||||
|
"LabelEpisodeNotLinkedToRssFeed": "পর্বটি আরএসএস ফিডের সাথে সংযুক্ত করা হয়নি",
|
||||||
|
"LabelEpisodeNumber": "পর্ব #{0}",
|
||||||
"LabelEpisodeTitle": "পর্বের শিরোনাম",
|
"LabelEpisodeTitle": "পর্বের শিরোনাম",
|
||||||
"LabelEpisodeType": "পর্বের ধরন",
|
"LabelEpisodeType": "পর্বের ধরন",
|
||||||
|
"LabelEpisodeUrlFromRssFeed": "আরএসএস ফিড থেকে পর্ব URL",
|
||||||
"LabelEpisodes": "পর্বগুলো",
|
"LabelEpisodes": "পর্বগুলো",
|
||||||
|
"LabelEpisodic": "প্রাসঙ্গিক",
|
||||||
"LabelExample": "উদাহরণ",
|
"LabelExample": "উদাহরণ",
|
||||||
"LabelExpandSeries": "সিরিজ প্রসারিত করুন",
|
"LabelExpandSeries": "সিরিজ প্রসারিত করুন",
|
||||||
"LabelExpandSubSeries": "সাব সিরিজ প্রসারিত করুন",
|
"LabelExpandSubSeries": "সাব সিরিজ প্রসারিত করুন",
|
||||||
@@ -336,6 +360,7 @@
|
|||||||
"LabelFontScale": "ফন্ট স্কেল",
|
"LabelFontScale": "ফন্ট স্কেল",
|
||||||
"LabelFontStrikethrough": "অবচ্ছেদন রেখা",
|
"LabelFontStrikethrough": "অবচ্ছেদন রেখা",
|
||||||
"LabelFormat": "ফরম্যাট",
|
"LabelFormat": "ফরম্যাট",
|
||||||
|
"LabelFull": "পূর্ণ",
|
||||||
"LabelGenre": "ঘরানা",
|
"LabelGenre": "ঘরানা",
|
||||||
"LabelGenres": "ঘরানাগুলো",
|
"LabelGenres": "ঘরানাগুলো",
|
||||||
"LabelHardDeleteFile": "জোরপূর্বক ফাইল মুছে ফেলুন",
|
"LabelHardDeleteFile": "জোরপূর্বক ফাইল মুছে ফেলুন",
|
||||||
@@ -391,6 +416,10 @@
|
|||||||
"LabelLowestPriority": "সর্বনিম্ন অগ্রাধিকার",
|
"LabelLowestPriority": "সর্বনিম্ন অগ্রাধিকার",
|
||||||
"LabelMatchExistingUsersBy": "বিদ্যমান ব্যবহারকারীদের দ্বারা মিলিত করুন",
|
"LabelMatchExistingUsersBy": "বিদ্যমান ব্যবহারকারীদের দ্বারা মিলিত করুন",
|
||||||
"LabelMatchExistingUsersByDescription": "বিদ্যমান ব্যবহারকারীদের সংযোগ করার জন্য ব্যবহৃত হয়। একবার সংযুক্ত হলে, ব্যবহারকারীদের আপনার SSO প্রদানকারীর থেকে একটি অনন্য আইডি দ্বারা মিলিত হবে",
|
"LabelMatchExistingUsersByDescription": "বিদ্যমান ব্যবহারকারীদের সংযোগ করার জন্য ব্যবহৃত হয়। একবার সংযুক্ত হলে, ব্যবহারকারীদের আপনার SSO প্রদানকারীর থেকে একটি অনন্য আইডি দ্বারা মিলিত হবে",
|
||||||
|
"LabelMaxEpisodesToDownload": "সর্বাধিক # টি পর্ব ডাউনলোড করা হবে। অসীমের জন্য 0 ব্যবহার করুন।",
|
||||||
|
"LabelMaxEpisodesToDownloadPerCheck": "প্রতি কিস্তিতে সর্বাধিক # টি নতুন পর্ব ডাউনলোড করা হবে",
|
||||||
|
"LabelMaxEpisodesToKeep": "সর্বোচ্চ # টি পর্ব রাখা হবে",
|
||||||
|
"LabelMaxEpisodesToKeepHelp": "০ কোন সর্বোচ্চ সীমা সেট করে না। একটি নতুন পর্ব স্বয়ংক্রিয়-ডাউনলোড হওয়ার পরে আপনার যদি X-এর বেশি পর্ব থাকে তবে এটি সবচেয়ে পুরানো পর্বটি মুছে ফেলবে। এটি প্রতি নতুন ডাউনলোডের জন্য শুধুমাত্র ১ টি পর্ব মুছে ফেলবে।",
|
||||||
"LabelMediaPlayer": "মিডিয়া প্লেয়ার",
|
"LabelMediaPlayer": "মিডিয়া প্লেয়ার",
|
||||||
"LabelMediaType": "মিডিয়ার ধরন",
|
"LabelMediaType": "মিডিয়ার ধরন",
|
||||||
"LabelMetaTag": "মেটা ট্যাগ",
|
"LabelMetaTag": "মেটা ট্যাগ",
|
||||||
@@ -436,12 +465,14 @@
|
|||||||
"LabelOpenIDGroupClaimDescription": "ওপেনআইডি দাবির নাম যাতে ব্যবহারকারীর গোষ্ঠীর একটি তালিকা থাকে। সাধারণত <code>গ্রুপ</code> হিসাবে উল্লেখ করা হয়। <b>কনফিগার করা থাকলে</b>, অ্যাপ্লিকেশনটি স্বয়ংক্রিয়ভাবে এর উপর ভিত্তি করে ব্যবহারকারীর গোষ্ঠীর সদস্যপদ নির্ধারণ করবে, শর্ত এই যে এই গোষ্ঠীগুলি কেস-অসংবেদনশীলভাবে দাবিতে 'অ্যাডমিন', 'ব্যবহারকারী' বা 'অতিথি' নাম দেওয়া হয়৷ দাবিতে একটি তালিকা থাকা উচিত এবং যদি একজন ব্যবহারকারী একাধিক গোষ্ঠীর অন্তর্গত হয় তবে অ্যাপ্লিকেশনটি বরাদ্দ করবে সর্বোচ্চ স্তরের অ্যাক্সেসের সাথে সঙ্গতিপূর্ণ ভূমিকা৷ যদি কোনও গোষ্ঠীর সাথে মেলে না, তবে অ্যাক্সেস অস্বীকার করা হবে।",
|
"LabelOpenIDGroupClaimDescription": "ওপেনআইডি দাবির নাম যাতে ব্যবহারকারীর গোষ্ঠীর একটি তালিকা থাকে। সাধারণত <code>গ্রুপ</code> হিসাবে উল্লেখ করা হয়। <b>কনফিগার করা থাকলে</b>, অ্যাপ্লিকেশনটি স্বয়ংক্রিয়ভাবে এর উপর ভিত্তি করে ব্যবহারকারীর গোষ্ঠীর সদস্যপদ নির্ধারণ করবে, শর্ত এই যে এই গোষ্ঠীগুলি কেস-অসংবেদনশীলভাবে দাবিতে 'অ্যাডমিন', 'ব্যবহারকারী' বা 'অতিথি' নাম দেওয়া হয়৷ দাবিতে একটি তালিকা থাকা উচিত এবং যদি একজন ব্যবহারকারী একাধিক গোষ্ঠীর অন্তর্গত হয় তবে অ্যাপ্লিকেশনটি বরাদ্দ করবে সর্বোচ্চ স্তরের অ্যাক্সেসের সাথে সঙ্গতিপূর্ণ ভূমিকা৷ যদি কোনও গোষ্ঠীর সাথে মেলে না, তবে অ্যাক্সেস অস্বীকার করা হবে।",
|
||||||
"LabelOpenRSSFeed": "আরএসএস ফিড খুলুন",
|
"LabelOpenRSSFeed": "আরএসএস ফিড খুলুন",
|
||||||
"LabelOverwrite": "পুনঃলিখিত",
|
"LabelOverwrite": "পুনঃলিখিত",
|
||||||
|
"LabelPaginationPageXOfY": "{1} টির মধ্যে {0} পৃষ্ঠা",
|
||||||
"LabelPassword": "পাসওয়ার্ড",
|
"LabelPassword": "পাসওয়ার্ড",
|
||||||
"LabelPath": "পথ",
|
"LabelPath": "পথ",
|
||||||
"LabelPermanent": "স্থায়ী",
|
"LabelPermanent": "স্থায়ী",
|
||||||
"LabelPermissionsAccessAllLibraries": "সমস্ত লাইব্রেরি অ্যাক্সেস করতে পারবে",
|
"LabelPermissionsAccessAllLibraries": "সমস্ত লাইব্রেরি অ্যাক্সেস করতে পারবে",
|
||||||
"LabelPermissionsAccessAllTags": "সমস্ত ট্যাগ অ্যাক্সেস করতে পারবে",
|
"LabelPermissionsAccessAllTags": "সমস্ত ট্যাগ অ্যাক্সেস করতে পারবে",
|
||||||
"LabelPermissionsAccessExplicitContent": "স্পষ্ট বিষয়বস্তু অ্যাক্সেস করতে পারে",
|
"LabelPermissionsAccessExplicitContent": "স্পষ্ট বিষয়বস্তু অ্যাক্সেস করতে পারে",
|
||||||
|
"LabelPermissionsCreateEreader": "ইরিডার তৈরি করতে পারেন",
|
||||||
"LabelPermissionsDelete": "মুছে দিতে পারবে",
|
"LabelPermissionsDelete": "মুছে দিতে পারবে",
|
||||||
"LabelPermissionsDownload": "ডাউনলোড করতে পারবে",
|
"LabelPermissionsDownload": "ডাউনলোড করতে পারবে",
|
||||||
"LabelPermissionsUpdate": "আপডেট করতে পারবে",
|
"LabelPermissionsUpdate": "আপডেট করতে পারবে",
|
||||||
@@ -465,6 +496,8 @@
|
|||||||
"LabelPubDate": "প্রকাশের তারিখ",
|
"LabelPubDate": "প্রকাশের তারিখ",
|
||||||
"LabelPublishYear": "প্রকাশের বছর",
|
"LabelPublishYear": "প্রকাশের বছর",
|
||||||
"LabelPublishedDate": "প্রকাশিত {0}",
|
"LabelPublishedDate": "প্রকাশিত {0}",
|
||||||
|
"LabelPublishedDecade": "প্রকাশনার দশক",
|
||||||
|
"LabelPublishedDecades": "প্রকাশনার দশকগুলো",
|
||||||
"LabelPublisher": "প্রকাশক",
|
"LabelPublisher": "প্রকাশক",
|
||||||
"LabelPublishers": "প্রকাশকরা",
|
"LabelPublishers": "প্রকাশকরা",
|
||||||
"LabelRSSFeedCustomOwnerEmail": "কাস্টম মালিকের ইমেইল",
|
"LabelRSSFeedCustomOwnerEmail": "কাস্টম মালিকের ইমেইল",
|
||||||
@@ -484,21 +517,28 @@
|
|||||||
"LabelRedo": "পুনরায় করুন",
|
"LabelRedo": "পুনরায় করুন",
|
||||||
"LabelRegion": "অঞ্চল",
|
"LabelRegion": "অঞ্চল",
|
||||||
"LabelReleaseDate": "উন্মোচনের তারিখ",
|
"LabelReleaseDate": "উন্মোচনের তারিখ",
|
||||||
|
"LabelRemoveAllMetadataAbs": "সমস্ত metadata.abs ফাইল সরান",
|
||||||
|
"LabelRemoveAllMetadataJson": "সমস্ত metadata.json ফাইল সরান",
|
||||||
"LabelRemoveCover": "কভার সরান",
|
"LabelRemoveCover": "কভার সরান",
|
||||||
|
"LabelRemoveMetadataFile": "লাইব্রেরি আইটেম ফোল্ডারে মেটাডেটা ফাইল সরান",
|
||||||
|
"LabelRemoveMetadataFileHelp": "আপনার {0} ফোল্ডারের সমস্ত metadata.json এবং metadata.abs ফাইলগুলি সরান।",
|
||||||
"LabelRowsPerPage": "প্রতি পৃষ্ঠায় সারি",
|
"LabelRowsPerPage": "প্রতি পৃষ্ঠায় সারি",
|
||||||
"LabelSearchTerm": "অনুসন্ধান শব্দ",
|
"LabelSearchTerm": "অনুসন্ধান শব্দ",
|
||||||
"LabelSearchTitle": "অনুসন্ধান শিরোনাম",
|
"LabelSearchTitle": "অনুসন্ধান শিরোনাম",
|
||||||
"LabelSearchTitleOrASIN": "অনুসন্ধান শিরোনাম বা ASIN",
|
"LabelSearchTitleOrASIN": "অনুসন্ধান শিরোনাম বা ASIN",
|
||||||
"LabelSeason": "সেশন",
|
"LabelSeason": "সেশন",
|
||||||
|
"LabelSeasonNumber": "মরসুম #{0}",
|
||||||
"LabelSelectAll": "সব নির্বাচন করুন",
|
"LabelSelectAll": "সব নির্বাচন করুন",
|
||||||
"LabelSelectAllEpisodes": "সমস্ত পর্ব নির্বাচন করুন",
|
"LabelSelectAllEpisodes": "সমস্ত পর্ব নির্বাচন করুন",
|
||||||
"LabelSelectEpisodesShowing": "দেখানো {0}টি পর্ব নির্বাচন করুন",
|
"LabelSelectEpisodesShowing": "দেখানো {0}টি পর্ব নির্বাচন করুন",
|
||||||
"LabelSelectUsers": "ব্যবহারকারী নির্বাচন করুন",
|
"LabelSelectUsers": "ব্যবহারকারী নির্বাচন করুন",
|
||||||
"LabelSendEbookToDevice": "ই-বই পাঠান...",
|
"LabelSendEbookToDevice": "ই-বই পাঠান...",
|
||||||
"LabelSequence": "ক্রম",
|
"LabelSequence": "ক্রম",
|
||||||
|
"LabelSerial": "ধারাবাহিক",
|
||||||
"LabelSeries": "সিরিজ",
|
"LabelSeries": "সিরিজ",
|
||||||
"LabelSeriesName": "সিরিজের নাম",
|
"LabelSeriesName": "সিরিজের নাম",
|
||||||
"LabelSeriesProgress": "সিরিজের অগ্রগতি",
|
"LabelSeriesProgress": "সিরিজের অগ্রগতি",
|
||||||
|
"LabelServerLogLevel": "সার্ভার লগ লেভেল",
|
||||||
"LabelServerYearReview": "সার্ভারের বাৎসরিক পর্যালোচনা ({0})",
|
"LabelServerYearReview": "সার্ভারের বাৎসরিক পর্যালোচনা ({0})",
|
||||||
"LabelSetEbookAsPrimary": "প্রাথমিক হিসাবে সেট করুন",
|
"LabelSetEbookAsPrimary": "প্রাথমিক হিসাবে সেট করুন",
|
||||||
"LabelSetEbookAsSupplementary": "পরিপূরক হিসেবে সেট করুন",
|
"LabelSetEbookAsSupplementary": "পরিপূরক হিসেবে সেট করুন",
|
||||||
@@ -523,6 +563,9 @@
|
|||||||
"LabelSettingsHideSingleBookSeriesHelp": "যে সিরিজগুলোতে একটি বই আছে সেগুলো সিরিজের পাতা এবং নীড় পেজের তাক থেকে লুকিয়ে রাখা হবে।",
|
"LabelSettingsHideSingleBookSeriesHelp": "যে সিরিজগুলোতে একটি বই আছে সেগুলো সিরিজের পাতা এবং নীড় পেজের তাক থেকে লুকিয়ে রাখা হবে।",
|
||||||
"LabelSettingsHomePageBookshelfView": "নীড় পেজে বুকশেলফ ভিউ ব্যবহার করুন",
|
"LabelSettingsHomePageBookshelfView": "নীড় পেজে বুকশেলফ ভিউ ব্যবহার করুন",
|
||||||
"LabelSettingsLibraryBookshelfView": "লাইব্রেরি বুকশেলফ ভিউ ব্যবহার করুন",
|
"LabelSettingsLibraryBookshelfView": "লাইব্রেরি বুকশেলফ ভিউ ব্যবহার করুন",
|
||||||
|
"LabelSettingsLibraryMarkAsFinishedPercentComplete": "শতকরা সম্পূর্ণ এর চেয়ে বেশি",
|
||||||
|
"LabelSettingsLibraryMarkAsFinishedTimeRemaining": "বাকি সময় (সেকেন্ড) এর চেয়ে কম",
|
||||||
|
"LabelSettingsLibraryMarkAsFinishedWhen": "মিডিয়া আইটেমকে সমাপ্ত হিসাবে চিহ্নিত করুন যখন",
|
||||||
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "কন্টিনিউ সিরিজে আগের বইগুলো এড়িয়ে যান",
|
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "কন্টিনিউ সিরিজে আগের বইগুলো এড়িয়ে যান",
|
||||||
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "কন্টিনিউ সিরিজের নীড় পেজ শেল্ফ দেখায় যে সিরিজে শুরু হয়নি এমন প্রথম বই যার অন্তত একটি বই শেষ হয়েছে এবং কোনো বই চলছে না। এই সেটিংটি সক্ষম করলে শুরু না হওয়া প্রথম বইটির পরিবর্তে সবচেয়ে দূরের সম্পূর্ণ বই থেকে সিরিজ চলতে থাকবে।",
|
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "কন্টিনিউ সিরিজের নীড় পেজ শেল্ফ দেখায় যে সিরিজে শুরু হয়নি এমন প্রথম বই যার অন্তত একটি বই শেষ হয়েছে এবং কোনো বই চলছে না। এই সেটিংটি সক্ষম করলে শুরু না হওয়া প্রথম বইটির পরিবর্তে সবচেয়ে দূরের সম্পূর্ণ বই থেকে সিরিজ চলতে থাকবে।",
|
||||||
"LabelSettingsParseSubtitles": "সাবটাইটেল পার্স করুন",
|
"LabelSettingsParseSubtitles": "সাবটাইটেল পার্স করুন",
|
||||||
@@ -587,6 +630,7 @@
|
|||||||
"LabelTimeDurationXMinutes": "{0} মিনিট",
|
"LabelTimeDurationXMinutes": "{0} মিনিট",
|
||||||
"LabelTimeDurationXSeconds": "{0} সেকেন্ড",
|
"LabelTimeDurationXSeconds": "{0} সেকেন্ড",
|
||||||
"LabelTimeInMinutes": "মিনিটে সময়",
|
"LabelTimeInMinutes": "মিনিটে সময়",
|
||||||
|
"LabelTimeLeft": "{0} বাকি",
|
||||||
"LabelTimeListened": "সময় শোনা হয়েছে",
|
"LabelTimeListened": "সময় শোনা হয়েছে",
|
||||||
"LabelTimeListenedToday": "আজ শোনার সময়",
|
"LabelTimeListenedToday": "আজ শোনার সময়",
|
||||||
"LabelTimeRemaining": "{0}টি অবশিষ্ট",
|
"LabelTimeRemaining": "{0}টি অবশিষ্ট",
|
||||||
@@ -594,6 +638,7 @@
|
|||||||
"LabelTitle": "শিরোনাম",
|
"LabelTitle": "শিরোনাম",
|
||||||
"LabelToolsEmbedMetadata": "মেটাডেটা এম্বেড করুন",
|
"LabelToolsEmbedMetadata": "মেটাডেটা এম্বেড করুন",
|
||||||
"LabelToolsEmbedMetadataDescription": "কভার ইমেজ এবং অধ্যায় সহ অডিও ফাইলগুলিতে মেটাডেটা এম্বেড করুন।",
|
"LabelToolsEmbedMetadataDescription": "কভার ইমেজ এবং অধ্যায় সহ অডিও ফাইলগুলিতে মেটাডেটা এম্বেড করুন।",
|
||||||
|
"LabelToolsM4bEncoder": "M4B এনকোডার",
|
||||||
"LabelToolsMakeM4b": "M4B অডিওবুক ফাইল তৈরি করুন",
|
"LabelToolsMakeM4b": "M4B অডিওবুক ফাইল তৈরি করুন",
|
||||||
"LabelToolsMakeM4bDescription": "এমবেডেড মেটাডেটা, কভার ইমেজ এবং অধ্যায় সহ একটি .M4B অডিওবুক ফাইল তৈরি করুন।",
|
"LabelToolsMakeM4bDescription": "এমবেডেড মেটাডেটা, কভার ইমেজ এবং অধ্যায় সহ একটি .M4B অডিওবুক ফাইল তৈরি করুন।",
|
||||||
"LabelToolsSplitM4b": "M4B কে MP3 তে বিভক্ত করুন",
|
"LabelToolsSplitM4b": "M4B কে MP3 তে বিভক্ত করুন",
|
||||||
@@ -606,6 +651,7 @@
|
|||||||
"LabelTracksMultiTrack": "মাল্টি-ট্র্যাক",
|
"LabelTracksMultiTrack": "মাল্টি-ট্র্যাক",
|
||||||
"LabelTracksNone": "কোন ট্র্যাক নেই",
|
"LabelTracksNone": "কোন ট্র্যাক নেই",
|
||||||
"LabelTracksSingleTrack": "একক-ট্র্যাক",
|
"LabelTracksSingleTrack": "একক-ট্র্যাক",
|
||||||
|
"LabelTrailer": "আনুগমিক",
|
||||||
"LabelType": "টাইপ",
|
"LabelType": "টাইপ",
|
||||||
"LabelUnabridged": "অসংলগ্ন",
|
"LabelUnabridged": "অসংলগ্ন",
|
||||||
"LabelUndo": "পূর্বাবস্থা",
|
"LabelUndo": "পূর্বাবস্থা",
|
||||||
@@ -617,10 +663,13 @@
|
|||||||
"LabelUpdateDetailsHelp": "একটি মিল থাকা অবস্থায় নির্বাচিত বইগুলির বিদ্যমান বিবরণ ওভাররাইট করার অনুমতি দিন",
|
"LabelUpdateDetailsHelp": "একটি মিল থাকা অবস্থায় নির্বাচিত বইগুলির বিদ্যমান বিবরণ ওভাররাইট করার অনুমতি দিন",
|
||||||
"LabelUpdatedAt": "আপডেট করা হয়েছে",
|
"LabelUpdatedAt": "আপডেট করা হয়েছে",
|
||||||
"LabelUploaderDragAndDrop": "ফাইল বা ফোল্ডার টেনে আনুন এবং ফেলে দিন",
|
"LabelUploaderDragAndDrop": "ফাইল বা ফোল্ডার টেনে আনুন এবং ফেলে দিন",
|
||||||
|
"LabelUploaderDragAndDropFilesOnly": "ফাইল টেনে আনুন",
|
||||||
"LabelUploaderDropFiles": "ফাইলগুলো ফেলে দিন",
|
"LabelUploaderDropFiles": "ফাইলগুলো ফেলে দিন",
|
||||||
"LabelUploaderItemFetchMetadataHelp": "স্বয়ংক্রিয়ভাবে শিরোনাম, লেখক এবং সিরিজ আনুন",
|
"LabelUploaderItemFetchMetadataHelp": "স্বয়ংক্রিয়ভাবে শিরোনাম, লেখক এবং সিরিজ আনুন",
|
||||||
|
"LabelUseAdvancedOptions": "উন্নত বিকল্প ব্যবহার করুন",
|
||||||
"LabelUseChapterTrack": "অধ্যায় ট্র্যাক ব্যবহার করুন",
|
"LabelUseChapterTrack": "অধ্যায় ট্র্যাক ব্যবহার করুন",
|
||||||
"LabelUseFullTrack": "সম্পূর্ণ ট্র্যাক ব্যবহার করুন",
|
"LabelUseFullTrack": "সম্পূর্ণ ট্র্যাক ব্যবহার করুন",
|
||||||
|
"LabelUseZeroForUnlimited": "অসীমের জন্য 0 ব্যবহার করুন",
|
||||||
"LabelUser": "ব্যবহারকারী",
|
"LabelUser": "ব্যবহারকারী",
|
||||||
"LabelUsername": "ব্যবহারকারীর নাম",
|
"LabelUsername": "ব্যবহারকারীর নাম",
|
||||||
"LabelValue": "মান",
|
"LabelValue": "মান",
|
||||||
@@ -667,6 +716,7 @@
|
|||||||
"MessageConfirmDeleteMetadataProvider": "আপনি কি নিশ্চিতভাবে কাস্টম মেটাডেটা প্রদানকারী \"{0}\" মুছতে চান?",
|
"MessageConfirmDeleteMetadataProvider": "আপনি কি নিশ্চিতভাবে কাস্টম মেটাডেটা প্রদানকারী \"{0}\" মুছতে চান?",
|
||||||
"MessageConfirmDeleteNotification": "আপনি কি নিশ্চিতভাবে এই বিজ্ঞপ্তিটি মুছতে চান?",
|
"MessageConfirmDeleteNotification": "আপনি কি নিশ্চিতভাবে এই বিজ্ঞপ্তিটি মুছতে চান?",
|
||||||
"MessageConfirmDeleteSession": "আপনি কি নিশ্চিত আপনি এই অধিবেশন মুছে দিতে চান?",
|
"MessageConfirmDeleteSession": "আপনি কি নিশ্চিত আপনি এই অধিবেশন মুছে দিতে চান?",
|
||||||
|
"MessageConfirmEmbedMetadataInAudioFiles": "আপনি কি {0}টি অডিও ফাইলে মেটাডেটা এম্বেড করার বিষয়ে নিশ্চিত?",
|
||||||
"MessageConfirmForceReScan": "আপনি কি নিশ্চিত যে আপনি জোর করে পুনরায় স্ক্যান করতে চান?",
|
"MessageConfirmForceReScan": "আপনি কি নিশ্চিত যে আপনি জোর করে পুনরায় স্ক্যান করতে চান?",
|
||||||
"MessageConfirmMarkAllEpisodesFinished": "আপনি কি নিশ্চিত যে আপনি সমস্ত পর্ব সমাপ্ত হিসাবে চিহ্নিত করতে চান?",
|
"MessageConfirmMarkAllEpisodesFinished": "আপনি কি নিশ্চিত যে আপনি সমস্ত পর্ব সমাপ্ত হিসাবে চিহ্নিত করতে চান?",
|
||||||
"MessageConfirmMarkAllEpisodesNotFinished": "আপনি কি নিশ্চিত যে আপনি সমস্ত পর্বকে শেষ হয়নি বলে চিহ্নিত করতে চান?",
|
"MessageConfirmMarkAllEpisodesNotFinished": "আপনি কি নিশ্চিত যে আপনি সমস্ত পর্বকে শেষ হয়নি বলে চিহ্নিত করতে চান?",
|
||||||
@@ -678,6 +728,7 @@
|
|||||||
"MessageConfirmPurgeCache": "ক্যাশে পরিষ্কারক <code>/metadata/cache</code>-এ সম্পূর্ণ ডিরেক্টরি মুছে ফেলবে। <br /><br />আপনি কি নিশ্চিত আপনি ক্যাশে ডিরেক্টরি সরাতে চান?",
|
"MessageConfirmPurgeCache": "ক্যাশে পরিষ্কারক <code>/metadata/cache</code>-এ সম্পূর্ণ ডিরেক্টরি মুছে ফেলবে। <br /><br />আপনি কি নিশ্চিত আপনি ক্যাশে ডিরেক্টরি সরাতে চান?",
|
||||||
"MessageConfirmPurgeItemsCache": "আইটেম ক্যাশে পরিষ্কারক <code>/metadata/cache/items</code>-এ সম্পূর্ণ ডিরেক্টরি মুছে ফেলবে।<br />আপনি কি নিশ্চিত?",
|
"MessageConfirmPurgeItemsCache": "আইটেম ক্যাশে পরিষ্কারক <code>/metadata/cache/items</code>-এ সম্পূর্ণ ডিরেক্টরি মুছে ফেলবে।<br />আপনি কি নিশ্চিত?",
|
||||||
"MessageConfirmQuickEmbed": "সতর্কতা! দ্রুত এম্বেড আপনার অডিও ফাইলের ব্যাকআপ করবে না। নিশ্চিত করুন যে আপনার অডিও ফাইলগুলির একটি ব্যাকআপ আছে। <br><br>আপনি কি চালিয়ে যেতে চান?",
|
"MessageConfirmQuickEmbed": "সতর্কতা! দ্রুত এম্বেড আপনার অডিও ফাইলের ব্যাকআপ করবে না। নিশ্চিত করুন যে আপনার অডিও ফাইলগুলির একটি ব্যাকআপ আছে। <br><br>আপনি কি চালিয়ে যেতে চান?",
|
||||||
|
"MessageConfirmQuickMatchEpisodes": "একটি মিল পাওয়া গেলে দ্রুত ম্যাচিং পর্বগুলি বিস্তারিত ওভাররাইট করবে। শুধুমাত্র অতুলনীয় পর্ব আপডেট করা হবে। আপনি কি নিশ্চিত?",
|
||||||
"MessageConfirmReScanLibraryItems": "আপনি কি নিশ্চিত যে আপনি {0}টি আইটেম পুনরায় স্ক্যান করতে চান?",
|
"MessageConfirmReScanLibraryItems": "আপনি কি নিশ্চিত যে আপনি {0}টি আইটেম পুনরায় স্ক্যান করতে চান?",
|
||||||
"MessageConfirmRemoveAllChapters": "আপনি কি নিশ্চিত যে আপনি সমস্ত অধ্যায় সরাতে চান?",
|
"MessageConfirmRemoveAllChapters": "আপনি কি নিশ্চিত যে আপনি সমস্ত অধ্যায় সরাতে চান?",
|
||||||
"MessageConfirmRemoveAuthor": "আপনি কি নিশ্চিত যে আপনি লেখক \"{0}\" অপসারণ করতে চান?",
|
"MessageConfirmRemoveAuthor": "আপনি কি নিশ্চিত যে আপনি লেখক \"{0}\" অপসারণ করতে চান?",
|
||||||
@@ -685,6 +736,7 @@
|
|||||||
"MessageConfirmRemoveEpisode": "আপনি কি নিশ্চিত আপনি \"{0}\" পর্বটি সরাতে চান?",
|
"MessageConfirmRemoveEpisode": "আপনি কি নিশ্চিত আপনি \"{0}\" পর্বটি সরাতে চান?",
|
||||||
"MessageConfirmRemoveEpisodes": "আপনি কি নিশ্চিত যে আপনি {0}টি পর্ব সরাতে চান?",
|
"MessageConfirmRemoveEpisodes": "আপনি কি নিশ্চিত যে আপনি {0}টি পর্ব সরাতে চান?",
|
||||||
"MessageConfirmRemoveListeningSessions": "আপনি কি নিশ্চিত যে আপনি {0}টি শোনার সেশন সরাতে চান?",
|
"MessageConfirmRemoveListeningSessions": "আপনি কি নিশ্চিত যে আপনি {0}টি শোনার সেশন সরাতে চান?",
|
||||||
|
"MessageConfirmRemoveMetadataFiles": "আপনি কি আপনার লাইব্রেরি আইটেম ফোল্ডারে থাকা সমস্ত মেটাডেটা {0} ফাইল মুছে ফেলার বিষয়ে নিশ্চিত?",
|
||||||
"MessageConfirmRemoveNarrator": "আপনি কি \"{0}\" বর্ণনাকারীকে সরানোর বিষয়ে নিশ্চিত?",
|
"MessageConfirmRemoveNarrator": "আপনি কি \"{0}\" বর্ণনাকারীকে সরানোর বিষয়ে নিশ্চিত?",
|
||||||
"MessageConfirmRemovePlaylist": "আপনি কি নিশ্চিত যে আপনি আপনার প্লেলিস্ট \"{0}\" সরাতে চান?",
|
"MessageConfirmRemovePlaylist": "আপনি কি নিশ্চিত যে আপনি আপনার প্লেলিস্ট \"{0}\" সরাতে চান?",
|
||||||
"MessageConfirmRenameGenre": "আপনি কি নিশ্চিত যে আপনি সমস্ত আইটেমের জন্য \"{0}\" ধারার নাম পরিবর্তন করে \"{1}\" করতে চান?",
|
"MessageConfirmRenameGenre": "আপনি কি নিশ্চিত যে আপনি সমস্ত আইটেমের জন্য \"{0}\" ধারার নাম পরিবর্তন করে \"{1}\" করতে চান?",
|
||||||
@@ -700,6 +752,7 @@
|
|||||||
"MessageDragFilesIntoTrackOrder": "সঠিক ট্র্যাক অর্ডারে ফাইল টেনে আনুন",
|
"MessageDragFilesIntoTrackOrder": "সঠিক ট্র্যাক অর্ডারে ফাইল টেনে আনুন",
|
||||||
"MessageEmbedFailed": "এম্বেড ব্যর্থ হয়েছে!",
|
"MessageEmbedFailed": "এম্বেড ব্যর্থ হয়েছে!",
|
||||||
"MessageEmbedFinished": "এম্বেড করা শেষ!",
|
"MessageEmbedFinished": "এম্বেড করা শেষ!",
|
||||||
|
"MessageEmbedQueue": "মেটাডেটা এম্বেডের জন্য সারিবদ্ধ ({0} সারিতে)",
|
||||||
"MessageEpisodesQueuedForDownload": "{0} পর্ব(গুলি) ডাউনলোডের জন্য সারিবদ্ধ",
|
"MessageEpisodesQueuedForDownload": "{0} পর্ব(গুলি) ডাউনলোডের জন্য সারিবদ্ধ",
|
||||||
"MessageEreaderDevices": "ই-বুক সরবরাহ নিশ্চিত করতে, আপনাকে নীচে তালিকাভুক্ত প্রতিটি ডিভাইসের জন্য একটি বৈধ প্রেরক হিসাবে উপরের ইমেল ঠিকানাটি যুক্ত করতে হতে পারে।",
|
"MessageEreaderDevices": "ই-বুক সরবরাহ নিশ্চিত করতে, আপনাকে নীচে তালিকাভুক্ত প্রতিটি ডিভাইসের জন্য একটি বৈধ প্রেরক হিসাবে উপরের ইমেল ঠিকানাটি যুক্ত করতে হতে পারে।",
|
||||||
"MessageFeedURLWillBe": "ফিড URL হবে {0}",
|
"MessageFeedURLWillBe": "ফিড URL হবে {0}",
|
||||||
@@ -744,6 +797,7 @@
|
|||||||
"MessageNoLogs": "কোনও লগ নেই",
|
"MessageNoLogs": "কোনও লগ নেই",
|
||||||
"MessageNoMediaProgress": "মিডিয়া অগ্রগতি নেই",
|
"MessageNoMediaProgress": "মিডিয়া অগ্রগতি নেই",
|
||||||
"MessageNoNotifications": "কোনো বিজ্ঞপ্তি নেই",
|
"MessageNoNotifications": "কোনো বিজ্ঞপ্তি নেই",
|
||||||
|
"MessageNoPodcastFeed": "অবৈধ পডকাস্ট: কোনো ফিড নেই",
|
||||||
"MessageNoPodcastsFound": "কোন পডকাস্ট পাওয়া যায়নি",
|
"MessageNoPodcastsFound": "কোন পডকাস্ট পাওয়া যায়নি",
|
||||||
"MessageNoResults": "কোন ফলাফল নেই",
|
"MessageNoResults": "কোন ফলাফল নেই",
|
||||||
"MessageNoSearchResultsFor": "\"{0}\" এর জন্য কোন অনুসন্ধান ফলাফল নেই",
|
"MessageNoSearchResultsFor": "\"{0}\" এর জন্য কোন অনুসন্ধান ফলাফল নেই",
|
||||||
@@ -760,6 +814,10 @@
|
|||||||
"MessagePlaylistCreateFromCollection": "সংগ্রহ থেকে প্লেলিস্ট তৈরি করুন",
|
"MessagePlaylistCreateFromCollection": "সংগ্রহ থেকে প্লেলিস্ট তৈরি করুন",
|
||||||
"MessagePleaseWait": "অনুগ্রহ করে অপেক্ষা করুন..।",
|
"MessagePleaseWait": "অনুগ্রহ করে অপেক্ষা করুন..।",
|
||||||
"MessagePodcastHasNoRSSFeedForMatching": "পডকাস্টের সাথে মিলের জন্য ব্যবহার করার জন্য কোন RSS ফিড ইউআরএল নেই",
|
"MessagePodcastHasNoRSSFeedForMatching": "পডকাস্টের সাথে মিলের জন্য ব্যবহার করার জন্য কোন RSS ফিড ইউআরএল নেই",
|
||||||
|
"MessagePodcastSearchField": "অনুসন্ধান শব্দ বা RSS ফিড URL লিখুন",
|
||||||
|
"MessageQuickEmbedInProgress": "দ্রুত এম্বেড করা হচ্ছে",
|
||||||
|
"MessageQuickEmbedQueue": "দ্রুত এম্বেড করার জন্য সারিবদ্ধ ({0} সারিতে)",
|
||||||
|
"MessageQuickMatchAllEpisodes": "দ্রুত ম্যাচ সব পর্ব",
|
||||||
"MessageQuickMatchDescription": "খালি আইটেমের বিশদ বিবরণ এবং '{0}' থেকে প্রথম ম্যাচের ফলাফলের সাথে কভার করুন। সার্ভার সেটিং সক্ষম না থাকলে বিশদ ওভাররাইট করে না।",
|
"MessageQuickMatchDescription": "খালি আইটেমের বিশদ বিবরণ এবং '{0}' থেকে প্রথম ম্যাচের ফলাফলের সাথে কভার করুন। সার্ভার সেটিং সক্ষম না থাকলে বিশদ ওভাররাইট করে না।",
|
||||||
"MessageRemoveChapter": "অধ্যায় সরান",
|
"MessageRemoveChapter": "অধ্যায় সরান",
|
||||||
"MessageRemoveEpisodes": "{0}টি পর্ব(গুলি) সরান",
|
"MessageRemoveEpisodes": "{0}টি পর্ব(গুলি) সরান",
|
||||||
@@ -802,6 +860,9 @@
|
|||||||
"MessageTaskOpmlImportFeedPodcastExists": "পডকাস্ট আগে থেকেই পাথে বিদ্যমান",
|
"MessageTaskOpmlImportFeedPodcastExists": "পডকাস্ট আগে থেকেই পাথে বিদ্যমান",
|
||||||
"MessageTaskOpmlImportFeedPodcastFailed": "পডকাস্ট তৈরি করতে ব্যর্থ",
|
"MessageTaskOpmlImportFeedPodcastFailed": "পডকাস্ট তৈরি করতে ব্যর্থ",
|
||||||
"MessageTaskOpmlImportFinished": "{0}টি পডকাস্ট যোগ করা হয়েছে",
|
"MessageTaskOpmlImportFinished": "{0}টি পডকাস্ট যোগ করা হয়েছে",
|
||||||
|
"MessageTaskOpmlParseFailed": "OPML ফাইল পার্স করতে ব্যর্থ হয়েছে",
|
||||||
|
"MessageTaskOpmlParseFastFail": "অবৈধ OPML ফাইল <opml> ট্যাগ পাওয়া যায়নি বা একটি <outline> ট্যাগ পাওয়া যায়নি",
|
||||||
|
"MessageTaskOpmlParseNoneFound": "OPML ফাইলে কোনো ফিড পাওয়া যায়নি",
|
||||||
"MessageTaskScanItemsAdded": "{0}টি করা হয়েছে",
|
"MessageTaskScanItemsAdded": "{0}টি করা হয়েছে",
|
||||||
"MessageTaskScanItemsMissing": "{0}টি অনুপস্থিত",
|
"MessageTaskScanItemsMissing": "{0}টি অনুপস্থিত",
|
||||||
"MessageTaskScanItemsUpdated": "{0} টি আপডেট করা হয়েছে",
|
"MessageTaskScanItemsUpdated": "{0} টি আপডেট করা হয়েছে",
|
||||||
@@ -826,6 +887,10 @@
|
|||||||
"NoteUploaderFoldersWithMediaFiles": "মিডিয়া ফাইল সহ ফোল্ডারগুলি আলাদা লাইব্রেরি আইটেম হিসাবে পরিচালনা করা হবে।",
|
"NoteUploaderFoldersWithMediaFiles": "মিডিয়া ফাইল সহ ফোল্ডারগুলি আলাদা লাইব্রেরি আইটেম হিসাবে পরিচালনা করা হবে।",
|
||||||
"NoteUploaderOnlyAudioFiles": "যদি শুধুমাত্র অডিও ফাইল আপলোড করা হয় তবে প্রতিটি অডিও ফাইল একটি পৃথক অডিওবুক হিসাবে পরিচালনা করা হবে।",
|
"NoteUploaderOnlyAudioFiles": "যদি শুধুমাত্র অডিও ফাইল আপলোড করা হয় তবে প্রতিটি অডিও ফাইল একটি পৃথক অডিওবুক হিসাবে পরিচালনা করা হবে।",
|
||||||
"NoteUploaderUnsupportedFiles": "অসমর্থিত ফাইলগুলি উপেক্ষা করা হয়। একটি ফোল্ডার বেছে নেওয়া বা ফেলে দেওয়ার সময়, আইটেম ফোল্ডারে নেই এমন অন্যান্য ফাইলগুলি উপেক্ষা করা হয়।",
|
"NoteUploaderUnsupportedFiles": "অসমর্থিত ফাইলগুলি উপেক্ষা করা হয়। একটি ফোল্ডার বেছে নেওয়া বা ফেলে দেওয়ার সময়, আইটেম ফোল্ডারে নেই এমন অন্যান্য ফাইলগুলি উপেক্ষা করা হয়।",
|
||||||
|
"NotificationOnBackupCompletedDescription": "ব্যাকআপ সম্পূর্ণ হলে ট্রিগার হবে",
|
||||||
|
"NotificationOnBackupFailedDescription": "ব্যাকআপ ব্যর্থ হলে ট্রিগার হবে",
|
||||||
|
"NotificationOnEpisodeDownloadedDescription": "একটি পডকাস্ট পর্ব স্বয়ংক্রিয়ভাবে ডাউনলোড হলে ট্রিগার হবে",
|
||||||
|
"NotificationOnTestDescription": "বিজ্ঞপ্তি সিস্টেম পরীক্ষার জন্য ইভেন্ট",
|
||||||
"PlaceholderNewCollection": "নতুন সংগ্রহের নাম",
|
"PlaceholderNewCollection": "নতুন সংগ্রহের নাম",
|
||||||
"PlaceholderNewFolderPath": "নতুন ফোল্ডার পথ",
|
"PlaceholderNewFolderPath": "নতুন ফোল্ডার পথ",
|
||||||
"PlaceholderNewPlaylist": "নতুন প্লেলিস্টের নাম",
|
"PlaceholderNewPlaylist": "নতুন প্লেলিস্টের নাম",
|
||||||
@@ -851,6 +916,7 @@
|
|||||||
"StatsYearInReview": "বাৎসরিক পর্যালোচনা",
|
"StatsYearInReview": "বাৎসরিক পর্যালোচনা",
|
||||||
"ToastAccountUpdateSuccess": "অ্যাকাউন্ট আপডেট করা হয়েছে",
|
"ToastAccountUpdateSuccess": "অ্যাকাউন্ট আপডেট করা হয়েছে",
|
||||||
"ToastAppriseUrlRequired": "একটি Apprise ইউআরএল লিখতে হবে",
|
"ToastAppriseUrlRequired": "একটি Apprise ইউআরএল লিখতে হবে",
|
||||||
|
"ToastAsinRequired": "ASIN প্রয়োজন",
|
||||||
"ToastAuthorImageRemoveSuccess": "লেখকের ছবি সরানো হয়েছে",
|
"ToastAuthorImageRemoveSuccess": "লেখকের ছবি সরানো হয়েছে",
|
||||||
"ToastAuthorNotFound": "লেখক \"{0}\" খুঁজে পাওয়া যায়নি",
|
"ToastAuthorNotFound": "লেখক \"{0}\" খুঁজে পাওয়া যায়নি",
|
||||||
"ToastAuthorRemoveSuccess": "লেখক সরানো হয়েছে",
|
"ToastAuthorRemoveSuccess": "লেখক সরানো হয়েছে",
|
||||||
@@ -870,6 +936,8 @@
|
|||||||
"ToastBackupUploadSuccess": "ব্যাকআপ আপলোড হয়েছে",
|
"ToastBackupUploadSuccess": "ব্যাকআপ আপলোড হয়েছে",
|
||||||
"ToastBatchDeleteFailed": "ব্যাচ মুছে ফেলতে ব্যর্থ হয়েছে",
|
"ToastBatchDeleteFailed": "ব্যাচ মুছে ফেলতে ব্যর্থ হয়েছে",
|
||||||
"ToastBatchDeleteSuccess": "ব্যাচ মুছে ফেলা সফল হয়েছে",
|
"ToastBatchDeleteSuccess": "ব্যাচ মুছে ফেলা সফল হয়েছে",
|
||||||
|
"ToastBatchQuickMatchFailed": "ব্যাচ কুইক ম্যাচ ব্যর্থ!",
|
||||||
|
"ToastBatchQuickMatchStarted": "{0}টি বইয়ের ব্যাচ কুইক ম্যাচ শুরু হয়েছে!",
|
||||||
"ToastBatchUpdateFailed": "ব্যাচ আপডেট ব্যর্থ হয়েছে",
|
"ToastBatchUpdateFailed": "ব্যাচ আপডেট ব্যর্থ হয়েছে",
|
||||||
"ToastBatchUpdateSuccess": "ব্যাচ আপডেট সাফল্য",
|
"ToastBatchUpdateSuccess": "ব্যাচ আপডেট সাফল্য",
|
||||||
"ToastBookmarkCreateFailed": "বুকমার্ক তৈরি করতে ব্যর্থ",
|
"ToastBookmarkCreateFailed": "বুকমার্ক তৈরি করতে ব্যর্থ",
|
||||||
@@ -881,6 +949,7 @@
|
|||||||
"ToastChaptersHaveErrors": "অধ্যায়ে ত্রুটি আছে",
|
"ToastChaptersHaveErrors": "অধ্যায়ে ত্রুটি আছে",
|
||||||
"ToastChaptersMustHaveTitles": "অধ্যায়ের শিরোনাম থাকতে হবে",
|
"ToastChaptersMustHaveTitles": "অধ্যায়ের শিরোনাম থাকতে হবে",
|
||||||
"ToastChaptersRemoved": "অধ্যায়গুলো মুছে ফেলা হয়েছে",
|
"ToastChaptersRemoved": "অধ্যায়গুলো মুছে ফেলা হয়েছে",
|
||||||
|
"ToastChaptersUpdated": "অধ্যায় আপডেট করা হয়েছে",
|
||||||
"ToastCollectionItemsAddFailed": "আইটেম(গুলি) সংগ্রহে যোগ করা ব্যর্থ হয়েছে",
|
"ToastCollectionItemsAddFailed": "আইটেম(গুলি) সংগ্রহে যোগ করা ব্যর্থ হয়েছে",
|
||||||
"ToastCollectionItemsAddSuccess": "আইটেম(গুলি) সংগ্রহে যোগ করা সফল হয়েছে",
|
"ToastCollectionItemsAddSuccess": "আইটেম(গুলি) সংগ্রহে যোগ করা সফল হয়েছে",
|
||||||
"ToastCollectionItemsRemoveSuccess": "আইটেম(গুলি) সংগ্রহ থেকে সরানো হয়েছে",
|
"ToastCollectionItemsRemoveSuccess": "আইটেম(গুলি) সংগ্রহ থেকে সরানো হয়েছে",
|
||||||
@@ -898,11 +967,14 @@
|
|||||||
"ToastEncodeCancelSucces": "এনকোড বাতিল করা হয়েছে",
|
"ToastEncodeCancelSucces": "এনকোড বাতিল করা হয়েছে",
|
||||||
"ToastEpisodeDownloadQueueClearFailed": "সারি সাফ করতে ব্যর্থ হয়েছে",
|
"ToastEpisodeDownloadQueueClearFailed": "সারি সাফ করতে ব্যর্থ হয়েছে",
|
||||||
"ToastEpisodeDownloadQueueClearSuccess": "পর্ব ডাউনলোড সারি পরিষ্কার করা হয়েছে",
|
"ToastEpisodeDownloadQueueClearSuccess": "পর্ব ডাউনলোড সারি পরিষ্কার করা হয়েছে",
|
||||||
|
"ToastEpisodeUpdateSuccess": "{0}টি পর্ব আপডেট করা হয়েছে",
|
||||||
"ToastErrorCannotShare": "এই ডিভাইসে স্থানীয়ভাবে শেয়ার করা যাবে না",
|
"ToastErrorCannotShare": "এই ডিভাইসে স্থানীয়ভাবে শেয়ার করা যাবে না",
|
||||||
"ToastFailedToLoadData": "ডেটা লোড করা যায়নি",
|
"ToastFailedToLoadData": "ডেটা লোড করা যায়নি",
|
||||||
|
"ToastFailedToMatch": "মেলাতে ব্যর্থ হয়েছে",
|
||||||
"ToastFailedToShare": "শেয়ার করতে ব্যর্থ",
|
"ToastFailedToShare": "শেয়ার করতে ব্যর্থ",
|
||||||
"ToastFailedToUpdate": "আপডেট করতে ব্যর্থ হয়েছে",
|
"ToastFailedToUpdate": "আপডেট করতে ব্যর্থ হয়েছে",
|
||||||
"ToastInvalidImageUrl": "অকার্যকর ছবির ইউআরএল",
|
"ToastInvalidImageUrl": "অকার্যকর ছবির ইউআরএল",
|
||||||
|
"ToastInvalidMaxEpisodesToDownload": "ডাউনলোড করার জন্য অবৈধ সর্বোচ্চ পর্ব",
|
||||||
"ToastInvalidUrl": "অকার্যকর ইউআরএল",
|
"ToastInvalidUrl": "অকার্যকর ইউআরএল",
|
||||||
"ToastItemCoverUpdateSuccess": "আইটেম কভার আপডেট করা হয়েছে",
|
"ToastItemCoverUpdateSuccess": "আইটেম কভার আপডেট করা হয়েছে",
|
||||||
"ToastItemDeletedFailed": "আইটেম মুছে ফেলতে ব্যর্থ",
|
"ToastItemDeletedFailed": "আইটেম মুছে ফেলতে ব্যর্থ",
|
||||||
@@ -920,14 +992,22 @@
|
|||||||
"ToastLibraryScanFailedToStart": "স্ক্যান শুরু করতে ব্যর্থ",
|
"ToastLibraryScanFailedToStart": "স্ক্যান শুরু করতে ব্যর্থ",
|
||||||
"ToastLibraryScanStarted": "লাইব্রেরি স্ক্যান শুরু হয়েছে",
|
"ToastLibraryScanStarted": "লাইব্রেরি স্ক্যান শুরু হয়েছে",
|
||||||
"ToastLibraryUpdateSuccess": "লাইব্রেরি \"{0}\" আপডেট করা হয়েছে",
|
"ToastLibraryUpdateSuccess": "লাইব্রেরি \"{0}\" আপডেট করা হয়েছে",
|
||||||
|
"ToastMatchAllAuthorsFailed": "সমস্ত লেখকের সাথে মিলতে ব্যর্থ হয়েছে",
|
||||||
|
"ToastMetadataFilesRemovedError": "মেটাডেটা সরানোর সময় ত্রুটি {0} ফাইল",
|
||||||
|
"ToastMetadataFilesRemovedNoneFound": "কোনো মেটাডেটা নেই।লাইব্রেরিতে {0} ফাইল পাওয়া গেছে",
|
||||||
|
"ToastMetadataFilesRemovedNoneRemoved": "কোনো মেটাডেটা নেই।{0} ফাইল সরানো হয়েছে",
|
||||||
|
"ToastMetadataFilesRemovedSuccess": "{0} মেটাডেটা৷{1} ফাইল সরানো হয়েছে",
|
||||||
|
"ToastMustHaveAtLeastOnePath": "অন্তত একটি পথ থাকতে হবে",
|
||||||
"ToastNameEmailRequired": "নাম এবং ইমেইল আবশ্যক",
|
"ToastNameEmailRequired": "নাম এবং ইমেইল আবশ্যক",
|
||||||
"ToastNameRequired": "নাম আবশ্যক",
|
"ToastNameRequired": "নাম আবশ্যক",
|
||||||
|
"ToastNewEpisodesFound": "{0}টি নতুন পর্ব পাওয়া গেছে",
|
||||||
"ToastNewUserCreatedFailed": "অ্যাকাউন্ট তৈরি করতে ব্যর্থ: \"{0}\"",
|
"ToastNewUserCreatedFailed": "অ্যাকাউন্ট তৈরি করতে ব্যর্থ: \"{0}\"",
|
||||||
"ToastNewUserCreatedSuccess": "নতুন একাউন্ট তৈরি হয়েছে",
|
"ToastNewUserCreatedSuccess": "নতুন একাউন্ট তৈরি হয়েছে",
|
||||||
"ToastNewUserLibraryError": "অন্তত একটি লাইব্রেরি নির্বাচন করতে হবে",
|
"ToastNewUserLibraryError": "অন্তত একটি লাইব্রেরি নির্বাচন করতে হবে",
|
||||||
"ToastNewUserPasswordError": "অন্তত একটি পাসওয়ার্ড থাকতে হবে, শুধুমাত্র রুট ব্যবহারকারীর একটি খালি পাসওয়ার্ড থাকতে পারে",
|
"ToastNewUserPasswordError": "অন্তত একটি পাসওয়ার্ড থাকতে হবে, শুধুমাত্র রুট ব্যবহারকারীর একটি খালি পাসওয়ার্ড থাকতে পারে",
|
||||||
"ToastNewUserTagError": "অন্তত একটি ট্যাগ নির্বাচন করতে হবে",
|
"ToastNewUserTagError": "অন্তত একটি ট্যাগ নির্বাচন করতে হবে",
|
||||||
"ToastNewUserUsernameError": "একটি ব্যবহারকারীর নাম লিখুন",
|
"ToastNewUserUsernameError": "একটি ব্যবহারকারীর নাম লিখুন",
|
||||||
|
"ToastNoNewEpisodesFound": "কোন নতুন পর্ব পাওয়া যায়নি",
|
||||||
"ToastNoUpdatesNecessary": "কোন আপডেটের প্রয়োজন নেই",
|
"ToastNoUpdatesNecessary": "কোন আপডেটের প্রয়োজন নেই",
|
||||||
"ToastNotificationCreateFailed": "বিজ্ঞপ্তি তৈরি করতে ব্যর্থ",
|
"ToastNotificationCreateFailed": "বিজ্ঞপ্তি তৈরি করতে ব্যর্থ",
|
||||||
"ToastNotificationDeleteFailed": "বিজ্ঞপ্তি মুছে ফেলতে ব্যর্থ",
|
"ToastNotificationDeleteFailed": "বিজ্ঞপ্তি মুছে ফেলতে ব্যর্থ",
|
||||||
@@ -946,6 +1026,7 @@
|
|||||||
"ToastPodcastGetFeedFailed": "পডকাস্ট ফিড পেতে ব্যর্থ হয়েছে",
|
"ToastPodcastGetFeedFailed": "পডকাস্ট ফিড পেতে ব্যর্থ হয়েছে",
|
||||||
"ToastPodcastNoEpisodesInFeed": "আরএসএস ফিডে কোনো পর্ব পাওয়া যায়নি",
|
"ToastPodcastNoEpisodesInFeed": "আরএসএস ফিডে কোনো পর্ব পাওয়া যায়নি",
|
||||||
"ToastPodcastNoRssFeed": "পডকাস্টের কোন আরএসএস ফিড নেই",
|
"ToastPodcastNoRssFeed": "পডকাস্টের কোন আরএসএস ফিড নেই",
|
||||||
|
"ToastProgressIsNotBeingSynced": "অগ্রগতি সিঙ্ক হচ্ছে না, প্লেব্যাক পুনরায় চালু করুন",
|
||||||
"ToastProviderCreatedFailed": "প্রদানকারী যোগ করতে ব্যর্থ হয়েছে",
|
"ToastProviderCreatedFailed": "প্রদানকারী যোগ করতে ব্যর্থ হয়েছে",
|
||||||
"ToastProviderCreatedSuccess": "নতুন প্রদানকারী যোগ করা হয়েছে",
|
"ToastProviderCreatedSuccess": "নতুন প্রদানকারী যোগ করা হয়েছে",
|
||||||
"ToastProviderNameAndUrlRequired": "নাম এবং ইউআরএল আবশ্যক",
|
"ToastProviderNameAndUrlRequired": "নাম এবং ইউআরএল আবশ্যক",
|
||||||
@@ -972,6 +1053,7 @@
|
|||||||
"ToastSessionCloseFailed": "অধিবেশন বন্ধ করতে ব্যর্থ হয়েছে",
|
"ToastSessionCloseFailed": "অধিবেশন বন্ধ করতে ব্যর্থ হয়েছে",
|
||||||
"ToastSessionDeleteFailed": "সেশন মুছে ফেলতে ব্যর্থ",
|
"ToastSessionDeleteFailed": "সেশন মুছে ফেলতে ব্যর্থ",
|
||||||
"ToastSessionDeleteSuccess": "সেশন মুছে ফেলা হয়েছে",
|
"ToastSessionDeleteSuccess": "সেশন মুছে ফেলা হয়েছে",
|
||||||
|
"ToastSleepTimerDone": "স্লিপ টাইমার হয়ে গেছে... zZzzZz",
|
||||||
"ToastSlugMustChange": "স্লাগে অবৈধ অক্ষর রয়েছে",
|
"ToastSlugMustChange": "স্লাগে অবৈধ অক্ষর রয়েছে",
|
||||||
"ToastSlugRequired": "স্লাগ আবশ্যক",
|
"ToastSlugRequired": "স্লাগ আবশ্যক",
|
||||||
"ToastSocketConnected": "সকেট সংযুক্ত",
|
"ToastSocketConnected": "সকেট সংযুক্ত",
|
||||||
|
|||||||
@@ -584,7 +584,7 @@
|
|||||||
"LabelSettingsStoreMetadataWithItemHelp": "Standardmäßig werden die Metadaten in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Metadaten als OPF-Datei (Textdatei) in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet",
|
"LabelSettingsStoreMetadataWithItemHelp": "Standardmäßig werden die Metadaten in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Metadaten als OPF-Datei (Textdatei) in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet",
|
||||||
"LabelSettingsTimeFormat": "Zeitformat",
|
"LabelSettingsTimeFormat": "Zeitformat",
|
||||||
"LabelShare": "Freigeben",
|
"LabelShare": "Freigeben",
|
||||||
"LabelShareOpen": "Freigabe",
|
"LabelShareOpen": "Freigeben",
|
||||||
"LabelShareURL": "Freigabe URL",
|
"LabelShareURL": "Freigabe URL",
|
||||||
"LabelShowAll": "Alles anzeigen",
|
"LabelShowAll": "Alles anzeigen",
|
||||||
"LabelShowSeconds": "Zeige Sekunden",
|
"LabelShowSeconds": "Zeige Sekunden",
|
||||||
@@ -663,6 +663,7 @@
|
|||||||
"LabelUpdateDetailsHelp": "Erlaube das Überschreiben bestehender Details für die ausgewählten Hörbücher, wenn eine Übereinstimmung gefunden wird",
|
"LabelUpdateDetailsHelp": "Erlaube das Überschreiben bestehender Details für die ausgewählten Hörbücher, wenn eine Übereinstimmung gefunden wird",
|
||||||
"LabelUpdatedAt": "Aktualisiert am",
|
"LabelUpdatedAt": "Aktualisiert am",
|
||||||
"LabelUploaderDragAndDrop": "Ziehen und Ablegen von Dateien oder Ordnern",
|
"LabelUploaderDragAndDrop": "Ziehen und Ablegen von Dateien oder Ordnern",
|
||||||
|
"LabelUploaderDragAndDropFilesOnly": "Dateien per Drag & Drop hierher ziehen",
|
||||||
"LabelUploaderDropFiles": "Dateien löschen",
|
"LabelUploaderDropFiles": "Dateien löschen",
|
||||||
"LabelUploaderItemFetchMetadataHelp": "Automatisches Aktualisieren von Titel, Autor und Serie",
|
"LabelUploaderItemFetchMetadataHelp": "Automatisches Aktualisieren von Titel, Autor und Serie",
|
||||||
"LabelUseAdvancedOptions": "Nutze Erweiterte Optionen",
|
"LabelUseAdvancedOptions": "Nutze Erweiterte Optionen",
|
||||||
@@ -727,7 +728,7 @@
|
|||||||
"MessageConfirmPurgeCache": "Cache leeren wird das ganze Verzeichnis <code>/metadata/cache</code> löschen. <br /><br />Bist du dir sicher, dass das Cache Verzeichnis gelöscht werden soll?",
|
"MessageConfirmPurgeCache": "Cache leeren wird das ganze Verzeichnis <code>/metadata/cache</code> löschen. <br /><br />Bist du dir sicher, dass das Cache Verzeichnis gelöscht werden soll?",
|
||||||
"MessageConfirmPurgeItemsCache": "Durch Elementcache leeren wird das gesamte Verzeichnis unter <code>/metadata/cache/items</code> gelöscht.<br />Bist du dir sicher?",
|
"MessageConfirmPurgeItemsCache": "Durch Elementcache leeren wird das gesamte Verzeichnis unter <code>/metadata/cache/items</code> gelöscht.<br />Bist du dir sicher?",
|
||||||
"MessageConfirmQuickEmbed": "Warnung! Audiodateien werden bei der Schnelleinbettung nicht gesichert! Achte darauf, dass du eine Sicherungskopie der Audiodateien besitzt. <br><br>Möchtest du fortfahren?",
|
"MessageConfirmQuickEmbed": "Warnung! Audiodateien werden bei der Schnelleinbettung nicht gesichert! Achte darauf, dass du eine Sicherungskopie der Audiodateien besitzt. <br><br>Möchtest du fortfahren?",
|
||||||
"MessageConfirmQuickMatchEpisodes": "Schnelles Zuordnen von Episoden überschreibt die Details, wenn eine Übereinstimmung gefunden wird. Nur nicht zugeordnete Episoden werden aktualisiert. Bist du sicher?",
|
"MessageConfirmQuickMatchEpisodes": "Schnellabgleich von Episoden überschreibt deren Details, wenn ein passender Eintrag gefunden wurde, wird aber nur auf bisher unbearbeitete Episoden angewendet. Wirklich fortfahren?",
|
||||||
"MessageConfirmReScanLibraryItems": "{0} Elemente werden erneut gescannt! Bist du dir sicher?",
|
"MessageConfirmReScanLibraryItems": "{0} Elemente werden erneut gescannt! Bist du dir sicher?",
|
||||||
"MessageConfirmRemoveAllChapters": "Alle Kapitel werden entfernt! Bist du dir sicher?",
|
"MessageConfirmRemoveAllChapters": "Alle Kapitel werden entfernt! Bist du dir sicher?",
|
||||||
"MessageConfirmRemoveAuthor": "Autor \"{0}\" wird enfernt! Bist du dir sicher?",
|
"MessageConfirmRemoveAuthor": "Autor \"{0}\" wird enfernt! Bist du dir sicher?",
|
||||||
@@ -832,7 +833,7 @@
|
|||||||
"MessageSetChaptersFromTracksDescription": "Kaitelerstellung basiert auf den existierenden einzelnen Audiodateien. Pro existierende Audiodatei wird 1 Kapitel erstellt, wobei deren Kapitelname aus dem Audiodateinamen extrahiert wird",
|
"MessageSetChaptersFromTracksDescription": "Kaitelerstellung basiert auf den existierenden einzelnen Audiodateien. Pro existierende Audiodatei wird 1 Kapitel erstellt, wobei deren Kapitelname aus dem Audiodateinamen extrahiert wird",
|
||||||
"MessageShareExpirationWillBe": "Läuft am <strong>{0}</strong> ab",
|
"MessageShareExpirationWillBe": "Läuft am <strong>{0}</strong> ab",
|
||||||
"MessageShareExpiresIn": "Läuft in {0} ab",
|
"MessageShareExpiresIn": "Läuft in {0} ab",
|
||||||
"MessageShareURLWillBe": "Der Freigabe Link wird <strong>{0}</strong> sein.",
|
"MessageShareURLWillBe": "Der Freigabe Link wird <strong>{0}</strong> sein",
|
||||||
"MessageStartPlaybackAtTime": "Start der Wiedergabe für \"{0}\" bei {1}?",
|
"MessageStartPlaybackAtTime": "Start der Wiedergabe für \"{0}\" bei {1}?",
|
||||||
"MessageTaskAudioFileNotWritable": "Die Audiodatei \"{0}\" ist schreibgeschützt",
|
"MessageTaskAudioFileNotWritable": "Die Audiodatei \"{0}\" ist schreibgeschützt",
|
||||||
"MessageTaskCanceledByUser": "Aufgabe vom Benutzer abgebrochen",
|
"MessageTaskCanceledByUser": "Aufgabe vom Benutzer abgebrochen",
|
||||||
@@ -1040,7 +1041,7 @@
|
|||||||
"ToastRenameFailed": "Umbenennen fehlgeschlagen",
|
"ToastRenameFailed": "Umbenennen fehlgeschlagen",
|
||||||
"ToastRescanFailed": "Erneut scannen fehlgeschlagen für {0}",
|
"ToastRescanFailed": "Erneut scannen fehlgeschlagen für {0}",
|
||||||
"ToastRescanRemoved": "Erneut scannen erledigt, Artikel wurde entfernt",
|
"ToastRescanRemoved": "Erneut scannen erledigt, Artikel wurde entfernt",
|
||||||
"ToastRescanUpToDate": "Erneut scannen erledigt, Artikel wahr auf dem neusten Stand",
|
"ToastRescanUpToDate": "Erneut scannen erledigt, Artikel war auf dem neusten Stand",
|
||||||
"ToastRescanUpdated": "Erneut scannen erledigt, Artikel wurde verändert",
|
"ToastRescanUpdated": "Erneut scannen erledigt, Artikel wurde verändert",
|
||||||
"ToastScanFailed": "Fehler beim scannen des Artikels der Bibliothek",
|
"ToastScanFailed": "Fehler beim scannen des Artikels der Bibliothek",
|
||||||
"ToastSelectAtLeastOneUser": "Wähle mindestens einen Benutzer aus",
|
"ToastSelectAtLeastOneUser": "Wähle mindestens einen Benutzer aus",
|
||||||
|
|||||||
@@ -679,6 +679,8 @@
|
|||||||
"LabelViewPlayerSettings": "View player settings",
|
"LabelViewPlayerSettings": "View player settings",
|
||||||
"LabelViewQueue": "View player queue",
|
"LabelViewQueue": "View player queue",
|
||||||
"LabelVolume": "Volume",
|
"LabelVolume": "Volume",
|
||||||
|
"LabelWebRedirectURLsDescription": "Authorize these URLs in your OAuth provider to allow redirection back to the web app after login:",
|
||||||
|
"LabelWebRedirectURLsSubfolder": "Subfolder for Redirect URLs",
|
||||||
"LabelWeekdaysToRun": "Weekdays to run",
|
"LabelWeekdaysToRun": "Weekdays to run",
|
||||||
"LabelXBooks": "{0} books",
|
"LabelXBooks": "{0} books",
|
||||||
"LabelXItems": "{0} items",
|
"LabelXItems": "{0} items",
|
||||||
|
|||||||
@@ -663,6 +663,7 @@
|
|||||||
"LabelUpdateDetailsHelp": "Permitir sobrescribir detalles existentes de los libros seleccionados cuando sean encontrados",
|
"LabelUpdateDetailsHelp": "Permitir sobrescribir detalles existentes de los libros seleccionados cuando sean encontrados",
|
||||||
"LabelUpdatedAt": "Actualizado En",
|
"LabelUpdatedAt": "Actualizado En",
|
||||||
"LabelUploaderDragAndDrop": "Arrastre y suelte archivos o carpetas",
|
"LabelUploaderDragAndDrop": "Arrastre y suelte archivos o carpetas",
|
||||||
|
"LabelUploaderDragAndDropFilesOnly": "Arrastrar y soltar archivos",
|
||||||
"LabelUploaderDropFiles": "Suelte los Archivos",
|
"LabelUploaderDropFiles": "Suelte los Archivos",
|
||||||
"LabelUploaderItemFetchMetadataHelp": "Buscar título, autor y series automáticamente",
|
"LabelUploaderItemFetchMetadataHelp": "Buscar título, autor y series automáticamente",
|
||||||
"LabelUseAdvancedOptions": "Usar opciones avanzadas",
|
"LabelUseAdvancedOptions": "Usar opciones avanzadas",
|
||||||
@@ -678,6 +679,8 @@
|
|||||||
"LabelViewPlayerSettings": "Ver los ajustes del reproductor",
|
"LabelViewPlayerSettings": "Ver los ajustes del reproductor",
|
||||||
"LabelViewQueue": "Ver Fila del Reproductor",
|
"LabelViewQueue": "Ver Fila del Reproductor",
|
||||||
"LabelVolume": "Volumen",
|
"LabelVolume": "Volumen",
|
||||||
|
"LabelWebRedirectURLsDescription": "Autorice estas URL en su proveedor OAuth para permitir la redirección a la aplicación web después de iniciar sesión:",
|
||||||
|
"LabelWebRedirectURLsSubfolder": "Subcarpeta para URL de redireccionamiento",
|
||||||
"LabelWeekdaysToRun": "Correr en Días de la Semana",
|
"LabelWeekdaysToRun": "Correr en Días de la Semana",
|
||||||
"LabelXBooks": "{0} libros",
|
"LabelXBooks": "{0} libros",
|
||||||
"LabelXItems": "{0} elementos",
|
"LabelXItems": "{0} elementos",
|
||||||
|
|||||||
@@ -663,6 +663,7 @@
|
|||||||
"LabelUpdateDetailsHelp": "Autoriser la mise à jour des détails existants lorsqu’une correspondance est trouvée",
|
"LabelUpdateDetailsHelp": "Autoriser la mise à jour des détails existants lorsqu’une correspondance est trouvée",
|
||||||
"LabelUpdatedAt": "Mis à jour à",
|
"LabelUpdatedAt": "Mis à jour à",
|
||||||
"LabelUploaderDragAndDrop": "Glisser et déposer des fichiers ou dossiers",
|
"LabelUploaderDragAndDrop": "Glisser et déposer des fichiers ou dossiers",
|
||||||
|
"LabelUploaderDragAndDropFilesOnly": "Glisser & déposer des fichiers",
|
||||||
"LabelUploaderDropFiles": "Déposer des fichiers",
|
"LabelUploaderDropFiles": "Déposer des fichiers",
|
||||||
"LabelUploaderItemFetchMetadataHelp": "Récupérer automatiquement le titre, l’auteur et la série",
|
"LabelUploaderItemFetchMetadataHelp": "Récupérer automatiquement le titre, l’auteur et la série",
|
||||||
"LabelUseAdvancedOptions": "Utiliser les options avancées",
|
"LabelUseAdvancedOptions": "Utiliser les options avancées",
|
||||||
@@ -869,10 +870,10 @@
|
|||||||
"MessageTaskScanningFileChanges": "Analyse des modifications du fichier dans « {0} »",
|
"MessageTaskScanningFileChanges": "Analyse des modifications du fichier dans « {0} »",
|
||||||
"MessageTaskScanningLibrary": "Analyse de la bibliothèque « {0} »",
|
"MessageTaskScanningLibrary": "Analyse de la bibliothèque « {0} »",
|
||||||
"MessageTaskTargetDirectoryNotWritable": "Le répertoire cible n’est pas accessible en écriture",
|
"MessageTaskTargetDirectoryNotWritable": "Le répertoire cible n’est pas accessible en écriture",
|
||||||
"MessageThinking": "Je cherche…",
|
"MessageThinking": "À la recherche de…",
|
||||||
"MessageUploaderItemFailed": "Échec du téléversement",
|
"MessageUploaderItemFailed": "Échec du téléversement",
|
||||||
"MessageUploaderItemSuccess": "Téléversement effectué !",
|
"MessageUploaderItemSuccess": "Téléversement effectué !",
|
||||||
"MessageUploading": "Téléversement…",
|
"MessageUploading": "Téléchargement…",
|
||||||
"MessageValidCronExpression": "Expression cron valide",
|
"MessageValidCronExpression": "Expression cron valide",
|
||||||
"MessageWatcherIsDisabledGlobally": "La surveillance est désactivée par un paramètre global du serveur",
|
"MessageWatcherIsDisabledGlobally": "La surveillance est désactivée par un paramètre global du serveur",
|
||||||
"MessageXLibraryIsEmpty": "La bibliothèque {0} est vide !",
|
"MessageXLibraryIsEmpty": "La bibliothèque {0} est vide !",
|
||||||
|
|||||||
@@ -271,7 +271,7 @@
|
|||||||
"LabelCollapseSubSeries": "Podserijale prikaži sažeto",
|
"LabelCollapseSubSeries": "Podserijale prikaži sažeto",
|
||||||
"LabelCollection": "Zbirka",
|
"LabelCollection": "Zbirka",
|
||||||
"LabelCollections": "Zbirke",
|
"LabelCollections": "Zbirke",
|
||||||
"LabelComplete": "Dovršeno",
|
"LabelComplete": "Potpuno",
|
||||||
"LabelConfirmPassword": "Potvrda zaporke",
|
"LabelConfirmPassword": "Potvrda zaporke",
|
||||||
"LabelContinueListening": "Nastavi slušati",
|
"LabelContinueListening": "Nastavi slušati",
|
||||||
"LabelContinueReading": "Nastavi čitati",
|
"LabelContinueReading": "Nastavi čitati",
|
||||||
@@ -532,7 +532,7 @@
|
|||||||
"LabelSelectAllEpisodes": "Označi sve nastavke",
|
"LabelSelectAllEpisodes": "Označi sve nastavke",
|
||||||
"LabelSelectEpisodesShowing": "Prikazujem {0} odabranih nastavaka",
|
"LabelSelectEpisodesShowing": "Prikazujem {0} odabranih nastavaka",
|
||||||
"LabelSelectUsers": "Označi korisnike",
|
"LabelSelectUsers": "Označi korisnike",
|
||||||
"LabelSendEbookToDevice": "Pošalji e-knjigu",
|
"LabelSendEbookToDevice": "Pošalji e-knjigu …",
|
||||||
"LabelSequence": "Slijed",
|
"LabelSequence": "Slijed",
|
||||||
"LabelSerial": "Serijal",
|
"LabelSerial": "Serijal",
|
||||||
"LabelSeries": "Serijal",
|
"LabelSeries": "Serijal",
|
||||||
@@ -567,7 +567,7 @@
|
|||||||
"LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Preostalo vrijeme je manje od (sekundi)",
|
"LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Preostalo vrijeme je manje od (sekundi)",
|
||||||
"LabelSettingsLibraryMarkAsFinishedWhen": "Označi medij dovršenim kada",
|
"LabelSettingsLibraryMarkAsFinishedWhen": "Označi medij dovršenim kada",
|
||||||
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Preskoči ranije knjige u funkciji Nastavi serijal",
|
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Preskoči ranije knjige u funkciji Nastavi serijal",
|
||||||
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Na polici početne stranice Nastavi serijal prikazuje se prva nezapočeta knjiga serijala koji imaju barem jednu dovršenu knjigu i nijednu započetu knjigu. Ako uključite ovu opciju, serijal će vam se nastaviti od zadnje dovršene knjige umjesto od prve nezapočete knjige.",
|
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Na polici početne stranice Nastavi serijal prikazuje se prva nezapočeta knjiga serijala koji imaju barem jednu dovršenu knjigu i nijednu započetu knjigu. Ako se ova opcija uključi serijal će nastaviti od zadnje dovršene knjige umjesto od prve nezapočete knjige.",
|
||||||
"LabelSettingsParseSubtitles": "Raščlani podnaslove",
|
"LabelSettingsParseSubtitles": "Raščlani podnaslove",
|
||||||
"LabelSettingsParseSubtitlesHelp": "Iz naziva mape zvučne knjige raščlanjuje podnaslov.<br>Podnaslov mora biti odvojen s \" - \"<br>npr. \"Naslov knjige - Ovo je podnaslov\" imat će podnaslov \"Ovo je podnaslov\"",
|
"LabelSettingsParseSubtitlesHelp": "Iz naziva mape zvučne knjige raščlanjuje podnaslov.<br>Podnaslov mora biti odvojen s \" - \"<br>npr. \"Naslov knjige - Ovo je podnaslov\" imat će podnaslov \"Ovo je podnaslov\"",
|
||||||
"LabelSettingsPreferMatchedMetadata": "Daj prednost meta-podatcima prepoznatih stavki",
|
"LabelSettingsPreferMatchedMetadata": "Daj prednost meta-podatcima prepoznatih stavki",
|
||||||
@@ -663,6 +663,7 @@
|
|||||||
"LabelUpdateDetailsHelp": "Dopusti prepisivanje postojećih podataka za odabrane knjige kada se prepoznaju",
|
"LabelUpdateDetailsHelp": "Dopusti prepisivanje postojećih podataka za odabrane knjige kada se prepoznaju",
|
||||||
"LabelUpdatedAt": "Ažurirano",
|
"LabelUpdatedAt": "Ažurirano",
|
||||||
"LabelUploaderDragAndDrop": "Pritisni i prevuci datoteke ili mape",
|
"LabelUploaderDragAndDrop": "Pritisni i prevuci datoteke ili mape",
|
||||||
|
"LabelUploaderDragAndDropFilesOnly": "Pritisni i prevuci datoteke",
|
||||||
"LabelUploaderDropFiles": "Ispusti datoteke",
|
"LabelUploaderDropFiles": "Ispusti datoteke",
|
||||||
"LabelUploaderItemFetchMetadataHelp": "Automatski dohvati naslov, autora i serijal",
|
"LabelUploaderItemFetchMetadataHelp": "Automatski dohvati naslov, autora i serijal",
|
||||||
"LabelUseAdvancedOptions": "Koristi se naprednim opcijama",
|
"LabelUseAdvancedOptions": "Koristi se naprednim opcijama",
|
||||||
@@ -678,6 +679,8 @@
|
|||||||
"LabelViewPlayerSettings": "Pogledaj postavke reproduktora",
|
"LabelViewPlayerSettings": "Pogledaj postavke reproduktora",
|
||||||
"LabelViewQueue": "Pogledaj redoslijed izvođenja reproduktora",
|
"LabelViewQueue": "Pogledaj redoslijed izvođenja reproduktora",
|
||||||
"LabelVolume": "Glasnoća",
|
"LabelVolume": "Glasnoća",
|
||||||
|
"LabelWebRedirectURLsDescription": "Autoriziraj ove URL-ove u svom pružatelju OAuth ovjere kako bi omogućio preusmjeravanje natrag na web-aplikaciju nakon prijave:",
|
||||||
|
"LabelWebRedirectURLsSubfolder": "Podmapa za URL-ove preusmjeravanja",
|
||||||
"LabelWeekdaysToRun": "Dani u tjednu za pokretanje",
|
"LabelWeekdaysToRun": "Dani u tjednu za pokretanje",
|
||||||
"LabelXBooks": "{0} knjiga",
|
"LabelXBooks": "{0} knjiga",
|
||||||
"LabelXItems": "{0} stavki",
|
"LabelXItems": "{0} stavki",
|
||||||
|
|||||||
@@ -663,6 +663,7 @@
|
|||||||
"LabelUpdateDetailsHelp": "Позволяет перезаписывать текущие подробности для выбранных книг если будут найдены",
|
"LabelUpdateDetailsHelp": "Позволяет перезаписывать текущие подробности для выбранных книг если будут найдены",
|
||||||
"LabelUpdatedAt": "Обновлено в",
|
"LabelUpdatedAt": "Обновлено в",
|
||||||
"LabelUploaderDragAndDrop": "Перетащите файлы или каталоги",
|
"LabelUploaderDragAndDrop": "Перетащите файлы или каталоги",
|
||||||
|
"LabelUploaderDragAndDropFilesOnly": "Перетаскивание файлов",
|
||||||
"LabelUploaderDropFiles": "Перетащите файлы",
|
"LabelUploaderDropFiles": "Перетащите файлы",
|
||||||
"LabelUploaderItemFetchMetadataHelp": "Автоматическое извлечение названия, автора и серии",
|
"LabelUploaderItemFetchMetadataHelp": "Автоматическое извлечение названия, автора и серии",
|
||||||
"LabelUseAdvancedOptions": "Используйте расширенные опции",
|
"LabelUseAdvancedOptions": "Используйте расширенные опции",
|
||||||
|
|||||||
@@ -184,7 +184,7 @@
|
|||||||
"HeaderScheduleEpisodeDownloads": "Načrtovanje samodejnega prenosa epizod",
|
"HeaderScheduleEpisodeDownloads": "Načrtovanje samodejnega prenosa epizod",
|
||||||
"HeaderScheduleLibraryScans": "Načrtuj samodejno pregledovanje knjižnice",
|
"HeaderScheduleLibraryScans": "Načrtuj samodejno pregledovanje knjižnice",
|
||||||
"HeaderSession": "Seja",
|
"HeaderSession": "Seja",
|
||||||
"HeaderSetBackupSchedule": "Nastavite urnik varnostnega kopiranja",
|
"HeaderSetBackupSchedule": "Nastavi urnik varnostnega kopiranja",
|
||||||
"HeaderSettings": "Nastavitve",
|
"HeaderSettings": "Nastavitve",
|
||||||
"HeaderSettingsDisplay": "Zaslon",
|
"HeaderSettingsDisplay": "Zaslon",
|
||||||
"HeaderSettingsExperimental": "Eksperimentalne funkcije",
|
"HeaderSettingsExperimental": "Eksperimentalne funkcije",
|
||||||
@@ -663,6 +663,7 @@
|
|||||||
"LabelUpdateDetailsHelp": "Dovoli prepisovanje obstoječih podrobnosti za izbrane knjige, ko se najde ujemanje",
|
"LabelUpdateDetailsHelp": "Dovoli prepisovanje obstoječih podrobnosti za izbrane knjige, ko se najde ujemanje",
|
||||||
"LabelUpdatedAt": "Posodobljeno ob",
|
"LabelUpdatedAt": "Posodobljeno ob",
|
||||||
"LabelUploaderDragAndDrop": "Povleci in spusti datoteke ali mape",
|
"LabelUploaderDragAndDrop": "Povleci in spusti datoteke ali mape",
|
||||||
|
"LabelUploaderDragAndDropFilesOnly": "Povleci in spusti datoteke",
|
||||||
"LabelUploaderDropFiles": "Spusti datoteke",
|
"LabelUploaderDropFiles": "Spusti datoteke",
|
||||||
"LabelUploaderItemFetchMetadataHelp": "Samodejno pridobi naslov, avtorja in serijo",
|
"LabelUploaderItemFetchMetadataHelp": "Samodejno pridobi naslov, avtorja in serijo",
|
||||||
"LabelUseAdvancedOptions": "Uporabi napredne možnosti",
|
"LabelUseAdvancedOptions": "Uporabi napredne možnosti",
|
||||||
@@ -829,7 +830,7 @@
|
|||||||
"MessageSearchResultsFor": "Rezultati iskanja za",
|
"MessageSearchResultsFor": "Rezultati iskanja za",
|
||||||
"MessageSelected": "{0} izbrano",
|
"MessageSelected": "{0} izbrano",
|
||||||
"MessageServerCouldNotBeReached": "Strežnika ni bilo mogoče doseči",
|
"MessageServerCouldNotBeReached": "Strežnika ni bilo mogoče doseči",
|
||||||
"MessageSetChaptersFromTracksDescription": "Nastavite poglavja z uporabo vsake zvočne datoteke kot poglavja in naslova poglavja kot imena zvočne datoteke",
|
"MessageSetChaptersFromTracksDescription": "Nastavi poglavja z uporabo vsake zvočne datoteke kot poglavja in naslova poglavja kot imena zvočne datoteke",
|
||||||
"MessageShareExpirationWillBe": "Potečeno bo <strong>{0}</strong>",
|
"MessageShareExpirationWillBe": "Potečeno bo <strong>{0}</strong>",
|
||||||
"MessageShareExpiresIn": "Poteče čez {0}",
|
"MessageShareExpiresIn": "Poteče čez {0}",
|
||||||
"MessageShareURLWillBe": "URL za skupno rabo bo <strong>{0}</strong>",
|
"MessageShareURLWillBe": "URL za skupno rabo bo <strong>{0}</strong>",
|
||||||
|
|||||||
@@ -663,6 +663,7 @@
|
|||||||
"LabelUpdateDetailsHelp": "Дозволити перезапис наявних подробиць обраних книг після віднайдення",
|
"LabelUpdateDetailsHelp": "Дозволити перезапис наявних подробиць обраних книг після віднайдення",
|
||||||
"LabelUpdatedAt": "Оновлення",
|
"LabelUpdatedAt": "Оновлення",
|
||||||
"LabelUploaderDragAndDrop": "Перетягніть файли або теки",
|
"LabelUploaderDragAndDrop": "Перетягніть файли або теки",
|
||||||
|
"LabelUploaderDragAndDropFilesOnly": "Перетягніть і скиньте файли",
|
||||||
"LabelUploaderDropFiles": "Перетягніть файли",
|
"LabelUploaderDropFiles": "Перетягніть файли",
|
||||||
"LabelUploaderItemFetchMetadataHelp": "Автоматично шукати назву, автора та серію",
|
"LabelUploaderItemFetchMetadataHelp": "Автоматично шукати назву, автора та серію",
|
||||||
"LabelUseAdvancedOptions": "Використовувати розширені налаштування",
|
"LabelUseAdvancedOptions": "Використовувати розширені налаштування",
|
||||||
|
|||||||
@@ -663,6 +663,7 @@
|
|||||||
"LabelUpdateDetailsHelp": "找到匹配项时允许覆盖所选书籍存在的详细信息",
|
"LabelUpdateDetailsHelp": "找到匹配项时允许覆盖所选书籍存在的详细信息",
|
||||||
"LabelUpdatedAt": "更新时间",
|
"LabelUpdatedAt": "更新时间",
|
||||||
"LabelUploaderDragAndDrop": "拖放文件或文件夹",
|
"LabelUploaderDragAndDrop": "拖放文件或文件夹",
|
||||||
|
"LabelUploaderDragAndDropFilesOnly": "拖放文件",
|
||||||
"LabelUploaderDropFiles": "删除文件",
|
"LabelUploaderDropFiles": "删除文件",
|
||||||
"LabelUploaderItemFetchMetadataHelp": "自动获取标题, 作者和系列",
|
"LabelUploaderItemFetchMetadataHelp": "自动获取标题, 作者和系列",
|
||||||
"LabelUseAdvancedOptions": "使用高级选项",
|
"LabelUseAdvancedOptions": "使用高级选项",
|
||||||
@@ -678,6 +679,8 @@
|
|||||||
"LabelViewPlayerSettings": "查看播放器设置",
|
"LabelViewPlayerSettings": "查看播放器设置",
|
||||||
"LabelViewQueue": "查看播放列表",
|
"LabelViewQueue": "查看播放列表",
|
||||||
"LabelVolume": "音量",
|
"LabelVolume": "音量",
|
||||||
|
"LabelWebRedirectURLsDescription": "在你的 OAuth 提供商中授权这些链接,以允许在登录后重定向回 Web 应用程序:",
|
||||||
|
"LabelWebRedirectURLsSubfolder": "重定向 URL 的子文件夹",
|
||||||
"LabelWeekdaysToRun": "工作日运行",
|
"LabelWeekdaysToRun": "工作日运行",
|
||||||
"LabelXBooks": "{0} 本书",
|
"LabelXBooks": "{0} 本书",
|
||||||
"LabelXItems": "{0} 项目",
|
"LabelXItems": "{0} 项目",
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.17.2",
|
"version": "2.17.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.17.2",
|
"version": "2.17.4",
|
||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^0.27.2",
|
"axios": "^0.27.2",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.17.2",
|
"version": "2.17.4",
|
||||||
"buildNumber": 1,
|
"buildNumber": 1,
|
||||||
"description": "Self-hosted audiobook and podcast server",
|
"description": "Self-hosted audiobook and podcast server",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
|
|||||||
@@ -41,6 +41,13 @@ Is there a feature you are looking for? [Suggest it](https://github.com/advplyr/
|
|||||||
|
|
||||||
Join us on [Discord](https://discord.gg/HQgCbd6E75)
|
Join us on [Discord](https://discord.gg/HQgCbd6E75)
|
||||||
|
|
||||||
|
### Demo
|
||||||
|
|
||||||
|
Check out the web client demo: https://audiobooks.dev/ (thanks for hosting [@Vito0912](https://github.com/Vito0912)!)
|
||||||
|
|
||||||
|
Username/password: `demo`/`demo` (user account)
|
||||||
|
|
||||||
|
|
||||||
### Android App (beta)
|
### Android App (beta)
|
||||||
|
|
||||||
Try it out on the [Google Play Store](https://play.google.com/store/apps/details?id=com.audiobookshelf.app)
|
Try it out on the [Google Play Store](https://play.google.com/store/apps/details?id=com.audiobookshelf.app)
|
||||||
|
|||||||
+4
-4
@@ -131,7 +131,7 @@ class Auth {
|
|||||||
{
|
{
|
||||||
client: openIdClient,
|
client: openIdClient,
|
||||||
params: {
|
params: {
|
||||||
redirect_uri: '/auth/openid/callback',
|
redirect_uri: `${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`,
|
||||||
scope: 'openid profile email'
|
scope: 'openid profile email'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -480,9 +480,9 @@ class Auth {
|
|||||||
// for the request to mobile-redirect and as such the session is not shared
|
// for the request to mobile-redirect and as such the session is not shared
|
||||||
this.openIdAuthSession.set(state, { mobile_redirect_uri: req.query.redirect_uri })
|
this.openIdAuthSession.set(state, { mobile_redirect_uri: req.query.redirect_uri })
|
||||||
|
|
||||||
redirectUri = new URL('/auth/openid/mobile-redirect', hostUrl).toString()
|
redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/mobile-redirect`, hostUrl).toString()
|
||||||
} else {
|
} else {
|
||||||
redirectUri = new URL('/auth/openid/callback', hostUrl).toString()
|
redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`, hostUrl).toString()
|
||||||
|
|
||||||
if (req.query.state) {
|
if (req.query.state) {
|
||||||
Logger.debug(`[Auth] Invalid state - not allowed on web openid flow`)
|
Logger.debug(`[Auth] Invalid state - not allowed on web openid flow`)
|
||||||
@@ -733,7 +733,7 @@ class Auth {
|
|||||||
const host = req.get('host')
|
const host = req.get('host')
|
||||||
// TODO: ABS does currently not support subfolders for installation
|
// TODO: ABS does currently not support subfolders for installation
|
||||||
// If we want to support it we need to include a config for the serverurl
|
// If we want to support it we need to include a config for the serverurl
|
||||||
postLogoutRedirectUri = `${protocol}://${host}/login`
|
postLogoutRedirectUri = `${protocol}://${host}${global.RouterBasePath}/login`
|
||||||
}
|
}
|
||||||
// else for openid-mobile we keep postLogoutRedirectUri on null
|
// 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
|
// nice would be to redirect to the app here, but for example Authentik does not implement
|
||||||
|
|||||||
@@ -406,11 +406,6 @@ class Database {
|
|||||||
return Promise.all(oldBooks.map((oldBook) => this.models.book.saveFromOld(oldBook)))
|
return Promise.all(oldBooks.map((oldBook) => this.models.book.saveFromOld(oldBook)))
|
||||||
}
|
}
|
||||||
|
|
||||||
removeLibrary(libraryId) {
|
|
||||||
if (!this.sequelize) return false
|
|
||||||
return this.models.library.removeById(libraryId)
|
|
||||||
}
|
|
||||||
|
|
||||||
createBulkCollectionBooks(collectionBooks) {
|
createBulkCollectionBooks(collectionBooks) {
|
||||||
if (!this.sequelize) return false
|
if (!this.sequelize) return false
|
||||||
return this.models.collectionBook.bulkCreate(collectionBooks)
|
return this.models.collectionBook.bulkCreate(collectionBooks)
|
||||||
|
|||||||
+19
-24
@@ -84,7 +84,6 @@ class Server {
|
|||||||
Logger.logManager = new LogManager()
|
Logger.logManager = new LogManager()
|
||||||
|
|
||||||
this.server = null
|
this.server = null
|
||||||
this.io = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -194,18 +193,21 @@ class Server {
|
|||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
|
|
||||||
/**
|
|
||||||
* @temporary
|
|
||||||
* This is necessary for the ebook & cover API endpoint in the mobile apps
|
|
||||||
* The mobile app ereader is using fetch api in Capacitor that is currently difficult to switch to native requests
|
|
||||||
* so we have to allow cors for specific origins to the /api/items/:id/ebook endpoint
|
|
||||||
* The cover image is fetched with XMLHttpRequest in the mobile apps to load into a canvas and extract colors
|
|
||||||
* @see https://ionicframework.com/docs/troubleshooting/cors
|
|
||||||
*
|
|
||||||
* Running in development allows cors to allow testing the mobile apps in the browser
|
|
||||||
* or env variable ALLOW_CORS = '1'
|
|
||||||
*/
|
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
|
// Prevent clickjacking by disallowing iframes
|
||||||
|
res.setHeader('Content-Security-Policy', "frame-ancestors 'self'")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @temporary
|
||||||
|
* This is necessary for the ebook & cover API endpoint in the mobile apps
|
||||||
|
* The mobile app ereader is using fetch api in Capacitor that is currently difficult to switch to native requests
|
||||||
|
* so we have to allow cors for specific origins to the /api/items/:id/ebook endpoint
|
||||||
|
* The cover image is fetched with XMLHttpRequest in the mobile apps to load into a canvas and extract colors
|
||||||
|
* @see https://ionicframework.com/docs/troubleshooting/cors
|
||||||
|
*
|
||||||
|
* Running in development allows cors to allow testing the mobile apps in the browser
|
||||||
|
* or env variable ALLOW_CORS = '1'
|
||||||
|
*/
|
||||||
if (Logger.isDev || req.path.match(/\/api\/items\/([a-z0-9-]{36})\/(ebook|cover)(\/[0-9]+)?/)) {
|
if (Logger.isDev || req.path.match(/\/api\/items\/([a-z0-9-]{36})\/(ebook|cover)(\/[0-9]+)?/)) {
|
||||||
const allowedOrigins = ['capacitor://localhost', 'http://localhost']
|
const allowedOrigins = ['capacitor://localhost', 'http://localhost']
|
||||||
if (global.AllowCors || Logger.isDev || allowedOrigins.some((o) => o === req.get('origin'))) {
|
if (global.AllowCors || Logger.isDev || allowedOrigins.some((o) => o === req.get('origin'))) {
|
||||||
@@ -438,18 +440,11 @@ class Server {
|
|||||||
async stop() {
|
async stop() {
|
||||||
Logger.info('=== Stopping Server ===')
|
Logger.info('=== Stopping Server ===')
|
||||||
Watcher.close()
|
Watcher.close()
|
||||||
Logger.info('Watcher Closed')
|
Logger.info('[Server] Watcher Closed')
|
||||||
|
await SocketAuthority.close()
|
||||||
return new Promise((resolve) => {
|
Logger.info('[Server] Closing HTTP Server')
|
||||||
SocketAuthority.close((err) => {
|
await new Promise((resolve) => this.server.close(resolve))
|
||||||
if (err) {
|
Logger.info('[Server] HTTP Server Closed')
|
||||||
Logger.error('Failed to close server', err)
|
|
||||||
} else {
|
|
||||||
Logger.info('Server successfully closed')
|
|
||||||
}
|
|
||||||
resolve()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
module.exports = Server
|
module.exports = Server
|
||||||
|
|||||||
+85
-63
@@ -14,7 +14,7 @@ const Auth = require('./Auth')
|
|||||||
class SocketAuthority {
|
class SocketAuthority {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.Server = null
|
this.Server = null
|
||||||
this.io = null
|
this.socketIoServers = []
|
||||||
|
|
||||||
/** @type {Object.<string, SocketClient>} */
|
/** @type {Object.<string, SocketClient>} */
|
||||||
this.clients = {}
|
this.clients = {}
|
||||||
@@ -89,82 +89,104 @@ class SocketAuthority {
|
|||||||
*
|
*
|
||||||
* @param {Function} callback
|
* @param {Function} callback
|
||||||
*/
|
*/
|
||||||
close(callback) {
|
async close() {
|
||||||
Logger.info('[SocketAuthority] Shutting down')
|
Logger.info('[SocketAuthority] closing...')
|
||||||
// This will close all open socket connections, and also close the underlying http server
|
const closePromises = this.socketIoServers.map((io) => {
|
||||||
if (this.io) this.io.close(callback)
|
return new Promise((resolve) => {
|
||||||
else callback()
|
Logger.info(`[SocketAuthority] Closing Socket.IO server: ${io.path}`)
|
||||||
|
io.close(() => {
|
||||||
|
Logger.info(`[SocketAuthority] Socket.IO server closed: ${io.path}`)
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await Promise.all(closePromises)
|
||||||
|
Logger.info('[SocketAuthority] closed')
|
||||||
|
this.socketIoServers = []
|
||||||
}
|
}
|
||||||
|
|
||||||
initialize(Server) {
|
initialize(Server) {
|
||||||
this.Server = Server
|
this.Server = Server
|
||||||
|
|
||||||
this.io = new SocketIO.Server(this.Server.server, {
|
const socketIoOptions = {
|
||||||
cors: {
|
cors: {
|
||||||
origin: '*',
|
origin: '*',
|
||||||
methods: ['GET', 'POST']
|
methods: ['GET', 'POST']
|
||||||
},
|
|
||||||
path: `${global.RouterBasePath}/socket.io`
|
|
||||||
})
|
|
||||||
|
|
||||||
this.io.on('connection', (socket) => {
|
|
||||||
this.clients[socket.id] = {
|
|
||||||
id: socket.id,
|
|
||||||
socket,
|
|
||||||
connected_at: Date.now()
|
|
||||||
}
|
}
|
||||||
socket.sheepClient = this.clients[socket.id]
|
}
|
||||||
|
|
||||||
Logger.info('[SocketAuthority] Socket Connected', socket.id)
|
const ioServer = new SocketIO.Server(Server.server, socketIoOptions)
|
||||||
|
ioServer.path = '/socket.io'
|
||||||
|
this.socketIoServers.push(ioServer)
|
||||||
|
|
||||||
// Required for associating a User with a socket
|
if (global.RouterBasePath) {
|
||||||
socket.on('auth', (token) => this.authenticateSocket(socket, token))
|
// open a separate socket.io server for the router base path, keeping the original server open for legacy clients
|
||||||
|
const ioBasePath = `${global.RouterBasePath}/socket.io`
|
||||||
|
const ioBasePathServer = new SocketIO.Server(Server.server, { ...socketIoOptions, path: ioBasePath })
|
||||||
|
ioBasePathServer.path = ioBasePath
|
||||||
|
this.socketIoServers.push(ioBasePathServer)
|
||||||
|
}
|
||||||
|
|
||||||
// Scanning
|
this.socketIoServers.forEach((io) => {
|
||||||
socket.on('cancel_scan', (libraryId) => this.cancelScan(libraryId))
|
io.on('connection', (socket) => {
|
||||||
|
this.clients[socket.id] = {
|
||||||
// Logs
|
id: socket.id,
|
||||||
socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level))
|
socket,
|
||||||
socket.on('remove_log_listener', () => Logger.removeSocketListener(socket.id))
|
connected_at: Date.now()
|
||||||
|
|
||||||
// Sent automatically from socket.io clients
|
|
||||||
socket.on('disconnect', (reason) => {
|
|
||||||
Logger.removeSocketListener(socket.id)
|
|
||||||
|
|
||||||
const _client = this.clients[socket.id]
|
|
||||||
if (!_client) {
|
|
||||||
Logger.warn(`[SocketAuthority] Socket ${socket.id} disconnect, no client (Reason: ${reason})`)
|
|
||||||
} else if (!_client.user) {
|
|
||||||
Logger.info(`[SocketAuthority] Unauth socket ${socket.id} disconnected (Reason: ${reason})`)
|
|
||||||
delete this.clients[socket.id]
|
|
||||||
} else {
|
|
||||||
Logger.debug('[SocketAuthority] User Offline ' + _client.user.username)
|
|
||||||
this.adminEmitter('user_offline', _client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions))
|
|
||||||
|
|
||||||
const disconnectTime = Date.now() - _client.connected_at
|
|
||||||
Logger.info(`[SocketAuthority] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms (Reason: ${reason})`)
|
|
||||||
delete this.clients[socket.id]
|
|
||||||
}
|
}
|
||||||
})
|
socket.sheepClient = this.clients[socket.id]
|
||||||
|
|
||||||
//
|
Logger.info(`[SocketAuthority] Socket Connected to ${io.path}`, socket.id)
|
||||||
// Events for testing
|
|
||||||
//
|
// Required for associating a User with a socket
|
||||||
socket.on('message_all_users', (payload) => {
|
socket.on('auth', (token) => this.authenticateSocket(socket, token))
|
||||||
// admin user can send a message to all authenticated users
|
|
||||||
// displays on the web app as a toast
|
// Scanning
|
||||||
const client = this.clients[socket.id] || {}
|
socket.on('cancel_scan', (libraryId) => this.cancelScan(libraryId))
|
||||||
if (client.user?.isAdminOrUp) {
|
|
||||||
this.emitter('admin_message', payload.message || '')
|
// Logs
|
||||||
} else {
|
socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level))
|
||||||
Logger.error(`[SocketAuthority] Non-admin user sent the message_all_users event`)
|
socket.on('remove_log_listener', () => Logger.removeSocketListener(socket.id))
|
||||||
}
|
|
||||||
})
|
// Sent automatically from socket.io clients
|
||||||
socket.on('ping', () => {
|
socket.on('disconnect', (reason) => {
|
||||||
const client = this.clients[socket.id] || {}
|
Logger.removeSocketListener(socket.id)
|
||||||
const user = client.user || {}
|
|
||||||
Logger.debug(`[SocketAuthority] Received ping from socket ${user.username || 'No User'}`)
|
const _client = this.clients[socket.id]
|
||||||
socket.emit('pong')
|
if (!_client) {
|
||||||
|
Logger.warn(`[SocketAuthority] Socket ${socket.id} disconnect, no client (Reason: ${reason})`)
|
||||||
|
} else if (!_client.user) {
|
||||||
|
Logger.info(`[SocketAuthority] Unauth socket ${socket.id} disconnected (Reason: ${reason})`)
|
||||||
|
delete this.clients[socket.id]
|
||||||
|
} else {
|
||||||
|
Logger.debug('[SocketAuthority] User Offline ' + _client.user.username)
|
||||||
|
this.adminEmitter('user_offline', _client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions))
|
||||||
|
|
||||||
|
const disconnectTime = Date.now() - _client.connected_at
|
||||||
|
Logger.info(`[SocketAuthority] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms (Reason: ${reason})`)
|
||||||
|
delete this.clients[socket.id]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
//
|
||||||
|
// Events for testing
|
||||||
|
//
|
||||||
|
socket.on('message_all_users', (payload) => {
|
||||||
|
// admin user can send a message to all authenticated users
|
||||||
|
// displays on the web app as a toast
|
||||||
|
const client = this.clients[socket.id] || {}
|
||||||
|
if (client.user?.isAdminOrUp) {
|
||||||
|
this.emitter('admin_message', payload.message || '')
|
||||||
|
} else {
|
||||||
|
Logger.error(`[SocketAuthority] Non-admin user sent the message_all_users event`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
socket.on('ping', () => {
|
||||||
|
const client = this.clients[socket.id] || {}
|
||||||
|
const user = client.user || {}
|
||||||
|
Logger.debug(`[SocketAuthority] Received ping from socket ${user.username || 'No User'}`)
|
||||||
|
socket.emit('pong')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -400,19 +400,48 @@ class LibraryController {
|
|||||||
model: Database.podcastEpisodeModel,
|
model: Database.podcastEpisodeModel,
|
||||||
attributes: ['id']
|
attributes: ['id']
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: Database.bookModel,
|
||||||
|
attributes: ['id'],
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Database.bookAuthorModel,
|
||||||
|
attributes: ['authorId']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: Database.bookSeriesModel,
|
||||||
|
attributes: ['seriesId']
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
Logger.info(`[LibraryController] Removed folder "${folder.path}" from library "${req.library.name}" with ${libraryItemsInFolder.length} library items`)
|
Logger.info(`[LibraryController] Removed folder "${folder.path}" from library "${req.library.name}" with ${libraryItemsInFolder.length} library items`)
|
||||||
|
const seriesIds = []
|
||||||
|
const authorIds = []
|
||||||
for (const libraryItem of libraryItemsInFolder) {
|
for (const libraryItem of libraryItemsInFolder) {
|
||||||
let mediaItemIds = []
|
let mediaItemIds = []
|
||||||
if (req.library.isPodcast) {
|
if (req.library.isPodcast) {
|
||||||
mediaItemIds = libraryItem.media.podcastEpisodes.map((pe) => pe.id)
|
mediaItemIds = libraryItem.media.podcastEpisodes.map((pe) => pe.id)
|
||||||
} else {
|
} else {
|
||||||
mediaItemIds.push(libraryItem.mediaId)
|
mediaItemIds.push(libraryItem.mediaId)
|
||||||
|
if (libraryItem.media.bookAuthors.length) {
|
||||||
|
authorIds.push(...libraryItem.media.bookAuthors.map((ba) => ba.authorId))
|
||||||
|
}
|
||||||
|
if (libraryItem.media.bookSeries.length) {
|
||||||
|
seriesIds.push(...libraryItem.media.bookSeries.map((bs) => bs.seriesId))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from folder "${folder.path}"`)
|
Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from folder "${folder.path}"`)
|
||||||
await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds)
|
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authorIds.length) {
|
||||||
|
await this.checkRemoveAuthorsWithNoBooks(authorIds)
|
||||||
|
}
|
||||||
|
if (seriesIds.length) {
|
||||||
|
await this.checkRemoveEmptySeries(seriesIds)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove folder
|
// Remove folder
|
||||||
@@ -501,11 +530,24 @@ class LibraryController {
|
|||||||
mediaItemIds.push(libraryItem.mediaId)
|
mediaItemIds.push(libraryItem.mediaId)
|
||||||
}
|
}
|
||||||
Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from library "${req.library.name}"`)
|
Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from library "${req.library.name}"`)
|
||||||
await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds)
|
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set PlaybackSessions libraryId to null
|
||||||
|
const [sessionsUpdated] = await Database.playbackSessionModel.update(
|
||||||
|
{
|
||||||
|
libraryId: null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
where: {
|
||||||
|
libraryId: req.library.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Logger.info(`[LibraryController] Updated ${sessionsUpdated} playback sessions to remove library id`)
|
||||||
|
|
||||||
const libraryJson = req.library.toOldJSON()
|
const libraryJson = req.library.toOldJSON()
|
||||||
await Database.removeLibrary(req.library.id)
|
await req.library.destroy()
|
||||||
|
|
||||||
// Re-order libraries
|
// Re-order libraries
|
||||||
await Database.libraryModel.resetDisplayOrder()
|
await Database.libraryModel.resetDisplayOrder()
|
||||||
@@ -567,6 +609,8 @@ class LibraryController {
|
|||||||
* DELETE: /api/libraries/:id/issues
|
* DELETE: /api/libraries/:id/issues
|
||||||
* Remove all library items missing or invalid
|
* Remove all library items missing or invalid
|
||||||
*
|
*
|
||||||
|
* @this {import('../routers/ApiRouter')}
|
||||||
|
*
|
||||||
* @param {LibraryControllerRequest} req
|
* @param {LibraryControllerRequest} req
|
||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
@@ -592,6 +636,20 @@ class LibraryController {
|
|||||||
model: Database.podcastEpisodeModel,
|
model: Database.podcastEpisodeModel,
|
||||||
attributes: ['id']
|
attributes: ['id']
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: Database.bookModel,
|
||||||
|
attributes: ['id'],
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Database.bookAuthorModel,
|
||||||
|
attributes: ['authorId']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: Database.bookSeriesModel,
|
||||||
|
attributes: ['seriesId']
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
@@ -602,15 +660,30 @@ class LibraryController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Logger.info(`[LibraryController] Removing ${libraryItemsWithIssues.length} items with issues`)
|
Logger.info(`[LibraryController] Removing ${libraryItemsWithIssues.length} items with issues`)
|
||||||
|
const authorIds = []
|
||||||
|
const seriesIds = []
|
||||||
for (const libraryItem of libraryItemsWithIssues) {
|
for (const libraryItem of libraryItemsWithIssues) {
|
||||||
let mediaItemIds = []
|
let mediaItemIds = []
|
||||||
if (req.library.isPodcast) {
|
if (req.library.isPodcast) {
|
||||||
mediaItemIds = libraryItem.media.podcastEpisodes.map((pe) => pe.id)
|
mediaItemIds = libraryItem.media.podcastEpisodes.map((pe) => pe.id)
|
||||||
} else {
|
} else {
|
||||||
mediaItemIds.push(libraryItem.mediaId)
|
mediaItemIds.push(libraryItem.mediaId)
|
||||||
|
if (libraryItem.media.bookAuthors.length) {
|
||||||
|
authorIds.push(...libraryItem.media.bookAuthors.map((ba) => ba.authorId))
|
||||||
|
}
|
||||||
|
if (libraryItem.media.bookSeries.length) {
|
||||||
|
seriesIds.push(...libraryItem.media.bookSeries.map((bs) => bs.seriesId))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" with issue`)
|
Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" with issue`)
|
||||||
await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds)
|
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authorIds.length) {
|
||||||
|
await this.checkRemoveAuthorsWithNoBooks(authorIds)
|
||||||
|
}
|
||||||
|
if (seriesIds.length) {
|
||||||
|
await this.checkRemoveEmptySeries(seriesIds)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set numIssues to 0 for library filter data
|
// Set numIssues to 0 for library filter data
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ class LibraryItemController {
|
|||||||
* Optional query params:
|
* Optional query params:
|
||||||
* ?hard=1
|
* ?hard=1
|
||||||
*
|
*
|
||||||
|
* @this {import('../routers/ApiRouter')}
|
||||||
|
*
|
||||||
* @param {RequestWithUser} req
|
* @param {RequestWithUser} req
|
||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
@@ -103,14 +105,36 @@ class LibraryItemController {
|
|||||||
const hardDelete = req.query.hard == 1 // Delete from file system
|
const hardDelete = req.query.hard == 1 // Delete from file system
|
||||||
const libraryItemPath = req.libraryItem.path
|
const libraryItemPath = req.libraryItem.path
|
||||||
|
|
||||||
const mediaItemIds = req.libraryItem.mediaType === 'podcast' ? req.libraryItem.media.episodes.map((ep) => ep.id) : [req.libraryItem.media.id]
|
const mediaItemIds = []
|
||||||
await this.handleDeleteLibraryItem(req.libraryItem.mediaType, req.libraryItem.id, mediaItemIds)
|
const authorIds = []
|
||||||
|
const seriesIds = []
|
||||||
|
if (req.libraryItem.isPodcast) {
|
||||||
|
mediaItemIds.push(...req.libraryItem.media.episodes.map((ep) => ep.id))
|
||||||
|
} else {
|
||||||
|
mediaItemIds.push(req.libraryItem.media.id)
|
||||||
|
if (req.libraryItem.media.metadata.authors?.length) {
|
||||||
|
authorIds.push(...req.libraryItem.media.metadata.authors.map((au) => au.id))
|
||||||
|
}
|
||||||
|
if (req.libraryItem.media.metadata.series?.length) {
|
||||||
|
seriesIds.push(...req.libraryItem.media.metadata.series.map((se) => se.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.handleDeleteLibraryItem(req.libraryItem.id, mediaItemIds)
|
||||||
if (hardDelete) {
|
if (hardDelete) {
|
||||||
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
|
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
|
||||||
await fs.remove(libraryItemPath).catch((error) => {
|
await fs.remove(libraryItemPath).catch((error) => {
|
||||||
Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error)
|
Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (authorIds.length) {
|
||||||
|
await this.checkRemoveAuthorsWithNoBooks(authorIds)
|
||||||
|
}
|
||||||
|
if (seriesIds.length) {
|
||||||
|
await this.checkRemoveEmptySeries(seriesIds)
|
||||||
|
}
|
||||||
|
|
||||||
await Database.resetLibraryIssuesFilterData(req.libraryItem.libraryId)
|
await Database.resetLibraryIssuesFilterData(req.libraryItem.libraryId)
|
||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
@@ -212,15 +236,6 @@ class LibraryItemController {
|
|||||||
if (hasUpdates) {
|
if (hasUpdates) {
|
||||||
libraryItem.updatedAt = Date.now()
|
libraryItem.updatedAt = Date.now()
|
||||||
|
|
||||||
if (seriesRemoved.length) {
|
|
||||||
// Check remove empty series
|
|
||||||
Logger.debug(`[LibraryItemController] Series was removed from book. Check if series is now empty.`)
|
|
||||||
await this.checkRemoveEmptySeries(
|
|
||||||
libraryItem.media.id,
|
|
||||||
seriesRemoved.map((se) => se.id)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isPodcastAutoDownloadUpdated) {
|
if (isPodcastAutoDownloadUpdated) {
|
||||||
this.cronManager.checkUpdatePodcastCron(libraryItem)
|
this.cronManager.checkUpdatePodcastCron(libraryItem)
|
||||||
}
|
}
|
||||||
@@ -232,10 +247,12 @@ class LibraryItemController {
|
|||||||
if (authorsRemoved.length) {
|
if (authorsRemoved.length) {
|
||||||
// Check remove empty authors
|
// Check remove empty authors
|
||||||
Logger.debug(`[LibraryItemController] Authors were removed from book. Check if authors are now empty.`)
|
Logger.debug(`[LibraryItemController] Authors were removed from book. Check if authors are now empty.`)
|
||||||
await this.checkRemoveAuthorsWithNoBooks(
|
await this.checkRemoveAuthorsWithNoBooks(authorsRemoved.map((au) => au.id))
|
||||||
libraryItem.libraryId,
|
}
|
||||||
authorsRemoved.map((au) => au.id)
|
if (seriesRemoved.length) {
|
||||||
)
|
// Check remove empty series
|
||||||
|
Logger.debug(`[LibraryItemController] Series were removed from book. Check if series are now empty.`)
|
||||||
|
await this.checkRemoveEmptySeries(seriesRemoved.map((se) => se.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
res.json({
|
res.json({
|
||||||
@@ -450,6 +467,8 @@ class LibraryItemController {
|
|||||||
* Optional query params:
|
* Optional query params:
|
||||||
* ?hard=1
|
* ?hard=1
|
||||||
*
|
*
|
||||||
|
* @this {import('../routers/ApiRouter')}
|
||||||
|
*
|
||||||
* @param {RequestWithUser} req
|
* @param {RequestWithUser} req
|
||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
@@ -477,14 +496,33 @@ class LibraryItemController {
|
|||||||
for (const libraryItem of itemsToDelete) {
|
for (const libraryItem of itemsToDelete) {
|
||||||
const libraryItemPath = libraryItem.path
|
const libraryItemPath = libraryItem.path
|
||||||
Logger.info(`[LibraryItemController] (${hardDelete ? 'Hard' : 'Soft'}) deleting Library Item "${libraryItem.media.metadata.title}" with id "${libraryItem.id}"`)
|
Logger.info(`[LibraryItemController] (${hardDelete ? 'Hard' : 'Soft'}) deleting Library Item "${libraryItem.media.metadata.title}" with id "${libraryItem.id}"`)
|
||||||
const mediaItemIds = libraryItem.mediaType === 'podcast' ? libraryItem.media.episodes.map((ep) => ep.id) : [libraryItem.media.id]
|
const mediaItemIds = []
|
||||||
await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds)
|
const seriesIds = []
|
||||||
|
const authorIds = []
|
||||||
|
if (libraryItem.isPodcast) {
|
||||||
|
mediaItemIds.push(...libraryItem.media.episodes.map((ep) => ep.id))
|
||||||
|
} else {
|
||||||
|
mediaItemIds.push(libraryItem.media.id)
|
||||||
|
if (libraryItem.media.metadata.series?.length) {
|
||||||
|
seriesIds.push(...libraryItem.media.metadata.series.map((se) => se.id))
|
||||||
|
}
|
||||||
|
if (libraryItem.media.metadata.authors?.length) {
|
||||||
|
authorIds.push(...libraryItem.media.metadata.authors.map((au) => au.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
|
||||||
if (hardDelete) {
|
if (hardDelete) {
|
||||||
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
|
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
|
||||||
await fs.remove(libraryItemPath).catch((error) => {
|
await fs.remove(libraryItemPath).catch((error) => {
|
||||||
Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error)
|
Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if (seriesIds.length) {
|
||||||
|
await this.checkRemoveEmptySeries(seriesIds)
|
||||||
|
}
|
||||||
|
if (authorIds.length) {
|
||||||
|
await this.checkRemoveAuthorsWithNoBooks(authorIds)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await Database.resetLibraryIssuesFilterData(libraryId)
|
await Database.resetLibraryIssuesFilterData(libraryId)
|
||||||
@@ -494,48 +532,74 @@ class LibraryItemController {
|
|||||||
/**
|
/**
|
||||||
* POST: /api/items/batch/update
|
* POST: /api/items/batch/update
|
||||||
*
|
*
|
||||||
|
* @this {import('../routers/ApiRouter')}
|
||||||
|
*
|
||||||
* @param {RequestWithUser} req
|
* @param {RequestWithUser} req
|
||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
async batchUpdate(req, res) {
|
async batchUpdate(req, res) {
|
||||||
const updatePayloads = req.body
|
const updatePayloads = req.body
|
||||||
if (!updatePayloads?.length) {
|
if (!Array.isArray(updatePayloads) || !updatePayloads.length) {
|
||||||
return res.sendStatus(500)
|
Logger.error(`[LibraryItemController] Batch update failed. Invalid payload`)
|
||||||
|
return res.sendStatus(400)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure that each update payload has a unique library item id
|
||||||
|
const libraryItemIds = [...new Set(updatePayloads.map((up) => up?.id).filter((id) => id))]
|
||||||
|
if (!libraryItemIds.length || libraryItemIds.length !== updatePayloads.length) {
|
||||||
|
Logger.error(`[LibraryItemController] Batch update failed. Each update payload must have a unique library item id`)
|
||||||
|
return res.sendStatus(400)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all library items to update
|
||||||
|
const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({
|
||||||
|
id: libraryItemIds
|
||||||
|
})
|
||||||
|
if (updatePayloads.length !== libraryItems.length) {
|
||||||
|
Logger.error(`[LibraryItemController] Batch update failed. Not all library items found`)
|
||||||
|
return res.sendStatus(404)
|
||||||
}
|
}
|
||||||
|
|
||||||
let itemsUpdated = 0
|
let itemsUpdated = 0
|
||||||
|
|
||||||
|
const seriesIdsRemoved = []
|
||||||
|
const authorIdsRemoved = []
|
||||||
|
|
||||||
for (const updatePayload of updatePayloads) {
|
for (const updatePayload of updatePayloads) {
|
||||||
const mediaPayload = updatePayload.mediaPayload
|
const mediaPayload = updatePayload.mediaPayload
|
||||||
const libraryItem = await Database.libraryItemModel.getOldById(updatePayload.id)
|
const libraryItem = libraryItems.find((li) => li.id === updatePayload.id)
|
||||||
if (!libraryItem) return null
|
|
||||||
|
|
||||||
await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryItem.libraryId)
|
await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryItem.libraryId)
|
||||||
|
|
||||||
let seriesRemoved = []
|
if (libraryItem.isBook) {
|
||||||
if (libraryItem.isBook && mediaPayload.metadata?.series) {
|
if (Array.isArray(mediaPayload.metadata?.series)) {
|
||||||
const seriesIdsInUpdate = (mediaPayload.metadata?.series || []).map((se) => se.id)
|
const seriesIdsInUpdate = mediaPayload.metadata.series.map((se) => se.id)
|
||||||
seriesRemoved = libraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id))
|
const seriesRemoved = libraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id))
|
||||||
|
seriesIdsRemoved.push(...seriesRemoved.map((se) => se.id))
|
||||||
|
}
|
||||||
|
if (Array.isArray(mediaPayload.metadata?.authors)) {
|
||||||
|
const authorIdsInUpdate = mediaPayload.metadata.authors.map((au) => au.id)
|
||||||
|
const authorsRemoved = libraryItem.media.metadata.authors.filter((au) => !authorIdsInUpdate.includes(au.id))
|
||||||
|
authorIdsRemoved.push(...authorsRemoved.map((au) => au.id))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (libraryItem.media.update(mediaPayload)) {
|
if (libraryItem.media.update(mediaPayload)) {
|
||||||
Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`)
|
Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`)
|
||||||
|
|
||||||
if (seriesRemoved.length) {
|
|
||||||
// Check remove empty series
|
|
||||||
Logger.debug(`[LibraryItemController] Series was removed from book. Check if series is now empty.`)
|
|
||||||
await this.checkRemoveEmptySeries(
|
|
||||||
libraryItem.media.id,
|
|
||||||
seriesRemoved.map((se) => se.id)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
await Database.updateLibraryItem(libraryItem)
|
await Database.updateLibraryItem(libraryItem)
|
||||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||||
itemsUpdated++
|
itemsUpdated++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (seriesIdsRemoved.length) {
|
||||||
|
await this.checkRemoveEmptySeries(seriesIdsRemoved)
|
||||||
|
}
|
||||||
|
if (authorIdsRemoved.length) {
|
||||||
|
await this.checkRemoveAuthorsWithNoBooks(authorIdsRemoved)
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
updates: itemsUpdated
|
updates: itemsUpdated
|
||||||
|
|||||||
@@ -679,9 +679,9 @@ class MiscController {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
let updatedValue = settingsUpdate[key]
|
let updatedValue = settingsUpdate[key]
|
||||||
if (updatedValue === '') updatedValue = null
|
if (updatedValue === '' && key != 'authOpenIDSubfolderForRedirectURLs') updatedValue = null
|
||||||
let currentValue = currentAuthenticationSettings[key]
|
let currentValue = currentAuthenticationSettings[key]
|
||||||
if (currentValue === '') currentValue = null
|
if (currentValue === '' && key != 'authOpenIDSubfolderForRedirectURLs') currentValue = null
|
||||||
|
|
||||||
if (updatedValue !== currentValue) {
|
if (updatedValue !== currentValue) {
|
||||||
Logger.debug(`[MiscController] Updating auth settings key "${key}" from "${currentValue}" to "${updatedValue}"`)
|
Logger.debug(`[MiscController] Updating auth settings key "${key}" from "${currentValue}" to "${updatedValue}"`)
|
||||||
|
|||||||
@@ -368,6 +368,19 @@ class UserController {
|
|||||||
await playlist.destroy()
|
await playlist.destroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set PlaybackSessions userId to null
|
||||||
|
const [sessionsUpdated] = await Database.playbackSessionModel.update(
|
||||||
|
{
|
||||||
|
userId: null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
where: {
|
||||||
|
userId: user.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Logger.info(`[UserController] Updated ${sessionsUpdated} playback sessions to remove user id`)
|
||||||
|
|
||||||
const userJson = user.toOldJSONForBrowser()
|
const userJson = user.toOldJSONForBrowser()
|
||||||
await user.destroy()
|
await user.destroy()
|
||||||
SocketAuthority.adminEmitter('user_removed', userJson)
|
SocketAuthority.adminEmitter('user_removed', userJson)
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ class CacheManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async purgeEntityCache(entityId, cachePath) {
|
async purgeEntityCache(entityId, cachePath) {
|
||||||
|
if (!entityId || !cachePath) return []
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
(await fs.readdir(cachePath)).reduce((promises, file) => {
|
(await fs.readdir(cachePath)).reduce((promises, file) => {
|
||||||
if (file.startsWith(entityId)) {
|
if (file.startsWith(entityId)) {
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
Please add a record of every database migration that you create to this file. This will help us keep track of changes to the database schema over time.
|
Please add a record of every database migration that you create to this file. This will help us keep track of changes to the database schema over time.
|
||||||
|
|
||||||
| Server Version | Migration Script Name | Description |
|
| Server Version | Migration Script Name | Description |
|
||||||
| -------------- | ---------------------------- | ------------------------------------------------------------------------------------ |
|
| -------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||||
| v2.15.0 | v2.15.0-series-column-unique | Series must have unique names in the same library |
|
| v2.15.0 | v2.15.0-series-column-unique | Series must have unique names in the same library |
|
||||||
| v2.15.1 | v2.15.1-reindex-nocase | Fix potential db corruption issues due to bad sqlite extension introduced in v2.12.0 |
|
| v2.15.1 | v2.15.1-reindex-nocase | Fix potential db corruption issues due to bad sqlite extension introduced in v2.12.0 |
|
||||||
| v2.15.2 | v2.15.2-index-creation | Creates author, series, and podcast episode indexes |
|
| v2.15.2 | v2.15.2-index-creation | Creates author, series, and podcast episode indexes |
|
||||||
| v2.17.0 | v2.17.0-uuid-replacement | Changes the data type of columns with UUIDv4 to UUID matching the associated model |
|
| v2.17.0 | v2.17.0-uuid-replacement | Changes the data type of columns with UUIDv4 to UUID matching the associated model |
|
||||||
|
| v2.17.3 | v2.17.3-fk-constraints | Changes the foreign key constraints for tables due to sequelize bug dropping constraints in v2.17.0 migration |
|
||||||
|
| v2.17.4 | v2.17.4-use-subfolder-for-oidc-redirect-uris | Save subfolder to OIDC redirect URIs to support existing installations |
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
/**
|
||||||
|
* @typedef MigrationContext
|
||||||
|
* @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.
|
||||||
|
* @property {import('../Logger')} logger - a Logger object.
|
||||||
|
*
|
||||||
|
* @typedef MigrationOptions
|
||||||
|
* @property {MigrationContext} context - an object containing the migration context.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This upward migration script changes foreign key constraints for the
|
||||||
|
* libraryItems, feeds, mediaItemShares, playbackSessions, playlistMediaItems, and mediaProgresses tables.
|
||||||
|
*
|
||||||
|
* @param {MigrationOptions} options - an object containing the migration context.
|
||||||
|
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||||
|
*/
|
||||||
|
async function up({ context: { queryInterface, logger } }) {
|
||||||
|
// Upwards migration script
|
||||||
|
logger.info('[2.17.3 migration] UPGRADE BEGIN: 2.17.3-fk-constraints')
|
||||||
|
|
||||||
|
const execQuery = queryInterface.sequelize.query.bind(queryInterface.sequelize)
|
||||||
|
|
||||||
|
// Disable foreign key constraints for the next sequence of operations
|
||||||
|
await execQuery(`PRAGMA foreign_keys = OFF;`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await execQuery(`BEGIN TRANSACTION;`)
|
||||||
|
|
||||||
|
logger.info('[2.17.3 migration] Updating libraryItems constraints')
|
||||||
|
const libraryItemsConstraints = [
|
||||||
|
{ field: 'libraryId', onDelete: 'SET NULL', onUpdate: 'CASCADE' },
|
||||||
|
{ field: 'libraryFolderId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }
|
||||||
|
]
|
||||||
|
if (await changeConstraints(queryInterface, 'libraryItems', libraryItemsConstraints)) {
|
||||||
|
logger.info('[2.17.3 migration] Finished updating libraryItems constraints')
|
||||||
|
} else {
|
||||||
|
logger.info('[2.17.3 migration] No changes needed for libraryItems constraints')
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('[2.17.3 migration] Updating feeds constraints')
|
||||||
|
const feedsConstraints = [{ field: 'userId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }]
|
||||||
|
if (await changeConstraints(queryInterface, 'feeds', feedsConstraints)) {
|
||||||
|
logger.info('[2.17.3 migration] Finished updating feeds constraints')
|
||||||
|
} else {
|
||||||
|
logger.info('[2.17.3 migration] No changes needed for feeds constraints')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await queryInterface.tableExists('mediaItemShares')) {
|
||||||
|
logger.info('[2.17.3 migration] Updating mediaItemShares constraints')
|
||||||
|
const mediaItemSharesConstraints = [{ field: 'userId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }]
|
||||||
|
if (await changeConstraints(queryInterface, 'mediaItemShares', mediaItemSharesConstraints)) {
|
||||||
|
logger.info('[2.17.3 migration] Finished updating mediaItemShares constraints')
|
||||||
|
} else {
|
||||||
|
logger.info('[2.17.3 migration] No changes needed for mediaItemShares constraints')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.info('[2.17.3 migration] mediaItemShares table does not exist, skipping column change')
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('[2.17.3 migration] Updating playbackSessions constraints')
|
||||||
|
const playbackSessionsConstraints = [
|
||||||
|
{ field: 'deviceId', onDelete: 'SET NULL', onUpdate: 'CASCADE' },
|
||||||
|
{ field: 'libraryId', onDelete: 'SET NULL', onUpdate: 'CASCADE' },
|
||||||
|
{ field: 'userId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }
|
||||||
|
]
|
||||||
|
if (await changeConstraints(queryInterface, 'playbackSessions', playbackSessionsConstraints)) {
|
||||||
|
logger.info('[2.17.3 migration] Finished updating playbackSessions constraints')
|
||||||
|
} else {
|
||||||
|
logger.info('[2.17.3 migration] No changes needed for playbackSessions constraints')
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('[2.17.3 migration] Updating playlistMediaItems constraints')
|
||||||
|
const playlistMediaItemsConstraints = [{ field: 'playlistId', onDelete: 'CASCADE', onUpdate: 'CASCADE' }]
|
||||||
|
if (await changeConstraints(queryInterface, 'playlistMediaItems', playlistMediaItemsConstraints)) {
|
||||||
|
logger.info('[2.17.3 migration] Finished updating playlistMediaItems constraints')
|
||||||
|
} else {
|
||||||
|
logger.info('[2.17.3 migration] No changes needed for playlistMediaItems constraints')
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('[2.17.3 migration] Updating mediaProgresses constraints')
|
||||||
|
const mediaProgressesConstraints = [{ field: 'userId', onDelete: 'CASCADE', onUpdate: 'CASCADE' }]
|
||||||
|
if (await changeConstraints(queryInterface, 'mediaProgresses', mediaProgressesConstraints)) {
|
||||||
|
logger.info('[2.17.3 migration] Finished updating mediaProgresses constraints')
|
||||||
|
} else {
|
||||||
|
logger.info('[2.17.3 migration] No changes needed for mediaProgresses constraints')
|
||||||
|
}
|
||||||
|
|
||||||
|
await execQuery(`COMMIT;`)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[2.17.3 migration] Migration failed - rolling back. Error:`, error)
|
||||||
|
await execQuery(`ROLLBACK;`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await execQuery(`PRAGMA foreign_keys = ON;`)
|
||||||
|
|
||||||
|
// Completed migration
|
||||||
|
logger.info('[2.17.3 migration] UPGRADE END: 2.17.3-fk-constraints')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This downward migration script is a no-op.
|
||||||
|
*
|
||||||
|
* @param {MigrationOptions} options - an object containing the migration context.
|
||||||
|
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||||
|
*/
|
||||||
|
async function down({ context: { queryInterface, logger } }) {
|
||||||
|
// Downward migration script
|
||||||
|
logger.info('[2.17.3 migration] DOWNGRADE BEGIN: 2.17.3-fk-constraints')
|
||||||
|
|
||||||
|
// This migration is a no-op
|
||||||
|
logger.info('[2.17.3 migration] No action required for downgrade')
|
||||||
|
|
||||||
|
// Completed migration
|
||||||
|
logger.info('[2.17.3 migration] DOWNGRADE END: 2.17.3-fk-constraints')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef ConstraintUpdateObj
|
||||||
|
* @property {string} field - The field to update
|
||||||
|
* @property {string} onDelete - The onDelete constraint
|
||||||
|
* @property {string} onUpdate - The onUpdate constraint
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef SequelizeFKObj
|
||||||
|
* @property {{ model: string, key: string }} references
|
||||||
|
* @property {string} onDelete
|
||||||
|
* @property {string} onUpdate
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Object} fk - The foreign key object from PRAGMA foreign_key_list
|
||||||
|
* @returns {SequelizeFKObj} - The foreign key object formatted for Sequelize
|
||||||
|
*/
|
||||||
|
const formatFKsPragmaToSequelizeFK = (fk) => {
|
||||||
|
return {
|
||||||
|
references: {
|
||||||
|
model: fk.table,
|
||||||
|
key: fk.to
|
||||||
|
},
|
||||||
|
onDelete: fk['on_delete'],
|
||||||
|
onUpdate: fk['on_update']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {import('sequelize').QueryInterface} queryInterface
|
||||||
|
* @param {string} tableName
|
||||||
|
* @param {ConstraintUpdateObj[]} constraints
|
||||||
|
* @returns {Promise<Record<string, SequelizeFKObj>|null>}
|
||||||
|
*/
|
||||||
|
async function getUpdatedForeignKeys(queryInterface, tableName, constraints) {
|
||||||
|
const execQuery = queryInterface.sequelize.query.bind(queryInterface.sequelize)
|
||||||
|
const quotedTableName = queryInterface.quoteIdentifier(tableName)
|
||||||
|
|
||||||
|
const foreignKeys = await execQuery(`PRAGMA foreign_key_list(${quotedTableName});`)
|
||||||
|
|
||||||
|
let hasUpdates = false
|
||||||
|
const foreignKeysByColName = foreignKeys.reduce((prev, curr) => {
|
||||||
|
const fk = formatFKsPragmaToSequelizeFK(curr)
|
||||||
|
|
||||||
|
const constraint = constraints.find((c) => c.field === curr.from)
|
||||||
|
if (constraint && (constraint.onDelete !== fk.onDelete || constraint.onUpdate !== fk.onUpdate)) {
|
||||||
|
fk.onDelete = constraint.onDelete
|
||||||
|
fk.onUpdate = constraint.onUpdate
|
||||||
|
hasUpdates = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...prev, [curr.from]: fk }
|
||||||
|
}, {})
|
||||||
|
|
||||||
|
return hasUpdates ? foreignKeysByColName : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extends the Sequelize describeTable function to include the updated foreign key constraints
|
||||||
|
*
|
||||||
|
* @param {import('sequelize').QueryInterface} queryInterface
|
||||||
|
* @param {String} tableName
|
||||||
|
* @param {Record<string, SequelizeFKObj>} updatedForeignKeys
|
||||||
|
*/
|
||||||
|
async function describeTableWithFKs(queryInterface, tableName, updatedForeignKeys) {
|
||||||
|
const tableDescription = await queryInterface.describeTable(tableName)
|
||||||
|
|
||||||
|
const tableDescriptionWithFks = Object.entries(tableDescription).reduce((prev, [col, attributes]) => {
|
||||||
|
let extendedAttributes = attributes
|
||||||
|
|
||||||
|
if (updatedForeignKeys[col]) {
|
||||||
|
extendedAttributes = {
|
||||||
|
...extendedAttributes,
|
||||||
|
...updatedForeignKeys[col]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...prev, [col]: extendedAttributes }
|
||||||
|
}, {})
|
||||||
|
|
||||||
|
return tableDescriptionWithFks
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @see https://www.sqlite.org/lang_altertable.html#otheralter
|
||||||
|
* @see https://sequelize.org/docs/v6/other-topics/query-interface/#changing-and-removing-columns-in-sqlite
|
||||||
|
*
|
||||||
|
* @param {import('sequelize').QueryInterface} queryInterface
|
||||||
|
* @param {string} tableName
|
||||||
|
* @param {ConstraintUpdateObj[]} constraints
|
||||||
|
* @returns {Promise<boolean>} - Return false if no changes are needed, true otherwise
|
||||||
|
*/
|
||||||
|
async function changeConstraints(queryInterface, tableName, constraints) {
|
||||||
|
const updatedForeignKeys = await getUpdatedForeignKeys(queryInterface, tableName, constraints)
|
||||||
|
if (!updatedForeignKeys) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const execQuery = queryInterface.sequelize.query.bind(queryInterface.sequelize)
|
||||||
|
const quotedTableName = queryInterface.quoteIdentifier(tableName)
|
||||||
|
|
||||||
|
const backupTableName = `${tableName}_${Math.round(Math.random() * 100)}_backup`
|
||||||
|
const quotedBackupTableName = queryInterface.quoteIdentifier(backupTableName)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tableDescriptionWithFks = await describeTableWithFKs(queryInterface, tableName, updatedForeignKeys)
|
||||||
|
|
||||||
|
const attributes = queryInterface.queryGenerator.attributesToSQL(tableDescriptionWithFks)
|
||||||
|
|
||||||
|
// Create the backup table
|
||||||
|
await queryInterface.createTable(backupTableName, attributes)
|
||||||
|
|
||||||
|
const attributeNames = Object.keys(attributes)
|
||||||
|
.map((attr) => queryInterface.quoteIdentifier(attr))
|
||||||
|
.join(', ')
|
||||||
|
|
||||||
|
// Copy all data from the target table to the backup table
|
||||||
|
await execQuery(`INSERT INTO ${quotedBackupTableName} SELECT ${attributeNames} FROM ${quotedTableName};`)
|
||||||
|
|
||||||
|
// Drop the old (original) table
|
||||||
|
await queryInterface.dropTable(tableName)
|
||||||
|
|
||||||
|
// Rename the backup table to the original table's name
|
||||||
|
await queryInterface.renameTable(backupTableName, tableName)
|
||||||
|
|
||||||
|
// Validate that all foreign key constraints are correct
|
||||||
|
const result = await execQuery(`PRAGMA foreign_key_check(${quotedTableName});`, {
|
||||||
|
type: queryInterface.sequelize.Sequelize.QueryTypes.SELECT
|
||||||
|
})
|
||||||
|
|
||||||
|
// There are foreign key violations, exit
|
||||||
|
if (result.length) {
|
||||||
|
return Promise.reject(`Foreign key violations detected: ${JSON.stringify(result, null, 2)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { up, down }
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* @typedef MigrationContext
|
||||||
|
* @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.
|
||||||
|
* @property {import('../Logger')} logger - a Logger object.
|
||||||
|
*
|
||||||
|
* @typedef MigrationOptions
|
||||||
|
* @property {MigrationContext} context - an object containing the migration context.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This upward migration adds an subfolder setting for OIDC redirect URIs.
|
||||||
|
* It updates existing OIDC setups to set this option to None (empty subfolder), so they continue to work as before.
|
||||||
|
* IF OIDC is not enabled, no action is taken (i.e. the subfolder is left undefined),
|
||||||
|
* so that future OIDC setups will use the default subfolder.
|
||||||
|
*
|
||||||
|
* @param {MigrationOptions} options - an object containing the migration context.
|
||||||
|
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||||
|
*/
|
||||||
|
async function up({ context: { queryInterface, logger } }) {
|
||||||
|
// Upwards migration script
|
||||||
|
logger.info('[2.17.4 migration] UPGRADE BEGIN: 2.17.4-use-subfolder-for-oidc-redirect-uris')
|
||||||
|
|
||||||
|
const serverSettings = await getServerSettings(queryInterface, logger)
|
||||||
|
if (serverSettings.authActiveAuthMethods?.includes('openid')) {
|
||||||
|
logger.info('[2.17.4 migration] OIDC is enabled, adding authOpenIDSubfolderForRedirectURLs to server settings')
|
||||||
|
serverSettings.authOpenIDSubfolderForRedirectURLs = ''
|
||||||
|
await updateServerSettings(queryInterface, logger, serverSettings)
|
||||||
|
} else {
|
||||||
|
logger.info('[2.17.4 migration] OIDC is not enabled, no action required')
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('[2.17.4 migration] UPGRADE END: 2.17.4-use-subfolder-for-oidc-redirect-uris')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This downward migration script removes the subfolder setting for OIDC redirect URIs.
|
||||||
|
*
|
||||||
|
* @param {MigrationOptions} options - an object containing the migration context.
|
||||||
|
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||||
|
*/
|
||||||
|
async function down({ context: { queryInterface, logger } }) {
|
||||||
|
// Downward migration script
|
||||||
|
logger.info('[2.17.4 migration] DOWNGRADE BEGIN: 2.17.4-use-subfolder-for-oidc-redirect-uris ')
|
||||||
|
|
||||||
|
// Remove the OIDC subfolder option from the server settings
|
||||||
|
const serverSettings = await getServerSettings(queryInterface, logger)
|
||||||
|
if (serverSettings.authOpenIDSubfolderForRedirectURLs !== undefined) {
|
||||||
|
logger.info('[2.17.4 migration] Removing authOpenIDSubfolderForRedirectURLs from server settings')
|
||||||
|
delete serverSettings.authOpenIDSubfolderForRedirectURLs
|
||||||
|
await updateServerSettings(queryInterface, logger, serverSettings)
|
||||||
|
} else {
|
||||||
|
logger.info('[2.17.4 migration] authOpenIDSubfolderForRedirectURLs not found in server settings, no action required')
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('[2.17.4 migration] DOWNGRADE END: 2.17.4-use-subfolder-for-oidc-redirect-uris ')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getServerSettings(queryInterface, logger) {
|
||||||
|
const result = await queryInterface.sequelize.query('SELECT value FROM settings WHERE key = "server-settings";')
|
||||||
|
if (!result[0].length) {
|
||||||
|
logger.error('[2.17.4 migration] Server settings not found')
|
||||||
|
throw new Error('Server settings not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
let serverSettings = null
|
||||||
|
try {
|
||||||
|
serverSettings = JSON.parse(result[0][0].value)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[2.17.4 migration] Error parsing server settings:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
return serverSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateServerSettings(queryInterface, logger, serverSettings) {
|
||||||
|
await queryInterface.sequelize.query('UPDATE settings SET value = :value WHERE key = "server-settings";', {
|
||||||
|
replacements: {
|
||||||
|
value: JSON.stringify(serverSettings)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { up, down }
|
||||||
@@ -107,19 +107,6 @@ class Library extends Model {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Destroy library by id
|
|
||||||
* @param {string} libraryId
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
static removeById(libraryId) {
|
|
||||||
return this.destroy({
|
|
||||||
where: {
|
|
||||||
id: libraryId
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all library ids
|
* Get all library ids
|
||||||
* @returns {Promise<string[]>} array of library ids
|
* @returns {Promise<string[]>} array of library ids
|
||||||
|
|||||||
@@ -611,7 +611,7 @@ class User extends Model {
|
|||||||
*/
|
*/
|
||||||
getOldMediaProgress(libraryItemId, episodeId = null) {
|
getOldMediaProgress(libraryItemId, episodeId = null) {
|
||||||
const mediaProgress = this.mediaProgresses?.find((mp) => {
|
const mediaProgress = this.mediaProgresses?.find((mp) => {
|
||||||
if (episodeId && mp.mediaItemId === episodeId) return true
|
if (episodeId && mp.mediaItemId !== episodeId) return false
|
||||||
return mp.extraData?.libraryItemId === libraryItemId
|
return mp.extraData?.libraryItemId === libraryItemId
|
||||||
})
|
})
|
||||||
return mediaProgress?.getOldMediaProgress() || null
|
return mediaProgress?.getOldMediaProgress() || null
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ class LibraryItem {
|
|||||||
* @returns {Promise<LibraryFile>} null if not saved
|
* @returns {Promise<LibraryFile>} null if not saved
|
||||||
*/
|
*/
|
||||||
async saveMetadata() {
|
async saveMetadata() {
|
||||||
if (this.isSavingMetadata) return null
|
if (this.isSavingMetadata || !global.MetadataPath) return null
|
||||||
|
|
||||||
this.isSavingMetadata = true
|
this.isSavingMetadata = true
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ class ServerSettings {
|
|||||||
this.authOpenIDMobileRedirectURIs = ['audiobookshelf://oauth']
|
this.authOpenIDMobileRedirectURIs = ['audiobookshelf://oauth']
|
||||||
this.authOpenIDGroupClaim = ''
|
this.authOpenIDGroupClaim = ''
|
||||||
this.authOpenIDAdvancedPermsClaim = ''
|
this.authOpenIDAdvancedPermsClaim = ''
|
||||||
|
this.authOpenIDSubfolderForRedirectURLs = undefined
|
||||||
|
|
||||||
if (settings) {
|
if (settings) {
|
||||||
this.construct(settings)
|
this.construct(settings)
|
||||||
@@ -139,6 +140,7 @@ class ServerSettings {
|
|||||||
this.authOpenIDMobileRedirectURIs = settings.authOpenIDMobileRedirectURIs || ['audiobookshelf://oauth']
|
this.authOpenIDMobileRedirectURIs = settings.authOpenIDMobileRedirectURIs || ['audiobookshelf://oauth']
|
||||||
this.authOpenIDGroupClaim = settings.authOpenIDGroupClaim || ''
|
this.authOpenIDGroupClaim = settings.authOpenIDGroupClaim || ''
|
||||||
this.authOpenIDAdvancedPermsClaim = settings.authOpenIDAdvancedPermsClaim || ''
|
this.authOpenIDAdvancedPermsClaim = settings.authOpenIDAdvancedPermsClaim || ''
|
||||||
|
this.authOpenIDSubfolderForRedirectURLs = settings.authOpenIDSubfolderForRedirectURLs
|
||||||
|
|
||||||
if (!Array.isArray(this.authActiveAuthMethods)) {
|
if (!Array.isArray(this.authActiveAuthMethods)) {
|
||||||
this.authActiveAuthMethods = ['local']
|
this.authActiveAuthMethods = ['local']
|
||||||
@@ -240,7 +242,8 @@ class ServerSettings {
|
|||||||
authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy,
|
authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy,
|
||||||
authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs, // Do not return to client
|
authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs, // Do not return to client
|
||||||
authOpenIDGroupClaim: this.authOpenIDGroupClaim, // Do not return to client
|
authOpenIDGroupClaim: this.authOpenIDGroupClaim, // Do not return to client
|
||||||
authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim // Do not return to client
|
authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim, // Do not return to client
|
||||||
|
authOpenIDSubfolderForRedirectURLs: this.authOpenIDSubfolderForRedirectURLs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,6 +289,7 @@ class ServerSettings {
|
|||||||
authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs, // Do not return to client
|
authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs, // Do not return to client
|
||||||
authOpenIDGroupClaim: this.authOpenIDGroupClaim, // Do not return to client
|
authOpenIDGroupClaim: this.authOpenIDGroupClaim, // Do not return to client
|
||||||
authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim, // Do not return to client
|
authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim, // Do not return to client
|
||||||
|
authOpenIDSubfolderForRedirectURLs: this.authOpenIDSubfolderForRedirectURLs,
|
||||||
|
|
||||||
authOpenIDSamplePermissions: User.getSampleAbsPermissions()
|
authOpenIDSamplePermissions: User.getSampleAbsPermissions()
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-52
@@ -348,11 +348,10 @@ class ApiRouter {
|
|||||||
//
|
//
|
||||||
/**
|
/**
|
||||||
* Remove library item and associated entities
|
* Remove library item and associated entities
|
||||||
* @param {string} mediaType
|
|
||||||
* @param {string} libraryItemId
|
* @param {string} libraryItemId
|
||||||
* @param {string[]} mediaItemIds array of bookId or podcastEpisodeId
|
* @param {string[]} mediaItemIds array of bookId or podcastEpisodeId
|
||||||
*/
|
*/
|
||||||
async handleDeleteLibraryItem(mediaType, libraryItemId, mediaItemIds) {
|
async handleDeleteLibraryItem(libraryItemId, mediaItemIds) {
|
||||||
const numProgressRemoved = await Database.mediaProgressModel.destroy({
|
const numProgressRemoved = await Database.mediaProgressModel.destroy({
|
||||||
where: {
|
where: {
|
||||||
mediaItemId: mediaItemIds
|
mediaItemId: mediaItemIds
|
||||||
@@ -362,29 +361,6 @@ class ApiRouter {
|
|||||||
Logger.info(`[ApiRouter] Removed ${numProgressRemoved} media progress entries for library item "${libraryItemId}"`)
|
Logger.info(`[ApiRouter] Removed ${numProgressRemoved} media progress entries for library item "${libraryItemId}"`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Remove open sessions for library item
|
|
||||||
|
|
||||||
// Remove series if empty
|
|
||||||
if (mediaType === 'book') {
|
|
||||||
// TODO: update filter data
|
|
||||||
const bookSeries = await Database.bookSeriesModel.findAll({
|
|
||||||
where: {
|
|
||||||
bookId: mediaItemIds[0]
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
model: Database.seriesModel,
|
|
||||||
include: {
|
|
||||||
model: Database.bookModel
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
for (const bs of bookSeries) {
|
|
||||||
if (bs.series.books.length === 1) {
|
|
||||||
await this.removeEmptySeries(bs.series)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// remove item from playlists
|
// remove item from playlists
|
||||||
const playlistsWithItem = await Database.playlistModel.getPlaylistsForMediaItemIds(mediaItemIds)
|
const playlistsWithItem = await Database.playlistModel.getPlaylistsForMediaItemIds(mediaItemIds)
|
||||||
for (const playlist of playlistsWithItem) {
|
for (const playlist of playlistsWithItem) {
|
||||||
@@ -423,10 +399,13 @@ class ApiRouter {
|
|||||||
// purge cover cache
|
// purge cover cache
|
||||||
await CacheManager.purgeCoverCache(libraryItemId)
|
await CacheManager.purgeCoverCache(libraryItemId)
|
||||||
|
|
||||||
const itemMetadataPath = Path.join(global.MetadataPath, 'items', libraryItemId)
|
// Remove metadata file if in /metadata/items dir
|
||||||
if (await fs.pathExists(itemMetadataPath)) {
|
if (global.MetadataPath) {
|
||||||
Logger.info(`[ApiRouter] Removing item metadata at "${itemMetadataPath}"`)
|
const itemMetadataPath = Path.join(global.MetadataPath, 'items', libraryItemId)
|
||||||
await fs.remove(itemMetadataPath)
|
if (await fs.pathExists(itemMetadataPath)) {
|
||||||
|
Logger.info(`[ApiRouter] Removing item metadata at "${itemMetadataPath}"`)
|
||||||
|
await fs.remove(itemMetadataPath)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await Database.libraryItemModel.removeById(libraryItemId)
|
await Database.libraryItemModel.removeById(libraryItemId)
|
||||||
@@ -437,32 +416,27 @@ class ApiRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Used when a series is removed from a book
|
* After deleting book(s), remove empty series
|
||||||
* Series is removed if it only has 1 book
|
|
||||||
*
|
*
|
||||||
* @param {string} bookId
|
|
||||||
* @param {string[]} seriesIds
|
* @param {string[]} seriesIds
|
||||||
*/
|
*/
|
||||||
async checkRemoveEmptySeries(bookId, seriesIds) {
|
async checkRemoveEmptySeries(seriesIds) {
|
||||||
if (!seriesIds?.length) return
|
if (!seriesIds?.length) return
|
||||||
|
|
||||||
const bookSeries = await Database.bookSeriesModel.findAll({
|
const series = await Database.seriesModel.findAll({
|
||||||
where: {
|
where: {
|
||||||
bookId,
|
id: seriesIds
|
||||||
seriesId: seriesIds
|
|
||||||
},
|
},
|
||||||
include: [
|
attributes: ['id', 'name', 'libraryId'],
|
||||||
{
|
include: {
|
||||||
model: Database.seriesModel,
|
model: Database.bookModel,
|
||||||
include: {
|
attributes: ['id']
|
||||||
model: Database.bookModel
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
})
|
})
|
||||||
for (const bs of bookSeries) {
|
|
||||||
if (bs.series.books.length === 1) {
|
for (const s of series) {
|
||||||
await this.removeEmptySeries(bs.series)
|
if (!s.books.length) {
|
||||||
|
await this.removeEmptySeries(s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -471,11 +445,10 @@ class ApiRouter {
|
|||||||
* Remove authors with no books and unset asin, description and imagePath
|
* Remove authors with no books and unset asin, description and imagePath
|
||||||
* Note: Other implementation is in BookScanner.checkAuthorsRemovedFromBooks (can be merged)
|
* Note: Other implementation is in BookScanner.checkAuthorsRemovedFromBooks (can be merged)
|
||||||
*
|
*
|
||||||
* @param {string} libraryId
|
|
||||||
* @param {string[]} authorIds
|
* @param {string[]} authorIds
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async checkRemoveAuthorsWithNoBooks(libraryId, authorIds) {
|
async checkRemoveAuthorsWithNoBooks(authorIds) {
|
||||||
if (!authorIds?.length) return
|
if (!authorIds?.length) return
|
||||||
|
|
||||||
const bookAuthorsToRemove = (
|
const bookAuthorsToRemove = (
|
||||||
@@ -495,10 +468,10 @@ class ApiRouter {
|
|||||||
},
|
},
|
||||||
sequelize.where(sequelize.literal('(SELECT count(*) FROM bookAuthors ba WHERE ba.authorId = author.id)'), 0)
|
sequelize.where(sequelize.literal('(SELECT count(*) FROM bookAuthors ba WHERE ba.authorId = author.id)'), 0)
|
||||||
],
|
],
|
||||||
attributes: ['id', 'name'],
|
attributes: ['id', 'name', 'libraryId'],
|
||||||
raw: true
|
raw: true
|
||||||
})
|
})
|
||||||
).map((au) => ({ id: au.id, name: au.name }))
|
).map((au) => ({ id: au.id, name: au.name, libraryId: au.libraryId }))
|
||||||
|
|
||||||
if (bookAuthorsToRemove.length) {
|
if (bookAuthorsToRemove.length) {
|
||||||
await Database.authorModel.destroy({
|
await Database.authorModel.destroy({
|
||||||
@@ -506,7 +479,7 @@ class ApiRouter {
|
|||||||
id: bookAuthorsToRemove.map((au) => au.id)
|
id: bookAuthorsToRemove.map((au) => au.id)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
bookAuthorsToRemove.forEach(({ id, name }) => {
|
bookAuthorsToRemove.forEach(({ id, name, libraryId }) => {
|
||||||
Database.removeAuthorFromFilterData(libraryId, id)
|
Database.removeAuthorFromFilterData(libraryId, id)
|
||||||
// TODO: Clients were expecting full author in payload but its unnecessary
|
// TODO: Clients were expecting full author in payload but its unnecessary
|
||||||
SocketAuthority.emitter('author_removed', { id, libraryId })
|
SocketAuthority.emitter('author_removed', { id, libraryId })
|
||||||
|
|||||||
@@ -133,8 +133,8 @@ class AudioFileScanner {
|
|||||||
|
|
||||||
// Look for disc number in folder path e.g. /Book Title/CD01/audiofile.mp3
|
// Look for disc number in folder path e.g. /Book Title/CD01/audiofile.mp3
|
||||||
const pathdir = Path.dirname(path).split('/').pop()
|
const pathdir = Path.dirname(path).split('/').pop()
|
||||||
if (pathdir && /^cd\d{1,3}$/i.test(pathdir)) {
|
if (pathdir && /^(cd|dis[ck])\s*\d{1,3}$/i.test(pathdir)) {
|
||||||
const discFromFolder = Number(pathdir.replace(/cd/i, ''))
|
const discFromFolder = Number(pathdir.replace(/^(cd|dis[ck])\s*/i, ''))
|
||||||
if (!isNaN(discFromFolder) && discFromFolder !== null) discNumber = discFromFolder
|
if (!isNaN(discFromFolder) && discFromFolder !== null) discNumber = discFromFolder
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -424,8 +424,8 @@ class LibraryScanner {
|
|||||||
}
|
}
|
||||||
const folder = library.libraryFolders[0]
|
const folder = library.libraryFolders[0]
|
||||||
|
|
||||||
const relFilePaths = folderGroups[folderId].fileUpdates.map((fileUpdate) => fileUpdate.relPath)
|
const filePathItems = folderGroups[folderId].fileUpdates.map((fileUpdate) => fileUtils.getFilePathItemFromFileUpdate(fileUpdate))
|
||||||
const fileUpdateGroup = scanUtils.groupFilesIntoLibraryItemPaths(library.mediaType, relFilePaths)
|
const fileUpdateGroup = scanUtils.groupFileItemsIntoLibraryItemDirs(library.mediaType, filePathItems, !!library.settings?.audiobooksOnly)
|
||||||
|
|
||||||
if (!Object.keys(fileUpdateGroup).length) {
|
if (!Object.keys(fileUpdateGroup).length) {
|
||||||
Logger.info(`[LibraryScanner] No important changes to scan for in folder "${folderId}"`)
|
Logger.info(`[LibraryScanner] No important changes to scan for in folder "${folderId}"`)
|
||||||
|
|||||||
@@ -131,11 +131,21 @@ async function readTextFile(path) {
|
|||||||
}
|
}
|
||||||
module.exports.readTextFile = readTextFile
|
module.exports.readTextFile = readTextFile
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef FilePathItem
|
||||||
|
* @property {string} name - file name e.g. "audiofile.m4b"
|
||||||
|
* @property {string} path - fullpath excluding folder e.g. "Author/Book/audiofile.m4b"
|
||||||
|
* @property {string} reldirpath - path excluding file name e.g. "Author/Book"
|
||||||
|
* @property {string} fullpath - full path e.g. "/audiobooks/Author/Book/audiofile.m4b"
|
||||||
|
* @property {string} extension - file extension e.g. ".m4b"
|
||||||
|
* @property {number} deep - depth of file in directory (0 is file in folder root)
|
||||||
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get array of files inside dir
|
* Get array of files inside dir
|
||||||
* @param {string} path
|
* @param {string} path
|
||||||
* @param {string} [relPathToReplace]
|
* @param {string} [relPathToReplace]
|
||||||
* @returns {{name:string, path:string, dirpath:string, reldirpath:string, fullpath:string, extension:string, deep:number}[]}
|
* @returns {FilePathItem[]}
|
||||||
*/
|
*/
|
||||||
async function recurseFiles(path, relPathToReplace = null) {
|
async function recurseFiles(path, relPathToReplace = null) {
|
||||||
path = filePathToPOSIX(path)
|
path = filePathToPOSIX(path)
|
||||||
@@ -213,7 +223,6 @@ async function recurseFiles(path, relPathToReplace = null) {
|
|||||||
return {
|
return {
|
||||||
name: item.name,
|
name: item.name,
|
||||||
path: item.fullname.replace(relPathToReplace, ''),
|
path: item.fullname.replace(relPathToReplace, ''),
|
||||||
dirpath: item.path,
|
|
||||||
reldirpath: isInRoot ? '' : item.path.replace(relPathToReplace, ''),
|
reldirpath: isInRoot ? '' : item.path.replace(relPathToReplace, ''),
|
||||||
fullpath: item.fullname,
|
fullpath: item.fullname,
|
||||||
extension: item.extension,
|
extension: item.extension,
|
||||||
@@ -228,6 +237,26 @@ async function recurseFiles(path, relPathToReplace = null) {
|
|||||||
}
|
}
|
||||||
module.exports.recurseFiles = recurseFiles
|
module.exports.recurseFiles = recurseFiles
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {import('../Watcher').PendingFileUpdate} fileUpdate
|
||||||
|
* @returns {FilePathItem}
|
||||||
|
*/
|
||||||
|
module.exports.getFilePathItemFromFileUpdate = (fileUpdate) => {
|
||||||
|
let relPath = fileUpdate.relPath
|
||||||
|
if (relPath.startsWith('/')) relPath = relPath.slice(1)
|
||||||
|
|
||||||
|
const dirname = Path.dirname(relPath)
|
||||||
|
return {
|
||||||
|
name: Path.basename(relPath),
|
||||||
|
path: relPath,
|
||||||
|
reldirpath: dirname === '.' ? '' : dirname,
|
||||||
|
fullpath: fileUpdate.path,
|
||||||
|
extension: Path.extname(relPath),
|
||||||
|
deep: relPath.split('/').length - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Download file from web to local file system
|
* Download file from web to local file system
|
||||||
* Uses SSRF filter to prevent internal URLs
|
* Uses SSRF filter to prevent internal URLs
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ function parseTags(format, verbose) {
|
|||||||
file_tag_genre: tryGrabTags(format, 'genre', 'tcon', 'tco'),
|
file_tag_genre: tryGrabTags(format, 'genre', 'tcon', 'tco'),
|
||||||
file_tag_series: tryGrabTags(format, 'series', 'show', 'mvnm'),
|
file_tag_series: tryGrabTags(format, 'series', 'show', 'mvnm'),
|
||||||
file_tag_seriespart: tryGrabTags(format, 'series-part', 'episode_id', 'mvin', 'part'),
|
file_tag_seriespart: tryGrabTags(format, 'series-part', 'episode_id', 'mvin', 'part'),
|
||||||
file_tag_grouping: tryGrabTags(format, 'grouping'),
|
file_tag_grouping: tryGrabTags(format, 'grouping', 'grp1'),
|
||||||
file_tag_isbn: tryGrabTags(format, 'isbn'), // custom
|
file_tag_isbn: tryGrabTags(format, 'isbn'), // custom
|
||||||
file_tag_language: tryGrabTags(format, 'language', 'lang'),
|
file_tag_language: tryGrabTags(format, 'language', 'lang'),
|
||||||
file_tag_asin: tryGrabTags(format, 'asin', 'audible_asin'), // custom
|
file_tag_asin: tryGrabTags(format, 'asin', 'audible_asin'), // custom
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ module.exports = {
|
|||||||
|
|
||||||
for (const book of booksAdded) {
|
for (const book of booksAdded) {
|
||||||
// Grab first 25 that have a cover
|
// Grab first 25 that have a cover
|
||||||
if (book.coverPath && !booksWithCovers.includes(book.libraryItem.id) && booksWithCovers.length < 25 && await fsExtra.pathExists(book.coverPath)) {
|
if (book.coverPath && !booksWithCovers.includes(book.libraryItem.id) && booksWithCovers.length < 25 && (await fsExtra.pathExists(book.coverPath))) {
|
||||||
booksWithCovers.push(book.libraryItem.id)
|
booksWithCovers.push(book.libraryItem.id)
|
||||||
}
|
}
|
||||||
if (book.duration && !isNaN(book.duration)) {
|
if (book.duration && !isNaN(book.duration)) {
|
||||||
@@ -95,45 +95,54 @@ module.exports = {
|
|||||||
const listeningSessions = await this.getListeningSessionsForYear(year)
|
const listeningSessions = await this.getListeningSessionsForYear(year)
|
||||||
let totalListeningTime = 0
|
let totalListeningTime = 0
|
||||||
for (const ls of listeningSessions) {
|
for (const ls of listeningSessions) {
|
||||||
totalListeningTime += (ls.timeListening || 0)
|
totalListeningTime += ls.timeListening || 0
|
||||||
|
|
||||||
const authors = ls.mediaMetadata.authors || []
|
const authors = ls.mediaMetadata?.authors || []
|
||||||
authors.forEach((au) => {
|
authors.forEach((au) => {
|
||||||
if (!authorListeningMap[au.name]) authorListeningMap[au.name] = 0
|
if (!authorListeningMap[au.name]) authorListeningMap[au.name] = 0
|
||||||
authorListeningMap[au.name] += (ls.timeListening || 0)
|
authorListeningMap[au.name] += ls.timeListening || 0
|
||||||
})
|
})
|
||||||
|
|
||||||
const narrators = ls.mediaMetadata.narrators || []
|
const narrators = ls.mediaMetadata?.narrators || []
|
||||||
narrators.forEach((narrator) => {
|
narrators.forEach((narrator) => {
|
||||||
if (!narratorListeningMap[narrator]) narratorListeningMap[narrator] = 0
|
if (!narratorListeningMap[narrator]) narratorListeningMap[narrator] = 0
|
||||||
narratorListeningMap[narrator] += (ls.timeListening || 0)
|
narratorListeningMap[narrator] += ls.timeListening || 0
|
||||||
})
|
})
|
||||||
|
|
||||||
// Filter out bad genres like "audiobook" and "audio book"
|
// Filter out bad genres like "audiobook" and "audio book"
|
||||||
const genres = (ls.mediaMetadata.genres || []).filter(g => g && !g.toLowerCase().includes('audiobook') && !g.toLowerCase().includes('audio book'))
|
const genres = (ls.mediaMetadata?.genres || []).filter((g) => g && !g.toLowerCase().includes('audiobook') && !g.toLowerCase().includes('audio book'))
|
||||||
genres.forEach((genre) => {
|
genres.forEach((genre) => {
|
||||||
if (!genreListeningMap[genre]) genreListeningMap[genre] = 0
|
if (!genreListeningMap[genre]) genreListeningMap[genre] = 0
|
||||||
genreListeningMap[genre] += (ls.timeListening || 0)
|
genreListeningMap[genre] += ls.timeListening || 0
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
let topAuthors = null
|
let topAuthors = null
|
||||||
topAuthors = Object.keys(authorListeningMap).map(authorName => ({
|
topAuthors = Object.keys(authorListeningMap)
|
||||||
name: authorName,
|
.map((authorName) => ({
|
||||||
time: Math.round(authorListeningMap[authorName])
|
name: authorName,
|
||||||
})).sort((a, b) => b.time - a.time).slice(0, 3)
|
time: Math.round(authorListeningMap[authorName])
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.time - a.time)
|
||||||
|
.slice(0, 3)
|
||||||
|
|
||||||
let topNarrators = null
|
let topNarrators = null
|
||||||
topNarrators = Object.keys(narratorListeningMap).map(narratorName => ({
|
topNarrators = Object.keys(narratorListeningMap)
|
||||||
name: narratorName,
|
.map((narratorName) => ({
|
||||||
time: Math.round(narratorListeningMap[narratorName])
|
name: narratorName,
|
||||||
})).sort((a, b) => b.time - a.time).slice(0, 3)
|
time: Math.round(narratorListeningMap[narratorName])
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.time - a.time)
|
||||||
|
.slice(0, 3)
|
||||||
|
|
||||||
let topGenres = null
|
let topGenres = null
|
||||||
topGenres = Object.keys(genreListeningMap).map(genre => ({
|
topGenres = Object.keys(genreListeningMap)
|
||||||
genre,
|
.map((genre) => ({
|
||||||
time: Math.round(genreListeningMap[genre])
|
genre,
|
||||||
})).sort((a, b) => b.time - a.time).slice(0, 3)
|
time: Math.round(genreListeningMap[genre])
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.time - a.time)
|
||||||
|
.slice(0, 3)
|
||||||
|
|
||||||
// Stats for total books, size and duration for everything added this year or earlier
|
// Stats for total books, size and duration for everything added this year or earlier
|
||||||
const [totalStatResultsRow] = await Database.sequelize.query(`SELECT SUM(li.size) AS totalSize, SUM(b.duration) AS totalDuration, COUNT(*) AS totalItems FROM libraryItems li, books b WHERE b.id = li.mediaId AND li.mediaType = 'book' AND li.createdAt < ":nextYear-01-01";`, {
|
const [totalStatResultsRow] = await Database.sequelize.query(`SELECT SUM(li.size) AS totalSize, SUM(b.duration) AS totalDuration, COUNT(*) AS totalItems FROM libraryItems li, books b WHERE b.id = li.mediaId AND li.mediaType = 'book' AND li.createdAt < ":nextYear-01-01";`, {
|
||||||
|
|||||||
+4
-103
@@ -33,109 +33,8 @@ function checkFilepathIsAudioFile(filepath) {
|
|||||||
module.exports.checkFilepathIsAudioFile = checkFilepathIsAudioFile
|
module.exports.checkFilepathIsAudioFile = checkFilepathIsAudioFile
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TODO: Function needs to be re-done
|
|
||||||
* @param {string} mediaType
|
* @param {string} mediaType
|
||||||
* @param {string[]} paths array of relative file paths
|
* @param {import('./fileUtils').FilePathItem[]} fileItems
|
||||||
* @returns {Record<string,string[]>} map of files grouped into potential libarary item dirs
|
|
||||||
*/
|
|
||||||
function groupFilesIntoLibraryItemPaths(mediaType, paths) {
|
|
||||||
// Step 1: Clean path, Remove leading "/", Filter out non-media files in root dir
|
|
||||||
var nonMediaFilePaths = []
|
|
||||||
var pathsFiltered = paths
|
|
||||||
.map((path) => {
|
|
||||||
return path.startsWith('/') ? path.slice(1) : path
|
|
||||||
})
|
|
||||||
.filter((path) => {
|
|
||||||
let parsedPath = Path.parse(path)
|
|
||||||
// Is not in root dir OR is a book media file
|
|
||||||
if (parsedPath.dir) {
|
|
||||||
if (!isMediaFile(mediaType, parsedPath.ext, false)) {
|
|
||||||
// Seperate out non-media files
|
|
||||||
nonMediaFilePaths.push(path)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
} else if (mediaType === 'book' && isMediaFile(mediaType, parsedPath.ext, false)) {
|
|
||||||
// (book media type supports single file audiobooks/ebooks in root dir)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
|
|
||||||
// Step 2: Sort by least number of directories
|
|
||||||
pathsFiltered.sort((a, b) => {
|
|
||||||
var pathsA = Path.dirname(a).split('/').length
|
|
||||||
var pathsB = Path.dirname(b).split('/').length
|
|
||||||
return pathsA - pathsB
|
|
||||||
})
|
|
||||||
|
|
||||||
// Step 3: Group files in dirs
|
|
||||||
var itemGroup = {}
|
|
||||||
pathsFiltered.forEach((path) => {
|
|
||||||
var dirparts = Path.dirname(path)
|
|
||||||
.split('/')
|
|
||||||
.filter((p) => !!p && p !== '.') // dirname returns . if no directory
|
|
||||||
var numparts = dirparts.length
|
|
||||||
var _path = ''
|
|
||||||
|
|
||||||
if (!numparts) {
|
|
||||||
// Media file in root
|
|
||||||
itemGroup[path] = path
|
|
||||||
} else {
|
|
||||||
// Iterate over directories in path
|
|
||||||
for (let i = 0; i < numparts; i++) {
|
|
||||||
var dirpart = dirparts.shift()
|
|
||||||
_path = Path.posix.join(_path, dirpart)
|
|
||||||
|
|
||||||
if (itemGroup[_path]) {
|
|
||||||
// Directory already has files, add file
|
|
||||||
var relpath = Path.posix.join(dirparts.join('/'), Path.basename(path))
|
|
||||||
itemGroup[_path].push(relpath)
|
|
||||||
return
|
|
||||||
} else if (!dirparts.length) {
|
|
||||||
// This is the last directory, create group
|
|
||||||
itemGroup[_path] = [Path.basename(path)]
|
|
||||||
return
|
|
||||||
} else if (dirparts.length === 1 && /^cd\d{1,3}$/i.test(dirparts[0])) {
|
|
||||||
// Next directory is the last and is a CD dir, create group
|
|
||||||
itemGroup[_path] = [Path.posix.join(dirparts[0], Path.basename(path))]
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Step 4: Add in non-media files if they fit into item group
|
|
||||||
if (nonMediaFilePaths.length) {
|
|
||||||
for (const nonMediaFilePath of nonMediaFilePaths) {
|
|
||||||
const pathDir = Path.dirname(nonMediaFilePath)
|
|
||||||
const filename = Path.basename(nonMediaFilePath)
|
|
||||||
const dirparts = pathDir.split('/')
|
|
||||||
const numparts = dirparts.length
|
|
||||||
let _path = ''
|
|
||||||
|
|
||||||
// Iterate over directories in path
|
|
||||||
for (let i = 0; i < numparts; i++) {
|
|
||||||
const dirpart = dirparts.shift()
|
|
||||||
_path = Path.posix.join(_path, dirpart)
|
|
||||||
if (itemGroup[_path]) {
|
|
||||||
// Directory is a group
|
|
||||||
const relpath = Path.posix.join(dirparts.join('/'), filename)
|
|
||||||
itemGroup[_path].push(relpath)
|
|
||||||
} else if (!dirparts.length) {
|
|
||||||
itemGroup[_path] = [filename]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return itemGroup
|
|
||||||
}
|
|
||||||
module.exports.groupFilesIntoLibraryItemPaths = groupFilesIntoLibraryItemPaths
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} mediaType
|
|
||||||
* @param {{name:string, path:string, dirpath:string, reldirpath:string, fullpath:string, extension:string, deep:number}[]} fileItems (see recurseFiles)
|
|
||||||
* @param {boolean} [audiobooksOnly=false]
|
* @param {boolean} [audiobooksOnly=false]
|
||||||
* @returns {Record<string,string[]>} map of files grouped into potential libarary item dirs
|
* @returns {Record<string,string[]>} map of files grouped into potential libarary item dirs
|
||||||
*/
|
*/
|
||||||
@@ -147,7 +46,9 @@ function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems, audiobooksOnly
|
|||||||
|
|
||||||
// Step 2: Seperate media files and other files
|
// Step 2: Seperate media files and other files
|
||||||
// - Directories without a media file will not be included
|
// - Directories without a media file will not be included
|
||||||
|
/** @type {import('./fileUtils').FilePathItem[]} */
|
||||||
const mediaFileItems = []
|
const mediaFileItems = []
|
||||||
|
/** @type {import('./fileUtils').FilePathItem[]} */
|
||||||
const otherFileItems = []
|
const otherFileItems = []
|
||||||
itemsFiltered.forEach((item) => {
|
itemsFiltered.forEach((item) => {
|
||||||
if (isMediaFile(mediaType, item.extension, audiobooksOnly)) mediaFileItems.push(item)
|
if (isMediaFile(mediaType, item.extension, audiobooksOnly)) mediaFileItems.push(item)
|
||||||
@@ -179,7 +80,7 @@ function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems, audiobooksOnly
|
|||||||
// This is the last directory, create group
|
// This is the last directory, create group
|
||||||
libraryItemGroup[_path] = [item.name]
|
libraryItemGroup[_path] = [item.name]
|
||||||
return
|
return
|
||||||
} else if (dirparts.length === 1 && /^cd\d{1,3}$/i.test(dirparts[0])) {
|
} else if (dirparts.length === 1 && /^(cd|dis[ck])\s*\d{1,3}$/i.test(dirparts[0])) {
|
||||||
// Next directory is the last and is a CD dir, create group
|
// Next directory is the last and is a CD dir, create group
|
||||||
libraryItemGroup[_path] = [Path.posix.join(dirparts[0], item.name)]
|
libraryItemGroup[_path] = [Path.posix.join(dirparts[0], item.name)]
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
const { expect } = require('chai')
|
||||||
|
const { Sequelize } = require('sequelize')
|
||||||
|
const sinon = require('sinon')
|
||||||
|
|
||||||
|
const Database = require('../../../server/Database')
|
||||||
|
const ApiRouter = require('../../../server/routers/ApiRouter')
|
||||||
|
const LibraryItemController = require('../../../server/controllers/LibraryItemController')
|
||||||
|
const ApiCacheManager = require('../../../server/managers/ApiCacheManager')
|
||||||
|
const RssFeedManager = require('../../../server/managers/RssFeedManager')
|
||||||
|
const Logger = require('../../../server/Logger')
|
||||||
|
|
||||||
|
describe('LibraryItemController', () => {
|
||||||
|
/** @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()
|
||||||
|
|
||||||
|
apiRouter = new ApiRouter({
|
||||||
|
apiCacheManager: new ApiCacheManager(),
|
||||||
|
rssFeedManager: new RssFeedManager()
|
||||||
|
})
|
||||||
|
|
||||||
|
sinon.stub(Logger, 'info')
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
sinon.restore()
|
||||||
|
|
||||||
|
// Clear all tables
|
||||||
|
await Database.sequelize.sync({ force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('checkRemoveAuthorsAndSeries', () => {
|
||||||
|
let libraryItem1Id
|
||||||
|
let libraryItem2Id
|
||||||
|
let author1Id
|
||||||
|
let author2Id
|
||||||
|
let author3Id
|
||||||
|
let series1Id
|
||||||
|
let series2Id
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const newLibrary = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' })
|
||||||
|
const newLibraryFolder = await Database.libraryFolderModel.create({ path: '/test', libraryId: newLibrary.id })
|
||||||
|
|
||||||
|
const newBook = await Database.bookModel.create({ title: 'Test Book', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
|
||||||
|
const newLibraryItem = await Database.libraryItemModel.create({ libraryFiles: [], mediaId: newBook.id, mediaType: 'book', libraryId: newLibrary.id, libraryFolderId: newLibraryFolder.id })
|
||||||
|
libraryItem1Id = newLibraryItem.id
|
||||||
|
|
||||||
|
const newBook2 = await Database.bookModel.create({ title: 'Test Book 2', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
|
||||||
|
const newLibraryItem2 = await Database.libraryItemModel.create({ libraryFiles: [], mediaId: newBook2.id, mediaType: 'book', libraryId: newLibrary.id, libraryFolderId: newLibraryFolder.id })
|
||||||
|
libraryItem2Id = newLibraryItem2.id
|
||||||
|
|
||||||
|
const newAuthor = await Database.authorModel.create({ name: 'Test Author', libraryId: newLibrary.id })
|
||||||
|
author1Id = newAuthor.id
|
||||||
|
const newAuthor2 = await Database.authorModel.create({ name: 'Test Author 2', libraryId: newLibrary.id })
|
||||||
|
author2Id = newAuthor2.id
|
||||||
|
const newAuthor3 = await Database.authorModel.create({ name: 'Test Author 3', imagePath: '/fake/path/author.png', libraryId: newLibrary.id })
|
||||||
|
author3Id = newAuthor3.id
|
||||||
|
|
||||||
|
// Book 1 has Author 1, Author 2 and Author 3
|
||||||
|
await Database.bookAuthorModel.create({ bookId: newBook.id, authorId: newAuthor.id })
|
||||||
|
await Database.bookAuthorModel.create({ bookId: newBook.id, authorId: newAuthor2.id })
|
||||||
|
await Database.bookAuthorModel.create({ bookId: newBook.id, authorId: newAuthor3.id })
|
||||||
|
|
||||||
|
// Book 2 has Author 2
|
||||||
|
await Database.bookAuthorModel.create({ bookId: newBook2.id, authorId: newAuthor2.id })
|
||||||
|
|
||||||
|
const newSeries = await Database.seriesModel.create({ name: 'Test Series', libraryId: newLibrary.id })
|
||||||
|
series1Id = newSeries.id
|
||||||
|
const newSeries2 = await Database.seriesModel.create({ name: 'Test Series 2', libraryId: newLibrary.id })
|
||||||
|
series2Id = newSeries2.id
|
||||||
|
|
||||||
|
// Book 1 is in Series 1 and Series 2
|
||||||
|
await Database.bookSeriesModel.create({ bookId: newBook.id, seriesId: newSeries.id })
|
||||||
|
await Database.bookSeriesModel.create({ bookId: newBook.id, seriesId: newSeries2.id })
|
||||||
|
|
||||||
|
// Book 2 is in Series 2
|
||||||
|
await Database.bookSeriesModel.create({ bookId: newBook2.id, seriesId: newSeries2.id })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should remove authors and series with no books on library item delete', async () => {
|
||||||
|
const oldLibraryItem = await Database.libraryItemModel.getOldById(libraryItem1Id)
|
||||||
|
|
||||||
|
const fakeReq = {
|
||||||
|
query: {},
|
||||||
|
libraryItem: oldLibraryItem
|
||||||
|
}
|
||||||
|
const fakeRes = {
|
||||||
|
sendStatus: sinon.spy()
|
||||||
|
}
|
||||||
|
await LibraryItemController.delete.bind(apiRouter)(fakeReq, fakeRes)
|
||||||
|
|
||||||
|
expect(fakeRes.sendStatus.calledWith(200)).to.be.true
|
||||||
|
|
||||||
|
// Author 1 should be removed because it has no books
|
||||||
|
const author1Exists = await Database.authorModel.checkExistsById(author1Id)
|
||||||
|
expect(author1Exists).to.be.false
|
||||||
|
|
||||||
|
// Author 2 should not be removed because it still has Book 2
|
||||||
|
const author2Exists = await Database.authorModel.checkExistsById(author2Id)
|
||||||
|
expect(author2Exists).to.be.true
|
||||||
|
|
||||||
|
// Author 3 should not be removed because it has an image
|
||||||
|
const author3Exists = await Database.authorModel.checkExistsById(author3Id)
|
||||||
|
expect(author3Exists).to.be.true
|
||||||
|
|
||||||
|
// Series 1 should be removed because it has no books
|
||||||
|
const series1Exists = await Database.seriesModel.checkExistsById(series1Id)
|
||||||
|
expect(series1Exists).to.be.false
|
||||||
|
|
||||||
|
// Series 2 should not be removed because it still has Book 2
|
||||||
|
const series2Exists = await Database.seriesModel.checkExistsById(series2Id)
|
||||||
|
expect(series2Exists).to.be.true
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should remove authors and series with no books on library item batch delete', async () => {
|
||||||
|
// Batch delete library item 1
|
||||||
|
const fakeReq = {
|
||||||
|
query: {},
|
||||||
|
user: {
|
||||||
|
canDelete: true
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
libraryItemIds: [libraryItem1Id]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const fakeRes = {
|
||||||
|
sendStatus: sinon.spy()
|
||||||
|
}
|
||||||
|
await LibraryItemController.batchDelete.bind(apiRouter)(fakeReq, fakeRes)
|
||||||
|
|
||||||
|
expect(fakeRes.sendStatus.calledWith(200)).to.be.true
|
||||||
|
|
||||||
|
// Author 1 should be removed because it has no books
|
||||||
|
const author1Exists = await Database.authorModel.checkExistsById(author1Id)
|
||||||
|
expect(author1Exists).to.be.false
|
||||||
|
|
||||||
|
// Author 2 should not be removed because it still has Book 2
|
||||||
|
const author2Exists = await Database.authorModel.checkExistsById(author2Id)
|
||||||
|
expect(author2Exists).to.be.true
|
||||||
|
|
||||||
|
// Author 3 should not be removed because it has an image
|
||||||
|
const author3Exists = await Database.authorModel.checkExistsById(author3Id)
|
||||||
|
expect(author3Exists).to.be.true
|
||||||
|
|
||||||
|
// Series 1 should be removed because it has no books
|
||||||
|
const series1Exists = await Database.seriesModel.checkExistsById(series1Id)
|
||||||
|
expect(series1Exists).to.be.false
|
||||||
|
|
||||||
|
// Series 2 should not be removed because it still has Book 2
|
||||||
|
const series2Exists = await Database.seriesModel.checkExistsById(series2Id)
|
||||||
|
expect(series2Exists).to.be.true
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should remove authors and series with no books on library item update media', async () => {
|
||||||
|
const oldLibraryItem = await Database.libraryItemModel.getOldById(libraryItem1Id)
|
||||||
|
|
||||||
|
// Update library item 1 remove all authors and series
|
||||||
|
const fakeReq = {
|
||||||
|
query: {},
|
||||||
|
body: {
|
||||||
|
metadata: {
|
||||||
|
authors: [],
|
||||||
|
series: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
libraryItem: oldLibraryItem
|
||||||
|
}
|
||||||
|
const fakeRes = {
|
||||||
|
json: sinon.spy()
|
||||||
|
}
|
||||||
|
await LibraryItemController.updateMedia.bind(apiRouter)(fakeReq, fakeRes)
|
||||||
|
|
||||||
|
expect(fakeRes.json.calledOnce).to.be.true
|
||||||
|
|
||||||
|
// Author 1 should be removed because it has no books
|
||||||
|
const author1Exists = await Database.authorModel.checkExistsById(author1Id)
|
||||||
|
expect(author1Exists).to.be.false
|
||||||
|
|
||||||
|
// Author 2 should not be removed because it still has Book 2
|
||||||
|
const author2Exists = await Database.authorModel.checkExistsById(author2Id)
|
||||||
|
expect(author2Exists).to.be.true
|
||||||
|
|
||||||
|
// Author 3 should not be removed because it has an image
|
||||||
|
const author3Exists = await Database.authorModel.checkExistsById(author3Id)
|
||||||
|
expect(author3Exists).to.be.true
|
||||||
|
|
||||||
|
// Series 1 should be removed because it has no books
|
||||||
|
const series1Exists = await Database.seriesModel.checkExistsById(series1Id)
|
||||||
|
expect(series1Exists).to.be.false
|
||||||
|
|
||||||
|
// Series 2 should not be removed because it still has Book 2
|
||||||
|
const series2Exists = await Database.seriesModel.checkExistsById(series2Id)
|
||||||
|
expect(series2Exists).to.be.true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
const { expect } = require('chai')
|
||||||
|
const sinon = require('sinon')
|
||||||
|
const { up } = require('../../../server/migrations/v2.17.3-fk-constraints')
|
||||||
|
const { Sequelize, QueryInterface } = require('sequelize')
|
||||||
|
const Logger = require('../../../server/Logger')
|
||||||
|
|
||||||
|
describe('migration-v2.17.3-fk-constraints', () => {
|
||||||
|
let sequelize
|
||||||
|
/** @type {QueryInterface} */
|
||||||
|
let queryInterface
|
||||||
|
let loggerInfoStub
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
|
||||||
|
queryInterface = sequelize.getQueryInterface()
|
||||||
|
loggerInfoStub = sinon.stub(Logger, 'info')
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
sinon.restore()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('up', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Create associated tables: Users, libraries, libraryFolders, playlists, devices
|
||||||
|
await queryInterface.sequelize.query('CREATE TABLE `users` (`id` UUID PRIMARY KEY);')
|
||||||
|
await queryInterface.sequelize.query('CREATE TABLE `libraries` (`id` UUID PRIMARY KEY);')
|
||||||
|
await queryInterface.sequelize.query('CREATE TABLE `libraryFolders` (`id` UUID PRIMARY KEY);')
|
||||||
|
await queryInterface.sequelize.query('CREATE TABLE `playlists` (`id` UUID PRIMARY KEY);')
|
||||||
|
await queryInterface.sequelize.query('CREATE TABLE `devices` (`id` UUID PRIMARY KEY);')
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await queryInterface.dropAllTables()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fix table foreign key constraints', async () => {
|
||||||
|
// Create tables with missing foreign key constraints: libraryItems, feeds, mediaItemShares, playbackSessions, playlistMediaItems, mediaProgresses
|
||||||
|
await queryInterface.sequelize.query('CREATE TABLE `libraryItems` (`id` UUID UNIQUE PRIMARY KEY, `libraryId` UUID REFERENCES `libraries` (`id`), `libraryFolderId` UUID REFERENCES `libraryFolders` (`id`));')
|
||||||
|
await queryInterface.sequelize.query('CREATE TABLE `feeds` (`id` UUID UNIQUE PRIMARY KEY, `userId` UUID REFERENCES `users` (`id`));')
|
||||||
|
await queryInterface.sequelize.query('CREATE TABLE `mediaItemShares` (`id` UUID UNIQUE PRIMARY KEY, `userId` UUID REFERENCES `users` (`id`));')
|
||||||
|
await queryInterface.sequelize.query('CREATE TABLE `playbackSessions` (`id` UUID UNIQUE PRIMARY KEY, `userId` UUID REFERENCES `users` (`id`), `deviceId` UUID REFERENCES `devices` (`id`), `libraryId` UUID REFERENCES `libraries` (`id`));')
|
||||||
|
await queryInterface.sequelize.query('CREATE TABLE `playlistMediaItems` (`id` UUID UNIQUE PRIMARY KEY, `playlistId` UUID REFERENCES `playlists` (`id`));')
|
||||||
|
await queryInterface.sequelize.query('CREATE TABLE `mediaProgresses` (`id` UUID UNIQUE PRIMARY KEY, `userId` UUID REFERENCES `users` (`id`));')
|
||||||
|
|
||||||
|
//
|
||||||
|
// Validate that foreign key constraints are missing
|
||||||
|
//
|
||||||
|
let libraryItemsForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(libraryItems);`)
|
||||||
|
expect(libraryItemsForeignKeys).to.have.deep.members([
|
||||||
|
{ id: 0, seq: 0, table: 'libraryFolders', from: 'libraryFolderId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' },
|
||||||
|
{ id: 1, seq: 0, table: 'libraries', from: 'libraryId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' }
|
||||||
|
])
|
||||||
|
|
||||||
|
let feedsForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(feeds);`)
|
||||||
|
expect(feedsForeignKeys).to.deep.equal([{ id: 0, seq: 0, table: 'users', from: 'userId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' }])
|
||||||
|
|
||||||
|
let mediaItemSharesForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(mediaItemShares);`)
|
||||||
|
expect(mediaItemSharesForeignKeys).to.deep.equal([{ id: 0, seq: 0, table: 'users', from: 'userId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' }])
|
||||||
|
|
||||||
|
let playbackSessionForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(playbackSessions);`)
|
||||||
|
expect(playbackSessionForeignKeys).to.deep.equal([
|
||||||
|
{ id: 0, seq: 0, table: 'libraries', from: 'libraryId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' },
|
||||||
|
{ id: 1, seq: 0, table: 'devices', from: 'deviceId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' },
|
||||||
|
{ id: 2, seq: 0, table: 'users', from: 'userId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' }
|
||||||
|
])
|
||||||
|
|
||||||
|
let playlistMediaItemsForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(playlistMediaItems);`)
|
||||||
|
expect(playlistMediaItemsForeignKeys).to.deep.equal([{ id: 0, seq: 0, table: 'playlists', from: 'playlistId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' }])
|
||||||
|
|
||||||
|
let mediaProgressesForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(mediaProgresses);`)
|
||||||
|
expect(mediaProgressesForeignKeys).to.deep.equal([{ id: 0, seq: 0, table: 'users', from: 'userId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' }])
|
||||||
|
|
||||||
|
//
|
||||||
|
// Insert test data into tables
|
||||||
|
//
|
||||||
|
await queryInterface.bulkInsert('users', [{ id: 'e1a96857-48a8-43b6-8966-abc909c55b0f' }])
|
||||||
|
await queryInterface.bulkInsert('libraries', [{ id: 'a41a40e3-f516-40f5-810d-757ab668ebba' }])
|
||||||
|
await queryInterface.bulkInsert('libraryFolders', [{ id: 'b41a40e3-f516-40f5-810d-757ab668ebba' }])
|
||||||
|
await queryInterface.bulkInsert('playlists', [{ id: 'f41a40e3-f516-40f5-810d-757ab668ebba' }])
|
||||||
|
await queryInterface.bulkInsert('devices', [{ id: 'g41a40e3-f516-40f5-810d-757ab668ebba' }])
|
||||||
|
|
||||||
|
await queryInterface.bulkInsert('libraryItems', [{ id: 'c1a96857-48a8-43b6-8966-abc909c55b0f', libraryId: 'a41a40e3-f516-40f5-810d-757ab668ebba', libraryFolderId: 'b41a40e3-f516-40f5-810d-757ab668ebba' }])
|
||||||
|
await queryInterface.bulkInsert('feeds', [{ id: 'd1a96857-48a8-43b6-8966-abc909c55b0f', userId: 'e1a96857-48a8-43b6-8966-abc909c55b0f' }])
|
||||||
|
await queryInterface.bulkInsert('mediaItemShares', [{ id: 'h1a96857-48a8-43b6-8966-abc909c55b0f', userId: 'e1a96857-48a8-43b6-8966-abc909c55b0f' }])
|
||||||
|
await queryInterface.bulkInsert('playbackSessions', [{ id: 'f1a96857-48a8-43b6-8966-abc909c55b0x', userId: 'e1a96857-48a8-43b6-8966-abc909c55b0f', deviceId: 'g41a40e3-f516-40f5-810d-757ab668ebba', libraryId: 'a41a40e3-f516-40f5-810d-757ab668ebba' }])
|
||||||
|
await queryInterface.bulkInsert('playlistMediaItems', [{ id: 'i1a96857-48a8-43b6-8966-abc909c55b0f', playlistId: 'f41a40e3-f516-40f5-810d-757ab668ebba' }])
|
||||||
|
await queryInterface.bulkInsert('mediaProgresses', [{ id: 'j1a96857-48a8-43b6-8966-abc909c55b0f', userId: 'e1a96857-48a8-43b6-8966-abc909c55b0f' }])
|
||||||
|
|
||||||
|
//
|
||||||
|
// Query data before migration
|
||||||
|
//
|
||||||
|
const libraryItems = await queryInterface.sequelize.query('SELECT * FROM libraryItems;')
|
||||||
|
const feeds = await queryInterface.sequelize.query('SELECT * FROM feeds;')
|
||||||
|
const mediaItemShares = await queryInterface.sequelize.query('SELECT * FROM mediaItemShares;')
|
||||||
|
const playbackSessions = await queryInterface.sequelize.query('SELECT * FROM playbackSessions;')
|
||||||
|
const playlistMediaItems = await queryInterface.sequelize.query('SELECT * FROM playlistMediaItems;')
|
||||||
|
const mediaProgresses = await queryInterface.sequelize.query('SELECT * FROM mediaProgresses;')
|
||||||
|
|
||||||
|
//
|
||||||
|
// Run migration
|
||||||
|
//
|
||||||
|
await up({ context: { queryInterface, logger: Logger } })
|
||||||
|
|
||||||
|
//
|
||||||
|
// Validate that foreign key constraints are updated
|
||||||
|
//
|
||||||
|
libraryItemsForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(libraryItems);`)
|
||||||
|
expect(libraryItemsForeignKeys).to.have.deep.members([
|
||||||
|
{ id: 0, seq: 0, table: 'libraryFolders', from: 'libraryFolderId', to: 'id', on_update: 'CASCADE', on_delete: 'SET NULL', match: 'NONE' },
|
||||||
|
{ id: 1, seq: 0, table: 'libraries', from: 'libraryId', to: 'id', on_update: 'CASCADE', on_delete: 'SET NULL', match: 'NONE' }
|
||||||
|
])
|
||||||
|
|
||||||
|
feedsForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(feeds);`)
|
||||||
|
expect(feedsForeignKeys).to.deep.equal([{ id: 0, seq: 0, table: 'users', from: 'userId', to: 'id', on_update: 'CASCADE', on_delete: 'SET NULL', match: 'NONE' }])
|
||||||
|
|
||||||
|
mediaItemSharesForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(mediaItemShares);`)
|
||||||
|
expect(mediaItemSharesForeignKeys).to.deep.equal([{ id: 0, seq: 0, table: 'users', from: 'userId', to: 'id', on_update: 'CASCADE', on_delete: 'SET NULL', match: 'NONE' }])
|
||||||
|
|
||||||
|
playbackSessionForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(playbackSessions);`)
|
||||||
|
expect(playbackSessionForeignKeys).to.deep.equal([
|
||||||
|
{ id: 0, seq: 0, table: 'libraries', from: 'libraryId', to: 'id', on_update: 'CASCADE', on_delete: 'SET NULL', match: 'NONE' },
|
||||||
|
{ id: 1, seq: 0, table: 'devices', from: 'deviceId', to: 'id', on_update: 'CASCADE', on_delete: 'SET NULL', match: 'NONE' },
|
||||||
|
{ id: 2, seq: 0, table: 'users', from: 'userId', to: 'id', on_update: 'CASCADE', on_delete: 'SET NULL', match: 'NONE' }
|
||||||
|
])
|
||||||
|
|
||||||
|
playlistMediaItemsForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(playlistMediaItems);`)
|
||||||
|
expect(playlistMediaItemsForeignKeys).to.deep.equal([{ id: 0, seq: 0, table: 'playlists', from: 'playlistId', to: 'id', on_update: 'CASCADE', on_delete: 'CASCADE', match: 'NONE' }])
|
||||||
|
|
||||||
|
mediaProgressesForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(mediaProgresses);`)
|
||||||
|
expect(mediaProgressesForeignKeys).to.deep.equal([{ id: 0, seq: 0, table: 'users', from: 'userId', to: 'id', on_update: 'CASCADE', on_delete: 'CASCADE', match: 'NONE' }])
|
||||||
|
|
||||||
|
//
|
||||||
|
// Validate that data is not changed
|
||||||
|
//
|
||||||
|
const libraryItemsAfter = await queryInterface.sequelize.query('SELECT * FROM libraryItems;')
|
||||||
|
expect(libraryItemsAfter).to.deep.equal(libraryItems)
|
||||||
|
|
||||||
|
const feedsAfter = await queryInterface.sequelize.query('SELECT * FROM feeds;')
|
||||||
|
expect(feedsAfter).to.deep.equal(feeds)
|
||||||
|
|
||||||
|
const mediaItemSharesAfter = await queryInterface.sequelize.query('SELECT * FROM mediaItemShares;')
|
||||||
|
expect(mediaItemSharesAfter).to.deep.equal(mediaItemShares)
|
||||||
|
|
||||||
|
const playbackSessionsAfter = await queryInterface.sequelize.query('SELECT * FROM playbackSessions;')
|
||||||
|
expect(playbackSessionsAfter).to.deep.equal(playbackSessions)
|
||||||
|
|
||||||
|
const playlistMediaItemsAfter = await queryInterface.sequelize.query('SELECT * FROM playlistMediaItems;')
|
||||||
|
expect(playlistMediaItemsAfter).to.deep.equal(playlistMediaItems)
|
||||||
|
|
||||||
|
const mediaProgressesAfter = await queryInterface.sequelize.query('SELECT * FROM mediaProgresses;')
|
||||||
|
expect(mediaProgressesAfter).to.deep.equal(mediaProgresses)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should keep correct table foreign key constraints', async () => {
|
||||||
|
// Create tables with correct foreign key constraints: libraryItems, feeds, mediaItemShares, playbackSessions, playlistMediaItems, mediaProgresses
|
||||||
|
await queryInterface.sequelize.query('CREATE TABLE `libraryItems` (`id` UUID PRIMARY KEY, `libraryId` UUID REFERENCES `libraries` (`id`) ON DELETE SET NULL ON UPDATE CASCADE, `libraryFolderId` UUID REFERENCES `libraryFolders` (`id`) ON DELETE SET NULL ON UPDATE CASCADE);')
|
||||||
|
await queryInterface.sequelize.query('CREATE TABLE `feeds` (`id` UUID PRIMARY KEY, `userId` UUID REFERENCES `users` (`id`) ON DELETE SET NULL ON UPDATE CASCADE);')
|
||||||
|
await queryInterface.sequelize.query('CREATE TABLE `mediaItemShares` (`id` UUID PRIMARY KEY, `userId` UUID REFERENCES `users` (`id`) ON DELETE SET NULL ON UPDATE CASCADE);')
|
||||||
|
await queryInterface.sequelize.query('CREATE TABLE `playbackSessions` (`id` UUID PRIMARY KEY, `userId` UUID REFERENCES `users` (`id`) ON DELETE SET NULL ON UPDATE CASCADE, `deviceId` UUID REFERENCES `devices` (`id`) ON DELETE SET NULL ON UPDATE CASCADE, `libraryId` UUID REFERENCES `libraries` (`id`) ON DELETE SET NULL ON UPDATE CASCADE);')
|
||||||
|
await queryInterface.sequelize.query('CREATE TABLE `playlistMediaItems` (`id` UUID PRIMARY KEY, `playlistId` UUID REFERENCES `playlists` (`id`) ON DELETE CASCADE ON UPDATE CASCADE);')
|
||||||
|
await queryInterface.sequelize.query('CREATE TABLE `mediaProgresses` (`id` UUID PRIMARY KEY, `userId` UUID REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE CASCADE);')
|
||||||
|
|
||||||
|
//
|
||||||
|
// Insert test data into tables
|
||||||
|
//
|
||||||
|
await queryInterface.bulkInsert('users', [{ id: 'e1a96857-48a8-43b6-8966-abc909c55b0f' }])
|
||||||
|
await queryInterface.bulkInsert('libraries', [{ id: 'a41a40e3-f516-40f5-810d-757ab668ebba' }])
|
||||||
|
await queryInterface.bulkInsert('libraryFolders', [{ id: 'b41a40e3-f516-40f5-810d-757ab668ebba' }])
|
||||||
|
await queryInterface.bulkInsert('playlists', [{ id: 'f41a40e3-f516-40f5-810d-757ab668ebba' }])
|
||||||
|
await queryInterface.bulkInsert('devices', [{ id: 'g41a40e3-f516-40f5-810d-757ab668ebba' }])
|
||||||
|
|
||||||
|
await queryInterface.bulkInsert('libraryItems', [{ id: 'c1a96857-48a8-43b6-8966-abc909c55b0f', libraryId: 'a41a40e3-f516-40f5-810d-757ab668ebba', libraryFolderId: 'b41a40e3-f516-40f5-810d-757ab668ebba' }])
|
||||||
|
await queryInterface.bulkInsert('feeds', [{ id: 'd1a96857-48a8-43b6-8966-abc909c55b0f', userId: 'e1a96857-48a8-43b6-8966-abc909c55b0f' }])
|
||||||
|
await queryInterface.bulkInsert('mediaItemShares', [{ id: 'h1a96857-48a8-43b6-8966-abc909c55b0f', userId: 'e1a96857-48a8-43b6-8966-abc909c55b0f' }])
|
||||||
|
await queryInterface.bulkInsert('playbackSessions', [{ id: 'f1a96857-48a8-43b6-8966-abc909c55b0x', userId: 'e1a96857-48a8-43b6-8966-abc909c55b0f', deviceId: 'g41a40e3-f516-40f5-810d-757ab668ebba', libraryId: 'a41a40e3-f516-40f5-810d-757ab668ebba' }])
|
||||||
|
await queryInterface.bulkInsert('playlistMediaItems', [{ id: 'i1a96857-48a8-43b6-8966-abc909c55b0f', playlistId: 'f41a40e3-f516-40f5-810d-757ab668ebba' }])
|
||||||
|
await queryInterface.bulkInsert('mediaProgresses', [{ id: 'j1a96857-48a8-43b6-8966-abc909c55b0f', userId: 'e1a96857-48a8-43b6-8966-abc909c55b0f' }])
|
||||||
|
|
||||||
|
//
|
||||||
|
// Query data before migration
|
||||||
|
//
|
||||||
|
const libraryItems = await queryInterface.sequelize.query('SELECT * FROM libraryItems;')
|
||||||
|
const feeds = await queryInterface.sequelize.query('SELECT * FROM feeds;')
|
||||||
|
const mediaItemShares = await queryInterface.sequelize.query('SELECT * FROM mediaItemShares;')
|
||||||
|
const playbackSessions = await queryInterface.sequelize.query('SELECT * FROM playbackSessions;')
|
||||||
|
const playlistMediaItems = await queryInterface.sequelize.query('SELECT * FROM playlistMediaItems;')
|
||||||
|
const mediaProgresses = await queryInterface.sequelize.query('SELECT * FROM mediaProgresses;')
|
||||||
|
|
||||||
|
await up({ context: { queryInterface, logger: Logger } })
|
||||||
|
|
||||||
|
expect(loggerInfoStub.callCount).to.equal(14)
|
||||||
|
expect(loggerInfoStub.getCall(0).calledWith(sinon.match('[2.17.3 migration] UPGRADE BEGIN: 2.17.3-fk-constraints'))).to.be.true
|
||||||
|
expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.17.3 migration] Updating libraryItems constraints'))).to.be.true
|
||||||
|
expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.17.3 migration] No changes needed for libraryItems constraints'))).to.be.true
|
||||||
|
expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.17.3 migration] Updating feeds constraints'))).to.be.true
|
||||||
|
expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.17.3 migration] No changes needed for feeds constraints'))).to.be.true
|
||||||
|
expect(loggerInfoStub.getCall(5).calledWith(sinon.match('[2.17.3 migration] Updating mediaItemShares constraints'))).to.be.true
|
||||||
|
expect(loggerInfoStub.getCall(6).calledWith(sinon.match('[2.17.3 migration] No changes needed for mediaItemShares constraints'))).to.be.true
|
||||||
|
expect(loggerInfoStub.getCall(7).calledWith(sinon.match('[2.17.3 migration] Updating playbackSessions constraints'))).to.be.true
|
||||||
|
expect(loggerInfoStub.getCall(8).calledWith(sinon.match('[2.17.3 migration] No changes needed for playbackSessions constraints'))).to.be.true
|
||||||
|
expect(loggerInfoStub.getCall(9).calledWith(sinon.match('[2.17.3 migration] Updating playlistMediaItems constraints'))).to.be.true
|
||||||
|
expect(loggerInfoStub.getCall(10).calledWith(sinon.match('[2.17.3 migration] No changes needed for playlistMediaItems constraints'))).to.be.true
|
||||||
|
expect(loggerInfoStub.getCall(11).calledWith(sinon.match('[2.17.3 migration] Updating mediaProgresses constraints'))).to.be.true
|
||||||
|
expect(loggerInfoStub.getCall(12).calledWith(sinon.match('[2.17.3 migration] No changes needed for mediaProgresses constraints'))).to.be.true
|
||||||
|
expect(loggerInfoStub.getCall(13).calledWith(sinon.match('[2.17.3 migration] UPGRADE END: 2.17.3-fk-constraints'))).to.be.true
|
||||||
|
|
||||||
|
//
|
||||||
|
// Validate that data is not changed
|
||||||
|
//
|
||||||
|
const libraryItemsAfter = await queryInterface.sequelize.query('SELECT * FROM libraryItems;')
|
||||||
|
expect(libraryItemsAfter).to.deep.equal(libraryItems)
|
||||||
|
|
||||||
|
const feedsAfter = await queryInterface.sequelize.query('SELECT * FROM feeds;')
|
||||||
|
expect(feedsAfter).to.deep.equal(feeds)
|
||||||
|
|
||||||
|
const mediaItemSharesAfter = await queryInterface.sequelize.query('SELECT * FROM mediaItemShares;')
|
||||||
|
expect(mediaItemSharesAfter).to.deep.equal(mediaItemShares)
|
||||||
|
|
||||||
|
const playbackSessionsAfter = await queryInterface.sequelize.query('SELECT * FROM playbackSessions;')
|
||||||
|
expect(playbackSessionsAfter).to.deep.equal(playbackSessions)
|
||||||
|
|
||||||
|
const playlistMediaItemsAfter = await queryInterface.sequelize.query('SELECT * FROM playlistMediaItems;')
|
||||||
|
expect(playlistMediaItemsAfter).to.deep.equal(playlistMediaItems)
|
||||||
|
|
||||||
|
const mediaProgressesAfter = await queryInterface.sequelize.query('SELECT * FROM mediaProgresses;')
|
||||||
|
expect(mediaProgressesAfter).to.deep.equal(mediaProgresses)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
const { expect } = require('chai')
|
||||||
|
const sinon = require('sinon')
|
||||||
|
const { up, down } = require('../../../server/migrations/v2.17.4-use-subfolder-for-oidc-redirect-uris')
|
||||||
|
const { Sequelize } = require('sequelize')
|
||||||
|
const Logger = require('../../../server/Logger')
|
||||||
|
|
||||||
|
describe('Migration v2.17.4-use-subfolder-for-oidc-redirect-uris', () => {
|
||||||
|
let queryInterface, logger, context
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
queryInterface = {
|
||||||
|
sequelize: {
|
||||||
|
query: sinon.stub()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger = {
|
||||||
|
info: sinon.stub(),
|
||||||
|
error: sinon.stub()
|
||||||
|
}
|
||||||
|
context = { queryInterface, logger }
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('up', () => {
|
||||||
|
it('should add authOpenIDSubfolderForRedirectURLs if OIDC is enabled', async () => {
|
||||||
|
queryInterface.sequelize.query.onFirstCall().resolves([[{ value: JSON.stringify({ authActiveAuthMethods: ['openid'] }) }]])
|
||||||
|
queryInterface.sequelize.query.onSecondCall().resolves()
|
||||||
|
|
||||||
|
await up({ context })
|
||||||
|
|
||||||
|
expect(logger.info.calledWith('[2.17.4 migration] UPGRADE BEGIN: 2.17.4-use-subfolder-for-oidc-redirect-uris')).to.be.true
|
||||||
|
expect(logger.info.calledWith('[2.17.4 migration] OIDC is enabled, adding authOpenIDSubfolderForRedirectURLs to server settings')).to.be.true
|
||||||
|
expect(queryInterface.sequelize.query.calledTwice).to.be.true
|
||||||
|
expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = "server-settings";')).to.be.true
|
||||||
|
expect(
|
||||||
|
queryInterface.sequelize.query.calledWith('UPDATE settings SET value = :value WHERE key = "server-settings";', {
|
||||||
|
replacements: {
|
||||||
|
value: JSON.stringify({ authActiveAuthMethods: ['openid'], authOpenIDSubfolderForRedirectURLs: '' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
).to.be.true
|
||||||
|
expect(logger.info.calledWith('[2.17.4 migration] UPGRADE END: 2.17.4-use-subfolder-for-oidc-redirect-uris')).to.be.true
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not add authOpenIDSubfolderForRedirectURLs if OIDC is not enabled', async () => {
|
||||||
|
queryInterface.sequelize.query.onFirstCall().resolves([[{ value: JSON.stringify({ authActiveAuthMethods: [] }) }]])
|
||||||
|
|
||||||
|
await up({ context })
|
||||||
|
|
||||||
|
expect(logger.info.calledWith('[2.17.4 migration] UPGRADE BEGIN: 2.17.4-use-subfolder-for-oidc-redirect-uris')).to.be.true
|
||||||
|
expect(logger.info.calledWith('[2.17.4 migration] OIDC is not enabled, no action required')).to.be.true
|
||||||
|
expect(queryInterface.sequelize.query.calledOnce).to.be.true
|
||||||
|
expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = "server-settings";')).to.be.true
|
||||||
|
expect(logger.info.calledWith('[2.17.4 migration] UPGRADE END: 2.17.4-use-subfolder-for-oidc-redirect-uris')).to.be.true
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw an error if server settings cannot be parsed', async () => {
|
||||||
|
queryInterface.sequelize.query.onFirstCall().resolves([[{ value: 'invalid json' }]])
|
||||||
|
|
||||||
|
try {
|
||||||
|
await up({ context })
|
||||||
|
} catch (error) {
|
||||||
|
expect(queryInterface.sequelize.query.calledOnce).to.be.true
|
||||||
|
expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = "server-settings";')).to.be.true
|
||||||
|
expect(logger.error.calledWith('[2.17.4 migration] Error parsing server settings:')).to.be.true
|
||||||
|
expect(error).to.be.instanceOf(Error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw an error if server settings are not found', async () => {
|
||||||
|
queryInterface.sequelize.query.onFirstCall().resolves([[]])
|
||||||
|
|
||||||
|
try {
|
||||||
|
await up({ context })
|
||||||
|
} catch (error) {
|
||||||
|
expect(queryInterface.sequelize.query.calledOnce).to.be.true
|
||||||
|
expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = "server-settings";')).to.be.true
|
||||||
|
expect(logger.error.calledWith('[2.17.4 migration] Server settings not found')).to.be.true
|
||||||
|
expect(error).to.be.instanceOf(Error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('down', () => {
|
||||||
|
it('should remove authOpenIDSubfolderForRedirectURLs if it exists', async () => {
|
||||||
|
queryInterface.sequelize.query.onFirstCall().resolves([[{ value: JSON.stringify({ authOpenIDSubfolderForRedirectURLs: '' }) }]])
|
||||||
|
queryInterface.sequelize.query.onSecondCall().resolves()
|
||||||
|
|
||||||
|
await down({ context })
|
||||||
|
|
||||||
|
expect(logger.info.calledWith('[2.17.4 migration] DOWNGRADE BEGIN: 2.17.4-use-subfolder-for-oidc-redirect-uris ')).to.be.true
|
||||||
|
expect(logger.info.calledWith('[2.17.4 migration] Removing authOpenIDSubfolderForRedirectURLs from server settings')).to.be.true
|
||||||
|
expect(queryInterface.sequelize.query.calledTwice).to.be.true
|
||||||
|
expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = "server-settings";')).to.be.true
|
||||||
|
expect(
|
||||||
|
queryInterface.sequelize.query.calledWith('UPDATE settings SET value = :value WHERE key = "server-settings";', {
|
||||||
|
replacements: {
|
||||||
|
value: JSON.stringify({})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
).to.be.true
|
||||||
|
expect(logger.info.calledWith('[2.17.4 migration] DOWNGRADE END: 2.17.4-use-subfolder-for-oidc-redirect-uris ')).to.be.true
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not remove authOpenIDSubfolderForRedirectURLs if it does not exist', async () => {
|
||||||
|
queryInterface.sequelize.query.onFirstCall().resolves([[{ value: JSON.stringify({}) }]])
|
||||||
|
|
||||||
|
await down({ context })
|
||||||
|
|
||||||
|
expect(logger.info.calledWith('[2.17.4 migration] DOWNGRADE BEGIN: 2.17.4-use-subfolder-for-oidc-redirect-uris ')).to.be.true
|
||||||
|
expect(logger.info.calledWith('[2.17.4 migration] authOpenIDSubfolderForRedirectURLs not found in server settings, no action required')).to.be.true
|
||||||
|
expect(queryInterface.sequelize.query.calledOnce).to.be.true
|
||||||
|
expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = "server-settings";')).to.be.true
|
||||||
|
expect(logger.info.calledWith('[2.17.4 migration] DOWNGRADE END: 2.17.4-use-subfolder-for-oidc-redirect-uris ')).to.be.true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
const Path = require('path')
|
||||||
|
const chai = require('chai')
|
||||||
|
const expect = chai.expect
|
||||||
|
const scanUtils = require('../../../server/utils/scandir')
|
||||||
|
|
||||||
|
describe('scanUtils', async () => {
|
||||||
|
it('should properly group files into potential book library items', async () => {
|
||||||
|
global.isWin = process.platform === 'win32'
|
||||||
|
global.ServerSettings = {
|
||||||
|
scannerParseSubtitle: true
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePaths = [
|
||||||
|
'randomfile.txt', // Should be ignored because it's not a book media file
|
||||||
|
'Book1.m4b', // Root single file audiobook
|
||||||
|
'Book2/audiofile.m4b',
|
||||||
|
'Book2/disk 001/audiofile.m4b',
|
||||||
|
'Book2/disk 002/audiofile.m4b',
|
||||||
|
'Author/Book3/audiofile.mp3',
|
||||||
|
'Author/Book3/Disc 1/audiofile.mp3',
|
||||||
|
'Author/Book3/Disc 2/audiofile.mp3',
|
||||||
|
'Author/Series/Book4/cover.jpg',
|
||||||
|
'Author/Series/Book4/CD1/audiofile.mp3',
|
||||||
|
'Author/Series/Book4/CD2/audiofile.mp3',
|
||||||
|
'Author/Series2/Book5/deeply/nested/cd 01/audiofile.mp3',
|
||||||
|
'Author/Series2/Book5/deeply/nested/cd 02/audiofile.mp3',
|
||||||
|
'Author/Series2/Book5/randomfile.js' // Should be ignored because it's not a book media file
|
||||||
|
]
|
||||||
|
|
||||||
|
// Create fileItems to match the format of fileUtils.recurseFiles
|
||||||
|
const fileItems = []
|
||||||
|
for (const filePath of filePaths) {
|
||||||
|
const dirname = Path.dirname(filePath)
|
||||||
|
fileItems.push({
|
||||||
|
name: Path.basename(filePath),
|
||||||
|
reldirpath: dirname === '.' ? '' : dirname,
|
||||||
|
extension: Path.extname(filePath),
|
||||||
|
deep: filePath.split('/').length - 1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const libraryItemGrouping = scanUtils.groupFileItemsIntoLibraryItemDirs('book', fileItems, false)
|
||||||
|
|
||||||
|
expect(libraryItemGrouping).to.deep.equal({
|
||||||
|
'Book1.m4b': 'Book1.m4b',
|
||||||
|
Book2: ['audiofile.m4b', 'disk 001/audiofile.m4b', 'disk 002/audiofile.m4b'],
|
||||||
|
'Author/Book3': ['audiofile.mp3', 'Disc 1/audiofile.mp3', 'Disc 2/audiofile.mp3'],
|
||||||
|
'Author/Series/Book4': ['CD1/audiofile.mp3', 'CD2/audiofile.mp3', 'cover.jpg'],
|
||||||
|
'Author/Series2/Book5/deeply/nested': ['cd 01/audiofile.mp3', 'cd 02/audiofile.mp3']
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user