Compare commits

...

49 Commits

Author SHA1 Message Date
advplyr
565eb423ee Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2025-02-19 17:44:21 -06:00
advplyr
42b0e31b4a Version bump v2.19.4 2025-02-19 17:44:14 -06:00
advplyr
97a8959bf8 Merge pull request #3974 from weblate/weblate-audiobookshelf-abs-web-client
Translations update from Hosted Weblate
2025-02-19 17:16:19 -06:00
advplyr
b5b99cbaca Merge pull request #4008 from mikiher/resort-after-title-change
Re-sort title-sorted bookshelf after title change
2025-02-19 17:15:45 -06:00
advplyr
f04ef320aa Restore scroll position on title change re-sort 2025-02-19 17:12:19 -06:00
polarwood
4e33059ac8 Translated using Weblate (Turkish)
Currently translated at 18.8% (205 of 1089 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/tr/
2025-02-19 23:59:53 +01:00
Jan-Eric Myhrgren
699644322b Translated using Weblate (Swedish)
Currently translated at 91.9% (1001 of 1089 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sv/
2025-02-19 23:59:52 +01:00
biuklija
49ba364b2a Translated using Weblate (Croatian)
Currently translated at 100.0% (1089 of 1089 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/
2025-02-19 23:59:52 +01:00
Armanc Keser
adb3967f89 Translated using Weblate (Turkish)
Currently translated at 14.2% (155 of 1089 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/tr/
2025-02-19 23:59:51 +01:00
polarwood
cfdcac9475 Translated using Weblate (Turkish)
Currently translated at 13.0% (142 of 1089 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/tr/
2025-02-19 23:59:51 +01:00
Jan-Eric Myhrgren
b1d57bc0b3 Translated using Weblate (Swedish)
Currently translated at 90.6% (987 of 1089 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sv/
2025-02-19 23:59:50 +01:00
A L
f7cea8ca12 Translated using Weblate (Bulgarian)
Currently translated at 77.2% (841 of 1089 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/bg/
2025-02-19 23:59:50 +01:00
Ivan Penchev
293440006b Translated using Weblate (Bulgarian)
Currently translated at 77.2% (841 of 1089 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/bg/
2025-02-19 23:59:49 +01:00
Ivan Penchev
45f7f54b6c Translated using Weblate (Bulgarian)
Currently translated at 70.8% (772 of 1089 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/bg/
2025-02-19 23:59:49 +01:00
advplyr
bb5e16157c Merge pull request #4005 from mikiher/fix-triggers-for-new-databases
Add title triggers in new databases
2025-02-19 16:59:41 -06:00
mikiher
2e8cb46c57 Resort title-sorted bookshelf after title change 2025-02-19 21:04:07 +02:00
mikiher
f9c0e52f18 Add title triggers in new databases 2025-02-19 17:39:32 +02:00
advplyr
6290cfaeb1 Auto format 2025-02-18 17:19:06 -06:00
advplyr
fd3d4f5fcf Merge pull request #3978 from sloped/fix/detect-http-https-upgrades
fix: allow upgrading HTTP to HTTPS for redirects
2025-02-18 17:18:36 -06:00
advplyr
9f9bee2ddc Merge pull request #3996 from mikiher/optimize-podcast-queries
Improve podcast library page query performance on title, titleIgnorePrefix, and addedAt sort orders
2025-02-18 17:04:45 -06:00
mikiher
568bf0254d Change migration version to v2.19.4 2025-02-18 07:57:46 +02:00
advplyr
79f4db5ff3 Version bump v2.19.3 2025-02-16 17:01:45 -06:00
mikiher
7038f5730f Set title[IgnorePrefix] when a podcast libraryItem is created 2025-02-16 14:57:05 +02:00
mikiher
0a8186cbda Add ANALYZE to database init sequence 2025-02-16 13:38:54 +02:00
mikiher
659164003f Clear LibraryItemsPodcastFilters count cache after podcast[Episode] is created or destroryed 2025-02-16 13:27:47 +02:00
mikiher
de5d8650e8 Add profiling to podcast library filterdata queries 2025-02-16 12:47:23 +02:00
mikiher
bacefb5f6f Format PodcastScanner (Pretteier-only changes) 2025-02-16 12:41:47 +02:00
mikiher
0169bf5518 Update podcast.numEpisodes when episodes are created or destroyed 2025-02-16 12:38:44 +02:00
mikiher
8f192b1b17 Add profiling to podcasts and podcast episodes page queries 2025-02-16 09:46:32 +02:00
mikiher
21343b5aa0 Add count cache to libraryItemsPodcastQueries 2025-02-16 09:40:29 +02:00
mikiher
a5508cdc4c Remove unnecessary 'distinct: true' from podcast episodes page query 2025-02-16 09:32:00 +02:00
mikiher
bd4f48ec39 Add required: true to includes in podcast episodes page query 2025-02-16 09:29:57 +02:00
mikiher
cb9fc3e0d1 Replace numEpisodesIncomplete subquery with cached user progress calculation 2025-02-16 09:22:06 +02:00
mikiher
707533df8f Remove numEpisodes subquery from podcasst page query 2025-02-16 09:15:54 +02:00
mikiher
2e48ec0dde Use libraryItem.title[IgnorePrefix] for sorting podcasts page query 2025-02-16 09:08:27 +02:00
mikiher
f1e46a351b Separate feed query from podcasts page query 2025-02-16 09:05:54 +02:00
mikiher
da8fd2d9d5 Set podcastId when mediaProgress is created 2025-02-16 08:57:10 +02:00
mikiher
f1de307bf9 Update cached user whenever mediaProgress is removed 2025-02-16 08:52:33 +02:00
mikiher
7282afcfde Add podcastId to mediaProgress model 2025-02-16 08:42:09 +02:00
mikiher
e2f1aeed75 Add numEpisodes to podcast model 2025-02-16 08:38:03 +02:00
mikiher
23a750214f Add migration in preparation for podcast query optimization 2025-02-16 08:35:51 +02:00
advplyr
6a7418ad41 Fix:Edit book cover tab local images overflowing #3986 2025-02-15 17:55:56 -06:00
advplyr
8b00c16062 Merge pull request #3993 from mikiher/fix-stringify-sequelize-query
fix stringifySequelizeQuery and add tests
2025-02-15 17:24:19 -06:00
mikiher
8ee5646d79 fix stringifySequelizeQuery and add tests 2025-02-15 23:57:27 +02:00
advplyr
373551fb74 Merge pull request #3985 from advplyr/fix-quick-match-all-crash
Fix server crash when quick match all updates series sequence #3961
2025-02-14 17:22:29 -06:00
advplyr
d9b206fe1c Fix server crash when quick match all updates existing series sequence #3961 2025-02-14 16:56:37 -06:00
advplyr
fe4e0145c9 Merge pull request #3984 from advplyr/fix-chapter-end-sleep-timer
Fix chapter end sleep timer sometimes not stopping #3969
2025-02-14 16:39:26 -06:00
advplyr
b96226966b Merge pull request #3980 from advplyr/stringify_sequelize_query
Fix count cache by stringify Symbols #3979
2025-02-13 18:24:36 -06:00
Conner McCall
f460297daf fix: allow upgrading HTTP to HTTPS for redirects
Re: #3142 and #3658

When adding certain podcasts, the server encountered a redirect from an HTTP URL to an HTTPS domain, causing an error that was difficult for end users to diagnose without inspecting logs or HTML.

This issue arose due to SSRF security measures that blocked such redirects. Instead of failing in these cases, we now detect when the error is caused by an HTTP-to-HTTPS upgrade. If confirmed, we upgrade the initial URL to HTTPS and resend the request.

Since this change does not allow cross-protocol or cross-domain redirections, it remains secure while resolving most of the reported issues.

Affected podcasts that are now fixed:

- D&D is for Nerds
- The New Yorker: The Writer's Voice - New Fiction from The New Yorker
- Radiolab
2025-02-13 09:19:02 -06:00
28 changed files with 1274 additions and 271 deletions

View File

@@ -419,7 +419,7 @@ export default {
this.postScrollTimeout = setTimeout(this.postScroll, 500)
},
async resetEntities() {
async resetEntities(scrollPositionToRestore) {
if (this.isFetchingEntities) {
this.pendingReset = true
return
@@ -437,6 +437,12 @@ export default {
await this.loadPage(0)
var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf)
this.mountEntities(0, lastBookIndex)
if (scrollPositionToRestore) {
if (window.bookshelf) {
window.bookshelf.scrollTop = scrollPositionToRestore
}
}
},
async rebuild() {
this.initSizeData()
@@ -444,9 +450,8 @@ export default {
var lastBookIndex = Math.min(this.totalEntities, this.booksPerFetch)
this.destroyEntityComponents()
await this.loadPage(0)
var bookshelfEl = document.getElementById('bookshelf')
if (bookshelfEl) {
bookshelfEl.scrollTop = 0
if (window.bookshelf) {
window.bookshelf.scrollTop = 0
}
this.mountEntities(0, lastBookIndex)
},
@@ -547,6 +552,15 @@ export default {
if (this.entityName === 'items' || this.entityName === 'series-books') {
var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id)
if (indexOf >= 0) {
if (this.entityName === 'items' && this.orderBy === 'media.metadata.title') {
const curTitle = this.entities[indexOf].media.metadata?.title
const newTitle = libraryItem.media.metadata?.title
if (curTitle != newTitle) {
console.log('Title changed. Re-sorting...')
this.resetEntities(this.currScrollTop)
return
}
}
this.entities[indexOf] = libraryItem
if (this.entityComponentRefs[indexOf]) {
this.entityComponentRefs[indexOf].setEntity(libraryItem)

View File

@@ -1,7 +1,7 @@
<template>
<div class="w-full h-full overflow-hidden overflow-y-auto px-2 sm:px-4 py-6 relative">
<div class="flex flex-col sm:flex-row mb-4">
<div class="relative self-center">
<div class="relative self-center md:self-start">
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId, libraryItemUpdatedAt, true)" :width="120" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<!-- book cover overlay -->
@@ -36,7 +36,7 @@
<ui-btn small @click="showLocalCovers = !showLocalCovers">{{ showLocalCovers ? $strings.ButtonHide : $strings.ButtonShow }}</ui-btn>
</div>
<div v-if="showLocalCovers" class="flex items-center justify-center pb-2">
<div v-if="showLocalCovers" class="flex items-center justify-center flex-wrap pb-2">
<template v-for="localCoverFile in localCovers">
<div :key="localCoverFile.ino" class="m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="localCoverFile.metadata.path === coverPath ? 'border-yellow-300' : ''" @click="setCover(localCoverFile)">
<div class="h-24 bg-primary" :style="{ width: 96 / bookCoverAspectRatio + 'px' }">

View File

@@ -1,12 +1,12 @@
{
"name": "audiobookshelf-client",
"version": "2.19.2",
"version": "2.19.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf-client",
"version": "2.19.2",
"version": "2.19.4",
"license": "ISC",
"dependencies": {
"@nuxtjs/axios": "^5.13.6",

View File

@@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
"version": "2.19.2",
"version": "2.19.4",
"buildNumber": 1,
"description": "Self-hosted audiobook and podcast client",
"main": "index.js",

View File

@@ -1,5 +1,5 @@
{
"ButtonAdd": "Добави",
"ButtonAdd": "Създай",
"ButtonAddChapters": "Добави Глави",
"ButtonAddDevice": "Добави Устройство",
"ButtonAddLibrary": "Добави Библиотека",
@@ -10,15 +10,18 @@
"ButtonApplyChapters": "Приложи Глави",
"ButtonAuthors": "Автори",
"ButtonBack": "Назад",
"ButtonBatchEditPopulateFromExisting": "Попълни от съществуващи",
"ButtonBatchEditPopulateMapDetails": "Попълни подробности за картата",
"ButtonBrowseForFolder": "Прегледай за папка",
"ButtonCancel": "Откажи",
"ButtonCancel": "Отказ",
"ButtonCancelEncode": "Откажи закодирането",
"ButtonChangeRootPassword": "Промени паролата за Root",
"ButtonCheckAndDownloadNewEpisodes": "Провери и Свали Нови Епизоди",
"ButtonChooseAFolder": "Избери Папка",
"ButtonChooseFiles": "Избери Файлове",
"ButtonClearFilter": "Изчисти Филтър",
"ButtonCloseFeed": "Затвори Feed",
"ButtonClearFilter": "Изчисти филтър",
"ButtonCloseFeed": "Затвори стената",
"ButtonCloseSession": "Затвори отворената сесия",
"ButtonCollections": "Колекции",
"ButtonConfigureScanner": "Конфигурирай Скенера",
"ButtonCreate": "Създай",
@@ -28,6 +31,9 @@
"ButtonEdit": "Редактирай",
"ButtonEditChapters": "Редактирай Глави",
"ButtonEditPodcast": "Редактирай Подкаст",
"ButtonEnable": "Активирай",
"ButtonFireAndFail": "Задействай и неуспей",
"ButtonFireOnTest": "Задействай събитие onTest",
"ButtonForceReScan": "Принудително Пресканиране",
"ButtonFullPath": "Пълен Път",
"ButtonHide": "Скрий",
@@ -44,24 +50,31 @@
"ButtonMatchAllAuthors": "Съвпадение на Всички Автори",
"ButtonMatchBooks": "Съвпадение на Книги",
"ButtonNevermind": "Няма значение",
"ButtonNext": "Следващо",
"ButtonNextChapter": "Следваща Глава",
"ButtonOk": "Добре",
"ButtonOpenFeed": "Отвори Feed",
"ButtonNextItemInQueue": "Следващият елемент в опашката",
"ButtonOk": "Приемам",
"ButtonOpenFeed": "Отвори стената",
"ButtonOpenManager": "Отвори Мениджър",
"ButtonPause": "Пауза",
"ButtonPause": "Паузирай",
"ButtonPlay": "Пусни",
"ButtonPlayAll": "Пусни всички",
"ButtonPlaying": "Пуска се",
"ButtonPlaylists": "Плейлисти",
"ButtonPrevious": "Предишен",
"ButtonPreviousChapter": "Предишна Глава",
"ButtonProbeAudioFile": "Провери аудио файла",
"ButtonPurgeAllCache": "Изчисти Всички Кешове",
"ButtonPurgeItemsCache": "Изчисти Кеша на Елементи",
"ButtonQueueAddItem": "Добави към опашката",
"ButtonQueueRemoveItem": "Премахни от опашката",
"ButtonQuickEmbed": "Бързо вграждане",
"ButtonQuickEmbedMetadata": "Бързо вграждане метадата",
"ButtonQuickMatch": "Бързо Съпоставяне",
"ButtonReScan": "Пресканирай",
"ButtonRead": "Прочети",
"ButtonReadLess": "Покажи по-малко",
"ButtonReadMore": окажи повече",
"ButtonReadLess": "Изчети по-малко",
"ButtonReadMore": рочети дълго",
"ButtonRefresh": "Обнови",
"ButtonRemove": "Премахни",
"ButtonRemoveAll": "Премахни Всички",
@@ -77,7 +90,9 @@
"ButtonSaveTracklist": "Запази Списък с Канали",
"ButtonScan": "Сканирай",
"ButtonScanLibrary": "Сканирай Библиотека",
"ButtonSearch": "Търси",
"ButtonScrollLeft": "Скролни наляво",
"ButtonScrollRight": "Скролни надясно",
"ButtonSearch": "Търси в",
"ButtonSelectFolderPath": "Избери Път на Папка",
"ButtonSeries": "Серии",
"ButtonSetChaptersFromTracks": "Задай Глави от Песни",
@@ -86,8 +101,10 @@
"ButtonShow": "Покажи",
"ButtonStartM4BEncode": "Започни M4B Кодиране",
"ButtonStartMetadataEmbed": "Започни Вграждане на Метаданни",
"ButtonStats": "Статистики",
"ButtonSubmit": "Изпрати",
"ButtonTest": "Тест",
"ButtonUnlinkOpenId": "Премахни връзката с OpenID",
"ButtonUpload": "Качи",
"ButtonUploadBackup": "Качи Backup",
"ButtonUploadCover": "Качи Корица",
@@ -100,9 +117,10 @@
"ErrorUploadFetchMetadataNoResults": "Метаданните не могат да бъдат взети - опитайте да обновите заглавието и/или автора",
"ErrorUploadLacksTitle": "Трябва да има Заглавие",
"HeaderAccount": "Профил",
"HeaderAdvanced": "Разширени",
"HeaderAddCustomMetadataProvider": "Добави персонализиран доставчик на метаданни",
"HeaderAdvanced": "Разширени настройки",
"HeaderAppriseNotificationSettings": "Apprise Notification Опции",
"HeaderAudioTracks": "Звуков Канал",
"HeaderAudioTracks": "Песни",
"HeaderAudiobookTools": "Инструмент за Менижиране на Аудиокниги",
"HeaderAuthentication": "Аутентикация",
"HeaderBackups": "Архив",
@@ -110,26 +128,26 @@
"HeaderChapters": "Глави",
"HeaderChooseAFolder": "Избети Папка",
"HeaderCollection": "Колекция",
"HeaderCollectionItems": "Елементи на Колекция",
"HeaderCollectionItems": "Елемент в колекция",
"HeaderCover": "Корица",
"HeaderCurrentDownloads": "Текущи Сваляния",
"HeaderCustomMessageOnLogin": "Потребителско съобщение при влизане",
"HeaderCustomMetadataProviders": "Потребителски Доставчици на Метаданни",
"HeaderDetails": "Детайли",
"HeaderDownloadQueue": "Опашка за Сваляне",
"HeaderEbookFiles": "Файлове на Електронни книги",
"HeaderEbookFiles": "Е-книги файлове",
"HeaderEmail": "Емейл",
"HeaderEmailSettings": "Настройки Емайл",
"HeaderEpisodes": "Епизоди",
"HeaderEreaderDevices": "Елктронни Четци",
"HeaderEreaderSettings": "Настройки на Електронни Четци",
"HeaderEreaderSettings": "Настройки на Е-четецът",
"HeaderFiles": "Файлове",
"HeaderFindChapters": "Намери Глави",
"HeaderIgnoredFiles": "Игнорирани Файлове",
"HeaderItemFiles": "Файлове на Елемент",
"HeaderItemMetadataUtils": "Инструменти за Метаданни на Елемент",
"HeaderLastListeningSession": "Последна Сесия на Слушане",
"HeaderLatestEpisodes": "Последни Епизоди",
"HeaderLatestEpisodes": "Последни епизоди",
"HeaderLibraries": "Библиотеки",
"HeaderLibraryFiles": "Файлове на Библиотека",
"HeaderLibraryStats": "Статистика на Библиотека",
@@ -145,24 +163,29 @@
"HeaderMetadataToEmbed": "Метаданни за Вграждане",
"HeaderNewAccount": "Нов Профил",
"HeaderNewLibrary": "Нова Библиотека",
"HeaderNotificationCreate": "Създай нотификация",
"HeaderNotificationUpdate": "Обнови нотификация",
"HeaderNotifications": "Известия",
"HeaderOpenIDConnectAuthentication": "OpenID Connect Аутентикация",
"HeaderOpenRSSFeed": "Отвори RSS Feed",
"HeaderOpenListeningSessions": "Отвори сесия",
"HeaderOpenRSSFeed": "Отвори RSS емисията",
"HeaderOtherFiles": "Други Файлове",
"HeaderPasswordAuthentication": "Паролна Аутентикация",
"HeaderPermissions": "Права",
"HeaderPlayerQueue": "Опашка на Плейъра",
"HeaderPlayerSettings": "Настройки на плейъра",
"HeaderPlaylist": "Плейлист",
"HeaderPlaylistItems": "Елементи на Плейлист",
"HeaderPlaylistItems": "Елементи от плейлист",
"HeaderPodcastsToAdd": "Подкасти за Добавяне",
"HeaderPreviewCover": "Преглед на Корица",
"HeaderRSSFeedGeneral": "RSS Детайли",
"HeaderRSSFeedIsOpen": "RSS Feed е Отворен",
"HeaderRSSFeedGeneral": "RSS подробности",
"HeaderRSSFeedIsOpen": "RSS емисията е отворена",
"HeaderRSSFeeds": "RSS Feed-ове",
"HeaderRemoveEpisode": "Премахни Епизод",
"HeaderRemoveEpisodes": "Премахни {0} Епизоди",
"HeaderSavedMediaProgress": "Запазен Прогрес на Медията",
"HeaderSchedule": "График",
"HeaderScheduleEpisodeDownloads": "Планирай автоматично изтегляне на епизоди",
"HeaderScheduleLibraryScans": "График за Автоматично Сканиране на Библиотека",
"HeaderSession": "Сесия",
"HeaderSetBackupSchedule": "Задай График за Backup",
@@ -171,11 +194,12 @@
"HeaderSettingsExperimental": "Експериментални Функции",
"HeaderSettingsGeneral": "Общи",
"HeaderSettingsScanner": "Скенер",
"HeaderSleepTimer": "Таймер за Сън",
"HeaderSettingsWebClient": "Уеб клиент",
"HeaderSleepTimer": "Таймер за заспиване",
"HeaderStatsLargestItems": "Най-Големите Елементи",
"HeaderStatsLongestItems": "Най-Дългите Елементи (часове)",
"HeaderStatsMinutesListeningChart": "Минути на Слушане (последни 7 дни)",
"HeaderStatsRecentSessions": "Скорошни Сесии",
"HeaderStatsMinutesListeningChart": "Изслушани минути (последните 7 дни)",
"HeaderStatsRecentSessions": "Последни сесии",
"HeaderStatsTop10Authors": "Топ 10 Автори",
"HeaderStatsTop5Genres": "Топ 5 Жанрове",
"HeaderTableOfContents": "Съдържание",
@@ -186,7 +210,7 @@
"HeaderUpdateLibrary": "Обнови Библиотека",
"HeaderUsers": "Потребители",
"HeaderYearReview": "Преглед на {0} Година",
"HeaderYourStats": "Твоята Статистика",
"HeaderYourStats": "Вашата статистика",
"LabelAbridged": "Съкратен",
"LabelAbridgedChecked": "Съкратена (отбелязано)",
"LabelAbridgedUnchecked": "Несъкратена (не отбелязано)",
@@ -198,21 +222,26 @@
"LabelActivity": "Дейност",
"LabelAddToCollection": "Добави в Колекция",
"LabelAddToCollectionBatch": "Добави {0} Книги в Колекция",
"LabelAddToPlaylist": "Добави в Плейлист",
"LabelAddToPlaylist": "Добави в плейлист",
"LabelAddToPlaylistBatch": "Добави {0} Елемент в Плейлист",
"LabelAddedAt": "Добавени На",
"LabelAddedAt": "Добавено в",
"LabelAddedDate": "Добавено",
"LabelAdminUsersOnly": "Само за Администратори",
"LabelAll": "Всички",
"LabelAll": "Всичко",
"LabelAllUsers": "Всички Потребители",
"LabelAllUsersExcludingGuests": "Всички потребители без гости",
"LabelAllUsersIncludingGuests": "Всички потребители включително гости",
"LabelAlreadyInYourLibrary": "Вече е в твоята библиотека",
"LabelApiToken": "АПИ Токен",
"LabelAppend": "Добави",
"LabelAudioBitrate": "Аудио битрейт (напр. 128k)",
"LabelAudioChannels": "Аудио канали (1 или 2)",
"LabelAudioCodec": "Аудио кодек",
"LabelAuthor": "Автор",
"LabelAuthorFirstLast": "Автор (Първо Име, Фамилия)",
"LabelAuthorLastFirst": "Автор (Фамилия, Първо Име)",
"LabelAuthorFirstLast": "Автор (Първи, Последен)",
"LabelAuthorLastFirst": "Автор (Последен, Първи)",
"LabelAuthors": "Автори",
"LabelAutoDownloadEpisodes": "Автоматично Сваляне на Епизоди",
"LabelAutoDownloadEpisodes": "Автоматично изтегляне на епизоди",
"LabelAutoFetchMetadata": "Автоматично Взимане на Метаданни",
"LabelAutoFetchMetadataHelp": "Взима метаданни за заглвие, автор и серии за да опрости качването. Допълнителни метаданни може да трябва да бъде взера след качване.",
"LabelAutoLaunch": "Автоматично Стартиране",
@@ -220,6 +249,7 @@
"LabelAutoRegister": "Автоматична Регистрация",
"LabelAutoRegisterDescription": "Автоматично създаване на нови потребители след вход",
"LabelBackToUser": "Обратно към Потребител",
"LabelBackupAudioFiles": "Създай резервно копие на аудио файлове",
"LabelBackupLocation": "Местоположение на Архив",
"LabelBackupsEnableAutomaticBackups": "Включи автоматично архивиране",
"LabelBackupsEnableAutomaticBackupsHelp": "Архиви запазени в /metadata/backups",
@@ -228,31 +258,38 @@
"LabelBackupsNumberToKeep": "Брой архиви за запазване",
"LabelBackupsNumberToKeepHelp": "Само 1 архив ще бъде премахнат на веднъж, така че ако вече имате повече архиви от това трябва да ги премахнете ръчно.",
"LabelBitrate": "Битрейт",
"LabelBonus": "Бонус",
"LabelBooks": "Книги",
"LabelButtonText": "Текст на Бутон",
"LabelByAuthor": "от {0}",
"LabelChangePassword": "Промени Парола",
"LabelChannels": "Канали",
"LabelChapterCount": "{0} Глави",
"LabelChapterTitle": "Заглавие на Глава",
"LabelChapters": "Глави",
"LabelChaptersFound": "намерени глави",
"LabelClickForMoreInfo": "Кликни за повече информация",
"LabelClosePlayer": "Затвори Плейъра",
"LabelClickToUseCurrentValue": "Натисни да ползваш сегашната стойност",
"LabelClosePlayer": "Затвори",
"LabelCodec": "Кодек",
"LabelCollapseSeries": "Свий Серия",
"LabelCollapseSeries": "Скрий сериите",
"LabelCollapseSubSeries": "Свий подсерии",
"LabelCollection": "Колекция",
"LabelCollections": "Колекции",
"LabelComplete": "Завършено",
"LabelComplete": "Приключено",
"LabelConfirmPassword": "Потвърди Парола",
"LabelContinueListening": "Продължи Слушане",
"LabelContinueReading": "Продължи Четене",
"LabelContinueSeries": "Продължи Серия",
"LabelContinueListening": "Продължи слушане",
"LabelContinueReading": "Продължи четене",
"LabelContinueSeries": "Продължи серии",
"LabelCover": "Корица",
"LabelCoverImageURL": "URL на Корица",
"LabelCreatedAt": "Създадено на",
"LabelCronExpression": "Cron израз",
"LabelCurrent": "Текущо",
"LabelCurrently": "Текущо:",
"LabelCustomCronExpression": "Потребителски Cron Expression:",
"LabelDatetime": "Дата и Време",
"LabelDays": "Дни",
"LabelDeleteFromFileSystemCheckbox": "Изтрий от файловата система (отмени за да бъдат премахни само от базата данни)",
"LabelDescription": "Описание",
"LabelDeselectAll": "Премахни всички",
@@ -263,16 +300,18 @@
"LabelDiscFromFilename": "Диск от Име на Файл",
"LabelDiscFromMetadata": "Диск от Метаданни",
"LabelDiscover": "Открий",
"LabelDownload": "Сваляне",
"LabelDownload": "Свали",
"LabelDownloadNEpisodes": "Свали {0} епизоди",
"LabelDownloadable": "Може да се изтегли",
"LabelDuration": "Продължителност",
"LabelDurationComparisonExactMatch": "(точно съвпадение)",
"LabelDurationComparisonLonger": "({0} по-дълго)",
"LabelDurationComparisonShorter": "({0} по-късо)",
"LabelDurationFound": "Намерена продължителност:",
"LabelEbook": "Електронна книга",
"LabelEbooks": "Електронни книги",
"LabelEbook": "Е-Книга",
"LabelEbooks": "Е-книги",
"LabelEdit": "Редакция",
"LabelEmail": "Имейл",
"LabelEmailSettingsFromAddress": "От Адрес",
"LabelEmailSettingsRejectUnauthorized": "Отхвърли неавторизирани сертификати",
"LabelEmailSettingsRejectUnauthorizedHelp": "Спирането на валидацията на SSL сертификате може да изложи връзката ви на рискове, като man-in-the-middle атака. Спираите тази опция само ако знете имоликацийте от това и се доверявате на mail сървъра към който се свързвате.",
@@ -280,41 +319,53 @@
"LabelEmailSettingsSecureHelp": "Ако е вярно възката ще изполва TLS когате се свързва със сървъра. Ако не е то TLS ще се използва ако сървъра поддържа разширението STARTTLS. В повечето случаи задайте тази стойност на истина ако се свързвате към порт 465. За порт 587 или 25 оставете я на лъжа. (от nodemailer.com/smtp/#authentication)",
"LabelEmailSettingsTestAddress": "Тестов Адрес",
"LabelEmbeddedCover": "Вградена Корица",
"LabelEnable": "Включи",
"LabelEnable": "Активирай",
"LabelEncodingBackupLocation": "Резервно копие на вашите оригинални аудио файлове ще бъде съхранено в:",
"LabelEncodingChaptersNotEmbedded": "Главите не са вградени в аудиокнигите с множество тракове.",
"LabelEncodingClearItemCache": "Уверете се, че периодично изчиствате кеша на елементите.",
"LabelEncodingFinishedM4B": "Завършеният M4B файл ще бъде поставен в папката на вашите аудиокниги на:",
"LabelEncodingInfoEmbedded": "Метаданните ще бъдат вградени в аудио траковете в папката на вашите аудиокниги.",
"LabelEnd": "Край",
"LabelEndOfChapter": "Край на глава",
"LabelEpisode": "Епизод",
"LabelEpisodeTitle": "Заглавие на Епизод",
"LabelEpisodeType": "Тип на Епизод",
"LabelExample": "Пример",
"LabelExplicit": "Експлицитно",
"LabelExpandSeries": "Покажи сериите",
"LabelExpandSubSeries": "Покажи съб сериите",
"LabelExplicit": "С нецензурно съдържание",
"LabelExplicitChecked": "С нецензурно съдържание (проверено)",
"LabelExplicitUnchecked": "Без нецензурно съдържание (непроверено)",
"LabelExportOPML": "Експортирай OPML",
"LabelFeedURL": "URL на емисия",
"LabelFetchingMetadata": "Взимане на Метаданни",
"LabelFile": "Файл",
"LabelFileBirthtime": "Дата на създаване на файла",
"LabelFileModified": "Файлът променен",
"LabelFilename": "Име на Файл",
"LabelFileModified": "Дата на модификация на файла",
"LabelFilename": "Име на файла",
"LabelFilterByUser": "Филтриране по Потребител",
"LabelFindEpisodes": "Намери Епизоди",
"LabelFinished": "Завършено",
"LabelFinished": "Дата на приключване",
"LabelFolder": "Папка",
"LabelFolders": "Папки",
"LabelFontBold": "Получерно",
"LabelFontBoldness": "Плътност на шрифта",
"LabelFontBoldness": "Дебелина на шрифта",
"LabelFontFamily": "Шрифт",
"LabelFontItalic": "Курсив",
"LabelFontScale": "Мащаб на Шрифта",
"LabelFontScale": "Мащаб на шрифта",
"LabelFontStrikethrough": "Зачертан",
"LabelFormat": "Формат",
"LabelGenre": "Жанр",
"LabelGenres": "Жанрове",
"LabelHardDeleteFile": "Пълно Изтриване на Файл",
"LabelHasEbook": "Има електронна книга",
"LabelHasSupplementaryEbook": "Има допълнителна електронна книга",
"LabelHasEbook": "Има е-книга",
"LabelHasSupplementaryEbook": "Има допълнителна е-книга",
"LabelHighestPriority": "Най-висок Приоритет",
"LabelHost": "Хост",
"LabelHour": "Час",
"LabelIcon": "Икона",
"LabelImageURLFromTheWeb": "URL на Изображение от Интернет",
"LabelInProgress": "В Прогрес",
"LabelInProgress": "В процес на изпълнение",
"LabelIncludeInTracklist": "Включи в Списъка с Канали",
"LabelIncomplete": "Незавършено",
"LabelInterval": "Интервал",
@@ -337,7 +388,7 @@
"LabelLastTime": "Последно Време",
"LabelLastUpdate": "Последно Обновяване",
"LabelLayout": "Оформление",
"LabelLayoutSinglePage": "Една Страница",
"LabelLayoutSinglePage": "Единична страница",
"LabelLayoutSplitPage": "Разделена Страница",
"LabelLess": "По-малко",
"LabelLibrariesAccessibleToUser": "Библиотеки Достъпни за Потребителя",
@@ -345,8 +396,8 @@
"LabelLibraryItem": "Елемент на Библиотека",
"LabelLibraryName": "Име на Библиотека",
"LabelLimit": "Лимит",
"LabelLineSpacing": "Линейно Разстояние",
"LabelListenAgain": "Слушай Отново",
"LabelLineSpacing": "Междуредие",
"LabelListenAgain": "Слушай отново",
"LabelLogLevelDebug": "Дебъг",
"LabelLogLevelInfo": "Информация",
"LabelLogLevelWarn": "Предупреждение",
@@ -355,7 +406,7 @@
"LabelMatchExistingUsersBy": "Съпостави съществуващи потребители по",
"LabelMatchExistingUsersByDescription": "Използва се за свързване на съществуващи потребители. След свързване потребителите ще бъдат съпоставени по уникален идентификатор от вашия доставчик на SSO",
"LabelMediaPlayer": "Медия Плейър",
"LabelMediaType": "Тип на Медията",
"LabelMediaType": "Тип медия",
"LabelMetaTag": "Мета Таг",
"LabelMetaTags": "Мета Тагове",
"LabelMetadataOrderOfPrecedenceDescription": "По-високите източници на метаданни ще заменят по-ниските",
@@ -367,19 +418,19 @@
"LabelMobileRedirectURIs": "Позволени URI за Мобилно Пренасочване",
"LabelMobileRedirectURIsDescription": "Това е whitelist на валидни URI за пренасочване за мобилни приложения. По подразбиране е <code>audiobookshelf://oauth</code>, който може да премахнете или допълните с допълнителни URI за интеграция на приложения от трети страни. Използването на звезда (<code>*</code>) като единствен запис позволява всеки URI.",
"LabelMore": "Повече",
"LabelMoreInfo": "Повече Информация",
"LabelMoreInfo": "Повече информация",
"LabelName": "Име",
"LabelNarrator": "Разказвач",
"LabelNarrators": "Разказвачи",
"LabelNew": "Нови",
"LabelNewPassword": "Нова Парола",
"LabelNewestAuthors": "Най-нови Автори",
"LabelNewestEpisodes": "Най-нови Епизоди",
"LabelNewestAuthors": "Най-новите автори",
"LabelNewestEpisodes": "Най-новите епизоди",
"LabelNextBackupDate": "Следваща Дата на Архивиране",
"LabelNextScheduledRun": "Следващо Планирано Изпълнение",
"LabelNoCustomMetadataProviders": "Няма потребителски доставчици на метаданни",
"LabelNoEpisodesSelected": "Няма избрани епизоди",
"LabelNotFinished": "Не е завършено",
"LabelNotFinished": "Не е приключено",
"LabelNotStarted": "Не е започнато",
"LabelNotes": "Бележки",
"LabelNotificationAppriseURL": "Apprise URL-и",
@@ -392,7 +443,10 @@
"LabelNotificationsMaxQueueSize": "Максимален размер на опашката за известия",
"LabelNotificationsMaxQueueSizeHelp": "Събитията са ограничени до изстрелване на 1 на секунда. Събитията ще бъдат игнорирани ако опашката е на максимален размер. Това предотвратява спамирането на известия.",
"LabelNumberOfBooks": "Брой на Книги",
"LabelNumberOfEpisodes": "# Епизоди",
"LabelNumberOfEpisodes": "Брой епизоди",
"LabelOpenIDAdvancedPermsClaimDescription": "Име на OpenID твърдението, което съдържа разширени права за достъп до потребителски действия в приложението, които ще се прилагат за роли, различни от администраторските (<b>ако е конфигурирано</b>). Ако твърдението липсва в отговора, достъпът до ABS ще бъде отказан. Ако липсва една опция, тя ще се третира като <code>false</code>. Уверете се, че твърдението на доставчика на идентичност съответства на очакваната структура:",
"LabelOpenIDClaims": "Оставете следните опции празни, за да деактивирате разширеното присвояване на групи, като автоматично ще бъде присвоена групата 'Потребител'.",
"LabelOpenIDGroupClaimDescription": "Име на OpenID твърдението, което съдържа списък с групите на потребителя. Обикновено се нарича <code>groups</code>. <b>Ако е конфигурирано</b>, приложението автоматично ще присвоява роли въз основа на членството на потребителя в групи, при условие че тези групи са наименувани без чувствителност към регистъра като 'admin', 'user' или 'guest' в твърдението. Твърдението трябва да съдържа списък и ако потребителят принадлежи към множество групи, приложението ще присвои ролята, съответстваща на най-високото ниво на достъп. Ако няма съвпадение с група, достъпът ще бъде отказан.",
"LabelOpenRSSFeed": "Отвори RSS Feed",
"LabelOverwrite": "Презапиши",
"LabelPassword": "Парола",
@@ -414,24 +468,27 @@
"LabelPodcasts": "Подкасти",
"LabelPort": "Порт",
"LabelPrefixesToIgnore": "Префикси за Игнориране (без значение за главни/малки букви)",
"LabelPreventIndexing": "Предотврати индексирането на вашия feed от iTunes и Google podcast директории",
"LabelPreventIndexing": "Предотвратете индексирането на вашата емисия от директориите на iTunes и Google за подкасти",
"LabelPrimaryEbook": "Основна Електронна Книга",
"LabelProgress": "Прогрес",
"LabelProvider": "Доставчик",
"LabelPubDate": "Дата на Издаване",
"LabelPublishYear": "Година на Издаване",
"LabelPubDate": "Дата на публикуване",
"LabelPublishYear": "Година на публикуване",
"LabelPublishedDate": "Публикувани {0}",
"LabelPublisher": "Издател",
"LabelPublishers": "Издателство",
"LabelRSSFeedCustomOwnerEmail": отребителски собственик Email",
"LabelRSSFeedCustomOwnerName": отребителски собственик Име",
"LabelRSSFeedCustomOwnerEmail": ерсонализиран имейл на собственика",
"LabelRSSFeedCustomOwnerName": ерсонализирано име на собственика",
"LabelRSSFeedOpen": "RSS Feed Оптворен",
"LabelRSSFeedPreventIndexing": "Предотврати индексиране",
"LabelRSSFeedSlug": "RSS Feed слъг",
"LabelRSSFeedPreventIndexing": "Предотвратете индексиране",
"LabelRSSFeedSlug": "идентификатор на RSS емисия",
"LabelRSSFeedURL": "URL на RSS емисия",
"LabelRandomly": "Случайно",
"LabelRead": "Прочети",
"LabelReadAgain": "Прочети Отново",
"LabelReadAgain": "Прочети отново",
"LabelReadEbookWithoutProgress": "Прочети електронната книга без записване прогрес",
"LabelRecentSeries": "Скорошни Серии",
"LabelRecentlyAdded": "Наскоро Добавени",
"LabelRecentSeries": "Скорошни серии",
"LabelRecentlyAdded": "Скорошно добавени",
"LabelRecommended": "Препоръчано",
"LabelRedo": "Повтори",
"LabelRegion": "Регион",
@@ -448,12 +505,12 @@
"LabelSelectUsers": "Избери Потребители",
"LabelSendEbookToDevice": "Изпрати електронна книга до ...",
"LabelSequence": "Последователност",
"LabelSeries": "Серия",
"LabelSeries": "От сериите",
"LabelSeriesName": "Име на Серия",
"LabelSeriesProgress": "Прогрес на Серия",
"LabelServerYearReview": "Преглед на годината на сървъра ({0})",
"LabelSetEbookAsPrimary": "Задай като основна",
"LabelSetEbookAsSupplementary": "Задай като допълнителна",
"LabelSetEbookAsPrimary": "Направи главен",
"LabelSetEbookAsSupplementary": "Направи второстепенен",
"LabelSettingsAudiobooksOnly": "Само аудиокниги",
"LabelSettingsAudiobooksOnlyHelp": "Активирането на тази настройка ще игнорира файловете на електронни книги, освен ако не са в папка с аудиокниги, в което случай ще бъдат зададени като допълнителни електронни книги",
"LabelSettingsBookshelfViewHelp": "Скеуморфен дизайн с дървени рафтове",
@@ -476,6 +533,7 @@
"LabelSettingsHomePageBookshelfView": "Начална страница изглед на рафт",
"LabelSettingsLibraryBookshelfView": "Библиотека изглед на рафт",
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Пропусни предишни книги в Продължи Поредица",
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Рафтът на началната страница 'Продължи поредицата' показва първата книга, която не е започната в поредици, в които има поне една завършена книга и няма книги в процес на четене. Активирането на тази настройка ще продължи поредицата от най-далечната завършена книга вместо от първата незапочната книга.",
"LabelSettingsParseSubtitles": "Извлечи подзаглавия",
"LabelSettingsParseSubtitlesHelp": "Извлича подзаглавия от имената на папките на аудиокнигите.<br>Подзаглавията трябва да бъдат разделени с \" - \"<br>например \"Заглавие на Книга - Тук е Подзаглавито\" има подзаглавие \"Тук е Подзаглавито\"",
"LabelSettingsPreferMatchedMetadata": "Предпочети съвпадащи метаданни",
@@ -491,9 +549,10 @@
"LabelSettingsStoreMetadataWithItem": "Запази метаданните с елемента",
"LabelSettingsStoreMetadataWithItemHelp": "По подразбиране метаданните се съхраняват в /metadata/items, като активирате тази настройка метаданните ще се съхраняват в папката на елемента на вашата библиотека",
"LabelSettingsTimeFormat": "Формат на Време",
"LabelShowAll": "Покажи Всички",
"LabelShowAll": "Покажи всички",
"LabelShowSeconds": "Покажи секунди",
"LabelSize": "Размер",
"LabelSleepTimer": "Таймер за Сън",
"LabelSleepTimer": "Таймер за изключване",
"LabelSlug": "Слъг",
"LabelStart": "Старт",
"LabelStartTime": "Начално Време",
@@ -501,19 +560,19 @@
"LabelStartedAt": "Стартирано на",
"LabelStatsAudioTracks": "Аудио Канали",
"LabelStatsAuthors": "Автори",
"LabelStatsBestDay": "Най-добър Ден",
"LabelStatsDailyAverage": "Дневна Средна Стойност",
"LabelStatsDays": "Дни",
"LabelStatsDaysListened": "Дни Слушани",
"LabelStatsBestDay": "Най-добър ден",
"LabelStatsDailyAverage": "Средно дневно",
"LabelStatsDays": "Общо дни",
"LabelStatsDaysListened": "Общо слушани дни",
"LabelStatsHours": "Часове",
"LabelStatsInARow": "подред",
"LabelStatsItemsFinished": "Завършени Елементи",
"LabelStatsInARow": "последователно",
"LabelStatsItemsFinished": "Приключени елементи",
"LabelStatsItemsInLibrary": "Елементи в Библиотеката",
"LabelStatsMinutes": "минути",
"LabelStatsMinutesListening": "Минути Слушани",
"LabelStatsMinutesListening": "Общо слушани минути",
"LabelStatsOverallDays": "Общо Дни",
"LabelStatsOverallHours": "Общо Часове",
"LabelStatsWeekListening": "Седмица Слушане",
"LabelStatsWeekListening": "Общо слушани седмици",
"LabelSubtitle": "Подзаглавие",
"LabelSupportedFileTypes": "Поддържани Типове Файлове",
"LabelTag": "Таг",
@@ -531,7 +590,7 @@
"LabelTimeBase": "Времева Основа",
"LabelTimeListened": "Време Слушано",
"LabelTimeListenedToday": "Време Слушано Днес",
"LabelTimeRemaining": "{0} оставащо време",
"LabelTimeRemaining": "{0} оставащи",
"LabelTimeToShift": "Време за изместване в секунди",
"LabelTitle": "Заглавие",
"LabelToolsEmbedMetadata": "Вграждане на Метаданни",
@@ -544,14 +603,14 @@
"LabelTotalTimeListened": "Общо Време Слушано",
"LabelTrackFromFilename": "Канал от Име на Файл",
"LabelTrackFromMetadata": "Канал от Метаданни",
"LabelTracks": "Канали",
"LabelTracks": "Тракове",
"LabelTracksMultiTrack": "Многоканален",
"LabelTracksNone": "Няма канали",
"LabelTracksSingleTrack": "Единичен канал",
"LabelType": "Тип",
"LabelUnabridged": "Несъкратен",
"LabelUndo": "Отмени",
"LabelUnknown": "Неизвестно",
"LabelUnknown": "Неизвестен",
"LabelUpdateCover": "Обнови Корица",
"LabelUpdateCoverHelp": "Позволи презаписване на съществуващите корици за избраните книги, когато се намери съвпадение",
"LabelUpdateDetails": "Обнови Детайли",
@@ -563,7 +622,7 @@
"LabelUseChapterTrack": "Използвай канал за глава",
"LabelUseFullTrack": "Използвай пълен канал",
"LabelUser": "Потребител",
"LabelUsername": "Потребителско Име",
"LabelUsername": "Потребителско име",
"LabelValue": "Стойност",
"LabelVersion": "Версия",
"LabelViewBookmarks": "Виж Отметки",
@@ -571,16 +630,20 @@
"LabelViewQueue": "Виж Опашка",
"LabelVolume": "Сила на Звука",
"LabelWeekdaysToRun": "Делници за изпълнение",
"LabelYearReviewHide": "Скрий ревю на годината ти",
"LabelYearReviewShow": "Виж ревю на годината ти",
"LabelYourAudiobookDuration": "Продължителност на вашата аудиокнига",
"LabelYourBookmarks": "Вашите Отметки",
"LabelYourBookmarks": "Твойте отметки",
"LabelYourPlaylists": "Вашите Плейлисти",
"LabelYourProgress": "Вашият Прогрес",
"LabelYourProgress": "Твоят прогрес",
"MessageAddToPlayerQueue": "Добави към опашката на плейъра",
"MessageAppriseDescription": "За да ползвате тази функция трябва да имате активна инстанция на <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> или на друго АПИ което да обработва тези заявки. <br />The Apprise API Url-а трябва дае пълния URL път за изпращане на известията, например, ако вашето АПИ ве подава от <code>http://192.168.1.1:8337</code> трябва да сложитев <code>http://192.168.1.1:8337/notify</code>.",
"MessageBackupsDescription": "Резервните копия включват потребители, напредък на потребителите, подробности за елементите в библиотеката, настройки на сървъра и изображения, съхранени в <code>/metadata/items</code> и <code>/metadata/authors</code>. Резервните копия <strong>не</strong> включват никакви файлове, съхранени в папките на вашата библиотека.",
"MessageBatchQuickMatchDescription": "Бързото Съпоставяне ще опита да добави липсващи корици и метаданни за избраните елементи. Активирайте опциите по-долу, за да позволите на Бързото съпоставяне да презапише съществуващите корици и/или метаданни.",
"MessageBookshelfNoCollections": "Все още нямате създадени колекции",
"MessageBookshelfNoRSSFeeds": "Няма отворени RSS feed-ове",
"MessageBookshelfNoResultsForFilter": "Няма резултат за филтер \"{0}: {1}\"",
"MessageBookshelfNoResultsForQuery": "Няма резултати от заявката",
"MessageBookshelfNoSeries": "Нямаш сеЗЙ",
"MessageChapterEndIsAfter": "Краят на главата е след края на вашата аудиокнига",
"MessageChapterErrorFirstNotZero": "Първата глава трябва да започва от 0",
@@ -600,6 +663,8 @@
"MessageConfirmMarkAllEpisodesNotFinished": "Сигурни ли сте, че искате да маркирате всички епизоди като незавършени?",
"MessageConfirmMarkSeriesFinished": "Сигурни ли сте, че искате да маркирате всички книги в тази серия като завършени?",
"MessageConfirmMarkSeriesNotFinished": "Сигурни ли сте, че искате да маркирате всички книги в тази серия като незавършени?",
"MessageConfirmPurgeCache": "Изчистването на кеша ще изтрие цялата директория в <code>/metadata/cache</code>. <br /><br />Сигурни ли сте, че искате да премахнете директорията на кеша?",
"MessageConfirmPurgeItemsCache": "Изчистването на кеша на елементите ще изтрие цялата директория в <code>/metadata/cache/items</code>. <br />Сигурни ли сте?",
"MessageConfirmQuickEmbed": "Внимание! Бързото вграждане няма да архивира вашите аудио файлове. Уверете се, че имате резервно копие на вашите аудио файлове. <br><br>Искате ли да продължите?",
"MessageConfirmReScanLibraryItems": "Сигурни ли сте, че искате да сканирате отново {0} елемента?",
"MessageConfirmRemoveAllChapters": "Сигурни ли сте, че искате да премахнете всички глави?",
@@ -617,34 +682,36 @@
"MessageConfirmRenameTagMergeNote": "Забележка: Този таг вече съществува и ще бъде слято.",
"MessageConfirmRenameTagWarning": "Внимание! Вече съществува подобен таг с различно писане \"{0}\".",
"MessageConfirmSendEbookToDevice": "Сигурни ли сте, че искате да изпратите {0} електронна книга \"{1}\" до устройство \"{2}\"?",
"MessageDownloadingEpisode": "Изтегляне на епизод",
"MessageDownloadingEpisode": "Сваля епизод",
"MessageDragFilesIntoTrackOrder": "Плъзнете файлове в правилния ред на каналите",
"MessageEmbedFinished": "Вграждането завърши!",
"MessageEpisodesQueuedForDownload": "{0} епизод(и) в опашка за изтегляне",
"MessageFeedURLWillBe": "Feed URL-a ще бъде {0}",
"MessageFetching": "Взимане...",
"MessageEpisodesQueuedForDownload": "{0} Епизод(и) са сложени за сваляне",
"MessageEreaderDevices": "За да осигурите доставката на е-книги, може да се наложи да добавите горепосочения имейл адрес като валиден подател за всяко устройство, изброено по-долу.",
"MessageFeedURLWillBe": "Адресът на емисията ще бъде {0}",
"MessageFetching": "Извличане...",
"MessageForceReScanDescription": "ще сканира всички файлове отново като прясно сканиране. Аудио файлове ID3 тагове, OPF файлове и текстови файлове ще бъдат сканирани като нови.",
"MessageImportantNotice": "Важно Съобщение!",
"MessageInsertChapterBelow": "Вмъкни глава под",
"MessageItemsSelected": "{0} избрани",
"MessageItemsUpdated": "{0} елемента обновени",
"MessageJoinUsOn": "Присъединете се към нас",
"MessageLoading": "Зареждане...",
"MessageLoading": "Зарежда...",
"MessageLoadingFolders": "Зареждане на Папки...",
"MessageLogsDescription": "Логовете се съхраняват в <code>/metadata/logs</code> като JSON файлове. Дневниците за сривове се съхраняват в <code>/metadata/logs/crash_logs.txt</code>.",
"MessageM4BFailed": "M4B Провалено!",
"MessageM4BFinished": "M4B Завършено!",
"MessageMapChapterTitles": "Съпостави заглавията на главите със съществуващите глави на аудиокнигата без да променяш времената",
"MessageMarkAllEpisodesFinished": "Маркирай всички епизоди като завършени",
"MessageMarkAllEpisodesNotFinished": "Маркирай всички епизоди като незавършени",
"MessageMarkAsFinished": "Маркирай като Завършено",
"MessageMarkAsFinished": "Маркирай като завършено",
"MessageMarkAsNotFinished": "Маркирай като Незавършено",
"MessageMatchBooksDescription": "ще се опита да съпостави книги в библиотеката с книга от избрания доставчик за търсене и ще попълни празни детайли и корици. Не презаписва детайлите.",
"MessageNoAudioTracks": "Няма аудио канали",
"MessageNoAuthors": "Няма Автори",
"MessageNoBackups": "Няма архиви",
"MessageNoBookmarks": "Няма Отметки",
"MessageNoChapters": "Няма Глави",
"MessageNoCollections": "Няма Колекции",
"MessageNoBookmarks": "Няма отметки",
"MessageNoChapters": "Няма глави",
"MessageNoCollections": "Няма колекции",
"MessageNoCoversFound": "Не са намерени корици",
"MessageNoDescription": "Няма описание",
"MessageNoDownloadsInProgress": "Няма изтегляния в прогрес",
@@ -654,9 +721,9 @@
"MessageNoFoldersAvailable": "Няма налични папки",
"MessageNoGenres": "Няма Жанрове",
"MessageNoIssues": "Няма проблеми",
"MessageNoItems": "Няма Елементи",
"MessageNoItems": "Няма елементи",
"MessageNoItemsFound": "Няма намерени елементи",
"MessageNoListeningSessions": "Няма слушателски сесии",
"MessageNoListeningSessions": "Няма сесии за слушане",
"MessageNoLogs": "Няма логове",
"MessageNoMediaProgress": "Няма прогрес на медията",
"MessageNoNotifications": "Няма известия",
@@ -666,20 +733,21 @@
"MessageNoSeries": "Няма Серии",
"MessageNoTags": "Няма Тагове",
"MessageNoTasksRunning": "Няма вършещи се задачи",
"MessageNoUpdatesWereNecessary": "Не бяха необходими обновления",
"MessageNoUserPlaylists": "Няма плейлисти на потребителя",
"MessageNoUpdatesWereNecessary": "Няма нужда от обновяване",
"MessageNoUserPlaylists": "Нямате създадени плейлисти",
"MessageNotYetImplemented": "Още не е изпълнено",
"MessageOr": "или",
"MessagePauseChapter": "Пауза на глава",
"MessagePlayChapter": "Пусни налчалото на глава",
"MessagePlaylistCreateFromCollection": "Създай плейлист от колекция",
"MessagePodcastHasNoRSSFeedForMatching": "Подкастът няма URL адрес на RSS feed за използване за съпоставяне",
"MessagePodcastSearchField": "Въведи какво да търся или RSS емисия адрес",
"MessageQuickMatchDescription": "Попълни празните детайли и корици с първия резултат от '{0}'. Не презаписва детайлите, освен ако не е активирана настройката 'Предпочети съвпадащи метаданни' на сървъра.",
"MessageRemoveChapter": "Премахни глава",
"MessageRemoveEpisodes": "Премахни {0} епизод(и)",
"MessageRemoveFromPlayerQueue": "Премахни от опашката на плейъра",
"MessageRemoveUserWarning": "Сигурни ли сте, че искате да изтриете потребител \"{0}\" завинаги?",
"MessageReportBugsAndContribute": "Съобщавайте за грешки, заявявайте функции и допринасяйте на",
"MessageReportBugsAndContribute": "Докладвайте грешки, поискайте нови функции и допринасяйте на",
"MessageResetChaptersConfirm": "Сигурни ли сте, че искате да нулирате главите и да отмените промените, които сте направили?",
"MessageRestoreBackupConfirm": "Сигурни ли сте, че искате да възстановите архива създаден на",
"MessageRestoreBackupWarning": "Възстановяването на архив ще презапише цялата база данни, намираща се в /config и кориците в /metadata/items & /metadata/authors.<br /><br />Архивите не променят файловете в папките на вашата библиотека. Ако сте активирали настройките на сървъра за съхранение на корици и метаданни в папките на вашата библиотека, те няма да бъдат архивирани или презаписани.<br /><br />Всички клиенти, използващи вашия сървър, ще бъдат автоматично обновени.",
@@ -700,8 +768,8 @@
"NoteChangeRootPassword": "Root потребителят е единственият потребител, който може да има празна парола",
"NoteChapterEditorTimes": "Забележка: Първото време на начало на главата трябва да остане на 0:00, а последното време на начало на главата не може да надвишава продължителността на тази аудиокнига.",
"NoteFolderPicker": "Забележка: папките, които вече са картографирани, няма да бъдат показани",
"NoteRSSFeedPodcastAppsHttps": "Внимание: Повечето приложения за подкасти изискват URL адреса на RSS feed да използва HTTPS",
"NoteRSSFeedPodcastAppsPubDate": "Внимание: 1 или повече от вашите епизоди нямат дата на публикуване. Някои приложения за подкасти изискват това",
"NoteRSSFeedPodcastAppsHttps": "Предупреждение: Повечето приложения за подкасти изискват URL адресът на RSS емисията да използва HTTPS",
"NoteRSSFeedPodcastAppsPubDate": "Предупреждение: Един или повече от вашите епизоди нямат дата на публикуване. Някои приложения за подкасти изискват това.",
"NoteUploaderFoldersWithMediaFiles": "Папките с медийни файлове ще бъдат обработени като отделни елементи на библиотеката.",
"NoteUploaderOnlyAudioFiles": "Ако качвате само аудио файлове, то всеки аудио файл ще бъде обработен като отделна аудиокнига.",
"NoteUploaderUnsupportedFiles": "Неподдържаните файлове се игнорират. При избор или пускане на папка, други файлове, които не са в папка на елемент, се игнорират.",
@@ -722,18 +790,25 @@
"ToastBackupRestoreFailed": "Неуспешно възстановяване на архив",
"ToastBackupUploadFailed": "Неуспешно качване на архив",
"ToastBackupUploadSuccess": "Архивът е качен",
"ToastBatchUpdateFailed": "Неуспешно групово актуализиране",
"ToastBatchUpdateSuccess": "Успешно групово актуализиране",
"ToastBookmarkCreateFailed": "Неуспешно създаване на отметка",
"ToastBookmarkCreateSuccess": "Отметката е създадена",
"ToastBookmarkRemoveSuccess": "Отметката е премахната",
"ToastCachePurgeFailed": "Неуспешно изчистване на кеша",
"ToastCachePurgeSuccess": "Успешно изчистване на кеша",
"ToastChaptersHaveErrors": "Главите имат грешки",
"ToastChaptersMustHaveTitles": "Главите трябва да имат заглавия",
"ToastCollectionRemoveSuccess": "Колекцията е премахната",
"ToastCollectionUpdateSuccess": "Колекцията е обновена",
"ToastDeleteFileFailed": "Неуспешно изтриване на файла",
"ToastDeleteFileSuccess": "Успешно изтриване на файла",
"ToastFailedToLoadData": "Неуспешно зареждане на данни",
"ToastItemCoverUpdateSuccess": "Корицата на елемента е обновена",
"ToastItemDetailsUpdateSuccess": "Детайлите на елемента са обновени",
"ToastItemMarkedAsFinishedFailed": "Неуспешно маркиране като завършено",
"ToastItemMarkedAsFinishedFailed": "Неуспешно маркиране като Завършено",
"ToastItemMarkedAsFinishedSuccess": "Елементът е маркиран като завършен",
"ToastItemMarkedAsNotFinishedFailed": "Неуспешно маркиране като незавършено",
"ToastItemMarkedAsNotFinishedFailed": "Неуспешно маркиране като Незавършено",
"ToastItemMarkedAsNotFinishedSuccess": "Елементът е маркиран като незавършен",
"ToastLibraryCreateFailed": "Неуспешно създаване на библиотека",
"ToastLibraryCreateSuccess": "Библиотеката \"{0}\" е създадена",
@@ -747,20 +822,23 @@
"ToastPlaylistRemoveSuccess": "Плейлистът е премахнат",
"ToastPlaylistUpdateSuccess": "Плейлистът е обновен",
"ToastPodcastCreateFailed": "Неуспешно създаване на подкаст",
"ToastPodcastCreateSuccess": "Подкастът е създаден",
"ToastRSSFeedCloseFailed": "Неуспешно затваряне на RSS feed",
"ToastRSSFeedCloseSuccess": "RSS feed затворен",
"ToastPodcastCreateSuccess": "Подкаст успешно създаден",
"ToastRSSFeedCloseFailed": "Неуспешно затваряне на RSS емисията",
"ToastRSSFeedCloseSuccess": "RSS емисията е затворена",
"ToastRemoveItemFromCollectionFailed": "Неуспешно премахване на елемент от колекция",
"ToastRemoveItemFromCollectionSuccess": "Елементът е премахнат от колекция",
"ToastSendEbookToDeviceFailed": "Неуспешно изпращане на електронна книга до устройство",
"ToastSendEbookToDeviceSuccess": "Електронната книга е изпратена до устройство \"{0}\"",
"ToastSeriesUpdateFailed": "Неуспешно обновяване на серия",
"ToastSeriesUpdateSuccess": "Серията е обновена",
"ToastServerSettingsUpdateSuccess": "Настройките на сървъра са актуализирани",
"ToastSessionDeleteFailed": "Неуспешно изтриване на сесия",
"ToastSessionDeleteSuccess": "Сесията е изтрита",
"ToastSocketConnected": "Свързан сокет",
"ToastSocketDisconnected": "Сокетът е прекъснат",
"ToastSocketFailedToConnect": "Неуспешно свързване на сокет",
"ToastSortingPrefixesEmptyError": "Трябва да има поне 1 префикс за сортиране",
"ToastSortingPrefixesUpdateSuccess": "Префиксите за сортиране са актуализирани ({0} елемента)",
"ToastUserDeleteFailed": "Неуспешно изтриване на потребител",
"ToastUserDeleteSuccess": "Потребителят е изтрит"
}

View File

@@ -678,7 +678,7 @@
"LabelUploaderDropFiles": "Ispusti datoteke",
"LabelUploaderItemFetchMetadataHelp": "Automatski dohvati naslov, autora i serijal",
"LabelUseAdvancedOptions": "Koristi se naprednim opcijama",
"LabelUseChapterTrack": "Koristi zvučni zapis poglavlja",
"LabelUseChapterTrack": "Upravljaj trakom poglavlja",
"LabelUseFullTrack": "Koristi cijeli zvučni zapis",
"LabelUseZeroForUnlimited": "0 za neograničeno",
"LabelUser": "Korisnik",

View File

@@ -16,7 +16,7 @@
"ButtonCancel": "Avbryt",
"ButtonCancelEncode": "Avbryt omkodning",
"ButtonChangeRootPassword": "Ändra lösenordet för root",
"ButtonCheckAndDownloadNewEpisodes": "Sök & Ladda ner nya avsnitt",
"ButtonCheckAndDownloadNewEpisodes": "Sök & Hämta nya avsnitt",
"ButtonChooseAFolder": "Välj en mapp",
"ButtonChooseFiles": "Välj filer",
"ButtonClearFilter": "Rensa filter",
@@ -75,8 +75,8 @@
"ButtonRemove": "Ta bort",
"ButtonRemoveAll": "Ta bort alla",
"ButtonRemoveAllLibraryItems": "Ta bort alla objekt i biblioteket",
"ButtonRemoveFromContinueListening": "Radera från 'Fortsätt lyssna'",
"ButtonRemoveFromContinueReading": "Radera från 'Fortsätt läsa'",
"ButtonRemoveFromContinueListening": "Radera från 'Fortsätt att lyssna'",
"ButtonRemoveFromContinueReading": "Radera från 'Fortsätt att läsa'",
"ButtonRemoveSeriesFromContinueSeries": "Radera från 'Fortsätt med serien'",
"ButtonReset": "Tillbaka",
"ButtonResetToDefault": "Återställ till standard",
@@ -231,6 +231,7 @@
"LabelAutoDownloadEpisodes": "Automatisk nedladdning av avsnitt",
"LabelAutoFetchMetadata": "Automatisk nedladdning av metadata",
"LabelAutoFetchMetadataHelp": "Hämtar metadata för titel, författare och serier. Kompletterande metadata kan manuellt adderas efter uppladdningen.",
"LabelAutoLaunch": "Automatisk start",
"LabelAutoRegisterDescription": "Skapa automatiskt nya användare efter inloggning",
"LabelBackToUser": "Tillbaka till användaren",
"LabelBackupAudioFiles": "Säkerhetskopiera ljudfiler",
@@ -242,7 +243,7 @@
"LabelBackupsNumberToKeep": "Antal säkerhetskopior att behålla",
"LabelBackupsNumberToKeepHelp": "Endast en gammal säkerhetskopia tas bort åt gången, så om du redan har fler säkerhetskopior än det angivna värdet bör du ta bort dem manuellt.",
"LabelBitrate": "Bitfrekvens",
"LabelBonus": "Bonus",
"LabelBonus": "Bonusavsnitt",
"LabelBooks": "Böcker",
"LabelButtonText": "Knapptext",
"LabelByAuthor": "av {0}",
@@ -312,9 +313,11 @@
"LabelEnd": "Slut",
"LabelEndOfChapter": "Slut av kapitel",
"LabelEpisode": "Avsnitt",
"LabelEpisodeNotLinkedToRssFeed": "Avsnittet är inte knutet till ett RSS-flöde",
"LabelEpisodeNumber": "Avsnitt #{0}",
"LabelEpisodeTitle": "Titel på avsnittet",
"LabelEpisodeType": "Typ av avsnitt",
"LabelEpisodeUrlFromRssFeed": "URL-adress till avsnittet i RSS-flödet",
"LabelEpisodes": "Avsnitt",
"LabelEpisodic": "Uppdelad i avsnitt",
"LabelExample": "Exempel",
@@ -327,6 +330,7 @@
"LabelFetchingMetadata": "Hämtar metadata",
"LabelFile": "Fil",
"LabelFileBirthtime": "Tidpunkt, fil skapad",
"LabelFileBornDate": "Skapad {0}",
"LabelFileModified": "Tidpunkt, fil ändrad",
"LabelFileModifiedDate": "Ändrad {0}",
"LabelFilename": "Filnamn",
@@ -341,6 +345,7 @@
"LabelFontItalic": "Kursiv",
"LabelFontScale": "Skala på typsnitt",
"LabelFontStrikethrough": "Genomstruken",
"LabelFull": "Komplett",
"LabelGenre": "Kategori",
"LabelGenres": "Kategorier",
"LabelHardDeleteFile": "Hård radering av fil",
@@ -355,7 +360,7 @@
"LabelImageURLFromTheWeb": "Skriv URL-adressen till bilden på webben",
"LabelInProgress": "Pågående",
"LabelIncludeInTracklist": "Inkludera i spårlista",
"LabelIncomplete": "Ofullständig",
"LabelIncomplete": "Ofullständigt",
"LabelInterval": "Intervall",
"LabelIntervalCustomDailyWeekly": "Anpassad daglig/veckovis",
"LabelIntervalEvery12Hours": "Var 12:e timme",
@@ -416,7 +421,7 @@
"LabelNew": "Nytt",
"LabelNewPassword": "Nytt lösenord",
"LabelNewestAuthors": "Senaste författarna",
"LabelNewestEpisodes": "Senast adderade avsnitt",
"LabelNewestEpisodes": "Senaste avsnitten",
"LabelNextBackupDate": "Nästa tillfälle för säkerhetskopiering",
"LabelNextScheduledRun": "Nästa schemalagda körning",
"LabelNoCustomMetadataProviders": "Ingen egen källa för metadata",
@@ -467,12 +472,13 @@
"LabelPublishYear": "Publiceringsår",
"LabelPublishedDecade": "Årtionde för publicering",
"LabelPublisher": "Utgivare",
"LabelPublishers": "Utgivare",
"LabelRSSFeedCustomOwnerEmail": "Anpassad ägarens e-post",
"LabelRSSFeedCustomOwnerName": "Anpassat ägarnamn",
"LabelRSSFeedOpen": "Öppna RSS-flöde",
"LabelRSSFeedPreventIndexing": "Förhindra indexering",
"LabelRSSFeedSlug": "RSS-flödesslag",
"LabelRSSFeedURL": "RSS-flöde URL",
"LabelRSSFeedURL": "URL-adress för RSS-flödet",
"LabelRandomly": "Slumpartat",
"LabelRead": "Läst",
"LabelReadAgain": "Läs igen",
@@ -550,6 +556,7 @@
"LabelSettingsStoreMetadataWithItemHelp": "Som standard lagras metadatafiler i mappen '/metadata/items'. Genom att aktivera detta alternativ kommer metadatafilerna att lagra i din biblioteksmapp",
"LabelSettingsTimeFormat": "Tidsformat",
"LabelShare": "Dela",
"LabelShareURL": "Dela URL-länk",
"LabelShowAll": "Visa alla",
"LabelShowSeconds": "Visa sekunder",
"LabelShowSubtitles": "Visa underrubriker",
@@ -693,6 +700,7 @@
"MessageConfirmPurgeCache": "När du rensar cashen kommer katalogen <code>/metadata/cache</code> att raderas. <br /><br />Är du säker på att du vill radera katalogen?",
"MessageConfirmPurgeItemsCache": "När du rensar cashen för föremål kommer katalogen <code>/metadata/cache/items</code> att raderas. <br /><br />Är du säker på att du vill radera katalogen?",
"MessageConfirmQuickEmbed": "VARNING! Quick embed kommer inte att säkerhetskopiera dina ljudfiler. Se till att du har en säkerhetskopia av dina ljudfiler. <br><br>Vill du fortsätta?",
"MessageConfirmQuickMatchEpisodes": "Snabbmatchning av avsnitt kommer att ersätta befintlig information vid en träff. Endast omatchade avsnitt kommer att uppdateras. Vill du fortsätta?",
"MessageConfirmReScanLibraryItems": "Är du säker på att du vill göra en ny skanning för {0} objekt?",
"MessageConfirmRemoveAllChapters": "Är du säker på att du vill ta bort alla kapitel?",
"MessageConfirmRemoveAuthor": "Är du säker på att du vill ta bort författaren \"{0}\"?",
@@ -705,7 +713,7 @@
"MessageConfirmRemovePlaylist": "Är du säker på att du vill ta bort din spellista \"{0}\"?",
"MessageConfirmRenameGenre": "Är du säker på att du vill byta namn på kategorin \"{0}\" till \"{1}\" för alla objekt?",
"MessageConfirmRenameGenreMergeNote": "OBS: Den här kategorin finns redan, så de kommer att slås samman.",
"MessageConfirmRenameGenreWarning": "Varning! En liknande kategori med annat skrivsätt finns redan \"{0}\".",
"MessageConfirmRenameGenreWarning": "VARNING! En liknande kategori med annat skrivsätt finns redan \"{0}\".",
"MessageConfirmRenameTag": "Är du säker på att du vill byta namn på taggen \"{0}\" till \"{1}\" för alla objekt?",
"MessageConfirmRenameTagMergeNote": "OBS: Den här taggen finns redan, så de kommer att slås samman.",
"MessageConfirmRenameTagWarning": "VARNING! En liknande tagg med annat skrivsätt finns redan \"{0}\".",
@@ -735,7 +743,7 @@
"MessageMarkAllEpisodesNotFinished": "Markera alla avsnitt som ej avslutade",
"MessageMarkAsFinished": "Markera som avslutad",
"MessageMarkAsNotFinished": "Markera som ej avslutad",
"MessageMatchBooksDescription": "kommer att försöka matcha böcker i biblioteket med en bok från<br/>den valda källan och fylla i uppgifter som saknas och omslag.<br/>Inga befintliga uppgifter kommer att ersättas.",
"MessageMatchBooksDescription": "kommer att försöka matcha böcker i biblioteket med en bok från den valda källan och fylla i uppgifter som saknas och omslag. Inga befintliga uppgifter kommer att ersättas.",
"MessageNoAudioTracks": "Inga ljudspår har hittats",
"MessageNoAuthors": "Inga författare",
"MessageNoBackups": "Inga säkerhetskopior",
@@ -797,12 +805,15 @@
"MessageTaskEmbeddingMetadataDescription": "Infogar metadata i ljudboken \"{0}\"",
"MessageTaskEncodingM4bDescription": "Omkodning av ljudbok \"{0}\" till en M4B-fil",
"MessageTaskFailed": "Misslyckades",
"MessageTaskFailedToBackupAudioFile": "Misslyckades med att göra backup på ljudfil \"{0}\"",
"MessageTaskFailedToCreateCacheDirectory": "Misslyckades med att skapa bibliotek för cachen",
"MessageTaskFailedToEmbedMetadataInFile": "Misslyckades med att infoga metadata i \"{0}\"",
"MessageTaskFailedToMergeAudioFiles": "Misslyckades med att sammanfoga ljudfilerna",
"MessageTaskFailedToMoveM4bFile": "Misslyckades med att flytta M4B-filen",
"MessageTaskFailedToWriteMetadataFile": "Misslyckades med att skapa filen med metadata",
"MessageTaskMatchingBooksInLibrary": "Matchar böcker i biblioteket \"{0}\"",
"MessageTaskOpmlImportFeedPodcastDescription": "Skapar podcast \"{0}\"",
"MessageTaskOpmlImportFeedPodcastFailed": "Misslyckades med att skapa podcast",
"MessageTaskOpmlImportFinished": "Adderade {0} podcasts",
"MessageTaskOpmlParseFailed": "Misslyckades att tolka OPML-filen",
"MessageTaskOpmlParseFastFail": "Felaktig OPML-fil. Ingen <opml> tag eller <outline> tag finns i filen",
@@ -814,7 +825,7 @@
"MessageTaskScanningLibrary": "Biblioteket \"{0}\" har skannats",
"MessageThinking": "Tänker...",
"MessageUploaderItemFailed": "Misslyckades med att ladda upp",
"MessageUploaderItemSuccess": "Uppladdning lyckades!",
"MessageUploaderItemSuccess": "har blivit uppladdad!",
"MessageUploading": "Laddar upp...",
"MessageValidCronExpression": "Giltigt cron-uttryck",
"MessageWatcherIsDisabledGlobally": "Watcher är inaktiverad centralt under rubriken 'Inställningar'",
@@ -829,6 +840,9 @@
"NoteUploaderFoldersWithMediaFiles": "Mappar som innehåller mediefiler hanteras som separata objekt i biblioteket.",
"NoteUploaderOnlyAudioFiles": "Om du bara laddar upp ljudfiler kommer varje ljudfil att hanteras som en separat ljudbok.",
"NoteUploaderUnsupportedFiles": "Oaccepterade filer ignoreras. När du väljer eller släpper en mapp ignoreras andra filer som inte finns i ett objektmapp.",
"NotificationOnBackupCompletedDescription": "Aktiveras när en backup är genomförd",
"NotificationOnBackupFailedDescription": "Aktiveras när en backup misslyckas",
"NotificationOnEpisodeDownloadedDescription": "Aktiveras när avsnitt i en podcast automatiskt har hämtats",
"PlaceholderNewCollection": "Nytt namn på samlingen",
"PlaceholderNewFolderPath": "Nytt sökväg till mappen",
"PlaceholderNewPlaylist": "Nytt namn på spellistan",
@@ -888,9 +902,12 @@
"ToastDateTimeInvalidOrIncomplete": "Datum och klockslag är felaktigt eller ej komplett",
"ToastDeleteFileFailed": "Misslyckades att radera filen",
"ToastDeleteFileSuccess": "Filen har raderats",
"ToastDeviceAddFailed": "Misslyckades med att addera enheten",
"ToastDeviceNameAlreadyExists": "En enhet för att läsa e-böcker med det namnet finns redan",
"ToastDeviceTestEmailFailed": "Misslyckades med att skicka ett testmail",
"ToastDeviceTestEmailSuccess": "Ett testmail har skickats",
"ToastEmailSettingsUpdateSuccess": "Inställningarna av e-post har uppdaterats",
"ToastEncodeCancelFailed": "Misslyckades med att avbryta omkodningen",
"ToastEncodeCancelSucces": "Omkodningen avbruten",
"ToastEpisodeDownloadQueueClearFailed": "Misslyckades med att tömma kön",
"ToastEpisodeDownloadQueueClearSuccess": "Kö för nedladdning av avsnitt har tömts",
@@ -931,6 +948,7 @@
"ToastNewUserTagError": "Minst en tagg måste läggas till",
"ToastNewUserUsernameError": "Ange ett användarnamn",
"ToastNoNewEpisodesFound": "Inga nya avsnitt kunde hittas",
"ToastNoRSSFeed": "Denna podcast har ingen RSS-flöde",
"ToastNoUpdatesNecessary": "Inga uppdateringar var nödvändiga",
"ToastNotificationCreateFailed": "Misslyckades med att skapa meddelandet",
"ToastNotificationDeleteFailed": "Misslyckades med att radera meddelandet",
@@ -942,6 +960,7 @@
"ToastPodcastCreateFailed": "Misslyckades med att skapa podcasten",
"ToastPodcastCreateSuccess": "Podcasten skapades framgångsrikt",
"ToastPodcastNoEpisodesInFeed": "Inga avsnitt finns i RSS-flödet",
"ToastPodcastNoRssFeed": "Denna podcast har ingen RSS-flöde",
"ToastProviderCreatedFailed": "Misslyckades med att addera en källa",
"ToastProviderCreatedSuccess": "En ny källa har adderats",
"ToastProviderNameAndUrlRequired": "Ett namn och en URL-adress krävs",

View File

@@ -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ışıı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
View File

@@ -1,12 +1,12 @@
{
"name": "audiobookshelf",
"version": "2.19.2",
"version": "2.19.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf",
"version": "2.19.2",
"version": "2.19.4",
"license": "GPL-3.0",
"dependencies": {
"axios": "^0.27.2",

View File

@@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
"version": "2.19.2",
"version": "2.19.4",
"buildNumber": 1,
"description": "Self-hosted audiobook and podcast server",
"main": "index.js",

View File

@@ -190,7 +190,13 @@ class Database {
await this.buildModels(force)
Logger.info(`[Database] Db initialized with models:`, Object.keys(this.sequelize.models).join(', '))
await this.addTriggers()
await this.loadData()
Logger.info(`[Database] running ANALYZE`)
await this.sequelize.query('ANALYZE')
Logger.info(`[Database] ANALYZE completed`)
}
/**
@@ -767,6 +773,43 @@ class Database {
return textQuery
}
/**
* This is used to create necessary triggers for new databases.
* It adds triggers to update libraryItems.title[IgnorePrefix] when (books|podcasts).title[IgnorePrefix] is updated
*/
async addTriggers() {
await this.addTriggerIfNotExists('books', 'title', 'id', 'libraryItems', 'title', 'mediaId')
await this.addTriggerIfNotExists('books', 'titleIgnorePrefix', 'id', 'libraryItems', 'titleIgnorePrefix', 'mediaId')
await this.addTriggerIfNotExists('podcasts', 'title', 'id', 'libraryItems', 'title', 'mediaId')
await this.addTriggerIfNotExists('podcasts', 'titleIgnorePrefix', 'id', 'libraryItems', 'titleIgnorePrefix', 'mediaId')
}
async addTriggerIfNotExists(sourceTable, sourceColumn, sourceIdColumn, targetTable, targetColumn, targetIdColumn) {
const action = `update_${targetTable}_${targetColumn}`
const fromSource = sourceTable === 'books' ? '' : `_from_${sourceTable}_${sourceColumn}`
const triggerName = this.convertToSnakeCase(`${action}${fromSource}`)
const [[{ count }]] = await this.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='${triggerName}'`)
if (count > 0) return // Trigger already exists
Logger.info(`[Database] Adding trigger ${triggerName}`)
await this.sequelize.query(`
CREATE TRIGGER ${triggerName}
AFTER UPDATE OF ${sourceColumn} ON ${sourceTable}
FOR EACH ROW
BEGIN
UPDATE ${targetTable}
SET ${targetColumn} = NEW.${sourceColumn}
WHERE ${targetTable}.${targetIdColumn} = NEW.${sourceIdColumn};
END;
`)
}
convertToSnakeCase(str) {
return str.replace(/([A-Z])/g, '_$1').toLowerCase()
}
TextSearchQuery = class {
constructor(sequelize, supportsUnaccent, query) {
this.sequelize = sequelize

View File

@@ -107,7 +107,9 @@ class PodcastController {
libraryFiles: [],
extraData: {},
libraryId: library.id,
libraryFolderId: folder.id
libraryFolderId: folder.id,
title: podcast.title,
titleIgnorePrefix: podcast.titleIgnorePrefix
},
{ transaction }
)
@@ -498,6 +500,10 @@ class PodcastController {
req.libraryItem.changed('libraryFiles', true)
await req.libraryItem.save()
// update number of episodes
req.libraryItem.media.numEpisodes = req.libraryItem.media.podcastEpisodes.length
await req.libraryItem.media.save()
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
res.json(req.libraryItem.toOldJSON())
}

View File

@@ -232,6 +232,11 @@ class PodcastManager {
await libraryItem.save()
if (libraryItem.media.numEpisodes !== libraryItem.media.podcastEpisodes.length) {
libraryItem.media.numEpisodes = libraryItem.media.podcastEpisodes.length
await libraryItem.media.save()
}
SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
const podcastEpisodeExpanded = podcastEpisode.toOldJSONExpanded(libraryItem.id)
podcastEpisodeExpanded.libraryItem = libraryItem.toOldJSONExpanded()
@@ -622,7 +627,9 @@ class PodcastManager {
libraryFiles: [],
extraData: {},
libraryId: folder.libraryId,
libraryFolderId: folder.id
libraryFolderId: folder.id,
title: podcast.title,
titleIgnorePrefix: podcast.titleIgnorePrefix
},
{ transaction }
)

View File

@@ -14,3 +14,4 @@ Please add a record of every database migration that you create to this file. Th
| v2.17.6 | v2.17.6-share-add-isdownloadable | Adds the isDownloadable column to the mediaItemShares table |
| v2.17.7 | v2.17.7-add-indices | Adds indices to the libraryItems and books tables to reduce query times |
| v2.19.1 | v2.19.1-copy-title-to-library-items | Copies title and titleIgnorePrefix to the libraryItems table, creates update triggers and indices |
| v2.19.4 | v2.19.4-improve-podcast-queries | Adds numEpisodes to podcasts, adds podcastId to mediaProgresses, copies podcast title to libraryItems |

View 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 }

View File

@@ -103,7 +103,7 @@ class LibraryItem extends Model {
{
model: this.sequelize.models.series,
through: {
attributes: ['sequence', 'createdAt']
attributes: ['id', 'sequence', 'createdAt']
}
}
]

View File

@@ -34,6 +34,8 @@ class MediaProgress extends Model {
this.updatedAt
/** @type {Date} */
this.createdAt
/** @type {UUIDV4} */
this.podcastId
}
static removeById(mediaProgressId) {
@@ -69,7 +71,8 @@ class MediaProgress extends Model {
ebookLocation: DataTypes.STRING,
ebookProgress: DataTypes.FLOAT,
finishedAt: DataTypes.DATE,
extraData: DataTypes.JSON
extraData: DataTypes.JSON,
podcastId: DataTypes.UUID
},
{
sequelize,
@@ -123,6 +126,16 @@ class MediaProgress extends Model {
}
})
// make sure to call the afterDestroy hook for each instance
MediaProgress.addHook('beforeBulkDestroy', (options) => {
options.individualHooks = true
})
// update the potentially cached user after destroying the media progress
MediaProgress.addHook('afterDestroy', (instance) => {
user.mediaProgressRemoved(instance)
})
user.hasMany(MediaProgress, {
onDelete: 'CASCADE'
})

View File

@@ -1,6 +1,7 @@
const { DataTypes, Model } = require('sequelize')
const { getTitlePrefixAtEnd, getTitleIgnorePrefix } = require('../utils')
const Logger = require('../Logger')
const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters')
/**
* @typedef PodcastExpandedProperties
@@ -61,6 +62,8 @@ class Podcast extends Model {
this.createdAt
/** @type {Date} */
this.updatedAt
/** @type {number} */
this.numEpisodes
/** @type {import('./PodcastEpisode')[]} */
this.podcastEpisodes
@@ -138,13 +141,22 @@ class Podcast extends Model {
maxNewEpisodesToDownload: DataTypes.INTEGER,
coverPath: DataTypes.STRING,
tags: DataTypes.JSON,
genres: DataTypes.JSON
genres: DataTypes.JSON,
numEpisodes: DataTypes.INTEGER
},
{
sequelize,
modelName: 'podcast'
}
)
Podcast.addHook('afterDestroy', async (instance) => {
libraryItemsPodcastFilters.clearCountCache('podcast', 'afterDestroy')
})
Podcast.addHook('afterCreate', async (instance) => {
libraryItemsPodcastFilters.clearCountCache('podcast', 'afterCreate')
})
}
get hasMediaFiles() {

View File

@@ -1,5 +1,5 @@
const { DataTypes, Model } = require('sequelize')
const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters')
/**
* @typedef ChapterObject
* @property {number} id
@@ -132,6 +132,14 @@ class PodcastEpisode extends Model {
onDelete: 'CASCADE'
})
PodcastEpisode.belongsTo(podcast)
PodcastEpisode.addHook('afterDestroy', async (instance) => {
libraryItemsPodcastFilters.clearCountCache('podcastEpisode', 'afterDestroy')
})
PodcastEpisode.addHook('afterCreate', async (instance) => {
libraryItemsPodcastFilters.clearCountCache('podcastEpisode', 'afterCreate')
})
}
get size() {

View File

@@ -404,6 +404,14 @@ class User extends Model {
return count > 0
}
static mediaProgressRemoved(mediaProgress) {
const cachedUser = userCache.getById(mediaProgress.userId)
if (cachedUser) {
Logger.debug(`[User] mediaProgressRemoved: ${mediaProgress.id} from user ${cachedUser.id}`)
cachedUser.mediaProgresses = cachedUser.mediaProgresses.filter((mp) => mp.id !== mediaProgress.id)
}
}
/**
* Initialize model
* @param {import('../Database').sequelize} sequelize
@@ -626,6 +634,7 @@ class User extends Model {
/** @type {import('./MediaProgress')|null} */
let mediaProgress = null
let mediaItemId = null
let podcastId = null
if (progressPayload.episodeId) {
const podcastEpisode = await this.sequelize.models.podcastEpisode.findByPk(progressPayload.episodeId, {
attributes: ['id', 'podcastId'],
@@ -654,6 +663,7 @@ class User extends Model {
}
mediaItemId = podcastEpisode.id
mediaProgress = podcastEpisode.mediaProgresses?.[0]
podcastId = podcastEpisode.podcastId
} else {
const libraryItem = await this.sequelize.models.libraryItem.findByPk(progressPayload.libraryItemId, {
attributes: ['id', 'mediaId', 'mediaType'],
@@ -686,6 +696,7 @@ class User extends Model {
const newMediaProgressPayload = {
userId: this.id,
mediaItemId,
podcastId,
mediaItemType: progressPayload.episodeId ? 'podcastEpisode' : 'book',
duration: isNullOrNaN(progressPayload.duration) ? 0 : Number(progressPayload.duration),
currentTime: isNullOrNaN(progressPayload.currentTime) ? 0 : Number(progressPayload.currentTime),

View File

@@ -1,4 +1,4 @@
const uuidv4 = require("uuid").v4
const uuidv4 = require('uuid').v4
const Path = require('path')
const { LogLevel } = require('../utils/constants')
const { getTitleIgnorePrefix } = require('../utils/index')
@@ -8,9 +8,9 @@ const { filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtil
const AudioFile = require('../objects/files/AudioFile')
const CoverManager = require('../managers/CoverManager')
const LibraryFile = require('../objects/files/LibraryFile')
const fsExtra = require("../libs/fsExtra")
const PodcastEpisode = require("../models/PodcastEpisode")
const AbsMetadataFileScanner = require("./AbsMetadataFileScanner")
const fsExtra = require('../libs/fsExtra')
const PodcastEpisode = require('../models/PodcastEpisode')
const AbsMetadataFileScanner = require('./AbsMetadataFileScanner')
/**
* Metadata for podcasts pulled from files
@@ -32,13 +32,13 @@ const AbsMetadataFileScanner = require("./AbsMetadataFileScanner")
*/
class PodcastScanner {
constructor() { }
constructor() {}
/**
* @param {import('../models/LibraryItem')} existingLibraryItem
* @param {import('./LibraryItemScanData')} libraryItemData
* @param {import('../models/LibraryItem')} existingLibraryItem
* @param {import('./LibraryItemScanData')} libraryItemData
* @param {import('../models/Library').LibrarySettingsObject} librarySettings
* @param {import('./LibraryScan')} libraryScan
* @param {import('./LibraryScan')} libraryScan
* @returns {Promise<{libraryItem:import('../models/LibraryItem'), wasUpdated:boolean}>}
*/
async rescanExistingPodcastLibraryItem(existingLibraryItem, libraryItemData, librarySettings, libraryScan) {
@@ -59,28 +59,34 @@ class PodcastScanner {
if (libraryItemData.hasAudioFileChanges || libraryItemData.audioLibraryFiles.length !== existingPodcastEpisodes.length) {
// Filter out and destroy episodes that were removed
existingPodcastEpisodes = await Promise.all(existingPodcastEpisodes.filter(async ep => {
if (libraryItemData.checkAudioFileRemoved(ep.audioFile)) {
libraryScan.addLog(LogLevel.INFO, `Podcast episode "${ep.title}" audio file was removed`)
// TODO: Should clean up other data linked to this episode
await ep.destroy()
return false
}
return true
}))
existingPodcastEpisodes = await Promise.all(
existingPodcastEpisodes.filter(async (ep) => {
if (libraryItemData.checkAudioFileRemoved(ep.audioFile)) {
libraryScan.addLog(LogLevel.INFO, `Podcast episode "${ep.title}" audio file was removed`)
// TODO: Should clean up other data linked to this episode
await ep.destroy()
return false
}
return true
})
)
// Update audio files that were modified
if (libraryItemData.audioLibraryFilesModified.length) {
let scannedAudioFiles = await AudioFileScanner.executeMediaFileScans(existingLibraryItem.mediaType, libraryItemData, libraryItemData.audioLibraryFilesModified.map(lf => lf.new))
let scannedAudioFiles = await AudioFileScanner.executeMediaFileScans(
existingLibraryItem.mediaType,
libraryItemData,
libraryItemData.audioLibraryFilesModified.map((lf) => lf.new)
)
for (const podcastEpisode of existingPodcastEpisodes) {
let matchedScannedAudioFile = scannedAudioFiles.find(saf => saf.metadata.path === podcastEpisode.audioFile.metadata.path)
let matchedScannedAudioFile = scannedAudioFiles.find((saf) => saf.metadata.path === podcastEpisode.audioFile.metadata.path)
if (!matchedScannedAudioFile) {
matchedScannedAudioFile = scannedAudioFiles.find(saf => saf.ino === podcastEpisode.audioFile.ino)
matchedScannedAudioFile = scannedAudioFiles.find((saf) => saf.ino === podcastEpisode.audioFile.ino)
}
if (matchedScannedAudioFile) {
scannedAudioFiles = scannedAudioFiles.filter(saf => saf !== matchedScannedAudioFile)
scannedAudioFiles = scannedAudioFiles.filter((saf) => saf !== matchedScannedAudioFile)
const audioFile = new AudioFile(podcastEpisode.audioFile)
audioFile.updateFromScan(matchedScannedAudioFile)
podcastEpisode.audioFile = audioFile.toJSON()
@@ -131,15 +137,20 @@ class PodcastScanner {
let hasMediaChanges = false
if (existingPodcastEpisodes.length !== media.numEpisodes) {
media.numEpisodes = existingPodcastEpisodes.length
hasMediaChanges = true
}
// Check if cover was removed
if (media.coverPath && libraryItemData.imageLibraryFilesRemoved.some(lf => lf.metadata.path === media.coverPath)) {
if (media.coverPath && libraryItemData.imageLibraryFilesRemoved.some((lf) => lf.metadata.path === media.coverPath)) {
media.coverPath = null
hasMediaChanges = true
}
// Update cover if it was modified
if (media.coverPath && libraryItemData.imageLibraryFilesModified.length) {
let coverMatch = libraryItemData.imageLibraryFilesModified.find(iFile => iFile.old.metadata.path === media.coverPath)
let coverMatch = libraryItemData.imageLibraryFilesModified.find((iFile) => iFile.old.metadata.path === media.coverPath)
if (coverMatch) {
const coverPath = coverMatch.new.metadata.path
if (coverPath !== media.coverPath) {
@@ -154,7 +165,7 @@ class PodcastScanner {
// Check if cover is not set and image files were found
if (!media.coverPath && libraryItemData.imageLibraryFiles.length) {
// Prefer using a cover image with the name "cover" otherwise use the first image
const coverMatch = libraryItemData.imageLibraryFiles.find(iFile => /\/cover\.[^.\/]*$/.test(iFile.metadata.path))
const coverMatch = libraryItemData.imageLibraryFiles.find((iFile) => /\/cover\.[^.\/]*$/.test(iFile.metadata.path))
media.coverPath = coverMatch?.metadata.path || libraryItemData.imageLibraryFiles[0].metadata.path
hasMediaChanges = true
}
@@ -167,7 +178,7 @@ class PodcastScanner {
if (key === 'genres') {
const existingGenres = media.genres || []
if (podcastMetadata.genres.some(g => !existingGenres.includes(g)) || existingGenres.some(g => !podcastMetadata.genres.includes(g))) {
if (podcastMetadata.genres.some((g) => !existingGenres.includes(g)) || existingGenres.some((g) => !podcastMetadata.genres.includes(g))) {
libraryScan.addLog(LogLevel.DEBUG, `Updating podcast genres "${existingGenres.join(',')}" => "${podcastMetadata.genres.join(',')}" for podcast "${podcastMetadata.title}"`)
media.genres = podcastMetadata.genres
media.changed('genres', true)
@@ -175,7 +186,7 @@ class PodcastScanner {
}
} else if (key === 'tags') {
const existingTags = media.tags || []
if (podcastMetadata.tags.some(t => !existingTags.includes(t)) || existingTags.some(t => !podcastMetadata.tags.includes(t))) {
if (podcastMetadata.tags.some((t) => !existingTags.includes(t)) || existingTags.some((t) => !podcastMetadata.tags.includes(t))) {
libraryScan.addLog(LogLevel.DEBUG, `Updating podcast tags "${existingTags.join(',')}" => "${podcastMetadata.tags.join(',')}" for podcast "${podcastMetadata.title}"`)
media.tags = podcastMetadata.tags
media.changed('tags', true)
@@ -190,7 +201,7 @@ class PodcastScanner {
// If no cover then extract cover from audio file if available
if (!media.coverPath && existingPodcastEpisodes.length) {
const audioFiles = existingPodcastEpisodes.map(ep => ep.audioFile)
const audioFiles = existingPodcastEpisodes.map((ep) => ep.audioFile)
const extractedCoverPath = await CoverManager.saveEmbeddedCoverArt(audioFiles, existingLibraryItem.id, existingLibraryItem.path)
if (extractedCoverPath) {
libraryScan.addLog(LogLevel.DEBUG, `Updating podcast "${podcastMetadata.title}" extracted embedded cover art from audio file to path "${extractedCoverPath}"`)
@@ -222,10 +233,10 @@ class PodcastScanner {
}
/**
*
* @param {import('./LibraryItemScanData')} libraryItemData
*
* @param {import('./LibraryItemScanData')} libraryItemData
* @param {import('../models/Library').LibrarySettingsObject} librarySettings
* @param {import('./LibraryScan')} libraryScan
* @param {import('./LibraryScan')} libraryScan
* @returns {Promise<import('../models/LibraryItem')>}
*/
async scanNewPodcastLibraryItem(libraryItemData, librarySettings, libraryScan) {
@@ -267,7 +278,7 @@ class PodcastScanner {
// Set cover image from library file
if (libraryItemData.imageLibraryFiles.length) {
// Prefer using a cover image with the name "cover" otherwise use the first image
const coverMatch = libraryItemData.imageLibraryFiles.find(iFile => /\/cover\.[^.\/]*$/.test(iFile.metadata.path))
const coverMatch = libraryItemData.imageLibraryFiles.find((iFile) => /\/cover\.[^.\/]*$/.test(iFile.metadata.path))
podcastMetadata.coverPath = coverMatch?.metadata.path || libraryItemData.imageLibraryFiles[0].metadata.path
}
@@ -283,7 +294,8 @@ class PodcastScanner {
lastEpisodeCheck: 0,
maxEpisodesToKeep: 0,
maxNewEpisodesToDownload: 3,
podcastEpisodes: newPodcastEpisodes
podcastEpisodes: newPodcastEpisodes,
numEpisodes: newPodcastEpisodes.length
}
const libraryItemObj = libraryItemData.libraryItemObject
@@ -291,6 +303,8 @@ class PodcastScanner {
libraryItemObj.isMissing = false
libraryItemObj.isInvalid = false
libraryItemObj.extraData = {}
libraryItemObj.title = podcastObject.title
libraryItemObj.titleIgnorePrefix = getTitleIgnorePrefix(podcastObject.title)
// If cover was not found in folder then check embedded covers in audio files
if (!podcastObject.coverPath && scannedAudioFiles.length) {
@@ -324,10 +338,10 @@ class PodcastScanner {
}
/**
*
*
* @param {PodcastEpisode[]} podcastEpisodes Not the models for new podcasts
* @param {import('./LibraryItemScanData')} libraryItemData
* @param {import('./LibraryScan')} libraryScan
* @param {import('./LibraryItemScanData')} libraryItemData
* @param {import('./LibraryScan')} libraryScan
* @param {string} [existingLibraryItemId]
* @returns {Promise<PodcastMetadataObject>}
*/
@@ -364,8 +378,8 @@ class PodcastScanner {
}
/**
*
* @param {import('../models/LibraryItem')} libraryItem
*
* @param {import('../models/LibraryItem')} libraryItem
* @param {import('./LibraryScan')} libraryScan
* @returns {Promise}
*/
@@ -399,41 +413,44 @@ class PodcastScanner {
explicit: !!libraryItem.media.explicit,
podcastType: libraryItem.media.podcastType
}
return fsExtra.writeFile(metadataFilePath, JSON.stringify(jsonObject, null, 2)).then(async () => {
// Add metadata.json to libraryFiles array if it is new
let metadataLibraryFile = libraryItem.libraryFiles.find(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath))
if (storeMetadataWithItem) {
if (!metadataLibraryFile) {
const newLibraryFile = new LibraryFile()
await newLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`)
metadataLibraryFile = newLibraryFile.toJSON()
libraryItem.libraryFiles.push(metadataLibraryFile)
} else {
const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath)
if (fileTimestamps) {
metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs
metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs
metadataLibraryFile.metadata.size = fileTimestamps.size
metadataLibraryFile.ino = fileTimestamps.ino
return fsExtra
.writeFile(metadataFilePath, JSON.stringify(jsonObject, null, 2))
.then(async () => {
// Add metadata.json to libraryFiles array if it is new
let metadataLibraryFile = libraryItem.libraryFiles.find((lf) => lf.metadata.path === filePathToPOSIX(metadataFilePath))
if (storeMetadataWithItem) {
if (!metadataLibraryFile) {
const newLibraryFile = new LibraryFile()
await newLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`)
metadataLibraryFile = newLibraryFile.toJSON()
libraryItem.libraryFiles.push(metadataLibraryFile)
} else {
const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath)
if (fileTimestamps) {
metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs
metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs
metadataLibraryFile.metadata.size = fileTimestamps.size
metadataLibraryFile.ino = fileTimestamps.ino
}
}
const libraryItemDirTimestamps = await getFileTimestampsWithIno(libraryItem.path)
if (libraryItemDirTimestamps) {
libraryItem.mtime = libraryItemDirTimestamps.mtimeMs
libraryItem.ctime = libraryItemDirTimestamps.ctimeMs
let size = 0
libraryItem.libraryFiles.forEach((lf) => (size += !isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0))
libraryItem.size = size
}
}
const libraryItemDirTimestamps = await getFileTimestampsWithIno(libraryItem.path)
if (libraryItemDirTimestamps) {
libraryItem.mtime = libraryItemDirTimestamps.mtimeMs
libraryItem.ctime = libraryItemDirTimestamps.ctimeMs
let size = 0
libraryItem.libraryFiles.forEach((lf) => size += (!isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0))
libraryItem.size = size
}
}
libraryScan.addLog(LogLevel.DEBUG, `Success saving abmetadata to "${metadataFilePath}"`)
libraryScan.addLog(LogLevel.DEBUG, `Success saving abmetadata to "${metadataFilePath}"`)
return metadataLibraryFile
}).catch((error) => {
libraryScan.addLog(LogLevel.ERROR, `Failed to save json file at "${metadataFilePath}"`, error)
return null
})
return metadataLibraryFile
})
.catch((error) => {
libraryScan.addLog(LogLevel.ERROR, `Failed to save json file at "${metadataFilePath}"`, error)
return null
})
}
}
module.exports = new PodcastScanner()
module.exports = new PodcastScanner()

View File

@@ -48,13 +48,7 @@ class Scanner {
let updatePayload = {}
let hasUpdated = false
let existingAuthors = [] // Used for checking if authors or series are now empty
let existingSeries = []
if (libraryItem.isBook) {
existingAuthors = libraryItem.media.authors.map((a) => a.id)
existingSeries = libraryItem.media.series.map((s) => s.id)
const searchISBN = options.isbn || libraryItem.media.isbn
const searchASIN = options.asin || libraryItem.media.asin

View File

@@ -145,15 +145,15 @@ function extractEpisodeData(item) {
if (item.enclosure?.[0]?.['$']?.url) {
enclosure = item.enclosure[0]['$']
} else if(item['media:content']?.find(c => c?.['$']?.url && (c?.['$']?.type ?? "").startsWith("audio"))) {
enclosure = item['media:content'].find(c => (c['$']?.type ?? "").startsWith("audio"))['$']
} else if (item['media:content']?.find((c) => c?.['$']?.url && (c?.['$']?.type ?? '').startsWith('audio'))) {
enclosure = item['media:content'].find((c) => (c['$']?.type ?? '').startsWith('audio'))['$']
} else {
Logger.error(`[podcastUtils] Invalid podcast episode data`)
return null
}
const episode = {
enclosure: enclosure,
enclosure: enclosure
}
episode.enclosure.url = episode.enclosure.url.trim()
@@ -343,6 +343,14 @@ module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => {
return payload.podcast
})
.catch((error) => {
// Check for failures due to redirecting from http to https. If original url was http, upgrade to https and try again
if (error.code === 'ERR_FR_REDIRECTION_FAILURE' && error.cause.code === 'ERR_INVALID_PROTOCOL') {
if (feedUrl.startsWith('http://') && error.request._options.protocol === 'https:') {
Logger.info('Redirection from http to https detected. Upgrading Request', error.request._options.href)
feedUrl = feedUrl.replace('http://', 'https://')
return this.getPodcastFeed(feedUrl, excludeEpisodeMetadata)
}
}
Logger.error('[podcastUtils] getPodcastFeed Error', error)
return null
})

View File

@@ -4,6 +4,7 @@ const Database = require('../../Database')
const libraryItemsBookFilters = require('./libraryItemsBookFilters')
const libraryItemsPodcastFilters = require('./libraryItemsPodcastFilters')
const { createNewSortInstance } = require('../../libs/fastSort')
const { profile } = require('../../utils/profiler')
const naturalSort = createNewSortInstance({
comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
})
@@ -474,7 +475,8 @@ module.exports = {
// Check how many podcasts are in library to determine if we need to load all of the data
// This is done to handle the edge case of podcasts having been deleted and not having
// an updatedAt timestamp to trigger a reload of the filter data
const podcastCountFromDatabase = await Database.podcastModel.count({
const podcastModelCount = process.env.QUERY_PROFILING ? profile(Database.podcastModel.count.bind(Database.podcastModel)) : Database.podcastModel.count.bind(Database.podcastModel)
const podcastCountFromDatabase = await podcastModelCount({
include: {
model: Database.libraryItemModel,
attributes: [],
@@ -489,7 +491,7 @@ module.exports = {
// data was loaded. If so, we can skip loading all of the data.
// Because many items could change, just check the count of items instead
// of actually loading the data twice
const changedPodcasts = await Database.podcastModel.count({
const changedPodcasts = await podcastModelCount({
include: {
model: Database.libraryItemModel,
attributes: [],
@@ -520,7 +522,8 @@ module.exports = {
}
// Something has changed in the podcasts table, so reload all of the filter data for library
const podcasts = await Database.podcastModel.findAll({
const findAll = process.env.QUERY_PROFILING ? profile(Database.podcastModel.findAll.bind(Database.podcastModel)) : Database.podcastModel.findAll.bind(Database.podcastModel)
const podcasts = await findAll({
include: {
model: Database.libraryItemModel,
attributes: [],

View File

@@ -1,6 +1,10 @@
const Sequelize = require('sequelize')
const Database = require('../../Database')
const Logger = require('../../Logger')
const { profile } = require('../../utils/profiler')
const stringifySequelizeQuery = require('../stringifySequelizeQuery')
const countCache = new Map()
module.exports = {
/**
@@ -84,9 +88,9 @@ module.exports = {
return [[Sequelize.literal(`\`podcast\`.\`author\` COLLATE NOCASE ${nullDir}`)]]
} else if (sortBy === 'media.metadata.title') {
if (global.ServerSettings.sortingIgnorePrefix) {
return [[Sequelize.literal('`podcast`.`titleIgnorePrefix` COLLATE NOCASE'), dir]]
return [[Sequelize.literal('`libraryItem`.`titleIgnorePrefix` COLLATE NOCASE'), dir]]
} else {
return [[Sequelize.literal('`podcast`.`title` COLLATE NOCASE'), dir]]
return [[Sequelize.literal('`libraryItem`.`title` COLLATE NOCASE'), dir]]
}
} else if (sortBy === 'media.numTracks') {
return [['numEpisodes', dir]]
@@ -96,6 +100,29 @@ module.exports = {
return []
},
clearCountCache(model, hook) {
Logger.debug(`[LibraryItemsPodcastFilters] ${model}.${hook}: Clearing count cache`)
countCache.clear()
},
async findAndCountAll(findOptions, model, limit, offset) {
const cacheKey = stringifySequelizeQuery(findOptions)
if (!countCache.has(cacheKey)) {
const count = await model.count(findOptions)
countCache.set(cacheKey, count)
}
findOptions.limit = limit
findOptions.offset = offset
const rows = await model.findAll(findOptions)
return {
rows,
count: countCache.get(cacheKey)
}
},
/**
* Get library items for podcast media type using filter and sort
* @param {string} libraryId
@@ -120,7 +147,8 @@ module.exports = {
if (includeRSSFeed) {
libraryItemIncludes.push({
model: Database.feedModel,
required: filterGroup === 'feed-open'
required: filterGroup === 'feed-open',
separate: true
})
}
if (filterGroup === 'issues') {
@@ -139,9 +167,6 @@ module.exports = {
}
const podcastIncludes = []
if (includeNumEpisodesIncomplete) {
podcastIncludes.push([Sequelize.literal(`(SELECT count(*) FROM podcastEpisodes pe LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = pe.id AND mp.userId = :userId WHERE pe.podcastId = podcast.id AND (mp.isFinished = 0 OR mp.isFinished IS NULL))`), 'numEpisodesIncomplete'])
}
let { mediaWhere, replacements } = this.getMediaGroupQuery(filterGroup, filterValue)
replacements.userId = user.id
@@ -153,12 +178,12 @@ module.exports = {
replacements = { ...replacements, ...userPermissionPodcastWhere.replacements }
podcastWhere.push(...userPermissionPodcastWhere.podcastWhere)
const { rows: podcasts, count } = await Database.podcastModel.findAndCountAll({
const findOptions = {
where: podcastWhere,
replacements,
distinct: true,
attributes: {
include: [[Sequelize.literal(`(SELECT count(*) FROM podcastEpisodes pe WHERE pe.podcastId = podcast.id)`), 'numEpisodes'], ...podcastIncludes]
include: [...podcastIncludes]
},
include: [
{
@@ -169,10 +194,12 @@ module.exports = {
}
],
order: this.getOrder(sortBy, sortDesc),
subQuery: false,
limit: limit || null,
offset
})
subQuery: false
}
const findAndCountAll = process.env.QUERY_PROFILING ? profile(this.findAndCountAll) : this.findAndCountAll
const { rows: podcasts, count } = await findAndCountAll(findOptions, Database.podcastModel, limit, offset)
const libraryItems = podcasts.map((podcastExpanded) => {
const libraryItem = podcastExpanded.libraryItem
@@ -183,11 +210,15 @@ module.exports = {
if (libraryItem.feeds?.length) {
libraryItem.rssFeed = libraryItem.feeds[0]
}
if (podcast.dataValues.numEpisodesIncomplete) {
libraryItem.numEpisodesIncomplete = podcast.dataValues.numEpisodesIncomplete
}
if (podcast.dataValues.numEpisodes) {
podcast.numEpisodes = podcast.dataValues.numEpisodes
if (includeNumEpisodesIncomplete) {
const numEpisodesComplete = user.mediaProgresses.reduce((acc, mp) => {
if (mp.podcastId === podcast.id && mp.isFinished) {
acc += 1
}
return acc
}, 0)
libraryItem.numEpisodesIncomplete = podcast.numEpisodes - numEpisodesComplete
}
libraryItem.media = podcast
@@ -268,28 +299,31 @@ module.exports = {
const userPermissionPodcastWhere = this.getUserPermissionPodcastWhereQuery(user)
const { rows: podcastEpisodes, count } = await Database.podcastEpisodeModel.findAndCountAll({
const findOptions = {
where: podcastEpisodeWhere,
replacements: userPermissionPodcastWhere.replacements,
include: [
{
model: Database.podcastModel,
required: true,
where: userPermissionPodcastWhere.podcastWhere,
include: [
{
model: Database.libraryItemModel,
required: true,
where: libraryItemWhere
}
]
},
...podcastEpisodeIncludes
],
distinct: true,
subQuery: false,
order: podcastEpisodeOrder,
limit,
offset
})
order: podcastEpisodeOrder
}
const findAndCountAll = process.env.QUERY_PROFILING ? profile(this.findAndCountAll) : this.findAndCountAll
const { rows: podcastEpisodes, count } = await findAndCountAll(findOptions, Database.podcastEpisodeModel, limit, offset)
const libraryItems = podcastEpisodes.map((ep) => {
const libraryItem = ep.podcast.libraryItem

View File

@@ -1,34 +1,25 @@
function stringifySequelizeQuery(findOptions) {
// Helper function to handle symbols in nested objects
function handleSymbols(obj) {
if (!obj || typeof obj !== 'object') return obj
if (Array.isArray(obj)) {
return obj.map(handleSymbols)
}
const newObj = {}
for (const [key, value] of Object.entries(obj)) {
// Handle Symbol keys from Object.getOwnPropertySymbols
Object.getOwnPropertySymbols(obj).forEach((sym) => {
newObj[`__Op.${sym.toString()}`] = handleSymbols(obj[sym])
})
// Handle regular keys
if (typeof key === 'string') {
if (value && typeof value === 'object' && Object.getPrototypeOf(value) === Symbol.prototype) {
// Handle Symbol values
newObj[key] = `__Op.${value.toString()}`
} else {
// Recursively handle nested objects
newObj[key] = handleSymbols(value)
}
}
}
return newObj
function isClass(func) {
return typeof func === 'function' && /^class\s/.test(func.toString())
}
const sanitizedOptions = handleSymbols(findOptions)
return JSON.stringify(sanitizedOptions)
function replacer(key, value) {
if (typeof value === 'object' && value !== null) {
const symbols = Object.getOwnPropertySymbols(value).reduce((acc, sym) => {
acc[sym.toString()] = value[sym]
return acc
}, {})
return { ...value, ...symbols }
}
if (isClass(value)) {
return `${value.name}`
}
return value
}
return JSON.stringify(findOptions, replacer)
}
module.exports = stringifySequelizeQuery

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

View File

@@ -0,0 +1,52 @@
const { expect } = require('chai')
const stringifySequelizeQuery = require('../../../server/utils/stringifySequelizeQuery')
const Sequelize = require('sequelize')
class DummyClass {}
describe('stringifySequelizeQuery', () => {
it('should stringify a sequelize query containing an op', () => {
const query = {
where: {
name: 'John',
age: {
[Sequelize.Op.gt]: 20
}
}
}
const result = stringifySequelizeQuery(query)
expect(result).to.equal('{"where":{"name":"John","age":{"Symbol(gt)":20}}}')
})
it('should stringify a sequelize query containing a literal', () => {
const query = {
order: [[Sequelize.literal('libraryItem.title'), 'ASC']]
}
const result = stringifySequelizeQuery(query)
expect(result).to.equal('{"order":{"0":{"0":{"val":"libraryItem.title"},"1":"ASC"}}}')
})
it('should stringify a sequelize query containing a class', () => {
const query = {
include: [
{
model: DummyClass
}
]
}
const result = stringifySequelizeQuery(query)
expect(result).to.equal('{"include":{"0":{"model":"DummyClass"}}}')
})
it('should ignore non-class functions', () => {
const query = {
logging: (query) => console.log(query)
}
const result = stringifySequelizeQuery(query)
expect(result).to.equal('{}')
})
})