mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-05-31 15:51:32 +00:00
Merge remote-tracking branch 'remotes/upstream/master'
This commit is contained in:
commit
817794118f
23 changed files with 1199 additions and 233 deletions
|
|
@ -419,7 +419,7 @@ export default {
|
||||||
|
|
||||||
this.postScrollTimeout = setTimeout(this.postScroll, 500)
|
this.postScrollTimeout = setTimeout(this.postScroll, 500)
|
||||||
},
|
},
|
||||||
async resetEntities() {
|
async resetEntities(scrollPositionToRestore) {
|
||||||
if (this.isFetchingEntities) {
|
if (this.isFetchingEntities) {
|
||||||
this.pendingReset = true
|
this.pendingReset = true
|
||||||
return
|
return
|
||||||
|
|
@ -437,6 +437,12 @@ export default {
|
||||||
await this.loadPage(0)
|
await this.loadPage(0)
|
||||||
var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf)
|
var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf)
|
||||||
this.mountEntities(0, lastBookIndex)
|
this.mountEntities(0, lastBookIndex)
|
||||||
|
|
||||||
|
if (scrollPositionToRestore) {
|
||||||
|
if (window.bookshelf) {
|
||||||
|
window.bookshelf.scrollTop = scrollPositionToRestore
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async rebuild() {
|
async rebuild() {
|
||||||
this.initSizeData()
|
this.initSizeData()
|
||||||
|
|
@ -444,9 +450,8 @@ export default {
|
||||||
var lastBookIndex = Math.min(this.totalEntities, this.booksPerFetch)
|
var lastBookIndex = Math.min(this.totalEntities, this.booksPerFetch)
|
||||||
this.destroyEntityComponents()
|
this.destroyEntityComponents()
|
||||||
await this.loadPage(0)
|
await this.loadPage(0)
|
||||||
var bookshelfEl = document.getElementById('bookshelf')
|
if (window.bookshelf) {
|
||||||
if (bookshelfEl) {
|
window.bookshelf.scrollTop = 0
|
||||||
bookshelfEl.scrollTop = 0
|
|
||||||
}
|
}
|
||||||
this.mountEntities(0, lastBookIndex)
|
this.mountEntities(0, lastBookIndex)
|
||||||
},
|
},
|
||||||
|
|
@ -547,6 +552,15 @@ export default {
|
||||||
if (this.entityName === 'items' || this.entityName === 'series-books') {
|
if (this.entityName === 'items' || this.entityName === 'series-books') {
|
||||||
var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id)
|
var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id)
|
||||||
if (indexOf >= 0) {
|
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
|
this.entities[indexOf] = libraryItem
|
||||||
if (this.entityComponentRefs[indexOf]) {
|
if (this.entityComponentRefs[indexOf]) {
|
||||||
this.entityComponentRefs[indexOf].setEntity(libraryItem)
|
this.entityComponentRefs[indexOf].setEntity(libraryItem)
|
||||||
|
|
|
||||||
4
client/package-lock.json
generated
4
client/package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.19.3",
|
"version": "2.19.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.19.3",
|
"version": "2.19.4",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/axios": "^5.13.6",
|
"@nuxtjs/axios": "^5.13.6",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.19.3",
|
"version": "2.19.4",
|
||||||
"buildNumber": 1,
|
"buildNumber": 1,
|
||||||
"description": "Self-hosted audiobook and podcast client",
|
"description": "Self-hosted audiobook and podcast client",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"ButtonAdd": "Добави",
|
"ButtonAdd": "Създай",
|
||||||
"ButtonAddChapters": "Добави Глави",
|
"ButtonAddChapters": "Добави Глави",
|
||||||
"ButtonAddDevice": "Добави Устройство",
|
"ButtonAddDevice": "Добави Устройство",
|
||||||
"ButtonAddLibrary": "Добави Библиотека",
|
"ButtonAddLibrary": "Добави Библиотека",
|
||||||
|
|
@ -10,15 +10,18 @@
|
||||||
"ButtonApplyChapters": "Приложи Глави",
|
"ButtonApplyChapters": "Приложи Глави",
|
||||||
"ButtonAuthors": "Автори",
|
"ButtonAuthors": "Автори",
|
||||||
"ButtonBack": "Назад",
|
"ButtonBack": "Назад",
|
||||||
|
"ButtonBatchEditPopulateFromExisting": "Попълни от съществуващи",
|
||||||
|
"ButtonBatchEditPopulateMapDetails": "Попълни подробности за картата",
|
||||||
"ButtonBrowseForFolder": "Прегледай за папка",
|
"ButtonBrowseForFolder": "Прегледай за папка",
|
||||||
"ButtonCancel": "Откажи",
|
"ButtonCancel": "Отказ",
|
||||||
"ButtonCancelEncode": "Откажи закодирането",
|
"ButtonCancelEncode": "Откажи закодирането",
|
||||||
"ButtonChangeRootPassword": "Промени паролата за Root",
|
"ButtonChangeRootPassword": "Промени паролата за Root",
|
||||||
"ButtonCheckAndDownloadNewEpisodes": "Провери и Свали Нови Епизоди",
|
"ButtonCheckAndDownloadNewEpisodes": "Провери и Свали Нови Епизоди",
|
||||||
"ButtonChooseAFolder": "Избери Папка",
|
"ButtonChooseAFolder": "Избери Папка",
|
||||||
"ButtonChooseFiles": "Избери Файлове",
|
"ButtonChooseFiles": "Избери Файлове",
|
||||||
"ButtonClearFilter": "Изчисти Филтър",
|
"ButtonClearFilter": "Изчисти филтър",
|
||||||
"ButtonCloseFeed": "Затвори Feed",
|
"ButtonCloseFeed": "Затвори стената",
|
||||||
|
"ButtonCloseSession": "Затвори отворената сесия",
|
||||||
"ButtonCollections": "Колекции",
|
"ButtonCollections": "Колекции",
|
||||||
"ButtonConfigureScanner": "Конфигурирай Скенера",
|
"ButtonConfigureScanner": "Конфигурирай Скенера",
|
||||||
"ButtonCreate": "Създай",
|
"ButtonCreate": "Създай",
|
||||||
|
|
@ -28,6 +31,9 @@
|
||||||
"ButtonEdit": "Редактирай",
|
"ButtonEdit": "Редактирай",
|
||||||
"ButtonEditChapters": "Редактирай Глави",
|
"ButtonEditChapters": "Редактирай Глави",
|
||||||
"ButtonEditPodcast": "Редактирай Подкаст",
|
"ButtonEditPodcast": "Редактирай Подкаст",
|
||||||
|
"ButtonEnable": "Активирай",
|
||||||
|
"ButtonFireAndFail": "Задействай и неуспей",
|
||||||
|
"ButtonFireOnTest": "Задействай събитие onTest",
|
||||||
"ButtonForceReScan": "Принудително Пресканиране",
|
"ButtonForceReScan": "Принудително Пресканиране",
|
||||||
"ButtonFullPath": "Пълен Път",
|
"ButtonFullPath": "Пълен Път",
|
||||||
"ButtonHide": "Скрий",
|
"ButtonHide": "Скрий",
|
||||||
|
|
@ -44,24 +50,31 @@
|
||||||
"ButtonMatchAllAuthors": "Съвпадение на Всички Автори",
|
"ButtonMatchAllAuthors": "Съвпадение на Всички Автори",
|
||||||
"ButtonMatchBooks": "Съвпадение на Книги",
|
"ButtonMatchBooks": "Съвпадение на Книги",
|
||||||
"ButtonNevermind": "Няма значение",
|
"ButtonNevermind": "Няма значение",
|
||||||
|
"ButtonNext": "Следващо",
|
||||||
"ButtonNextChapter": "Следваща Глава",
|
"ButtonNextChapter": "Следваща Глава",
|
||||||
"ButtonOk": "Добре",
|
"ButtonNextItemInQueue": "Следващият елемент в опашката",
|
||||||
"ButtonOpenFeed": "Отвори Feed",
|
"ButtonOk": "Приемам",
|
||||||
|
"ButtonOpenFeed": "Отвори стената",
|
||||||
"ButtonOpenManager": "Отвори Мениджър",
|
"ButtonOpenManager": "Отвори Мениджър",
|
||||||
"ButtonPause": "Пауза",
|
"ButtonPause": "Паузирай",
|
||||||
"ButtonPlay": "Пусни",
|
"ButtonPlay": "Пусни",
|
||||||
|
"ButtonPlayAll": "Пусни всички",
|
||||||
"ButtonPlaying": "Пуска се",
|
"ButtonPlaying": "Пуска се",
|
||||||
"ButtonPlaylists": "Плейлисти",
|
"ButtonPlaylists": "Плейлисти",
|
||||||
|
"ButtonPrevious": "Предишен",
|
||||||
"ButtonPreviousChapter": "Предишна Глава",
|
"ButtonPreviousChapter": "Предишна Глава",
|
||||||
|
"ButtonProbeAudioFile": "Провери аудио файла",
|
||||||
"ButtonPurgeAllCache": "Изчисти Всички Кешове",
|
"ButtonPurgeAllCache": "Изчисти Всички Кешове",
|
||||||
"ButtonPurgeItemsCache": "Изчисти Кеша на Елементи",
|
"ButtonPurgeItemsCache": "Изчисти Кеша на Елементи",
|
||||||
"ButtonQueueAddItem": "Добави към опашката",
|
"ButtonQueueAddItem": "Добави към опашката",
|
||||||
"ButtonQueueRemoveItem": "Премахни от опашката",
|
"ButtonQueueRemoveItem": "Премахни от опашката",
|
||||||
|
"ButtonQuickEmbed": "Бързо вграждане",
|
||||||
|
"ButtonQuickEmbedMetadata": "Бързо вграждане метадата",
|
||||||
"ButtonQuickMatch": "Бързо Съпоставяне",
|
"ButtonQuickMatch": "Бързо Съпоставяне",
|
||||||
"ButtonReScan": "Пресканирай",
|
"ButtonReScan": "Пресканирай",
|
||||||
"ButtonRead": "Прочети",
|
"ButtonRead": "Прочети",
|
||||||
"ButtonReadLess": "Покажи по-малко",
|
"ButtonReadLess": "Изчети по-малко",
|
||||||
"ButtonReadMore": "Покажи повече",
|
"ButtonReadMore": "Прочети дълго",
|
||||||
"ButtonRefresh": "Обнови",
|
"ButtonRefresh": "Обнови",
|
||||||
"ButtonRemove": "Премахни",
|
"ButtonRemove": "Премахни",
|
||||||
"ButtonRemoveAll": "Премахни Всички",
|
"ButtonRemoveAll": "Премахни Всички",
|
||||||
|
|
@ -77,7 +90,9 @@
|
||||||
"ButtonSaveTracklist": "Запази Списък с Канали",
|
"ButtonSaveTracklist": "Запази Списък с Канали",
|
||||||
"ButtonScan": "Сканирай",
|
"ButtonScan": "Сканирай",
|
||||||
"ButtonScanLibrary": "Сканирай Библиотека",
|
"ButtonScanLibrary": "Сканирай Библиотека",
|
||||||
"ButtonSearch": "Търси",
|
"ButtonScrollLeft": "Скролни наляво",
|
||||||
|
"ButtonScrollRight": "Скролни надясно",
|
||||||
|
"ButtonSearch": "Търси в",
|
||||||
"ButtonSelectFolderPath": "Избери Път на Папка",
|
"ButtonSelectFolderPath": "Избери Път на Папка",
|
||||||
"ButtonSeries": "Серии",
|
"ButtonSeries": "Серии",
|
||||||
"ButtonSetChaptersFromTracks": "Задай Глави от Песни",
|
"ButtonSetChaptersFromTracks": "Задай Глави от Песни",
|
||||||
|
|
@ -86,8 +101,10 @@
|
||||||
"ButtonShow": "Покажи",
|
"ButtonShow": "Покажи",
|
||||||
"ButtonStartM4BEncode": "Започни M4B Кодиране",
|
"ButtonStartM4BEncode": "Започни M4B Кодиране",
|
||||||
"ButtonStartMetadataEmbed": "Започни Вграждане на Метаданни",
|
"ButtonStartMetadataEmbed": "Започни Вграждане на Метаданни",
|
||||||
|
"ButtonStats": "Статистики",
|
||||||
"ButtonSubmit": "Изпрати",
|
"ButtonSubmit": "Изпрати",
|
||||||
"ButtonTest": "Тест",
|
"ButtonTest": "Тест",
|
||||||
|
"ButtonUnlinkOpenId": "Премахни връзката с OpenID",
|
||||||
"ButtonUpload": "Качи",
|
"ButtonUpload": "Качи",
|
||||||
"ButtonUploadBackup": "Качи Backup",
|
"ButtonUploadBackup": "Качи Backup",
|
||||||
"ButtonUploadCover": "Качи Корица",
|
"ButtonUploadCover": "Качи Корица",
|
||||||
|
|
@ -100,9 +117,10 @@
|
||||||
"ErrorUploadFetchMetadataNoResults": "Метаданните не могат да бъдат взети - опитайте да обновите заглавието и/или автора",
|
"ErrorUploadFetchMetadataNoResults": "Метаданните не могат да бъдат взети - опитайте да обновите заглавието и/или автора",
|
||||||
"ErrorUploadLacksTitle": "Трябва да има Заглавие",
|
"ErrorUploadLacksTitle": "Трябва да има Заглавие",
|
||||||
"HeaderAccount": "Профил",
|
"HeaderAccount": "Профил",
|
||||||
"HeaderAdvanced": "Разширени",
|
"HeaderAddCustomMetadataProvider": "Добави персонализиран доставчик на метаданни",
|
||||||
|
"HeaderAdvanced": "Разширени настройки",
|
||||||
"HeaderAppriseNotificationSettings": "Apprise Notification Опции",
|
"HeaderAppriseNotificationSettings": "Apprise Notification Опции",
|
||||||
"HeaderAudioTracks": "Звуков Канал",
|
"HeaderAudioTracks": "Песни",
|
||||||
"HeaderAudiobookTools": "Инструмент за Менижиране на Аудиокниги",
|
"HeaderAudiobookTools": "Инструмент за Менижиране на Аудиокниги",
|
||||||
"HeaderAuthentication": "Аутентикация",
|
"HeaderAuthentication": "Аутентикация",
|
||||||
"HeaderBackups": "Архив",
|
"HeaderBackups": "Архив",
|
||||||
|
|
@ -110,26 +128,26 @@
|
||||||
"HeaderChapters": "Глави",
|
"HeaderChapters": "Глави",
|
||||||
"HeaderChooseAFolder": "Избети Папка",
|
"HeaderChooseAFolder": "Избети Папка",
|
||||||
"HeaderCollection": "Колекция",
|
"HeaderCollection": "Колекция",
|
||||||
"HeaderCollectionItems": "Елементи на Колекция",
|
"HeaderCollectionItems": "Елемент в колекция",
|
||||||
"HeaderCover": "Корица",
|
"HeaderCover": "Корица",
|
||||||
"HeaderCurrentDownloads": "Текущи Сваляния",
|
"HeaderCurrentDownloads": "Текущи Сваляния",
|
||||||
"HeaderCustomMessageOnLogin": "Потребителско съобщение при влизане",
|
"HeaderCustomMessageOnLogin": "Потребителско съобщение при влизане",
|
||||||
"HeaderCustomMetadataProviders": "Потребителски Доставчици на Метаданни",
|
"HeaderCustomMetadataProviders": "Потребителски Доставчици на Метаданни",
|
||||||
"HeaderDetails": "Детайли",
|
"HeaderDetails": "Детайли",
|
||||||
"HeaderDownloadQueue": "Опашка за Сваляне",
|
"HeaderDownloadQueue": "Опашка за Сваляне",
|
||||||
"HeaderEbookFiles": "Файлове на Електронни книги",
|
"HeaderEbookFiles": "Е-книги файлове",
|
||||||
"HeaderEmail": "Емейл",
|
"HeaderEmail": "Емейл",
|
||||||
"HeaderEmailSettings": "Настройки Емайл",
|
"HeaderEmailSettings": "Настройки Емайл",
|
||||||
"HeaderEpisodes": "Епизоди",
|
"HeaderEpisodes": "Епизоди",
|
||||||
"HeaderEreaderDevices": "Елктронни Четци",
|
"HeaderEreaderDevices": "Елктронни Четци",
|
||||||
"HeaderEreaderSettings": "Настройки на Електронни Четци",
|
"HeaderEreaderSettings": "Настройки на Е-четецът",
|
||||||
"HeaderFiles": "Файлове",
|
"HeaderFiles": "Файлове",
|
||||||
"HeaderFindChapters": "Намери Глави",
|
"HeaderFindChapters": "Намери Глави",
|
||||||
"HeaderIgnoredFiles": "Игнорирани Файлове",
|
"HeaderIgnoredFiles": "Игнорирани Файлове",
|
||||||
"HeaderItemFiles": "Файлове на Елемент",
|
"HeaderItemFiles": "Файлове на Елемент",
|
||||||
"HeaderItemMetadataUtils": "Инструменти за Метаданни на Елемент",
|
"HeaderItemMetadataUtils": "Инструменти за Метаданни на Елемент",
|
||||||
"HeaderLastListeningSession": "Последна Сесия на Слушане",
|
"HeaderLastListeningSession": "Последна Сесия на Слушане",
|
||||||
"HeaderLatestEpisodes": "Последни Епизоди",
|
"HeaderLatestEpisodes": "Последни епизоди",
|
||||||
"HeaderLibraries": "Библиотеки",
|
"HeaderLibraries": "Библиотеки",
|
||||||
"HeaderLibraryFiles": "Файлове на Библиотека",
|
"HeaderLibraryFiles": "Файлове на Библиотека",
|
||||||
"HeaderLibraryStats": "Статистика на Библиотека",
|
"HeaderLibraryStats": "Статистика на Библиотека",
|
||||||
|
|
@ -145,24 +163,29 @@
|
||||||
"HeaderMetadataToEmbed": "Метаданни за Вграждане",
|
"HeaderMetadataToEmbed": "Метаданни за Вграждане",
|
||||||
"HeaderNewAccount": "Нов Профил",
|
"HeaderNewAccount": "Нов Профил",
|
||||||
"HeaderNewLibrary": "Нова Библиотека",
|
"HeaderNewLibrary": "Нова Библиотека",
|
||||||
|
"HeaderNotificationCreate": "Създай нотификация",
|
||||||
|
"HeaderNotificationUpdate": "Обнови нотификация",
|
||||||
"HeaderNotifications": "Известия",
|
"HeaderNotifications": "Известия",
|
||||||
"HeaderOpenIDConnectAuthentication": "OpenID Connect Аутентикация",
|
"HeaderOpenIDConnectAuthentication": "OpenID Connect Аутентикация",
|
||||||
"HeaderOpenRSSFeed": "Отвори RSS Feed",
|
"HeaderOpenListeningSessions": "Отвори сесия",
|
||||||
|
"HeaderOpenRSSFeed": "Отвори RSS емисията",
|
||||||
"HeaderOtherFiles": "Други Файлове",
|
"HeaderOtherFiles": "Други Файлове",
|
||||||
"HeaderPasswordAuthentication": "Паролна Аутентикация",
|
"HeaderPasswordAuthentication": "Паролна Аутентикация",
|
||||||
"HeaderPermissions": "Права",
|
"HeaderPermissions": "Права",
|
||||||
"HeaderPlayerQueue": "Опашка на Плейъра",
|
"HeaderPlayerQueue": "Опашка на Плейъра",
|
||||||
|
"HeaderPlayerSettings": "Настройки на плейъра",
|
||||||
"HeaderPlaylist": "Плейлист",
|
"HeaderPlaylist": "Плейлист",
|
||||||
"HeaderPlaylistItems": "Елементи на Плейлист",
|
"HeaderPlaylistItems": "Елементи от плейлист",
|
||||||
"HeaderPodcastsToAdd": "Подкасти за Добавяне",
|
"HeaderPodcastsToAdd": "Подкасти за Добавяне",
|
||||||
"HeaderPreviewCover": "Преглед на Корица",
|
"HeaderPreviewCover": "Преглед на Корица",
|
||||||
"HeaderRSSFeedGeneral": "RSS Детайли",
|
"HeaderRSSFeedGeneral": "RSS подробности",
|
||||||
"HeaderRSSFeedIsOpen": "RSS Feed е Отворен",
|
"HeaderRSSFeedIsOpen": "RSS емисията е отворена",
|
||||||
"HeaderRSSFeeds": "RSS Feed-ове",
|
"HeaderRSSFeeds": "RSS Feed-ове",
|
||||||
"HeaderRemoveEpisode": "Премахни Епизод",
|
"HeaderRemoveEpisode": "Премахни Епизод",
|
||||||
"HeaderRemoveEpisodes": "Премахни {0} Епизоди",
|
"HeaderRemoveEpisodes": "Премахни {0} Епизоди",
|
||||||
"HeaderSavedMediaProgress": "Запазен Прогрес на Медията",
|
"HeaderSavedMediaProgress": "Запазен Прогрес на Медията",
|
||||||
"HeaderSchedule": "График",
|
"HeaderSchedule": "График",
|
||||||
|
"HeaderScheduleEpisodeDownloads": "Планирай автоматично изтегляне на епизоди",
|
||||||
"HeaderScheduleLibraryScans": "График за Автоматично Сканиране на Библиотека",
|
"HeaderScheduleLibraryScans": "График за Автоматично Сканиране на Библиотека",
|
||||||
"HeaderSession": "Сесия",
|
"HeaderSession": "Сесия",
|
||||||
"HeaderSetBackupSchedule": "Задай График за Backup",
|
"HeaderSetBackupSchedule": "Задай График за Backup",
|
||||||
|
|
@ -171,11 +194,12 @@
|
||||||
"HeaderSettingsExperimental": "Експериментални Функции",
|
"HeaderSettingsExperimental": "Експериментални Функции",
|
||||||
"HeaderSettingsGeneral": "Общи",
|
"HeaderSettingsGeneral": "Общи",
|
||||||
"HeaderSettingsScanner": "Скенер",
|
"HeaderSettingsScanner": "Скенер",
|
||||||
"HeaderSleepTimer": "Таймер за Сън",
|
"HeaderSettingsWebClient": "Уеб клиент",
|
||||||
|
"HeaderSleepTimer": "Таймер за заспиване",
|
||||||
"HeaderStatsLargestItems": "Най-Големите Елементи",
|
"HeaderStatsLargestItems": "Най-Големите Елементи",
|
||||||
"HeaderStatsLongestItems": "Най-Дългите Елементи (часове)",
|
"HeaderStatsLongestItems": "Най-Дългите Елементи (часове)",
|
||||||
"HeaderStatsMinutesListeningChart": "Минути на Слушане (последни 7 дни)",
|
"HeaderStatsMinutesListeningChart": "Изслушани минути (последните 7 дни)",
|
||||||
"HeaderStatsRecentSessions": "Скорошни Сесии",
|
"HeaderStatsRecentSessions": "Последни сесии",
|
||||||
"HeaderStatsTop10Authors": "Топ 10 Автори",
|
"HeaderStatsTop10Authors": "Топ 10 Автори",
|
||||||
"HeaderStatsTop5Genres": "Топ 5 Жанрове",
|
"HeaderStatsTop5Genres": "Топ 5 Жанрове",
|
||||||
"HeaderTableOfContents": "Съдържание",
|
"HeaderTableOfContents": "Съдържание",
|
||||||
|
|
@ -186,7 +210,7 @@
|
||||||
"HeaderUpdateLibrary": "Обнови Библиотека",
|
"HeaderUpdateLibrary": "Обнови Библиотека",
|
||||||
"HeaderUsers": "Потребители",
|
"HeaderUsers": "Потребители",
|
||||||
"HeaderYearReview": "Преглед на {0} Година",
|
"HeaderYearReview": "Преглед на {0} Година",
|
||||||
"HeaderYourStats": "Твоята Статистика",
|
"HeaderYourStats": "Вашата статистика",
|
||||||
"LabelAbridged": "Съкратен",
|
"LabelAbridged": "Съкратен",
|
||||||
"LabelAbridgedChecked": "Съкратена (отбелязано)",
|
"LabelAbridgedChecked": "Съкратена (отбелязано)",
|
||||||
"LabelAbridgedUnchecked": "Несъкратена (не отбелязано)",
|
"LabelAbridgedUnchecked": "Несъкратена (не отбелязано)",
|
||||||
|
|
@ -198,21 +222,26 @@
|
||||||
"LabelActivity": "Дейност",
|
"LabelActivity": "Дейност",
|
||||||
"LabelAddToCollection": "Добави в Колекция",
|
"LabelAddToCollection": "Добави в Колекция",
|
||||||
"LabelAddToCollectionBatch": "Добави {0} Книги в Колекция",
|
"LabelAddToCollectionBatch": "Добави {0} Книги в Колекция",
|
||||||
"LabelAddToPlaylist": "Добави в Плейлист",
|
"LabelAddToPlaylist": "Добави в плейлист",
|
||||||
"LabelAddToPlaylistBatch": "Добави {0} Елемент в Плейлист",
|
"LabelAddToPlaylistBatch": "Добави {0} Елемент в Плейлист",
|
||||||
"LabelAddedAt": "Добавени На",
|
"LabelAddedAt": "Добавено в",
|
||||||
|
"LabelAddedDate": "Добавено",
|
||||||
"LabelAdminUsersOnly": "Само за Администратори",
|
"LabelAdminUsersOnly": "Само за Администратори",
|
||||||
"LabelAll": "Всички",
|
"LabelAll": "Всичко",
|
||||||
"LabelAllUsers": "Всички Потребители",
|
"LabelAllUsers": "Всички Потребители",
|
||||||
"LabelAllUsersExcludingGuests": "Всички потребители без гости",
|
"LabelAllUsersExcludingGuests": "Всички потребители без гости",
|
||||||
"LabelAllUsersIncludingGuests": "Всички потребители включително гости",
|
"LabelAllUsersIncludingGuests": "Всички потребители включително гости",
|
||||||
"LabelAlreadyInYourLibrary": "Вече е в твоята библиотека",
|
"LabelAlreadyInYourLibrary": "Вече е в твоята библиотека",
|
||||||
|
"LabelApiToken": "АПИ Токен",
|
||||||
"LabelAppend": "Добави",
|
"LabelAppend": "Добави",
|
||||||
|
"LabelAudioBitrate": "Аудио битрейт (напр. 128k)",
|
||||||
|
"LabelAudioChannels": "Аудио канали (1 или 2)",
|
||||||
|
"LabelAudioCodec": "Аудио кодек",
|
||||||
"LabelAuthor": "Автор",
|
"LabelAuthor": "Автор",
|
||||||
"LabelAuthorFirstLast": "Автор (Първо Име, Фамилия)",
|
"LabelAuthorFirstLast": "Автор (Първи, Последен)",
|
||||||
"LabelAuthorLastFirst": "Автор (Фамилия, Първо Име)",
|
"LabelAuthorLastFirst": "Автор (Последен, Първи)",
|
||||||
"LabelAuthors": "Автори",
|
"LabelAuthors": "Автори",
|
||||||
"LabelAutoDownloadEpisodes": "Автоматично Сваляне на Епизоди",
|
"LabelAutoDownloadEpisodes": "Автоматично изтегляне на епизоди",
|
||||||
"LabelAutoFetchMetadata": "Автоматично Взимане на Метаданни",
|
"LabelAutoFetchMetadata": "Автоматично Взимане на Метаданни",
|
||||||
"LabelAutoFetchMetadataHelp": "Взима метаданни за заглвие, автор и серии за да опрости качването. Допълнителни метаданни може да трябва да бъде взера след качване.",
|
"LabelAutoFetchMetadataHelp": "Взима метаданни за заглвие, автор и серии за да опрости качването. Допълнителни метаданни може да трябва да бъде взера след качване.",
|
||||||
"LabelAutoLaunch": "Автоматично Стартиране",
|
"LabelAutoLaunch": "Автоматично Стартиране",
|
||||||
|
|
@ -220,6 +249,7 @@
|
||||||
"LabelAutoRegister": "Автоматична Регистрация",
|
"LabelAutoRegister": "Автоматична Регистрация",
|
||||||
"LabelAutoRegisterDescription": "Автоматично създаване на нови потребители след вход",
|
"LabelAutoRegisterDescription": "Автоматично създаване на нови потребители след вход",
|
||||||
"LabelBackToUser": "Обратно към Потребител",
|
"LabelBackToUser": "Обратно към Потребител",
|
||||||
|
"LabelBackupAudioFiles": "Създай резервно копие на аудио файлове",
|
||||||
"LabelBackupLocation": "Местоположение на Архив",
|
"LabelBackupLocation": "Местоположение на Архив",
|
||||||
"LabelBackupsEnableAutomaticBackups": "Включи автоматично архивиране",
|
"LabelBackupsEnableAutomaticBackups": "Включи автоматично архивиране",
|
||||||
"LabelBackupsEnableAutomaticBackupsHelp": "Архиви запазени в /metadata/backups",
|
"LabelBackupsEnableAutomaticBackupsHelp": "Архиви запазени в /metadata/backups",
|
||||||
|
|
@ -228,31 +258,38 @@
|
||||||
"LabelBackupsNumberToKeep": "Брой архиви за запазване",
|
"LabelBackupsNumberToKeep": "Брой архиви за запазване",
|
||||||
"LabelBackupsNumberToKeepHelp": "Само 1 архив ще бъде премахнат на веднъж, така че ако вече имате повече архиви от това трябва да ги премахнете ръчно.",
|
"LabelBackupsNumberToKeepHelp": "Само 1 архив ще бъде премахнат на веднъж, така че ако вече имате повече архиви от това трябва да ги премахнете ръчно.",
|
||||||
"LabelBitrate": "Битрейт",
|
"LabelBitrate": "Битрейт",
|
||||||
|
"LabelBonus": "Бонус",
|
||||||
"LabelBooks": "Книги",
|
"LabelBooks": "Книги",
|
||||||
"LabelButtonText": "Текст на Бутон",
|
"LabelButtonText": "Текст на Бутон",
|
||||||
|
"LabelByAuthor": "от {0}",
|
||||||
"LabelChangePassword": "Промени Парола",
|
"LabelChangePassword": "Промени Парола",
|
||||||
"LabelChannels": "Канали",
|
"LabelChannels": "Канали",
|
||||||
|
"LabelChapterCount": "{0} Глави",
|
||||||
"LabelChapterTitle": "Заглавие на Глава",
|
"LabelChapterTitle": "Заглавие на Глава",
|
||||||
"LabelChapters": "Глави",
|
"LabelChapters": "Глави",
|
||||||
"LabelChaptersFound": "намерени глави",
|
"LabelChaptersFound": "намерени глави",
|
||||||
"LabelClickForMoreInfo": "Кликни за повече информация",
|
"LabelClickForMoreInfo": "Кликни за повече информация",
|
||||||
"LabelClosePlayer": "Затвори Плейъра",
|
"LabelClickToUseCurrentValue": "Натисни да ползваш сегашната стойност",
|
||||||
|
"LabelClosePlayer": "Затвори",
|
||||||
"LabelCodec": "Кодек",
|
"LabelCodec": "Кодек",
|
||||||
"LabelCollapseSeries": "Свий Серия",
|
"LabelCollapseSeries": "Скрий сериите",
|
||||||
|
"LabelCollapseSubSeries": "Свий подсерии",
|
||||||
"LabelCollection": "Колекция",
|
"LabelCollection": "Колекция",
|
||||||
"LabelCollections": "Колекции",
|
"LabelCollections": "Колекции",
|
||||||
"LabelComplete": "Завършено",
|
"LabelComplete": "Приключено",
|
||||||
"LabelConfirmPassword": "Потвърди Парола",
|
"LabelConfirmPassword": "Потвърди Парола",
|
||||||
"LabelContinueListening": "Продължи Слушане",
|
"LabelContinueListening": "Продължи слушане",
|
||||||
"LabelContinueReading": "Продължи Четене",
|
"LabelContinueReading": "Продължи четене",
|
||||||
"LabelContinueSeries": "Продължи Серия",
|
"LabelContinueSeries": "Продължи серии",
|
||||||
"LabelCover": "Корица",
|
"LabelCover": "Корица",
|
||||||
"LabelCoverImageURL": "URL на Корица",
|
"LabelCoverImageURL": "URL на Корица",
|
||||||
"LabelCreatedAt": "Създадено на",
|
"LabelCreatedAt": "Създадено на",
|
||||||
|
"LabelCronExpression": "Cron израз",
|
||||||
"LabelCurrent": "Текущо",
|
"LabelCurrent": "Текущо",
|
||||||
"LabelCurrently": "Текущо:",
|
"LabelCurrently": "Текущо:",
|
||||||
"LabelCustomCronExpression": "Потребителски Cron Expression:",
|
"LabelCustomCronExpression": "Потребителски Cron Expression:",
|
||||||
"LabelDatetime": "Дата и Време",
|
"LabelDatetime": "Дата и Време",
|
||||||
|
"LabelDays": "Дни",
|
||||||
"LabelDeleteFromFileSystemCheckbox": "Изтрий от файловата система (отмени за да бъдат премахни само от базата данни)",
|
"LabelDeleteFromFileSystemCheckbox": "Изтрий от файловата система (отмени за да бъдат премахни само от базата данни)",
|
||||||
"LabelDescription": "Описание",
|
"LabelDescription": "Описание",
|
||||||
"LabelDeselectAll": "Премахни всички",
|
"LabelDeselectAll": "Премахни всички",
|
||||||
|
|
@ -263,16 +300,18 @@
|
||||||
"LabelDiscFromFilename": "Диск от Име на Файл",
|
"LabelDiscFromFilename": "Диск от Име на Файл",
|
||||||
"LabelDiscFromMetadata": "Диск от Метаданни",
|
"LabelDiscFromMetadata": "Диск от Метаданни",
|
||||||
"LabelDiscover": "Открий",
|
"LabelDiscover": "Открий",
|
||||||
"LabelDownload": "Сваляне",
|
"LabelDownload": "Свали",
|
||||||
"LabelDownloadNEpisodes": "Свали {0} епизоди",
|
"LabelDownloadNEpisodes": "Свали {0} епизоди",
|
||||||
|
"LabelDownloadable": "Може да се изтегли",
|
||||||
"LabelDuration": "Продължителност",
|
"LabelDuration": "Продължителност",
|
||||||
"LabelDurationComparisonExactMatch": "(точно съвпадение)",
|
"LabelDurationComparisonExactMatch": "(точно съвпадение)",
|
||||||
"LabelDurationComparisonLonger": "({0} по-дълго)",
|
"LabelDurationComparisonLonger": "({0} по-дълго)",
|
||||||
"LabelDurationComparisonShorter": "({0} по-късо)",
|
"LabelDurationComparisonShorter": "({0} по-късо)",
|
||||||
"LabelDurationFound": "Намерена продължителност:",
|
"LabelDurationFound": "Намерена продължителност:",
|
||||||
"LabelEbook": "Електронна книга",
|
"LabelEbook": "Е-Книга",
|
||||||
"LabelEbooks": "Електронни книги",
|
"LabelEbooks": "Е-книги",
|
||||||
"LabelEdit": "Редакция",
|
"LabelEdit": "Редакция",
|
||||||
|
"LabelEmail": "Имейл",
|
||||||
"LabelEmailSettingsFromAddress": "От Адрес",
|
"LabelEmailSettingsFromAddress": "От Адрес",
|
||||||
"LabelEmailSettingsRejectUnauthorized": "Отхвърли неавторизирани сертификати",
|
"LabelEmailSettingsRejectUnauthorized": "Отхвърли неавторизирани сертификати",
|
||||||
"LabelEmailSettingsRejectUnauthorizedHelp": "Спирането на валидацията на SSL сертификате може да изложи връзката ви на рискове, като man-in-the-middle атака. Спираите тази опция само ако знете имоликацийте от това и се доверявате на mail сървъра към който се свързвате.",
|
"LabelEmailSettingsRejectUnauthorizedHelp": "Спирането на валидацията на SSL сертификате може да изложи връзката ви на рискове, като man-in-the-middle атака. Спираите тази опция само ако знете имоликацийте от това и се доверявате на mail сървъра към който се свързвате.",
|
||||||
|
|
@ -280,41 +319,53 @@
|
||||||
"LabelEmailSettingsSecureHelp": "Ако е вярно възката ще изполва TLS когате се свързва със сървъра. Ако не е то TLS ще се използва ако сървъра поддържа разширението STARTTLS. В повечето случаи задайте тази стойност на истина ако се свързвате към порт 465. За порт 587 или 25 оставете я на лъжа. (от nodemailer.com/smtp/#authentication)",
|
"LabelEmailSettingsSecureHelp": "Ако е вярно възката ще изполва TLS когате се свързва със сървъра. Ако не е то TLS ще се използва ако сървъра поддържа разширението STARTTLS. В повечето случаи задайте тази стойност на истина ако се свързвате към порт 465. За порт 587 или 25 оставете я на лъжа. (от nodemailer.com/smtp/#authentication)",
|
||||||
"LabelEmailSettingsTestAddress": "Тестов Адрес",
|
"LabelEmailSettingsTestAddress": "Тестов Адрес",
|
||||||
"LabelEmbeddedCover": "Вградена Корица",
|
"LabelEmbeddedCover": "Вградена Корица",
|
||||||
"LabelEnable": "Включи",
|
"LabelEnable": "Активирай",
|
||||||
|
"LabelEncodingBackupLocation": "Резервно копие на вашите оригинални аудио файлове ще бъде съхранено в:",
|
||||||
|
"LabelEncodingChaptersNotEmbedded": "Главите не са вградени в аудиокнигите с множество тракове.",
|
||||||
|
"LabelEncodingClearItemCache": "Уверете се, че периодично изчиствате кеша на елементите.",
|
||||||
|
"LabelEncodingFinishedM4B": "Завършеният M4B файл ще бъде поставен в папката на вашите аудиокниги на:",
|
||||||
|
"LabelEncodingInfoEmbedded": "Метаданните ще бъдат вградени в аудио траковете в папката на вашите аудиокниги.",
|
||||||
"LabelEnd": "Край",
|
"LabelEnd": "Край",
|
||||||
|
"LabelEndOfChapter": "Край на глава",
|
||||||
"LabelEpisode": "Епизод",
|
"LabelEpisode": "Епизод",
|
||||||
"LabelEpisodeTitle": "Заглавие на Епизод",
|
"LabelEpisodeTitle": "Заглавие на Епизод",
|
||||||
"LabelEpisodeType": "Тип на Епизод",
|
"LabelEpisodeType": "Тип на Епизод",
|
||||||
"LabelExample": "Пример",
|
"LabelExample": "Пример",
|
||||||
"LabelExplicit": "Експлицитно",
|
"LabelExpandSeries": "Покажи сериите",
|
||||||
|
"LabelExpandSubSeries": "Покажи съб сериите",
|
||||||
|
"LabelExplicit": "С нецензурно съдържание",
|
||||||
|
"LabelExplicitChecked": "С нецензурно съдържание (проверено)",
|
||||||
|
"LabelExplicitUnchecked": "Без нецензурно съдържание (непроверено)",
|
||||||
|
"LabelExportOPML": "Експортирай OPML",
|
||||||
|
"LabelFeedURL": "URL на емисия",
|
||||||
"LabelFetchingMetadata": "Взимане на Метаданни",
|
"LabelFetchingMetadata": "Взимане на Метаданни",
|
||||||
"LabelFile": "Файл",
|
"LabelFile": "Файл",
|
||||||
"LabelFileBirthtime": "Дата на създаване на файла",
|
"LabelFileBirthtime": "Дата на създаване на файла",
|
||||||
"LabelFileModified": "Файлът променен",
|
"LabelFileModified": "Дата на модификация на файла",
|
||||||
"LabelFilename": "Име на Файл",
|
"LabelFilename": "Име на файла",
|
||||||
"LabelFilterByUser": "Филтриране по Потребител",
|
"LabelFilterByUser": "Филтриране по Потребител",
|
||||||
"LabelFindEpisodes": "Намери Епизоди",
|
"LabelFindEpisodes": "Намери Епизоди",
|
||||||
"LabelFinished": "Завършено",
|
"LabelFinished": "Дата на приключване",
|
||||||
"LabelFolder": "Папка",
|
"LabelFolder": "Папка",
|
||||||
"LabelFolders": "Папки",
|
"LabelFolders": "Папки",
|
||||||
"LabelFontBold": "Получерно",
|
"LabelFontBold": "Получерно",
|
||||||
"LabelFontBoldness": "Плътност на шрифта",
|
"LabelFontBoldness": "Дебелина на шрифта",
|
||||||
"LabelFontFamily": "Шрифт",
|
"LabelFontFamily": "Шрифт",
|
||||||
"LabelFontItalic": "Курсив",
|
"LabelFontItalic": "Курсив",
|
||||||
"LabelFontScale": "Мащаб на Шрифта",
|
"LabelFontScale": "Мащаб на шрифта",
|
||||||
"LabelFontStrikethrough": "Зачертан",
|
"LabelFontStrikethrough": "Зачертан",
|
||||||
"LabelFormat": "Формат",
|
"LabelFormat": "Формат",
|
||||||
"LabelGenre": "Жанр",
|
"LabelGenre": "Жанр",
|
||||||
"LabelGenres": "Жанрове",
|
"LabelGenres": "Жанрове",
|
||||||
"LabelHardDeleteFile": "Пълно Изтриване на Файл",
|
"LabelHardDeleteFile": "Пълно Изтриване на Файл",
|
||||||
"LabelHasEbook": "Има електронна книга",
|
"LabelHasEbook": "Има е-книга",
|
||||||
"LabelHasSupplementaryEbook": "Има допълнителна електронна книга",
|
"LabelHasSupplementaryEbook": "Има допълнителна е-книга",
|
||||||
"LabelHighestPriority": "Най-висок Приоритет",
|
"LabelHighestPriority": "Най-висок Приоритет",
|
||||||
"LabelHost": "Хост",
|
"LabelHost": "Хост",
|
||||||
"LabelHour": "Час",
|
"LabelHour": "Час",
|
||||||
"LabelIcon": "Икона",
|
"LabelIcon": "Икона",
|
||||||
"LabelImageURLFromTheWeb": "URL на Изображение от Интернет",
|
"LabelImageURLFromTheWeb": "URL на Изображение от Интернет",
|
||||||
"LabelInProgress": "В Прогрес",
|
"LabelInProgress": "В процес на изпълнение",
|
||||||
"LabelIncludeInTracklist": "Включи в Списъка с Канали",
|
"LabelIncludeInTracklist": "Включи в Списъка с Канали",
|
||||||
"LabelIncomplete": "Незавършено",
|
"LabelIncomplete": "Незавършено",
|
||||||
"LabelInterval": "Интервал",
|
"LabelInterval": "Интервал",
|
||||||
|
|
@ -337,7 +388,7 @@
|
||||||
"LabelLastTime": "Последно Време",
|
"LabelLastTime": "Последно Време",
|
||||||
"LabelLastUpdate": "Последно Обновяване",
|
"LabelLastUpdate": "Последно Обновяване",
|
||||||
"LabelLayout": "Оформление",
|
"LabelLayout": "Оформление",
|
||||||
"LabelLayoutSinglePage": "Една Страница",
|
"LabelLayoutSinglePage": "Единична страница",
|
||||||
"LabelLayoutSplitPage": "Разделена Страница",
|
"LabelLayoutSplitPage": "Разделена Страница",
|
||||||
"LabelLess": "По-малко",
|
"LabelLess": "По-малко",
|
||||||
"LabelLibrariesAccessibleToUser": "Библиотеки Достъпни за Потребителя",
|
"LabelLibrariesAccessibleToUser": "Библиотеки Достъпни за Потребителя",
|
||||||
|
|
@ -345,8 +396,8 @@
|
||||||
"LabelLibraryItem": "Елемент на Библиотека",
|
"LabelLibraryItem": "Елемент на Библиотека",
|
||||||
"LabelLibraryName": "Име на Библиотека",
|
"LabelLibraryName": "Име на Библиотека",
|
||||||
"LabelLimit": "Лимит",
|
"LabelLimit": "Лимит",
|
||||||
"LabelLineSpacing": "Линейно Разстояние",
|
"LabelLineSpacing": "Междуредие",
|
||||||
"LabelListenAgain": "Слушай Отново",
|
"LabelListenAgain": "Слушай отново",
|
||||||
"LabelLogLevelDebug": "Дебъг",
|
"LabelLogLevelDebug": "Дебъг",
|
||||||
"LabelLogLevelInfo": "Информация",
|
"LabelLogLevelInfo": "Информация",
|
||||||
"LabelLogLevelWarn": "Предупреждение",
|
"LabelLogLevelWarn": "Предупреждение",
|
||||||
|
|
@ -355,7 +406,7 @@
|
||||||
"LabelMatchExistingUsersBy": "Съпостави съществуващи потребители по",
|
"LabelMatchExistingUsersBy": "Съпостави съществуващи потребители по",
|
||||||
"LabelMatchExistingUsersByDescription": "Използва се за свързване на съществуващи потребители. След свързване потребителите ще бъдат съпоставени по уникален идентификатор от вашия доставчик на SSO",
|
"LabelMatchExistingUsersByDescription": "Използва се за свързване на съществуващи потребители. След свързване потребителите ще бъдат съпоставени по уникален идентификатор от вашия доставчик на SSO",
|
||||||
"LabelMediaPlayer": "Медия Плейър",
|
"LabelMediaPlayer": "Медия Плейър",
|
||||||
"LabelMediaType": "Тип на Медията",
|
"LabelMediaType": "Тип медия",
|
||||||
"LabelMetaTag": "Мета Таг",
|
"LabelMetaTag": "Мета Таг",
|
||||||
"LabelMetaTags": "Мета Тагове",
|
"LabelMetaTags": "Мета Тагове",
|
||||||
"LabelMetadataOrderOfPrecedenceDescription": "По-високите източници на метаданни ще заменят по-ниските",
|
"LabelMetadataOrderOfPrecedenceDescription": "По-високите източници на метаданни ще заменят по-ниските",
|
||||||
|
|
@ -367,19 +418,19 @@
|
||||||
"LabelMobileRedirectURIs": "Позволени URI за Мобилно Пренасочване",
|
"LabelMobileRedirectURIs": "Позволени URI за Мобилно Пренасочване",
|
||||||
"LabelMobileRedirectURIsDescription": "Това е whitelist на валидни URI за пренасочване за мобилни приложения. По подразбиране е <code>audiobookshelf://oauth</code>, който може да премахнете или допълните с допълнителни URI за интеграция на приложения от трети страни. Използването на звезда (<code>*</code>) като единствен запис позволява всеки URI.",
|
"LabelMobileRedirectURIsDescription": "Това е whitelist на валидни URI за пренасочване за мобилни приложения. По подразбиране е <code>audiobookshelf://oauth</code>, който може да премахнете или допълните с допълнителни URI за интеграция на приложения от трети страни. Използването на звезда (<code>*</code>) като единствен запис позволява всеки URI.",
|
||||||
"LabelMore": "Повече",
|
"LabelMore": "Повече",
|
||||||
"LabelMoreInfo": "Повече Информация",
|
"LabelMoreInfo": "Повече информация",
|
||||||
"LabelName": "Име",
|
"LabelName": "Име",
|
||||||
"LabelNarrator": "Разказвач",
|
"LabelNarrator": "Разказвач",
|
||||||
"LabelNarrators": "Разказвачи",
|
"LabelNarrators": "Разказвачи",
|
||||||
"LabelNew": "Нови",
|
"LabelNew": "Нови",
|
||||||
"LabelNewPassword": "Нова Парола",
|
"LabelNewPassword": "Нова Парола",
|
||||||
"LabelNewestAuthors": "Най-нови Автори",
|
"LabelNewestAuthors": "Най-новите автори",
|
||||||
"LabelNewestEpisodes": "Най-нови Епизоди",
|
"LabelNewestEpisodes": "Най-новите епизоди",
|
||||||
"LabelNextBackupDate": "Следваща Дата на Архивиране",
|
"LabelNextBackupDate": "Следваща Дата на Архивиране",
|
||||||
"LabelNextScheduledRun": "Следващо Планирано Изпълнение",
|
"LabelNextScheduledRun": "Следващо Планирано Изпълнение",
|
||||||
"LabelNoCustomMetadataProviders": "Няма потребителски доставчици на метаданни",
|
"LabelNoCustomMetadataProviders": "Няма потребителски доставчици на метаданни",
|
||||||
"LabelNoEpisodesSelected": "Няма избрани епизоди",
|
"LabelNoEpisodesSelected": "Няма избрани епизоди",
|
||||||
"LabelNotFinished": "Не е завършено",
|
"LabelNotFinished": "Не е приключено",
|
||||||
"LabelNotStarted": "Не е започнато",
|
"LabelNotStarted": "Не е започнато",
|
||||||
"LabelNotes": "Бележки",
|
"LabelNotes": "Бележки",
|
||||||
"LabelNotificationAppriseURL": "Apprise URL-и",
|
"LabelNotificationAppriseURL": "Apprise URL-и",
|
||||||
|
|
@ -392,7 +443,10 @@
|
||||||
"LabelNotificationsMaxQueueSize": "Максимален размер на опашката за известия",
|
"LabelNotificationsMaxQueueSize": "Максимален размер на опашката за известия",
|
||||||
"LabelNotificationsMaxQueueSizeHelp": "Събитията са ограничени до изстрелване на 1 на секунда. Събитията ще бъдат игнорирани ако опашката е на максимален размер. Това предотвратява спамирането на известия.",
|
"LabelNotificationsMaxQueueSizeHelp": "Събитията са ограничени до изстрелване на 1 на секунда. Събитията ще бъдат игнорирани ако опашката е на максимален размер. Това предотвратява спамирането на известия.",
|
||||||
"LabelNumberOfBooks": "Брой на Книги",
|
"LabelNumberOfBooks": "Брой на Книги",
|
||||||
"LabelNumberOfEpisodes": "# Епизоди",
|
"LabelNumberOfEpisodes": "Брой епизоди",
|
||||||
|
"LabelOpenIDAdvancedPermsClaimDescription": "Име на OpenID твърдението, което съдържа разширени права за достъп до потребителски действия в приложението, които ще се прилагат за роли, различни от администраторските (<b>ако е конфигурирано</b>). Ако твърдението липсва в отговора, достъпът до ABS ще бъде отказан. Ако липсва една опция, тя ще се третира като <code>false</code>. Уверете се, че твърдението на доставчика на идентичност съответства на очакваната структура:",
|
||||||
|
"LabelOpenIDClaims": "Оставете следните опции празни, за да деактивирате разширеното присвояване на групи, като автоматично ще бъде присвоена групата 'Потребител'.",
|
||||||
|
"LabelOpenIDGroupClaimDescription": "Име на OpenID твърдението, което съдържа списък с групите на потребителя. Обикновено се нарича <code>groups</code>. <b>Ако е конфигурирано</b>, приложението автоматично ще присвоява роли въз основа на членството на потребителя в групи, при условие че тези групи са наименувани без чувствителност към регистъра като 'admin', 'user' или 'guest' в твърдението. Твърдението трябва да съдържа списък и ако потребителят принадлежи към множество групи, приложението ще присвои ролята, съответстваща на най-високото ниво на достъп. Ако няма съвпадение с група, достъпът ще бъде отказан.",
|
||||||
"LabelOpenRSSFeed": "Отвори RSS Feed",
|
"LabelOpenRSSFeed": "Отвори RSS Feed",
|
||||||
"LabelOverwrite": "Презапиши",
|
"LabelOverwrite": "Презапиши",
|
||||||
"LabelPassword": "Парола",
|
"LabelPassword": "Парола",
|
||||||
|
|
@ -414,24 +468,27 @@
|
||||||
"LabelPodcasts": "Подкасти",
|
"LabelPodcasts": "Подкасти",
|
||||||
"LabelPort": "Порт",
|
"LabelPort": "Порт",
|
||||||
"LabelPrefixesToIgnore": "Префикси за Игнориране (без значение за главни/малки букви)",
|
"LabelPrefixesToIgnore": "Префикси за Игнориране (без значение за главни/малки букви)",
|
||||||
"LabelPreventIndexing": "Предотврати индексирането на вашия feed от iTunes и Google podcast директории",
|
"LabelPreventIndexing": "Предотвратете индексирането на вашата емисия от директориите на iTunes и Google за подкасти",
|
||||||
"LabelPrimaryEbook": "Основна Електронна Книга",
|
"LabelPrimaryEbook": "Основна Електронна Книга",
|
||||||
"LabelProgress": "Прогрес",
|
"LabelProgress": "Прогрес",
|
||||||
"LabelProvider": "Доставчик",
|
"LabelProvider": "Доставчик",
|
||||||
"LabelPubDate": "Дата на Издаване",
|
"LabelPubDate": "Дата на публикуване",
|
||||||
"LabelPublishYear": "Година на Издаване",
|
"LabelPublishYear": "Година на публикуване",
|
||||||
|
"LabelPublishedDate": "Публикувани {0}",
|
||||||
"LabelPublisher": "Издател",
|
"LabelPublisher": "Издател",
|
||||||
"LabelPublishers": "Издателство",
|
"LabelPublishers": "Издателство",
|
||||||
"LabelRSSFeedCustomOwnerEmail": "Потребителски собственик Email",
|
"LabelRSSFeedCustomOwnerEmail": "Персонализиран имейл на собственика",
|
||||||
"LabelRSSFeedCustomOwnerName": "Потребителски собственик Име",
|
"LabelRSSFeedCustomOwnerName": "Персонализирано име на собственика",
|
||||||
"LabelRSSFeedOpen": "RSS Feed Оптворен",
|
"LabelRSSFeedOpen": "RSS Feed Оптворен",
|
||||||
"LabelRSSFeedPreventIndexing": "Предотврати индексиране",
|
"LabelRSSFeedPreventIndexing": "Предотвратете индексиране",
|
||||||
"LabelRSSFeedSlug": "RSS Feed слъг",
|
"LabelRSSFeedSlug": "идентификатор на RSS емисия",
|
||||||
|
"LabelRSSFeedURL": "URL на RSS емисия",
|
||||||
|
"LabelRandomly": "Случайно",
|
||||||
"LabelRead": "Прочети",
|
"LabelRead": "Прочети",
|
||||||
"LabelReadAgain": "Прочети Отново",
|
"LabelReadAgain": "Прочети отново",
|
||||||
"LabelReadEbookWithoutProgress": "Прочети електронната книга без записване прогрес",
|
"LabelReadEbookWithoutProgress": "Прочети електронната книга без записване прогрес",
|
||||||
"LabelRecentSeries": "Скорошни Серии",
|
"LabelRecentSeries": "Скорошни серии",
|
||||||
"LabelRecentlyAdded": "Наскоро Добавени",
|
"LabelRecentlyAdded": "Скорошно добавени",
|
||||||
"LabelRecommended": "Препоръчано",
|
"LabelRecommended": "Препоръчано",
|
||||||
"LabelRedo": "Повтори",
|
"LabelRedo": "Повтори",
|
||||||
"LabelRegion": "Регион",
|
"LabelRegion": "Регион",
|
||||||
|
|
@ -448,12 +505,12 @@
|
||||||
"LabelSelectUsers": "Избери Потребители",
|
"LabelSelectUsers": "Избери Потребители",
|
||||||
"LabelSendEbookToDevice": "Изпрати електронна книга до ...",
|
"LabelSendEbookToDevice": "Изпрати електронна книга до ...",
|
||||||
"LabelSequence": "Последователност",
|
"LabelSequence": "Последователност",
|
||||||
"LabelSeries": "Серия",
|
"LabelSeries": "От сериите",
|
||||||
"LabelSeriesName": "Име на Серия",
|
"LabelSeriesName": "Име на Серия",
|
||||||
"LabelSeriesProgress": "Прогрес на Серия",
|
"LabelSeriesProgress": "Прогрес на Серия",
|
||||||
"LabelServerYearReview": "Преглед на годината на сървъра ({0})",
|
"LabelServerYearReview": "Преглед на годината на сървъра ({0})",
|
||||||
"LabelSetEbookAsPrimary": "Задай като основна",
|
"LabelSetEbookAsPrimary": "Направи главен",
|
||||||
"LabelSetEbookAsSupplementary": "Задай като допълнителна",
|
"LabelSetEbookAsSupplementary": "Направи второстепенен",
|
||||||
"LabelSettingsAudiobooksOnly": "Само аудиокниги",
|
"LabelSettingsAudiobooksOnly": "Само аудиокниги",
|
||||||
"LabelSettingsAudiobooksOnlyHelp": "Активирането на тази настройка ще игнорира файловете на електронни книги, освен ако не са в папка с аудиокниги, в което случай ще бъдат зададени като допълнителни електронни книги",
|
"LabelSettingsAudiobooksOnlyHelp": "Активирането на тази настройка ще игнорира файловете на електронни книги, освен ако не са в папка с аудиокниги, в което случай ще бъдат зададени като допълнителни електронни книги",
|
||||||
"LabelSettingsBookshelfViewHelp": "Скеуморфен дизайн с дървени рафтове",
|
"LabelSettingsBookshelfViewHelp": "Скеуморфен дизайн с дървени рафтове",
|
||||||
|
|
@ -476,6 +533,7 @@
|
||||||
"LabelSettingsHomePageBookshelfView": "Начална страница изглед на рафт",
|
"LabelSettingsHomePageBookshelfView": "Начална страница изглед на рафт",
|
||||||
"LabelSettingsLibraryBookshelfView": "Библиотека изглед на рафт",
|
"LabelSettingsLibraryBookshelfView": "Библиотека изглед на рафт",
|
||||||
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Пропусни предишни книги в Продължи Поредица",
|
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Пропусни предишни книги в Продължи Поредица",
|
||||||
|
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Рафтът на началната страница 'Продължи поредицата' показва първата книга, която не е започната в поредици, в които има поне една завършена книга и няма книги в процес на четене. Активирането на тази настройка ще продължи поредицата от най-далечната завършена книга вместо от първата незапочната книга.",
|
||||||
"LabelSettingsParseSubtitles": "Извлечи подзаглавия",
|
"LabelSettingsParseSubtitles": "Извлечи подзаглавия",
|
||||||
"LabelSettingsParseSubtitlesHelp": "Извлича подзаглавия от имената на папките на аудиокнигите.<br>Подзаглавията трябва да бъдат разделени с \" - \"<br>например \"Заглавие на Книга - Тук е Подзаглавито\" има подзаглавие \"Тук е Подзаглавито\"",
|
"LabelSettingsParseSubtitlesHelp": "Извлича подзаглавия от имената на папките на аудиокнигите.<br>Подзаглавията трябва да бъдат разделени с \" - \"<br>например \"Заглавие на Книга - Тук е Подзаглавито\" има подзаглавие \"Тук е Подзаглавито\"",
|
||||||
"LabelSettingsPreferMatchedMetadata": "Предпочети съвпадащи метаданни",
|
"LabelSettingsPreferMatchedMetadata": "Предпочети съвпадащи метаданни",
|
||||||
|
|
@ -491,9 +549,10 @@
|
||||||
"LabelSettingsStoreMetadataWithItem": "Запази метаданните с елемента",
|
"LabelSettingsStoreMetadataWithItem": "Запази метаданните с елемента",
|
||||||
"LabelSettingsStoreMetadataWithItemHelp": "По подразбиране метаданните се съхраняват в /metadata/items, като активирате тази настройка метаданните ще се съхраняват в папката на елемента на вашата библиотека",
|
"LabelSettingsStoreMetadataWithItemHelp": "По подразбиране метаданните се съхраняват в /metadata/items, като активирате тази настройка метаданните ще се съхраняват в папката на елемента на вашата библиотека",
|
||||||
"LabelSettingsTimeFormat": "Формат на Време",
|
"LabelSettingsTimeFormat": "Формат на Време",
|
||||||
"LabelShowAll": "Покажи Всички",
|
"LabelShowAll": "Покажи всички",
|
||||||
|
"LabelShowSeconds": "Покажи секунди",
|
||||||
"LabelSize": "Размер",
|
"LabelSize": "Размер",
|
||||||
"LabelSleepTimer": "Таймер за Сън",
|
"LabelSleepTimer": "Таймер за изключване",
|
||||||
"LabelSlug": "Слъг",
|
"LabelSlug": "Слъг",
|
||||||
"LabelStart": "Старт",
|
"LabelStart": "Старт",
|
||||||
"LabelStartTime": "Начално Време",
|
"LabelStartTime": "Начално Време",
|
||||||
|
|
@ -501,19 +560,19 @@
|
||||||
"LabelStartedAt": "Стартирано на",
|
"LabelStartedAt": "Стартирано на",
|
||||||
"LabelStatsAudioTracks": "Аудио Канали",
|
"LabelStatsAudioTracks": "Аудио Канали",
|
||||||
"LabelStatsAuthors": "Автори",
|
"LabelStatsAuthors": "Автори",
|
||||||
"LabelStatsBestDay": "Най-добър Ден",
|
"LabelStatsBestDay": "Най-добър ден",
|
||||||
"LabelStatsDailyAverage": "Дневна Средна Стойност",
|
"LabelStatsDailyAverage": "Средно дневно",
|
||||||
"LabelStatsDays": "Дни",
|
"LabelStatsDays": "Общо дни",
|
||||||
"LabelStatsDaysListened": "Дни Слушани",
|
"LabelStatsDaysListened": "Общо слушани дни",
|
||||||
"LabelStatsHours": "Часове",
|
"LabelStatsHours": "Часове",
|
||||||
"LabelStatsInARow": "подред",
|
"LabelStatsInARow": "последователно",
|
||||||
"LabelStatsItemsFinished": "Завършени Елементи",
|
"LabelStatsItemsFinished": "Приключени елементи",
|
||||||
"LabelStatsItemsInLibrary": "Елементи в Библиотеката",
|
"LabelStatsItemsInLibrary": "Елементи в Библиотеката",
|
||||||
"LabelStatsMinutes": "минути",
|
"LabelStatsMinutes": "минути",
|
||||||
"LabelStatsMinutesListening": "Минути Слушани",
|
"LabelStatsMinutesListening": "Общо слушани минути",
|
||||||
"LabelStatsOverallDays": "Общо Дни",
|
"LabelStatsOverallDays": "Общо Дни",
|
||||||
"LabelStatsOverallHours": "Общо Часове",
|
"LabelStatsOverallHours": "Общо Часове",
|
||||||
"LabelStatsWeekListening": "Седмица Слушане",
|
"LabelStatsWeekListening": "Общо слушани седмици",
|
||||||
"LabelSubtitle": "Подзаглавие",
|
"LabelSubtitle": "Подзаглавие",
|
||||||
"LabelSupportedFileTypes": "Поддържани Типове Файлове",
|
"LabelSupportedFileTypes": "Поддържани Типове Файлове",
|
||||||
"LabelTag": "Таг",
|
"LabelTag": "Таг",
|
||||||
|
|
@ -531,7 +590,7 @@
|
||||||
"LabelTimeBase": "Времева Основа",
|
"LabelTimeBase": "Времева Основа",
|
||||||
"LabelTimeListened": "Време Слушано",
|
"LabelTimeListened": "Време Слушано",
|
||||||
"LabelTimeListenedToday": "Време Слушано Днес",
|
"LabelTimeListenedToday": "Време Слушано Днес",
|
||||||
"LabelTimeRemaining": "{0} оставащо време",
|
"LabelTimeRemaining": "{0} оставащи",
|
||||||
"LabelTimeToShift": "Време за изместване в секунди",
|
"LabelTimeToShift": "Време за изместване в секунди",
|
||||||
"LabelTitle": "Заглавие",
|
"LabelTitle": "Заглавие",
|
||||||
"LabelToolsEmbedMetadata": "Вграждане на Метаданни",
|
"LabelToolsEmbedMetadata": "Вграждане на Метаданни",
|
||||||
|
|
@ -544,14 +603,14 @@
|
||||||
"LabelTotalTimeListened": "Общо Време Слушано",
|
"LabelTotalTimeListened": "Общо Време Слушано",
|
||||||
"LabelTrackFromFilename": "Канал от Име на Файл",
|
"LabelTrackFromFilename": "Канал от Име на Файл",
|
||||||
"LabelTrackFromMetadata": "Канал от Метаданни",
|
"LabelTrackFromMetadata": "Канал от Метаданни",
|
||||||
"LabelTracks": "Канали",
|
"LabelTracks": "Тракове",
|
||||||
"LabelTracksMultiTrack": "Многоканален",
|
"LabelTracksMultiTrack": "Многоканален",
|
||||||
"LabelTracksNone": "Няма канали",
|
"LabelTracksNone": "Няма канали",
|
||||||
"LabelTracksSingleTrack": "Единичен канал",
|
"LabelTracksSingleTrack": "Единичен канал",
|
||||||
"LabelType": "Тип",
|
"LabelType": "Тип",
|
||||||
"LabelUnabridged": "Несъкратен",
|
"LabelUnabridged": "Несъкратен",
|
||||||
"LabelUndo": "Отмени",
|
"LabelUndo": "Отмени",
|
||||||
"LabelUnknown": "Неизвестно",
|
"LabelUnknown": "Неизвестен",
|
||||||
"LabelUpdateCover": "Обнови Корица",
|
"LabelUpdateCover": "Обнови Корица",
|
||||||
"LabelUpdateCoverHelp": "Позволи презаписване на съществуващите корици за избраните книги, когато се намери съвпадение",
|
"LabelUpdateCoverHelp": "Позволи презаписване на съществуващите корици за избраните книги, когато се намери съвпадение",
|
||||||
"LabelUpdateDetails": "Обнови Детайли",
|
"LabelUpdateDetails": "Обнови Детайли",
|
||||||
|
|
@ -563,7 +622,7 @@
|
||||||
"LabelUseChapterTrack": "Използвай канал за глава",
|
"LabelUseChapterTrack": "Използвай канал за глава",
|
||||||
"LabelUseFullTrack": "Използвай пълен канал",
|
"LabelUseFullTrack": "Използвай пълен канал",
|
||||||
"LabelUser": "Потребител",
|
"LabelUser": "Потребител",
|
||||||
"LabelUsername": "Потребителско Име",
|
"LabelUsername": "Потребителско име",
|
||||||
"LabelValue": "Стойност",
|
"LabelValue": "Стойност",
|
||||||
"LabelVersion": "Версия",
|
"LabelVersion": "Версия",
|
||||||
"LabelViewBookmarks": "Виж Отметки",
|
"LabelViewBookmarks": "Виж Отметки",
|
||||||
|
|
@ -571,16 +630,20 @@
|
||||||
"LabelViewQueue": "Виж Опашка",
|
"LabelViewQueue": "Виж Опашка",
|
||||||
"LabelVolume": "Сила на Звука",
|
"LabelVolume": "Сила на Звука",
|
||||||
"LabelWeekdaysToRun": "Делници за изпълнение",
|
"LabelWeekdaysToRun": "Делници за изпълнение",
|
||||||
|
"LabelYearReviewHide": "Скрий ревю на годината ти",
|
||||||
|
"LabelYearReviewShow": "Виж ревю на годината ти",
|
||||||
"LabelYourAudiobookDuration": "Продължителност на вашата аудиокнига",
|
"LabelYourAudiobookDuration": "Продължителност на вашата аудиокнига",
|
||||||
"LabelYourBookmarks": "Вашите Отметки",
|
"LabelYourBookmarks": "Твойте отметки",
|
||||||
"LabelYourPlaylists": "Вашите Плейлисти",
|
"LabelYourPlaylists": "Вашите Плейлисти",
|
||||||
"LabelYourProgress": "Вашият Прогрес",
|
"LabelYourProgress": "Твоят прогрес",
|
||||||
"MessageAddToPlayerQueue": "Добави към опашката на плейъра",
|
"MessageAddToPlayerQueue": "Добави към опашката на плейъра",
|
||||||
"MessageAppriseDescription": "За да ползвате тази функция трябва да имате активна инстанция на <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> или на друго АПИ което да обработва тези заявки. <br />The Apprise API Url-а трябва дае пълния URL път за изпращане на известията, например, ако вашето АПИ ве подава от <code>http://192.168.1.1:8337</code> трябва да сложитев <code>http://192.168.1.1:8337/notify</code>.",
|
"MessageAppriseDescription": "За да ползвате тази функция трябва да имате активна инстанция на <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> или на друго АПИ което да обработва тези заявки. <br />The Apprise API Url-а трябва дае пълния URL път за изпращане на известията, например, ако вашето АПИ ве подава от <code>http://192.168.1.1:8337</code> трябва да сложитев <code>http://192.168.1.1:8337/notify</code>.",
|
||||||
|
"MessageBackupsDescription": "Резервните копия включват потребители, напредък на потребителите, подробности за елементите в библиотеката, настройки на сървъра и изображения, съхранени в <code>/metadata/items</code> и <code>/metadata/authors</code>. Резервните копия <strong>не</strong> включват никакви файлове, съхранени в папките на вашата библиотека.",
|
||||||
"MessageBatchQuickMatchDescription": "Бързото Съпоставяне ще опита да добави липсващи корици и метаданни за избраните елементи. Активирайте опциите по-долу, за да позволите на Бързото съпоставяне да презапише съществуващите корици и/или метаданни.",
|
"MessageBatchQuickMatchDescription": "Бързото Съпоставяне ще опита да добави липсващи корици и метаданни за избраните елементи. Активирайте опциите по-долу, за да позволите на Бързото съпоставяне да презапише съществуващите корици и/или метаданни.",
|
||||||
"MessageBookshelfNoCollections": "Все още нямате създадени колекции",
|
"MessageBookshelfNoCollections": "Все още нямате създадени колекции",
|
||||||
"MessageBookshelfNoRSSFeeds": "Няма отворени RSS feed-ове",
|
"MessageBookshelfNoRSSFeeds": "Няма отворени RSS feed-ове",
|
||||||
"MessageBookshelfNoResultsForFilter": "Няма резултат за филтер \"{0}: {1}\"",
|
"MessageBookshelfNoResultsForFilter": "Няма резултат за филтер \"{0}: {1}\"",
|
||||||
|
"MessageBookshelfNoResultsForQuery": "Няма резултати от заявката",
|
||||||
"MessageBookshelfNoSeries": "Нямаш сеЗЙ",
|
"MessageBookshelfNoSeries": "Нямаш сеЗЙ",
|
||||||
"MessageChapterEndIsAfter": "Краят на главата е след края на вашата аудиокнига",
|
"MessageChapterEndIsAfter": "Краят на главата е след края на вашата аудиокнига",
|
||||||
"MessageChapterErrorFirstNotZero": "Първата глава трябва да започва от 0",
|
"MessageChapterErrorFirstNotZero": "Първата глава трябва да започва от 0",
|
||||||
|
|
@ -600,6 +663,8 @@
|
||||||
"MessageConfirmMarkAllEpisodesNotFinished": "Сигурни ли сте, че искате да маркирате всички епизоди като незавършени?",
|
"MessageConfirmMarkAllEpisodesNotFinished": "Сигурни ли сте, че искате да маркирате всички епизоди като незавършени?",
|
||||||
"MessageConfirmMarkSeriesFinished": "Сигурни ли сте, че искате да маркирате всички книги в тази серия като завършени?",
|
"MessageConfirmMarkSeriesFinished": "Сигурни ли сте, че искате да маркирате всички книги в тази серия като завършени?",
|
||||||
"MessageConfirmMarkSeriesNotFinished": "Сигурни ли сте, че искате да маркирате всички книги в тази серия като незавършени?",
|
"MessageConfirmMarkSeriesNotFinished": "Сигурни ли сте, че искате да маркирате всички книги в тази серия като незавършени?",
|
||||||
|
"MessageConfirmPurgeCache": "Изчистването на кеша ще изтрие цялата директория в <code>/metadata/cache</code>. <br /><br />Сигурни ли сте, че искате да премахнете директорията на кеша?",
|
||||||
|
"MessageConfirmPurgeItemsCache": "Изчистването на кеша на елементите ще изтрие цялата директория в <code>/metadata/cache/items</code>. <br />Сигурни ли сте?",
|
||||||
"MessageConfirmQuickEmbed": "Внимание! Бързото вграждане няма да архивира вашите аудио файлове. Уверете се, че имате резервно копие на вашите аудио файлове. <br><br>Искате ли да продължите?",
|
"MessageConfirmQuickEmbed": "Внимание! Бързото вграждане няма да архивира вашите аудио файлове. Уверете се, че имате резервно копие на вашите аудио файлове. <br><br>Искате ли да продължите?",
|
||||||
"MessageConfirmReScanLibraryItems": "Сигурни ли сте, че искате да сканирате отново {0} елемента?",
|
"MessageConfirmReScanLibraryItems": "Сигурни ли сте, че искате да сканирате отново {0} елемента?",
|
||||||
"MessageConfirmRemoveAllChapters": "Сигурни ли сте, че искате да премахнете всички глави?",
|
"MessageConfirmRemoveAllChapters": "Сигурни ли сте, че искате да премахнете всички глави?",
|
||||||
|
|
@ -617,34 +682,36 @@
|
||||||
"MessageConfirmRenameTagMergeNote": "Забележка: Този таг вече съществува и ще бъде слято.",
|
"MessageConfirmRenameTagMergeNote": "Забележка: Този таг вече съществува и ще бъде слято.",
|
||||||
"MessageConfirmRenameTagWarning": "Внимание! Вече съществува подобен таг с различно писане \"{0}\".",
|
"MessageConfirmRenameTagWarning": "Внимание! Вече съществува подобен таг с различно писане \"{0}\".",
|
||||||
"MessageConfirmSendEbookToDevice": "Сигурни ли сте, че искате да изпратите {0} електронна книга \"{1}\" до устройство \"{2}\"?",
|
"MessageConfirmSendEbookToDevice": "Сигурни ли сте, че искате да изпратите {0} електронна книга \"{1}\" до устройство \"{2}\"?",
|
||||||
"MessageDownloadingEpisode": "Изтегляне на епизод",
|
"MessageDownloadingEpisode": "Сваля епизод",
|
||||||
"MessageDragFilesIntoTrackOrder": "Плъзнете файлове в правилния ред на каналите",
|
"MessageDragFilesIntoTrackOrder": "Плъзнете файлове в правилния ред на каналите",
|
||||||
"MessageEmbedFinished": "Вграждането завърши!",
|
"MessageEmbedFinished": "Вграждането завърши!",
|
||||||
"MessageEpisodesQueuedForDownload": "{0} епизод(и) в опашка за изтегляне",
|
"MessageEpisodesQueuedForDownload": "{0} Епизод(и) са сложени за сваляне",
|
||||||
"MessageFeedURLWillBe": "Feed URL-a ще бъде {0}",
|
"MessageEreaderDevices": "За да осигурите доставката на е-книги, може да се наложи да добавите горепосочения имейл адрес като валиден подател за всяко устройство, изброено по-долу.",
|
||||||
"MessageFetching": "Взимане...",
|
"MessageFeedURLWillBe": "Адресът на емисията ще бъде {0}",
|
||||||
|
"MessageFetching": "Извличане...",
|
||||||
"MessageForceReScanDescription": "ще сканира всички файлове отново като прясно сканиране. Аудио файлове ID3 тагове, OPF файлове и текстови файлове ще бъдат сканирани като нови.",
|
"MessageForceReScanDescription": "ще сканира всички файлове отново като прясно сканиране. Аудио файлове ID3 тагове, OPF файлове и текстови файлове ще бъдат сканирани като нови.",
|
||||||
"MessageImportantNotice": "Важно Съобщение!",
|
"MessageImportantNotice": "Важно Съобщение!",
|
||||||
"MessageInsertChapterBelow": "Вмъкни глава под",
|
"MessageInsertChapterBelow": "Вмъкни глава под",
|
||||||
"MessageItemsSelected": "{0} избрани",
|
"MessageItemsSelected": "{0} избрани",
|
||||||
"MessageItemsUpdated": "{0} елемента обновени",
|
"MessageItemsUpdated": "{0} елемента обновени",
|
||||||
"MessageJoinUsOn": "Присъединете се към нас",
|
"MessageJoinUsOn": "Присъединете се към нас",
|
||||||
"MessageLoading": "Зареждане...",
|
"MessageLoading": "Зарежда...",
|
||||||
"MessageLoadingFolders": "Зареждане на Папки...",
|
"MessageLoadingFolders": "Зареждане на Папки...",
|
||||||
|
"MessageLogsDescription": "Логовете се съхраняват в <code>/metadata/logs</code> като JSON файлове. Дневниците за сривове се съхраняват в <code>/metadata/logs/crash_logs.txt</code>.",
|
||||||
"MessageM4BFailed": "M4B Провалено!",
|
"MessageM4BFailed": "M4B Провалено!",
|
||||||
"MessageM4BFinished": "M4B Завършено!",
|
"MessageM4BFinished": "M4B Завършено!",
|
||||||
"MessageMapChapterTitles": "Съпостави заглавията на главите със съществуващите глави на аудиокнигата без да променяш времената",
|
"MessageMapChapterTitles": "Съпостави заглавията на главите със съществуващите глави на аудиокнигата без да променяш времената",
|
||||||
"MessageMarkAllEpisodesFinished": "Маркирай всички епизоди като завършени",
|
"MessageMarkAllEpisodesFinished": "Маркирай всички епизоди като завършени",
|
||||||
"MessageMarkAllEpisodesNotFinished": "Маркирай всички епизоди като незавършени",
|
"MessageMarkAllEpisodesNotFinished": "Маркирай всички епизоди като незавършени",
|
||||||
"MessageMarkAsFinished": "Маркирай като Завършено",
|
"MessageMarkAsFinished": "Маркирай като завършено",
|
||||||
"MessageMarkAsNotFinished": "Маркирай като Незавършено",
|
"MessageMarkAsNotFinished": "Маркирай като Незавършено",
|
||||||
"MessageMatchBooksDescription": "ще се опита да съпостави книги в библиотеката с книга от избрания доставчик за търсене и ще попълни празни детайли и корици. Не презаписва детайлите.",
|
"MessageMatchBooksDescription": "ще се опита да съпостави книги в библиотеката с книга от избрания доставчик за търсене и ще попълни празни детайли и корици. Не презаписва детайлите.",
|
||||||
"MessageNoAudioTracks": "Няма аудио канали",
|
"MessageNoAudioTracks": "Няма аудио канали",
|
||||||
"MessageNoAuthors": "Няма Автори",
|
"MessageNoAuthors": "Няма Автори",
|
||||||
"MessageNoBackups": "Няма архиви",
|
"MessageNoBackups": "Няма архиви",
|
||||||
"MessageNoBookmarks": "Няма Отметки",
|
"MessageNoBookmarks": "Няма отметки",
|
||||||
"MessageNoChapters": "Няма Глави",
|
"MessageNoChapters": "Няма глави",
|
||||||
"MessageNoCollections": "Няма Колекции",
|
"MessageNoCollections": "Няма колекции",
|
||||||
"MessageNoCoversFound": "Не са намерени корици",
|
"MessageNoCoversFound": "Не са намерени корици",
|
||||||
"MessageNoDescription": "Няма описание",
|
"MessageNoDescription": "Няма описание",
|
||||||
"MessageNoDownloadsInProgress": "Няма изтегляния в прогрес",
|
"MessageNoDownloadsInProgress": "Няма изтегляния в прогрес",
|
||||||
|
|
@ -654,9 +721,9 @@
|
||||||
"MessageNoFoldersAvailable": "Няма налични папки",
|
"MessageNoFoldersAvailable": "Няма налични папки",
|
||||||
"MessageNoGenres": "Няма Жанрове",
|
"MessageNoGenres": "Няма Жанрове",
|
||||||
"MessageNoIssues": "Няма проблеми",
|
"MessageNoIssues": "Няма проблеми",
|
||||||
"MessageNoItems": "Няма Елементи",
|
"MessageNoItems": "Няма елементи",
|
||||||
"MessageNoItemsFound": "Няма намерени елементи",
|
"MessageNoItemsFound": "Няма намерени елементи",
|
||||||
"MessageNoListeningSessions": "Няма слушателски сесии",
|
"MessageNoListeningSessions": "Няма сесии за слушане",
|
||||||
"MessageNoLogs": "Няма логове",
|
"MessageNoLogs": "Няма логове",
|
||||||
"MessageNoMediaProgress": "Няма прогрес на медията",
|
"MessageNoMediaProgress": "Няма прогрес на медията",
|
||||||
"MessageNoNotifications": "Няма известия",
|
"MessageNoNotifications": "Няма известия",
|
||||||
|
|
@ -666,20 +733,21 @@
|
||||||
"MessageNoSeries": "Няма Серии",
|
"MessageNoSeries": "Няма Серии",
|
||||||
"MessageNoTags": "Няма Тагове",
|
"MessageNoTags": "Няма Тагове",
|
||||||
"MessageNoTasksRunning": "Няма вършещи се задачи",
|
"MessageNoTasksRunning": "Няма вършещи се задачи",
|
||||||
"MessageNoUpdatesWereNecessary": "Не бяха необходими обновления",
|
"MessageNoUpdatesWereNecessary": "Няма нужда от обновяване",
|
||||||
"MessageNoUserPlaylists": "Няма плейлисти на потребителя",
|
"MessageNoUserPlaylists": "Нямате създадени плейлисти",
|
||||||
"MessageNotYetImplemented": "Още не е изпълнено",
|
"MessageNotYetImplemented": "Още не е изпълнено",
|
||||||
"MessageOr": "или",
|
"MessageOr": "или",
|
||||||
"MessagePauseChapter": "Пауза на глава",
|
"MessagePauseChapter": "Пауза на глава",
|
||||||
"MessagePlayChapter": "Пусни налчалото на глава",
|
"MessagePlayChapter": "Пусни налчалото на глава",
|
||||||
"MessagePlaylistCreateFromCollection": "Създай плейлист от колекция",
|
"MessagePlaylistCreateFromCollection": "Създай плейлист от колекция",
|
||||||
"MessagePodcastHasNoRSSFeedForMatching": "Подкастът няма URL адрес на RSS feed за използване за съпоставяне",
|
"MessagePodcastHasNoRSSFeedForMatching": "Подкастът няма URL адрес на RSS feed за използване за съпоставяне",
|
||||||
|
"MessagePodcastSearchField": "Въведи какво да търся или RSS емисия адрес",
|
||||||
"MessageQuickMatchDescription": "Попълни празните детайли и корици с първия резултат от '{0}'. Не презаписва детайлите, освен ако не е активирана настройката 'Предпочети съвпадащи метаданни' на сървъра.",
|
"MessageQuickMatchDescription": "Попълни празните детайли и корици с първия резултат от '{0}'. Не презаписва детайлите, освен ако не е активирана настройката 'Предпочети съвпадащи метаданни' на сървъра.",
|
||||||
"MessageRemoveChapter": "Премахни глава",
|
"MessageRemoveChapter": "Премахни глава",
|
||||||
"MessageRemoveEpisodes": "Премахни {0} епизод(и)",
|
"MessageRemoveEpisodes": "Премахни {0} епизод(и)",
|
||||||
"MessageRemoveFromPlayerQueue": "Премахни от опашката на плейъра",
|
"MessageRemoveFromPlayerQueue": "Премахни от опашката на плейъра",
|
||||||
"MessageRemoveUserWarning": "Сигурни ли сте, че искате да изтриете потребител \"{0}\" завинаги?",
|
"MessageRemoveUserWarning": "Сигурни ли сте, че искате да изтриете потребител \"{0}\" завинаги?",
|
||||||
"MessageReportBugsAndContribute": "Съобщавайте за грешки, заявявайте функции и допринасяйте на",
|
"MessageReportBugsAndContribute": "Докладвайте грешки, поискайте нови функции и допринасяйте на",
|
||||||
"MessageResetChaptersConfirm": "Сигурни ли сте, че искате да нулирате главите и да отмените промените, които сте направили?",
|
"MessageResetChaptersConfirm": "Сигурни ли сте, че искате да нулирате главите и да отмените промените, които сте направили?",
|
||||||
"MessageRestoreBackupConfirm": "Сигурни ли сте, че искате да възстановите архива създаден на",
|
"MessageRestoreBackupConfirm": "Сигурни ли сте, че искате да възстановите архива създаден на",
|
||||||
"MessageRestoreBackupWarning": "Възстановяването на архив ще презапише цялата база данни, намираща се в /config и кориците в /metadata/items & /metadata/authors.<br /><br />Архивите не променят файловете в папките на вашата библиотека. Ако сте активирали настройките на сървъра за съхранение на корици и метаданни в папките на вашата библиотека, те няма да бъдат архивирани или презаписани.<br /><br />Всички клиенти, използващи вашия сървър, ще бъдат автоматично обновени.",
|
"MessageRestoreBackupWarning": "Възстановяването на архив ще презапише цялата база данни, намираща се в /config и кориците в /metadata/items & /metadata/authors.<br /><br />Архивите не променят файловете в папките на вашата библиотека. Ако сте активирали настройките на сървъра за съхранение на корици и метаданни в папките на вашата библиотека, те няма да бъдат архивирани или презаписани.<br /><br />Всички клиенти, използващи вашия сървър, ще бъдат автоматично обновени.",
|
||||||
|
|
@ -700,8 +768,8 @@
|
||||||
"NoteChangeRootPassword": "Root потребителят е единственият потребител, който може да има празна парола",
|
"NoteChangeRootPassword": "Root потребителят е единственият потребител, който може да има празна парола",
|
||||||
"NoteChapterEditorTimes": "Забележка: Първото време на начало на главата трябва да остане на 0:00, а последното време на начало на главата не може да надвишава продължителността на тази аудиокнига.",
|
"NoteChapterEditorTimes": "Забележка: Първото време на начало на главата трябва да остане на 0:00, а последното време на начало на главата не може да надвишава продължителността на тази аудиокнига.",
|
||||||
"NoteFolderPicker": "Забележка: папките, които вече са картографирани, няма да бъдат показани",
|
"NoteFolderPicker": "Забележка: папките, които вече са картографирани, няма да бъдат показани",
|
||||||
"NoteRSSFeedPodcastAppsHttps": "Внимание: Повечето приложения за подкасти изискват URL адреса на RSS feed да използва HTTPS",
|
"NoteRSSFeedPodcastAppsHttps": "Предупреждение: Повечето приложения за подкасти изискват URL адресът на RSS емисията да използва HTTPS",
|
||||||
"NoteRSSFeedPodcastAppsPubDate": "Внимание: 1 или повече от вашите епизоди нямат дата на публикуване. Някои приложения за подкасти изискват това",
|
"NoteRSSFeedPodcastAppsPubDate": "Предупреждение: Един или повече от вашите епизоди нямат дата на публикуване. Някои приложения за подкасти изискват това.",
|
||||||
"NoteUploaderFoldersWithMediaFiles": "Папките с медийни файлове ще бъдат обработени като отделни елементи на библиотеката.",
|
"NoteUploaderFoldersWithMediaFiles": "Папките с медийни файлове ще бъдат обработени като отделни елементи на библиотеката.",
|
||||||
"NoteUploaderOnlyAudioFiles": "Ако качвате само аудио файлове, то всеки аудио файл ще бъде обработен като отделна аудиокнига.",
|
"NoteUploaderOnlyAudioFiles": "Ако качвате само аудио файлове, то всеки аудио файл ще бъде обработен като отделна аудиокнига.",
|
||||||
"NoteUploaderUnsupportedFiles": "Неподдържаните файлове се игнорират. При избор или пускане на папка, други файлове, които не са в папка на елемент, се игнорират.",
|
"NoteUploaderUnsupportedFiles": "Неподдържаните файлове се игнорират. При избор или пускане на папка, други файлове, които не са в папка на елемент, се игнорират.",
|
||||||
|
|
@ -722,18 +790,25 @@
|
||||||
"ToastBackupRestoreFailed": "Неуспешно възстановяване на архив",
|
"ToastBackupRestoreFailed": "Неуспешно възстановяване на архив",
|
||||||
"ToastBackupUploadFailed": "Неуспешно качване на архив",
|
"ToastBackupUploadFailed": "Неуспешно качване на архив",
|
||||||
"ToastBackupUploadSuccess": "Архивът е качен",
|
"ToastBackupUploadSuccess": "Архивът е качен",
|
||||||
|
"ToastBatchUpdateFailed": "Неуспешно групово актуализиране",
|
||||||
|
"ToastBatchUpdateSuccess": "Успешно групово актуализиране",
|
||||||
"ToastBookmarkCreateFailed": "Неуспешно създаване на отметка",
|
"ToastBookmarkCreateFailed": "Неуспешно създаване на отметка",
|
||||||
"ToastBookmarkCreateSuccess": "Отметката е създадена",
|
"ToastBookmarkCreateSuccess": "Отметката е създадена",
|
||||||
"ToastBookmarkRemoveSuccess": "Отметката е премахната",
|
"ToastBookmarkRemoveSuccess": "Отметката е премахната",
|
||||||
|
"ToastCachePurgeFailed": "Неуспешно изчистване на кеша",
|
||||||
|
"ToastCachePurgeSuccess": "Успешно изчистване на кеша",
|
||||||
"ToastChaptersHaveErrors": "Главите имат грешки",
|
"ToastChaptersHaveErrors": "Главите имат грешки",
|
||||||
"ToastChaptersMustHaveTitles": "Главите трябва да имат заглавия",
|
"ToastChaptersMustHaveTitles": "Главите трябва да имат заглавия",
|
||||||
"ToastCollectionRemoveSuccess": "Колекцията е премахната",
|
"ToastCollectionRemoveSuccess": "Колекцията е премахната",
|
||||||
"ToastCollectionUpdateSuccess": "Колекцията е обновена",
|
"ToastCollectionUpdateSuccess": "Колекцията е обновена",
|
||||||
|
"ToastDeleteFileFailed": "Неуспешно изтриване на файла",
|
||||||
|
"ToastDeleteFileSuccess": "Успешно изтриване на файла",
|
||||||
|
"ToastFailedToLoadData": "Неуспешно зареждане на данни",
|
||||||
"ToastItemCoverUpdateSuccess": "Корицата на елемента е обновена",
|
"ToastItemCoverUpdateSuccess": "Корицата на елемента е обновена",
|
||||||
"ToastItemDetailsUpdateSuccess": "Детайлите на елемента са обновени",
|
"ToastItemDetailsUpdateSuccess": "Детайлите на елемента са обновени",
|
||||||
"ToastItemMarkedAsFinishedFailed": "Неуспешно маркиране като завършено",
|
"ToastItemMarkedAsFinishedFailed": "Неуспешно маркиране като Завършено",
|
||||||
"ToastItemMarkedAsFinishedSuccess": "Елементът е маркиран като завършен",
|
"ToastItemMarkedAsFinishedSuccess": "Елементът е маркиран като завършен",
|
||||||
"ToastItemMarkedAsNotFinishedFailed": "Неуспешно маркиране като незавършено",
|
"ToastItemMarkedAsNotFinishedFailed": "Неуспешно маркиране като Незавършено",
|
||||||
"ToastItemMarkedAsNotFinishedSuccess": "Елементът е маркиран като незавършен",
|
"ToastItemMarkedAsNotFinishedSuccess": "Елементът е маркиран като незавършен",
|
||||||
"ToastLibraryCreateFailed": "Неуспешно създаване на библиотека",
|
"ToastLibraryCreateFailed": "Неуспешно създаване на библиотека",
|
||||||
"ToastLibraryCreateSuccess": "Библиотеката \"{0}\" е създадена",
|
"ToastLibraryCreateSuccess": "Библиотеката \"{0}\" е създадена",
|
||||||
|
|
@ -747,20 +822,23 @@
|
||||||
"ToastPlaylistRemoveSuccess": "Плейлистът е премахнат",
|
"ToastPlaylistRemoveSuccess": "Плейлистът е премахнат",
|
||||||
"ToastPlaylistUpdateSuccess": "Плейлистът е обновен",
|
"ToastPlaylistUpdateSuccess": "Плейлистът е обновен",
|
||||||
"ToastPodcastCreateFailed": "Неуспешно създаване на подкаст",
|
"ToastPodcastCreateFailed": "Неуспешно създаване на подкаст",
|
||||||
"ToastPodcastCreateSuccess": "Подкастът е създаден",
|
"ToastPodcastCreateSuccess": "Подкаст успешно създаден",
|
||||||
"ToastRSSFeedCloseFailed": "Неуспешно затваряне на RSS feed",
|
"ToastRSSFeedCloseFailed": "Неуспешно затваряне на RSS емисията",
|
||||||
"ToastRSSFeedCloseSuccess": "RSS feed затворен",
|
"ToastRSSFeedCloseSuccess": "RSS емисията е затворена",
|
||||||
"ToastRemoveItemFromCollectionFailed": "Неуспешно премахване на елемент от колекция",
|
"ToastRemoveItemFromCollectionFailed": "Неуспешно премахване на елемент от колекция",
|
||||||
"ToastRemoveItemFromCollectionSuccess": "Елементът е премахнат от колекция",
|
"ToastRemoveItemFromCollectionSuccess": "Елементът е премахнат от колекция",
|
||||||
"ToastSendEbookToDeviceFailed": "Неуспешно изпращане на електронна книга до устройство",
|
"ToastSendEbookToDeviceFailed": "Неуспешно изпращане на електронна книга до устройство",
|
||||||
"ToastSendEbookToDeviceSuccess": "Електронната книга е изпратена до устройство \"{0}\"",
|
"ToastSendEbookToDeviceSuccess": "Електронната книга е изпратена до устройство \"{0}\"",
|
||||||
"ToastSeriesUpdateFailed": "Неуспешно обновяване на серия",
|
"ToastSeriesUpdateFailed": "Неуспешно обновяване на серия",
|
||||||
"ToastSeriesUpdateSuccess": "Серията е обновена",
|
"ToastSeriesUpdateSuccess": "Серията е обновена",
|
||||||
|
"ToastServerSettingsUpdateSuccess": "Настройките на сървъра са актуализирани",
|
||||||
"ToastSessionDeleteFailed": "Неуспешно изтриване на сесия",
|
"ToastSessionDeleteFailed": "Неуспешно изтриване на сесия",
|
||||||
"ToastSessionDeleteSuccess": "Сесията е изтрита",
|
"ToastSessionDeleteSuccess": "Сесията е изтрита",
|
||||||
"ToastSocketConnected": "Свързан сокет",
|
"ToastSocketConnected": "Свързан сокет",
|
||||||
"ToastSocketDisconnected": "Сокетът е прекъснат",
|
"ToastSocketDisconnected": "Сокетът е прекъснат",
|
||||||
"ToastSocketFailedToConnect": "Неуспешно свързване на сокет",
|
"ToastSocketFailedToConnect": "Неуспешно свързване на сокет",
|
||||||
|
"ToastSortingPrefixesEmptyError": "Трябва да има поне 1 префикс за сортиране",
|
||||||
|
"ToastSortingPrefixesUpdateSuccess": "Префиксите за сортиране са актуализирани ({0} елемента)",
|
||||||
"ToastUserDeleteFailed": "Неуспешно изтриване на потребител",
|
"ToastUserDeleteFailed": "Неуспешно изтриване на потребител",
|
||||||
"ToastUserDeleteSuccess": "Потребителят е изтрит"
|
"ToastUserDeleteSuccess": "Потребителят е изтрит"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -678,7 +678,7 @@
|
||||||
"LabelUploaderDropFiles": "Ispusti datoteke",
|
"LabelUploaderDropFiles": "Ispusti datoteke",
|
||||||
"LabelUploaderItemFetchMetadataHelp": "Automatski dohvati naslov, autora i serijal",
|
"LabelUploaderItemFetchMetadataHelp": "Automatski dohvati naslov, autora i serijal",
|
||||||
"LabelUseAdvancedOptions": "Koristi se naprednim opcijama",
|
"LabelUseAdvancedOptions": "Koristi se naprednim opcijama",
|
||||||
"LabelUseChapterTrack": "Koristi zvučni zapis poglavlja",
|
"LabelUseChapterTrack": "Upravljaj trakom poglavlja",
|
||||||
"LabelUseFullTrack": "Koristi cijeli zvučni zapis",
|
"LabelUseFullTrack": "Koristi cijeli zvučni zapis",
|
||||||
"LabelUseZeroForUnlimited": "0 za neograničeno",
|
"LabelUseZeroForUnlimited": "0 za neograničeno",
|
||||||
"LabelUser": "Korisnik",
|
"LabelUser": "Korisnik",
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
"ButtonCancel": "Avbryt",
|
"ButtonCancel": "Avbryt",
|
||||||
"ButtonCancelEncode": "Avbryt omkodning",
|
"ButtonCancelEncode": "Avbryt omkodning",
|
||||||
"ButtonChangeRootPassword": "Ändra lösenordet för root",
|
"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",
|
"ButtonChooseAFolder": "Välj en mapp",
|
||||||
"ButtonChooseFiles": "Välj filer",
|
"ButtonChooseFiles": "Välj filer",
|
||||||
"ButtonClearFilter": "Rensa filter",
|
"ButtonClearFilter": "Rensa filter",
|
||||||
|
|
@ -75,8 +75,8 @@
|
||||||
"ButtonRemove": "Ta bort",
|
"ButtonRemove": "Ta bort",
|
||||||
"ButtonRemoveAll": "Ta bort alla",
|
"ButtonRemoveAll": "Ta bort alla",
|
||||||
"ButtonRemoveAllLibraryItems": "Ta bort alla objekt i biblioteket",
|
"ButtonRemoveAllLibraryItems": "Ta bort alla objekt i biblioteket",
|
||||||
"ButtonRemoveFromContinueListening": "Radera från 'Fortsätt lyssna'",
|
"ButtonRemoveFromContinueListening": "Radera från 'Fortsätt att lyssna'",
|
||||||
"ButtonRemoveFromContinueReading": "Radera från 'Fortsätt läsa'",
|
"ButtonRemoveFromContinueReading": "Radera från 'Fortsätt att läsa'",
|
||||||
"ButtonRemoveSeriesFromContinueSeries": "Radera från 'Fortsätt med serien'",
|
"ButtonRemoveSeriesFromContinueSeries": "Radera från 'Fortsätt med serien'",
|
||||||
"ButtonReset": "Tillbaka",
|
"ButtonReset": "Tillbaka",
|
||||||
"ButtonResetToDefault": "Återställ till standard",
|
"ButtonResetToDefault": "Återställ till standard",
|
||||||
|
|
@ -231,6 +231,7 @@
|
||||||
"LabelAutoDownloadEpisodes": "Automatisk nedladdning av avsnitt",
|
"LabelAutoDownloadEpisodes": "Automatisk nedladdning av avsnitt",
|
||||||
"LabelAutoFetchMetadata": "Automatisk nedladdning av metadata",
|
"LabelAutoFetchMetadata": "Automatisk nedladdning av metadata",
|
||||||
"LabelAutoFetchMetadataHelp": "Hämtar metadata för titel, författare och serier. Kompletterande metadata kan manuellt adderas efter uppladdningen.",
|
"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",
|
"LabelAutoRegisterDescription": "Skapa automatiskt nya användare efter inloggning",
|
||||||
"LabelBackToUser": "Tillbaka till användaren",
|
"LabelBackToUser": "Tillbaka till användaren",
|
||||||
"LabelBackupAudioFiles": "Säkerhetskopiera ljudfiler",
|
"LabelBackupAudioFiles": "Säkerhetskopiera ljudfiler",
|
||||||
|
|
@ -242,7 +243,7 @@
|
||||||
"LabelBackupsNumberToKeep": "Antal säkerhetskopior att behålla",
|
"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.",
|
"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",
|
"LabelBitrate": "Bitfrekvens",
|
||||||
"LabelBonus": "Bonus",
|
"LabelBonus": "Bonusavsnitt",
|
||||||
"LabelBooks": "Böcker",
|
"LabelBooks": "Böcker",
|
||||||
"LabelButtonText": "Knapptext",
|
"LabelButtonText": "Knapptext",
|
||||||
"LabelByAuthor": "av {0}",
|
"LabelByAuthor": "av {0}",
|
||||||
|
|
@ -312,9 +313,11 @@
|
||||||
"LabelEnd": "Slut",
|
"LabelEnd": "Slut",
|
||||||
"LabelEndOfChapter": "Slut av kapitel",
|
"LabelEndOfChapter": "Slut av kapitel",
|
||||||
"LabelEpisode": "Avsnitt",
|
"LabelEpisode": "Avsnitt",
|
||||||
|
"LabelEpisodeNotLinkedToRssFeed": "Avsnittet är inte knutet till ett RSS-flöde",
|
||||||
"LabelEpisodeNumber": "Avsnitt #{0}",
|
"LabelEpisodeNumber": "Avsnitt #{0}",
|
||||||
"LabelEpisodeTitle": "Titel på avsnittet",
|
"LabelEpisodeTitle": "Titel på avsnittet",
|
||||||
"LabelEpisodeType": "Typ av avsnitt",
|
"LabelEpisodeType": "Typ av avsnitt",
|
||||||
|
"LabelEpisodeUrlFromRssFeed": "URL-adress till avsnittet i RSS-flödet",
|
||||||
"LabelEpisodes": "Avsnitt",
|
"LabelEpisodes": "Avsnitt",
|
||||||
"LabelEpisodic": "Uppdelad i avsnitt",
|
"LabelEpisodic": "Uppdelad i avsnitt",
|
||||||
"LabelExample": "Exempel",
|
"LabelExample": "Exempel",
|
||||||
|
|
@ -327,6 +330,7 @@
|
||||||
"LabelFetchingMetadata": "Hämtar metadata",
|
"LabelFetchingMetadata": "Hämtar metadata",
|
||||||
"LabelFile": "Fil",
|
"LabelFile": "Fil",
|
||||||
"LabelFileBirthtime": "Tidpunkt, fil skapad",
|
"LabelFileBirthtime": "Tidpunkt, fil skapad",
|
||||||
|
"LabelFileBornDate": "Skapad {0}",
|
||||||
"LabelFileModified": "Tidpunkt, fil ändrad",
|
"LabelFileModified": "Tidpunkt, fil ändrad",
|
||||||
"LabelFileModifiedDate": "Ändrad {0}",
|
"LabelFileModifiedDate": "Ändrad {0}",
|
||||||
"LabelFilename": "Filnamn",
|
"LabelFilename": "Filnamn",
|
||||||
|
|
@ -341,6 +345,7 @@
|
||||||
"LabelFontItalic": "Kursiv",
|
"LabelFontItalic": "Kursiv",
|
||||||
"LabelFontScale": "Skala på typsnitt",
|
"LabelFontScale": "Skala på typsnitt",
|
||||||
"LabelFontStrikethrough": "Genomstruken",
|
"LabelFontStrikethrough": "Genomstruken",
|
||||||
|
"LabelFull": "Komplett",
|
||||||
"LabelGenre": "Kategori",
|
"LabelGenre": "Kategori",
|
||||||
"LabelGenres": "Kategorier",
|
"LabelGenres": "Kategorier",
|
||||||
"LabelHardDeleteFile": "Hård radering av fil",
|
"LabelHardDeleteFile": "Hård radering av fil",
|
||||||
|
|
@ -355,7 +360,7 @@
|
||||||
"LabelImageURLFromTheWeb": "Skriv URL-adressen till bilden på webben",
|
"LabelImageURLFromTheWeb": "Skriv URL-adressen till bilden på webben",
|
||||||
"LabelInProgress": "Pågående",
|
"LabelInProgress": "Pågående",
|
||||||
"LabelIncludeInTracklist": "Inkludera i spårlista",
|
"LabelIncludeInTracklist": "Inkludera i spårlista",
|
||||||
"LabelIncomplete": "Ofullständig",
|
"LabelIncomplete": "Ofullständigt",
|
||||||
"LabelInterval": "Intervall",
|
"LabelInterval": "Intervall",
|
||||||
"LabelIntervalCustomDailyWeekly": "Anpassad daglig/veckovis",
|
"LabelIntervalCustomDailyWeekly": "Anpassad daglig/veckovis",
|
||||||
"LabelIntervalEvery12Hours": "Var 12:e timme",
|
"LabelIntervalEvery12Hours": "Var 12:e timme",
|
||||||
|
|
@ -416,7 +421,7 @@
|
||||||
"LabelNew": "Nytt",
|
"LabelNew": "Nytt",
|
||||||
"LabelNewPassword": "Nytt lösenord",
|
"LabelNewPassword": "Nytt lösenord",
|
||||||
"LabelNewestAuthors": "Senaste författarna",
|
"LabelNewestAuthors": "Senaste författarna",
|
||||||
"LabelNewestEpisodes": "Senast adderade avsnitt",
|
"LabelNewestEpisodes": "Senaste avsnitten",
|
||||||
"LabelNextBackupDate": "Nästa tillfälle för säkerhetskopiering",
|
"LabelNextBackupDate": "Nästa tillfälle för säkerhetskopiering",
|
||||||
"LabelNextScheduledRun": "Nästa schemalagda körning",
|
"LabelNextScheduledRun": "Nästa schemalagda körning",
|
||||||
"LabelNoCustomMetadataProviders": "Ingen egen källa för metadata",
|
"LabelNoCustomMetadataProviders": "Ingen egen källa för metadata",
|
||||||
|
|
@ -467,12 +472,13 @@
|
||||||
"LabelPublishYear": "Publiceringsår",
|
"LabelPublishYear": "Publiceringsår",
|
||||||
"LabelPublishedDecade": "Årtionde för publicering",
|
"LabelPublishedDecade": "Årtionde för publicering",
|
||||||
"LabelPublisher": "Utgivare",
|
"LabelPublisher": "Utgivare",
|
||||||
|
"LabelPublishers": "Utgivare",
|
||||||
"LabelRSSFeedCustomOwnerEmail": "Anpassad ägarens e-post",
|
"LabelRSSFeedCustomOwnerEmail": "Anpassad ägarens e-post",
|
||||||
"LabelRSSFeedCustomOwnerName": "Anpassat ägarnamn",
|
"LabelRSSFeedCustomOwnerName": "Anpassat ägarnamn",
|
||||||
"LabelRSSFeedOpen": "Öppna RSS-flöde",
|
"LabelRSSFeedOpen": "Öppna RSS-flöde",
|
||||||
"LabelRSSFeedPreventIndexing": "Förhindra indexering",
|
"LabelRSSFeedPreventIndexing": "Förhindra indexering",
|
||||||
"LabelRSSFeedSlug": "RSS-flödesslag",
|
"LabelRSSFeedSlug": "RSS-flödesslag",
|
||||||
"LabelRSSFeedURL": "RSS-flöde URL",
|
"LabelRSSFeedURL": "URL-adress för RSS-flödet",
|
||||||
"LabelRandomly": "Slumpartat",
|
"LabelRandomly": "Slumpartat",
|
||||||
"LabelRead": "Läst",
|
"LabelRead": "Läst",
|
||||||
"LabelReadAgain": "Läs igen",
|
"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",
|
"LabelSettingsStoreMetadataWithItemHelp": "Som standard lagras metadatafiler i mappen '/metadata/items'. Genom att aktivera detta alternativ kommer metadatafilerna att lagra i din biblioteksmapp",
|
||||||
"LabelSettingsTimeFormat": "Tidsformat",
|
"LabelSettingsTimeFormat": "Tidsformat",
|
||||||
"LabelShare": "Dela",
|
"LabelShare": "Dela",
|
||||||
|
"LabelShareURL": "Dela URL-länk",
|
||||||
"LabelShowAll": "Visa alla",
|
"LabelShowAll": "Visa alla",
|
||||||
"LabelShowSeconds": "Visa sekunder",
|
"LabelShowSeconds": "Visa sekunder",
|
||||||
"LabelShowSubtitles": "Visa underrubriker",
|
"LabelShowSubtitles": "Visa underrubriker",
|
||||||
|
|
@ -693,6 +700,7 @@
|
||||||
"MessageConfirmPurgeCache": "När du rensar cashen kommer katalogen <code>/metadata/cache</code> att raderas. <br /><br />Är du säker på att du vill radera katalogen?",
|
"MessageConfirmPurgeCache": "När du rensar cashen kommer katalogen <code>/metadata/cache</code> att raderas. <br /><br />Är du säker på att du vill radera katalogen?",
|
||||||
"MessageConfirmPurgeItemsCache": "När du rensar cashen för föremål kommer katalogen <code>/metadata/cache/items</code> att raderas. <br /><br />Är du säker på att du vill radera katalogen?",
|
"MessageConfirmPurgeItemsCache": "När du rensar cashen för föremål kommer katalogen <code>/metadata/cache/items</code> att raderas. <br /><br />Är du säker på att du vill radera katalogen?",
|
||||||
"MessageConfirmQuickEmbed": "VARNING! Quick embed kommer inte att säkerhetskopiera dina ljudfiler. Se till att du har en säkerhetskopia av dina ljudfiler. <br><br>Vill du fortsätta?",
|
"MessageConfirmQuickEmbed": "VARNING! Quick embed kommer inte att säkerhetskopiera dina ljudfiler. Se till att du har en säkerhetskopia av dina ljudfiler. <br><br>Vill du fortsätta?",
|
||||||
|
"MessageConfirmQuickMatchEpisodes": "Snabbmatchning av avsnitt kommer att ersätta befintlig information vid en träff. Endast omatchade avsnitt kommer att uppdateras. Vill du fortsätta?",
|
||||||
"MessageConfirmReScanLibraryItems": "Är du säker på att du vill göra en ny skanning för {0} objekt?",
|
"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?",
|
"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}\"?",
|
"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}\"?",
|
"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?",
|
"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.",
|
"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?",
|
"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.",
|
"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}\".",
|
"MessageConfirmRenameTagWarning": "VARNING! En liknande tagg med annat skrivsätt finns redan \"{0}\".",
|
||||||
|
|
@ -735,7 +743,7 @@
|
||||||
"MessageMarkAllEpisodesNotFinished": "Markera alla avsnitt som ej avslutade",
|
"MessageMarkAllEpisodesNotFinished": "Markera alla avsnitt som ej avslutade",
|
||||||
"MessageMarkAsFinished": "Markera som avslutad",
|
"MessageMarkAsFinished": "Markera som avslutad",
|
||||||
"MessageMarkAsNotFinished": "Markera som ej avslutad",
|
"MessageMarkAsNotFinished": "Markera som ej avslutad",
|
||||||
"MessageMatchBooksDescription": "kommer att försöka matcha böcker i biblioteket med en bok från<br/>den valda källan och fylla i uppgifter som saknas och omslag.<br/>Inga befintliga uppgifter kommer att ersättas.",
|
"MessageMatchBooksDescription": "kommer att försöka matcha böcker i biblioteket med en bok från den valda källan och fylla i uppgifter som saknas och omslag. Inga befintliga uppgifter kommer att ersättas.",
|
||||||
"MessageNoAudioTracks": "Inga ljudspår har hittats",
|
"MessageNoAudioTracks": "Inga ljudspår har hittats",
|
||||||
"MessageNoAuthors": "Inga författare",
|
"MessageNoAuthors": "Inga författare",
|
||||||
"MessageNoBackups": "Inga säkerhetskopior",
|
"MessageNoBackups": "Inga säkerhetskopior",
|
||||||
|
|
@ -797,12 +805,15 @@
|
||||||
"MessageTaskEmbeddingMetadataDescription": "Infogar metadata i ljudboken \"{0}\"",
|
"MessageTaskEmbeddingMetadataDescription": "Infogar metadata i ljudboken \"{0}\"",
|
||||||
"MessageTaskEncodingM4bDescription": "Omkodning av ljudbok \"{0}\" till en M4B-fil",
|
"MessageTaskEncodingM4bDescription": "Omkodning av ljudbok \"{0}\" till en M4B-fil",
|
||||||
"MessageTaskFailed": "Misslyckades",
|
"MessageTaskFailed": "Misslyckades",
|
||||||
|
"MessageTaskFailedToBackupAudioFile": "Misslyckades med att göra backup på ljudfil \"{0}\"",
|
||||||
"MessageTaskFailedToCreateCacheDirectory": "Misslyckades med att skapa bibliotek för cachen",
|
"MessageTaskFailedToCreateCacheDirectory": "Misslyckades med att skapa bibliotek för cachen",
|
||||||
"MessageTaskFailedToEmbedMetadataInFile": "Misslyckades med att infoga metadata i \"{0}\"",
|
"MessageTaskFailedToEmbedMetadataInFile": "Misslyckades med att infoga metadata i \"{0}\"",
|
||||||
"MessageTaskFailedToMergeAudioFiles": "Misslyckades med att sammanfoga ljudfilerna",
|
"MessageTaskFailedToMergeAudioFiles": "Misslyckades med att sammanfoga ljudfilerna",
|
||||||
"MessageTaskFailedToMoveM4bFile": "Misslyckades med att flytta M4B-filen",
|
"MessageTaskFailedToMoveM4bFile": "Misslyckades med att flytta M4B-filen",
|
||||||
"MessageTaskFailedToWriteMetadataFile": "Misslyckades med att skapa filen med metadata",
|
"MessageTaskFailedToWriteMetadataFile": "Misslyckades med att skapa filen med metadata",
|
||||||
"MessageTaskMatchingBooksInLibrary": "Matchar böcker i biblioteket \"{0}\"",
|
"MessageTaskMatchingBooksInLibrary": "Matchar böcker i biblioteket \"{0}\"",
|
||||||
|
"MessageTaskOpmlImportFeedPodcastDescription": "Skapar podcast \"{0}\"",
|
||||||
|
"MessageTaskOpmlImportFeedPodcastFailed": "Misslyckades med att skapa podcast",
|
||||||
"MessageTaskOpmlImportFinished": "Adderade {0} podcasts",
|
"MessageTaskOpmlImportFinished": "Adderade {0} podcasts",
|
||||||
"MessageTaskOpmlParseFailed": "Misslyckades att tolka OPML-filen",
|
"MessageTaskOpmlParseFailed": "Misslyckades att tolka OPML-filen",
|
||||||
"MessageTaskOpmlParseFastFail": "Felaktig OPML-fil. Ingen <opml> tag eller <outline> tag finns i filen",
|
"MessageTaskOpmlParseFastFail": "Felaktig OPML-fil. Ingen <opml> tag eller <outline> tag finns i filen",
|
||||||
|
|
@ -814,7 +825,7 @@
|
||||||
"MessageTaskScanningLibrary": "Biblioteket \"{0}\" har skannats",
|
"MessageTaskScanningLibrary": "Biblioteket \"{0}\" har skannats",
|
||||||
"MessageThinking": "Tänker...",
|
"MessageThinking": "Tänker...",
|
||||||
"MessageUploaderItemFailed": "Misslyckades med att ladda upp",
|
"MessageUploaderItemFailed": "Misslyckades med att ladda upp",
|
||||||
"MessageUploaderItemSuccess": "Uppladdning lyckades!",
|
"MessageUploaderItemSuccess": "har blivit uppladdad!",
|
||||||
"MessageUploading": "Laddar upp...",
|
"MessageUploading": "Laddar upp...",
|
||||||
"MessageValidCronExpression": "Giltigt cron-uttryck",
|
"MessageValidCronExpression": "Giltigt cron-uttryck",
|
||||||
"MessageWatcherIsDisabledGlobally": "Watcher är inaktiverad centralt under rubriken 'Inställningar'",
|
"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.",
|
"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.",
|
"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.",
|
"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",
|
"PlaceholderNewCollection": "Nytt namn på samlingen",
|
||||||
"PlaceholderNewFolderPath": "Nytt sökväg till mappen",
|
"PlaceholderNewFolderPath": "Nytt sökväg till mappen",
|
||||||
"PlaceholderNewPlaylist": "Nytt namn på spellistan",
|
"PlaceholderNewPlaylist": "Nytt namn på spellistan",
|
||||||
|
|
@ -888,9 +902,12 @@
|
||||||
"ToastDateTimeInvalidOrIncomplete": "Datum och klockslag är felaktigt eller ej komplett",
|
"ToastDateTimeInvalidOrIncomplete": "Datum och klockslag är felaktigt eller ej komplett",
|
||||||
"ToastDeleteFileFailed": "Misslyckades att radera filen",
|
"ToastDeleteFileFailed": "Misslyckades att radera filen",
|
||||||
"ToastDeleteFileSuccess": "Filen har raderats",
|
"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",
|
"ToastDeviceTestEmailFailed": "Misslyckades med att skicka ett testmail",
|
||||||
"ToastDeviceTestEmailSuccess": "Ett testmail har skickats",
|
"ToastDeviceTestEmailSuccess": "Ett testmail har skickats",
|
||||||
"ToastEmailSettingsUpdateSuccess": "Inställningarna av e-post har uppdaterats",
|
"ToastEmailSettingsUpdateSuccess": "Inställningarna av e-post har uppdaterats",
|
||||||
|
"ToastEncodeCancelFailed": "Misslyckades med att avbryta omkodningen",
|
||||||
"ToastEncodeCancelSucces": "Omkodningen avbruten",
|
"ToastEncodeCancelSucces": "Omkodningen avbruten",
|
||||||
"ToastEpisodeDownloadQueueClearFailed": "Misslyckades med att tömma kön",
|
"ToastEpisodeDownloadQueueClearFailed": "Misslyckades med att tömma kön",
|
||||||
"ToastEpisodeDownloadQueueClearSuccess": "Kö för nedladdning av avsnitt har tömts",
|
"ToastEpisodeDownloadQueueClearSuccess": "Kö för nedladdning av avsnitt har tömts",
|
||||||
|
|
@ -931,6 +948,7 @@
|
||||||
"ToastNewUserTagError": "Minst en tagg måste läggas till",
|
"ToastNewUserTagError": "Minst en tagg måste läggas till",
|
||||||
"ToastNewUserUsernameError": "Ange ett användarnamn",
|
"ToastNewUserUsernameError": "Ange ett användarnamn",
|
||||||
"ToastNoNewEpisodesFound": "Inga nya avsnitt kunde hittas",
|
"ToastNoNewEpisodesFound": "Inga nya avsnitt kunde hittas",
|
||||||
|
"ToastNoRSSFeed": "Denna podcast har ingen RSS-flöde",
|
||||||
"ToastNoUpdatesNecessary": "Inga uppdateringar var nödvändiga",
|
"ToastNoUpdatesNecessary": "Inga uppdateringar var nödvändiga",
|
||||||
"ToastNotificationCreateFailed": "Misslyckades med att skapa meddelandet",
|
"ToastNotificationCreateFailed": "Misslyckades med att skapa meddelandet",
|
||||||
"ToastNotificationDeleteFailed": "Misslyckades med att radera meddelandet",
|
"ToastNotificationDeleteFailed": "Misslyckades med att radera meddelandet",
|
||||||
|
|
@ -942,6 +960,7 @@
|
||||||
"ToastPodcastCreateFailed": "Misslyckades med att skapa podcasten",
|
"ToastPodcastCreateFailed": "Misslyckades med att skapa podcasten",
|
||||||
"ToastPodcastCreateSuccess": "Podcasten skapades framgångsrikt",
|
"ToastPodcastCreateSuccess": "Podcasten skapades framgångsrikt",
|
||||||
"ToastPodcastNoEpisodesInFeed": "Inga avsnitt finns i RSS-flödet",
|
"ToastPodcastNoEpisodesInFeed": "Inga avsnitt finns i RSS-flödet",
|
||||||
|
"ToastPodcastNoRssFeed": "Denna podcast har ingen RSS-flöde",
|
||||||
"ToastProviderCreatedFailed": "Misslyckades med att addera en källa",
|
"ToastProviderCreatedFailed": "Misslyckades med att addera en källa",
|
||||||
"ToastProviderCreatedSuccess": "En ny källa har adderats",
|
"ToastProviderCreatedSuccess": "En ny källa har adderats",
|
||||||
"ToastProviderNameAndUrlRequired": "Ett namn och en URL-adress krävs",
|
"ToastProviderNameAndUrlRequired": "Ett namn och en URL-adress krävs",
|
||||||
|
|
|
||||||
|
|
@ -1 +1,209 @@
|
||||||
{}
|
{
|
||||||
|
"ButtonAdd": "Ekle",
|
||||||
|
"ButtonAddChapters": "Bölüm Ekle",
|
||||||
|
"ButtonAddDevice": "Cihaz Ekle",
|
||||||
|
"ButtonAddLibrary": "Kütüphane Ekle",
|
||||||
|
"ButtonAddPodcasts": "Podcast Ekle",
|
||||||
|
"ButtonAddUser": "Kullanıcı Ekle",
|
||||||
|
"ButtonAddYourFirstLibrary": "İlk kütüphaneni ekle",
|
||||||
|
"ButtonApply": "Uygula",
|
||||||
|
"ButtonApplyChapters": "Bölümleri Uygula",
|
||||||
|
"ButtonAuthors": "Yazarlar",
|
||||||
|
"ButtonBack": "Geri",
|
||||||
|
"ButtonBatchEditPopulateFromExisting": "Mevcut olandan çoğalt",
|
||||||
|
"ButtonBatchEditPopulateMapDetails": "Harita detaylarını çoğalt",
|
||||||
|
"ButtonBrowseForFolder": "Klasör için göz at",
|
||||||
|
"ButtonCancel": "İptal",
|
||||||
|
"ButtonCancelEncode": "Kodlamayı Durdur",
|
||||||
|
"ButtonChangeRootPassword": "Root Şifresini Değiştir",
|
||||||
|
"ButtonCheckAndDownloadNewEpisodes": "Yeni Bölümleri Kontrol Et & İndir",
|
||||||
|
"ButtonChooseAFolder": "Klasör seç",
|
||||||
|
"ButtonChooseFiles": "Dosya seç",
|
||||||
|
"ButtonClearFilter": "Filtreyi Temizle",
|
||||||
|
"ButtonCloseFeed": "Akışı Kapat",
|
||||||
|
"ButtonCloseSession": "Acık Oturumu Kapat",
|
||||||
|
"ButtonCollections": "Koleksiyonlar",
|
||||||
|
"ButtonConfigureScanner": "Tarayıcı Ayarları",
|
||||||
|
"ButtonCreate": "Oluştur",
|
||||||
|
"ButtonCreateBackup": "Yedek Oluştur",
|
||||||
|
"ButtonDelete": "Sil",
|
||||||
|
"ButtonDownloadQueue": "Sıra",
|
||||||
|
"ButtonEdit": "Düzenle",
|
||||||
|
"ButtonEditChapters": "Bölümleri Düzenle",
|
||||||
|
"ButtonEditPodcast": "Podcast Düzenle",
|
||||||
|
"ButtonEnable": "Etkinleştir",
|
||||||
|
"ButtonFireAndFail": "Gönder ve hata al",
|
||||||
|
"ButtonFireOnTest": "onTest Gönder",
|
||||||
|
"ButtonForceReScan": "Zorla Yeniden Tara",
|
||||||
|
"ButtonFullPath": "Tam Dosya Yolu",
|
||||||
|
"ButtonHide": "Gizle",
|
||||||
|
"ButtonHome": "Ana sayfa",
|
||||||
|
"ButtonIssues": "Sorunlar",
|
||||||
|
"ButtonJumpBackward": "Geri Sar",
|
||||||
|
"ButtonJumpForward": "İleri Sar",
|
||||||
|
"ButtonLatest": "En yeni",
|
||||||
|
"ButtonLibrary": "Kütüphane",
|
||||||
|
"ButtonLogout": "Çıkış Yap",
|
||||||
|
"ButtonLookup": "Sorgula",
|
||||||
|
"ButtonManageTracks": "Parçaları Yönet",
|
||||||
|
"ButtonMapChapterTitles": "Bölüm Başlıklarını Haritalandır",
|
||||||
|
"ButtonNevermind": "Vazgeç",
|
||||||
|
"ButtonNext": "Sonraki",
|
||||||
|
"ButtonNextChapter": "Sonraki Bölüm",
|
||||||
|
"ButtonNextItemInQueue": "Sıradaki Sonraki Öğe",
|
||||||
|
"ButtonOk": "Tamam",
|
||||||
|
"ButtonOpenFeed": "Akışı Aç",
|
||||||
|
"ButtonOpenManager": "Yöneticiyi Aç",
|
||||||
|
"ButtonPause": "Durdur",
|
||||||
|
"ButtonPlay": "Oynat",
|
||||||
|
"ButtonPlayAll": "Hepsini Oynat",
|
||||||
|
"ButtonPlaying": "Oynatılıyor",
|
||||||
|
"ButtonPlaylists": "Oynatma listeleri",
|
||||||
|
"ButtonPrevious": "Önceki",
|
||||||
|
"ButtonPreviousChapter": "Önceki Bölüm",
|
||||||
|
"ButtonProbeAudioFile": "Ses Dosyasını Yokla",
|
||||||
|
"ButtonPurgeAllCache": "Bütün Önbelleği Temizle",
|
||||||
|
"ButtonPurgeItemsCache": "Öğenin Önbelleğini Temizle",
|
||||||
|
"ButtonQueueAddItem": "Sıraya ekle",
|
||||||
|
"ButtonQueueRemoveItem": "Sıradan çıkar",
|
||||||
|
"ButtonReScan": "Yeniden Tara",
|
||||||
|
"ButtonRead": "Oku",
|
||||||
|
"ButtonReadLess": "Daha az göster",
|
||||||
|
"ButtonReadMore": "Daha fazla göster",
|
||||||
|
"ButtonRefresh": "Yenile",
|
||||||
|
"ButtonRemove": "Kaldır",
|
||||||
|
"ButtonRemoveAll": "Hepsini Sil",
|
||||||
|
"ButtonRemoveAllLibraryItems": "Bütün Kütüphane Öğelerini Sil",
|
||||||
|
"ButtonSave": "Kaydet",
|
||||||
|
"ButtonSearch": "Ara",
|
||||||
|
"ButtonSeries": "Dizi",
|
||||||
|
"ButtonSubmit": "Gönder",
|
||||||
|
"ButtonYes": "Evet",
|
||||||
|
"HeaderAccount": "Hesap",
|
||||||
|
"HeaderAdvanced": "Gelişmiş",
|
||||||
|
"HeaderAudioTracks": "Ses Kanalları",
|
||||||
|
"HeaderChapters": "Bölümler",
|
||||||
|
"HeaderCollection": "Koleksiyon",
|
||||||
|
"HeaderCollectionItems": "Koleksiyon Öğeleri",
|
||||||
|
"HeaderDetails": "Detaylar",
|
||||||
|
"HeaderEbookFiles": "Ebook Dosyaları",
|
||||||
|
"HeaderEpisodes": "Bölümler",
|
||||||
|
"HeaderEreaderSettings": "Ereader Ayarları",
|
||||||
|
"HeaderLatestEpisodes": "En son bölümler",
|
||||||
|
"HeaderLibraries": "Kütüphaneler",
|
||||||
|
"HeaderOpenRSSFeed": "RSS Akışını Aç",
|
||||||
|
"HeaderPlaylist": "Oynatma listesi",
|
||||||
|
"HeaderPlaylistItems": "Oynatma Listesi Öğeleri",
|
||||||
|
"HeaderRSSFeedGeneral": "RSS Detayları",
|
||||||
|
"HeaderRSSFeedIsOpen": "RSS Akışı Açık",
|
||||||
|
"HeaderSettings": "Ayarlar",
|
||||||
|
"HeaderSleepTimer": "Uyku Zamanlayıcısı",
|
||||||
|
"HeaderStatsMinutesListeningChart": "Dinlenilen Dakika (son 7 gün)",
|
||||||
|
"HeaderStatsRecentSessions": "Geçmiş Oturumlar",
|
||||||
|
"HeaderTableOfContents": "İçindekiler",
|
||||||
|
"HeaderYourStats": "İstatistiklerin",
|
||||||
|
"LabelAddToPlaylist": "Oynatma Listesine Ekle",
|
||||||
|
"LabelAddedAt": "Eklenme Zamanı",
|
||||||
|
"LabelAddedDate": "Eklendi {0}",
|
||||||
|
"LabelAll": "Hepsi",
|
||||||
|
"LabelAuthor": "Yazar",
|
||||||
|
"LabelAuthorFirstLast": "Yazar (İlk Son)",
|
||||||
|
"LabelAuthorLastFirst": "Yazar (Son, İlk)",
|
||||||
|
"LabelAuthors": "Yazarlar",
|
||||||
|
"LabelAutoDownloadEpisodes": "Bölümleri Otomatik Olarak İndir",
|
||||||
|
"LabelBooks": "Kitaplar",
|
||||||
|
"LabelChapters": "Bölümler",
|
||||||
|
"LabelClosePlayer": "Oynatıcıyı kapat",
|
||||||
|
"LabelCollapseSeries": "Seriyi Daralt",
|
||||||
|
"LabelComplete": "Tamamlandı",
|
||||||
|
"LabelContinueListening": "Dinlemeye Devam Et",
|
||||||
|
"LabelContinueReading": "Okumaya Devam Et",
|
||||||
|
"LabelContinueSeries": "Seriye Devam Et",
|
||||||
|
"LabelDescription": "Açıklama",
|
||||||
|
"LabelDiscover": "Keşfet",
|
||||||
|
"LabelDownload": "İndir",
|
||||||
|
"LabelDuration": "Süre",
|
||||||
|
"LabelEbook": "Ekitap",
|
||||||
|
"LabelEbooks": "Ekitaplar",
|
||||||
|
"LabelEnable": "Etkinleştir",
|
||||||
|
"LabelEnd": "Son",
|
||||||
|
"LabelEndOfChapter": "Bölüm Sonu",
|
||||||
|
"LabelEpisode": "Bölüm",
|
||||||
|
"LabelFeedURL": "Akış URLsi",
|
||||||
|
"LabelFile": "Dosya",
|
||||||
|
"LabelFileBirthtime": "Dosya Oluşum Zamanı",
|
||||||
|
"LabelFileModified": "Dosya Düzenlendi",
|
||||||
|
"LabelFilename": "Dosya İsmi",
|
||||||
|
"LabelFinished": "Tamamlandı",
|
||||||
|
"LabelFolder": "Klasör",
|
||||||
|
"LabelFontBoldness": "Font Kalınlığı",
|
||||||
|
"LabelFontScale": "Font büyüklüğü",
|
||||||
|
"LabelGenre": "Tür",
|
||||||
|
"LabelGenres": "Türler",
|
||||||
|
"LabelHasEbook": "Ekitabı var",
|
||||||
|
"LabelHasSupplementaryEbook": "İlave ekitabı var",
|
||||||
|
"LabelHost": "Sunucu",
|
||||||
|
"LabelInProgress": "İlerleme Halinde",
|
||||||
|
"LabelIncomplete": "Tamamlanmamış",
|
||||||
|
"LabelLanguage": "Dil",
|
||||||
|
"LabelLayout": "Düzen",
|
||||||
|
"LabelLayoutSinglePage": "Tek sayfa",
|
||||||
|
"LabelLineSpacing": "Satır aralığı",
|
||||||
|
"LabelListenAgain": "Tekrar Dinle",
|
||||||
|
"LabelMediaType": "Medya Türü",
|
||||||
|
"LabelMissing": "Kayıp",
|
||||||
|
"LabelMore": "Daha fazla",
|
||||||
|
"LabelMoreInfo": "Daha fazla bilgi",
|
||||||
|
"LabelName": "İsim",
|
||||||
|
"LabelNarrator": "Anlatıcı",
|
||||||
|
"LabelNarrators": "Anlatıcılar",
|
||||||
|
"LabelNewestAuthors": "En Yeni Yazarlar",
|
||||||
|
"LabelNewestEpisodes": "En Yeni Bölümler",
|
||||||
|
"LabelNotFinished": "Tamamlanmadı",
|
||||||
|
"LabelNotStarted": "Başlanmadı",
|
||||||
|
"LabelNumberOfEpisodes": "Bölüm Sayısı",
|
||||||
|
"LabelPassword": "Şifre",
|
||||||
|
"LabelPath": "Yol",
|
||||||
|
"LabelPodcast": "Podcast",
|
||||||
|
"LabelPodcasts": "Podcastler",
|
||||||
|
"LabelPreventIndexing": "Akışınızın iTunes ve Google podcast dizinleri tarafından dizinlenmesini önleyin",
|
||||||
|
"LabelProgress": "İlerleme",
|
||||||
|
"LabelPubDate": "Yay. Tarihi",
|
||||||
|
"LabelPublishYear": "Yayım Yılı",
|
||||||
|
"LabelPublishedDate": "Yayımlandı {0}",
|
||||||
|
"LabelRSSFeedCustomOwnerEmail": "Özelleştirilmiş sahip Emaili",
|
||||||
|
"LabelRSSFeedCustomOwnerName": "Özelleştirilmis sahip İsmi",
|
||||||
|
"LabelRSSFeedPreventIndexing": "Dizinlemeyi Önle",
|
||||||
|
"LabelRandomly": "Rastgele",
|
||||||
|
"LabelRead": "Oku",
|
||||||
|
"LabelReadAgain": "Tekrar Oku",
|
||||||
|
"LabelRecentlyAdded": "Yakınlarda Eklenmiş",
|
||||||
|
"LabelSeason": "Sezon",
|
||||||
|
"LabelSetEbookAsPrimary": "Birincil olarak ayarla",
|
||||||
|
"LabelSetEbookAsSupplementary": "Yedek olarak ayarla",
|
||||||
|
"LabelShowAll": "Hepsini Göster",
|
||||||
|
"LabelSize": "Boyut",
|
||||||
|
"LabelSleepTimer": "Uyku Zamanlayıcısı",
|
||||||
|
"LabelStart": "Başla",
|
||||||
|
"LabelStatsBestDay": "En İyi Gün",
|
||||||
|
"LabelStatsDailyAverage": "Günlük Ortalama",
|
||||||
|
"LabelStatsDays": "Günler",
|
||||||
|
"LabelStatsDaysListened": "Dinlenen Günler",
|
||||||
|
"LabelStatsInARow": "art arda",
|
||||||
|
"LabelStatsItemsFinished": "Bitirilen Öğeler",
|
||||||
|
"LabelStatsMinutes": "dakika",
|
||||||
|
"LabelStatsMinutesListening": "Dinlenen Dakika",
|
||||||
|
"LabelTag": "Etiket",
|
||||||
|
"LabelTags": "Etiketler",
|
||||||
|
"LabelTheme": "Tema",
|
||||||
|
"LabelThemeDark": "Koyu",
|
||||||
|
"LabelThemeLight": "Açık",
|
||||||
|
"LabelTimeRemaining": "{0} kalan",
|
||||||
|
"LabelTitle": "Başlık",
|
||||||
|
"LabelTracks": "Parçalar",
|
||||||
|
"LabelType": "Tür",
|
||||||
|
"LabelUnknown": "Bilinmeyen",
|
||||||
|
"LabelUser": "Kullanıcı",
|
||||||
|
"LabelUsername": "Kullanıcı Adı",
|
||||||
|
"LabelYourBookmarks": "Yer İşaretleriniz"
|
||||||
|
}
|
||||||
|
|
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.19.3",
|
"version": "2.19.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.19.3",
|
"version": "2.19.4",
|
||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^0.27.2",
|
"axios": "^0.27.2",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.19.3",
|
"version": "2.19.4",
|
||||||
"buildNumber": 1,
|
"buildNumber": 1,
|
||||||
"description": "Self-hosted audiobook and podcast server",
|
"description": "Self-hosted audiobook and podcast server",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
|
|
|
||||||
|
|
@ -190,7 +190,13 @@ class Database {
|
||||||
await this.buildModels(force)
|
await this.buildModels(force)
|
||||||
Logger.info(`[Database] Db initialized with models:`, Object.keys(this.sequelize.models).join(', '))
|
Logger.info(`[Database] Db initialized with models:`, Object.keys(this.sequelize.models).join(', '))
|
||||||
|
|
||||||
|
await this.addTriggers()
|
||||||
|
|
||||||
await this.loadData()
|
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
|
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 {
|
TextSearchQuery = class {
|
||||||
constructor(sequelize, supportsUnaccent, query) {
|
constructor(sequelize, supportsUnaccent, query) {
|
||||||
this.sequelize = sequelize
|
this.sequelize = sequelize
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,9 @@ class PodcastController {
|
||||||
libraryFiles: [],
|
libraryFiles: [],
|
||||||
extraData: {},
|
extraData: {},
|
||||||
libraryId: library.id,
|
libraryId: library.id,
|
||||||
libraryFolderId: folder.id
|
libraryFolderId: folder.id,
|
||||||
|
title: podcast.title,
|
||||||
|
titleIgnorePrefix: podcast.titleIgnorePrefix
|
||||||
},
|
},
|
||||||
{ transaction }
|
{ transaction }
|
||||||
)
|
)
|
||||||
|
|
@ -498,6 +500,10 @@ class PodcastController {
|
||||||
req.libraryItem.changed('libraryFiles', true)
|
req.libraryItem.changed('libraryFiles', true)
|
||||||
await req.libraryItem.save()
|
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())
|
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
|
||||||
res.json(req.libraryItem.toOldJSON())
|
res.json(req.libraryItem.toOldJSON())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -232,6 +232,11 @@ class PodcastManager {
|
||||||
|
|
||||||
await libraryItem.save()
|
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())
|
SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
|
||||||
const podcastEpisodeExpanded = podcastEpisode.toOldJSONExpanded(libraryItem.id)
|
const podcastEpisodeExpanded = podcastEpisode.toOldJSONExpanded(libraryItem.id)
|
||||||
podcastEpisodeExpanded.libraryItem = libraryItem.toOldJSONExpanded()
|
podcastEpisodeExpanded.libraryItem = libraryItem.toOldJSONExpanded()
|
||||||
|
|
@ -622,7 +627,9 @@ class PodcastManager {
|
||||||
libraryFiles: [],
|
libraryFiles: [],
|
||||||
extraData: {},
|
extraData: {},
|
||||||
libraryId: folder.libraryId,
|
libraryId: folder.libraryId,
|
||||||
libraryFolderId: folder.id
|
libraryFolderId: folder.id,
|
||||||
|
title: podcast.title,
|
||||||
|
titleIgnorePrefix: podcast.titleIgnorePrefix
|
||||||
},
|
},
|
||||||
{ transaction }
|
{ transaction }
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -14,3 +14,4 @@ Please add a record of every database migration that you create to this file. Th
|
||||||
| v2.17.6 | v2.17.6-share-add-isdownloadable | Adds the isDownloadable column to the mediaItemShares table |
|
| v2.17.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.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.1 | v2.19.1-copy-title-to-library-items | Copies title and titleIgnorePrefix to the libraryItems table, creates update triggers and indices |
|
||||||
|
| v2.19.4 | v2.19.4-improve-podcast-queries | Adds numEpisodes to podcasts, adds podcastId to mediaProgresses, copies podcast title to libraryItems |
|
||||||
|
|
|
||||||
219
server/migrations/v2.19.4-improve-podcast-queries.js
Normal file
219
server/migrations/v2.19.4-improve-podcast-queries.js
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
const util = require('util')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef MigrationContext
|
||||||
|
* @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.
|
||||||
|
* @property {import('../Logger')} logger - a Logger object.
|
||||||
|
*
|
||||||
|
* @typedef MigrationOptions
|
||||||
|
* @property {MigrationContext} context - an object containing the migration context.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const migrationVersion = '2.19.4'
|
||||||
|
const migrationName = `${migrationVersion}-improve-podcast-queries`
|
||||||
|
const loggerPrefix = `[${migrationVersion} migration]`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This upward migration adds a numEpisodes column to the podcasts table and populates it.
|
||||||
|
* It also adds a podcastId column to the mediaProgresses table and populates it.
|
||||||
|
* It also copies the title and titleIgnorePrefix columns from the podcasts table to the libraryItems table,
|
||||||
|
* and adds triggers to update them when the corresponding columns in the podcasts table are updated.
|
||||||
|
*
|
||||||
|
* @param {MigrationOptions} options - an object containing the migration context.
|
||||||
|
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||||
|
*/
|
||||||
|
async function up({ context: { queryInterface, logger } }) {
|
||||||
|
// Upwards migration script
|
||||||
|
logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)
|
||||||
|
|
||||||
|
// Add numEpisodes column to podcasts table
|
||||||
|
await addColumn(queryInterface, logger, 'podcasts', 'numEpisodes', { type: queryInterface.sequelize.Sequelize.INTEGER, allowNull: false, defaultValue: 0 })
|
||||||
|
|
||||||
|
// Populate numEpisodes column with the number of episodes for each podcast
|
||||||
|
await populateNumEpisodes(queryInterface, logger)
|
||||||
|
|
||||||
|
// Add podcastId column to mediaProgresses table
|
||||||
|
await addColumn(queryInterface, logger, 'mediaProgresses', 'podcastId', { type: queryInterface.sequelize.Sequelize.UUID, allowNull: true })
|
||||||
|
|
||||||
|
// Populate podcastId column with the podcastId for each mediaProgress
|
||||||
|
await populatePodcastId(queryInterface, logger)
|
||||||
|
|
||||||
|
// Copy title and titleIgnorePrefix columns from podcasts to libraryItems
|
||||||
|
await copyColumn(queryInterface, logger, 'podcasts', 'title', 'id', 'libraryItems', 'title', 'mediaId')
|
||||||
|
await copyColumn(queryInterface, logger, 'podcasts', 'titleIgnorePrefix', 'id', 'libraryItems', 'titleIgnorePrefix', 'mediaId')
|
||||||
|
|
||||||
|
// Add triggers to update title and titleIgnorePrefix in libraryItems
|
||||||
|
await addTrigger(queryInterface, logger, 'podcasts', 'title', 'id', 'libraryItems', 'title', 'mediaId')
|
||||||
|
await addTrigger(queryInterface, logger, 'podcasts', 'titleIgnorePrefix', 'id', 'libraryItems', 'titleIgnorePrefix', 'mediaId')
|
||||||
|
|
||||||
|
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This downward migration removes the triggers on the podcasts table,
|
||||||
|
* the numEpisodes column from the podcasts table, and the podcastId column from the mediaProgresses table.
|
||||||
|
*
|
||||||
|
* @param {MigrationOptions} options - an object containing the migration context.
|
||||||
|
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||||
|
*/
|
||||||
|
async function down({ context: { queryInterface, logger } }) {
|
||||||
|
// Downward migration script
|
||||||
|
logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)
|
||||||
|
|
||||||
|
// Remove triggers from libraryItems
|
||||||
|
await removeTrigger(queryInterface, logger, 'podcasts', 'title', 'libraryItems', 'title')
|
||||||
|
await removeTrigger(queryInterface, logger, 'podcasts', 'titleIgnorePrefix', 'libraryItems', 'titleIgnorePrefix')
|
||||||
|
|
||||||
|
// Remove numEpisodes column from podcasts table
|
||||||
|
await removeColumn(queryInterface, logger, 'podcasts', 'numEpisodes')
|
||||||
|
|
||||||
|
// Remove podcastId column from mediaProgresses table
|
||||||
|
await removeColumn(queryInterface, logger, 'mediaProgresses', 'podcastId')
|
||||||
|
|
||||||
|
logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function populateNumEpisodes(queryInterface, logger) {
|
||||||
|
logger.info(`${loggerPrefix} populating numEpisodes column in podcasts table`)
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
UPDATE podcasts
|
||||||
|
SET numEpisodes = (SELECT COUNT(*) FROM podcastEpisodes WHERE podcastEpisodes.podcastId = podcasts.id)
|
||||||
|
`)
|
||||||
|
logger.info(`${loggerPrefix} populated numEpisodes column in podcasts table`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function populatePodcastId(queryInterface, logger) {
|
||||||
|
logger.info(`${loggerPrefix} populating podcastId column in mediaProgresses table`)
|
||||||
|
// bulk update podcastId to the podcastId of the podcastEpisode if the mediaItemType is podcastEpisode
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
UPDATE mediaProgresses
|
||||||
|
SET podcastId = (SELECT podcastId FROM podcastEpisodes WHERE podcastEpisodes.id = mediaProgresses.mediaItemId)
|
||||||
|
WHERE mediaItemType = 'podcastEpisode'
|
||||||
|
`)
|
||||||
|
logger.info(`${loggerPrefix} populated podcastId column in mediaProgresses table`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function to add a column to a table. If the column already exists, it logs a message and continues.
|
||||||
|
*
|
||||||
|
* @param {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.
|
||||||
|
* @param {import('../Logger')} logger - a Logger object.
|
||||||
|
* @param {string} table - the name of the table to add the column to.
|
||||||
|
* @param {string} column - the name of the column to add.
|
||||||
|
* @param {Object} options - the options for the column.
|
||||||
|
*/
|
||||||
|
async function addColumn(queryInterface, logger, table, column, options) {
|
||||||
|
logger.info(`${loggerPrefix} adding column "${column}" to table "${table}"`)
|
||||||
|
const tableDescription = await queryInterface.describeTable(table)
|
||||||
|
if (!tableDescription[column]) {
|
||||||
|
await queryInterface.addColumn(table, column, options)
|
||||||
|
logger.info(`${loggerPrefix} added column "${column}" to table "${table}"`)
|
||||||
|
} else {
|
||||||
|
logger.info(`${loggerPrefix} column "${column}" already exists in table "${table}"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function to remove a column from a table. If the column does not exist, it logs a message and continues.
|
||||||
|
*
|
||||||
|
* @param {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.
|
||||||
|
* @param {import('../Logger')} logger - a Logger object.
|
||||||
|
* @param {string} table - the name of the table to remove the column from.
|
||||||
|
* @param {string} column - the name of the column to remove.
|
||||||
|
*/
|
||||||
|
async function removeColumn(queryInterface, logger, table, column) {
|
||||||
|
logger.info(`${loggerPrefix} removing column "${column}" from table "${table}"`)
|
||||||
|
const tableDescription = await queryInterface.describeTable(table)
|
||||||
|
if (tableDescription[column]) {
|
||||||
|
await queryInterface.sequelize.query(`ALTER TABLE ${table} DROP COLUMN ${column}`)
|
||||||
|
logger.info(`${loggerPrefix} removed column "${column}" from table "${table}"`)
|
||||||
|
} else {
|
||||||
|
logger.info(`${loggerPrefix} column "${column}" does not exist in table "${table}"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function to add a trigger to update a column in a target table when a column in a source table is updated.
|
||||||
|
* If the trigger already exists, it drops it and creates a new one.
|
||||||
|
* sourceIdColumn and targetIdColumn are used to match the source and target rows.
|
||||||
|
*
|
||||||
|
* @param {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.
|
||||||
|
* @param {import('../Logger')} logger - a Logger object.
|
||||||
|
* @param {string} sourceTable - the name of the source table.
|
||||||
|
* @param {string} sourceColumn - the name of the column to update.
|
||||||
|
* @param {string} sourceIdColumn - the name of the id column of the source table.
|
||||||
|
* @param {string} targetTable - the name of the target table.
|
||||||
|
* @param {string} targetColumn - the name of the column to update.
|
||||||
|
* @param {string} targetIdColumn - the name of the id column of the target table.
|
||||||
|
*/
|
||||||
|
async function addTrigger(queryInterface, logger, sourceTable, sourceColumn, sourceIdColumn, targetTable, targetColumn, targetIdColumn) {
|
||||||
|
logger.info(`${loggerPrefix} adding trigger to update ${targetTable}.${targetColumn} when ${sourceTable}.${sourceColumn} is updated`)
|
||||||
|
const triggerName = convertToSnakeCase(`update_${targetTable}_${targetColumn}_from_${sourceTable}_${sourceColumn}`)
|
||||||
|
|
||||||
|
await queryInterface.sequelize.query(`DROP TRIGGER IF EXISTS ${triggerName}`)
|
||||||
|
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
CREATE TRIGGER ${triggerName}
|
||||||
|
AFTER UPDATE OF ${sourceColumn} ON ${sourceTable}
|
||||||
|
FOR EACH ROW
|
||||||
|
BEGIN
|
||||||
|
UPDATE ${targetTable}
|
||||||
|
SET ${targetColumn} = NEW.${sourceColumn}
|
||||||
|
WHERE ${targetTable}.${targetIdColumn} = NEW.${sourceIdColumn};
|
||||||
|
END;
|
||||||
|
`)
|
||||||
|
logger.info(`${loggerPrefix} added trigger to update ${targetTable}.${targetColumn} when ${sourceTable}.${sourceColumn} is updated`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function to remove an update trigger from a table.
|
||||||
|
*
|
||||||
|
* @param {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.
|
||||||
|
* @param {import('../Logger')} logger - a Logger object.
|
||||||
|
* @param {string} sourceTable - the name of the source table.
|
||||||
|
* @param {string} sourceColumn - the name of the column to update.
|
||||||
|
* @param {string} targetTable - the name of the target table.
|
||||||
|
* @param {string} targetColumn - the name of the column to update.
|
||||||
|
*/
|
||||||
|
async function removeTrigger(queryInterface, logger, sourceTable, sourceColumn, targetTable, targetColumn) {
|
||||||
|
logger.info(`${loggerPrefix} removing trigger to update ${targetTable}.${targetColumn}`)
|
||||||
|
const triggerName = convertToSnakeCase(`update_${targetTable}_${targetColumn}_from_${sourceTable}_${sourceColumn}`)
|
||||||
|
await queryInterface.sequelize.query(`DROP TRIGGER IF EXISTS ${triggerName}`)
|
||||||
|
logger.info(`${loggerPrefix} removed trigger to update ${targetTable}.${targetColumn}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function to copy a column from a source table to a target table.
|
||||||
|
* sourceIdColumn and targetIdColumn are used to match the source and target rows.
|
||||||
|
*
|
||||||
|
* @param {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.
|
||||||
|
* @param {import('../Logger')} logger - a Logger object.
|
||||||
|
* @param {string} sourceTable - the name of the source table.
|
||||||
|
* @param {string} sourceColumn - the name of the column to copy.
|
||||||
|
* @param {string} sourceIdColumn - the name of the id column of the source table.
|
||||||
|
* @param {string} targetTable - the name of the target table.
|
||||||
|
* @param {string} targetColumn - the name of the column to copy to.
|
||||||
|
* @param {string} targetIdColumn - the name of the id column of the target table.
|
||||||
|
*/
|
||||||
|
async function copyColumn(queryInterface, logger, sourceTable, sourceColumn, sourceIdColumn, targetTable, targetColumn, targetIdColumn) {
|
||||||
|
logger.info(`${loggerPrefix} copying column "${sourceColumn}" from table "${sourceTable}" to table "${targetTable}"`)
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
UPDATE ${targetTable}
|
||||||
|
SET ${targetColumn} = ${sourceTable}.${sourceColumn}
|
||||||
|
FROM ${sourceTable}
|
||||||
|
WHERE ${targetTable}.${targetIdColumn} = ${sourceTable}.${sourceIdColumn}
|
||||||
|
`)
|
||||||
|
logger.info(`${loggerPrefix} copied column "${sourceColumn}" from table "${sourceTable}" to table "${targetTable}"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function to convert a string to snake case, e.g. "titleIgnorePrefix" -> "title_ignore_prefix"
|
||||||
|
*
|
||||||
|
* @param {string} str - the string to convert to snake case.
|
||||||
|
* @returns {string} - the string in snake case.
|
||||||
|
*/
|
||||||
|
function convertToSnakeCase(str) {
|
||||||
|
return str.replace(/([A-Z])/g, '_$1').toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { up, down }
|
||||||
|
|
@ -34,6 +34,8 @@ class MediaProgress extends Model {
|
||||||
this.updatedAt
|
this.updatedAt
|
||||||
/** @type {Date} */
|
/** @type {Date} */
|
||||||
this.createdAt
|
this.createdAt
|
||||||
|
/** @type {UUIDV4} */
|
||||||
|
this.podcastId
|
||||||
}
|
}
|
||||||
|
|
||||||
static removeById(mediaProgressId) {
|
static removeById(mediaProgressId) {
|
||||||
|
|
@ -69,7 +71,8 @@ class MediaProgress extends Model {
|
||||||
ebookLocation: DataTypes.STRING,
|
ebookLocation: DataTypes.STRING,
|
||||||
ebookProgress: DataTypes.FLOAT,
|
ebookProgress: DataTypes.FLOAT,
|
||||||
finishedAt: DataTypes.DATE,
|
finishedAt: DataTypes.DATE,
|
||||||
extraData: DataTypes.JSON
|
extraData: DataTypes.JSON,
|
||||||
|
podcastId: DataTypes.UUID
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
sequelize,
|
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, {
|
user.hasMany(MediaProgress, {
|
||||||
onDelete: 'CASCADE'
|
onDelete: 'CASCADE'
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
const { DataTypes, Model } = require('sequelize')
|
const { DataTypes, Model } = require('sequelize')
|
||||||
const { getTitlePrefixAtEnd, getTitleIgnorePrefix } = require('../utils')
|
const { getTitlePrefixAtEnd, getTitleIgnorePrefix } = require('../utils')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
|
const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef PodcastExpandedProperties
|
* @typedef PodcastExpandedProperties
|
||||||
|
|
@ -61,6 +62,8 @@ class Podcast extends Model {
|
||||||
this.createdAt
|
this.createdAt
|
||||||
/** @type {Date} */
|
/** @type {Date} */
|
||||||
this.updatedAt
|
this.updatedAt
|
||||||
|
/** @type {number} */
|
||||||
|
this.numEpisodes
|
||||||
|
|
||||||
/** @type {import('./PodcastEpisode')[]} */
|
/** @type {import('./PodcastEpisode')[]} */
|
||||||
this.podcastEpisodes
|
this.podcastEpisodes
|
||||||
|
|
@ -138,13 +141,22 @@ class Podcast extends Model {
|
||||||
maxNewEpisodesToDownload: DataTypes.INTEGER,
|
maxNewEpisodesToDownload: DataTypes.INTEGER,
|
||||||
coverPath: DataTypes.STRING,
|
coverPath: DataTypes.STRING,
|
||||||
tags: DataTypes.JSON,
|
tags: DataTypes.JSON,
|
||||||
genres: DataTypes.JSON
|
genres: DataTypes.JSON,
|
||||||
|
numEpisodes: DataTypes.INTEGER
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
sequelize,
|
sequelize,
|
||||||
modelName: 'podcast'
|
modelName: 'podcast'
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Podcast.addHook('afterDestroy', async (instance) => {
|
||||||
|
libraryItemsPodcastFilters.clearCountCache('podcast', 'afterDestroy')
|
||||||
|
})
|
||||||
|
|
||||||
|
Podcast.addHook('afterCreate', async (instance) => {
|
||||||
|
libraryItemsPodcastFilters.clearCountCache('podcast', 'afterCreate')
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
get hasMediaFiles() {
|
get hasMediaFiles() {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
const { DataTypes, Model } = require('sequelize')
|
const { DataTypes, Model } = require('sequelize')
|
||||||
|
const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters')
|
||||||
/**
|
/**
|
||||||
* @typedef ChapterObject
|
* @typedef ChapterObject
|
||||||
* @property {number} id
|
* @property {number} id
|
||||||
|
|
@ -132,6 +132,14 @@ class PodcastEpisode extends Model {
|
||||||
onDelete: 'CASCADE'
|
onDelete: 'CASCADE'
|
||||||
})
|
})
|
||||||
PodcastEpisode.belongsTo(podcast)
|
PodcastEpisode.belongsTo(podcast)
|
||||||
|
|
||||||
|
PodcastEpisode.addHook('afterDestroy', async (instance) => {
|
||||||
|
libraryItemsPodcastFilters.clearCountCache('podcastEpisode', 'afterDestroy')
|
||||||
|
})
|
||||||
|
|
||||||
|
PodcastEpisode.addHook('afterCreate', async (instance) => {
|
||||||
|
libraryItemsPodcastFilters.clearCountCache('podcastEpisode', 'afterCreate')
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
get size() {
|
get size() {
|
||||||
|
|
|
||||||
|
|
@ -404,6 +404,14 @@ class User extends Model {
|
||||||
return count > 0
|
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
|
* Initialize model
|
||||||
* @param {import('../Database').sequelize} sequelize
|
* @param {import('../Database').sequelize} sequelize
|
||||||
|
|
@ -626,6 +634,7 @@ class User extends Model {
|
||||||
/** @type {import('./MediaProgress')|null} */
|
/** @type {import('./MediaProgress')|null} */
|
||||||
let mediaProgress = null
|
let mediaProgress = null
|
||||||
let mediaItemId = null
|
let mediaItemId = null
|
||||||
|
let podcastId = null
|
||||||
if (progressPayload.episodeId) {
|
if (progressPayload.episodeId) {
|
||||||
const podcastEpisode = await this.sequelize.models.podcastEpisode.findByPk(progressPayload.episodeId, {
|
const podcastEpisode = await this.sequelize.models.podcastEpisode.findByPk(progressPayload.episodeId, {
|
||||||
attributes: ['id', 'podcastId'],
|
attributes: ['id', 'podcastId'],
|
||||||
|
|
@ -654,6 +663,7 @@ class User extends Model {
|
||||||
}
|
}
|
||||||
mediaItemId = podcastEpisode.id
|
mediaItemId = podcastEpisode.id
|
||||||
mediaProgress = podcastEpisode.mediaProgresses?.[0]
|
mediaProgress = podcastEpisode.mediaProgresses?.[0]
|
||||||
|
podcastId = podcastEpisode.podcastId
|
||||||
} else {
|
} else {
|
||||||
const libraryItem = await this.sequelize.models.libraryItem.findByPk(progressPayload.libraryItemId, {
|
const libraryItem = await this.sequelize.models.libraryItem.findByPk(progressPayload.libraryItemId, {
|
||||||
attributes: ['id', 'mediaId', 'mediaType'],
|
attributes: ['id', 'mediaId', 'mediaType'],
|
||||||
|
|
@ -686,6 +696,7 @@ class User extends Model {
|
||||||
const newMediaProgressPayload = {
|
const newMediaProgressPayload = {
|
||||||
userId: this.id,
|
userId: this.id,
|
||||||
mediaItemId,
|
mediaItemId,
|
||||||
|
podcastId,
|
||||||
mediaItemType: progressPayload.episodeId ? 'podcastEpisode' : 'book',
|
mediaItemType: progressPayload.episodeId ? 'podcastEpisode' : 'book',
|
||||||
duration: isNullOrNaN(progressPayload.duration) ? 0 : Number(progressPayload.duration),
|
duration: isNullOrNaN(progressPayload.duration) ? 0 : Number(progressPayload.duration),
|
||||||
currentTime: isNullOrNaN(progressPayload.currentTime) ? 0 : Number(progressPayload.currentTime),
|
currentTime: isNullOrNaN(progressPayload.currentTime) ? 0 : Number(progressPayload.currentTime),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
const uuidv4 = require("uuid").v4
|
const uuidv4 = require('uuid').v4
|
||||||
const Path = require('path')
|
const Path = require('path')
|
||||||
const { LogLevel } = require('../utils/constants')
|
const { LogLevel } = require('../utils/constants')
|
||||||
const { getTitleIgnorePrefix } = require('../utils/index')
|
const { getTitleIgnorePrefix } = require('../utils/index')
|
||||||
|
|
@ -8,9 +8,9 @@ const { filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtil
|
||||||
const AudioFile = require('../objects/files/AudioFile')
|
const AudioFile = require('../objects/files/AudioFile')
|
||||||
const CoverManager = require('../managers/CoverManager')
|
const CoverManager = require('../managers/CoverManager')
|
||||||
const LibraryFile = require('../objects/files/LibraryFile')
|
const LibraryFile = require('../objects/files/LibraryFile')
|
||||||
const fsExtra = require("../libs/fsExtra")
|
const fsExtra = require('../libs/fsExtra')
|
||||||
const PodcastEpisode = require("../models/PodcastEpisode")
|
const PodcastEpisode = require('../models/PodcastEpisode')
|
||||||
const AbsMetadataFileScanner = require("./AbsMetadataFileScanner")
|
const AbsMetadataFileScanner = require('./AbsMetadataFileScanner')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Metadata for podcasts pulled from files
|
* Metadata for podcasts pulled from files
|
||||||
|
|
@ -32,7 +32,7 @@ const AbsMetadataFileScanner = require("./AbsMetadataFileScanner")
|
||||||
*/
|
*/
|
||||||
|
|
||||||
class PodcastScanner {
|
class PodcastScanner {
|
||||||
constructor() { }
|
constructor() {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import('../models/LibraryItem')} existingLibraryItem
|
* @param {import('../models/LibraryItem')} existingLibraryItem
|
||||||
|
|
@ -59,7 +59,8 @@ class PodcastScanner {
|
||||||
|
|
||||||
if (libraryItemData.hasAudioFileChanges || libraryItemData.audioLibraryFiles.length !== existingPodcastEpisodes.length) {
|
if (libraryItemData.hasAudioFileChanges || libraryItemData.audioLibraryFiles.length !== existingPodcastEpisodes.length) {
|
||||||
// Filter out and destroy episodes that were removed
|
// Filter out and destroy episodes that were removed
|
||||||
existingPodcastEpisodes = await Promise.all(existingPodcastEpisodes.filter(async ep => {
|
existingPodcastEpisodes = await Promise.all(
|
||||||
|
existingPodcastEpisodes.filter(async (ep) => {
|
||||||
if (libraryItemData.checkAudioFileRemoved(ep.audioFile)) {
|
if (libraryItemData.checkAudioFileRemoved(ep.audioFile)) {
|
||||||
libraryScan.addLog(LogLevel.INFO, `Podcast episode "${ep.title}" audio file was removed`)
|
libraryScan.addLog(LogLevel.INFO, `Podcast episode "${ep.title}" audio file was removed`)
|
||||||
// TODO: Should clean up other data linked to this episode
|
// TODO: Should clean up other data linked to this episode
|
||||||
|
|
@ -67,20 +68,25 @@ class PodcastScanner {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}))
|
})
|
||||||
|
)
|
||||||
|
|
||||||
// Update audio files that were modified
|
// Update audio files that were modified
|
||||||
if (libraryItemData.audioLibraryFilesModified.length) {
|
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) {
|
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) {
|
if (!matchedScannedAudioFile) {
|
||||||
matchedScannedAudioFile = scannedAudioFiles.find(saf => saf.ino === podcastEpisode.audioFile.ino)
|
matchedScannedAudioFile = scannedAudioFiles.find((saf) => saf.ino === podcastEpisode.audioFile.ino)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matchedScannedAudioFile) {
|
if (matchedScannedAudioFile) {
|
||||||
scannedAudioFiles = scannedAudioFiles.filter(saf => saf !== matchedScannedAudioFile)
|
scannedAudioFiles = scannedAudioFiles.filter((saf) => saf !== matchedScannedAudioFile)
|
||||||
const audioFile = new AudioFile(podcastEpisode.audioFile)
|
const audioFile = new AudioFile(podcastEpisode.audioFile)
|
||||||
audioFile.updateFromScan(matchedScannedAudioFile)
|
audioFile.updateFromScan(matchedScannedAudioFile)
|
||||||
podcastEpisode.audioFile = audioFile.toJSON()
|
podcastEpisode.audioFile = audioFile.toJSON()
|
||||||
|
|
@ -131,15 +137,20 @@ class PodcastScanner {
|
||||||
|
|
||||||
let hasMediaChanges = false
|
let hasMediaChanges = false
|
||||||
|
|
||||||
|
if (existingPodcastEpisodes.length !== media.numEpisodes) {
|
||||||
|
media.numEpisodes = existingPodcastEpisodes.length
|
||||||
|
hasMediaChanges = true
|
||||||
|
}
|
||||||
|
|
||||||
// Check if cover was removed
|
// 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
|
media.coverPath = null
|
||||||
hasMediaChanges = true
|
hasMediaChanges = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update cover if it was modified
|
// Update cover if it was modified
|
||||||
if (media.coverPath && libraryItemData.imageLibraryFilesModified.length) {
|
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) {
|
if (coverMatch) {
|
||||||
const coverPath = coverMatch.new.metadata.path
|
const coverPath = coverMatch.new.metadata.path
|
||||||
if (coverPath !== media.coverPath) {
|
if (coverPath !== media.coverPath) {
|
||||||
|
|
@ -154,7 +165,7 @@ class PodcastScanner {
|
||||||
// Check if cover is not set and image files were found
|
// Check if cover is not set and image files were found
|
||||||
if (!media.coverPath && libraryItemData.imageLibraryFiles.length) {
|
if (!media.coverPath && libraryItemData.imageLibraryFiles.length) {
|
||||||
// Prefer using a cover image with the name "cover" otherwise use the first image
|
// 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
|
media.coverPath = coverMatch?.metadata.path || libraryItemData.imageLibraryFiles[0].metadata.path
|
||||||
hasMediaChanges = true
|
hasMediaChanges = true
|
||||||
}
|
}
|
||||||
|
|
@ -167,7 +178,7 @@ class PodcastScanner {
|
||||||
|
|
||||||
if (key === 'genres') {
|
if (key === 'genres') {
|
||||||
const existingGenres = media.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}"`)
|
libraryScan.addLog(LogLevel.DEBUG, `Updating podcast genres "${existingGenres.join(',')}" => "${podcastMetadata.genres.join(',')}" for podcast "${podcastMetadata.title}"`)
|
||||||
media.genres = podcastMetadata.genres
|
media.genres = podcastMetadata.genres
|
||||||
media.changed('genres', true)
|
media.changed('genres', true)
|
||||||
|
|
@ -175,7 +186,7 @@ class PodcastScanner {
|
||||||
}
|
}
|
||||||
} else if (key === 'tags') {
|
} else if (key === 'tags') {
|
||||||
const existingTags = media.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}"`)
|
libraryScan.addLog(LogLevel.DEBUG, `Updating podcast tags "${existingTags.join(',')}" => "${podcastMetadata.tags.join(',')}" for podcast "${podcastMetadata.title}"`)
|
||||||
media.tags = podcastMetadata.tags
|
media.tags = podcastMetadata.tags
|
||||||
media.changed('tags', true)
|
media.changed('tags', true)
|
||||||
|
|
@ -190,7 +201,7 @@ class PodcastScanner {
|
||||||
|
|
||||||
// If no cover then extract cover from audio file if available
|
// If no cover then extract cover from audio file if available
|
||||||
if (!media.coverPath && existingPodcastEpisodes.length) {
|
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)
|
const extractedCoverPath = await CoverManager.saveEmbeddedCoverArt(audioFiles, existingLibraryItem.id, existingLibraryItem.path)
|
||||||
if (extractedCoverPath) {
|
if (extractedCoverPath) {
|
||||||
libraryScan.addLog(LogLevel.DEBUG, `Updating podcast "${podcastMetadata.title}" extracted embedded cover art from audio file to path "${extractedCoverPath}"`)
|
libraryScan.addLog(LogLevel.DEBUG, `Updating podcast "${podcastMetadata.title}" extracted embedded cover art from audio file to path "${extractedCoverPath}"`)
|
||||||
|
|
@ -267,7 +278,7 @@ class PodcastScanner {
|
||||||
// Set cover image from library file
|
// Set cover image from library file
|
||||||
if (libraryItemData.imageLibraryFiles.length) {
|
if (libraryItemData.imageLibraryFiles.length) {
|
||||||
// Prefer using a cover image with the name "cover" otherwise use the first image
|
// 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
|
podcastMetadata.coverPath = coverMatch?.metadata.path || libraryItemData.imageLibraryFiles[0].metadata.path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -283,7 +294,8 @@ class PodcastScanner {
|
||||||
lastEpisodeCheck: 0,
|
lastEpisodeCheck: 0,
|
||||||
maxEpisodesToKeep: 0,
|
maxEpisodesToKeep: 0,
|
||||||
maxNewEpisodesToDownload: 3,
|
maxNewEpisodesToDownload: 3,
|
||||||
podcastEpisodes: newPodcastEpisodes
|
podcastEpisodes: newPodcastEpisodes,
|
||||||
|
numEpisodes: newPodcastEpisodes.length
|
||||||
}
|
}
|
||||||
|
|
||||||
const libraryItemObj = libraryItemData.libraryItemObject
|
const libraryItemObj = libraryItemData.libraryItemObject
|
||||||
|
|
@ -291,6 +303,8 @@ class PodcastScanner {
|
||||||
libraryItemObj.isMissing = false
|
libraryItemObj.isMissing = false
|
||||||
libraryItemObj.isInvalid = false
|
libraryItemObj.isInvalid = false
|
||||||
libraryItemObj.extraData = {}
|
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 cover was not found in folder then check embedded covers in audio files
|
||||||
if (!podcastObject.coverPath && scannedAudioFiles.length) {
|
if (!podcastObject.coverPath && scannedAudioFiles.length) {
|
||||||
|
|
@ -399,9 +413,11 @@ class PodcastScanner {
|
||||||
explicit: !!libraryItem.media.explicit,
|
explicit: !!libraryItem.media.explicit,
|
||||||
podcastType: libraryItem.media.podcastType
|
podcastType: libraryItem.media.podcastType
|
||||||
}
|
}
|
||||||
return fsExtra.writeFile(metadataFilePath, JSON.stringify(jsonObject, null, 2)).then(async () => {
|
return fsExtra
|
||||||
|
.writeFile(metadataFilePath, JSON.stringify(jsonObject, null, 2))
|
||||||
|
.then(async () => {
|
||||||
// Add metadata.json to libraryFiles array if it is new
|
// Add metadata.json to libraryFiles array if it is new
|
||||||
let metadataLibraryFile = libraryItem.libraryFiles.find(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath))
|
let metadataLibraryFile = libraryItem.libraryFiles.find((lf) => lf.metadata.path === filePathToPOSIX(metadataFilePath))
|
||||||
if (storeMetadataWithItem) {
|
if (storeMetadataWithItem) {
|
||||||
if (!metadataLibraryFile) {
|
if (!metadataLibraryFile) {
|
||||||
const newLibraryFile = new LibraryFile()
|
const newLibraryFile = new LibraryFile()
|
||||||
|
|
@ -422,7 +438,7 @@ class PodcastScanner {
|
||||||
libraryItem.mtime = libraryItemDirTimestamps.mtimeMs
|
libraryItem.mtime = libraryItemDirTimestamps.mtimeMs
|
||||||
libraryItem.ctime = libraryItemDirTimestamps.ctimeMs
|
libraryItem.ctime = libraryItemDirTimestamps.ctimeMs
|
||||||
let size = 0
|
let size = 0
|
||||||
libraryItem.libraryFiles.forEach((lf) => size += (!isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0))
|
libraryItem.libraryFiles.forEach((lf) => (size += !isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0))
|
||||||
libraryItem.size = size
|
libraryItem.size = size
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -430,7 +446,8 @@ class PodcastScanner {
|
||||||
libraryScan.addLog(LogLevel.DEBUG, `Success saving abmetadata to "${metadataFilePath}"`)
|
libraryScan.addLog(LogLevel.DEBUG, `Success saving abmetadata to "${metadataFilePath}"`)
|
||||||
|
|
||||||
return metadataLibraryFile
|
return metadataLibraryFile
|
||||||
}).catch((error) => {
|
})
|
||||||
|
.catch((error) => {
|
||||||
libraryScan.addLog(LogLevel.ERROR, `Failed to save json file at "${metadataFilePath}"`, error)
|
libraryScan.addLog(LogLevel.ERROR, `Failed to save json file at "${metadataFilePath}"`, error)
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -145,15 +145,15 @@ function extractEpisodeData(item) {
|
||||||
|
|
||||||
if (item.enclosure?.[0]?.['$']?.url) {
|
if (item.enclosure?.[0]?.['$']?.url) {
|
||||||
enclosure = item.enclosure[0]['$']
|
enclosure = item.enclosure[0]['$']
|
||||||
} else if(item['media:content']?.find(c => c?.['$']?.url && (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"))['$']
|
enclosure = item['media:content'].find((c) => (c['$']?.type ?? '').startsWith('audio'))['$']
|
||||||
} else {
|
} else {
|
||||||
Logger.error(`[podcastUtils] Invalid podcast episode data`)
|
Logger.error(`[podcastUtils] Invalid podcast episode data`)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const episode = {
|
const episode = {
|
||||||
enclosure: enclosure,
|
enclosure: enclosure
|
||||||
}
|
}
|
||||||
|
|
||||||
episode.enclosure.url = episode.enclosure.url.trim()
|
episode.enclosure.url = episode.enclosure.url.trim()
|
||||||
|
|
@ -343,6 +343,14 @@ module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => {
|
||||||
return payload.podcast
|
return payload.podcast
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.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)
|
Logger.error('[podcastUtils] getPodcastFeed Error', error)
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ const Database = require('../../Database')
|
||||||
const libraryItemsBookFilters = require('./libraryItemsBookFilters')
|
const libraryItemsBookFilters = require('./libraryItemsBookFilters')
|
||||||
const libraryItemsPodcastFilters = require('./libraryItemsPodcastFilters')
|
const libraryItemsPodcastFilters = require('./libraryItemsPodcastFilters')
|
||||||
const { createNewSortInstance } = require('../../libs/fastSort')
|
const { createNewSortInstance } = require('../../libs/fastSort')
|
||||||
|
const { profile } = require('../../utils/profiler')
|
||||||
const naturalSort = createNewSortInstance({
|
const naturalSort = createNewSortInstance({
|
||||||
comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
|
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
|
// 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
|
// 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
|
// 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: {
|
include: {
|
||||||
model: Database.libraryItemModel,
|
model: Database.libraryItemModel,
|
||||||
attributes: [],
|
attributes: [],
|
||||||
|
|
@ -489,7 +491,7 @@ module.exports = {
|
||||||
// data was loaded. If so, we can skip loading all of the data.
|
// 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
|
// Because many items could change, just check the count of items instead
|
||||||
// of actually loading the data twice
|
// of actually loading the data twice
|
||||||
const changedPodcasts = await Database.podcastModel.count({
|
const changedPodcasts = await podcastModelCount({
|
||||||
include: {
|
include: {
|
||||||
model: Database.libraryItemModel,
|
model: Database.libraryItemModel,
|
||||||
attributes: [],
|
attributes: [],
|
||||||
|
|
@ -520,7 +522,8 @@ module.exports = {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Something has changed in the podcasts table, so reload all of the filter data for library
|
// 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: {
|
include: {
|
||||||
model: Database.libraryItemModel,
|
model: Database.libraryItemModel,
|
||||||
attributes: [],
|
attributes: [],
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
const Sequelize = require('sequelize')
|
const Sequelize = require('sequelize')
|
||||||
const Database = require('../../Database')
|
const Database = require('../../Database')
|
||||||
const Logger = require('../../Logger')
|
const Logger = require('../../Logger')
|
||||||
|
const { profile } = require('../../utils/profiler')
|
||||||
|
const stringifySequelizeQuery = require('../stringifySequelizeQuery')
|
||||||
|
|
||||||
|
const countCache = new Map()
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
/**
|
/**
|
||||||
|
|
@ -84,9 +88,9 @@ module.exports = {
|
||||||
return [[Sequelize.literal(`\`podcast\`.\`author\` COLLATE NOCASE ${nullDir}`)]]
|
return [[Sequelize.literal(`\`podcast\`.\`author\` COLLATE NOCASE ${nullDir}`)]]
|
||||||
} else if (sortBy === 'media.metadata.title') {
|
} else if (sortBy === 'media.metadata.title') {
|
||||||
if (global.ServerSettings.sortingIgnorePrefix) {
|
if (global.ServerSettings.sortingIgnorePrefix) {
|
||||||
return [[Sequelize.literal('`podcast`.`titleIgnorePrefix` COLLATE NOCASE'), dir]]
|
return [[Sequelize.literal('`libraryItem`.`titleIgnorePrefix` COLLATE NOCASE'), dir]]
|
||||||
} else {
|
} else {
|
||||||
return [[Sequelize.literal('`podcast`.`title` COLLATE NOCASE'), dir]]
|
return [[Sequelize.literal('`libraryItem`.`title` COLLATE NOCASE'), dir]]
|
||||||
}
|
}
|
||||||
} else if (sortBy === 'media.numTracks') {
|
} else if (sortBy === 'media.numTracks') {
|
||||||
return [['numEpisodes', dir]]
|
return [['numEpisodes', dir]]
|
||||||
|
|
@ -96,6 +100,29 @@ module.exports = {
|
||||||
return []
|
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
|
* Get library items for podcast media type using filter and sort
|
||||||
* @param {string} libraryId
|
* @param {string} libraryId
|
||||||
|
|
@ -120,7 +147,8 @@ module.exports = {
|
||||||
if (includeRSSFeed) {
|
if (includeRSSFeed) {
|
||||||
libraryItemIncludes.push({
|
libraryItemIncludes.push({
|
||||||
model: Database.feedModel,
|
model: Database.feedModel,
|
||||||
required: filterGroup === 'feed-open'
|
required: filterGroup === 'feed-open',
|
||||||
|
separate: true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (filterGroup === 'issues') {
|
if (filterGroup === 'issues') {
|
||||||
|
|
@ -139,9 +167,6 @@ module.exports = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const podcastIncludes = []
|
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)
|
let { mediaWhere, replacements } = this.getMediaGroupQuery(filterGroup, filterValue)
|
||||||
replacements.userId = user.id
|
replacements.userId = user.id
|
||||||
|
|
@ -153,12 +178,12 @@ module.exports = {
|
||||||
replacements = { ...replacements, ...userPermissionPodcastWhere.replacements }
|
replacements = { ...replacements, ...userPermissionPodcastWhere.replacements }
|
||||||
podcastWhere.push(...userPermissionPodcastWhere.podcastWhere)
|
podcastWhere.push(...userPermissionPodcastWhere.podcastWhere)
|
||||||
|
|
||||||
const { rows: podcasts, count } = await Database.podcastModel.findAndCountAll({
|
const findOptions = {
|
||||||
where: podcastWhere,
|
where: podcastWhere,
|
||||||
replacements,
|
replacements,
|
||||||
distinct: true,
|
distinct: true,
|
||||||
attributes: {
|
attributes: {
|
||||||
include: [[Sequelize.literal(`(SELECT count(*) FROM podcastEpisodes pe WHERE pe.podcastId = podcast.id)`), 'numEpisodes'], ...podcastIncludes]
|
include: [...podcastIncludes]
|
||||||
},
|
},
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
|
|
@ -169,10 +194,12 @@ module.exports = {
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
order: this.getOrder(sortBy, sortDesc),
|
order: this.getOrder(sortBy, sortDesc),
|
||||||
subQuery: false,
|
subQuery: false
|
||||||
limit: limit || null,
|
}
|
||||||
offset
|
|
||||||
})
|
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 libraryItems = podcasts.map((podcastExpanded) => {
|
||||||
const libraryItem = podcastExpanded.libraryItem
|
const libraryItem = podcastExpanded.libraryItem
|
||||||
|
|
@ -183,11 +210,15 @@ module.exports = {
|
||||||
if (libraryItem.feeds?.length) {
|
if (libraryItem.feeds?.length) {
|
||||||
libraryItem.rssFeed = libraryItem.feeds[0]
|
libraryItem.rssFeed = libraryItem.feeds[0]
|
||||||
}
|
}
|
||||||
if (podcast.dataValues.numEpisodesIncomplete) {
|
|
||||||
libraryItem.numEpisodesIncomplete = podcast.dataValues.numEpisodesIncomplete
|
if (includeNumEpisodesIncomplete) {
|
||||||
|
const numEpisodesComplete = user.mediaProgresses.reduce((acc, mp) => {
|
||||||
|
if (mp.podcastId === podcast.id && mp.isFinished) {
|
||||||
|
acc += 1
|
||||||
}
|
}
|
||||||
if (podcast.dataValues.numEpisodes) {
|
return acc
|
||||||
podcast.numEpisodes = podcast.dataValues.numEpisodes
|
}, 0)
|
||||||
|
libraryItem.numEpisodesIncomplete = podcast.numEpisodes - numEpisodesComplete
|
||||||
}
|
}
|
||||||
|
|
||||||
libraryItem.media = podcast
|
libraryItem.media = podcast
|
||||||
|
|
@ -268,28 +299,31 @@ module.exports = {
|
||||||
|
|
||||||
const userPermissionPodcastWhere = this.getUserPermissionPodcastWhereQuery(user)
|
const userPermissionPodcastWhere = this.getUserPermissionPodcastWhereQuery(user)
|
||||||
|
|
||||||
const { rows: podcastEpisodes, count } = await Database.podcastEpisodeModel.findAndCountAll({
|
const findOptions = {
|
||||||
where: podcastEpisodeWhere,
|
where: podcastEpisodeWhere,
|
||||||
replacements: userPermissionPodcastWhere.replacements,
|
replacements: userPermissionPodcastWhere.replacements,
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
model: Database.podcastModel,
|
model: Database.podcastModel,
|
||||||
|
required: true,
|
||||||
where: userPermissionPodcastWhere.podcastWhere,
|
where: userPermissionPodcastWhere.podcastWhere,
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
model: Database.libraryItemModel,
|
model: Database.libraryItemModel,
|
||||||
|
required: true,
|
||||||
where: libraryItemWhere
|
where: libraryItemWhere
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
...podcastEpisodeIncludes
|
...podcastEpisodeIncludes
|
||||||
],
|
],
|
||||||
distinct: true,
|
|
||||||
subQuery: false,
|
subQuery: false,
|
||||||
order: podcastEpisodeOrder,
|
order: podcastEpisodeOrder
|
||||||
limit,
|
}
|
||||||
offset
|
|
||||||
})
|
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 libraryItems = podcastEpisodes.map((ep) => {
|
||||||
const libraryItem = ep.podcast.libraryItem
|
const libraryItem = ep.podcast.libraryItem
|
||||||
|
|
|
||||||
265
test/server/migrations/v2.19.4-improve-podcast-queries.test.js
Normal file
265
test/server/migrations/v2.19.4-improve-podcast-queries.test.js
Normal file
|
|
@ -0,0 +1,265 @@
|
||||||
|
const chai = require('chai')
|
||||||
|
const sinon = require('sinon')
|
||||||
|
const { expect } = chai
|
||||||
|
|
||||||
|
const { DataTypes, Sequelize } = require('sequelize')
|
||||||
|
const Logger = require('../../../server/Logger')
|
||||||
|
|
||||||
|
const { up, down } = require('../../../server/migrations/v2.19.4-improve-podcast-queries')
|
||||||
|
|
||||||
|
describe('Migration v2.19.4-improve-podcast-queries', () => {
|
||||||
|
let sequelize
|
||||||
|
let queryInterface
|
||||||
|
let loggerInfoStub
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
|
||||||
|
queryInterface = sequelize.getQueryInterface()
|
||||||
|
loggerInfoStub = sinon.stub(Logger, 'info')
|
||||||
|
|
||||||
|
await queryInterface.createTable('libraryItems', {
|
||||||
|
id: { type: DataTypes.INTEGER, allowNull: false, primaryKey: true, unique: true },
|
||||||
|
mediaId: { type: DataTypes.INTEGER, allowNull: false },
|
||||||
|
title: { type: DataTypes.STRING, allowNull: true },
|
||||||
|
titleIgnorePrefix: { type: DataTypes.STRING, allowNull: true }
|
||||||
|
})
|
||||||
|
await queryInterface.createTable('podcasts', {
|
||||||
|
id: { type: DataTypes.INTEGER, allowNull: false, primaryKey: true, unique: true },
|
||||||
|
title: { type: DataTypes.STRING, allowNull: false },
|
||||||
|
titleIgnorePrefix: { type: DataTypes.STRING, allowNull: false }
|
||||||
|
})
|
||||||
|
|
||||||
|
await queryInterface.createTable('podcastEpisodes', {
|
||||||
|
id: { type: DataTypes.INTEGER, allowNull: false, primaryKey: true, unique: true },
|
||||||
|
podcastId: { type: DataTypes.INTEGER, allowNull: false, references: { model: 'podcasts', key: 'id', onDelete: 'CASCADE' } }
|
||||||
|
})
|
||||||
|
|
||||||
|
await queryInterface.createTable('mediaProgresses', {
|
||||||
|
id: { type: DataTypes.INTEGER, allowNull: false, primaryKey: true, unique: true },
|
||||||
|
userId: { type: DataTypes.INTEGER, allowNull: false },
|
||||||
|
mediaItemId: { type: DataTypes.INTEGER, allowNull: false },
|
||||||
|
mediaItemType: { type: DataTypes.STRING, allowNull: false },
|
||||||
|
isFinished: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false }
|
||||||
|
})
|
||||||
|
|
||||||
|
await queryInterface.bulkInsert('libraryItems', [
|
||||||
|
{ id: 1, mediaId: 1, title: null, titleIgnorePrefix: null },
|
||||||
|
{ id: 2, mediaId: 2, title: null, titleIgnorePrefix: null }
|
||||||
|
])
|
||||||
|
|
||||||
|
await queryInterface.bulkInsert('podcasts', [
|
||||||
|
{ id: 1, title: 'The Podcast 1', titleIgnorePrefix: 'Podcast 1, The' },
|
||||||
|
{ id: 2, title: 'The Podcast 2', titleIgnorePrefix: 'Podcast 2, The' }
|
||||||
|
])
|
||||||
|
|
||||||
|
await queryInterface.bulkInsert('podcastEpisodes', [
|
||||||
|
{ id: 1, podcastId: 1 },
|
||||||
|
{ id: 2, podcastId: 1 },
|
||||||
|
{ id: 3, podcastId: 2 }
|
||||||
|
])
|
||||||
|
|
||||||
|
await queryInterface.bulkInsert('mediaProgresses', [
|
||||||
|
{ id: 1, userId: 1, mediaItemId: 1, mediaItemType: 'podcastEpisode', isFinished: 1 },
|
||||||
|
{ id: 2, userId: 1, mediaItemId: 2, mediaItemType: 'podcastEpisode', isFinished: 0 },
|
||||||
|
{ id: 3, userId: 1, mediaItemId: 3, mediaItemType: 'podcastEpisode', isFinished: 1 },
|
||||||
|
{ id: 4, userId: 2, mediaItemId: 1, mediaItemType: 'podcastEpisode', isFinished: 0 },
|
||||||
|
{ id: 5, userId: 2, mediaItemId: 2, mediaItemType: 'podcastEpisode', isFinished: 1 },
|
||||||
|
{ id: 6, userId: 2, mediaItemId: 3, mediaItemType: 'podcastEpisode', isFinished: 0 },
|
||||||
|
{ id: 7, userId: 1, mediaItemId: 1, mediaItemType: 'book', isFinished: 1 },
|
||||||
|
{ id: 8, userId: 1, mediaItemId: 2, mediaItemType: 'book', isFinished: 0 }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
sinon.restore()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('up', () => {
|
||||||
|
it('should add numEpisodes column to podcasts', async () => {
|
||||||
|
await up({ context: { queryInterface, logger: Logger } })
|
||||||
|
|
||||||
|
const [podcasts] = await queryInterface.sequelize.query('SELECT * FROM podcasts')
|
||||||
|
expect(podcasts).to.deep.equal([
|
||||||
|
{ id: 1, numEpisodes: 2, title: 'The Podcast 1', titleIgnorePrefix: 'Podcast 1, The' },
|
||||||
|
{ id: 2, numEpisodes: 1, title: 'The Podcast 2', titleIgnorePrefix: 'Podcast 2, The' }
|
||||||
|
])
|
||||||
|
|
||||||
|
// Make sure podcastEpisodes are not affected due to ON DELETE CASCADE
|
||||||
|
const [podcastEpisodes] = await queryInterface.sequelize.query('SELECT * FROM podcastEpisodes')
|
||||||
|
expect(podcastEpisodes).to.deep.equal([
|
||||||
|
{ id: 1, podcastId: 1 },
|
||||||
|
{ id: 2, podcastId: 1 },
|
||||||
|
{ id: 3, podcastId: 2 }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should add podcastId column to mediaProgresses', async () => {
|
||||||
|
await up({ context: { queryInterface, logger: Logger } })
|
||||||
|
|
||||||
|
const [mediaProgresses] = await queryInterface.sequelize.query('SELECT * FROM mediaProgresses')
|
||||||
|
expect(mediaProgresses).to.deep.equal([
|
||||||
|
{ id: 1, userId: 1, mediaItemId: 1, mediaItemType: 'podcastEpisode', podcastId: 1, isFinished: 1 },
|
||||||
|
{ id: 2, userId: 1, mediaItemId: 2, mediaItemType: 'podcastEpisode', podcastId: 1, isFinished: 0 },
|
||||||
|
{ id: 3, userId: 1, mediaItemId: 3, mediaItemType: 'podcastEpisode', podcastId: 2, isFinished: 1 },
|
||||||
|
{ id: 4, userId: 2, mediaItemId: 1, mediaItemType: 'podcastEpisode', podcastId: 1, isFinished: 0 },
|
||||||
|
{ id: 5, userId: 2, mediaItemId: 2, mediaItemType: 'podcastEpisode', podcastId: 1, isFinished: 1 },
|
||||||
|
{ id: 6, userId: 2, mediaItemId: 3, mediaItemType: 'podcastEpisode', podcastId: 2, isFinished: 0 },
|
||||||
|
{ id: 7, userId: 1, mediaItemId: 1, mediaItemType: 'book', podcastId: null, isFinished: 1 },
|
||||||
|
{ id: 8, userId: 1, mediaItemId: 2, mediaItemType: 'book', podcastId: null, isFinished: 0 }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should copy title and titleIgnorePrefix from podcasts to libraryItems', async () => {
|
||||||
|
await up({ context: { queryInterface, logger: Logger } })
|
||||||
|
|
||||||
|
const [libraryItems] = await queryInterface.sequelize.query('SELECT * FROM libraryItems')
|
||||||
|
expect(libraryItems).to.deep.equal([
|
||||||
|
{ id: 1, mediaId: 1, title: 'The Podcast 1', titleIgnorePrefix: 'Podcast 1, The' },
|
||||||
|
{ id: 2, mediaId: 2, title: 'The Podcast 2', titleIgnorePrefix: 'Podcast 2, The' }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should add trigger to update title in libraryItems', async () => {
|
||||||
|
await up({ context: { queryInterface, logger: Logger } })
|
||||||
|
|
||||||
|
const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_title_from_podcasts_title'`)
|
||||||
|
expect(count).to.equal(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should add trigger to update titleIgnorePrefix in libraryItems', async () => {
|
||||||
|
await up({ context: { queryInterface, logger: Logger } })
|
||||||
|
|
||||||
|
const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_title_ignore_prefix_from_podcasts_title_ignore_prefix'`)
|
||||||
|
expect(count).to.equal(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should be idempotent', async () => {
|
||||||
|
await up({ context: { queryInterface, logger: Logger } })
|
||||||
|
await up({ context: { queryInterface, logger: Logger } })
|
||||||
|
|
||||||
|
const [podcasts] = await queryInterface.sequelize.query('SELECT * FROM podcasts')
|
||||||
|
expect(podcasts).to.deep.equal([
|
||||||
|
{ id: 1, numEpisodes: 2, title: 'The Podcast 1', titleIgnorePrefix: 'Podcast 1, The' },
|
||||||
|
{ id: 2, numEpisodes: 1, title: 'The Podcast 2', titleIgnorePrefix: 'Podcast 2, The' }
|
||||||
|
])
|
||||||
|
|
||||||
|
const [mediaProgresses] = await queryInterface.sequelize.query('SELECT * FROM mediaProgresses')
|
||||||
|
expect(mediaProgresses).to.deep.equal([
|
||||||
|
{ id: 1, userId: 1, mediaItemId: 1, mediaItemType: 'podcastEpisode', podcastId: 1, isFinished: 1 },
|
||||||
|
{ id: 2, userId: 1, mediaItemId: 2, mediaItemType: 'podcastEpisode', podcastId: 1, isFinished: 0 },
|
||||||
|
{ id: 3, userId: 1, mediaItemId: 3, mediaItemType: 'podcastEpisode', podcastId: 2, isFinished: 1 },
|
||||||
|
{ id: 4, userId: 2, mediaItemId: 1, mediaItemType: 'podcastEpisode', podcastId: 1, isFinished: 0 },
|
||||||
|
{ id: 5, userId: 2, mediaItemId: 2, mediaItemType: 'podcastEpisode', podcastId: 1, isFinished: 1 },
|
||||||
|
{ id: 6, userId: 2, mediaItemId: 3, mediaItemType: 'podcastEpisode', podcastId: 2, isFinished: 0 },
|
||||||
|
{ id: 7, userId: 1, mediaItemId: 1, mediaItemType: 'book', podcastId: null, isFinished: 1 },
|
||||||
|
{ id: 8, userId: 1, mediaItemId: 2, mediaItemType: 'book', podcastId: null, isFinished: 0 }
|
||||||
|
])
|
||||||
|
|
||||||
|
const [libraryItems] = await queryInterface.sequelize.query('SELECT * FROM libraryItems')
|
||||||
|
expect(libraryItems).to.deep.equal([
|
||||||
|
{ id: 1, mediaId: 1, title: 'The Podcast 1', titleIgnorePrefix: 'Podcast 1, The' },
|
||||||
|
{ id: 2, mediaId: 2, title: 'The Podcast 2', titleIgnorePrefix: 'Podcast 2, The' }
|
||||||
|
])
|
||||||
|
|
||||||
|
const [[{ count: count1 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_title_from_podcasts_title'`)
|
||||||
|
expect(count1).to.equal(1)
|
||||||
|
|
||||||
|
const [[{ count: count2 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_title_ignore_prefix_from_podcasts_title_ignore_prefix'`)
|
||||||
|
expect(count2).to.equal(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('down', () => {
|
||||||
|
it('should remove numEpisodes column from podcasts', async () => {
|
||||||
|
await up({ context: { queryInterface, logger: Logger } })
|
||||||
|
try {
|
||||||
|
await down({ context: { queryInterface, logger: Logger } })
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const [podcasts] = await queryInterface.sequelize.query('SELECT * FROM podcasts')
|
||||||
|
expect(podcasts).to.deep.equal([
|
||||||
|
{ id: 1, title: 'The Podcast 1', titleIgnorePrefix: 'Podcast 1, The' },
|
||||||
|
{ id: 2, title: 'The Podcast 2', titleIgnorePrefix: 'Podcast 2, The' }
|
||||||
|
])
|
||||||
|
|
||||||
|
// Make sure podcastEpisodes are not affected due to ON DELETE CASCADE
|
||||||
|
const [podcastEpisodes] = await queryInterface.sequelize.query('SELECT * FROM podcastEpisodes')
|
||||||
|
expect(podcastEpisodes).to.deep.equal([
|
||||||
|
{ id: 1, podcastId: 1 },
|
||||||
|
{ id: 2, podcastId: 1 },
|
||||||
|
{ id: 3, podcastId: 2 }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should remove podcastId column from mediaProgresses', async () => {
|
||||||
|
await up({ context: { queryInterface, logger: Logger } })
|
||||||
|
await down({ context: { queryInterface, logger: Logger } })
|
||||||
|
|
||||||
|
const [mediaProgresses] = await queryInterface.sequelize.query('SELECT * FROM mediaProgresses')
|
||||||
|
expect(mediaProgresses).to.deep.equal([
|
||||||
|
{ id: 1, userId: 1, mediaItemId: 1, mediaItemType: 'podcastEpisode', isFinished: 1 },
|
||||||
|
{ id: 2, userId: 1, mediaItemId: 2, mediaItemType: 'podcastEpisode', isFinished: 0 },
|
||||||
|
{ id: 3, userId: 1, mediaItemId: 3, mediaItemType: 'podcastEpisode', isFinished: 1 },
|
||||||
|
{ id: 4, userId: 2, mediaItemId: 1, mediaItemType: 'podcastEpisode', isFinished: 0 },
|
||||||
|
{ id: 5, userId: 2, mediaItemId: 2, mediaItemType: 'podcastEpisode', isFinished: 1 },
|
||||||
|
{ id: 6, userId: 2, mediaItemId: 3, mediaItemType: 'podcastEpisode', isFinished: 0 },
|
||||||
|
{ id: 7, userId: 1, mediaItemId: 1, mediaItemType: 'book', isFinished: 1 },
|
||||||
|
{ id: 8, userId: 1, mediaItemId: 2, mediaItemType: 'book', isFinished: 0 }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should remove trigger to update title in libraryItems', async () => {
|
||||||
|
await up({ context: { queryInterface, logger: Logger } })
|
||||||
|
await down({ context: { queryInterface, logger: Logger } })
|
||||||
|
|
||||||
|
const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_title_from_podcasts_title'`)
|
||||||
|
expect(count).to.equal(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should remove trigger to update titleIgnorePrefix in libraryItems', async () => {
|
||||||
|
await up({ context: { queryInterface, logger: Logger } })
|
||||||
|
await down({ context: { queryInterface, logger: Logger } })
|
||||||
|
|
||||||
|
const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_title_ignore_prefix_from_podcasts_title_ignore_prefix'`)
|
||||||
|
expect(count).to.equal(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should be idempotent', async () => {
|
||||||
|
await up({ context: { queryInterface, logger: Logger } })
|
||||||
|
await down({ context: { queryInterface, logger: Logger } })
|
||||||
|
await down({ context: { queryInterface, logger: Logger } })
|
||||||
|
|
||||||
|
const [podcasts] = await queryInterface.sequelize.query('SELECT * FROM podcasts')
|
||||||
|
expect(podcasts).to.deep.equal([
|
||||||
|
{ id: 1, title: 'The Podcast 1', titleIgnorePrefix: 'Podcast 1, The' },
|
||||||
|
{ id: 2, title: 'The Podcast 2', titleIgnorePrefix: 'Podcast 2, The' }
|
||||||
|
])
|
||||||
|
|
||||||
|
const [mediaProgresses] = await queryInterface.sequelize.query('SELECT * FROM mediaProgresses')
|
||||||
|
expect(mediaProgresses).to.deep.equal([
|
||||||
|
{ id: 1, userId: 1, mediaItemId: 1, mediaItemType: 'podcastEpisode', isFinished: 1 },
|
||||||
|
{ id: 2, userId: 1, mediaItemId: 2, mediaItemType: 'podcastEpisode', isFinished: 0 },
|
||||||
|
{ id: 3, userId: 1, mediaItemId: 3, mediaItemType: 'podcastEpisode', isFinished: 1 },
|
||||||
|
{ id: 4, userId: 2, mediaItemId: 1, mediaItemType: 'podcastEpisode', isFinished: 0 },
|
||||||
|
{ id: 5, userId: 2, mediaItemId: 2, mediaItemType: 'podcastEpisode', isFinished: 1 },
|
||||||
|
{ id: 6, userId: 2, mediaItemId: 3, mediaItemType: 'podcastEpisode', isFinished: 0 },
|
||||||
|
{ id: 7, userId: 1, mediaItemId: 1, mediaItemType: 'book', isFinished: 1 },
|
||||||
|
{ id: 8, userId: 1, mediaItemId: 2, mediaItemType: 'book', isFinished: 0 }
|
||||||
|
])
|
||||||
|
|
||||||
|
const [libraryItems] = await queryInterface.sequelize.query('SELECT * FROM libraryItems')
|
||||||
|
expect(libraryItems).to.deep.equal([
|
||||||
|
{ id: 1, mediaId: 1, title: 'The Podcast 1', titleIgnorePrefix: 'Podcast 1, The' },
|
||||||
|
{ id: 2, mediaId: 2, title: 'The Podcast 2', titleIgnorePrefix: 'Podcast 2, The' }
|
||||||
|
])
|
||||||
|
|
||||||
|
const [[{ count: count1 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_title_from_podcasts_title'`)
|
||||||
|
expect(count1).to.equal(0)
|
||||||
|
|
||||||
|
const [[{ count: count2 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_title_ignore_prefix_from_podcasts_title_ignore_prefix'`)
|
||||||
|
expect(count2).to.equal(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue