mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-12-31 11:38:47 -05:00
Compare commits
49 Commits
fix-chapte
...
v2.19.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
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)
|
||||
|
||||
@@ -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' }">
|
||||
|
||||
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.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.19.2",
|
||||
"version": "2.19.4",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@nuxtjs/axios": "^5.13.6",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.19.2",
|
||||
"version": "2.19.4",
|
||||
"buildNumber": 1,
|
||||
"description": "Self-hosted audiobook and podcast client",
|
||||
"main": "index.js",
|
||||
|
||||
@@ -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": "Потребителят е изтрит"
|
||||
}
|
||||
|
||||
@@ -678,7 +678,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",
|
||||
|
||||
@@ -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",
|
||||
@@ -231,6 +231,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 +243,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}",
|
||||
@@ -312,9 +313,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 +330,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 +345,7 @@
|
||||
"LabelFontItalic": "Kursiv",
|
||||
"LabelFontScale": "Skala på typsnitt",
|
||||
"LabelFontStrikethrough": "Genomstruken",
|
||||
"LabelFull": "Komplett",
|
||||
"LabelGenre": "Kategori",
|
||||
"LabelGenres": "Kategorier",
|
||||
"LabelHardDeleteFile": "Hård radering av fil",
|
||||
@@ -355,7 +360,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",
|
||||
@@ -416,7 +421,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",
|
||||
@@ -467,12 +472,13 @@
|
||||
"LabelPublishYear": "Publiceringsår",
|
||||
"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 +556,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 +700,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 +713,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 +743,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",
|
||||
@@ -797,12 +805,15 @@
|
||||
"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}\"",
|
||||
"MessageTaskOpmlImportFeedPodcastDescription": "Skapar podcast \"{0}\"",
|
||||
"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",
|
||||
@@ -814,7 +825,7 @@
|
||||
"MessageTaskScanningLibrary": "Biblioteket \"{0}\" har skannats",
|
||||
"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 +840,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",
|
||||
@@ -888,9 +902,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 +948,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 +960,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"
|
||||
}
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.19.2",
|
||||
"version": "2.19.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.19.2",
|
||||
"version": "2.19.4",
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"axios": "^0.27.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.19.2",
|
||||
"version": "2.19.4",
|
||||
"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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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,29 @@ module.exports = {
|
||||
return []
|
||||
},
|
||||
|
||||
clearCountCache(model, hook) {
|
||||
Logger.debug(`[LibraryItemsPodcastFilters] ${model}.${hook}: Clearing count cache`)
|
||||
countCache.clear()
|
||||
},
|
||||
|
||||
async findAndCountAll(findOptions, model, limit, offset) {
|
||||
const cacheKey = stringifySequelizeQuery(findOptions)
|
||||
if (!countCache.has(cacheKey)) {
|
||||
const count = await model.count(findOptions)
|
||||
countCache.set(cacheKey, count)
|
||||
}
|
||||
|
||||
findOptions.limit = limit
|
||||
findOptions.offset = offset
|
||||
|
||||
const rows = await model.findAll(findOptions)
|
||||
|
||||
return {
|
||||
rows,
|
||||
count: countCache.get(cacheKey)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get library items for podcast media type using filter and sort
|
||||
* @param {string} libraryId
|
||||
@@ -120,7 +147,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 +167,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 +178,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 +194,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)
|
||||
|
||||
const libraryItems = podcasts.map((podcastExpanded) => {
|
||||
const libraryItem = podcastExpanded.libraryItem
|
||||
@@ -183,11 +210,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 +299,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)
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
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