mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-12-31 11:38:47 -05:00
Compare commits
88 Commits
stringify_
...
v2.19.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7d8021a16 | ||
|
|
9e83ad25b9 | ||
|
|
2eccb9465c | ||
|
|
599b6bd6ad | ||
|
|
e01ac489fb | ||
|
|
271dbc4764 | ||
|
|
84c2931434 | ||
|
|
38483c9269 | ||
|
|
b2e97d70df | ||
|
|
78aafe038d | ||
|
|
34f7ddfdd7 | ||
|
|
0e9777feec | ||
|
|
6351fd8d7b | ||
|
|
b7591abd06 | ||
|
|
2b36caf096 | ||
|
|
f87a0bfc2f | ||
|
|
b109b2edee | ||
|
|
7795bf25d0 | ||
|
|
3d5c02ae7c | ||
|
|
373d14a49e | ||
|
|
a17127f078 | ||
|
|
20f812403f | ||
|
|
a864c6bcc6 | ||
|
|
6c0e42db49 | ||
|
|
364ccd85fe | ||
|
|
d6b58c2f10 | ||
|
|
64185b7519 | ||
|
|
e1b3b657c4 | ||
|
|
4662fc5244 | ||
|
|
13c20e0cdd | ||
|
|
19a65dba98 | ||
|
|
799879d67d | ||
|
|
452d354b52 | ||
|
|
9d7f44f73a | ||
|
|
e8b60defb6 | ||
|
|
0cc2e39367 | ||
|
|
a34b01fcb4 | ||
|
|
7919a8b581 | ||
|
|
565eb423ee | ||
|
|
42b0e31b4a | ||
|
|
97a8959bf8 | ||
|
|
b5b99cbaca | ||
|
|
f04ef320aa | ||
|
|
4e33059ac8 | ||
|
|
699644322b | ||
|
|
49ba364b2a | ||
|
|
adb3967f89 | ||
|
|
cfdcac9475 | ||
|
|
b1d57bc0b3 | ||
|
|
f7cea8ca12 | ||
|
|
293440006b | ||
|
|
45f7f54b6c | ||
|
|
bb5e16157c | ||
|
|
2e8cb46c57 | ||
|
|
f9c0e52f18 | ||
|
|
6290cfaeb1 | ||
|
|
fd3d4f5fcf | ||
|
|
9f9bee2ddc | ||
|
|
568bf0254d | ||
|
|
79f4db5ff3 | ||
|
|
7038f5730f | ||
|
|
0a8186cbda | ||
|
|
659164003f | ||
|
|
de5d8650e8 | ||
|
|
bacefb5f6f | ||
|
|
0169bf5518 | ||
|
|
8f192b1b17 | ||
|
|
21343b5aa0 | ||
|
|
a5508cdc4c | ||
|
|
bd4f48ec39 | ||
|
|
cb9fc3e0d1 | ||
|
|
707533df8f | ||
|
|
2e48ec0dde | ||
|
|
f1e46a351b | ||
|
|
da8fd2d9d5 | ||
|
|
f1de307bf9 | ||
|
|
7282afcfde | ||
|
|
e2f1aeed75 | ||
|
|
23a750214f | ||
|
|
6a7418ad41 | ||
|
|
8b00c16062 | ||
|
|
8ee5646d79 | ||
|
|
373551fb74 | ||
|
|
d9b206fe1c | ||
|
|
fe4e0145c9 | ||
|
|
c4d99a118f | ||
|
|
b96226966b | ||
|
|
f460297daf |
@@ -419,7 +419,7 @@ export default {
|
||||
|
||||
this.postScrollTimeout = setTimeout(this.postScroll, 500)
|
||||
},
|
||||
async resetEntities() {
|
||||
async resetEntities(scrollPositionToRestore) {
|
||||
if (this.isFetchingEntities) {
|
||||
this.pendingReset = true
|
||||
return
|
||||
@@ -437,6 +437,12 @@ export default {
|
||||
await this.loadPage(0)
|
||||
var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf)
|
||||
this.mountEntities(0, lastBookIndex)
|
||||
|
||||
if (scrollPositionToRestore) {
|
||||
if (window.bookshelf) {
|
||||
window.bookshelf.scrollTop = scrollPositionToRestore
|
||||
}
|
||||
}
|
||||
},
|
||||
async rebuild() {
|
||||
this.initSizeData()
|
||||
@@ -444,9 +450,8 @@ export default {
|
||||
var lastBookIndex = Math.min(this.totalEntities, this.booksPerFetch)
|
||||
this.destroyEntityComponents()
|
||||
await this.loadPage(0)
|
||||
var bookshelfEl = document.getElementById('bookshelf')
|
||||
if (bookshelfEl) {
|
||||
bookshelfEl.scrollTop = 0
|
||||
if (window.bookshelf) {
|
||||
window.bookshelf.scrollTop = 0
|
||||
}
|
||||
this.mountEntities(0, lastBookIndex)
|
||||
},
|
||||
@@ -547,6 +552,15 @@ export default {
|
||||
if (this.entityName === 'items' || this.entityName === 'series-books') {
|
||||
var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id)
|
||||
if (indexOf >= 0) {
|
||||
if (this.entityName === 'items' && this.orderBy === 'media.metadata.title') {
|
||||
const curTitle = this.entities[indexOf].media.metadata?.title
|
||||
const newTitle = libraryItem.media.metadata?.title
|
||||
if (curTitle != newTitle) {
|
||||
console.log('Title changed. Re-sorting...')
|
||||
this.resetEntities(this.currScrollTop)
|
||||
return
|
||||
}
|
||||
}
|
||||
this.entities[indexOf] = libraryItem
|
||||
if (this.entityComponentRefs[indexOf]) {
|
||||
this.entityComponentRefs[indexOf].setEntity(libraryItem)
|
||||
@@ -554,6 +568,18 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
routeToBookshelfIfLastIssueRemoved() {
|
||||
if (this.totalEntities === 0) {
|
||||
const currentRouteQuery = this.$route.query
|
||||
if (currentRouteQuery?.filter && currentRouteQuery.filter === 'issues') {
|
||||
this.$nextTick(() => {
|
||||
console.log('Last issue removed. Redirecting to library bookshelf')
|
||||
this.$router.push(`/library/${this.currentLibraryId}/bookshelf`)
|
||||
this.$store.dispatch('libraries/fetch', this.currentLibraryId)
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
libraryItemRemoved(libraryItem) {
|
||||
if (this.entityName === 'items' || this.entityName === 'series-books') {
|
||||
var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id)
|
||||
@@ -564,6 +590,7 @@ export default {
|
||||
this.executeRebuild()
|
||||
}
|
||||
}
|
||||
this.routeToBookshelfIfLastIssueRemoved()
|
||||
},
|
||||
libraryItemsAdded(libraryItems) {
|
||||
console.log('items added', libraryItems)
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</div>
|
||||
<div class="text-gray-400 flex items-center w-1/2 sm:w-4/5 lg:w-2/5">
|
||||
<span class="material-symbols text-sm">person</span>
|
||||
<div v-if="podcastAuthor" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ podcastAuthor }}</div>
|
||||
<div v-if="podcastAuthor" class="pl-1 sm:pl-1.5 text-xs sm:text-base truncate">{{ podcastAuthor }}</div>
|
||||
<div v-else-if="authors.length" class="pl-1 sm:pl-1.5 text-xs sm:text-base truncate">
|
||||
<nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link>
|
||||
</div>
|
||||
@@ -85,7 +85,8 @@ export default {
|
||||
displayTitle: null,
|
||||
currentPlaybackRate: 1,
|
||||
syncFailedToast: null,
|
||||
coverAspectRatio: 1
|
||||
coverAspectRatio: 1,
|
||||
lastChapterId: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -236,12 +237,16 @@ export default {
|
||||
}
|
||||
}, 1000)
|
||||
},
|
||||
checkChapterEnd(time) {
|
||||
checkChapterEnd() {
|
||||
if (!this.currentChapter) return
|
||||
const chapterEndTime = this.currentChapter.end
|
||||
const tolerance = 0.75
|
||||
if (time >= chapterEndTime - tolerance) {
|
||||
this.sleepTimerEnd()
|
||||
|
||||
// Track chapter transitions by comparing current chapter with last chapter
|
||||
if (this.lastChapterId !== this.currentChapter.id) {
|
||||
// Chapter changed - if we had a previous chapter, this means we crossed a boundary
|
||||
if (this.lastChapterId) {
|
||||
this.sleepTimerEnd()
|
||||
}
|
||||
this.lastChapterId = this.currentChapter.id
|
||||
}
|
||||
},
|
||||
sleepTimerEnd() {
|
||||
@@ -301,7 +306,7 @@ export default {
|
||||
}
|
||||
|
||||
if (this.sleepTimerType === this.$constants.SleepTimerTypes.CHAPTER && this.sleepTimerSet) {
|
||||
this.checkChapterEnd(time)
|
||||
this.checkChapterEnd()
|
||||
}
|
||||
},
|
||||
setDuration(duration) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-2 sm:px-4 py-6 relative">
|
||||
<div class="flex flex-col sm:flex-row mb-4">
|
||||
<div class="relative self-center">
|
||||
<div class="relative self-center md:self-start">
|
||||
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId, libraryItemUpdatedAt, true)" :width="120" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
|
||||
<!-- book cover overlay -->
|
||||
@@ -36,7 +36,7 @@
|
||||
<ui-btn small @click="showLocalCovers = !showLocalCovers">{{ showLocalCovers ? $strings.ButtonHide : $strings.ButtonShow }}</ui-btn>
|
||||
</div>
|
||||
|
||||
<div v-if="showLocalCovers" class="flex items-center justify-center pb-2">
|
||||
<div v-if="showLocalCovers" class="flex items-center justify-center flex-wrap pb-2">
|
||||
<template v-for="localCoverFile in localCovers">
|
||||
<div :key="localCoverFile.ino" class="m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="localCoverFile.metadata.path === coverPath ? 'border-yellow-300' : ''" @click="setCover(localCoverFile)">
|
||||
<div class="h-24 bg-primary" :style="{ width: 96 / bookCoverAspectRatio + 'px' }">
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
<ui-tooltip v-if="tasksRunning" :text="$strings.LabelTasks" direction="bottom" class="flex items-center">
|
||||
<widgets-loading-spinner />
|
||||
</ui-tooltip>
|
||||
<ui-tooltip v-else text="Activities" direction="bottom" class="flex items-center">
|
||||
<span class="material-symbols text-1.5xl" aria-label="Activities" role="button">notifications</span>
|
||||
<ui-tooltip v-else :text="$strings.LabelActivities" direction="bottom" class="flex items-center">
|
||||
<span class="material-symbols text-1.5xl" :aria-label="$strings.LabelActivities" role="button">notifications</span>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div v-if="showUnseenSuccessIndicator" class="w-2 h-2 rounded-full bg-success pointer-events-none absolute -top-1 -right-0.5" />
|
||||
|
||||
4
client/package-lock.json
generated
4
client/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.19.2",
|
||||
"version": "2.19.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.19.2",
|
||||
"version": "2.19.5",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@nuxtjs/axios": "^5.13.6",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.19.2",
|
||||
"version": "2.19.5",
|
||||
"buildNumber": 1,
|
||||
"description": "Self-hosted audiobook and podcast client",
|
||||
"main": "index.js",
|
||||
|
||||
@@ -122,7 +122,7 @@ export default {
|
||||
},
|
||||
scheduleDescription() {
|
||||
if (!this.cronExpression) return ''
|
||||
const parsed = this.$parseCronExpression(this.cronExpression)
|
||||
const parsed = this.$parseCronExpression(this.cronExpression, this)
|
||||
return parsed ? parsed.description : `${this.$strings.LabelCustomCronExpression} ${this.cronExpression}`
|
||||
},
|
||||
nextBackupDate() {
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
<div class="flex-grow" />
|
||||
</div>
|
||||
<div v-if="newServerSettings.scannerFindCovers" class="w-44 ml-14 mb-2">
|
||||
<ui-dropdown v-model="newServerSettings.scannerCoverProvider" small :items="providers" label="Cover Provider" @input="updateScannerCoverProvider" :disabled="updatingServerSettings" />
|
||||
<ui-dropdown v-model="newServerSettings.scannerCoverProvider" small :items="providers" :label="$strings.LabelCoverProvider" @input="updateScannerCoverProvider" :disabled="updatingServerSettings" />
|
||||
</div>
|
||||
|
||||
<div role="article" :aria-label="$strings.LabelSettingsPreferMatchedMetadataHelp" class="flex items-center py-2">
|
||||
|
||||
@@ -107,6 +107,19 @@ Vue.prototype.$formatNumber = (num) => {
|
||||
return Intl.NumberFormat(Vue.prototype.$languageCodes.current).format(num)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the days of the week for the current language
|
||||
* Starts with Sunday
|
||||
* @returns {string[]}
|
||||
*/
|
||||
Vue.prototype.$getDaysOfWeek = () => {
|
||||
const days = []
|
||||
for (let i = 0; i < 7; i++) {
|
||||
days.push(new Date(2025, 0, 5 + i).toLocaleString(Vue.prototype.$languageCodes.current, { weekday: 'long' }))
|
||||
}
|
||||
return days
|
||||
}
|
||||
|
||||
const translations = {
|
||||
[defaultCode]: enUsStrings
|
||||
}
|
||||
@@ -148,6 +161,7 @@ async function loadi18n(code) {
|
||||
Vue.prototype.$setDateFnsLocale(languageCodeMap[code].dateFnsLocale)
|
||||
|
||||
this?.$eventBus?.$emit('change-lang', code)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ Vue.prototype.$elapsedPrettyExtended = (seconds, useDays = true, showSeconds = t
|
||||
return strs.join(' ')
|
||||
}
|
||||
|
||||
Vue.prototype.$parseCronExpression = (expression) => {
|
||||
Vue.prototype.$parseCronExpression = (expression, context) => {
|
||||
if (!expression) return null
|
||||
const pieces = expression.split(' ')
|
||||
if (pieces.length !== 5) {
|
||||
@@ -102,31 +102,31 @@ Vue.prototype.$parseCronExpression = (expression) => {
|
||||
|
||||
const commonPatterns = [
|
||||
{
|
||||
text: 'Every 12 hours',
|
||||
text: context.$strings.LabelIntervalEvery12Hours,
|
||||
value: '0 */12 * * *'
|
||||
},
|
||||
{
|
||||
text: 'Every 6 hours',
|
||||
text: context.$strings.LabelIntervalEvery6Hours,
|
||||
value: '0 */6 * * *'
|
||||
},
|
||||
{
|
||||
text: 'Every 2 hours',
|
||||
text: context.$strings.LabelIntervalEvery2Hours,
|
||||
value: '0 */2 * * *'
|
||||
},
|
||||
{
|
||||
text: 'Every hour',
|
||||
text: context.$strings.LabelIntervalEveryHour,
|
||||
value: '0 * * * *'
|
||||
},
|
||||
{
|
||||
text: 'Every 30 minutes',
|
||||
text: context.$strings.LabelIntervalEvery30Minutes,
|
||||
value: '*/30 * * * *'
|
||||
},
|
||||
{
|
||||
text: 'Every 15 minutes',
|
||||
text: context.$strings.LabelIntervalEvery15Minutes,
|
||||
value: '*/15 * * * *'
|
||||
},
|
||||
{
|
||||
text: 'Every minute',
|
||||
text: context.$strings.LabelIntervalEveryMinute,
|
||||
value: '* * * * *'
|
||||
}
|
||||
]
|
||||
@@ -147,7 +147,7 @@ Vue.prototype.$parseCronExpression = (expression) => {
|
||||
return null
|
||||
}
|
||||
|
||||
const weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
|
||||
const weekdays = context.$getDaysOfWeek()
|
||||
var weekdayText = 'day'
|
||||
if (pieces[4] !== '*')
|
||||
weekdayText = pieces[4]
|
||||
@@ -156,7 +156,7 @@ Vue.prototype.$parseCronExpression = (expression) => {
|
||||
.join(', ')
|
||||
|
||||
return {
|
||||
description: `Run every ${weekdayText} at ${pieces[1]}:${pieces[0].padStart(2, '0')}`
|
||||
description: context.$getString('MessageScheduleRunEveryWeekdayAtTime', [weekdayText, `${pieces[1]}:${pieces[0].padStart(2, '0')}`])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
"ButtonLatest": "Апошняе",
|
||||
"ButtonLibrary": "Бібліятэка",
|
||||
"ButtonLogout": "Выйсці",
|
||||
"ButtonLookup": "",
|
||||
"ButtonLookup": "Пошук",
|
||||
"ButtonManageTracks": "Кіраванне дарожкамі",
|
||||
"ButtonMapChapterTitles": "Супаставіць назвы раздзелаў",
|
||||
"ButtonMatchAllAuthors": "Супадзенне ўсіх аўтараў",
|
||||
@@ -159,6 +159,15 @@
|
||||
"HeaderNotificationUpdate": "Абнавіць апавяшчэнне",
|
||||
"HeaderNotifications": "Апавяшчэнні",
|
||||
"HeaderOpenListeningSessions": "Адкрыць сеансы праслухоўвання",
|
||||
"HeaderOpenRSSFeed": "Адкрыць RSS-стужку",
|
||||
"HeaderPlaylist": "Плэйліст",
|
||||
"HeaderPlaylistItems": "Элементы плэйліста",
|
||||
"HeaderRSSFeedGeneral": "Падрабязнасці RSS",
|
||||
"HeaderRSSFeedIsOpen": "RSS-стужка адкрыта",
|
||||
"HeaderRSSFeeds": "RSS-стужкі",
|
||||
"HeaderRemoveEpisode": "Выдаліць эпізод",
|
||||
"HeaderRemoveEpisodes": "Выдаліць {0} эпізодаў",
|
||||
"HeaderSavedMediaProgress": "Захаваны прагрэс медыя",
|
||||
"HeaderScheduleEpisodeDownloads": "Расклад аўтаматычных спамповак эпізодаў",
|
||||
"HeaderSettings": "Налады",
|
||||
"HeaderSettingsDisplay": "Дысплей",
|
||||
@@ -166,50 +175,167 @@
|
||||
"HeaderSettingsGeneral": "Агульныя",
|
||||
"HeaderSettingsScanner": "Сканер",
|
||||
"HeaderSettingsWebClient": "Вэб-кліент",
|
||||
"HeaderSleepTimer": "Таймер сну",
|
||||
"HeaderStatsMinutesListeningChart": "Хвіліны праслухоўвання (апошнія 7 дзён)",
|
||||
"HeaderStatsTop10Authors": "10 лепшых аўтараў",
|
||||
"HeaderStatsTop5Genres": "5 лепшых жанраў",
|
||||
"HeaderTableOfContents": "Змест",
|
||||
"HeaderTools": "Інструменты",
|
||||
"HeaderUpdateAccount": "Абнавіць уліковы запіс",
|
||||
"HeaderYourStats": "Ваша статыстыка",
|
||||
"LabelAccountType": "Тып уліковага запіса",
|
||||
"LabelAccountTypeAdmin": "Адміністратар",
|
||||
"LabelAccountTypeGuest": "Госць",
|
||||
"LabelAccountTypeUser": "Карыстальнік",
|
||||
"LabelAddToPlaylist": "Дадаць у плэйліст",
|
||||
"LabelAddedDate": "Дададзена {0}",
|
||||
"LabelAll": "Усе",
|
||||
"LabelAudioBitrate": "Бітрэйт аўдыё (напрыклад, 128к)",
|
||||
"LabelAudioChannels": "Аўдыёканалы (1 або 2)",
|
||||
"LabelAudioCodec": "Аўдыёкодэк",
|
||||
"LabelAuthor": "Аўтар",
|
||||
"LabelAuthorFirstLast": "Аўтар (Імя Прозвішча)",
|
||||
"LabelAuthorLastFirst": "Аўтар (Прозвішча, Імя)",
|
||||
"LabelAuthors": "Аўтары",
|
||||
"LabelAutoDownloadEpisodes": "Аўтаматычнае спампаванне эпізодаў",
|
||||
"LabelBackupAudioFiles": "Рэзервовае капіраванне аўдыёфайлаў",
|
||||
"LabelBooks": "Кнігі",
|
||||
"LabelChapters": "Раздзелы",
|
||||
"LabelClosePlayer": "Зачыніць прайгравальнік",
|
||||
"LabelCollapseSeries": "Згарнуць серыі",
|
||||
"LabelComplete": "Завершана",
|
||||
"LabelContinueListening": "Працягваць слухаць",
|
||||
"LabelContinueReading": "Працягнуць чытанне",
|
||||
"LabelContinueSeries": "Працягнуць серыі",
|
||||
"LabelDescription": "Апісанне",
|
||||
"LabelDiscover": "Знайсці",
|
||||
"LabelDownload": "Спампаваць",
|
||||
"LabelDownloadNEpisodes": "Спампована {0} эпізодаў",
|
||||
"LabelDownloadable": "Спампоўваецца",
|
||||
"LabelDuration": "Працягласць",
|
||||
"LabelEbook": "Электронная кніга",
|
||||
"LabelEbooks": "Электронныя кнігі",
|
||||
"LabelEnable": "Уключыць",
|
||||
"LabelEncodingBackupLocation": "Рэзервовая копія вашых арыгінальных аўдыёфайлаў будзе захавана ў:",
|
||||
"LabelEncodingChaptersNotEmbedded": "Раздзелы не ўбудаваны ў шматдарожкавыя аўдыякнігі.",
|
||||
"LabelEncodingFinishedM4B": "Гатовы файл M4B будзе змешчаны ў вашу тэчку з аўдыякнігамі па адрасе:",
|
||||
"LabelEncodingInfoEmbedded": "Метаданыя будуць убудаваны ў аўдыядарожкі ўнутры вашай тэчкі з аўдыякнігамі.",
|
||||
"LabelEnd": "Канец",
|
||||
"LabelEndOfChapter": "Канец раздзела",
|
||||
"LabelEpisode": "Эпізод",
|
||||
"LabelEpisodeNotLinkedToRssFeed": "Эпізод не звязаны з RSS-стужкай",
|
||||
"LabelEpisodeUrlFromRssFeed": "URL эпізоду з RSS-стужкі",
|
||||
"LabelFeedURL": "URL стужкі",
|
||||
"LabelFile": "Файл",
|
||||
"LabelFileBirthtime": "Час стварэння файла",
|
||||
"LabelFileModified": "Час змянення файла",
|
||||
"LabelFilename": "Імя файла",
|
||||
"LabelFinished": "Скончана",
|
||||
"LabelFolder": "Тэчка",
|
||||
"LabelFontBoldness": "Таўшчыня шрыфта",
|
||||
"LabelFontScale": "Памер шрыфту",
|
||||
"LabelGenre": "Жанр",
|
||||
"LabelGenres": "Жанры",
|
||||
"LabelHasEbook": "Мае электронную кнігу",
|
||||
"LabelHasSupplementaryEbook": "Мае дадатковую электронную кнігу",
|
||||
"LabelHost": "Хост",
|
||||
"LabelInProgress": "У працэсе",
|
||||
"LabelIncomplete": "Незавершана",
|
||||
"LabelLanguage": "Мова",
|
||||
"LabelLayoutSinglePage": "Аднабаковы",
|
||||
"LabelLineSpacing": "Міжрадковы інтэрвал",
|
||||
"LabelListenAgain": "Паслухаць зноў",
|
||||
"LabelMaxEpisodesToDownload": "Максімальная колькасць эпізодаў для спампоўкі. Выкарыстоўвайце 0 для неабмежаванай колькасці.",
|
||||
"LabelMaxEpisodesToDownloadPerCheck": "Максімальная колькасць новых эпізодаў для спампоўкі за праверку",
|
||||
"LabelMaxEpisodesToKeepHelp": "Значэнне 0 не ўстанаўлівае максімальнага абмежавання. Пасля аўтаматычнай спампоўкі новага эпізоду будзе выдалены самы стары эпізод, калі ў вас больш за X эпізодаў. Пры кожнай новай спампоўцы будзе выдаляцца толькі 1 эпізод.",
|
||||
"LabelMediaPlayer": "Медыяплэер",
|
||||
"LabelMediaType": "Тып медыя",
|
||||
"LabelMissing": "Адсутнічае",
|
||||
"LabelMore": "Больш",
|
||||
"LabelMoreInfo": "Больш інфармацыі",
|
||||
"LabelName": "Імя",
|
||||
"LabelNarrator": "Чытальнік",
|
||||
"LabelNarrators": "Чытальнікі",
|
||||
"LabelOpenRSSFeed": "Адкрыць RSS-стужку",
|
||||
"LabelPermissionsDownload": "Можна спампаваць",
|
||||
"LabelPreventIndexing": "Прадухіліць індэксацыю вашай стужкі каталогамі падкастаў iTunes і Google",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Карыстальніцкая электронная пошта ўладальніка",
|
||||
"LabelRSSFeedCustomOwnerName": "Карыстальніцкае імя ўладальніка",
|
||||
"LabelRSSFeedOpen": "RSS-стужка адкрытая",
|
||||
"LabelRSSFeedPreventIndexing": "Прадухіліць індэксацыю",
|
||||
"LabelRSSFeedURL": "URL RSS-стужкі",
|
||||
"LabelReAddSeriesToContinueListening": "Дадаць серыю зноў у Працягваць слухаць",
|
||||
"LabelRecentSeries": "Апошнія серыі",
|
||||
"LabelSeries": "Серыі",
|
||||
"LabelSetEbookAsPrimary": "Зрабіць асноўным",
|
||||
"LabelSetEbookAsSupplementary": "Зрабіць дадатковым",
|
||||
"LabelSettingsExperimentalFeaturesHelp": "Функцыі ў распрацоўцы, для якіх вашы водгукі і дапамога ў тэставанні будуць карыснымі. Націсніце, каб адкрыць абмеркаванне на GitHub.",
|
||||
"LabelSettingsLibraryMarkAsFinishedWhen": "Пазначыць элемент медыя як скончаны, калі",
|
||||
"LabelShareDownloadableHelp": "Дазваляе карыстальнікам, якія маюць спасылку на доступ, спампаваць ZIP-файл элемента бібліятэкі.",
|
||||
"LabelShowAll": "Паказаць усё",
|
||||
"LabelSize": "Памер",
|
||||
"LabelStatsAudioTracks": "Аўдыядарожкі",
|
||||
"LabelTracks": "Дарожкі",
|
||||
"MessageBookshelfNoRSSFeeds": "Няма адкрытых RSS-стужак",
|
||||
"MessageConfirmCloseFeed": "Вы ўпэўнены, што жадаеце закрыць гэтую стужку?",
|
||||
"MessageConfirmRemoveListeningSessions": "Вы ўпэўнены, што жадаеце выдаліць {0} сеансаў праслухоўвання?",
|
||||
"MessageDownloadingEpisode": "Спампоўка эпізоду",
|
||||
"MessageEpisodesQueuedForDownload": "{0} эпізод(аў) у чарзе для спампоўкі",
|
||||
"MessageFeedURLWillBe": "URL стужкі будзе {0}",
|
||||
"MessageNoChapters": "Няма раздзелаў",
|
||||
"MessageNoDownloadsInProgress": "Зараз няма актыўных спамповак",
|
||||
"MessageNoDownloadsQueued": "Няма спамповак у чарзе",
|
||||
"MessageNoListeningSessions": "Няма сеансаў праслухоўвання",
|
||||
"MessageNoMediaProgress": "Няма прагрэсу медыя",
|
||||
"MessageNoPodcastFeed": "Няправільны падкаст: Няма стужкі",
|
||||
"MessageOpmlPreviewNote": "Заўвага: гэта папярэдні прагляд разабранага OPML-файла. Фактычная назва падкаста будзе ўзятая з RSS-стужкі.",
|
||||
"MessagePodcastHasNoRSSFeedForMatching": "У падкаста няма URL RSS-стужкі для супадзення",
|
||||
"MessagePodcastSearchField": "Увядзіце пошукавы запыт або URL RSS-стужкі",
|
||||
"MessageTaskDownloadingEpisodeDescription": "Спампоўка эпізоду \"{0}\"",
|
||||
"MessageTaskOpmlImportDescription": "Стварэнне падкастаў з {0} RSS-стужак",
|
||||
"MessageTaskOpmlImportFeed": "Імпарт стужкі з OPML",
|
||||
"MessageTaskOpmlImportFeedDescription": "Імпартаванне RSS-стужкі \"{0}\"",
|
||||
"MessageTaskOpmlImportFeedFailed": "Не ўдалося атрымаць стужку падкаста",
|
||||
"MessageTaskOpmlImportFeedPodcastDescription": "Стварэнне падкаста \"{0}\"",
|
||||
"MessageTaskOpmlImportFeedPodcastExists": "Падкаст ужо існуе па гэтым шляху",
|
||||
"MessageTaskOpmlImportFeedPodcastFailed": "Не ўдалося стварыць падкаст",
|
||||
"MessageTaskOpmlParseNoneFound": "У OPML-файле не знойдзена стужак",
|
||||
"NoteRSSFeedPodcastAppsHttps": "Папярэджанне: большасць праграм для падкастаў патрабуюць, каб URL RSS-стужкі выкарыстоўваў HTTPS",
|
||||
"NoteRSSFeedPodcastAppsPubDate": "Папярэджанне: адзін ці больш вашых эпізодаў не маюць даты публікацыі. Некаторыя праграмы для падкастаў патрабуюць гэтага.",
|
||||
"NoteUploaderFoldersWithMediaFiles": "Тэчкі з медыяфайламі будуць апрацоўвацца як асобныя элементы бібліятэкі.",
|
||||
"NotificationOnEpisodeDownloadedDescription": "Выклікаецца, калі эпізод падкаста аўтаматычна спампоўваецца",
|
||||
"ToastAccountUpdateSuccess": "Уліковы запіс абноўлены",
|
||||
"ToastEpisodeDownloadQueueClearFailed": "Не ўдалося ачысціць чаргу",
|
||||
"ToastEpisodeDownloadQueueClearSuccess": "Чарга спампоўкі эпізодаў ачышчана",
|
||||
"ToastInvalidMaxEpisodesToDownload": "Няправільная максімальная колькасць эпізодаў для спампоўкі",
|
||||
"ToastItemMarkedAsFinishedFailed": "Не ўдалося пазначыць як Скончана",
|
||||
"ToastItemMarkedAsFinishedSuccess": "Элемент пазначаны як Завершаны",
|
||||
"ToastItemMarkedAsNotFinishedFailed": "Не ўдалося пазначыць як Незавершанае",
|
||||
"ToastItemMarkedAsNotFinishedSuccess": "Элемент пазначаны як Незавершаны",
|
||||
"ToastItemUpdateSuccess": "Элемент абноўлены",
|
||||
"ToastLibraryCreateFailed": "Не ўдалося стварыць бібліятэку",
|
||||
"ToastLibraryCreateSuccess": "Бібліятэка \"{0}\" створана",
|
||||
"ToastLibraryDeleteFailed": "Не ўдалося выдаліць бібліятэку",
|
||||
"ToastLibraryDeleteSuccess": "Бібліятэка выдалена",
|
||||
"ToastLibraryScanFailedToStart": "Не ўдалося запусціць сканаванне",
|
||||
"ToastLibraryScanStarted": "Сканаванне бібліятэкі запушчана",
|
||||
"ToastLibraryUpdateSuccess": "Бібліятэка \"{0}\" абноўлена",
|
||||
"ToastMatchAllAuthorsFailed": "Не ўдалося знайсці адпаведнасць для ўсіх аўтараў",
|
||||
"ToastMetadataFilesRemovedError": "Памылка пры выдаленні metadata.{0} файлаў",
|
||||
"ToastMetadataFilesRemovedNoneFound": "У бібліятэцы не знойдзены metadata.{0} файлаў",
|
||||
"ToastMetadataFilesRemovedNoneRemoved": "Не выдалена metadata.{0} файлаў",
|
||||
"ToastMetadataFilesRemovedSuccess": "{0} metadata.{1} файлаў выдалена",
|
||||
"ToastMustHaveAtLeastOnePath": "Павінен быць хаця б адзін шлях",
|
||||
"ToastNameEmailRequired": "Імя і электронная пошта абавязковыя",
|
||||
"ToastNameRequired": "Імя абавязковае",
|
||||
"ToastNewUserCreatedFailed": "Не ўдалося стварыць уліковы запіс: \"{0}\"",
|
||||
"ToastNewUserCreatedSuccess": "Новы ўліковы запіс створаны",
|
||||
"ToastNoRSSFeed": "У падкаста няма RSS-стужкі",
|
||||
"ToastPodcastGetFeedFailed": "Не ўдалося атрымаць стужку падкаста",
|
||||
"ToastPodcastNoEpisodesInFeed": "У RSS-стужцы не знойдзена эпізодаў",
|
||||
"ToastPodcastNoRssFeed": "У падкаста няма RSS-стужкі",
|
||||
"ToastRSSFeedCloseFailed": "Не ўдалося закрыць RSS-стужку",
|
||||
"ToastRSSFeedCloseSuccess": "RSS-стужка закрыта",
|
||||
"ToastUserPasswordMustChange": "Новы пароль не можа супадаць са старым",
|
||||
"ToastUserRootRequireName": "Неабходна ўвесці імя карыстальніка адміністратара"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"ButtonAdd": "Добави",
|
||||
"ButtonAdd": "Създай",
|
||||
"ButtonAddChapters": "Добави Глави",
|
||||
"ButtonAddDevice": "Добави Устройство",
|
||||
"ButtonAddLibrary": "Добави Библиотека",
|
||||
@@ -10,15 +10,18 @@
|
||||
"ButtonApplyChapters": "Приложи Глави",
|
||||
"ButtonAuthors": "Автори",
|
||||
"ButtonBack": "Назад",
|
||||
"ButtonBatchEditPopulateFromExisting": "Попълни от съществуващи",
|
||||
"ButtonBatchEditPopulateMapDetails": "Попълни подробности за картата",
|
||||
"ButtonBrowseForFolder": "Прегледай за папка",
|
||||
"ButtonCancel": "Откажи",
|
||||
"ButtonCancel": "Отказ",
|
||||
"ButtonCancelEncode": "Откажи закодирането",
|
||||
"ButtonChangeRootPassword": "Промени паролата за Root",
|
||||
"ButtonCheckAndDownloadNewEpisodes": "Провери и Свали Нови Епизоди",
|
||||
"ButtonChooseAFolder": "Избери Папка",
|
||||
"ButtonChooseFiles": "Избери Файлове",
|
||||
"ButtonClearFilter": "Изчисти Филтър",
|
||||
"ButtonCloseFeed": "Затвори Feed",
|
||||
"ButtonClearFilter": "Изчисти филтър",
|
||||
"ButtonCloseFeed": "Затвори стената",
|
||||
"ButtonCloseSession": "Затвори отворената сесия",
|
||||
"ButtonCollections": "Колекции",
|
||||
"ButtonConfigureScanner": "Конфигурирай Скенера",
|
||||
"ButtonCreate": "Създай",
|
||||
@@ -28,6 +31,9 @@
|
||||
"ButtonEdit": "Редактирай",
|
||||
"ButtonEditChapters": "Редактирай Глави",
|
||||
"ButtonEditPodcast": "Редактирай Подкаст",
|
||||
"ButtonEnable": "Активирай",
|
||||
"ButtonFireAndFail": "Задействай и неуспей",
|
||||
"ButtonFireOnTest": "Задействай събитие onTest",
|
||||
"ButtonForceReScan": "Принудително Пресканиране",
|
||||
"ButtonFullPath": "Пълен Път",
|
||||
"ButtonHide": "Скрий",
|
||||
@@ -44,24 +50,31 @@
|
||||
"ButtonMatchAllAuthors": "Съвпадение на Всички Автори",
|
||||
"ButtonMatchBooks": "Съвпадение на Книги",
|
||||
"ButtonNevermind": "Няма значение",
|
||||
"ButtonNext": "Следващо",
|
||||
"ButtonNextChapter": "Следваща Глава",
|
||||
"ButtonOk": "Добре",
|
||||
"ButtonOpenFeed": "Отвори Feed",
|
||||
"ButtonNextItemInQueue": "Следващият елемент в опашката",
|
||||
"ButtonOk": "Приемам",
|
||||
"ButtonOpenFeed": "Отвори стената",
|
||||
"ButtonOpenManager": "Отвори Мениджър",
|
||||
"ButtonPause": "Пауза",
|
||||
"ButtonPause": "Паузирай",
|
||||
"ButtonPlay": "Пусни",
|
||||
"ButtonPlayAll": "Пусни всички",
|
||||
"ButtonPlaying": "Пуска се",
|
||||
"ButtonPlaylists": "Плейлисти",
|
||||
"ButtonPrevious": "Предишен",
|
||||
"ButtonPreviousChapter": "Предишна Глава",
|
||||
"ButtonProbeAudioFile": "Провери аудио файла",
|
||||
"ButtonPurgeAllCache": "Изчисти Всички Кешове",
|
||||
"ButtonPurgeItemsCache": "Изчисти Кеша на Елементи",
|
||||
"ButtonQueueAddItem": "Добави към опашката",
|
||||
"ButtonQueueRemoveItem": "Премахни от опашката",
|
||||
"ButtonQuickEmbed": "Бързо вграждане",
|
||||
"ButtonQuickEmbedMetadata": "Бързо вграждане метадата",
|
||||
"ButtonQuickMatch": "Бързо Съпоставяне",
|
||||
"ButtonReScan": "Пресканирай",
|
||||
"ButtonRead": "Прочети",
|
||||
"ButtonReadLess": "Покажи по-малко",
|
||||
"ButtonReadMore": "Покажи повече",
|
||||
"ButtonReadLess": "Изчети по-малко",
|
||||
"ButtonReadMore": "Прочети дълго",
|
||||
"ButtonRefresh": "Обнови",
|
||||
"ButtonRemove": "Премахни",
|
||||
"ButtonRemoveAll": "Премахни Всички",
|
||||
@@ -77,7 +90,9 @@
|
||||
"ButtonSaveTracklist": "Запази Списък с Канали",
|
||||
"ButtonScan": "Сканирай",
|
||||
"ButtonScanLibrary": "Сканирай Библиотека",
|
||||
"ButtonSearch": "Търси",
|
||||
"ButtonScrollLeft": "Скролни наляво",
|
||||
"ButtonScrollRight": "Скролни надясно",
|
||||
"ButtonSearch": "Търси в",
|
||||
"ButtonSelectFolderPath": "Избери Път на Папка",
|
||||
"ButtonSeries": "Серии",
|
||||
"ButtonSetChaptersFromTracks": "Задай Глави от Песни",
|
||||
@@ -86,8 +101,10 @@
|
||||
"ButtonShow": "Покажи",
|
||||
"ButtonStartM4BEncode": "Започни M4B Кодиране",
|
||||
"ButtonStartMetadataEmbed": "Започни Вграждане на Метаданни",
|
||||
"ButtonStats": "Статистики",
|
||||
"ButtonSubmit": "Изпрати",
|
||||
"ButtonTest": "Тест",
|
||||
"ButtonUnlinkOpenId": "Премахни връзката с OpenID",
|
||||
"ButtonUpload": "Качи",
|
||||
"ButtonUploadBackup": "Качи Backup",
|
||||
"ButtonUploadCover": "Качи Корица",
|
||||
@@ -100,9 +117,10 @@
|
||||
"ErrorUploadFetchMetadataNoResults": "Метаданните не могат да бъдат взети - опитайте да обновите заглавието и/или автора",
|
||||
"ErrorUploadLacksTitle": "Трябва да има Заглавие",
|
||||
"HeaderAccount": "Профил",
|
||||
"HeaderAdvanced": "Разширени",
|
||||
"HeaderAddCustomMetadataProvider": "Добави персонализиран доставчик на метаданни",
|
||||
"HeaderAdvanced": "Разширени настройки",
|
||||
"HeaderAppriseNotificationSettings": "Apprise Notification Опции",
|
||||
"HeaderAudioTracks": "Звуков Канал",
|
||||
"HeaderAudioTracks": "Песни",
|
||||
"HeaderAudiobookTools": "Инструмент за Менижиране на Аудиокниги",
|
||||
"HeaderAuthentication": "Аутентикация",
|
||||
"HeaderBackups": "Архив",
|
||||
@@ -110,26 +128,26 @@
|
||||
"HeaderChapters": "Глави",
|
||||
"HeaderChooseAFolder": "Избети Папка",
|
||||
"HeaderCollection": "Колекция",
|
||||
"HeaderCollectionItems": "Елементи на Колекция",
|
||||
"HeaderCollectionItems": "Елемент в колекция",
|
||||
"HeaderCover": "Корица",
|
||||
"HeaderCurrentDownloads": "Текущи Сваляния",
|
||||
"HeaderCustomMessageOnLogin": "Потребителско съобщение при влизане",
|
||||
"HeaderCustomMetadataProviders": "Потребителски Доставчици на Метаданни",
|
||||
"HeaderDetails": "Детайли",
|
||||
"HeaderDownloadQueue": "Опашка за Сваляне",
|
||||
"HeaderEbookFiles": "Файлове на Електронни книги",
|
||||
"HeaderEbookFiles": "Е-книги файлове",
|
||||
"HeaderEmail": "Емейл",
|
||||
"HeaderEmailSettings": "Настройки Емайл",
|
||||
"HeaderEpisodes": "Епизоди",
|
||||
"HeaderEreaderDevices": "Елктронни Четци",
|
||||
"HeaderEreaderSettings": "Настройки на Електронни Четци",
|
||||
"HeaderEreaderSettings": "Настройки на Е-четецът",
|
||||
"HeaderFiles": "Файлове",
|
||||
"HeaderFindChapters": "Намери Глави",
|
||||
"HeaderIgnoredFiles": "Игнорирани Файлове",
|
||||
"HeaderItemFiles": "Файлове на Елемент",
|
||||
"HeaderItemMetadataUtils": "Инструменти за Метаданни на Елемент",
|
||||
"HeaderLastListeningSession": "Последна Сесия на Слушане",
|
||||
"HeaderLatestEpisodes": "Последни Епизоди",
|
||||
"HeaderLatestEpisodes": "Последни епизоди",
|
||||
"HeaderLibraries": "Библиотеки",
|
||||
"HeaderLibraryFiles": "Файлове на Библиотека",
|
||||
"HeaderLibraryStats": "Статистика на Библиотека",
|
||||
@@ -145,24 +163,29 @@
|
||||
"HeaderMetadataToEmbed": "Метаданни за Вграждане",
|
||||
"HeaderNewAccount": "Нов Профил",
|
||||
"HeaderNewLibrary": "Нова Библиотека",
|
||||
"HeaderNotificationCreate": "Създай нотификация",
|
||||
"HeaderNotificationUpdate": "Обнови нотификация",
|
||||
"HeaderNotifications": "Известия",
|
||||
"HeaderOpenIDConnectAuthentication": "OpenID Connect Аутентикация",
|
||||
"HeaderOpenRSSFeed": "Отвори RSS Feed",
|
||||
"HeaderOpenListeningSessions": "Отвори сесия",
|
||||
"HeaderOpenRSSFeed": "Отвори RSS емисията",
|
||||
"HeaderOtherFiles": "Други Файлове",
|
||||
"HeaderPasswordAuthentication": "Паролна Аутентикация",
|
||||
"HeaderPermissions": "Права",
|
||||
"HeaderPlayerQueue": "Опашка на Плейъра",
|
||||
"HeaderPlayerSettings": "Настройки на плейъра",
|
||||
"HeaderPlaylist": "Плейлист",
|
||||
"HeaderPlaylistItems": "Елементи на Плейлист",
|
||||
"HeaderPlaylistItems": "Елементи от плейлист",
|
||||
"HeaderPodcastsToAdd": "Подкасти за Добавяне",
|
||||
"HeaderPreviewCover": "Преглед на Корица",
|
||||
"HeaderRSSFeedGeneral": "RSS Детайли",
|
||||
"HeaderRSSFeedIsOpen": "RSS Feed е Отворен",
|
||||
"HeaderRSSFeedGeneral": "RSS подробности",
|
||||
"HeaderRSSFeedIsOpen": "RSS емисията е отворена",
|
||||
"HeaderRSSFeeds": "RSS Feed-ове",
|
||||
"HeaderRemoveEpisode": "Премахни Епизод",
|
||||
"HeaderRemoveEpisodes": "Премахни {0} Епизоди",
|
||||
"HeaderSavedMediaProgress": "Запазен Прогрес на Медията",
|
||||
"HeaderSchedule": "График",
|
||||
"HeaderScheduleEpisodeDownloads": "Планирай автоматично изтегляне на епизоди",
|
||||
"HeaderScheduleLibraryScans": "График за Автоматично Сканиране на Библиотека",
|
||||
"HeaderSession": "Сесия",
|
||||
"HeaderSetBackupSchedule": "Задай График за Backup",
|
||||
@@ -171,11 +194,12 @@
|
||||
"HeaderSettingsExperimental": "Експериментални Функции",
|
||||
"HeaderSettingsGeneral": "Общи",
|
||||
"HeaderSettingsScanner": "Скенер",
|
||||
"HeaderSleepTimer": "Таймер за Сън",
|
||||
"HeaderSettingsWebClient": "Уеб клиент",
|
||||
"HeaderSleepTimer": "Таймер за заспиване",
|
||||
"HeaderStatsLargestItems": "Най-Големите Елементи",
|
||||
"HeaderStatsLongestItems": "Най-Дългите Елементи (часове)",
|
||||
"HeaderStatsMinutesListeningChart": "Минути на Слушане (последни 7 дни)",
|
||||
"HeaderStatsRecentSessions": "Скорошни Сесии",
|
||||
"HeaderStatsMinutesListeningChart": "Изслушани минути (последните 7 дни)",
|
||||
"HeaderStatsRecentSessions": "Последни сесии",
|
||||
"HeaderStatsTop10Authors": "Топ 10 Автори",
|
||||
"HeaderStatsTop5Genres": "Топ 5 Жанрове",
|
||||
"HeaderTableOfContents": "Съдържание",
|
||||
@@ -186,7 +210,7 @@
|
||||
"HeaderUpdateLibrary": "Обнови Библиотека",
|
||||
"HeaderUsers": "Потребители",
|
||||
"HeaderYearReview": "Преглед на {0} Година",
|
||||
"HeaderYourStats": "Твоята Статистика",
|
||||
"HeaderYourStats": "Вашата статистика",
|
||||
"LabelAbridged": "Съкратен",
|
||||
"LabelAbridgedChecked": "Съкратена (отбелязано)",
|
||||
"LabelAbridgedUnchecked": "Несъкратена (не отбелязано)",
|
||||
@@ -198,21 +222,26 @@
|
||||
"LabelActivity": "Дейност",
|
||||
"LabelAddToCollection": "Добави в Колекция",
|
||||
"LabelAddToCollectionBatch": "Добави {0} Книги в Колекция",
|
||||
"LabelAddToPlaylist": "Добави в Плейлист",
|
||||
"LabelAddToPlaylist": "Добави в плейлист",
|
||||
"LabelAddToPlaylistBatch": "Добави {0} Елемент в Плейлист",
|
||||
"LabelAddedAt": "Добавени На",
|
||||
"LabelAddedAt": "Добавено в",
|
||||
"LabelAddedDate": "Добавено",
|
||||
"LabelAdminUsersOnly": "Само за Администратори",
|
||||
"LabelAll": "Всички",
|
||||
"LabelAll": "Всичко",
|
||||
"LabelAllUsers": "Всички Потребители",
|
||||
"LabelAllUsersExcludingGuests": "Всички потребители без гости",
|
||||
"LabelAllUsersIncludingGuests": "Всички потребители включително гости",
|
||||
"LabelAlreadyInYourLibrary": "Вече е в твоята библиотека",
|
||||
"LabelApiToken": "АПИ Токен",
|
||||
"LabelAppend": "Добави",
|
||||
"LabelAudioBitrate": "Аудио битрейт (напр. 128k)",
|
||||
"LabelAudioChannels": "Аудио канали (1 или 2)",
|
||||
"LabelAudioCodec": "Аудио кодек",
|
||||
"LabelAuthor": "Автор",
|
||||
"LabelAuthorFirstLast": "Автор (Първо Име, Фамилия)",
|
||||
"LabelAuthorLastFirst": "Автор (Фамилия, Първо Име)",
|
||||
"LabelAuthorFirstLast": "Автор (Първи, Последен)",
|
||||
"LabelAuthorLastFirst": "Автор (Последен, Първи)",
|
||||
"LabelAuthors": "Автори",
|
||||
"LabelAutoDownloadEpisodes": "Автоматично Сваляне на Епизоди",
|
||||
"LabelAutoDownloadEpisodes": "Автоматично изтегляне на епизоди",
|
||||
"LabelAutoFetchMetadata": "Автоматично Взимане на Метаданни",
|
||||
"LabelAutoFetchMetadataHelp": "Взима метаданни за заглвие, автор и серии за да опрости качването. Допълнителни метаданни може да трябва да бъде взера след качване.",
|
||||
"LabelAutoLaunch": "Автоматично Стартиране",
|
||||
@@ -220,6 +249,7 @@
|
||||
"LabelAutoRegister": "Автоматична Регистрация",
|
||||
"LabelAutoRegisterDescription": "Автоматично създаване на нови потребители след вход",
|
||||
"LabelBackToUser": "Обратно към Потребител",
|
||||
"LabelBackupAudioFiles": "Създай резервно копие на аудио файлове",
|
||||
"LabelBackupLocation": "Местоположение на Архив",
|
||||
"LabelBackupsEnableAutomaticBackups": "Включи автоматично архивиране",
|
||||
"LabelBackupsEnableAutomaticBackupsHelp": "Архиви запазени в /metadata/backups",
|
||||
@@ -228,31 +258,38 @@
|
||||
"LabelBackupsNumberToKeep": "Брой архиви за запазване",
|
||||
"LabelBackupsNumberToKeepHelp": "Само 1 архив ще бъде премахнат на веднъж, така че ако вече имате повече архиви от това трябва да ги премахнете ръчно.",
|
||||
"LabelBitrate": "Битрейт",
|
||||
"LabelBonus": "Бонус",
|
||||
"LabelBooks": "Книги",
|
||||
"LabelButtonText": "Текст на Бутон",
|
||||
"LabelByAuthor": "от {0}",
|
||||
"LabelChangePassword": "Промени Парола",
|
||||
"LabelChannels": "Канали",
|
||||
"LabelChapterCount": "{0} Глави",
|
||||
"LabelChapterTitle": "Заглавие на Глава",
|
||||
"LabelChapters": "Глави",
|
||||
"LabelChaptersFound": "намерени глави",
|
||||
"LabelClickForMoreInfo": "Кликни за повече информация",
|
||||
"LabelClosePlayer": "Затвори Плейъра",
|
||||
"LabelClickToUseCurrentValue": "Натисни да ползваш сегашната стойност",
|
||||
"LabelClosePlayer": "Затвори",
|
||||
"LabelCodec": "Кодек",
|
||||
"LabelCollapseSeries": "Свий Серия",
|
||||
"LabelCollapseSeries": "Скрий сериите",
|
||||
"LabelCollapseSubSeries": "Свий подсерии",
|
||||
"LabelCollection": "Колекция",
|
||||
"LabelCollections": "Колекции",
|
||||
"LabelComplete": "Завършено",
|
||||
"LabelComplete": "Приключено",
|
||||
"LabelConfirmPassword": "Потвърди Парола",
|
||||
"LabelContinueListening": "Продължи Слушане",
|
||||
"LabelContinueReading": "Продължи Четене",
|
||||
"LabelContinueSeries": "Продължи Серия",
|
||||
"LabelContinueListening": "Продължи слушане",
|
||||
"LabelContinueReading": "Продължи четене",
|
||||
"LabelContinueSeries": "Продължи серии",
|
||||
"LabelCover": "Корица",
|
||||
"LabelCoverImageURL": "URL на Корица",
|
||||
"LabelCreatedAt": "Създадено на",
|
||||
"LabelCronExpression": "Cron израз",
|
||||
"LabelCurrent": "Текущо",
|
||||
"LabelCurrently": "Текущо:",
|
||||
"LabelCustomCronExpression": "Потребителски Cron Expression:",
|
||||
"LabelDatetime": "Дата и Време",
|
||||
"LabelDays": "Дни",
|
||||
"LabelDeleteFromFileSystemCheckbox": "Изтрий от файловата система (отмени за да бъдат премахни само от базата данни)",
|
||||
"LabelDescription": "Описание",
|
||||
"LabelDeselectAll": "Премахни всички",
|
||||
@@ -263,16 +300,18 @@
|
||||
"LabelDiscFromFilename": "Диск от Име на Файл",
|
||||
"LabelDiscFromMetadata": "Диск от Метаданни",
|
||||
"LabelDiscover": "Открий",
|
||||
"LabelDownload": "Сваляне",
|
||||
"LabelDownload": "Свали",
|
||||
"LabelDownloadNEpisodes": "Свали {0} епизоди",
|
||||
"LabelDownloadable": "Може да се изтегли",
|
||||
"LabelDuration": "Продължителност",
|
||||
"LabelDurationComparisonExactMatch": "(точно съвпадение)",
|
||||
"LabelDurationComparisonLonger": "({0} по-дълго)",
|
||||
"LabelDurationComparisonShorter": "({0} по-късо)",
|
||||
"LabelDurationFound": "Намерена продължителност:",
|
||||
"LabelEbook": "Електронна книга",
|
||||
"LabelEbooks": "Електронни книги",
|
||||
"LabelEbook": "Е-Книга",
|
||||
"LabelEbooks": "Е-книги",
|
||||
"LabelEdit": "Редакция",
|
||||
"LabelEmail": "Имейл",
|
||||
"LabelEmailSettingsFromAddress": "От Адрес",
|
||||
"LabelEmailSettingsRejectUnauthorized": "Отхвърли неавторизирани сертификати",
|
||||
"LabelEmailSettingsRejectUnauthorizedHelp": "Спирането на валидацията на SSL сертификате може да изложи връзката ви на рискове, като man-in-the-middle атака. Спираите тази опция само ако знете имоликацийте от това и се доверявате на mail сървъра към който се свързвате.",
|
||||
@@ -280,41 +319,53 @@
|
||||
"LabelEmailSettingsSecureHelp": "Ако е вярно възката ще изполва TLS когате се свързва със сървъра. Ако не е то TLS ще се използва ако сървъра поддържа разширението STARTTLS. В повечето случаи задайте тази стойност на истина ако се свързвате към порт 465. За порт 587 или 25 оставете я на лъжа. (от nodemailer.com/smtp/#authentication)",
|
||||
"LabelEmailSettingsTestAddress": "Тестов Адрес",
|
||||
"LabelEmbeddedCover": "Вградена Корица",
|
||||
"LabelEnable": "Включи",
|
||||
"LabelEnable": "Активирай",
|
||||
"LabelEncodingBackupLocation": "Резервно копие на вашите оригинални аудио файлове ще бъде съхранено в:",
|
||||
"LabelEncodingChaptersNotEmbedded": "Главите не са вградени в аудиокнигите с множество тракове.",
|
||||
"LabelEncodingClearItemCache": "Уверете се, че периодично изчиствате кеша на елементите.",
|
||||
"LabelEncodingFinishedM4B": "Завършеният M4B файл ще бъде поставен в папката на вашите аудиокниги на:",
|
||||
"LabelEncodingInfoEmbedded": "Метаданните ще бъдат вградени в аудио траковете в папката на вашите аудиокниги.",
|
||||
"LabelEnd": "Край",
|
||||
"LabelEndOfChapter": "Край на глава",
|
||||
"LabelEpisode": "Епизод",
|
||||
"LabelEpisodeTitle": "Заглавие на Епизод",
|
||||
"LabelEpisodeType": "Тип на Епизод",
|
||||
"LabelExample": "Пример",
|
||||
"LabelExplicit": "Експлицитно",
|
||||
"LabelExpandSeries": "Покажи сериите",
|
||||
"LabelExpandSubSeries": "Покажи съб сериите",
|
||||
"LabelExplicit": "С нецензурно съдържание",
|
||||
"LabelExplicitChecked": "С нецензурно съдържание (проверено)",
|
||||
"LabelExplicitUnchecked": "Без нецензурно съдържание (непроверено)",
|
||||
"LabelExportOPML": "Експортирай OPML",
|
||||
"LabelFeedURL": "URL на емисия",
|
||||
"LabelFetchingMetadata": "Взимане на Метаданни",
|
||||
"LabelFile": "Файл",
|
||||
"LabelFileBirthtime": "Дата на създаване на файла",
|
||||
"LabelFileModified": "Файлът променен",
|
||||
"LabelFilename": "Име на Файл",
|
||||
"LabelFileModified": "Дата на модификация на файла",
|
||||
"LabelFilename": "Име на файла",
|
||||
"LabelFilterByUser": "Филтриране по Потребител",
|
||||
"LabelFindEpisodes": "Намери Епизоди",
|
||||
"LabelFinished": "Завършено",
|
||||
"LabelFinished": "Дата на приключване",
|
||||
"LabelFolder": "Папка",
|
||||
"LabelFolders": "Папки",
|
||||
"LabelFontBold": "Получерно",
|
||||
"LabelFontBoldness": "Плътност на шрифта",
|
||||
"LabelFontBoldness": "Дебелина на шрифта",
|
||||
"LabelFontFamily": "Шрифт",
|
||||
"LabelFontItalic": "Курсив",
|
||||
"LabelFontScale": "Мащаб на Шрифта",
|
||||
"LabelFontScale": "Мащаб на шрифта",
|
||||
"LabelFontStrikethrough": "Зачертан",
|
||||
"LabelFormat": "Формат",
|
||||
"LabelGenre": "Жанр",
|
||||
"LabelGenres": "Жанрове",
|
||||
"LabelHardDeleteFile": "Пълно Изтриване на Файл",
|
||||
"LabelHasEbook": "Има електронна книга",
|
||||
"LabelHasSupplementaryEbook": "Има допълнителна електронна книга",
|
||||
"LabelHasEbook": "Има е-книга",
|
||||
"LabelHasSupplementaryEbook": "Има допълнителна е-книга",
|
||||
"LabelHighestPriority": "Най-висок Приоритет",
|
||||
"LabelHost": "Хост",
|
||||
"LabelHour": "Час",
|
||||
"LabelIcon": "Икона",
|
||||
"LabelImageURLFromTheWeb": "URL на Изображение от Интернет",
|
||||
"LabelInProgress": "В Прогрес",
|
||||
"LabelInProgress": "В процес на изпълнение",
|
||||
"LabelIncludeInTracklist": "Включи в Списъка с Канали",
|
||||
"LabelIncomplete": "Незавършено",
|
||||
"LabelInterval": "Интервал",
|
||||
@@ -337,7 +388,7 @@
|
||||
"LabelLastTime": "Последно Време",
|
||||
"LabelLastUpdate": "Последно Обновяване",
|
||||
"LabelLayout": "Оформление",
|
||||
"LabelLayoutSinglePage": "Една Страница",
|
||||
"LabelLayoutSinglePage": "Единична страница",
|
||||
"LabelLayoutSplitPage": "Разделена Страница",
|
||||
"LabelLess": "По-малко",
|
||||
"LabelLibrariesAccessibleToUser": "Библиотеки Достъпни за Потребителя",
|
||||
@@ -345,8 +396,8 @@
|
||||
"LabelLibraryItem": "Елемент на Библиотека",
|
||||
"LabelLibraryName": "Име на Библиотека",
|
||||
"LabelLimit": "Лимит",
|
||||
"LabelLineSpacing": "Линейно Разстояние",
|
||||
"LabelListenAgain": "Слушай Отново",
|
||||
"LabelLineSpacing": "Междуредие",
|
||||
"LabelListenAgain": "Слушай отново",
|
||||
"LabelLogLevelDebug": "Дебъг",
|
||||
"LabelLogLevelInfo": "Информация",
|
||||
"LabelLogLevelWarn": "Предупреждение",
|
||||
@@ -355,7 +406,7 @@
|
||||
"LabelMatchExistingUsersBy": "Съпостави съществуващи потребители по",
|
||||
"LabelMatchExistingUsersByDescription": "Използва се за свързване на съществуващи потребители. След свързване потребителите ще бъдат съпоставени по уникален идентификатор от вашия доставчик на SSO",
|
||||
"LabelMediaPlayer": "Медия Плейър",
|
||||
"LabelMediaType": "Тип на Медията",
|
||||
"LabelMediaType": "Тип медия",
|
||||
"LabelMetaTag": "Мета Таг",
|
||||
"LabelMetaTags": "Мета Тагове",
|
||||
"LabelMetadataOrderOfPrecedenceDescription": "По-високите източници на метаданни ще заменят по-ниските",
|
||||
@@ -367,19 +418,19 @@
|
||||
"LabelMobileRedirectURIs": "Позволени URI за Мобилно Пренасочване",
|
||||
"LabelMobileRedirectURIsDescription": "Това е whitelist на валидни URI за пренасочване за мобилни приложения. По подразбиране е <code>audiobookshelf://oauth</code>, който може да премахнете или допълните с допълнителни URI за интеграция на приложения от трети страни. Използването на звезда (<code>*</code>) като единствен запис позволява всеки URI.",
|
||||
"LabelMore": "Повече",
|
||||
"LabelMoreInfo": "Повече Информация",
|
||||
"LabelMoreInfo": "Повече информация",
|
||||
"LabelName": "Име",
|
||||
"LabelNarrator": "Разказвач",
|
||||
"LabelNarrators": "Разказвачи",
|
||||
"LabelNew": "Нови",
|
||||
"LabelNewPassword": "Нова Парола",
|
||||
"LabelNewestAuthors": "Най-нови Автори",
|
||||
"LabelNewestEpisodes": "Най-нови Епизоди",
|
||||
"LabelNewestAuthors": "Най-новите автори",
|
||||
"LabelNewestEpisodes": "Най-новите епизоди",
|
||||
"LabelNextBackupDate": "Следваща Дата на Архивиране",
|
||||
"LabelNextScheduledRun": "Следващо Планирано Изпълнение",
|
||||
"LabelNoCustomMetadataProviders": "Няма потребителски доставчици на метаданни",
|
||||
"LabelNoEpisodesSelected": "Няма избрани епизоди",
|
||||
"LabelNotFinished": "Не е завършено",
|
||||
"LabelNotFinished": "Не е приключено",
|
||||
"LabelNotStarted": "Не е започнато",
|
||||
"LabelNotes": "Бележки",
|
||||
"LabelNotificationAppriseURL": "Apprise URL-и",
|
||||
@@ -392,7 +443,10 @@
|
||||
"LabelNotificationsMaxQueueSize": "Максимален размер на опашката за известия",
|
||||
"LabelNotificationsMaxQueueSizeHelp": "Събитията са ограничени до изстрелване на 1 на секунда. Събитията ще бъдат игнорирани ако опашката е на максимален размер. Това предотвратява спамирането на известия.",
|
||||
"LabelNumberOfBooks": "Брой на Книги",
|
||||
"LabelNumberOfEpisodes": "# Епизоди",
|
||||
"LabelNumberOfEpisodes": "Брой епизоди",
|
||||
"LabelOpenIDAdvancedPermsClaimDescription": "Име на OpenID твърдението, което съдържа разширени права за достъп до потребителски действия в приложението, които ще се прилагат за роли, различни от администраторските (<b>ако е конфигурирано</b>). Ако твърдението липсва в отговора, достъпът до ABS ще бъде отказан. Ако липсва една опция, тя ще се третира като <code>false</code>. Уверете се, че твърдението на доставчика на идентичност съответства на очакваната структура:",
|
||||
"LabelOpenIDClaims": "Оставете следните опции празни, за да деактивирате разширеното присвояване на групи, като автоматично ще бъде присвоена групата 'Потребител'.",
|
||||
"LabelOpenIDGroupClaimDescription": "Име на OpenID твърдението, което съдържа списък с групите на потребителя. Обикновено се нарича <code>groups</code>. <b>Ако е конфигурирано</b>, приложението автоматично ще присвоява роли въз основа на членството на потребителя в групи, при условие че тези групи са наименувани без чувствителност към регистъра като 'admin', 'user' или 'guest' в твърдението. Твърдението трябва да съдържа списък и ако потребителят принадлежи към множество групи, приложението ще присвои ролята, съответстваща на най-високото ниво на достъп. Ако няма съвпадение с група, достъпът ще бъде отказан.",
|
||||
"LabelOpenRSSFeed": "Отвори RSS Feed",
|
||||
"LabelOverwrite": "Презапиши",
|
||||
"LabelPassword": "Парола",
|
||||
@@ -414,24 +468,27 @@
|
||||
"LabelPodcasts": "Подкасти",
|
||||
"LabelPort": "Порт",
|
||||
"LabelPrefixesToIgnore": "Префикси за Игнориране (без значение за главни/малки букви)",
|
||||
"LabelPreventIndexing": "Предотврати индексирането на вашия feed от iTunes и Google podcast директории",
|
||||
"LabelPreventIndexing": "Предотвратете индексирането на вашата емисия от директориите на iTunes и Google за подкасти",
|
||||
"LabelPrimaryEbook": "Основна Електронна Книга",
|
||||
"LabelProgress": "Прогрес",
|
||||
"LabelProvider": "Доставчик",
|
||||
"LabelPubDate": "Дата на Издаване",
|
||||
"LabelPublishYear": "Година на Издаване",
|
||||
"LabelPubDate": "Дата на публикуване",
|
||||
"LabelPublishYear": "Година на публикуване",
|
||||
"LabelPublishedDate": "Публикувани {0}",
|
||||
"LabelPublisher": "Издател",
|
||||
"LabelPublishers": "Издателство",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Потребителски собственик Email",
|
||||
"LabelRSSFeedCustomOwnerName": "Потребителски собственик Име",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Персонализиран имейл на собственика",
|
||||
"LabelRSSFeedCustomOwnerName": "Персонализирано име на собственика",
|
||||
"LabelRSSFeedOpen": "RSS Feed Оптворен",
|
||||
"LabelRSSFeedPreventIndexing": "Предотврати индексиране",
|
||||
"LabelRSSFeedSlug": "RSS Feed слъг",
|
||||
"LabelRSSFeedPreventIndexing": "Предотвратете индексиране",
|
||||
"LabelRSSFeedSlug": "идентификатор на RSS емисия",
|
||||
"LabelRSSFeedURL": "URL на RSS емисия",
|
||||
"LabelRandomly": "Случайно",
|
||||
"LabelRead": "Прочети",
|
||||
"LabelReadAgain": "Прочети Отново",
|
||||
"LabelReadAgain": "Прочети отново",
|
||||
"LabelReadEbookWithoutProgress": "Прочети електронната книга без записване прогрес",
|
||||
"LabelRecentSeries": "Скорошни Серии",
|
||||
"LabelRecentlyAdded": "Наскоро Добавени",
|
||||
"LabelRecentSeries": "Скорошни серии",
|
||||
"LabelRecentlyAdded": "Скорошно добавени",
|
||||
"LabelRecommended": "Препоръчано",
|
||||
"LabelRedo": "Повтори",
|
||||
"LabelRegion": "Регион",
|
||||
@@ -448,12 +505,12 @@
|
||||
"LabelSelectUsers": "Избери Потребители",
|
||||
"LabelSendEbookToDevice": "Изпрати електронна книга до ...",
|
||||
"LabelSequence": "Последователност",
|
||||
"LabelSeries": "Серия",
|
||||
"LabelSeries": "От сериите",
|
||||
"LabelSeriesName": "Име на Серия",
|
||||
"LabelSeriesProgress": "Прогрес на Серия",
|
||||
"LabelServerYearReview": "Преглед на годината на сървъра ({0})",
|
||||
"LabelSetEbookAsPrimary": "Задай като основна",
|
||||
"LabelSetEbookAsSupplementary": "Задай като допълнителна",
|
||||
"LabelSetEbookAsPrimary": "Направи главен",
|
||||
"LabelSetEbookAsSupplementary": "Направи второстепенен",
|
||||
"LabelSettingsAudiobooksOnly": "Само аудиокниги",
|
||||
"LabelSettingsAudiobooksOnlyHelp": "Активирането на тази настройка ще игнорира файловете на електронни книги, освен ако не са в папка с аудиокниги, в което случай ще бъдат зададени като допълнителни електронни книги",
|
||||
"LabelSettingsBookshelfViewHelp": "Скеуморфен дизайн с дървени рафтове",
|
||||
@@ -476,6 +533,7 @@
|
||||
"LabelSettingsHomePageBookshelfView": "Начална страница изглед на рафт",
|
||||
"LabelSettingsLibraryBookshelfView": "Библиотека изглед на рафт",
|
||||
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Пропусни предишни книги в Продължи Поредица",
|
||||
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Рафтът на началната страница 'Продължи поредицата' показва първата книга, която не е започната в поредици, в които има поне една завършена книга и няма книги в процес на четене. Активирането на тази настройка ще продължи поредицата от най-далечната завършена книга вместо от първата незапочната книга.",
|
||||
"LabelSettingsParseSubtitles": "Извлечи подзаглавия",
|
||||
"LabelSettingsParseSubtitlesHelp": "Извлича подзаглавия от имената на папките на аудиокнигите.<br>Подзаглавията трябва да бъдат разделени с \" - \"<br>например \"Заглавие на Книга - Тук е Подзаглавито\" има подзаглавие \"Тук е Подзаглавито\"",
|
||||
"LabelSettingsPreferMatchedMetadata": "Предпочети съвпадащи метаданни",
|
||||
@@ -491,9 +549,10 @@
|
||||
"LabelSettingsStoreMetadataWithItem": "Запази метаданните с елемента",
|
||||
"LabelSettingsStoreMetadataWithItemHelp": "По подразбиране метаданните се съхраняват в /metadata/items, като активирате тази настройка метаданните ще се съхраняват в папката на елемента на вашата библиотека",
|
||||
"LabelSettingsTimeFormat": "Формат на Време",
|
||||
"LabelShowAll": "Покажи Всички",
|
||||
"LabelShowAll": "Покажи всички",
|
||||
"LabelShowSeconds": "Покажи секунди",
|
||||
"LabelSize": "Размер",
|
||||
"LabelSleepTimer": "Таймер за Сън",
|
||||
"LabelSleepTimer": "Таймер за изключване",
|
||||
"LabelSlug": "Слъг",
|
||||
"LabelStart": "Старт",
|
||||
"LabelStartTime": "Начално Време",
|
||||
@@ -501,19 +560,19 @@
|
||||
"LabelStartedAt": "Стартирано на",
|
||||
"LabelStatsAudioTracks": "Аудио Канали",
|
||||
"LabelStatsAuthors": "Автори",
|
||||
"LabelStatsBestDay": "Най-добър Ден",
|
||||
"LabelStatsDailyAverage": "Дневна Средна Стойност",
|
||||
"LabelStatsDays": "Дни",
|
||||
"LabelStatsDaysListened": "Дни Слушани",
|
||||
"LabelStatsBestDay": "Най-добър ден",
|
||||
"LabelStatsDailyAverage": "Средно дневно",
|
||||
"LabelStatsDays": "Общо дни",
|
||||
"LabelStatsDaysListened": "Общо слушани дни",
|
||||
"LabelStatsHours": "Часове",
|
||||
"LabelStatsInARow": "подред",
|
||||
"LabelStatsItemsFinished": "Завършени Елементи",
|
||||
"LabelStatsInARow": "последователно",
|
||||
"LabelStatsItemsFinished": "Приключени елементи",
|
||||
"LabelStatsItemsInLibrary": "Елементи в Библиотеката",
|
||||
"LabelStatsMinutes": "минути",
|
||||
"LabelStatsMinutesListening": "Минути Слушани",
|
||||
"LabelStatsMinutesListening": "Общо слушани минути",
|
||||
"LabelStatsOverallDays": "Общо Дни",
|
||||
"LabelStatsOverallHours": "Общо Часове",
|
||||
"LabelStatsWeekListening": "Седмица Слушане",
|
||||
"LabelStatsWeekListening": "Общо слушани седмици",
|
||||
"LabelSubtitle": "Подзаглавие",
|
||||
"LabelSupportedFileTypes": "Поддържани Типове Файлове",
|
||||
"LabelTag": "Таг",
|
||||
@@ -531,7 +590,7 @@
|
||||
"LabelTimeBase": "Времева Основа",
|
||||
"LabelTimeListened": "Време Слушано",
|
||||
"LabelTimeListenedToday": "Време Слушано Днес",
|
||||
"LabelTimeRemaining": "{0} оставащо време",
|
||||
"LabelTimeRemaining": "{0} оставащи",
|
||||
"LabelTimeToShift": "Време за изместване в секунди",
|
||||
"LabelTitle": "Заглавие",
|
||||
"LabelToolsEmbedMetadata": "Вграждане на Метаданни",
|
||||
@@ -544,14 +603,14 @@
|
||||
"LabelTotalTimeListened": "Общо Време Слушано",
|
||||
"LabelTrackFromFilename": "Канал от Име на Файл",
|
||||
"LabelTrackFromMetadata": "Канал от Метаданни",
|
||||
"LabelTracks": "Канали",
|
||||
"LabelTracks": "Тракове",
|
||||
"LabelTracksMultiTrack": "Многоканален",
|
||||
"LabelTracksNone": "Няма канали",
|
||||
"LabelTracksSingleTrack": "Единичен канал",
|
||||
"LabelType": "Тип",
|
||||
"LabelUnabridged": "Несъкратен",
|
||||
"LabelUndo": "Отмени",
|
||||
"LabelUnknown": "Неизвестно",
|
||||
"LabelUnknown": "Неизвестен",
|
||||
"LabelUpdateCover": "Обнови Корица",
|
||||
"LabelUpdateCoverHelp": "Позволи презаписване на съществуващите корици за избраните книги, когато се намери съвпадение",
|
||||
"LabelUpdateDetails": "Обнови Детайли",
|
||||
@@ -563,7 +622,7 @@
|
||||
"LabelUseChapterTrack": "Използвай канал за глава",
|
||||
"LabelUseFullTrack": "Използвай пълен канал",
|
||||
"LabelUser": "Потребител",
|
||||
"LabelUsername": "Потребителско Име",
|
||||
"LabelUsername": "Потребителско име",
|
||||
"LabelValue": "Стойност",
|
||||
"LabelVersion": "Версия",
|
||||
"LabelViewBookmarks": "Виж Отметки",
|
||||
@@ -571,16 +630,20 @@
|
||||
"LabelViewQueue": "Виж Опашка",
|
||||
"LabelVolume": "Сила на Звука",
|
||||
"LabelWeekdaysToRun": "Делници за изпълнение",
|
||||
"LabelYearReviewHide": "Скрий ревю на годината ти",
|
||||
"LabelYearReviewShow": "Виж ревю на годината ти",
|
||||
"LabelYourAudiobookDuration": "Продължителност на вашата аудиокнига",
|
||||
"LabelYourBookmarks": "Вашите Отметки",
|
||||
"LabelYourBookmarks": "Твойте отметки",
|
||||
"LabelYourPlaylists": "Вашите Плейлисти",
|
||||
"LabelYourProgress": "Вашият Прогрес",
|
||||
"LabelYourProgress": "Твоят прогрес",
|
||||
"MessageAddToPlayerQueue": "Добави към опашката на плейъра",
|
||||
"MessageAppriseDescription": "За да ползвате тази функция трябва да имате активна инстанция на <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> или на друго АПИ което да обработва тези заявки. <br />The Apprise API Url-а трябва дае пълния URL път за изпращане на известията, например, ако вашето АПИ ве подава от <code>http://192.168.1.1:8337</code> трябва да сложитев <code>http://192.168.1.1:8337/notify</code>.",
|
||||
"MessageBackupsDescription": "Резервните копия включват потребители, напредък на потребителите, подробности за елементите в библиотеката, настройки на сървъра и изображения, съхранени в <code>/metadata/items</code> и <code>/metadata/authors</code>. Резервните копия <strong>не</strong> включват никакви файлове, съхранени в папките на вашата библиотека.",
|
||||
"MessageBatchQuickMatchDescription": "Бързото Съпоставяне ще опита да добави липсващи корици и метаданни за избраните елементи. Активирайте опциите по-долу, за да позволите на Бързото съпоставяне да презапише съществуващите корици и/или метаданни.",
|
||||
"MessageBookshelfNoCollections": "Все още нямате създадени колекции",
|
||||
"MessageBookshelfNoRSSFeeds": "Няма отворени RSS feed-ове",
|
||||
"MessageBookshelfNoResultsForFilter": "Няма резултат за филтер \"{0}: {1}\"",
|
||||
"MessageBookshelfNoResultsForQuery": "Няма резултати от заявката",
|
||||
"MessageBookshelfNoSeries": "Нямаш сеЗЙ",
|
||||
"MessageChapterEndIsAfter": "Краят на главата е след края на вашата аудиокнига",
|
||||
"MessageChapterErrorFirstNotZero": "Първата глава трябва да започва от 0",
|
||||
@@ -600,6 +663,8 @@
|
||||
"MessageConfirmMarkAllEpisodesNotFinished": "Сигурни ли сте, че искате да маркирате всички епизоди като незавършени?",
|
||||
"MessageConfirmMarkSeriesFinished": "Сигурни ли сте, че искате да маркирате всички книги в тази серия като завършени?",
|
||||
"MessageConfirmMarkSeriesNotFinished": "Сигурни ли сте, че искате да маркирате всички книги в тази серия като незавършени?",
|
||||
"MessageConfirmPurgeCache": "Изчистването на кеша ще изтрие цялата директория в <code>/metadata/cache</code>. <br /><br />Сигурни ли сте, че искате да премахнете директорията на кеша?",
|
||||
"MessageConfirmPurgeItemsCache": "Изчистването на кеша на елементите ще изтрие цялата директория в <code>/metadata/cache/items</code>. <br />Сигурни ли сте?",
|
||||
"MessageConfirmQuickEmbed": "Внимание! Бързото вграждане няма да архивира вашите аудио файлове. Уверете се, че имате резервно копие на вашите аудио файлове. <br><br>Искате ли да продължите?",
|
||||
"MessageConfirmReScanLibraryItems": "Сигурни ли сте, че искате да сканирате отново {0} елемента?",
|
||||
"MessageConfirmRemoveAllChapters": "Сигурни ли сте, че искате да премахнете всички глави?",
|
||||
@@ -617,34 +682,36 @@
|
||||
"MessageConfirmRenameTagMergeNote": "Забележка: Този таг вече съществува и ще бъде слято.",
|
||||
"MessageConfirmRenameTagWarning": "Внимание! Вече съществува подобен таг с различно писане \"{0}\".",
|
||||
"MessageConfirmSendEbookToDevice": "Сигурни ли сте, че искате да изпратите {0} електронна книга \"{1}\" до устройство \"{2}\"?",
|
||||
"MessageDownloadingEpisode": "Изтегляне на епизод",
|
||||
"MessageDownloadingEpisode": "Сваля епизод",
|
||||
"MessageDragFilesIntoTrackOrder": "Плъзнете файлове в правилния ред на каналите",
|
||||
"MessageEmbedFinished": "Вграждането завърши!",
|
||||
"MessageEpisodesQueuedForDownload": "{0} епизод(и) в опашка за изтегляне",
|
||||
"MessageFeedURLWillBe": "Feed URL-a ще бъде {0}",
|
||||
"MessageFetching": "Взимане...",
|
||||
"MessageEpisodesQueuedForDownload": "{0} Епизод(и) са сложени за сваляне",
|
||||
"MessageEreaderDevices": "За да осигурите доставката на е-книги, може да се наложи да добавите горепосочения имейл адрес като валиден подател за всяко устройство, изброено по-долу.",
|
||||
"MessageFeedURLWillBe": "Адресът на емисията ще бъде {0}",
|
||||
"MessageFetching": "Извличане...",
|
||||
"MessageForceReScanDescription": "ще сканира всички файлове отново като прясно сканиране. Аудио файлове ID3 тагове, OPF файлове и текстови файлове ще бъдат сканирани като нови.",
|
||||
"MessageImportantNotice": "Важно Съобщение!",
|
||||
"MessageInsertChapterBelow": "Вмъкни глава под",
|
||||
"MessageItemsSelected": "{0} избрани",
|
||||
"MessageItemsUpdated": "{0} елемента обновени",
|
||||
"MessageJoinUsOn": "Присъединете се към нас",
|
||||
"MessageLoading": "Зареждане...",
|
||||
"MessageLoading": "Зарежда...",
|
||||
"MessageLoadingFolders": "Зареждане на Папки...",
|
||||
"MessageLogsDescription": "Логовете се съхраняват в <code>/metadata/logs</code> като JSON файлове. Дневниците за сривове се съхраняват в <code>/metadata/logs/crash_logs.txt</code>.",
|
||||
"MessageM4BFailed": "M4B Провалено!",
|
||||
"MessageM4BFinished": "M4B Завършено!",
|
||||
"MessageMapChapterTitles": "Съпостави заглавията на главите със съществуващите глави на аудиокнигата без да променяш времената",
|
||||
"MessageMarkAllEpisodesFinished": "Маркирай всички епизоди като завършени",
|
||||
"MessageMarkAllEpisodesNotFinished": "Маркирай всички епизоди като незавършени",
|
||||
"MessageMarkAsFinished": "Маркирай като Завършено",
|
||||
"MessageMarkAsFinished": "Маркирай като завършено",
|
||||
"MessageMarkAsNotFinished": "Маркирай като Незавършено",
|
||||
"MessageMatchBooksDescription": "ще се опита да съпостави книги в библиотеката с книга от избрания доставчик за търсене и ще попълни празни детайли и корици. Не презаписва детайлите.",
|
||||
"MessageNoAudioTracks": "Няма аудио канали",
|
||||
"MessageNoAuthors": "Няма Автори",
|
||||
"MessageNoBackups": "Няма архиви",
|
||||
"MessageNoBookmarks": "Няма Отметки",
|
||||
"MessageNoChapters": "Няма Глави",
|
||||
"MessageNoCollections": "Няма Колекции",
|
||||
"MessageNoBookmarks": "Няма отметки",
|
||||
"MessageNoChapters": "Няма глави",
|
||||
"MessageNoCollections": "Няма колекции",
|
||||
"MessageNoCoversFound": "Не са намерени корици",
|
||||
"MessageNoDescription": "Няма описание",
|
||||
"MessageNoDownloadsInProgress": "Няма изтегляния в прогрес",
|
||||
@@ -654,9 +721,9 @@
|
||||
"MessageNoFoldersAvailable": "Няма налични папки",
|
||||
"MessageNoGenres": "Няма Жанрове",
|
||||
"MessageNoIssues": "Няма проблеми",
|
||||
"MessageNoItems": "Няма Елементи",
|
||||
"MessageNoItems": "Няма елементи",
|
||||
"MessageNoItemsFound": "Няма намерени елементи",
|
||||
"MessageNoListeningSessions": "Няма слушателски сесии",
|
||||
"MessageNoListeningSessions": "Няма сесии за слушане",
|
||||
"MessageNoLogs": "Няма логове",
|
||||
"MessageNoMediaProgress": "Няма прогрес на медията",
|
||||
"MessageNoNotifications": "Няма известия",
|
||||
@@ -666,20 +733,21 @@
|
||||
"MessageNoSeries": "Няма Серии",
|
||||
"MessageNoTags": "Няма Тагове",
|
||||
"MessageNoTasksRunning": "Няма вършещи се задачи",
|
||||
"MessageNoUpdatesWereNecessary": "Не бяха необходими обновления",
|
||||
"MessageNoUserPlaylists": "Няма плейлисти на потребителя",
|
||||
"MessageNoUpdatesWereNecessary": "Няма нужда от обновяване",
|
||||
"MessageNoUserPlaylists": "Нямате създадени плейлисти",
|
||||
"MessageNotYetImplemented": "Още не е изпълнено",
|
||||
"MessageOr": "или",
|
||||
"MessagePauseChapter": "Пауза на глава",
|
||||
"MessagePlayChapter": "Пусни налчалото на глава",
|
||||
"MessagePlaylistCreateFromCollection": "Създай плейлист от колекция",
|
||||
"MessagePodcastHasNoRSSFeedForMatching": "Подкастът няма URL адрес на RSS feed за използване за съпоставяне",
|
||||
"MessagePodcastSearchField": "Въведи какво да търся или RSS емисия адрес",
|
||||
"MessageQuickMatchDescription": "Попълни празните детайли и корици с първия резултат от '{0}'. Не презаписва детайлите, освен ако не е активирана настройката 'Предпочети съвпадащи метаданни' на сървъра.",
|
||||
"MessageRemoveChapter": "Премахни глава",
|
||||
"MessageRemoveEpisodes": "Премахни {0} епизод(и)",
|
||||
"MessageRemoveFromPlayerQueue": "Премахни от опашката на плейъра",
|
||||
"MessageRemoveUserWarning": "Сигурни ли сте, че искате да изтриете потребител \"{0}\" завинаги?",
|
||||
"MessageReportBugsAndContribute": "Съобщавайте за грешки, заявявайте функции и допринасяйте на",
|
||||
"MessageReportBugsAndContribute": "Докладвайте грешки, поискайте нови функции и допринасяйте на",
|
||||
"MessageResetChaptersConfirm": "Сигурни ли сте, че искате да нулирате главите и да отмените промените, които сте направили?",
|
||||
"MessageRestoreBackupConfirm": "Сигурни ли сте, че искате да възстановите архива създаден на",
|
||||
"MessageRestoreBackupWarning": "Възстановяването на архив ще презапише цялата база данни, намираща се в /config и кориците в /metadata/items & /metadata/authors.<br /><br />Архивите не променят файловете в папките на вашата библиотека. Ако сте активирали настройките на сървъра за съхранение на корици и метаданни в папките на вашата библиотека, те няма да бъдат архивирани или презаписани.<br /><br />Всички клиенти, използващи вашия сървър, ще бъдат автоматично обновени.",
|
||||
@@ -700,8 +768,8 @@
|
||||
"NoteChangeRootPassword": "Root потребителят е единственият потребител, който може да има празна парола",
|
||||
"NoteChapterEditorTimes": "Забележка: Първото време на начало на главата трябва да остане на 0:00, а последното време на начало на главата не може да надвишава продължителността на тази аудиокнига.",
|
||||
"NoteFolderPicker": "Забележка: папките, които вече са картографирани, няма да бъдат показани",
|
||||
"NoteRSSFeedPodcastAppsHttps": "Внимание: Повечето приложения за подкасти изискват URL адреса на RSS feed да използва HTTPS",
|
||||
"NoteRSSFeedPodcastAppsPubDate": "Внимание: 1 или повече от вашите епизоди нямат дата на публикуване. Някои приложения за подкасти изискват това",
|
||||
"NoteRSSFeedPodcastAppsHttps": "Предупреждение: Повечето приложения за подкасти изискват URL адресът на RSS емисията да използва HTTPS",
|
||||
"NoteRSSFeedPodcastAppsPubDate": "Предупреждение: Един или повече от вашите епизоди нямат дата на публикуване. Някои приложения за подкасти изискват това.",
|
||||
"NoteUploaderFoldersWithMediaFiles": "Папките с медийни файлове ще бъдат обработени като отделни елементи на библиотеката.",
|
||||
"NoteUploaderOnlyAudioFiles": "Ако качвате само аудио файлове, то всеки аудио файл ще бъде обработен като отделна аудиокнига.",
|
||||
"NoteUploaderUnsupportedFiles": "Неподдържаните файлове се игнорират. При избор или пускане на папка, други файлове, които не са в папка на елемент, се игнорират.",
|
||||
@@ -722,18 +790,25 @@
|
||||
"ToastBackupRestoreFailed": "Неуспешно възстановяване на архив",
|
||||
"ToastBackupUploadFailed": "Неуспешно качване на архив",
|
||||
"ToastBackupUploadSuccess": "Архивът е качен",
|
||||
"ToastBatchUpdateFailed": "Неуспешно групово актуализиране",
|
||||
"ToastBatchUpdateSuccess": "Успешно групово актуализиране",
|
||||
"ToastBookmarkCreateFailed": "Неуспешно създаване на отметка",
|
||||
"ToastBookmarkCreateSuccess": "Отметката е създадена",
|
||||
"ToastBookmarkRemoveSuccess": "Отметката е премахната",
|
||||
"ToastCachePurgeFailed": "Неуспешно изчистване на кеша",
|
||||
"ToastCachePurgeSuccess": "Успешно изчистване на кеша",
|
||||
"ToastChaptersHaveErrors": "Главите имат грешки",
|
||||
"ToastChaptersMustHaveTitles": "Главите трябва да имат заглавия",
|
||||
"ToastCollectionRemoveSuccess": "Колекцията е премахната",
|
||||
"ToastCollectionUpdateSuccess": "Колекцията е обновена",
|
||||
"ToastDeleteFileFailed": "Неуспешно изтриване на файла",
|
||||
"ToastDeleteFileSuccess": "Успешно изтриване на файла",
|
||||
"ToastFailedToLoadData": "Неуспешно зареждане на данни",
|
||||
"ToastItemCoverUpdateSuccess": "Корицата на елемента е обновена",
|
||||
"ToastItemDetailsUpdateSuccess": "Детайлите на елемента са обновени",
|
||||
"ToastItemMarkedAsFinishedFailed": "Неуспешно маркиране като завършено",
|
||||
"ToastItemMarkedAsFinishedFailed": "Неуспешно маркиране като Завършено",
|
||||
"ToastItemMarkedAsFinishedSuccess": "Елементът е маркиран като завършен",
|
||||
"ToastItemMarkedAsNotFinishedFailed": "Неуспешно маркиране като незавършено",
|
||||
"ToastItemMarkedAsNotFinishedFailed": "Неуспешно маркиране като Незавършено",
|
||||
"ToastItemMarkedAsNotFinishedSuccess": "Елементът е маркиран като незавършен",
|
||||
"ToastLibraryCreateFailed": "Неуспешно създаване на библиотека",
|
||||
"ToastLibraryCreateSuccess": "Библиотеката \"{0}\" е създадена",
|
||||
@@ -747,20 +822,23 @@
|
||||
"ToastPlaylistRemoveSuccess": "Плейлистът е премахнат",
|
||||
"ToastPlaylistUpdateSuccess": "Плейлистът е обновен",
|
||||
"ToastPodcastCreateFailed": "Неуспешно създаване на подкаст",
|
||||
"ToastPodcastCreateSuccess": "Подкастът е създаден",
|
||||
"ToastRSSFeedCloseFailed": "Неуспешно затваряне на RSS feed",
|
||||
"ToastRSSFeedCloseSuccess": "RSS feed затворен",
|
||||
"ToastPodcastCreateSuccess": "Подкаст успешно създаден",
|
||||
"ToastRSSFeedCloseFailed": "Неуспешно затваряне на RSS емисията",
|
||||
"ToastRSSFeedCloseSuccess": "RSS емисията е затворена",
|
||||
"ToastRemoveItemFromCollectionFailed": "Неуспешно премахване на елемент от колекция",
|
||||
"ToastRemoveItemFromCollectionSuccess": "Елементът е премахнат от колекция",
|
||||
"ToastSendEbookToDeviceFailed": "Неуспешно изпращане на електронна книга до устройство",
|
||||
"ToastSendEbookToDeviceSuccess": "Електронната книга е изпратена до устройство \"{0}\"",
|
||||
"ToastSeriesUpdateFailed": "Неуспешно обновяване на серия",
|
||||
"ToastSeriesUpdateSuccess": "Серията е обновена",
|
||||
"ToastServerSettingsUpdateSuccess": "Настройките на сървъра са актуализирани",
|
||||
"ToastSessionDeleteFailed": "Неуспешно изтриване на сесия",
|
||||
"ToastSessionDeleteSuccess": "Сесията е изтрита",
|
||||
"ToastSocketConnected": "Свързан сокет",
|
||||
"ToastSocketDisconnected": "Сокетът е прекъснат",
|
||||
"ToastSocketFailedToConnect": "Неуспешно свързване на сокет",
|
||||
"ToastSortingPrefixesEmptyError": "Трябва да има поне 1 префикс за сортиране",
|
||||
"ToastSortingPrefixesUpdateSuccess": "Префиксите за сортиране са актуализирани ({0} елемента)",
|
||||
"ToastUserDeleteFailed": "Неуспешно изтриване на потребител",
|
||||
"ToastUserDeleteSuccess": "Потребителят е изтрит"
|
||||
}
|
||||
|
||||
@@ -217,6 +217,7 @@
|
||||
"LabelAccountTypeAdmin": "Správce",
|
||||
"LabelAccountTypeGuest": "Host",
|
||||
"LabelAccountTypeUser": "Uživatel",
|
||||
"LabelActivities": "Aktivity",
|
||||
"LabelActivity": "Aktivita",
|
||||
"LabelAddToCollection": "Přidat do kolekce",
|
||||
"LabelAddToCollectionBatch": "Přidat {0} knihy do kolekce",
|
||||
@@ -389,6 +390,7 @@
|
||||
"LabelIntervalEvery6Hours": "Každých 6 hodin",
|
||||
"LabelIntervalEveryDay": "Každý den",
|
||||
"LabelIntervalEveryHour": "Každou hodinu",
|
||||
"LabelIntervalEveryMinute": "Každou minutu",
|
||||
"LabelInvert": "Invertovat",
|
||||
"LabelItem": "Položka",
|
||||
"LabelJumpBackwardAmount": "Přeskočit zpět o",
|
||||
@@ -484,6 +486,7 @@
|
||||
"LabelPersonalYearReview": "Váš přehled roku ({0})",
|
||||
"LabelPhotoPathURL": "Cesta k fotografii/URL",
|
||||
"LabelPlayMethod": "Metoda přehrávání",
|
||||
"LabelPlaybackRateIncrementDecrement": "Velikost kroku pro změnu rychlosti přehrávání",
|
||||
"LabelPlayerChapterNumberMarker": "{0} z {1}",
|
||||
"LabelPlaylists": "Seznamy skladeb",
|
||||
"LabelPodcast": "Podcast",
|
||||
@@ -706,6 +709,7 @@
|
||||
"MessageBackupsLocationPathEmpty": "Umístění záloh nemůže být prázdné",
|
||||
"MessageBatchQuickMatchDescription": "Rychlá párování se pokusí přidat chybějící obálky a metadata pro vybrané položky. Povolením níže uvedených možností umožníte funkci Rychlé párování přepsat stávající obálky a/nebo metadata.",
|
||||
"MessageBookshelfNoCollections": "Ještě jste nevytvořili žádnou sbírku",
|
||||
"MessageBookshelfNoCollectionsHelp": "Kolekce jsou veřejné. Mohou je zobrazit všichni uživatelé s přístupem do knihovny.",
|
||||
"MessageBookshelfNoRSSFeeds": "Nejsou otevřeny žádné RSS kanály",
|
||||
"MessageBookshelfNoResultsForFilter": "Filtr \"{0}: {1}\"",
|
||||
"MessageBookshelfNoResultsForQuery": "Žádné výsledky pro dotaz",
|
||||
@@ -816,6 +820,7 @@
|
||||
"MessageNoTasksRunning": "Nejsou spuštěny žádné úlohy",
|
||||
"MessageNoUpdatesWereNecessary": "Nebyly nutné žádné aktualizace",
|
||||
"MessageNoUserPlaylists": "Nemáte žádné seznamy skladeb",
|
||||
"MessageNoUserPlaylistsHelp": "Seznamy skladeb jsou soukromé. Zobrazit je může pouze uživatel, který je vytvořil.",
|
||||
"MessageNotYetImplemented": "Ještě není implementováno",
|
||||
"MessageOpmlPreviewNote": "Poznámka: Toto je náhled načteného OMPL souboru. Aktuální název podcastu bude načten z RSS feedu.",
|
||||
"MessageOr": "nebo",
|
||||
|
||||
@@ -219,7 +219,8 @@
|
||||
"LabelAccountTypeAdmin": "Admin",
|
||||
"LabelAccountTypeGuest": "Gast",
|
||||
"LabelAccountTypeUser": "Benutzer",
|
||||
"LabelActivity": "Aktivitäten",
|
||||
"LabelActivities": "Aktivitäten",
|
||||
"LabelActivity": "Aktivität",
|
||||
"LabelAddToCollection": "Zur Sammlung hinzufügen",
|
||||
"LabelAddToCollectionBatch": "Füge {0} Hörbüch(er)/Podcast(s) der Sammlung hinzu",
|
||||
"LabelAddToPlaylist": "Zur Wiedergabeliste hinzufügen",
|
||||
@@ -283,6 +284,7 @@
|
||||
"LabelContinueSeries": "Serien fortsetzen",
|
||||
"LabelCover": "Titelbild",
|
||||
"LabelCoverImageURL": "URL des Titelbildes",
|
||||
"LabelCoverProvider": "Titelbildanbieter",
|
||||
"LabelCreatedAt": "Erstellt am",
|
||||
"LabelCronExpression": "Cron-Ausdruck",
|
||||
"LabelCurrent": "Aktuell",
|
||||
@@ -391,6 +393,7 @@
|
||||
"LabelIntervalEvery6Hours": "Alle 6 Stunden",
|
||||
"LabelIntervalEveryDay": "Jeden Tag",
|
||||
"LabelIntervalEveryHour": "Jede Stunde",
|
||||
"LabelIntervalEveryMinute": "Jede Minute",
|
||||
"LabelInvert": "Umkehren",
|
||||
"LabelItem": "Medium",
|
||||
"LabelJumpBackwardAmount": "Zurückspringen Zeit",
|
||||
@@ -844,6 +847,7 @@
|
||||
"MessageRestoreBackupConfirm": "Bist du dir sicher, dass du die Sicherung wiederherstellen willst, welche am",
|
||||
"MessageRestoreBackupWarning": "Bei der Wiederherstellung einer Sicherung wird die gesamte Datenbank unter /config und die Titelbilder in /metadata/items und /metadata/authors überschrieben.<br /><br />Bei der Sicherung werden keine Dateien in deinen Bibliotheksordnern verändert. Wenn du die Servereinstellungen aktiviert hast, um Cover und Metadaten in deinen Bibliotheksordnern zu speichern, werden diese nicht gesichert oder überschrieben.<br /><br />Alle Clients, die Ihren Server nutzen, werden automatisch aktualisiert.",
|
||||
"MessageScheduleLibraryScanNote": "Für die meisten Nutzer wird empfohlen, diese Funktion deaktiviert zu lassen und stattdessen die Ordnerüberwachung aktiviert zu lassen. Die Ordnerüberwachung erkennt automatisch Änderungen in deinen Bibliotheksordnern. Da die Ordnerüberwachung jedoch nicht mit jedem Dateisystem (z.B. NFS) funktioniert, können alternativ hier geplante Bibliotheks-Scans aktiviert werden.",
|
||||
"MessageScheduleRunEveryWeekdayAtTime": "Immer {0} um {1} ausführen",
|
||||
"MessageSearchResultsFor": "Suchergebnisse für",
|
||||
"MessageSelected": "{0} ausgewählt",
|
||||
"MessageServerCouldNotBeReached": "Server kann nicht erreicht werden",
|
||||
|
||||
@@ -219,6 +219,7 @@
|
||||
"LabelAccountTypeAdmin": "Admin",
|
||||
"LabelAccountTypeGuest": "Guest",
|
||||
"LabelAccountTypeUser": "User",
|
||||
"LabelActivities": "Activities",
|
||||
"LabelActivity": "Activity",
|
||||
"LabelAddToCollection": "Add to Collection",
|
||||
"LabelAddToCollectionBatch": "Add {0} Books to Collection",
|
||||
@@ -283,6 +284,7 @@
|
||||
"LabelContinueSeries": "Continue Series",
|
||||
"LabelCover": "Cover",
|
||||
"LabelCoverImageURL": "Cover Image URL",
|
||||
"LabelCoverProvider": "Cover Provider",
|
||||
"LabelCreatedAt": "Created At",
|
||||
"LabelCronExpression": "Cron Expression",
|
||||
"LabelCurrent": "Current",
|
||||
@@ -391,6 +393,7 @@
|
||||
"LabelIntervalEvery6Hours": "Every 6 hours",
|
||||
"LabelIntervalEveryDay": "Every day",
|
||||
"LabelIntervalEveryHour": "Every hour",
|
||||
"LabelIntervalEveryMinute": "Every minute",
|
||||
"LabelInvert": "Invert",
|
||||
"LabelItem": "Item",
|
||||
"LabelJumpBackwardAmount": "Jump backward amount",
|
||||
@@ -845,6 +848,7 @@
|
||||
"MessageRestoreBackupConfirm": "Are you sure you want to restore the backup created on",
|
||||
"MessageRestoreBackupWarning": "Restoring a backup will overwrite the entire database located at /config and cover images in /metadata/items & /metadata/authors.<br /><br />Backups do not modify any files in your library folders. If you have enabled server settings to store cover art and metadata in your library folders then those are not backed up or overwritten.<br /><br />All clients using your server will be automatically refreshed.",
|
||||
"MessageScheduleLibraryScanNote": "For most users, it is recommended to leave this feature disabled and keep the folder watcher setting enabled. The folder watcher will automatically detect changes in your library folders. The folder watcher doesn't work for every file system (like NFS) so scheduled library scans can be used instead.",
|
||||
"MessageScheduleRunEveryWeekdayAtTime": "Run every {0} at {1}",
|
||||
"MessageSearchResultsFor": "Search results for",
|
||||
"MessageSelected": "{0} selected",
|
||||
"MessageServerCouldNotBeReached": "Server could not be reached",
|
||||
|
||||
@@ -707,7 +707,7 @@
|
||||
"MessageBackupsLocationEditNote": "Remarque : Mettre à jour l'emplacement de sauvegarde ne déplacera pas ou ne modifiera pas les sauvegardes existantes",
|
||||
"MessageBackupsLocationNoEditNote": "Remarque : l’emplacement de sauvegarde est défini via une variable d’environnement et ne peut pas être modifié ici.",
|
||||
"MessageBackupsLocationPathEmpty": "L'emplacement de secours ne peut pas être vide",
|
||||
"MessageBatchEditPopulateMapDetailsAllHelp": "Remplir les champs disponibles avec les données de tous les éléments. les champs avec des valeurs multiples seront fusionnés",
|
||||
"MessageBatchEditPopulateMapDetailsAllHelp": "Remplir les champs disponibles avec les données de tous les éléments. Les champs avec des valeurs multiples seront fusionnés.",
|
||||
"MessageBatchQuickMatchDescription": "La recherche par correspondance rapide tentera d’ajouter les couvertures et métadonnées manquantes pour les éléments sélectionnés. Activez les options ci-dessous pour permettre la Recherche par correspondance d’écraser les couvertures et/ou métadonnées existantes.",
|
||||
"MessageBookshelfNoCollections": "Vous n’avez pas encore de collections",
|
||||
"MessageBookshelfNoRSSFeeds": "Aucun flux RSS n’est ouvert",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"ButtonCancel": "Odustani",
|
||||
"ButtonCancelEncode": "Otkaži kodiranje",
|
||||
"ButtonChangeRootPassword": "Promijeni zaporku root korisnika",
|
||||
"ButtonCheckAndDownloadNewEpisodes": "Provjeri i preuzmi nove epizode",
|
||||
"ButtonCheckAndDownloadNewEpisodes": "Provjeri i preuzmi nove nastavke",
|
||||
"ButtonChooseAFolder": "Odaberi mapu",
|
||||
"ButtonChooseFiles": "Odaberi datoteke",
|
||||
"ButtonClearFilter": "Poništi filter",
|
||||
@@ -219,6 +219,7 @@
|
||||
"LabelAccountTypeAdmin": "Administrator",
|
||||
"LabelAccountTypeGuest": "Gost",
|
||||
"LabelAccountTypeUser": "Korisnik",
|
||||
"LabelActivities": "Aktivnosti",
|
||||
"LabelActivity": "Aktivnost",
|
||||
"LabelAddToCollection": "Dodaj u zbirku",
|
||||
"LabelAddToCollectionBatch": "Dodaj {0} knjiga u zbirku",
|
||||
@@ -283,6 +284,7 @@
|
||||
"LabelContinueSeries": "Nastavi serijal",
|
||||
"LabelCover": "Naslovnica",
|
||||
"LabelCoverImageURL": "URL naslovnice",
|
||||
"LabelCoverProvider": "Pružatelj naslovnica",
|
||||
"LabelCreatedAt": "Izrađen",
|
||||
"LabelCronExpression": "Cron izraz",
|
||||
"LabelCurrent": "Trenutan",
|
||||
@@ -355,7 +357,7 @@
|
||||
"LabelFileModifiedDate": "Izmijenjeno {0}",
|
||||
"LabelFilename": "Naziv datoteke",
|
||||
"LabelFilterByUser": "Filtriraj po korisniku",
|
||||
"LabelFindEpisodes": "Pronađi epizode",
|
||||
"LabelFindEpisodes": "Pronađi nastavke",
|
||||
"LabelFinished": "Dovršeno",
|
||||
"LabelFolder": "Mapa",
|
||||
"LabelFolders": "Mape",
|
||||
@@ -391,6 +393,7 @@
|
||||
"LabelIntervalEvery6Hours": "Svakih 6 sati",
|
||||
"LabelIntervalEveryDay": "Svaki dan",
|
||||
"LabelIntervalEveryHour": "Svaki sat",
|
||||
"LabelIntervalEveryMinute": "Svaku minutu",
|
||||
"LabelInvert": "Obrni",
|
||||
"LabelItem": "Stavka",
|
||||
"LabelJumpBackwardAmount": "Dužina skoka unatrag",
|
||||
@@ -400,8 +403,8 @@
|
||||
"LabelLanguages": "Jezici",
|
||||
"LabelLastBookAdded": "Zadnja dodana knjiga",
|
||||
"LabelLastBookUpdated": "Zadnja ažurirana knjiga",
|
||||
"LabelLastSeen": "Zadnji puta viđen",
|
||||
"LabelLastTime": "Zadnje vrijeme",
|
||||
"LabelLastSeen": "Zadnje gledano",
|
||||
"LabelLastTime": "Vrijeme zadnjeg slušanja",
|
||||
"LabelLastUpdate": "Zadnje ažuriranje",
|
||||
"LabelLayout": "Prikaz",
|
||||
"LabelLayoutSinglePage": "Jedna stranica",
|
||||
@@ -418,7 +421,7 @@
|
||||
"LabelLogLevelDebug": "Debug",
|
||||
"LabelLogLevelInfo": "Info",
|
||||
"LabelLogLevelWarn": "Warn",
|
||||
"LabelLookForNewEpisodesAfterDate": "Traži nove epizode nakon ovog datuma",
|
||||
"LabelLookForNewEpisodesAfterDate": "Traži nove nastavke nakon ovog datuma",
|
||||
"LabelLowestPriority": "Najniži prioritet",
|
||||
"LabelMatchExistingUsersBy": "Prepoznaj postojeće korisnike pomoću",
|
||||
"LabelMatchExistingUsersByDescription": "Rabi se za povezivanje postojećih korisnika. Nakon što se spoje, korisnike se prepoznaje temeljem jedinstvene oznake vašeg pružatelja SSO usluga",
|
||||
@@ -447,7 +450,7 @@
|
||||
"LabelNew": "Novo",
|
||||
"LabelNewPassword": "Nova zaporka",
|
||||
"LabelNewestAuthors": "Najnoviji autori",
|
||||
"LabelNewestEpisodes": "Najnovije epizode",
|
||||
"LabelNewestEpisodes": "Najnoviji nastavci",
|
||||
"LabelNextBackupDate": "Sljedeća izrada sigurnosne kopije",
|
||||
"LabelNextScheduledRun": "Sljedeće zakazano izvođenje",
|
||||
"LabelNoCustomMetadataProviders": "Nema prilagođenih pružatelja meta-podataka",
|
||||
@@ -678,7 +681,7 @@
|
||||
"LabelUploaderDropFiles": "Ispusti datoteke",
|
||||
"LabelUploaderItemFetchMetadataHelp": "Automatski dohvati naslov, autora i serijal",
|
||||
"LabelUseAdvancedOptions": "Koristi se naprednim opcijama",
|
||||
"LabelUseChapterTrack": "Koristi zvučni zapis poglavlja",
|
||||
"LabelUseChapterTrack": "Upravljaj trakom poglavlja",
|
||||
"LabelUseFullTrack": "Koristi cijeli zvučni zapis",
|
||||
"LabelUseZeroForUnlimited": "0 za neograničeno",
|
||||
"LabelUser": "Korisnik",
|
||||
@@ -845,6 +848,7 @@
|
||||
"MessageRestoreBackupConfirm": "Sigurno želite vratiti sigurnosnu kopiju izrađenu",
|
||||
"MessageRestoreBackupWarning": "Vraćanjem sigurnosne kopije prepisat ćete cijelu bazu podataka koja se nalazi u /config i slike naslovnice u /metadata/items i /metadata/authors.<br /><br />Sigurnosne kopije ne mijenjaju datoteke koje se nalaze u mapama vaših knjižnica. Ako ste u postavkama poslužitelja uključili mogućnost spremanja naslovnica i meta-podataka u mape knjižnice, te se datoteke neće niti sigurnosno pohraniti niti prepisati. <br /><br />Svi klijenti koji se spajaju na vaš poslužitelj automatski će se osvježiti.",
|
||||
"MessageScheduleLibraryScanNote": "Za većinu korisnika se preporučuje ostaviti ovu funkciju deaktiviranom i ostaviti postavku promatrača mape aktiviranom. Promatrač mapa će automatski otkriti promjene u mapama vaše knjižnice. Promatrač mapa ne radi na svakom datotečnom sustavu (kao što je NFS) pa se umjesto njega mogu koristiti planirana pretraživanja knjižnice.",
|
||||
"MessageScheduleRunEveryWeekdayAtTime": "Pokreni svaki {0} u {1}",
|
||||
"MessageSearchResultsFor": "Rezultati pretrage za",
|
||||
"MessageSelected": "{0} odabrano",
|
||||
"MessageServerCouldNotBeReached": "Nije moguće pristupiti poslužitelju",
|
||||
|
||||
@@ -219,6 +219,7 @@
|
||||
"LabelAccountTypeAdmin": "Amministratore",
|
||||
"LabelAccountTypeGuest": "Ospite",
|
||||
"LabelAccountTypeUser": "Utente",
|
||||
"LabelActivities": "Attività",
|
||||
"LabelActivity": "Attività",
|
||||
"LabelAddToCollection": "Aggiungi alla Raccolta",
|
||||
"LabelAddToCollectionBatch": "Aggiungi {0} Libri alla Raccolta",
|
||||
@@ -283,6 +284,7 @@
|
||||
"LabelContinueSeries": "Continua serie",
|
||||
"LabelCover": "Copertina",
|
||||
"LabelCoverImageURL": "Indirizzo della cover URL",
|
||||
"LabelCoverProvider": "Cover Provider",
|
||||
"LabelCreatedAt": "Creato A",
|
||||
"LabelCronExpression": "Espressione Cron",
|
||||
"LabelCurrent": "Attuale",
|
||||
@@ -391,6 +393,7 @@
|
||||
"LabelIntervalEvery6Hours": "Ogni 6 ore",
|
||||
"LabelIntervalEveryDay": "Ogni Giorno",
|
||||
"LabelIntervalEveryHour": "Ogni ora",
|
||||
"LabelIntervalEveryMinute": "Ogni minuto",
|
||||
"LabelInvert": "Inverti",
|
||||
"LabelItem": "Oggetti",
|
||||
"LabelJumpBackwardAmount": "secondi di avvolgimento",
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
"ButtonApplyChapters": "Zatwierdź rozdziały",
|
||||
"ButtonAuthors": "Autorzy",
|
||||
"ButtonBack": "Wstecz",
|
||||
"ButtonBatchEditPopulateFromExisting": "Powiel z poprzednich",
|
||||
"ButtonBatchEditPopulateMapDetails": "Powiel szczegóły mapy",
|
||||
"ButtonBrowseForFolder": "Wyszukaj folder",
|
||||
"ButtonCancel": "Anuluj",
|
||||
"ButtonCancelEncode": "Anuluj enkodowanie",
|
||||
@@ -31,6 +33,7 @@
|
||||
"ButtonEditPodcast": "Edytuj podcast",
|
||||
"ButtonEnable": "Włącz",
|
||||
"ButtonFireAndFail": "Fail start",
|
||||
"ButtonFireOnTest": "Uruchom po zdarzeniu testowym",
|
||||
"ButtonForceReScan": "Wymuś ponowne skanowanie",
|
||||
"ButtonFullPath": "Pełna ścieżka",
|
||||
"ButtonHide": "Ukryj",
|
||||
@@ -87,6 +90,8 @@
|
||||
"ButtonSaveTracklist": "Zapisz listę odtwarzania",
|
||||
"ButtonScan": "Zeskanuj",
|
||||
"ButtonScanLibrary": "Skanuj bibliotekę",
|
||||
"ButtonScrollLeft": "Przewiń w lewo",
|
||||
"ButtonScrollRight": "Przewiń w prawo",
|
||||
"ButtonSearch": "Szukaj",
|
||||
"ButtonSelectFolderPath": "Wybierz ścieżkę folderu",
|
||||
"ButtonSeries": "Seria",
|
||||
@@ -155,13 +160,14 @@
|
||||
"HeaderMapDetails": "Szczegóły mapowania",
|
||||
"HeaderMatch": "Dopasuj",
|
||||
"HeaderMetadataOrderOfPrecedence": "Kolejność metadanych",
|
||||
"HeaderMetadataToEmbed": "Osadź metadane",
|
||||
"HeaderMetadataToEmbed": "Metadane do osadzenia",
|
||||
"HeaderNewAccount": "Nowe konto",
|
||||
"HeaderNewLibrary": "Nowa biblioteka",
|
||||
"HeaderNotificationCreate": "Utwórz powiadomienie",
|
||||
"HeaderNotificationUpdate": "Zaktualizuj powiadomienie",
|
||||
"HeaderNotifications": "Powiadomienia",
|
||||
"HeaderOpenIDConnectAuthentication": "Uwierzytelnianie OpenID Connect",
|
||||
"HeaderOpenListeningSessions": "Otwarte sesje słuchania",
|
||||
"HeaderOpenRSSFeed": "Utwórz kanał RSS",
|
||||
"HeaderOtherFiles": "Inne pliki",
|
||||
"HeaderPasswordAuthentication": "Uwierzytelnianie hasłem",
|
||||
@@ -188,6 +194,7 @@
|
||||
"HeaderSettingsExperimental": "Funkcje eksperymentalne",
|
||||
"HeaderSettingsGeneral": "Ogólne",
|
||||
"HeaderSettingsScanner": "Skanowanie",
|
||||
"HeaderSettingsWebClient": "Klient webowy",
|
||||
"HeaderSleepTimer": "Wyłącznik czasowy",
|
||||
"HeaderStatsLargestItems": "Największe pozycje",
|
||||
"HeaderStatsLongestItems": "Najdłuższe pozycje (godziny)",
|
||||
@@ -438,7 +445,7 @@
|
||||
"LabelNotificationsMaxQueueSize": "Maksymalny rozmiar kolejki dla powiadomień",
|
||||
"LabelNotificationsMaxQueueSizeHelp": "Zdarzenia są ograniczone do 1 na sekundę. Zdarzenia będą ignorowane jeśli kolejka ma maksymalny rozmiar. Zapobiega to spamowaniu powiadomieniami.",
|
||||
"LabelNumberOfBooks": "Liczba książek",
|
||||
"LabelNumberOfEpisodes": "# odcinków",
|
||||
"LabelNumberOfEpisodes": "# Odcinków",
|
||||
"LabelOpenRSSFeed": "Otwórz kanał RSS",
|
||||
"LabelOverwrite": "Nadpisz",
|
||||
"LabelPassword": "Hasło",
|
||||
|
||||
1
client/strings/ro.json
Normal file
1
client/strings/ro.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -16,7 +16,7 @@
|
||||
"ButtonCancel": "Avbryt",
|
||||
"ButtonCancelEncode": "Avbryt omkodning",
|
||||
"ButtonChangeRootPassword": "Ändra lösenordet för root",
|
||||
"ButtonCheckAndDownloadNewEpisodes": "Sök & Ladda ner nya avsnitt",
|
||||
"ButtonCheckAndDownloadNewEpisodes": "Sök & Hämta nya avsnitt",
|
||||
"ButtonChooseAFolder": "Välj en mapp",
|
||||
"ButtonChooseFiles": "Välj filer",
|
||||
"ButtonClearFilter": "Rensa filter",
|
||||
@@ -75,8 +75,8 @@
|
||||
"ButtonRemove": "Ta bort",
|
||||
"ButtonRemoveAll": "Ta bort alla",
|
||||
"ButtonRemoveAllLibraryItems": "Ta bort alla objekt i biblioteket",
|
||||
"ButtonRemoveFromContinueListening": "Radera från 'Fortsätt lyssna'",
|
||||
"ButtonRemoveFromContinueReading": "Radera från 'Fortsätt läsa'",
|
||||
"ButtonRemoveFromContinueListening": "Radera från 'Fortsätt att lyssna'",
|
||||
"ButtonRemoveFromContinueReading": "Radera från 'Fortsätt att läsa'",
|
||||
"ButtonRemoveSeriesFromContinueSeries": "Radera från 'Fortsätt med serien'",
|
||||
"ButtonReset": "Tillbaka",
|
||||
"ButtonResetToDefault": "Återställ till standard",
|
||||
@@ -206,6 +206,7 @@
|
||||
"LabelAccountTypeAdmin": "Administratör",
|
||||
"LabelAccountTypeGuest": "Gäst",
|
||||
"LabelAccountTypeUser": "Användare",
|
||||
"LabelActivities": "Aktiviteter",
|
||||
"LabelActivity": "Aktivitet",
|
||||
"LabelAddToCollection": "Lägg till i en samling",
|
||||
"LabelAddToCollectionBatch": "Lägg till {0} böcker i samlingen",
|
||||
@@ -231,6 +232,7 @@
|
||||
"LabelAutoDownloadEpisodes": "Automatisk nedladdning av avsnitt",
|
||||
"LabelAutoFetchMetadata": "Automatisk nedladdning av metadata",
|
||||
"LabelAutoFetchMetadataHelp": "Hämtar metadata för titel, författare och serier. Kompletterande metadata kan manuellt adderas efter uppladdningen.",
|
||||
"LabelAutoLaunch": "Automatisk start",
|
||||
"LabelAutoRegisterDescription": "Skapa automatiskt nya användare efter inloggning",
|
||||
"LabelBackToUser": "Tillbaka till användaren",
|
||||
"LabelBackupAudioFiles": "Säkerhetskopiera ljudfiler",
|
||||
@@ -242,7 +244,7 @@
|
||||
"LabelBackupsNumberToKeep": "Antal säkerhetskopior att behålla",
|
||||
"LabelBackupsNumberToKeepHelp": "Endast en gammal säkerhetskopia tas bort åt gången, så om du redan har fler säkerhetskopior än det angivna värdet bör du ta bort dem manuellt.",
|
||||
"LabelBitrate": "Bitfrekvens",
|
||||
"LabelBonus": "Bonus",
|
||||
"LabelBonus": "Bonusavsnitt",
|
||||
"LabelBooks": "Böcker",
|
||||
"LabelButtonText": "Knapptext",
|
||||
"LabelByAuthor": "av {0}",
|
||||
@@ -266,6 +268,7 @@
|
||||
"LabelContinueSeries": "Fortsätt med serien",
|
||||
"LabelCover": "Omslag",
|
||||
"LabelCoverImageURL": "URL till omslagsbild",
|
||||
"LabelCoverProvider": "Källa för omslag",
|
||||
"LabelCreatedAt": "Skapad",
|
||||
"LabelCronExpression": "Schemaläggning med hjälp av Cron (Cron Expression)",
|
||||
"LabelCurrent": "Nuvarande",
|
||||
@@ -312,9 +315,11 @@
|
||||
"LabelEnd": "Slut",
|
||||
"LabelEndOfChapter": "Slut av kapitel",
|
||||
"LabelEpisode": "Avsnitt",
|
||||
"LabelEpisodeNotLinkedToRssFeed": "Avsnittet är inte knutet till ett RSS-flöde",
|
||||
"LabelEpisodeNumber": "Avsnitt #{0}",
|
||||
"LabelEpisodeTitle": "Titel på avsnittet",
|
||||
"LabelEpisodeType": "Typ av avsnitt",
|
||||
"LabelEpisodeUrlFromRssFeed": "URL-adress till avsnittet i RSS-flödet",
|
||||
"LabelEpisodes": "Avsnitt",
|
||||
"LabelEpisodic": "Uppdelad i avsnitt",
|
||||
"LabelExample": "Exempel",
|
||||
@@ -327,6 +332,7 @@
|
||||
"LabelFetchingMetadata": "Hämtar metadata",
|
||||
"LabelFile": "Fil",
|
||||
"LabelFileBirthtime": "Tidpunkt, fil skapad",
|
||||
"LabelFileBornDate": "Skapad {0}",
|
||||
"LabelFileModified": "Tidpunkt, fil ändrad",
|
||||
"LabelFileModifiedDate": "Ändrad {0}",
|
||||
"LabelFilename": "Filnamn",
|
||||
@@ -341,6 +347,7 @@
|
||||
"LabelFontItalic": "Kursiv",
|
||||
"LabelFontScale": "Skala på typsnitt",
|
||||
"LabelFontStrikethrough": "Genomstruken",
|
||||
"LabelFull": "Komplett",
|
||||
"LabelGenre": "Kategori",
|
||||
"LabelGenres": "Kategorier",
|
||||
"LabelHardDeleteFile": "Hård radering av fil",
|
||||
@@ -355,7 +362,7 @@
|
||||
"LabelImageURLFromTheWeb": "Skriv URL-adressen till bilden på webben",
|
||||
"LabelInProgress": "Pågående",
|
||||
"LabelIncludeInTracklist": "Inkludera i spårlista",
|
||||
"LabelIncomplete": "Ofullständig",
|
||||
"LabelIncomplete": "Ofullständigt",
|
||||
"LabelInterval": "Intervall",
|
||||
"LabelIntervalCustomDailyWeekly": "Anpassad daglig/veckovis",
|
||||
"LabelIntervalEvery12Hours": "Var 12:e timme",
|
||||
@@ -365,6 +372,7 @@
|
||||
"LabelIntervalEvery6Hours": "Var 6:e timme",
|
||||
"LabelIntervalEveryDay": "Varje dag",
|
||||
"LabelIntervalEveryHour": "Varje timme",
|
||||
"LabelIntervalEveryMinute": "Varje minut",
|
||||
"LabelInvert": "Invertera",
|
||||
"LabelItem": "Objekt",
|
||||
"LabelJumpBackwardAmount": "Inställning för \"hopp bakåt\"",
|
||||
@@ -416,7 +424,7 @@
|
||||
"LabelNew": "Nytt",
|
||||
"LabelNewPassword": "Nytt lösenord",
|
||||
"LabelNewestAuthors": "Senaste författarna",
|
||||
"LabelNewestEpisodes": "Senast adderade avsnitt",
|
||||
"LabelNewestEpisodes": "Senaste avsnitten",
|
||||
"LabelNextBackupDate": "Nästa tillfälle för säkerhetskopiering",
|
||||
"LabelNextScheduledRun": "Nästa schemalagda körning",
|
||||
"LabelNoCustomMetadataProviders": "Ingen egen källa för metadata",
|
||||
@@ -459,20 +467,22 @@
|
||||
"LabelPodcasts": "Podcasts",
|
||||
"LabelPort": "Port",
|
||||
"LabelPrefixesToIgnore": "Prefix att ignorera (skiftlägesokänsligt)",
|
||||
"LabelPreventIndexing": "Förhindra att ditt flöde indexeras av iTunes och Google-podcastsökmotorer",
|
||||
"LabelPreventIndexing": "Förhindra att ditt flöde indexeras av sökmotorer från iTunes och Google",
|
||||
"LabelPrimaryEbook": "Primär e-bok",
|
||||
"LabelProgress": "Framsteg",
|
||||
"LabelProvider": "Källa",
|
||||
"LabelPubDate": "Publiceringsdatum",
|
||||
"LabelPublishYear": "Publiceringsår",
|
||||
"LabelPublishedDate": "Publicerad {0}",
|
||||
"LabelPublishedDecade": "Årtionde för publicering",
|
||||
"LabelPublisher": "Utgivare",
|
||||
"LabelPublishers": "Utgivare",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Anpassad ägarens e-post",
|
||||
"LabelRSSFeedCustomOwnerName": "Anpassat ägarnamn",
|
||||
"LabelRSSFeedOpen": "Öppna RSS-flöde",
|
||||
"LabelRSSFeedPreventIndexing": "Förhindra indexering",
|
||||
"LabelRSSFeedSlug": "RSS-flödesslag",
|
||||
"LabelRSSFeedURL": "RSS-flöde URL",
|
||||
"LabelRSSFeedURL": "URL-adress för RSS-flödet",
|
||||
"LabelRandomly": "Slumpartat",
|
||||
"LabelRead": "Läst",
|
||||
"LabelReadAgain": "Läs igen",
|
||||
@@ -550,6 +560,7 @@
|
||||
"LabelSettingsStoreMetadataWithItemHelp": "Som standard lagras metadatafiler i mappen '/metadata/items'. Genom att aktivera detta alternativ kommer metadatafilerna att lagra i din biblioteksmapp",
|
||||
"LabelSettingsTimeFormat": "Tidsformat",
|
||||
"LabelShare": "Dela",
|
||||
"LabelShareURL": "Dela URL-länk",
|
||||
"LabelShowAll": "Visa alla",
|
||||
"LabelShowSeconds": "Visa sekunder",
|
||||
"LabelShowSubtitles": "Visa underrubriker",
|
||||
@@ -693,6 +704,7 @@
|
||||
"MessageConfirmPurgeCache": "När du rensar cashen kommer katalogen <code>/metadata/cache</code> att raderas. <br /><br />Är du säker på att du vill radera katalogen?",
|
||||
"MessageConfirmPurgeItemsCache": "När du rensar cashen för föremål kommer katalogen <code>/metadata/cache/items</code> att raderas. <br /><br />Är du säker på att du vill radera katalogen?",
|
||||
"MessageConfirmQuickEmbed": "VARNING! Quick embed kommer inte att säkerhetskopiera dina ljudfiler. Se till att du har en säkerhetskopia av dina ljudfiler. <br><br>Vill du fortsätta?",
|
||||
"MessageConfirmQuickMatchEpisodes": "Snabbmatchning av avsnitt kommer att ersätta befintlig information vid en träff. Endast omatchade avsnitt kommer att uppdateras. Vill du fortsätta?",
|
||||
"MessageConfirmReScanLibraryItems": "Är du säker på att du vill göra en ny skanning för {0} objekt?",
|
||||
"MessageConfirmRemoveAllChapters": "Är du säker på att du vill ta bort alla kapitel?",
|
||||
"MessageConfirmRemoveAuthor": "Är du säker på att du vill ta bort författaren \"{0}\"?",
|
||||
@@ -705,7 +717,7 @@
|
||||
"MessageConfirmRemovePlaylist": "Är du säker på att du vill ta bort din spellista \"{0}\"?",
|
||||
"MessageConfirmRenameGenre": "Är du säker på att du vill byta namn på kategorin \"{0}\" till \"{1}\" för alla objekt?",
|
||||
"MessageConfirmRenameGenreMergeNote": "OBS: Den här kategorin finns redan, så de kommer att slås samman.",
|
||||
"MessageConfirmRenameGenreWarning": "Varning! En liknande kategori med annat skrivsätt finns redan \"{0}\".",
|
||||
"MessageConfirmRenameGenreWarning": "VARNING! En liknande kategori med annat skrivsätt finns redan \"{0}\".",
|
||||
"MessageConfirmRenameTag": "Är du säker på att du vill byta namn på taggen \"{0}\" till \"{1}\" för alla objekt?",
|
||||
"MessageConfirmRenameTagMergeNote": "OBS: Den här taggen finns redan, så de kommer att slås samman.",
|
||||
"MessageConfirmRenameTagWarning": "VARNING! En liknande tagg med annat skrivsätt finns redan \"{0}\".",
|
||||
@@ -735,7 +747,7 @@
|
||||
"MessageMarkAllEpisodesNotFinished": "Markera alla avsnitt som ej avslutade",
|
||||
"MessageMarkAsFinished": "Markera som avslutad",
|
||||
"MessageMarkAsNotFinished": "Markera som ej avslutad",
|
||||
"MessageMatchBooksDescription": "kommer att försöka matcha böcker i biblioteket med en bok från<br/>den valda källan och fylla i uppgifter som saknas och omslag.<br/>Inga befintliga uppgifter kommer att ersättas.",
|
||||
"MessageMatchBooksDescription": "kommer att försöka matcha böcker i biblioteket med en bok från den valda källan och fylla i uppgifter som saknas och omslag. Inga befintliga uppgifter kommer att ersättas.",
|
||||
"MessageNoAudioTracks": "Inga ljudspår har hittats",
|
||||
"MessageNoAuthors": "Inga författare",
|
||||
"MessageNoBackups": "Inga säkerhetskopior",
|
||||
@@ -786,23 +798,32 @@
|
||||
"MessageRestoreBackupConfirm": "Är du säker på att du vill läsa in säkerhetskopian som skapades den",
|
||||
"MessageRestoreBackupWarning": "Att återställa en säkerhetskopia kommer att skriva över hela databasen som finns i /config och omslagsbilder i /metadata/items & /metadata/authors.<br /><br />Säkerhetskopior ändrar inte några filer i dina biblioteksmappar. Om du har aktiverat serverinställningar för att lagra omslagskonst och metadata i dina biblioteksmappar säkerhetskopieras eller skrivs de inte över.<br /><br />Alla klienter som använder din server kommer att uppdateras automatiskt.",
|
||||
"MessageScheduleLibraryScanNote": "För de flesta användare rekommenderas att denna funktion ej aktiveras. Istället bör funktionen 'Watcher' vara aktiverad. Watcher kommer då automatiskt identifiera förändringar i biblioteket. För vissa filsystem (som t.ex. NFS) fungerar inte Watcher. Då kan schemalagda skanningar av biblioteken användas istället.",
|
||||
"MessageScheduleRunEveryWeekdayAtTime": "Startar varje {0} klockan {1}",
|
||||
"MessageSearchResultsFor": "Sökresultat för",
|
||||
"MessageSelected": "{0} valda",
|
||||
"MessageServerCouldNotBeReached": "Servern kunde inte nås",
|
||||
"MessageSetChaptersFromTracksDescription": "Ställ in kapitel med varje ljudfil som ett kapitel och kapitelrubrik som ljudfilens namn",
|
||||
"MessageStartPlaybackAtTime": "Starta uppspelning av \"{0}\" vid tidpunkt {1}?",
|
||||
"MessageTaskAudioFileNotWritable": "Det går inte att skriva till ljudfilen \"{0}\"",
|
||||
"MessageTaskCanceledByUser": "Uppgiften avslutades av användaren",
|
||||
"MessageTaskDownloadingEpisodeDescription": "Laddar ner avsnitt \"{0}\"",
|
||||
"MessageTaskEmbeddingMetadata": "Infogar metadata",
|
||||
"MessageTaskEmbeddingMetadataDescription": "Infogar metadata i ljudboken \"{0}\"",
|
||||
"MessageTaskEncodingM4bDescription": "Omkodning av ljudbok \"{0}\" till en M4B-fil",
|
||||
"MessageTaskFailed": "Misslyckades",
|
||||
"MessageTaskFailedToBackupAudioFile": "Misslyckades med att göra backup på ljudfil \"{0}\"",
|
||||
"MessageTaskFailedToCreateCacheDirectory": "Misslyckades med att skapa bibliotek för cachen",
|
||||
"MessageTaskFailedToEmbedMetadataInFile": "Misslyckades med att infoga metadata i \"{0}\"",
|
||||
"MessageTaskFailedToMergeAudioFiles": "Misslyckades med att sammanfoga ljudfilerna",
|
||||
"MessageTaskFailedToMoveM4bFile": "Misslyckades med att flytta M4B-filen",
|
||||
"MessageTaskFailedToWriteMetadataFile": "Misslyckades med att skapa filen med metadata",
|
||||
"MessageTaskMatchingBooksInLibrary": "Matchar böcker i biblioteket \"{0}\"",
|
||||
"MessageTaskNoFilesToScan": "Inga filer finns tillgängliga för skanning",
|
||||
"MessageTaskOpmlImportDescription": "Skapar podcasts från {0} RSS-flöden",
|
||||
"MessageTaskOpmlImportFeedDescription": "Importerar RSS-flödet \"{0}\"",
|
||||
"MessageTaskOpmlImportFeedPodcastDescription": "Skapar podcast \"{0}\"",
|
||||
"MessageTaskOpmlImportFeedPodcastExists": "En podcast finns redan med den adressen",
|
||||
"MessageTaskOpmlImportFeedPodcastFailed": "Misslyckades med att skapa podcast",
|
||||
"MessageTaskOpmlImportFinished": "Adderade {0} podcasts",
|
||||
"MessageTaskOpmlParseFailed": "Misslyckades att tolka OPML-filen",
|
||||
"MessageTaskOpmlParseFastFail": "Felaktig OPML-fil. Ingen <opml> tag eller <outline> tag finns i filen",
|
||||
@@ -812,9 +833,10 @@
|
||||
"MessageTaskScanItemsUpdated": "{0} uppdaterades",
|
||||
"MessageTaskScanNoChangesNeeded": "Inget adderades eller uppdaterades",
|
||||
"MessageTaskScanningLibrary": "Biblioteket \"{0}\" har skannats",
|
||||
"MessageTaskTargetDirectoryNotWritable": "Det är inte tillåtet att skriva i den angivna katalogen",
|
||||
"MessageThinking": "Tänker...",
|
||||
"MessageUploaderItemFailed": "Misslyckades med att ladda upp",
|
||||
"MessageUploaderItemSuccess": "Uppladdning lyckades!",
|
||||
"MessageUploaderItemSuccess": "har blivit uppladdad!",
|
||||
"MessageUploading": "Laddar upp...",
|
||||
"MessageValidCronExpression": "Giltigt cron-uttryck",
|
||||
"MessageWatcherIsDisabledGlobally": "Watcher är inaktiverad centralt under rubriken 'Inställningar'",
|
||||
@@ -829,6 +851,9 @@
|
||||
"NoteUploaderFoldersWithMediaFiles": "Mappar som innehåller mediefiler hanteras som separata objekt i biblioteket.",
|
||||
"NoteUploaderOnlyAudioFiles": "Om du bara laddar upp ljudfiler kommer varje ljudfil att hanteras som en separat ljudbok.",
|
||||
"NoteUploaderUnsupportedFiles": "Oaccepterade filer ignoreras. När du väljer eller släpper en mapp ignoreras andra filer som inte finns i ett objektmapp.",
|
||||
"NotificationOnBackupCompletedDescription": "Aktiveras när en backup är genomförd",
|
||||
"NotificationOnBackupFailedDescription": "Aktiveras när en backup misslyckas",
|
||||
"NotificationOnEpisodeDownloadedDescription": "Aktiveras när avsnitt i en podcast automatiskt har hämtats",
|
||||
"PlaceholderNewCollection": "Nytt namn på samlingen",
|
||||
"PlaceholderNewFolderPath": "Nytt sökväg till mappen",
|
||||
"PlaceholderNewPlaylist": "Nytt namn på spellistan",
|
||||
@@ -870,6 +895,7 @@
|
||||
"ToastBackupRestoreFailed": "Det gick inte att återställa säkerhetskopian",
|
||||
"ToastBackupUploadFailed": "Det gick inte att ladda upp säkerhetskopian",
|
||||
"ToastBackupUploadSuccess": "Säkerhetskopian uppladdad",
|
||||
"ToastBatchQuickMatchStarted": "Snabbmatchning av {0} böcker har påbörjats!",
|
||||
"ToastBatchUpdateFailed": "Batchuppdateringen misslyckades",
|
||||
"ToastBatchUpdateSuccess": "Batchuppdateringen lyckades",
|
||||
"ToastBookmarkCreateFailed": "Det gick inte att skapa bokmärket",
|
||||
@@ -888,9 +914,12 @@
|
||||
"ToastDateTimeInvalidOrIncomplete": "Datum och klockslag är felaktigt eller ej komplett",
|
||||
"ToastDeleteFileFailed": "Misslyckades att radera filen",
|
||||
"ToastDeleteFileSuccess": "Filen har raderats",
|
||||
"ToastDeviceAddFailed": "Misslyckades med att addera enheten",
|
||||
"ToastDeviceNameAlreadyExists": "En enhet för att läsa e-böcker med det namnet finns redan",
|
||||
"ToastDeviceTestEmailFailed": "Misslyckades med att skicka ett testmail",
|
||||
"ToastDeviceTestEmailSuccess": "Ett testmail har skickats",
|
||||
"ToastEmailSettingsUpdateSuccess": "Inställningarna av e-post har uppdaterats",
|
||||
"ToastEncodeCancelFailed": "Misslyckades med att avbryta omkodningen",
|
||||
"ToastEncodeCancelSucces": "Omkodningen avbruten",
|
||||
"ToastEpisodeDownloadQueueClearFailed": "Misslyckades med att tömma kön",
|
||||
"ToastEpisodeDownloadQueueClearSuccess": "Kö för nedladdning av avsnitt har tömts",
|
||||
@@ -931,6 +960,7 @@
|
||||
"ToastNewUserTagError": "Minst en tagg måste läggas till",
|
||||
"ToastNewUserUsernameError": "Ange ett användarnamn",
|
||||
"ToastNoNewEpisodesFound": "Inga nya avsnitt kunde hittas",
|
||||
"ToastNoRSSFeed": "Denna podcast har ingen RSS-flöde",
|
||||
"ToastNoUpdatesNecessary": "Inga uppdateringar var nödvändiga",
|
||||
"ToastNotificationCreateFailed": "Misslyckades med att skapa meddelandet",
|
||||
"ToastNotificationDeleteFailed": "Misslyckades med att radera meddelandet",
|
||||
@@ -942,6 +972,7 @@
|
||||
"ToastPodcastCreateFailed": "Misslyckades med att skapa podcasten",
|
||||
"ToastPodcastCreateSuccess": "Podcasten skapades framgångsrikt",
|
||||
"ToastPodcastNoEpisodesInFeed": "Inga avsnitt finns i RSS-flödet",
|
||||
"ToastPodcastNoRssFeed": "Denna podcast har ingen RSS-flöde",
|
||||
"ToastProviderCreatedFailed": "Misslyckades med att addera en källa",
|
||||
"ToastProviderCreatedSuccess": "En ny källa har adderats",
|
||||
"ToastProviderNameAndUrlRequired": "Ett namn och en URL-adress krävs",
|
||||
|
||||
@@ -1 +1,209 @@
|
||||
{}
|
||||
{
|
||||
"ButtonAdd": "Ekle",
|
||||
"ButtonAddChapters": "Bölüm Ekle",
|
||||
"ButtonAddDevice": "Cihaz Ekle",
|
||||
"ButtonAddLibrary": "Kütüphane Ekle",
|
||||
"ButtonAddPodcasts": "Podcast Ekle",
|
||||
"ButtonAddUser": "Kullanıcı Ekle",
|
||||
"ButtonAddYourFirstLibrary": "İlk kütüphaneni ekle",
|
||||
"ButtonApply": "Uygula",
|
||||
"ButtonApplyChapters": "Bölümleri Uygula",
|
||||
"ButtonAuthors": "Yazarlar",
|
||||
"ButtonBack": "Geri",
|
||||
"ButtonBatchEditPopulateFromExisting": "Mevcut olandan çoğalt",
|
||||
"ButtonBatchEditPopulateMapDetails": "Harita detaylarını çoğalt",
|
||||
"ButtonBrowseForFolder": "Klasör için göz at",
|
||||
"ButtonCancel": "İptal",
|
||||
"ButtonCancelEncode": "Kodlamayı Durdur",
|
||||
"ButtonChangeRootPassword": "Root Şifresini Değiştir",
|
||||
"ButtonCheckAndDownloadNewEpisodes": "Yeni Bölümleri Kontrol Et & İndir",
|
||||
"ButtonChooseAFolder": "Klasör seç",
|
||||
"ButtonChooseFiles": "Dosya seç",
|
||||
"ButtonClearFilter": "Filtreyi Temizle",
|
||||
"ButtonCloseFeed": "Akışı Kapat",
|
||||
"ButtonCloseSession": "Acık Oturumu Kapat",
|
||||
"ButtonCollections": "Koleksiyonlar",
|
||||
"ButtonConfigureScanner": "Tarayıcı Ayarları",
|
||||
"ButtonCreate": "Oluştur",
|
||||
"ButtonCreateBackup": "Yedek Oluştur",
|
||||
"ButtonDelete": "Sil",
|
||||
"ButtonDownloadQueue": "Sıra",
|
||||
"ButtonEdit": "Düzenle",
|
||||
"ButtonEditChapters": "Bölümleri Düzenle",
|
||||
"ButtonEditPodcast": "Podcast Düzenle",
|
||||
"ButtonEnable": "Etkinleştir",
|
||||
"ButtonFireAndFail": "Gönder ve hata al",
|
||||
"ButtonFireOnTest": "onTest Gönder",
|
||||
"ButtonForceReScan": "Zorla Yeniden Tara",
|
||||
"ButtonFullPath": "Tam Dosya Yolu",
|
||||
"ButtonHide": "Gizle",
|
||||
"ButtonHome": "Ana sayfa",
|
||||
"ButtonIssues": "Sorunlar",
|
||||
"ButtonJumpBackward": "Geri Sar",
|
||||
"ButtonJumpForward": "İleri Sar",
|
||||
"ButtonLatest": "En yeni",
|
||||
"ButtonLibrary": "Kütüphane",
|
||||
"ButtonLogout": "Çıkış Yap",
|
||||
"ButtonLookup": "Sorgula",
|
||||
"ButtonManageTracks": "Parçaları Yönet",
|
||||
"ButtonMapChapterTitles": "Bölüm Başlıklarını Haritalandır",
|
||||
"ButtonNevermind": "Vazgeç",
|
||||
"ButtonNext": "Sonraki",
|
||||
"ButtonNextChapter": "Sonraki Bölüm",
|
||||
"ButtonNextItemInQueue": "Sıradaki Sonraki Öğe",
|
||||
"ButtonOk": "Tamam",
|
||||
"ButtonOpenFeed": "Akışı Aç",
|
||||
"ButtonOpenManager": "Yöneticiyi Aç",
|
||||
"ButtonPause": "Durdur",
|
||||
"ButtonPlay": "Oynat",
|
||||
"ButtonPlayAll": "Hepsini Oynat",
|
||||
"ButtonPlaying": "Oynatılıyor",
|
||||
"ButtonPlaylists": "Oynatma listeleri",
|
||||
"ButtonPrevious": "Önceki",
|
||||
"ButtonPreviousChapter": "Önceki Bölüm",
|
||||
"ButtonProbeAudioFile": "Ses Dosyasını Yokla",
|
||||
"ButtonPurgeAllCache": "Bütün Önbelleği Temizle",
|
||||
"ButtonPurgeItemsCache": "Öğenin Önbelleğini Temizle",
|
||||
"ButtonQueueAddItem": "Sıraya ekle",
|
||||
"ButtonQueueRemoveItem": "Sıradan çıkar",
|
||||
"ButtonReScan": "Yeniden Tara",
|
||||
"ButtonRead": "Oku",
|
||||
"ButtonReadLess": "Daha az göster",
|
||||
"ButtonReadMore": "Daha fazla göster",
|
||||
"ButtonRefresh": "Yenile",
|
||||
"ButtonRemove": "Kaldır",
|
||||
"ButtonRemoveAll": "Hepsini Sil",
|
||||
"ButtonRemoveAllLibraryItems": "Bütün Kütüphane Öğelerini Sil",
|
||||
"ButtonSave": "Kaydet",
|
||||
"ButtonSearch": "Ara",
|
||||
"ButtonSeries": "Dizi",
|
||||
"ButtonSubmit": "Gönder",
|
||||
"ButtonYes": "Evet",
|
||||
"HeaderAccount": "Hesap",
|
||||
"HeaderAdvanced": "Gelişmiş",
|
||||
"HeaderAudioTracks": "Ses Kanalları",
|
||||
"HeaderChapters": "Bölümler",
|
||||
"HeaderCollection": "Koleksiyon",
|
||||
"HeaderCollectionItems": "Koleksiyon Öğeleri",
|
||||
"HeaderDetails": "Detaylar",
|
||||
"HeaderEbookFiles": "Ebook Dosyaları",
|
||||
"HeaderEpisodes": "Bölümler",
|
||||
"HeaderEreaderSettings": "Ereader Ayarları",
|
||||
"HeaderLatestEpisodes": "En son bölümler",
|
||||
"HeaderLibraries": "Kütüphaneler",
|
||||
"HeaderOpenRSSFeed": "RSS Akışını Aç",
|
||||
"HeaderPlaylist": "Oynatma listesi",
|
||||
"HeaderPlaylistItems": "Oynatma Listesi Öğeleri",
|
||||
"HeaderRSSFeedGeneral": "RSS Detayları",
|
||||
"HeaderRSSFeedIsOpen": "RSS Akışı Açık",
|
||||
"HeaderSettings": "Ayarlar",
|
||||
"HeaderSleepTimer": "Uyku Zamanlayıcısı",
|
||||
"HeaderStatsMinutesListeningChart": "Dinlenilen Dakika (son 7 gün)",
|
||||
"HeaderStatsRecentSessions": "Geçmiş Oturumlar",
|
||||
"HeaderTableOfContents": "İçindekiler",
|
||||
"HeaderYourStats": "İstatistiklerin",
|
||||
"LabelAddToPlaylist": "Oynatma Listesine Ekle",
|
||||
"LabelAddedAt": "Eklenme Zamanı",
|
||||
"LabelAddedDate": "Eklendi {0}",
|
||||
"LabelAll": "Hepsi",
|
||||
"LabelAuthor": "Yazar",
|
||||
"LabelAuthorFirstLast": "Yazar (İlk Son)",
|
||||
"LabelAuthorLastFirst": "Yazar (Son, İlk)",
|
||||
"LabelAuthors": "Yazarlar",
|
||||
"LabelAutoDownloadEpisodes": "Bölümleri Otomatik Olarak İndir",
|
||||
"LabelBooks": "Kitaplar",
|
||||
"LabelChapters": "Bölümler",
|
||||
"LabelClosePlayer": "Oynatıcıyı kapat",
|
||||
"LabelCollapseSeries": "Seriyi Daralt",
|
||||
"LabelComplete": "Tamamlandı",
|
||||
"LabelContinueListening": "Dinlemeye Devam Et",
|
||||
"LabelContinueReading": "Okumaya Devam Et",
|
||||
"LabelContinueSeries": "Seriye Devam Et",
|
||||
"LabelDescription": "Açıklama",
|
||||
"LabelDiscover": "Keşfet",
|
||||
"LabelDownload": "İndir",
|
||||
"LabelDuration": "Süre",
|
||||
"LabelEbook": "Ekitap",
|
||||
"LabelEbooks": "Ekitaplar",
|
||||
"LabelEnable": "Etkinleştir",
|
||||
"LabelEnd": "Son",
|
||||
"LabelEndOfChapter": "Bölüm Sonu",
|
||||
"LabelEpisode": "Bölüm",
|
||||
"LabelFeedURL": "Akış URLsi",
|
||||
"LabelFile": "Dosya",
|
||||
"LabelFileBirthtime": "Dosya Oluşum Zamanı",
|
||||
"LabelFileModified": "Dosya Düzenlendi",
|
||||
"LabelFilename": "Dosya İsmi",
|
||||
"LabelFinished": "Tamamlandı",
|
||||
"LabelFolder": "Klasör",
|
||||
"LabelFontBoldness": "Font Kalınlığı",
|
||||
"LabelFontScale": "Font büyüklüğü",
|
||||
"LabelGenre": "Tür",
|
||||
"LabelGenres": "Türler",
|
||||
"LabelHasEbook": "Ekitabı var",
|
||||
"LabelHasSupplementaryEbook": "İlave ekitabı var",
|
||||
"LabelHost": "Sunucu",
|
||||
"LabelInProgress": "İlerleme Halinde",
|
||||
"LabelIncomplete": "Tamamlanmamış",
|
||||
"LabelLanguage": "Dil",
|
||||
"LabelLayout": "Düzen",
|
||||
"LabelLayoutSinglePage": "Tek sayfa",
|
||||
"LabelLineSpacing": "Satır aralığı",
|
||||
"LabelListenAgain": "Tekrar Dinle",
|
||||
"LabelMediaType": "Medya Türü",
|
||||
"LabelMissing": "Kayıp",
|
||||
"LabelMore": "Daha fazla",
|
||||
"LabelMoreInfo": "Daha fazla bilgi",
|
||||
"LabelName": "İsim",
|
||||
"LabelNarrator": "Anlatıcı",
|
||||
"LabelNarrators": "Anlatıcılar",
|
||||
"LabelNewestAuthors": "En Yeni Yazarlar",
|
||||
"LabelNewestEpisodes": "En Yeni Bölümler",
|
||||
"LabelNotFinished": "Tamamlanmadı",
|
||||
"LabelNotStarted": "Başlanmadı",
|
||||
"LabelNumberOfEpisodes": "Bölüm Sayısı",
|
||||
"LabelPassword": "Şifre",
|
||||
"LabelPath": "Yol",
|
||||
"LabelPodcast": "Podcast",
|
||||
"LabelPodcasts": "Podcastler",
|
||||
"LabelPreventIndexing": "Akışınızın iTunes ve Google podcast dizinleri tarafından dizinlenmesini önleyin",
|
||||
"LabelProgress": "İlerleme",
|
||||
"LabelPubDate": "Yay. Tarihi",
|
||||
"LabelPublishYear": "Yayım Yılı",
|
||||
"LabelPublishedDate": "Yayımlandı {0}",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Özelleştirilmiş sahip Emaili",
|
||||
"LabelRSSFeedCustomOwnerName": "Özelleştirilmis sahip İsmi",
|
||||
"LabelRSSFeedPreventIndexing": "Dizinlemeyi Önle",
|
||||
"LabelRandomly": "Rastgele",
|
||||
"LabelRead": "Oku",
|
||||
"LabelReadAgain": "Tekrar Oku",
|
||||
"LabelRecentlyAdded": "Yakınlarda Eklenmiş",
|
||||
"LabelSeason": "Sezon",
|
||||
"LabelSetEbookAsPrimary": "Birincil olarak ayarla",
|
||||
"LabelSetEbookAsSupplementary": "Yedek olarak ayarla",
|
||||
"LabelShowAll": "Hepsini Göster",
|
||||
"LabelSize": "Boyut",
|
||||
"LabelSleepTimer": "Uyku Zamanlayıcısı",
|
||||
"LabelStart": "Başla",
|
||||
"LabelStatsBestDay": "En İyi Gün",
|
||||
"LabelStatsDailyAverage": "Günlük Ortalama",
|
||||
"LabelStatsDays": "Günler",
|
||||
"LabelStatsDaysListened": "Dinlenen Günler",
|
||||
"LabelStatsInARow": "art arda",
|
||||
"LabelStatsItemsFinished": "Bitirilen Öğeler",
|
||||
"LabelStatsMinutes": "dakika",
|
||||
"LabelStatsMinutesListening": "Dinlenen Dakika",
|
||||
"LabelTag": "Etiket",
|
||||
"LabelTags": "Etiketler",
|
||||
"LabelTheme": "Tema",
|
||||
"LabelThemeDark": "Koyu",
|
||||
"LabelThemeLight": "Açık",
|
||||
"LabelTimeRemaining": "{0} kalan",
|
||||
"LabelTitle": "Başlık",
|
||||
"LabelTracks": "Parçalar",
|
||||
"LabelType": "Tür",
|
||||
"LabelUnknown": "Bilinmeyen",
|
||||
"LabelUser": "Kullanıcı",
|
||||
"LabelUsername": "Kullanıcı Adı",
|
||||
"LabelYourBookmarks": "Yer İşaretleriniz"
|
||||
}
|
||||
|
||||
@@ -219,6 +219,7 @@
|
||||
"LabelAccountTypeAdmin": "Адміністратор",
|
||||
"LabelAccountTypeGuest": "Гість",
|
||||
"LabelAccountTypeUser": "Користувач",
|
||||
"LabelActivities": "Діяльність",
|
||||
"LabelActivity": "Активність",
|
||||
"LabelAddToCollection": "Додати у добірку",
|
||||
"LabelAddToCollectionBatch": "Додати книги до добірки: {0}",
|
||||
@@ -283,6 +284,7 @@
|
||||
"LabelContinueSeries": "Продовжити серії",
|
||||
"LabelCover": "Обкладинка",
|
||||
"LabelCoverImageURL": "URL-адреса обкладинки",
|
||||
"LabelCoverProvider": "Постачальник покриття",
|
||||
"LabelCreatedAt": "Дата створення",
|
||||
"LabelCronExpression": "Команда cron",
|
||||
"LabelCurrent": "Поточне",
|
||||
@@ -391,6 +393,7 @@
|
||||
"LabelIntervalEvery6Hours": "Кожні 6 годин",
|
||||
"LabelIntervalEveryDay": "Щодня",
|
||||
"LabelIntervalEveryHour": "Щогодини",
|
||||
"LabelIntervalEveryMinute": "Кожну хвилину",
|
||||
"LabelInvert": "Інвертувати",
|
||||
"LabelItem": "Елемент",
|
||||
"LabelJumpBackwardAmount": "Час переходу назад",
|
||||
@@ -845,6 +848,7 @@
|
||||
"MessageRestoreBackupConfirm": "Ви дійсно бажаєте відновити резервну копію від",
|
||||
"MessageRestoreBackupWarning": "Відновлення резервної копії перезапише всю базу даних, розташовану в /config, і зображення обкладинок в /metadata/items та /metadata/authors.<br /><br />Резервні копії не змінюють жодних файлів у теках бібліотеки. Якщо у налаштуваннях сервера увімкнено збереження обкладинок і метаданих у теках бібліотеки, вони не створюються під час резервного копіювання і не перезаписуються..<br /><br />Всі клієнти, що користуються вашим сервером, будуть автоматично оновлені.",
|
||||
"MessageScheduleLibraryScanNote": "Для більшості користувачів рекомендується залишити цю функцію вимкненою та залишити параметр перегляду папок увімкненим. Засіб спостереження за папками автоматично виявить зміни в папках вашої бібліотеки. Засіб спостереження за папками не працює для кожної файлової системи (наприклад, NFS), тому замість нього можна використовувати сканування бібліотек за розкладом.",
|
||||
"MessageScheduleRunEveryWeekdayAtTime": "Запуск кожні {0} о {1}",
|
||||
"MessageSearchResultsFor": "Результати пошуку для",
|
||||
"MessageSelected": "Вибрано: {0}",
|
||||
"MessageServerCouldNotBeReached": "Не вдалося підключитися до сервера",
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.19.2",
|
||||
"version": "2.19.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.19.2",
|
||||
"version": "2.19.5",
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"axios": "^0.27.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.19.2",
|
||||
"version": "2.19.5",
|
||||
"buildNumber": 1,
|
||||
"description": "Self-hosted audiobook and podcast server",
|
||||
"main": "index.js",
|
||||
|
||||
@@ -190,7 +190,13 @@ class Database {
|
||||
await this.buildModels(force)
|
||||
Logger.info(`[Database] Db initialized with models:`, Object.keys(this.sequelize.models).join(', '))
|
||||
|
||||
await this.addTriggers()
|
||||
|
||||
await this.loadData()
|
||||
|
||||
Logger.info(`[Database] running ANALYZE`)
|
||||
await this.sequelize.query('ANALYZE')
|
||||
Logger.info(`[Database] ANALYZE completed`)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -767,6 +773,43 @@ class Database {
|
||||
return textQuery
|
||||
}
|
||||
|
||||
/**
|
||||
* This is used to create necessary triggers for new databases.
|
||||
* It adds triggers to update libraryItems.title[IgnorePrefix] when (books|podcasts).title[IgnorePrefix] is updated
|
||||
*/
|
||||
async addTriggers() {
|
||||
await this.addTriggerIfNotExists('books', 'title', 'id', 'libraryItems', 'title', 'mediaId')
|
||||
await this.addTriggerIfNotExists('books', 'titleIgnorePrefix', 'id', 'libraryItems', 'titleIgnorePrefix', 'mediaId')
|
||||
await this.addTriggerIfNotExists('podcasts', 'title', 'id', 'libraryItems', 'title', 'mediaId')
|
||||
await this.addTriggerIfNotExists('podcasts', 'titleIgnorePrefix', 'id', 'libraryItems', 'titleIgnorePrefix', 'mediaId')
|
||||
}
|
||||
|
||||
async addTriggerIfNotExists(sourceTable, sourceColumn, sourceIdColumn, targetTable, targetColumn, targetIdColumn) {
|
||||
const action = `update_${targetTable}_${targetColumn}`
|
||||
const fromSource = sourceTable === 'books' ? '' : `_from_${sourceTable}_${sourceColumn}`
|
||||
const triggerName = this.convertToSnakeCase(`${action}${fromSource}`)
|
||||
|
||||
const [[{ count }]] = await this.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='${triggerName}'`)
|
||||
if (count > 0) return // Trigger already exists
|
||||
|
||||
Logger.info(`[Database] Adding trigger ${triggerName}`)
|
||||
|
||||
await this.sequelize.query(`
|
||||
CREATE TRIGGER ${triggerName}
|
||||
AFTER UPDATE OF ${sourceColumn} ON ${sourceTable}
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE ${targetTable}
|
||||
SET ${targetColumn} = NEW.${sourceColumn}
|
||||
WHERE ${targetTable}.${targetIdColumn} = NEW.${sourceIdColumn};
|
||||
END;
|
||||
`)
|
||||
}
|
||||
|
||||
convertToSnakeCase(str) {
|
||||
return str.replace(/([A-Z])/g, '_$1').toLowerCase()
|
||||
}
|
||||
|
||||
TextSearchQuery = class {
|
||||
constructor(sequelize, supportsUnaccent, query) {
|
||||
this.sequelize = sequelize
|
||||
|
||||
@@ -5,7 +5,7 @@ const Logger = require('./Logger')
|
||||
const Task = require('./objects/Task')
|
||||
const TaskManager = require('./managers/TaskManager')
|
||||
|
||||
const { filePathToPOSIX, isSameOrSubPath, getFileMTimeMs } = require('./utils/fileUtils')
|
||||
const { filePathToPOSIX, isSameOrSubPath, getFileMTimeMs, shouldIgnoreFile } = require('./utils/fileUtils')
|
||||
|
||||
/**
|
||||
* @typedef PendingFileUpdate
|
||||
@@ -286,15 +286,10 @@ class FolderWatcher extends EventEmitter {
|
||||
|
||||
const relPath = path.replace(folderPath, '')
|
||||
|
||||
if (Path.extname(relPath).toLowerCase() === '.part') {
|
||||
Logger.debug(`[Watcher] Ignoring .part file "${relPath}"`)
|
||||
return false
|
||||
}
|
||||
|
||||
// Ignore files/folders starting with "."
|
||||
const hasDotPath = relPath.split('/').find((p) => p.startsWith('.'))
|
||||
if (hasDotPath) {
|
||||
Logger.debug(`[Watcher] Ignoring dot path "${relPath}" | Piece "${hasDotPath}"`)
|
||||
// Check for ignored extensions or directories, such as dotfiles and hidden directories
|
||||
const shouldIgnore = shouldIgnoreFile(relPath)
|
||||
if (shouldIgnore) {
|
||||
Logger.debug(`[Watcher] Ignoring ${shouldIgnore} - "${relPath}"`)
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -254,6 +254,11 @@ class LibraryController {
|
||||
* @param {Response} res
|
||||
*/
|
||||
async update(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[LibraryController] Non-admin user "${req.user.username}" attempted to update library`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
// Validation
|
||||
const updatePayload = {}
|
||||
const keysToCheck = ['name', 'provider', 'mediaType', 'icon']
|
||||
@@ -519,6 +524,11 @@ class LibraryController {
|
||||
* @param {Response} res
|
||||
*/
|
||||
async delete(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[LibraryController] Non-admin user "${req.user.username}" attempted to delete library`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
// Remove library watcher
|
||||
Watcher.removeLibrary(req.library)
|
||||
|
||||
@@ -639,6 +649,11 @@ class LibraryController {
|
||||
* @param {Response} res
|
||||
*/
|
||||
async removeLibraryItemsWithIssues(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[LibraryController] Non-admin user "${req.user.username}" attempted to delete library items missing or invalid`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
const libraryItemsWithIssues = await Database.libraryItemModel.findAll({
|
||||
where: {
|
||||
libraryId: req.library.id,
|
||||
|
||||
@@ -107,7 +107,9 @@ class PodcastController {
|
||||
libraryFiles: [],
|
||||
extraData: {},
|
||||
libraryId: library.id,
|
||||
libraryFolderId: folder.id
|
||||
libraryFolderId: folder.id,
|
||||
title: podcast.title,
|
||||
titleIgnorePrefix: podcast.titleIgnorePrefix
|
||||
},
|
||||
{ transaction }
|
||||
)
|
||||
@@ -498,6 +500,10 @@ class PodcastController {
|
||||
req.libraryItem.changed('libraryFiles', true)
|
||||
await req.libraryItem.save()
|
||||
|
||||
// update number of episodes
|
||||
req.libraryItem.media.numEpisodes = req.libraryItem.media.podcastEpisodes.length
|
||||
await req.libraryItem.media.save()
|
||||
|
||||
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
|
||||
res.json(req.libraryItem.toOldJSON())
|
||||
}
|
||||
|
||||
@@ -232,6 +232,11 @@ class PodcastManager {
|
||||
|
||||
await libraryItem.save()
|
||||
|
||||
if (libraryItem.media.numEpisodes !== libraryItem.media.podcastEpisodes.length) {
|
||||
libraryItem.media.numEpisodes = libraryItem.media.podcastEpisodes.length
|
||||
await libraryItem.media.save()
|
||||
}
|
||||
|
||||
SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
|
||||
const podcastEpisodeExpanded = podcastEpisode.toOldJSONExpanded(libraryItem.id)
|
||||
podcastEpisodeExpanded.libraryItem = libraryItem.toOldJSONExpanded()
|
||||
@@ -622,7 +627,9 @@ class PodcastManager {
|
||||
libraryFiles: [],
|
||||
extraData: {},
|
||||
libraryId: folder.libraryId,
|
||||
libraryFolderId: folder.id
|
||||
libraryFolderId: folder.id,
|
||||
title: podcast.title,
|
||||
titleIgnorePrefix: podcast.titleIgnorePrefix
|
||||
},
|
||||
{ transaction }
|
||||
)
|
||||
|
||||
@@ -14,3 +14,4 @@ Please add a record of every database migration that you create to this file. Th
|
||||
| v2.17.6 | v2.17.6-share-add-isdownloadable | Adds the isDownloadable column to the mediaItemShares table |
|
||||
| v2.17.7 | v2.17.7-add-indices | Adds indices to the libraryItems and books tables to reduce query times |
|
||||
| v2.19.1 | v2.19.1-copy-title-to-library-items | Copies title and titleIgnorePrefix to the libraryItems table, creates update triggers and indices |
|
||||
| v2.19.4 | v2.19.4-improve-podcast-queries | Adds numEpisodes to podcasts, adds podcastId to mediaProgresses, copies podcast title to libraryItems |
|
||||
|
||||
219
server/migrations/v2.19.4-improve-podcast-queries.js
Normal file
219
server/migrations/v2.19.4-improve-podcast-queries.js
Normal file
@@ -0,0 +1,219 @@
|
||||
const util = require('util')
|
||||
|
||||
/**
|
||||
* @typedef MigrationContext
|
||||
* @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.
|
||||
* @property {import('../Logger')} logger - a Logger object.
|
||||
*
|
||||
* @typedef MigrationOptions
|
||||
* @property {MigrationContext} context - an object containing the migration context.
|
||||
*/
|
||||
|
||||
const migrationVersion = '2.19.4'
|
||||
const migrationName = `${migrationVersion}-improve-podcast-queries`
|
||||
const loggerPrefix = `[${migrationVersion} migration]`
|
||||
|
||||
/**
|
||||
* This upward migration adds a numEpisodes column to the podcasts table and populates it.
|
||||
* It also adds a podcastId column to the mediaProgresses table and populates it.
|
||||
* It also copies the title and titleIgnorePrefix columns from the podcasts table to the libraryItems table,
|
||||
* and adds triggers to update them when the corresponding columns in the podcasts table are updated.
|
||||
*
|
||||
* @param {MigrationOptions} options - an object containing the migration context.
|
||||
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||
*/
|
||||
async function up({ context: { queryInterface, logger } }) {
|
||||
// Upwards migration script
|
||||
logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)
|
||||
|
||||
// Add numEpisodes column to podcasts table
|
||||
await addColumn(queryInterface, logger, 'podcasts', 'numEpisodes', { type: queryInterface.sequelize.Sequelize.INTEGER, allowNull: false, defaultValue: 0 })
|
||||
|
||||
// Populate numEpisodes column with the number of episodes for each podcast
|
||||
await populateNumEpisodes(queryInterface, logger)
|
||||
|
||||
// Add podcastId column to mediaProgresses table
|
||||
await addColumn(queryInterface, logger, 'mediaProgresses', 'podcastId', { type: queryInterface.sequelize.Sequelize.UUID, allowNull: true })
|
||||
|
||||
// Populate podcastId column with the podcastId for each mediaProgress
|
||||
await populatePodcastId(queryInterface, logger)
|
||||
|
||||
// Copy title and titleIgnorePrefix columns from podcasts to libraryItems
|
||||
await copyColumn(queryInterface, logger, 'podcasts', 'title', 'id', 'libraryItems', 'title', 'mediaId')
|
||||
await copyColumn(queryInterface, logger, 'podcasts', 'titleIgnorePrefix', 'id', 'libraryItems', 'titleIgnorePrefix', 'mediaId')
|
||||
|
||||
// Add triggers to update title and titleIgnorePrefix in libraryItems
|
||||
await addTrigger(queryInterface, logger, 'podcasts', 'title', 'id', 'libraryItems', 'title', 'mediaId')
|
||||
await addTrigger(queryInterface, logger, 'podcasts', 'titleIgnorePrefix', 'id', 'libraryItems', 'titleIgnorePrefix', 'mediaId')
|
||||
|
||||
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* This downward migration removes the triggers on the podcasts table,
|
||||
* the numEpisodes column from the podcasts table, and the podcastId column from the mediaProgresses table.
|
||||
*
|
||||
* @param {MigrationOptions} options - an object containing the migration context.
|
||||
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||
*/
|
||||
async function down({ context: { queryInterface, logger } }) {
|
||||
// Downward migration script
|
||||
logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)
|
||||
|
||||
// Remove triggers from libraryItems
|
||||
await removeTrigger(queryInterface, logger, 'podcasts', 'title', 'libraryItems', 'title')
|
||||
await removeTrigger(queryInterface, logger, 'podcasts', 'titleIgnorePrefix', 'libraryItems', 'titleIgnorePrefix')
|
||||
|
||||
// Remove numEpisodes column from podcasts table
|
||||
await removeColumn(queryInterface, logger, 'podcasts', 'numEpisodes')
|
||||
|
||||
// Remove podcastId column from mediaProgresses table
|
||||
await removeColumn(queryInterface, logger, 'mediaProgresses', 'podcastId')
|
||||
|
||||
logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
|
||||
}
|
||||
|
||||
async function populateNumEpisodes(queryInterface, logger) {
|
||||
logger.info(`${loggerPrefix} populating numEpisodes column in podcasts table`)
|
||||
await queryInterface.sequelize.query(`
|
||||
UPDATE podcasts
|
||||
SET numEpisodes = (SELECT COUNT(*) FROM podcastEpisodes WHERE podcastEpisodes.podcastId = podcasts.id)
|
||||
`)
|
||||
logger.info(`${loggerPrefix} populated numEpisodes column in podcasts table`)
|
||||
}
|
||||
|
||||
async function populatePodcastId(queryInterface, logger) {
|
||||
logger.info(`${loggerPrefix} populating podcastId column in mediaProgresses table`)
|
||||
// bulk update podcastId to the podcastId of the podcastEpisode if the mediaItemType is podcastEpisode
|
||||
await queryInterface.sequelize.query(`
|
||||
UPDATE mediaProgresses
|
||||
SET podcastId = (SELECT podcastId FROM podcastEpisodes WHERE podcastEpisodes.id = mediaProgresses.mediaItemId)
|
||||
WHERE mediaItemType = 'podcastEpisode'
|
||||
`)
|
||||
logger.info(`${loggerPrefix} populated podcastId column in mediaProgresses table`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to add a column to a table. If the column already exists, it logs a message and continues.
|
||||
*
|
||||
* @param {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.
|
||||
* @param {import('../Logger')} logger - a Logger object.
|
||||
* @param {string} table - the name of the table to add the column to.
|
||||
* @param {string} column - the name of the column to add.
|
||||
* @param {Object} options - the options for the column.
|
||||
*/
|
||||
async function addColumn(queryInterface, logger, table, column, options) {
|
||||
logger.info(`${loggerPrefix} adding column "${column}" to table "${table}"`)
|
||||
const tableDescription = await queryInterface.describeTable(table)
|
||||
if (!tableDescription[column]) {
|
||||
await queryInterface.addColumn(table, column, options)
|
||||
logger.info(`${loggerPrefix} added column "${column}" to table "${table}"`)
|
||||
} else {
|
||||
logger.info(`${loggerPrefix} column "${column}" already exists in table "${table}"`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to remove a column from a table. If the column does not exist, it logs a message and continues.
|
||||
*
|
||||
* @param {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.
|
||||
* @param {import('../Logger')} logger - a Logger object.
|
||||
* @param {string} table - the name of the table to remove the column from.
|
||||
* @param {string} column - the name of the column to remove.
|
||||
*/
|
||||
async function removeColumn(queryInterface, logger, table, column) {
|
||||
logger.info(`${loggerPrefix} removing column "${column}" from table "${table}"`)
|
||||
const tableDescription = await queryInterface.describeTable(table)
|
||||
if (tableDescription[column]) {
|
||||
await queryInterface.sequelize.query(`ALTER TABLE ${table} DROP COLUMN ${column}`)
|
||||
logger.info(`${loggerPrefix} removed column "${column}" from table "${table}"`)
|
||||
} else {
|
||||
logger.info(`${loggerPrefix} column "${column}" does not exist in table "${table}"`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to add a trigger to update a column in a target table when a column in a source table is updated.
|
||||
* If the trigger already exists, it drops it and creates a new one.
|
||||
* sourceIdColumn and targetIdColumn are used to match the source and target rows.
|
||||
*
|
||||
* @param {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.
|
||||
* @param {import('../Logger')} logger - a Logger object.
|
||||
* @param {string} sourceTable - the name of the source table.
|
||||
* @param {string} sourceColumn - the name of the column to update.
|
||||
* @param {string} sourceIdColumn - the name of the id column of the source table.
|
||||
* @param {string} targetTable - the name of the target table.
|
||||
* @param {string} targetColumn - the name of the column to update.
|
||||
* @param {string} targetIdColumn - the name of the id column of the target table.
|
||||
*/
|
||||
async function addTrigger(queryInterface, logger, sourceTable, sourceColumn, sourceIdColumn, targetTable, targetColumn, targetIdColumn) {
|
||||
logger.info(`${loggerPrefix} adding trigger to update ${targetTable}.${targetColumn} when ${sourceTable}.${sourceColumn} is updated`)
|
||||
const triggerName = convertToSnakeCase(`update_${targetTable}_${targetColumn}_from_${sourceTable}_${sourceColumn}`)
|
||||
|
||||
await queryInterface.sequelize.query(`DROP TRIGGER IF EXISTS ${triggerName}`)
|
||||
|
||||
await queryInterface.sequelize.query(`
|
||||
CREATE TRIGGER ${triggerName}
|
||||
AFTER UPDATE OF ${sourceColumn} ON ${sourceTable}
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE ${targetTable}
|
||||
SET ${targetColumn} = NEW.${sourceColumn}
|
||||
WHERE ${targetTable}.${targetIdColumn} = NEW.${sourceIdColumn};
|
||||
END;
|
||||
`)
|
||||
logger.info(`${loggerPrefix} added trigger to update ${targetTable}.${targetColumn} when ${sourceTable}.${sourceColumn} is updated`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to remove an update trigger from a table.
|
||||
*
|
||||
* @param {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.
|
||||
* @param {import('../Logger')} logger - a Logger object.
|
||||
* @param {string} sourceTable - the name of the source table.
|
||||
* @param {string} sourceColumn - the name of the column to update.
|
||||
* @param {string} targetTable - the name of the target table.
|
||||
* @param {string} targetColumn - the name of the column to update.
|
||||
*/
|
||||
async function removeTrigger(queryInterface, logger, sourceTable, sourceColumn, targetTable, targetColumn) {
|
||||
logger.info(`${loggerPrefix} removing trigger to update ${targetTable}.${targetColumn}`)
|
||||
const triggerName = convertToSnakeCase(`update_${targetTable}_${targetColumn}_from_${sourceTable}_${sourceColumn}`)
|
||||
await queryInterface.sequelize.query(`DROP TRIGGER IF EXISTS ${triggerName}`)
|
||||
logger.info(`${loggerPrefix} removed trigger to update ${targetTable}.${targetColumn}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to copy a column from a source table to a target table.
|
||||
* sourceIdColumn and targetIdColumn are used to match the source and target rows.
|
||||
*
|
||||
* @param {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.
|
||||
* @param {import('../Logger')} logger - a Logger object.
|
||||
* @param {string} sourceTable - the name of the source table.
|
||||
* @param {string} sourceColumn - the name of the column to copy.
|
||||
* @param {string} sourceIdColumn - the name of the id column of the source table.
|
||||
* @param {string} targetTable - the name of the target table.
|
||||
* @param {string} targetColumn - the name of the column to copy to.
|
||||
* @param {string} targetIdColumn - the name of the id column of the target table.
|
||||
*/
|
||||
async function copyColumn(queryInterface, logger, sourceTable, sourceColumn, sourceIdColumn, targetTable, targetColumn, targetIdColumn) {
|
||||
logger.info(`${loggerPrefix} copying column "${sourceColumn}" from table "${sourceTable}" to table "${targetTable}"`)
|
||||
await queryInterface.sequelize.query(`
|
||||
UPDATE ${targetTable}
|
||||
SET ${targetColumn} = ${sourceTable}.${sourceColumn}
|
||||
FROM ${sourceTable}
|
||||
WHERE ${targetTable}.${targetIdColumn} = ${sourceTable}.${sourceIdColumn}
|
||||
`)
|
||||
logger.info(`${loggerPrefix} copied column "${sourceColumn}" from table "${sourceTable}" to table "${targetTable}"`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to convert a string to snake case, e.g. "titleIgnorePrefix" -> "title_ignore_prefix"
|
||||
*
|
||||
* @param {string} str - the string to convert to snake case.
|
||||
* @returns {string} - the string in snake case.
|
||||
*/
|
||||
function convertToSnakeCase(str) {
|
||||
return str.replace(/([A-Z])/g, '_$1').toLowerCase()
|
||||
}
|
||||
|
||||
module.exports = { up, down }
|
||||
@@ -103,7 +103,7 @@ class LibraryItem extends Model {
|
||||
{
|
||||
model: this.sequelize.models.series,
|
||||
through: {
|
||||
attributes: ['sequence', 'createdAt']
|
||||
attributes: ['id', 'sequence', 'createdAt']
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -34,6 +34,8 @@ class MediaProgress extends Model {
|
||||
this.updatedAt
|
||||
/** @type {Date} */
|
||||
this.createdAt
|
||||
/** @type {UUIDV4} */
|
||||
this.podcastId
|
||||
}
|
||||
|
||||
static removeById(mediaProgressId) {
|
||||
@@ -69,7 +71,8 @@ class MediaProgress extends Model {
|
||||
ebookLocation: DataTypes.STRING,
|
||||
ebookProgress: DataTypes.FLOAT,
|
||||
finishedAt: DataTypes.DATE,
|
||||
extraData: DataTypes.JSON
|
||||
extraData: DataTypes.JSON,
|
||||
podcastId: DataTypes.UUID
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
@@ -123,6 +126,16 @@ class MediaProgress extends Model {
|
||||
}
|
||||
})
|
||||
|
||||
// make sure to call the afterDestroy hook for each instance
|
||||
MediaProgress.addHook('beforeBulkDestroy', (options) => {
|
||||
options.individualHooks = true
|
||||
})
|
||||
|
||||
// update the potentially cached user after destroying the media progress
|
||||
MediaProgress.addHook('afterDestroy', (instance) => {
|
||||
user.mediaProgressRemoved(instance)
|
||||
})
|
||||
|
||||
user.hasMany(MediaProgress, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const { DataTypes, Model } = require('sequelize')
|
||||
const { getTitlePrefixAtEnd, getTitleIgnorePrefix } = require('../utils')
|
||||
const Logger = require('../Logger')
|
||||
const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters')
|
||||
|
||||
/**
|
||||
* @typedef PodcastExpandedProperties
|
||||
@@ -61,6 +62,8 @@ class Podcast extends Model {
|
||||
this.createdAt
|
||||
/** @type {Date} */
|
||||
this.updatedAt
|
||||
/** @type {number} */
|
||||
this.numEpisodes
|
||||
|
||||
/** @type {import('./PodcastEpisode')[]} */
|
||||
this.podcastEpisodes
|
||||
@@ -138,13 +141,22 @@ class Podcast extends Model {
|
||||
maxNewEpisodesToDownload: DataTypes.INTEGER,
|
||||
coverPath: DataTypes.STRING,
|
||||
tags: DataTypes.JSON,
|
||||
genres: DataTypes.JSON
|
||||
genres: DataTypes.JSON,
|
||||
numEpisodes: DataTypes.INTEGER
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
modelName: 'podcast'
|
||||
}
|
||||
)
|
||||
|
||||
Podcast.addHook('afterDestroy', async (instance) => {
|
||||
libraryItemsPodcastFilters.clearCountCache('podcast', 'afterDestroy')
|
||||
})
|
||||
|
||||
Podcast.addHook('afterCreate', async (instance) => {
|
||||
libraryItemsPodcastFilters.clearCountCache('podcast', 'afterCreate')
|
||||
})
|
||||
}
|
||||
|
||||
get hasMediaFiles() {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const { DataTypes, Model } = require('sequelize')
|
||||
|
||||
const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters')
|
||||
/**
|
||||
* @typedef ChapterObject
|
||||
* @property {number} id
|
||||
@@ -132,6 +132,14 @@ class PodcastEpisode extends Model {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
PodcastEpisode.belongsTo(podcast)
|
||||
|
||||
PodcastEpisode.addHook('afterDestroy', async (instance) => {
|
||||
libraryItemsPodcastFilters.clearCountCache('podcastEpisode', 'afterDestroy')
|
||||
})
|
||||
|
||||
PodcastEpisode.addHook('afterCreate', async (instance) => {
|
||||
libraryItemsPodcastFilters.clearCountCache('podcastEpisode', 'afterCreate')
|
||||
})
|
||||
}
|
||||
|
||||
get size() {
|
||||
|
||||
@@ -404,6 +404,14 @@ class User extends Model {
|
||||
return count > 0
|
||||
}
|
||||
|
||||
static mediaProgressRemoved(mediaProgress) {
|
||||
const cachedUser = userCache.getById(mediaProgress.userId)
|
||||
if (cachedUser) {
|
||||
Logger.debug(`[User] mediaProgressRemoved: ${mediaProgress.id} from user ${cachedUser.id}`)
|
||||
cachedUser.mediaProgresses = cachedUser.mediaProgresses.filter((mp) => mp.id !== mediaProgress.id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize model
|
||||
* @param {import('../Database').sequelize} sequelize
|
||||
@@ -626,6 +634,7 @@ class User extends Model {
|
||||
/** @type {import('./MediaProgress')|null} */
|
||||
let mediaProgress = null
|
||||
let mediaItemId = null
|
||||
let podcastId = null
|
||||
if (progressPayload.episodeId) {
|
||||
const podcastEpisode = await this.sequelize.models.podcastEpisode.findByPk(progressPayload.episodeId, {
|
||||
attributes: ['id', 'podcastId'],
|
||||
@@ -654,6 +663,7 @@ class User extends Model {
|
||||
}
|
||||
mediaItemId = podcastEpisode.id
|
||||
mediaProgress = podcastEpisode.mediaProgresses?.[0]
|
||||
podcastId = podcastEpisode.podcastId
|
||||
} else {
|
||||
const libraryItem = await this.sequelize.models.libraryItem.findByPk(progressPayload.libraryItemId, {
|
||||
attributes: ['id', 'mediaId', 'mediaType'],
|
||||
@@ -686,6 +696,7 @@ class User extends Model {
|
||||
const newMediaProgressPayload = {
|
||||
userId: this.id,
|
||||
mediaItemId,
|
||||
podcastId,
|
||||
mediaItemType: progressPayload.episodeId ? 'podcastEpisode' : 'book',
|
||||
duration: isNullOrNaN(progressPayload.duration) ? 0 : Number(progressPayload.duration),
|
||||
currentTime: isNullOrNaN(progressPayload.currentTime) ? 0 : Number(progressPayload.currentTime),
|
||||
|
||||
@@ -41,6 +41,9 @@ class CustomProviderAdapter {
|
||||
}
|
||||
const queryString = new URLSearchParams(queryObj).toString()
|
||||
|
||||
const url = `${provider.url}/search?${queryString}`
|
||||
Logger.debug(`[CustomMetadataProvider] Search url: ${url}`)
|
||||
|
||||
// Setup headers
|
||||
const axiosOptions = {
|
||||
timeout
|
||||
@@ -52,7 +55,7 @@ class CustomProviderAdapter {
|
||||
}
|
||||
|
||||
const matches = await axios
|
||||
.get(`${provider.url}/search?${queryString}`, axiosOptions)
|
||||
.get(url, axiosOptions)
|
||||
.then((res) => {
|
||||
if (!res?.data || !Array.isArray(res.data.matches)) return null
|
||||
return res.data.matches
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const uuidv4 = require("uuid").v4
|
||||
const uuidv4 = require('uuid').v4
|
||||
const Path = require('path')
|
||||
const { LogLevel } = require('../utils/constants')
|
||||
const { getTitleIgnorePrefix } = require('../utils/index')
|
||||
@@ -8,9 +8,9 @@ const { filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtil
|
||||
const AudioFile = require('../objects/files/AudioFile')
|
||||
const CoverManager = require('../managers/CoverManager')
|
||||
const LibraryFile = require('../objects/files/LibraryFile')
|
||||
const fsExtra = require("../libs/fsExtra")
|
||||
const PodcastEpisode = require("../models/PodcastEpisode")
|
||||
const AbsMetadataFileScanner = require("./AbsMetadataFileScanner")
|
||||
const fsExtra = require('../libs/fsExtra')
|
||||
const PodcastEpisode = require('../models/PodcastEpisode')
|
||||
const AbsMetadataFileScanner = require('./AbsMetadataFileScanner')
|
||||
|
||||
/**
|
||||
* Metadata for podcasts pulled from files
|
||||
@@ -32,13 +32,13 @@ const AbsMetadataFileScanner = require("./AbsMetadataFileScanner")
|
||||
*/
|
||||
|
||||
class PodcastScanner {
|
||||
constructor() { }
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* @param {import('../models/LibraryItem')} existingLibraryItem
|
||||
* @param {import('./LibraryItemScanData')} libraryItemData
|
||||
* @param {import('../models/LibraryItem')} existingLibraryItem
|
||||
* @param {import('./LibraryItemScanData')} libraryItemData
|
||||
* @param {import('../models/Library').LibrarySettingsObject} librarySettings
|
||||
* @param {import('./LibraryScan')} libraryScan
|
||||
* @param {import('./LibraryScan')} libraryScan
|
||||
* @returns {Promise<{libraryItem:import('../models/LibraryItem'), wasUpdated:boolean}>}
|
||||
*/
|
||||
async rescanExistingPodcastLibraryItem(existingLibraryItem, libraryItemData, librarySettings, libraryScan) {
|
||||
@@ -59,28 +59,34 @@ class PodcastScanner {
|
||||
|
||||
if (libraryItemData.hasAudioFileChanges || libraryItemData.audioLibraryFiles.length !== existingPodcastEpisodes.length) {
|
||||
// Filter out and destroy episodes that were removed
|
||||
existingPodcastEpisodes = await Promise.all(existingPodcastEpisodes.filter(async ep => {
|
||||
if (libraryItemData.checkAudioFileRemoved(ep.audioFile)) {
|
||||
libraryScan.addLog(LogLevel.INFO, `Podcast episode "${ep.title}" audio file was removed`)
|
||||
// TODO: Should clean up other data linked to this episode
|
||||
await ep.destroy()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}))
|
||||
existingPodcastEpisodes = await Promise.all(
|
||||
existingPodcastEpisodes.filter(async (ep) => {
|
||||
if (libraryItemData.checkAudioFileRemoved(ep.audioFile)) {
|
||||
libraryScan.addLog(LogLevel.INFO, `Podcast episode "${ep.title}" audio file was removed`)
|
||||
// TODO: Should clean up other data linked to this episode
|
||||
await ep.destroy()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
)
|
||||
|
||||
// Update audio files that were modified
|
||||
if (libraryItemData.audioLibraryFilesModified.length) {
|
||||
let scannedAudioFiles = await AudioFileScanner.executeMediaFileScans(existingLibraryItem.mediaType, libraryItemData, libraryItemData.audioLibraryFilesModified.map(lf => lf.new))
|
||||
let scannedAudioFiles = await AudioFileScanner.executeMediaFileScans(
|
||||
existingLibraryItem.mediaType,
|
||||
libraryItemData,
|
||||
libraryItemData.audioLibraryFilesModified.map((lf) => lf.new)
|
||||
)
|
||||
|
||||
for (const podcastEpisode of existingPodcastEpisodes) {
|
||||
let matchedScannedAudioFile = scannedAudioFiles.find(saf => saf.metadata.path === podcastEpisode.audioFile.metadata.path)
|
||||
let matchedScannedAudioFile = scannedAudioFiles.find((saf) => saf.metadata.path === podcastEpisode.audioFile.metadata.path)
|
||||
if (!matchedScannedAudioFile) {
|
||||
matchedScannedAudioFile = scannedAudioFiles.find(saf => saf.ino === podcastEpisode.audioFile.ino)
|
||||
matchedScannedAudioFile = scannedAudioFiles.find((saf) => saf.ino === podcastEpisode.audioFile.ino)
|
||||
}
|
||||
|
||||
if (matchedScannedAudioFile) {
|
||||
scannedAudioFiles = scannedAudioFiles.filter(saf => saf !== matchedScannedAudioFile)
|
||||
scannedAudioFiles = scannedAudioFiles.filter((saf) => saf !== matchedScannedAudioFile)
|
||||
const audioFile = new AudioFile(podcastEpisode.audioFile)
|
||||
audioFile.updateFromScan(matchedScannedAudioFile)
|
||||
podcastEpisode.audioFile = audioFile.toJSON()
|
||||
@@ -131,15 +137,20 @@ class PodcastScanner {
|
||||
|
||||
let hasMediaChanges = false
|
||||
|
||||
if (existingPodcastEpisodes.length !== media.numEpisodes) {
|
||||
media.numEpisodes = existingPodcastEpisodes.length
|
||||
hasMediaChanges = true
|
||||
}
|
||||
|
||||
// Check if cover was removed
|
||||
if (media.coverPath && libraryItemData.imageLibraryFilesRemoved.some(lf => lf.metadata.path === media.coverPath)) {
|
||||
if (media.coverPath && libraryItemData.imageLibraryFilesRemoved.some((lf) => lf.metadata.path === media.coverPath)) {
|
||||
media.coverPath = null
|
||||
hasMediaChanges = true
|
||||
}
|
||||
|
||||
// Update cover if it was modified
|
||||
if (media.coverPath && libraryItemData.imageLibraryFilesModified.length) {
|
||||
let coverMatch = libraryItemData.imageLibraryFilesModified.find(iFile => iFile.old.metadata.path === media.coverPath)
|
||||
let coverMatch = libraryItemData.imageLibraryFilesModified.find((iFile) => iFile.old.metadata.path === media.coverPath)
|
||||
if (coverMatch) {
|
||||
const coverPath = coverMatch.new.metadata.path
|
||||
if (coverPath !== media.coverPath) {
|
||||
@@ -154,7 +165,7 @@ class PodcastScanner {
|
||||
// Check if cover is not set and image files were found
|
||||
if (!media.coverPath && libraryItemData.imageLibraryFiles.length) {
|
||||
// Prefer using a cover image with the name "cover" otherwise use the first image
|
||||
const coverMatch = libraryItemData.imageLibraryFiles.find(iFile => /\/cover\.[^.\/]*$/.test(iFile.metadata.path))
|
||||
const coverMatch = libraryItemData.imageLibraryFiles.find((iFile) => /\/cover\.[^.\/]*$/.test(iFile.metadata.path))
|
||||
media.coverPath = coverMatch?.metadata.path || libraryItemData.imageLibraryFiles[0].metadata.path
|
||||
hasMediaChanges = true
|
||||
}
|
||||
@@ -167,7 +178,7 @@ class PodcastScanner {
|
||||
|
||||
if (key === 'genres') {
|
||||
const existingGenres = media.genres || []
|
||||
if (podcastMetadata.genres.some(g => !existingGenres.includes(g)) || existingGenres.some(g => !podcastMetadata.genres.includes(g))) {
|
||||
if (podcastMetadata.genres.some((g) => !existingGenres.includes(g)) || existingGenres.some((g) => !podcastMetadata.genres.includes(g))) {
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Updating podcast genres "${existingGenres.join(',')}" => "${podcastMetadata.genres.join(',')}" for podcast "${podcastMetadata.title}"`)
|
||||
media.genres = podcastMetadata.genres
|
||||
media.changed('genres', true)
|
||||
@@ -175,7 +186,7 @@ class PodcastScanner {
|
||||
}
|
||||
} else if (key === 'tags') {
|
||||
const existingTags = media.tags || []
|
||||
if (podcastMetadata.tags.some(t => !existingTags.includes(t)) || existingTags.some(t => !podcastMetadata.tags.includes(t))) {
|
||||
if (podcastMetadata.tags.some((t) => !existingTags.includes(t)) || existingTags.some((t) => !podcastMetadata.tags.includes(t))) {
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Updating podcast tags "${existingTags.join(',')}" => "${podcastMetadata.tags.join(',')}" for podcast "${podcastMetadata.title}"`)
|
||||
media.tags = podcastMetadata.tags
|
||||
media.changed('tags', true)
|
||||
@@ -190,7 +201,7 @@ class PodcastScanner {
|
||||
|
||||
// If no cover then extract cover from audio file if available
|
||||
if (!media.coverPath && existingPodcastEpisodes.length) {
|
||||
const audioFiles = existingPodcastEpisodes.map(ep => ep.audioFile)
|
||||
const audioFiles = existingPodcastEpisodes.map((ep) => ep.audioFile)
|
||||
const extractedCoverPath = await CoverManager.saveEmbeddedCoverArt(audioFiles, existingLibraryItem.id, existingLibraryItem.path)
|
||||
if (extractedCoverPath) {
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Updating podcast "${podcastMetadata.title}" extracted embedded cover art from audio file to path "${extractedCoverPath}"`)
|
||||
@@ -222,10 +233,10 @@ class PodcastScanner {
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('./LibraryItemScanData')} libraryItemData
|
||||
*
|
||||
* @param {import('./LibraryItemScanData')} libraryItemData
|
||||
* @param {import('../models/Library').LibrarySettingsObject} librarySettings
|
||||
* @param {import('./LibraryScan')} libraryScan
|
||||
* @param {import('./LibraryScan')} libraryScan
|
||||
* @returns {Promise<import('../models/LibraryItem')>}
|
||||
*/
|
||||
async scanNewPodcastLibraryItem(libraryItemData, librarySettings, libraryScan) {
|
||||
@@ -267,7 +278,7 @@ class PodcastScanner {
|
||||
// Set cover image from library file
|
||||
if (libraryItemData.imageLibraryFiles.length) {
|
||||
// Prefer using a cover image with the name "cover" otherwise use the first image
|
||||
const coverMatch = libraryItemData.imageLibraryFiles.find(iFile => /\/cover\.[^.\/]*$/.test(iFile.metadata.path))
|
||||
const coverMatch = libraryItemData.imageLibraryFiles.find((iFile) => /\/cover\.[^.\/]*$/.test(iFile.metadata.path))
|
||||
podcastMetadata.coverPath = coverMatch?.metadata.path || libraryItemData.imageLibraryFiles[0].metadata.path
|
||||
}
|
||||
|
||||
@@ -283,7 +294,8 @@ class PodcastScanner {
|
||||
lastEpisodeCheck: 0,
|
||||
maxEpisodesToKeep: 0,
|
||||
maxNewEpisodesToDownload: 3,
|
||||
podcastEpisodes: newPodcastEpisodes
|
||||
podcastEpisodes: newPodcastEpisodes,
|
||||
numEpisodes: newPodcastEpisodes.length
|
||||
}
|
||||
|
||||
const libraryItemObj = libraryItemData.libraryItemObject
|
||||
@@ -291,6 +303,8 @@ class PodcastScanner {
|
||||
libraryItemObj.isMissing = false
|
||||
libraryItemObj.isInvalid = false
|
||||
libraryItemObj.extraData = {}
|
||||
libraryItemObj.title = podcastObject.title
|
||||
libraryItemObj.titleIgnorePrefix = getTitleIgnorePrefix(podcastObject.title)
|
||||
|
||||
// If cover was not found in folder then check embedded covers in audio files
|
||||
if (!podcastObject.coverPath && scannedAudioFiles.length) {
|
||||
@@ -324,10 +338,10 @@ class PodcastScanner {
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* @param {PodcastEpisode[]} podcastEpisodes Not the models for new podcasts
|
||||
* @param {import('./LibraryItemScanData')} libraryItemData
|
||||
* @param {import('./LibraryScan')} libraryScan
|
||||
* @param {import('./LibraryItemScanData')} libraryItemData
|
||||
* @param {import('./LibraryScan')} libraryScan
|
||||
* @param {string} [existingLibraryItemId]
|
||||
* @returns {Promise<PodcastMetadataObject>}
|
||||
*/
|
||||
@@ -364,8 +378,8 @@ class PodcastScanner {
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('../models/LibraryItem')} libraryItem
|
||||
*
|
||||
* @param {import('../models/LibraryItem')} libraryItem
|
||||
* @param {import('./LibraryScan')} libraryScan
|
||||
* @returns {Promise}
|
||||
*/
|
||||
@@ -399,41 +413,44 @@ class PodcastScanner {
|
||||
explicit: !!libraryItem.media.explicit,
|
||||
podcastType: libraryItem.media.podcastType
|
||||
}
|
||||
return fsExtra.writeFile(metadataFilePath, JSON.stringify(jsonObject, null, 2)).then(async () => {
|
||||
// Add metadata.json to libraryFiles array if it is new
|
||||
let metadataLibraryFile = libraryItem.libraryFiles.find(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath))
|
||||
if (storeMetadataWithItem) {
|
||||
if (!metadataLibraryFile) {
|
||||
const newLibraryFile = new LibraryFile()
|
||||
await newLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`)
|
||||
metadataLibraryFile = newLibraryFile.toJSON()
|
||||
libraryItem.libraryFiles.push(metadataLibraryFile)
|
||||
} else {
|
||||
const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath)
|
||||
if (fileTimestamps) {
|
||||
metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs
|
||||
metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs
|
||||
metadataLibraryFile.metadata.size = fileTimestamps.size
|
||||
metadataLibraryFile.ino = fileTimestamps.ino
|
||||
return fsExtra
|
||||
.writeFile(metadataFilePath, JSON.stringify(jsonObject, null, 2))
|
||||
.then(async () => {
|
||||
// Add metadata.json to libraryFiles array if it is new
|
||||
let metadataLibraryFile = libraryItem.libraryFiles.find((lf) => lf.metadata.path === filePathToPOSIX(metadataFilePath))
|
||||
if (storeMetadataWithItem) {
|
||||
if (!metadataLibraryFile) {
|
||||
const newLibraryFile = new LibraryFile()
|
||||
await newLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`)
|
||||
metadataLibraryFile = newLibraryFile.toJSON()
|
||||
libraryItem.libraryFiles.push(metadataLibraryFile)
|
||||
} else {
|
||||
const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath)
|
||||
if (fileTimestamps) {
|
||||
metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs
|
||||
metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs
|
||||
metadataLibraryFile.metadata.size = fileTimestamps.size
|
||||
metadataLibraryFile.ino = fileTimestamps.ino
|
||||
}
|
||||
}
|
||||
const libraryItemDirTimestamps = await getFileTimestampsWithIno(libraryItem.path)
|
||||
if (libraryItemDirTimestamps) {
|
||||
libraryItem.mtime = libraryItemDirTimestamps.mtimeMs
|
||||
libraryItem.ctime = libraryItemDirTimestamps.ctimeMs
|
||||
let size = 0
|
||||
libraryItem.libraryFiles.forEach((lf) => (size += !isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0))
|
||||
libraryItem.size = size
|
||||
}
|
||||
}
|
||||
const libraryItemDirTimestamps = await getFileTimestampsWithIno(libraryItem.path)
|
||||
if (libraryItemDirTimestamps) {
|
||||
libraryItem.mtime = libraryItemDirTimestamps.mtimeMs
|
||||
libraryItem.ctime = libraryItemDirTimestamps.ctimeMs
|
||||
let size = 0
|
||||
libraryItem.libraryFiles.forEach((lf) => size += (!isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0))
|
||||
libraryItem.size = size
|
||||
}
|
||||
}
|
||||
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Success saving abmetadata to "${metadataFilePath}"`)
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Success saving abmetadata to "${metadataFilePath}"`)
|
||||
|
||||
return metadataLibraryFile
|
||||
}).catch((error) => {
|
||||
libraryScan.addLog(LogLevel.ERROR, `Failed to save json file at "${metadataFilePath}"`, error)
|
||||
return null
|
||||
})
|
||||
return metadataLibraryFile
|
||||
})
|
||||
.catch((error) => {
|
||||
libraryScan.addLog(LogLevel.ERROR, `Failed to save json file at "${metadataFilePath}"`, error)
|
||||
return null
|
||||
})
|
||||
}
|
||||
}
|
||||
module.exports = new PodcastScanner()
|
||||
module.exports = new PodcastScanner()
|
||||
|
||||
@@ -48,13 +48,7 @@ class Scanner {
|
||||
let updatePayload = {}
|
||||
let hasUpdated = false
|
||||
|
||||
let existingAuthors = [] // Used for checking if authors or series are now empty
|
||||
let existingSeries = []
|
||||
|
||||
if (libraryItem.isBook) {
|
||||
existingAuthors = libraryItem.media.authors.map((a) => a.id)
|
||||
existingSeries = libraryItem.media.series.map((s) => s.id)
|
||||
|
||||
const searchISBN = options.isbn || libraryItem.media.isbn
|
||||
const searchASIN = options.asin || libraryItem.media.asin
|
||||
|
||||
|
||||
@@ -131,6 +131,40 @@ async function readTextFile(path) {
|
||||
}
|
||||
module.exports.readTextFile = readTextFile
|
||||
|
||||
/**
|
||||
* Check if file or directory should be ignored. Returns a string of the reason to ignore, or null if not ignored
|
||||
*
|
||||
* @param {string} path
|
||||
* @returns {string}
|
||||
*/
|
||||
module.exports.shouldIgnoreFile = (path) => {
|
||||
// Check if directory or file name starts with "."
|
||||
if (Path.basename(path).startsWith('.')) {
|
||||
return 'dotfile'
|
||||
}
|
||||
if (path.split('/').find((p) => p.startsWith('.'))) {
|
||||
return 'dotpath'
|
||||
}
|
||||
|
||||
// If these strings exist anywhere in the filename or directory name, ignore. Vendor specific hidden directories
|
||||
const includeAnywhereIgnore = ['@eaDir']
|
||||
const filteredInclude = includeAnywhereIgnore.filter((str) => path.includes(str))
|
||||
if (filteredInclude.length) {
|
||||
return `${filteredInclude[0]} directory`
|
||||
}
|
||||
|
||||
const extensionIgnores = ['.part', '.tmp', '.crdownload', '.download', '.bak', '.old', '.temp', '.tempfile', '.tempfile~']
|
||||
|
||||
// Check extension
|
||||
if (extensionIgnores.includes(Path.extname(path).toLowerCase())) {
|
||||
// Return the extension that is ignored
|
||||
return `${Path.extname(path)} file`
|
||||
}
|
||||
|
||||
// Should not ignore this file or directory
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef FilePathItem
|
||||
* @property {string} name - file name e.g. "audiofile.m4b"
|
||||
@@ -147,7 +181,7 @@ module.exports.readTextFile = readTextFile
|
||||
* @param {string} [relPathToReplace]
|
||||
* @returns {FilePathItem[]}
|
||||
*/
|
||||
async function recurseFiles(path, relPathToReplace = null) {
|
||||
module.exports.recurseFiles = async (path, relPathToReplace = null) => {
|
||||
path = filePathToPOSIX(path)
|
||||
if (!path.endsWith('/')) path = path + '/'
|
||||
|
||||
@@ -197,14 +231,10 @@ async function recurseFiles(path, relPathToReplace = null) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (item.extension === '.part') {
|
||||
Logger.debug(`[fileUtils] Ignoring .part file "${relpath}"`)
|
||||
return false
|
||||
}
|
||||
|
||||
// Ignore any file if a directory or the filename starts with "."
|
||||
if (relpath.split('/').find((p) => p.startsWith('.'))) {
|
||||
Logger.debug(`[fileUtils] Ignoring path has . "${relpath}"`)
|
||||
// Check for ignored extensions or directories
|
||||
const shouldIgnore = this.shouldIgnoreFile(relpath)
|
||||
if (shouldIgnore) {
|
||||
Logger.debug(`[fileUtils] Ignoring ${shouldIgnore} - "${relpath}"`)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -235,7 +265,6 @@ async function recurseFiles(path, relPathToReplace = null) {
|
||||
|
||||
return list
|
||||
}
|
||||
module.exports.recurseFiles = recurseFiles
|
||||
|
||||
/**
|
||||
*
|
||||
|
||||
@@ -145,15 +145,15 @@ function extractEpisodeData(item) {
|
||||
|
||||
if (item.enclosure?.[0]?.['$']?.url) {
|
||||
enclosure = item.enclosure[0]['$']
|
||||
} else if(item['media:content']?.find(c => c?.['$']?.url && (c?.['$']?.type ?? "").startsWith("audio"))) {
|
||||
enclosure = item['media:content'].find(c => (c['$']?.type ?? "").startsWith("audio"))['$']
|
||||
} else if (item['media:content']?.find((c) => c?.['$']?.url && (c?.['$']?.type ?? '').startsWith('audio'))) {
|
||||
enclosure = item['media:content'].find((c) => (c['$']?.type ?? '').startsWith('audio'))['$']
|
||||
} else {
|
||||
Logger.error(`[podcastUtils] Invalid podcast episode data`)
|
||||
return null
|
||||
}
|
||||
|
||||
const episode = {
|
||||
enclosure: enclosure,
|
||||
enclosure: enclosure
|
||||
}
|
||||
|
||||
episode.enclosure.url = episode.enclosure.url.trim()
|
||||
@@ -343,6 +343,14 @@ module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => {
|
||||
return payload.podcast
|
||||
})
|
||||
.catch((error) => {
|
||||
// Check for failures due to redirecting from http to https. If original url was http, upgrade to https and try again
|
||||
if (error.code === 'ERR_FR_REDIRECTION_FAILURE' && error.cause.code === 'ERR_INVALID_PROTOCOL') {
|
||||
if (feedUrl.startsWith('http://') && error.request._options.protocol === 'https:') {
|
||||
Logger.info('Redirection from http to https detected. Upgrading Request', error.request._options.href)
|
||||
feedUrl = feedUrl.replace('http://', 'https://')
|
||||
return this.getPodcastFeed(feedUrl, excludeEpisodeMetadata)
|
||||
}
|
||||
}
|
||||
Logger.error('[podcastUtils] getPodcastFeed Error', error)
|
||||
return null
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ const Database = require('../../Database')
|
||||
const libraryItemsBookFilters = require('./libraryItemsBookFilters')
|
||||
const libraryItemsPodcastFilters = require('./libraryItemsPodcastFilters')
|
||||
const { createNewSortInstance } = require('../../libs/fastSort')
|
||||
const { profile } = require('../../utils/profiler')
|
||||
const naturalSort = createNewSortInstance({
|
||||
comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
|
||||
})
|
||||
@@ -474,7 +475,8 @@ module.exports = {
|
||||
// Check how many podcasts are in library to determine if we need to load all of the data
|
||||
// This is done to handle the edge case of podcasts having been deleted and not having
|
||||
// an updatedAt timestamp to trigger a reload of the filter data
|
||||
const podcastCountFromDatabase = await Database.podcastModel.count({
|
||||
const podcastModelCount = process.env.QUERY_PROFILING ? profile(Database.podcastModel.count.bind(Database.podcastModel)) : Database.podcastModel.count.bind(Database.podcastModel)
|
||||
const podcastCountFromDatabase = await podcastModelCount({
|
||||
include: {
|
||||
model: Database.libraryItemModel,
|
||||
attributes: [],
|
||||
@@ -489,7 +491,7 @@ module.exports = {
|
||||
// data was loaded. If so, we can skip loading all of the data.
|
||||
// Because many items could change, just check the count of items instead
|
||||
// of actually loading the data twice
|
||||
const changedPodcasts = await Database.podcastModel.count({
|
||||
const changedPodcasts = await podcastModelCount({
|
||||
include: {
|
||||
model: Database.libraryItemModel,
|
||||
attributes: [],
|
||||
@@ -520,7 +522,8 @@ module.exports = {
|
||||
}
|
||||
|
||||
// Something has changed in the podcasts table, so reload all of the filter data for library
|
||||
const podcasts = await Database.podcastModel.findAll({
|
||||
const findAll = process.env.QUERY_PROFILING ? profile(Database.podcastModel.findAll.bind(Database.podcastModel)) : Database.podcastModel.findAll.bind(Database.podcastModel)
|
||||
const podcasts = await findAll({
|
||||
include: {
|
||||
model: Database.libraryItemModel,
|
||||
attributes: [],
|
||||
|
||||
@@ -344,22 +344,28 @@ module.exports = {
|
||||
countCache.clear()
|
||||
},
|
||||
|
||||
async findAndCountAll(findOptions, limit, offset) {
|
||||
const findOptionsKey = stringifySequelizeQuery(findOptions)
|
||||
Logger.debug(`[LibraryItemsBookFilters] findOptionsKey: ${findOptionsKey}`)
|
||||
async findAndCountAll(findOptions, limit, offset, useCountCache) {
|
||||
const model = Database.bookModel
|
||||
if (useCountCache) {
|
||||
const countCacheKey = stringifySequelizeQuery(findOptions)
|
||||
Logger.debug(`[LibraryItemsBookFilters] countCacheKey: ${countCacheKey}`)
|
||||
if (!countCache.has(countCacheKey)) {
|
||||
const count = await model.count(findOptions)
|
||||
countCache.set(countCacheKey, count)
|
||||
}
|
||||
|
||||
findOptions.limit = limit || null
|
||||
findOptions.offset = offset
|
||||
|
||||
const rows = await model.findAll(findOptions)
|
||||
|
||||
return { rows, count: countCache.get(countCacheKey) }
|
||||
}
|
||||
|
||||
findOptions.limit = limit || null
|
||||
findOptions.offset = offset
|
||||
|
||||
if (countCache.has(findOptionsKey)) {
|
||||
const rows = await Database.bookModel.findAll(findOptions)
|
||||
|
||||
return { rows, count: countCache.get(findOptionsKey) }
|
||||
} else {
|
||||
const result = await Database.bookModel.findAndCountAll(findOptions)
|
||||
countCache.set(findOptionsKey, result.count)
|
||||
return result
|
||||
}
|
||||
return await model.findAndCountAll(findOptions)
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -434,19 +440,17 @@ module.exports = {
|
||||
|
||||
const libraryItemIncludes = []
|
||||
const bookIncludes = []
|
||||
if (includeRSSFeed) {
|
||||
|
||||
if (filterGroup === 'feed-open' || includeRSSFeed) {
|
||||
const rssFeedRequired = filterGroup === 'feed-open'
|
||||
libraryItemIncludes.push({
|
||||
model: Database.feedModel,
|
||||
required: filterGroup === 'feed-open',
|
||||
separate: true
|
||||
required: rssFeedRequired,
|
||||
separate: !rssFeedRequired
|
||||
})
|
||||
}
|
||||
if (filterGroup === 'feed-open' && !includeRSSFeed) {
|
||||
libraryItemIncludes.push({
|
||||
model: Database.feedModel,
|
||||
required: true
|
||||
})
|
||||
} else if (filterGroup === 'share-open') {
|
||||
|
||||
if (filterGroup === 'share-open') {
|
||||
bookIncludes.push({
|
||||
model: Database.mediaItemShareModel,
|
||||
required: true
|
||||
@@ -608,7 +612,7 @@ module.exports = {
|
||||
}
|
||||
|
||||
const findAndCountAll = process.env.QUERY_PROFILING ? profile(this.findAndCountAll) : this.findAndCountAll
|
||||
const { rows: books, count } = await findAndCountAll(findOptions, limit, offset)
|
||||
const { rows: books, count } = await findAndCountAll(findOptions, limit, offset, !filterGroup)
|
||||
|
||||
const libraryItems = books.map((bookExpanded) => {
|
||||
const libraryItem = bookExpanded.libraryItem
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
const Sequelize = require('sequelize')
|
||||
const Database = require('../../Database')
|
||||
const Logger = require('../../Logger')
|
||||
const { profile } = require('../../utils/profiler')
|
||||
const stringifySequelizeQuery = require('../stringifySequelizeQuery')
|
||||
|
||||
const countCache = new Map()
|
||||
|
||||
module.exports = {
|
||||
/**
|
||||
@@ -84,9 +88,9 @@ module.exports = {
|
||||
return [[Sequelize.literal(`\`podcast\`.\`author\` COLLATE NOCASE ${nullDir}`)]]
|
||||
} else if (sortBy === 'media.metadata.title') {
|
||||
if (global.ServerSettings.sortingIgnorePrefix) {
|
||||
return [[Sequelize.literal('`podcast`.`titleIgnorePrefix` COLLATE NOCASE'), dir]]
|
||||
return [[Sequelize.literal('`libraryItem`.`titleIgnorePrefix` COLLATE NOCASE'), dir]]
|
||||
} else {
|
||||
return [[Sequelize.literal('`podcast`.`title` COLLATE NOCASE'), dir]]
|
||||
return [[Sequelize.literal('`libraryItem`.`title` COLLATE NOCASE'), dir]]
|
||||
}
|
||||
} else if (sortBy === 'media.numTracks') {
|
||||
return [['numEpisodes', dir]]
|
||||
@@ -96,6 +100,34 @@ module.exports = {
|
||||
return []
|
||||
},
|
||||
|
||||
clearCountCache(model, hook) {
|
||||
Logger.debug(`[LibraryItemsPodcastFilters] ${model}.${hook}: Clearing count cache`)
|
||||
countCache.clear()
|
||||
},
|
||||
|
||||
async findAndCountAll(findOptions, model, limit, offset, useCountCache) {
|
||||
if (useCountCache) {
|
||||
const countCacheKey = stringifySequelizeQuery(findOptions)
|
||||
Logger.debug(`[LibraryItemsPodcastFilters] countCacheKey: ${countCacheKey}`)
|
||||
if (!countCache.has(countCacheKey)) {
|
||||
const count = await model.count(findOptions)
|
||||
countCache.set(countCacheKey, count)
|
||||
}
|
||||
|
||||
findOptions.limit = limit || null
|
||||
findOptions.offset = offset
|
||||
|
||||
const rows = await model.findAll(findOptions)
|
||||
|
||||
return { rows, count: countCache.get(countCacheKey) }
|
||||
}
|
||||
|
||||
findOptions.limit = limit || null
|
||||
findOptions.offset = offset
|
||||
|
||||
return await model.findAndCountAll(findOptions)
|
||||
},
|
||||
|
||||
/**
|
||||
* Get library items for podcast media type using filter and sort
|
||||
* @param {string} libraryId
|
||||
@@ -120,7 +152,8 @@ module.exports = {
|
||||
if (includeRSSFeed) {
|
||||
libraryItemIncludes.push({
|
||||
model: Database.feedModel,
|
||||
required: filterGroup === 'feed-open'
|
||||
required: filterGroup === 'feed-open',
|
||||
separate: true
|
||||
})
|
||||
}
|
||||
if (filterGroup === 'issues') {
|
||||
@@ -139,9 +172,6 @@ module.exports = {
|
||||
}
|
||||
|
||||
const podcastIncludes = []
|
||||
if (includeNumEpisodesIncomplete) {
|
||||
podcastIncludes.push([Sequelize.literal(`(SELECT count(*) FROM podcastEpisodes pe LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = pe.id AND mp.userId = :userId WHERE pe.podcastId = podcast.id AND (mp.isFinished = 0 OR mp.isFinished IS NULL))`), 'numEpisodesIncomplete'])
|
||||
}
|
||||
|
||||
let { mediaWhere, replacements } = this.getMediaGroupQuery(filterGroup, filterValue)
|
||||
replacements.userId = user.id
|
||||
@@ -153,12 +183,12 @@ module.exports = {
|
||||
replacements = { ...replacements, ...userPermissionPodcastWhere.replacements }
|
||||
podcastWhere.push(...userPermissionPodcastWhere.podcastWhere)
|
||||
|
||||
const { rows: podcasts, count } = await Database.podcastModel.findAndCountAll({
|
||||
const findOptions = {
|
||||
where: podcastWhere,
|
||||
replacements,
|
||||
distinct: true,
|
||||
attributes: {
|
||||
include: [[Sequelize.literal(`(SELECT count(*) FROM podcastEpisodes pe WHERE pe.podcastId = podcast.id)`), 'numEpisodes'], ...podcastIncludes]
|
||||
include: [...podcastIncludes]
|
||||
},
|
||||
include: [
|
||||
{
|
||||
@@ -169,10 +199,12 @@ module.exports = {
|
||||
}
|
||||
],
|
||||
order: this.getOrder(sortBy, sortDesc),
|
||||
subQuery: false,
|
||||
limit: limit || null,
|
||||
offset
|
||||
})
|
||||
subQuery: false
|
||||
}
|
||||
|
||||
const findAndCountAll = process.env.QUERY_PROFILING ? profile(this.findAndCountAll) : this.findAndCountAll
|
||||
|
||||
const { rows: podcasts, count } = await findAndCountAll(findOptions, Database.podcastModel, limit, offset, !filterGroup)
|
||||
|
||||
const libraryItems = podcasts.map((podcastExpanded) => {
|
||||
const libraryItem = podcastExpanded.libraryItem
|
||||
@@ -183,11 +215,15 @@ module.exports = {
|
||||
if (libraryItem.feeds?.length) {
|
||||
libraryItem.rssFeed = libraryItem.feeds[0]
|
||||
}
|
||||
if (podcast.dataValues.numEpisodesIncomplete) {
|
||||
libraryItem.numEpisodesIncomplete = podcast.dataValues.numEpisodesIncomplete
|
||||
}
|
||||
if (podcast.dataValues.numEpisodes) {
|
||||
podcast.numEpisodes = podcast.dataValues.numEpisodes
|
||||
|
||||
if (includeNumEpisodesIncomplete) {
|
||||
const numEpisodesComplete = user.mediaProgresses.reduce((acc, mp) => {
|
||||
if (mp.podcastId === podcast.id && mp.isFinished) {
|
||||
acc += 1
|
||||
}
|
||||
return acc
|
||||
}, 0)
|
||||
libraryItem.numEpisodesIncomplete = podcast.numEpisodes - numEpisodesComplete
|
||||
}
|
||||
|
||||
libraryItem.media = podcast
|
||||
@@ -268,28 +304,31 @@ module.exports = {
|
||||
|
||||
const userPermissionPodcastWhere = this.getUserPermissionPodcastWhereQuery(user)
|
||||
|
||||
const { rows: podcastEpisodes, count } = await Database.podcastEpisodeModel.findAndCountAll({
|
||||
const findOptions = {
|
||||
where: podcastEpisodeWhere,
|
||||
replacements: userPermissionPodcastWhere.replacements,
|
||||
include: [
|
||||
{
|
||||
model: Database.podcastModel,
|
||||
required: true,
|
||||
where: userPermissionPodcastWhere.podcastWhere,
|
||||
include: [
|
||||
{
|
||||
model: Database.libraryItemModel,
|
||||
required: true,
|
||||
where: libraryItemWhere
|
||||
}
|
||||
]
|
||||
},
|
||||
...podcastEpisodeIncludes
|
||||
],
|
||||
distinct: true,
|
||||
subQuery: false,
|
||||
order: podcastEpisodeOrder,
|
||||
limit,
|
||||
offset
|
||||
})
|
||||
order: podcastEpisodeOrder
|
||||
}
|
||||
|
||||
const findAndCountAll = process.env.QUERY_PROFILING ? profile(this.findAndCountAll) : this.findAndCountAll
|
||||
|
||||
const { rows: podcastEpisodes, count } = await findAndCountAll(findOptions, Database.podcastEpisodeModel, limit, offset, !filterGroup)
|
||||
|
||||
const libraryItems = podcastEpisodes.map((ep) => {
|
||||
const libraryItem = ep.podcast.libraryItem
|
||||
|
||||
@@ -1,34 +1,25 @@
|
||||
function stringifySequelizeQuery(findOptions) {
|
||||
// Helper function to handle symbols in nested objects
|
||||
function handleSymbols(obj) {
|
||||
if (!obj || typeof obj !== 'object') return obj
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(handleSymbols)
|
||||
}
|
||||
|
||||
const newObj = {}
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
// Handle Symbol keys from Object.getOwnPropertySymbols
|
||||
Object.getOwnPropertySymbols(obj).forEach((sym) => {
|
||||
newObj[`__Op.${sym.toString()}`] = handleSymbols(obj[sym])
|
||||
})
|
||||
|
||||
// Handle regular keys
|
||||
if (typeof key === 'string') {
|
||||
if (value && typeof value === 'object' && Object.getPrototypeOf(value) === Symbol.prototype) {
|
||||
// Handle Symbol values
|
||||
newObj[key] = `__Op.${value.toString()}`
|
||||
} else {
|
||||
// Recursively handle nested objects
|
||||
newObj[key] = handleSymbols(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
return newObj
|
||||
function isClass(func) {
|
||||
return typeof func === 'function' && /^class\s/.test(func.toString())
|
||||
}
|
||||
|
||||
const sanitizedOptions = handleSymbols(findOptions)
|
||||
return JSON.stringify(sanitizedOptions)
|
||||
function replacer(key, value) {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
const symbols = Object.getOwnPropertySymbols(value).reduce((acc, sym) => {
|
||||
acc[sym.toString()] = value[sym]
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
return { ...value, ...symbols }
|
||||
}
|
||||
|
||||
if (isClass(value)) {
|
||||
return `${value.name}`
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
return JSON.stringify(findOptions, replacer)
|
||||
}
|
||||
module.exports = stringifySequelizeQuery
|
||||
|
||||
265
test/server/migrations/v2.19.4-improve-podcast-queries.test.js
Normal file
265
test/server/migrations/v2.19.4-improve-podcast-queries.test.js
Normal file
@@ -0,0 +1,265 @@
|
||||
const chai = require('chai')
|
||||
const sinon = require('sinon')
|
||||
const { expect } = chai
|
||||
|
||||
const { DataTypes, Sequelize } = require('sequelize')
|
||||
const Logger = require('../../../server/Logger')
|
||||
|
||||
const { up, down } = require('../../../server/migrations/v2.19.4-improve-podcast-queries')
|
||||
|
||||
describe('Migration v2.19.4-improve-podcast-queries', () => {
|
||||
let sequelize
|
||||
let queryInterface
|
||||
let loggerInfoStub
|
||||
|
||||
beforeEach(async () => {
|
||||
sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
|
||||
queryInterface = sequelize.getQueryInterface()
|
||||
loggerInfoStub = sinon.stub(Logger, 'info')
|
||||
|
||||
await queryInterface.createTable('libraryItems', {
|
||||
id: { type: DataTypes.INTEGER, allowNull: false, primaryKey: true, unique: true },
|
||||
mediaId: { type: DataTypes.INTEGER, allowNull: false },
|
||||
title: { type: DataTypes.STRING, allowNull: true },
|
||||
titleIgnorePrefix: { type: DataTypes.STRING, allowNull: true }
|
||||
})
|
||||
await queryInterface.createTable('podcasts', {
|
||||
id: { type: DataTypes.INTEGER, allowNull: false, primaryKey: true, unique: true },
|
||||
title: { type: DataTypes.STRING, allowNull: false },
|
||||
titleIgnorePrefix: { type: DataTypes.STRING, allowNull: false }
|
||||
})
|
||||
|
||||
await queryInterface.createTable('podcastEpisodes', {
|
||||
id: { type: DataTypes.INTEGER, allowNull: false, primaryKey: true, unique: true },
|
||||
podcastId: { type: DataTypes.INTEGER, allowNull: false, references: { model: 'podcasts', key: 'id', onDelete: 'CASCADE' } }
|
||||
})
|
||||
|
||||
await queryInterface.createTable('mediaProgresses', {
|
||||
id: { type: DataTypes.INTEGER, allowNull: false, primaryKey: true, unique: true },
|
||||
userId: { type: DataTypes.INTEGER, allowNull: false },
|
||||
mediaItemId: { type: DataTypes.INTEGER, allowNull: false },
|
||||
mediaItemType: { type: DataTypes.STRING, allowNull: false },
|
||||
isFinished: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false }
|
||||
})
|
||||
|
||||
await queryInterface.bulkInsert('libraryItems', [
|
||||
{ id: 1, mediaId: 1, title: null, titleIgnorePrefix: null },
|
||||
{ id: 2, mediaId: 2, title: null, titleIgnorePrefix: null }
|
||||
])
|
||||
|
||||
await queryInterface.bulkInsert('podcasts', [
|
||||
{ id: 1, title: 'The Podcast 1', titleIgnorePrefix: 'Podcast 1, The' },
|
||||
{ id: 2, title: 'The Podcast 2', titleIgnorePrefix: 'Podcast 2, The' }
|
||||
])
|
||||
|
||||
await queryInterface.bulkInsert('podcastEpisodes', [
|
||||
{ id: 1, podcastId: 1 },
|
||||
{ id: 2, podcastId: 1 },
|
||||
{ id: 3, podcastId: 2 }
|
||||
])
|
||||
|
||||
await queryInterface.bulkInsert('mediaProgresses', [
|
||||
{ id: 1, userId: 1, mediaItemId: 1, mediaItemType: 'podcastEpisode', isFinished: 1 },
|
||||
{ id: 2, userId: 1, mediaItemId: 2, mediaItemType: 'podcastEpisode', isFinished: 0 },
|
||||
{ id: 3, userId: 1, mediaItemId: 3, mediaItemType: 'podcastEpisode', isFinished: 1 },
|
||||
{ id: 4, userId: 2, mediaItemId: 1, mediaItemType: 'podcastEpisode', isFinished: 0 },
|
||||
{ id: 5, userId: 2, mediaItemId: 2, mediaItemType: 'podcastEpisode', isFinished: 1 },
|
||||
{ id: 6, userId: 2, mediaItemId: 3, mediaItemType: 'podcastEpisode', isFinished: 0 },
|
||||
{ id: 7, userId: 1, mediaItemId: 1, mediaItemType: 'book', isFinished: 1 },
|
||||
{ id: 8, userId: 1, mediaItemId: 2, mediaItemType: 'book', isFinished: 0 }
|
||||
])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
describe('up', () => {
|
||||
it('should add numEpisodes column to podcasts', async () => {
|
||||
await up({ context: { queryInterface, logger: Logger } })
|
||||
|
||||
const [podcasts] = await queryInterface.sequelize.query('SELECT * FROM podcasts')
|
||||
expect(podcasts).to.deep.equal([
|
||||
{ id: 1, numEpisodes: 2, title: 'The Podcast 1', titleIgnorePrefix: 'Podcast 1, The' },
|
||||
{ id: 2, numEpisodes: 1, title: 'The Podcast 2', titleIgnorePrefix: 'Podcast 2, The' }
|
||||
])
|
||||
|
||||
// Make sure podcastEpisodes are not affected due to ON DELETE CASCADE
|
||||
const [podcastEpisodes] = await queryInterface.sequelize.query('SELECT * FROM podcastEpisodes')
|
||||
expect(podcastEpisodes).to.deep.equal([
|
||||
{ id: 1, podcastId: 1 },
|
||||
{ id: 2, podcastId: 1 },
|
||||
{ id: 3, podcastId: 2 }
|
||||
])
|
||||
})
|
||||
|
||||
it('should add podcastId column to mediaProgresses', async () => {
|
||||
await up({ context: { queryInterface, logger: Logger } })
|
||||
|
||||
const [mediaProgresses] = await queryInterface.sequelize.query('SELECT * FROM mediaProgresses')
|
||||
expect(mediaProgresses).to.deep.equal([
|
||||
{ id: 1, userId: 1, mediaItemId: 1, mediaItemType: 'podcastEpisode', podcastId: 1, isFinished: 1 },
|
||||
{ id: 2, userId: 1, mediaItemId: 2, mediaItemType: 'podcastEpisode', podcastId: 1, isFinished: 0 },
|
||||
{ id: 3, userId: 1, mediaItemId: 3, mediaItemType: 'podcastEpisode', podcastId: 2, isFinished: 1 },
|
||||
{ id: 4, userId: 2, mediaItemId: 1, mediaItemType: 'podcastEpisode', podcastId: 1, isFinished: 0 },
|
||||
{ id: 5, userId: 2, mediaItemId: 2, mediaItemType: 'podcastEpisode', podcastId: 1, isFinished: 1 },
|
||||
{ id: 6, userId: 2, mediaItemId: 3, mediaItemType: 'podcastEpisode', podcastId: 2, isFinished: 0 },
|
||||
{ id: 7, userId: 1, mediaItemId: 1, mediaItemType: 'book', podcastId: null, isFinished: 1 },
|
||||
{ id: 8, userId: 1, mediaItemId: 2, mediaItemType: 'book', podcastId: null, isFinished: 0 }
|
||||
])
|
||||
})
|
||||
|
||||
it('should copy title and titleIgnorePrefix from podcasts to libraryItems', async () => {
|
||||
await up({ context: { queryInterface, logger: Logger } })
|
||||
|
||||
const [libraryItems] = await queryInterface.sequelize.query('SELECT * FROM libraryItems')
|
||||
expect(libraryItems).to.deep.equal([
|
||||
{ id: 1, mediaId: 1, title: 'The Podcast 1', titleIgnorePrefix: 'Podcast 1, The' },
|
||||
{ id: 2, mediaId: 2, title: 'The Podcast 2', titleIgnorePrefix: 'Podcast 2, The' }
|
||||
])
|
||||
})
|
||||
|
||||
it('should add trigger to update title in libraryItems', async () => {
|
||||
await up({ context: { queryInterface, logger: Logger } })
|
||||
|
||||
const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_title_from_podcasts_title'`)
|
||||
expect(count).to.equal(1)
|
||||
})
|
||||
|
||||
it('should add trigger to update titleIgnorePrefix in libraryItems', async () => {
|
||||
await up({ context: { queryInterface, logger: Logger } })
|
||||
|
||||
const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_title_ignore_prefix_from_podcasts_title_ignore_prefix'`)
|
||||
expect(count).to.equal(1)
|
||||
})
|
||||
|
||||
it('should be idempotent', async () => {
|
||||
await up({ context: { queryInterface, logger: Logger } })
|
||||
await up({ context: { queryInterface, logger: Logger } })
|
||||
|
||||
const [podcasts] = await queryInterface.sequelize.query('SELECT * FROM podcasts')
|
||||
expect(podcasts).to.deep.equal([
|
||||
{ id: 1, numEpisodes: 2, title: 'The Podcast 1', titleIgnorePrefix: 'Podcast 1, The' },
|
||||
{ id: 2, numEpisodes: 1, title: 'The Podcast 2', titleIgnorePrefix: 'Podcast 2, The' }
|
||||
])
|
||||
|
||||
const [mediaProgresses] = await queryInterface.sequelize.query('SELECT * FROM mediaProgresses')
|
||||
expect(mediaProgresses).to.deep.equal([
|
||||
{ id: 1, userId: 1, mediaItemId: 1, mediaItemType: 'podcastEpisode', podcastId: 1, isFinished: 1 },
|
||||
{ id: 2, userId: 1, mediaItemId: 2, mediaItemType: 'podcastEpisode', podcastId: 1, isFinished: 0 },
|
||||
{ id: 3, userId: 1, mediaItemId: 3, mediaItemType: 'podcastEpisode', podcastId: 2, isFinished: 1 },
|
||||
{ id: 4, userId: 2, mediaItemId: 1, mediaItemType: 'podcastEpisode', podcastId: 1, isFinished: 0 },
|
||||
{ id: 5, userId: 2, mediaItemId: 2, mediaItemType: 'podcastEpisode', podcastId: 1, isFinished: 1 },
|
||||
{ id: 6, userId: 2, mediaItemId: 3, mediaItemType: 'podcastEpisode', podcastId: 2, isFinished: 0 },
|
||||
{ id: 7, userId: 1, mediaItemId: 1, mediaItemType: 'book', podcastId: null, isFinished: 1 },
|
||||
{ id: 8, userId: 1, mediaItemId: 2, mediaItemType: 'book', podcastId: null, isFinished: 0 }
|
||||
])
|
||||
|
||||
const [libraryItems] = await queryInterface.sequelize.query('SELECT * FROM libraryItems')
|
||||
expect(libraryItems).to.deep.equal([
|
||||
{ id: 1, mediaId: 1, title: 'The Podcast 1', titleIgnorePrefix: 'Podcast 1, The' },
|
||||
{ id: 2, mediaId: 2, title: 'The Podcast 2', titleIgnorePrefix: 'Podcast 2, The' }
|
||||
])
|
||||
|
||||
const [[{ count: count1 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_title_from_podcasts_title'`)
|
||||
expect(count1).to.equal(1)
|
||||
|
||||
const [[{ count: count2 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_title_ignore_prefix_from_podcasts_title_ignore_prefix'`)
|
||||
expect(count2).to.equal(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('down', () => {
|
||||
it('should remove numEpisodes column from podcasts', async () => {
|
||||
await up({ context: { queryInterface, logger: Logger } })
|
||||
try {
|
||||
await down({ context: { queryInterface, logger: Logger } })
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
|
||||
const [podcasts] = await queryInterface.sequelize.query('SELECT * FROM podcasts')
|
||||
expect(podcasts).to.deep.equal([
|
||||
{ id: 1, title: 'The Podcast 1', titleIgnorePrefix: 'Podcast 1, The' },
|
||||
{ id: 2, title: 'The Podcast 2', titleIgnorePrefix: 'Podcast 2, The' }
|
||||
])
|
||||
|
||||
// Make sure podcastEpisodes are not affected due to ON DELETE CASCADE
|
||||
const [podcastEpisodes] = await queryInterface.sequelize.query('SELECT * FROM podcastEpisodes')
|
||||
expect(podcastEpisodes).to.deep.equal([
|
||||
{ id: 1, podcastId: 1 },
|
||||
{ id: 2, podcastId: 1 },
|
||||
{ id: 3, podcastId: 2 }
|
||||
])
|
||||
})
|
||||
|
||||
it('should remove podcastId column from mediaProgresses', async () => {
|
||||
await up({ context: { queryInterface, logger: Logger } })
|
||||
await down({ context: { queryInterface, logger: Logger } })
|
||||
|
||||
const [mediaProgresses] = await queryInterface.sequelize.query('SELECT * FROM mediaProgresses')
|
||||
expect(mediaProgresses).to.deep.equal([
|
||||
{ id: 1, userId: 1, mediaItemId: 1, mediaItemType: 'podcastEpisode', isFinished: 1 },
|
||||
{ id: 2, userId: 1, mediaItemId: 2, mediaItemType: 'podcastEpisode', isFinished: 0 },
|
||||
{ id: 3, userId: 1, mediaItemId: 3, mediaItemType: 'podcastEpisode', isFinished: 1 },
|
||||
{ id: 4, userId: 2, mediaItemId: 1, mediaItemType: 'podcastEpisode', isFinished: 0 },
|
||||
{ id: 5, userId: 2, mediaItemId: 2, mediaItemType: 'podcastEpisode', isFinished: 1 },
|
||||
{ id: 6, userId: 2, mediaItemId: 3, mediaItemType: 'podcastEpisode', isFinished: 0 },
|
||||
{ id: 7, userId: 1, mediaItemId: 1, mediaItemType: 'book', isFinished: 1 },
|
||||
{ id: 8, userId: 1, mediaItemId: 2, mediaItemType: 'book', isFinished: 0 }
|
||||
])
|
||||
})
|
||||
|
||||
it('should remove trigger to update title in libraryItems', async () => {
|
||||
await up({ context: { queryInterface, logger: Logger } })
|
||||
await down({ context: { queryInterface, logger: Logger } })
|
||||
|
||||
const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_title_from_podcasts_title'`)
|
||||
expect(count).to.equal(0)
|
||||
})
|
||||
|
||||
it('should remove trigger to update titleIgnorePrefix in libraryItems', async () => {
|
||||
await up({ context: { queryInterface, logger: Logger } })
|
||||
await down({ context: { queryInterface, logger: Logger } })
|
||||
|
||||
const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_title_ignore_prefix_from_podcasts_title_ignore_prefix'`)
|
||||
expect(count).to.equal(0)
|
||||
})
|
||||
|
||||
it('should be idempotent', async () => {
|
||||
await up({ context: { queryInterface, logger: Logger } })
|
||||
await down({ context: { queryInterface, logger: Logger } })
|
||||
await down({ context: { queryInterface, logger: Logger } })
|
||||
|
||||
const [podcasts] = await queryInterface.sequelize.query('SELECT * FROM podcasts')
|
||||
expect(podcasts).to.deep.equal([
|
||||
{ id: 1, title: 'The Podcast 1', titleIgnorePrefix: 'Podcast 1, The' },
|
||||
{ id: 2, title: 'The Podcast 2', titleIgnorePrefix: 'Podcast 2, The' }
|
||||
])
|
||||
|
||||
const [mediaProgresses] = await queryInterface.sequelize.query('SELECT * FROM mediaProgresses')
|
||||
expect(mediaProgresses).to.deep.equal([
|
||||
{ id: 1, userId: 1, mediaItemId: 1, mediaItemType: 'podcastEpisode', isFinished: 1 },
|
||||
{ id: 2, userId: 1, mediaItemId: 2, mediaItemType: 'podcastEpisode', isFinished: 0 },
|
||||
{ id: 3, userId: 1, mediaItemId: 3, mediaItemType: 'podcastEpisode', isFinished: 1 },
|
||||
{ id: 4, userId: 2, mediaItemId: 1, mediaItemType: 'podcastEpisode', isFinished: 0 },
|
||||
{ id: 5, userId: 2, mediaItemId: 2, mediaItemType: 'podcastEpisode', isFinished: 1 },
|
||||
{ id: 6, userId: 2, mediaItemId: 3, mediaItemType: 'podcastEpisode', isFinished: 0 },
|
||||
{ id: 7, userId: 1, mediaItemId: 1, mediaItemType: 'book', isFinished: 1 },
|
||||
{ id: 8, userId: 1, mediaItemId: 2, mediaItemType: 'book', isFinished: 0 }
|
||||
])
|
||||
|
||||
const [libraryItems] = await queryInterface.sequelize.query('SELECT * FROM libraryItems')
|
||||
expect(libraryItems).to.deep.equal([
|
||||
{ id: 1, mediaId: 1, title: 'The Podcast 1', titleIgnorePrefix: 'Podcast 1, The' },
|
||||
{ id: 2, mediaId: 2, title: 'The Podcast 2', titleIgnorePrefix: 'Podcast 2, The' }
|
||||
])
|
||||
|
||||
const [[{ count: count1 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_title_from_podcasts_title'`)
|
||||
expect(count1).to.equal(0)
|
||||
|
||||
const [[{ count: count2 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_title_ignore_prefix_from_podcasts_title_ignore_prefix'`)
|
||||
expect(count2).to.equal(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
127
test/server/utils/fileUtils.test.js
Normal file
127
test/server/utils/fileUtils.test.js
Normal file
@@ -0,0 +1,127 @@
|
||||
const chai = require('chai')
|
||||
const expect = chai.expect
|
||||
const sinon = require('sinon')
|
||||
const fileUtils = require('../../../server/utils/fileUtils')
|
||||
const fs = require('fs')
|
||||
const Logger = require('../../../server/Logger')
|
||||
|
||||
describe('fileUtils', () => {
|
||||
it('shouldIgnoreFile', () => {
|
||||
global.isWin = process.platform === 'win32'
|
||||
|
||||
const testCases = [
|
||||
{ path: 'test.txt', expected: null },
|
||||
{ path: 'folder/test.mp3', expected: null },
|
||||
{ path: 'normal/path/file.m4b', expected: null },
|
||||
{ path: 'test.txt.part', expected: '.part file' },
|
||||
{ path: 'test.txt.tmp', expected: '.tmp file' },
|
||||
{ path: 'test.txt.crdownload', expected: '.crdownload file' },
|
||||
{ path: 'test.txt.download', expected: '.download file' },
|
||||
{ path: 'test.txt.bak', expected: '.bak file' },
|
||||
{ path: 'test.txt.old', expected: '.old file' },
|
||||
{ path: 'test.txt.temp', expected: '.temp file' },
|
||||
{ path: 'test.txt.tempfile', expected: '.tempfile file' },
|
||||
{ path: 'test.txt.tempfile~', expected: '.tempfile~ file' },
|
||||
{ path: '.gitignore', expected: 'dotfile' },
|
||||
{ path: 'folder/.hidden', expected: 'dotfile' },
|
||||
{ path: '.git/config', expected: 'dotpath' },
|
||||
{ path: 'path/.hidden/file.txt', expected: 'dotpath' },
|
||||
{ path: '@eaDir', expected: '@eaDir directory' },
|
||||
{ path: 'folder/@eaDir', expected: '@eaDir directory' },
|
||||
{ path: 'path/@eaDir/file.txt', expected: '@eaDir directory' },
|
||||
{ path: '.hidden/test.tmp', expected: 'dotpath' },
|
||||
{ path: '@eaDir/test.part', expected: '@eaDir directory' }
|
||||
]
|
||||
|
||||
testCases.forEach(({ path, expected }) => {
|
||||
const result = fileUtils.shouldIgnoreFile(path)
|
||||
expect(result).to.equal(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('recurseFiles', () => {
|
||||
let readdirStub, realpathStub, statStub
|
||||
|
||||
beforeEach(() => {
|
||||
global.isWin = process.platform === 'win32'
|
||||
|
||||
// Mock file structure with normalized paths
|
||||
const mockDirContents = new Map([
|
||||
['/test', ['file1.mp3', 'subfolder', 'ignoreme', 'temp.mp3.tmp']],
|
||||
['/test/subfolder', ['file2.m4b']],
|
||||
['/test/ignoreme', ['.ignore', 'ignored.mp3']]
|
||||
])
|
||||
|
||||
const mockStats = new Map([
|
||||
['/test/file1.mp3', { isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '1' }],
|
||||
['/test/subfolder', { isDirectory: () => true, size: 0, mtimeMs: Date.now(), ino: '2' }],
|
||||
['/test/subfolder/file2.m4b', { isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '3' }],
|
||||
['/test/ignoreme', { isDirectory: () => true, size: 0, mtimeMs: Date.now(), ino: '4' }],
|
||||
['/test/ignoreme/.ignore', { isDirectory: () => false, size: 0, mtimeMs: Date.now(), ino: '5' }],
|
||||
['/test/ignoreme/ignored.mp3', { isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '6' }],
|
||||
['/test/temp.mp3.tmp', { isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '7' }]
|
||||
])
|
||||
|
||||
// Stub fs.readdir
|
||||
readdirStub = sinon.stub(fs, 'readdir')
|
||||
readdirStub.callsFake((path, callback) => {
|
||||
const contents = mockDirContents.get(path)
|
||||
if (contents) {
|
||||
callback(null, contents)
|
||||
} else {
|
||||
callback(new Error(`ENOENT: no such file or directory, scandir '${path}'`))
|
||||
}
|
||||
})
|
||||
|
||||
// Stub fs.realpath
|
||||
realpathStub = sinon.stub(fs, 'realpath')
|
||||
realpathStub.callsFake((path, callback) => {
|
||||
// Return normalized path
|
||||
callback(null, fileUtils.filePathToPOSIX(path).replace(/\/$/, ''))
|
||||
})
|
||||
|
||||
// Stub fs.stat
|
||||
statStub = sinon.stub(fs, 'stat')
|
||||
statStub.callsFake((path, callback) => {
|
||||
const normalizedPath = fileUtils.filePathToPOSIX(path).replace(/\/$/, '')
|
||||
const stats = mockStats.get(normalizedPath)
|
||||
if (stats) {
|
||||
callback(null, stats)
|
||||
} else {
|
||||
callback(new Error(`ENOENT: no such file or directory, stat '${normalizedPath}'`))
|
||||
}
|
||||
})
|
||||
|
||||
// Stub Logger
|
||||
sinon.stub(Logger, 'debug')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
it('should return filtered file list', async () => {
|
||||
const files = await fileUtils.recurseFiles('/test')
|
||||
expect(files).to.be.an('array')
|
||||
expect(files).to.have.lengthOf(2)
|
||||
|
||||
expect(files[0]).to.deep.equal({
|
||||
name: 'file1.mp3',
|
||||
path: 'file1.mp3',
|
||||
reldirpath: '',
|
||||
fullpath: '/test/file1.mp3',
|
||||
extension: '.mp3',
|
||||
deep: 0
|
||||
})
|
||||
|
||||
expect(files[1]).to.deep.equal({
|
||||
name: 'file2.m4b',
|
||||
path: 'subfolder/file2.m4b',
|
||||
reldirpath: 'subfolder',
|
||||
fullpath: '/test/subfolder/file2.m4b',
|
||||
extension: '.m4b',
|
||||
deep: 1
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
52
test/server/utils/stringifySequeslizeQuery.test.js
Normal file
52
test/server/utils/stringifySequeslizeQuery.test.js
Normal file
@@ -0,0 +1,52 @@
|
||||
const { expect } = require('chai')
|
||||
const stringifySequelizeQuery = require('../../../server/utils/stringifySequelizeQuery')
|
||||
const Sequelize = require('sequelize')
|
||||
|
||||
class DummyClass {}
|
||||
|
||||
describe('stringifySequelizeQuery', () => {
|
||||
it('should stringify a sequelize query containing an op', () => {
|
||||
const query = {
|
||||
where: {
|
||||
name: 'John',
|
||||
age: {
|
||||
[Sequelize.Op.gt]: 20
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = stringifySequelizeQuery(query)
|
||||
expect(result).to.equal('{"where":{"name":"John","age":{"Symbol(gt)":20}}}')
|
||||
})
|
||||
|
||||
it('should stringify a sequelize query containing a literal', () => {
|
||||
const query = {
|
||||
order: [[Sequelize.literal('libraryItem.title'), 'ASC']]
|
||||
}
|
||||
|
||||
const result = stringifySequelizeQuery(query)
|
||||
expect(result).to.equal('{"order":{"0":{"0":{"val":"libraryItem.title"},"1":"ASC"}}}')
|
||||
})
|
||||
|
||||
it('should stringify a sequelize query containing a class', () => {
|
||||
const query = {
|
||||
include: [
|
||||
{
|
||||
model: DummyClass
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const result = stringifySequelizeQuery(query)
|
||||
expect(result).to.equal('{"include":{"0":{"model":"DummyClass"}}}')
|
||||
})
|
||||
|
||||
it('should ignore non-class functions', () => {
|
||||
const query = {
|
||||
logging: (query) => console.log(query)
|
||||
}
|
||||
|
||||
const result = stringifySequelizeQuery(query)
|
||||
expect(result).to.equal('{}')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user