diff --git a/client/components/app/LazyBookshelf.vue b/client/components/app/LazyBookshelf.vue
index 22ab731d5..2144b899c 100644
--- a/client/components/app/LazyBookshelf.vue
+++ b/client/components/app/LazyBookshelf.vue
@@ -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)
diff --git a/client/package-lock.json b/client/package-lock.json
index 350a58690..2d3955f79 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "audiobookshelf-client",
- "version": "2.19.3",
+ "version": "2.19.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf-client",
- "version": "2.19.3",
+ "version": "2.19.4",
"license": "ISC",
"dependencies": {
"@nuxtjs/axios": "^5.13.6",
diff --git a/client/package.json b/client/package.json
index 6fb5df14e..c11b3be06 100644
--- a/client/package.json
+++ b/client/package.json
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
- "version": "2.19.3",
+ "version": "2.19.4",
"buildNumber": 1,
"description": "Self-hosted audiobook and podcast client",
"main": "index.js",
diff --git a/client/strings/bg.json b/client/strings/bg.json
index 086407bd1..72ca62f53 100644
--- a/client/strings/bg.json
+++ b/client/strings/bg.json
@@ -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 за пренасочване за мобилни приложения. По подразбиране е audiobookshelf://oauth, който може да премахнете или допълните с допълнителни URI за интеграция на приложения от трети страни. Използването на звезда (*) като единствен запис позволява всеки 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 твърдението, което съдържа разширени права за достъп до потребителски действия в приложението, които ще се прилагат за роли, различни от администраторските (ако е конфигурирано). Ако твърдението липсва в отговора, достъпът до ABS ще бъде отказан. Ако липсва една опция, тя ще се третира като false. Уверете се, че твърдението на доставчика на идентичност съответства на очакваната структура:",
+ "LabelOpenIDClaims": "Оставете следните опции празни, за да деактивирате разширеното присвояване на групи, като автоматично ще бъде присвоена групата 'Потребител'.",
+ "LabelOpenIDGroupClaimDescription": "Име на OpenID твърдението, което съдържа списък с групите на потребителя. Обикновено се нарича groups. Ако е конфигурирано, приложението автоматично ще присвоява роли въз основа на членството на потребителя в групи, при условие че тези групи са наименувани без чувствителност към регистъра като '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": "Извлича подзаглавия от имената на папките на аудиокнигите.
Подзаглавията трябва да бъдат разделени с \" - \"
например \"Заглавие на Книга - Тук е Подзаглавито\" има подзаглавие \"Тук е Подзаглавито\"",
"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": "За да ползвате тази функция трябва да имате активна инстанция на Apprise API или на друго АПИ което да обработва тези заявки.
The Apprise API Url-а трябва дае пълния URL път за изпращане на известията, например, ако вашето АПИ ве подава от http://192.168.1.1:8337 трябва да сложитев http://192.168.1.1:8337/notify.",
+ "MessageBackupsDescription": "Резервните копия включват потребители, напредък на потребителите, подробности за елементите в библиотеката, настройки на сървъра и изображения, съхранени в /metadata/items и /metadata/authors. Резервните копия не включват никакви файлове, съхранени в папките на вашата библиотека.",
"MessageBatchQuickMatchDescription": "Бързото Съпоставяне ще опита да добави липсващи корици и метаданни за избраните елементи. Активирайте опциите по-долу, за да позволите на Бързото съпоставяне да презапише съществуващите корици и/или метаданни.",
"MessageBookshelfNoCollections": "Все още нямате създадени колекции",
"MessageBookshelfNoRSSFeeds": "Няма отворени RSS feed-ове",
"MessageBookshelfNoResultsForFilter": "Няма резултат за филтер \"{0}: {1}\"",
+ "MessageBookshelfNoResultsForQuery": "Няма резултати от заявката",
"MessageBookshelfNoSeries": "Нямаш сеЗЙ",
"MessageChapterEndIsAfter": "Краят на главата е след края на вашата аудиокнига",
"MessageChapterErrorFirstNotZero": "Първата глава трябва да започва от 0",
@@ -600,6 +663,8 @@
"MessageConfirmMarkAllEpisodesNotFinished": "Сигурни ли сте, че искате да маркирате всички епизоди като незавършени?",
"MessageConfirmMarkSeriesFinished": "Сигурни ли сте, че искате да маркирате всички книги в тази серия като завършени?",
"MessageConfirmMarkSeriesNotFinished": "Сигурни ли сте, че искате да маркирате всички книги в тази серия като незавършени?",
+ "MessageConfirmPurgeCache": "Изчистването на кеша ще изтрие цялата директория в /metadata/cache.
Сигурни ли сте, че искате да премахнете директорията на кеша?",
+ "MessageConfirmPurgeItemsCache": "Изчистването на кеша на елементите ще изтрие цялата директория в /metadata/cache/items.
Сигурни ли сте?",
"MessageConfirmQuickEmbed": "Внимание! Бързото вграждане няма да архивира вашите аудио файлове. Уверете се, че имате резервно копие на вашите аудио файлове.
Искате ли да продължите?",
"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": "Логовете се съхраняват в /metadata/logs като JSON файлове. Дневниците за сривове се съхраняват в /metadata/logs/crash_logs.txt.",
"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.
Архивите не променят файловете в папките на вашата библиотека. Ако сте активирали настройките на сървъра за съхранение на корици и метаданни в папките на вашата библиотека, те няма да бъдат архивирани или презаписани.
Всички клиенти, използващи вашия сървър, ще бъдат автоматично обновени.",
@@ -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": "Потребителят е изтрит"
}
diff --git a/client/strings/hr.json b/client/strings/hr.json
index 9ac334765..e1d372a7e 100644
--- a/client/strings/hr.json
+++ b/client/strings/hr.json
@@ -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",
diff --git a/client/strings/sv.json b/client/strings/sv.json
index 1bc566a5b..771ec0196 100644
--- a/client/strings/sv.json
+++ b/client/strings/sv.json
@@ -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 /metadata/cache att raderas.
Är du säker på att du vill radera katalogen?",
"MessageConfirmPurgeItemsCache": "När du rensar cashen för föremål kommer katalogen /metadata/cache/items att raderas.
Ä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.
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
den valda källan och fylla i uppgifter som saknas och omslag.
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 tag eller 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",
diff --git a/client/strings/tr.json b/client/strings/tr.json
index 0967ef424..d7a622cd2 100644
--- a/client/strings/tr.json
+++ b/client/strings/tr.json
@@ -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"
+}
diff --git a/package-lock.json b/package-lock.json
index 18c877c00..147a39540 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "audiobookshelf",
- "version": "2.19.3",
+ "version": "2.19.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf",
- "version": "2.19.3",
+ "version": "2.19.4",
"license": "GPL-3.0",
"dependencies": {
"axios": "^0.27.2",
diff --git a/package.json b/package.json
index b00f30c2d..801afddcd 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
- "version": "2.19.3",
+ "version": "2.19.4",
"buildNumber": 1,
"description": "Self-hosted audiobook and podcast server",
"main": "index.js",
diff --git a/server/Database.js b/server/Database.js
index 04d024dfb..498e9e5e7 100644
--- a/server/Database.js
+++ b/server/Database.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
diff --git a/server/controllers/PodcastController.js b/server/controllers/PodcastController.js
index 90b2c3836..c66b4088d 100644
--- a/server/controllers/PodcastController.js
+++ b/server/controllers/PodcastController.js
@@ -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())
}
diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js
index 64d001a39..11e231ddc 100644
--- a/server/managers/PodcastManager.js
+++ b/server/managers/PodcastManager.js
@@ -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 }
)
diff --git a/server/migrations/changelog.md b/server/migrations/changelog.md
index acccef90d..b447970f5 100644
--- a/server/migrations/changelog.md
+++ b/server/migrations/changelog.md
@@ -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 |
diff --git a/server/migrations/v2.19.4-improve-podcast-queries.js b/server/migrations/v2.19.4-improve-podcast-queries.js
new file mode 100644
index 000000000..689795c31
--- /dev/null
+++ b/server/migrations/v2.19.4-improve-podcast-queries.js
@@ -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} - 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} - 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 }
diff --git a/server/models/MediaProgress.js b/server/models/MediaProgress.js
index bb8276826..3218d2e9f 100644
--- a/server/models/MediaProgress.js
+++ b/server/models/MediaProgress.js
@@ -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'
})
diff --git a/server/models/Podcast.js b/server/models/Podcast.js
index ce47754be..fa27821db 100644
--- a/server/models/Podcast.js
+++ b/server/models/Podcast.js
@@ -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() {
diff --git a/server/models/PodcastEpisode.js b/server/models/PodcastEpisode.js
index 08baa4be3..4746f3150 100644
--- a/server/models/PodcastEpisode.js
+++ b/server/models/PodcastEpisode.js
@@ -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() {
diff --git a/server/models/User.js b/server/models/User.js
index 56d6ba0ea..12f2f4bbc 100644
--- a/server/models/User.js
+++ b/server/models/User.js
@@ -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),
diff --git a/server/scanner/PodcastScanner.js b/server/scanner/PodcastScanner.js
index 4958d5f77..77ccf1342 100644
--- a/server/scanner/PodcastScanner.js
+++ b/server/scanner/PodcastScanner.js
@@ -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}
*/
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}
*/
@@ -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()
\ No newline at end of file
+module.exports = new PodcastScanner()
diff --git a/server/utils/podcastUtils.js b/server/utils/podcastUtils.js
index 485fccfbf..53ed8e7e5 100644
--- a/server/utils/podcastUtils.js
+++ b/server/utils/podcastUtils.js
@@ -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
})
diff --git a/server/utils/queries/libraryFilters.js b/server/utils/queries/libraryFilters.js
index 5d5f0c83c..7312b9d5d 100644
--- a/server/utils/queries/libraryFilters.js
+++ b/server/utils/queries/libraryFilters.js
@@ -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: [],
diff --git a/server/utils/queries/libraryItemsPodcastFilters.js b/server/utils/queries/libraryItemsPodcastFilters.js
index 0cd159bac..a04113811 100644
--- a/server/utils/queries/libraryItemsPodcastFilters.js
+++ b/server/utils/queries/libraryItemsPodcastFilters.js
@@ -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
diff --git a/test/server/migrations/v2.19.4-improve-podcast-queries.test.js b/test/server/migrations/v2.19.4-improve-podcast-queries.test.js
new file mode 100644
index 000000000..0ca697d70
--- /dev/null
+++ b/test/server/migrations/v2.19.4-improve-podcast-queries.test.js
@@ -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)
+ })
+ })
+})