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) + }) + }) +})