Compare commits

...

113 Commits

Author SHA1 Message Date
advplyr
c29935e57b Update migration manager to validate migration files #4042 2025-03-06 17:24:33 -06:00
advplyr
d41b48c89a Merge pull request #4075 from Vito0912/feat/fixCrashCustomProvider
Fixes search not returning results if description field is not provided by a custom provider
2025-03-04 17:53:58 -06:00
advplyr
b17e6010fd Add validation for custom metadata provider responses 2025-03-04 17:50:40 -06:00
Vito0912
a296ac6132 fix crash 2025-03-04 18:06:58 +01:00
advplyr
5746e848b0 Fix:Trim whitespace from custom metadata provider name & url #4069 2025-03-02 17:13:27 -06:00
advplyr
c6b5d4aa26 Update author by string translation #4017 2025-03-01 17:48:11 -06:00
advplyr
43a507faa8 Merge pull request #4030 from 4ch1m/add_filename_sorting_for_podcasts-view
new sort option for podcasts view (-> sort by filename)
2025-02-28 17:45:43 -06:00
advplyr
828d5d2afc Update episode row to show filename when sorting by filename 2025-02-28 17:42:56 -06:00
advplyr
6075f2686f Merge pull request #3546 from justcallmelarry/master
API PATCH /me/progress/:id - allow providing createdAt and respect provided finishedAt when syncing progress
2025-02-28 17:25:46 -06:00
advplyr
ae3517bcde Merge pull request #4055 from nichwall/2_15_0_migration_fix
Fix: flaky 2.15.0 migration test
2025-02-27 18:28:21 -06:00
Nicholas Wallace
0a00ebcde1 Fix: flaky 2.15.0 migration test 2025-02-26 21:40:56 -07:00
advplyr
68ef0f83e1 Update select all in feed modal to check downloading 2025-02-26 18:00:36 -06:00
advplyr
e4a34b0145 Merge pull request #4041 from nichwall/podcast_queue_no_duplicates
Prevent duplicate episodes from being added to queue
2025-02-26 17:58:27 -06:00
advplyr
0ca65d1f79 Show download icon for queued/downloaded episodes in rss feed modal 2025-02-26 17:56:17 -06:00
advplyr
bd3d396f37 Merge pull request #4035 from nichwall/podcast_episode_play_order
Play first podcast episode in table
2025-02-25 17:31:48 -06:00
advplyr
fd1c8ee513 Update episode list to come from component ref, populate queue from table order when playing episode 2025-02-25 17:25:56 -06:00
advplyr
b0045b5b8b Update browser confirm prompts to use confirm prompt modal instead 2025-02-24 17:44:17 -06:00
Nicholas Wallace
6674189acd Add: prevent duplicates from being added to queue 2025-02-23 19:23:26 -07:00
advplyr
c7d8021a16 Version bump v2.19.5 2025-02-23 17:20:30 -06:00
advplyr
9e83ad25b9 Merge pull request #4015 from weblate/weblate-audiobookshelf-abs-web-client
Translations update from Hosted Weblate
2025-02-23 17:18:50 -06:00
Troja
2eccb9465c Translated using Weblate (Belarusian)
Currently translated at 31.0% (339 of 1093 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/be/
2025-02-24 00:01:03 +01:00
Максим Горпиніч
599b6bd6ad Translated using Weblate (Ukrainian)
Currently translated at 100.0% (1093 of 1093 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/uk/
2025-02-24 00:01:03 +01:00
Jan-Eric Myhrgren
e01ac489fb Translated using Weblate (Swedish)
Currently translated at 92.6% (1013 of 1093 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sv/
2025-02-24 00:01:03 +01:00
biuklija
271dbc4764 Translated using Weblate (Croatian)
Currently translated at 100.0% (1093 of 1093 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/
2025-02-24 00:01:03 +01:00
Vito0912
84c2931434 Translated using Weblate (German)
Currently translated at 99.9% (1092 of 1093 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/
2025-02-24 00:01:03 +01:00
burghy86
38483c9269 Translated using Weblate (Italian)
Currently translated at 100.0% (1092 of 1092 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/it/
2025-02-24 00:01:03 +01:00
mickeynos
b2e97d70df Translated using Weblate (Czech)
Currently translated at 99.5% (1087 of 1092 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/cs/
2025-02-24 00:01:03 +01:00
Troja
78aafe038d Translated using Weblate (Belarusian)
Currently translated at 28.6% (313 of 1092 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/be/
2025-02-24 00:01:03 +01:00
Максим Горпиніч
34f7ddfdd7 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (1092 of 1092 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/uk/
2025-02-24 00:01:03 +01:00
Jan-Eric Myhrgren
0e9777feec Translated using Weblate (Swedish)
Currently translated at 92.0% (1005 of 1092 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sv/
2025-02-24 00:01:03 +01:00
Troja
6351fd8d7b Translated using Weblate (Belarusian)
Currently translated at 25.6% (280 of 1091 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/be/
2025-02-24 00:01:03 +01:00
Максим Горпиніч
b7591abd06 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (1091 of 1091 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/uk/
2025-02-24 00:01:03 +01:00
Michał Rączka-Dudek
2b36caf096 Translated using Weblate (Polish)
Currently translated at 74.5% (812 of 1089 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/pl/
2025-02-24 00:01:03 +01:00
Charlie
f87a0bfc2f Translated using Weblate (French)
Currently translated at 99.6% (1085 of 1089 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fr/
2025-02-24 00:01:03 +01:00
Nicholas W
b109b2edee Added translation using Weblate (Romanian) 2025-02-24 00:01:03 +01:00
Milo Ivir
7795bf25d0 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-24 00:01:03 +01:00
advplyr
3d5c02ae7c Merge pull request #4037 from mikiher/route-to-library-if-last-issue-removed
Route from Issues to Library page after last issue was removed
2025-02-23 17:00:57 -06:00
advplyr
373d14a49e Merge pull request #4034 from nichwall/custom-metadata-provider-logging
Add: log custom metadata provider to match other providers
2025-02-23 16:58:07 -06:00
advplyr
a17127f078 Merge pull request #4031 from nichwall/temp_file_ignore_refactor
Refactor ignore file logic
2025-02-23 16:56:09 -06:00
advplyr
20f812403f Add fileUtils recurseFiles and shouldIgnoreFile tests 2025-02-23 16:53:11 -06:00
advplyr
a864c6bcc6 Merge pull request #4020 from mikiher/invalidate-count-cache-on-entity-update
Invalidate count cache on entity update
2025-02-23 15:21:36 -06:00
mikiher
6c0e42db49 Route from Issues to Library if last issue is removed 2025-02-23 18:06:36 +02:00
mikiher
364ccd85fe Use count cache only when no filter is set 2025-02-23 08:53:57 +02:00
mikiher
d6b58c2f10 Revert "Invalidate count cache on entity update"
This reverts commit e8b60defb6.
2025-02-23 08:03:10 +02:00
Nicholas Wallace
72169990ac Fix: double reverse of array 2025-02-22 22:06:51 -07:00
Nicholas Wallace
5f105dc6cc Change: Play button for podcast picks first episode in table 2025-02-22 21:50:37 -07:00
Nicholas Wallace
706b2d7d72 Add: store for filtered podcast episodes 2025-02-22 21:50:09 -07:00
advplyr
64185b7519 Add backup schedule string translation #4017 2025-02-22 17:53:05 -06:00
advplyr
e1b3b657c4 Merge pull request #4027 from Alexshch09/Add-admin-auth-to-LibraryController
fix(auth): Add admin-level auth to LibraryController 'delete', 'update' and 'delete items with issues'
2025-02-22 17:45:38 -06:00
Nicholas Wallace
4662fc5244 Add: log custom metadata provider to match other providers 2025-02-22 14:48:13 -07:00
Nicholas Wallace
13c20e0cdd Add: generic function to ignor files 2025-02-22 12:28:51 -07:00
Achim
007691ffe5 add "sort by filename" 2025-02-22 17:08:29 +01:00
advplyr
19a65dba98 Update backup schedule description translations #4017 2025-02-21 18:18:54 -06:00
Mike Smith
799879d67d prevent long author strings from pushing the player controls down by truncating (#3944)
* prevent long author strings from pushing the player controls down by truncating

* move truncate to single author, instead of the main container
2025-02-21 17:45:29 -06:00
alexshch09
452d354b52 fix(auth): Add admin-level auth to LibraryController delete update and issue removal 2025-02-22 00:44:52 +01:00
advplyr
9d7f44f73a Fix RSS Feed Open query 2025-02-21 17:39:36 -06:00
mikiher
e8b60defb6 Invalidate count cache on entity update 2025-02-21 09:45:10 +02:00
advplyr
0cc2e39367 Update en-us string order 2025-02-20 17:59:09 -06:00
advplyr
a34b01fcb4 Add localization strings for Cover Provider and Activities #4017 2025-02-20 17:45:33 -06:00
advplyr
7919a8b581 Fix get podcast library items endpoint when not including a limit query param #4014 2025-02-20 17:40:54 -06:00
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
c4d99a118f Fix chapter end sleep timer sometimes not stopping #3969 2025-02-14 16:24:39 -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
advplyr
5ca12eee19 Fix count cache by stringify Symbols #3979 2025-02-13 18:07:59 -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
Lauri Vuorela
2fdab39e27 Merge branch 'advplyr:master' into master 2024-10-29 22:08:01 +01:00
Lauri Vuorela
9b01d11b27 allow setting createdAt and respect set finishedAt when syncing progress 2024-10-22 23:58:09 +02:00
61 changed files with 2064 additions and 462 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)
@@ -554,6 +568,18 @@ export default {
}
}
},
routeToBookshelfIfLastIssueRemoved() {
if (this.totalEntities === 0) {
const currentRouteQuery = this.$route.query
if (currentRouteQuery?.filter && currentRouteQuery.filter === 'issues') {
this.$nextTick(() => {
console.log('Last issue removed. Redirecting to library bookshelf')
this.$router.push(`/library/${this.currentLibraryId}/bookshelf`)
this.$store.dispatch('libraries/fetch', this.currentLibraryId)
})
}
}
},
libraryItemRemoved(libraryItem) {
if (this.entityName === 'items' || this.entityName === 'series-books') {
var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id)
@@ -564,6 +590,7 @@ export default {
this.executeRebuild()
}
}
this.routeToBookshelfIfLastIssueRemoved()
},
libraryItemsAdded(libraryItems) {
console.log('items added', libraryItems)

View File

@@ -13,7 +13,7 @@
</div>
<div class="text-gray-400 flex items-center w-1/2 sm:w-4/5 lg:w-2/5">
<span class="material-symbols text-sm">person</span>
<div v-if="podcastAuthor" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ podcastAuthor }}</div>
<div v-if="podcastAuthor" class="pl-1 sm:pl-1.5 text-xs sm:text-base truncate">{{ podcastAuthor }}</div>
<div v-else-if="authors.length" class="pl-1 sm:pl-1.5 text-xs sm:text-base truncate">
<nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">,&nbsp;</span></nuxt-link>
</div>
@@ -85,7 +85,8 @@ export default {
displayTitle: null,
currentPlaybackRate: 1,
syncFailedToast: null,
coverAspectRatio: 1
coverAspectRatio: 1,
lastChapterId: null
}
},
computed: {
@@ -236,12 +237,16 @@ export default {
}
}, 1000)
},
checkChapterEnd(time) {
checkChapterEnd() {
if (!this.currentChapter) return
const chapterEndTime = this.currentChapter.end
const tolerance = 0.75
if (time >= chapterEndTime - tolerance) {
this.sleepTimerEnd()
// Track chapter transitions by comparing current chapter with last chapter
if (this.lastChapterId !== this.currentChapter.id) {
// Chapter changed - if we had a previous chapter, this means we crossed a boundary
if (this.lastChapterId) {
this.sleepTimerEnd()
}
this.lastChapterId = this.currentChapter.id
}
},
sleepTimerEnd() {
@@ -301,7 +306,7 @@ export default {
}
if (this.sleepTimerType === this.$constants.SleepTimerTypes.CHAPTER && this.sleepTimerSet) {
this.checkChapterEnd(time)
this.checkChapterEnd()
}
},
setDuration(duration) {

View File

@@ -10,14 +10,14 @@
<div class="w-full p-8">
<div class="flex mb-2">
<div class="w-3/4 p-1">
<ui-text-input-with-label v-model="newName" :label="$strings.LabelName" />
<ui-text-input-with-label v-model="newName" :label="$strings.LabelName" trim-whitespace />
</div>
<div class="w-1/4 p-1">
<ui-text-input-with-label value="Book" readonly :label="$strings.LabelMediaType" />
</div>
</div>
<div class="w-full mb-2 p-1">
<ui-text-input-with-label v-model="newUrl" label="URL" />
<ui-text-input-with-label v-model="newUrl" label="URL" trim-whitespace />
</div>
<div class="w-full mb-2 p-1">
<ui-text-input-with-label v-model="newAuthHeaderValue" :label="$strings.LabelProviderAuthorizationValue" type="password" />
@@ -65,7 +65,11 @@ export default {
}
},
methods: {
submitForm() {
async submitForm() {
// Remove focus from active input
document.activeElement?.blur?.()
await this.$nextTick()
if (!this.newName || !this.newUrl) {
this.$toast.error(this.$strings.ToastProviderNameAndUrlRequired)
return

View File

@@ -18,7 +18,7 @@
<ui-textarea-with-label v-model="newCollectionDescription" :label="$strings.LabelDescription" />
</div>
</div>
<div class="absolute bottom-0 left-0 right-0 w-full py-2 px-4 flex">
<div class="absolute bottom-0 left-0 right-0 w-full py-4 px-4 flex">
<ui-btn v-if="userCanDelete" small color="error" type="button" @click.stop="removeClick">{{ $strings.ButtonRemove }}</ui-btn>
<div class="flex-grow" />
<ui-btn color="success" type="submit">{{ $strings.ButtonSave }}</ui-btn>
@@ -94,21 +94,32 @@ export default {
this.newCollectionDescription = this.collection.description || ''
},
removeClick() {
if (confirm(this.$getString('MessageConfirmRemoveCollection', [this.collectionName]))) {
this.processing = true
this.$axios
.$delete(`/api/collections/${this.collection.id}`)
.then(() => {
this.processing = false
this.show = false
this.$toast.success(this.$strings.ToastCollectionRemoveSuccess)
})
.catch((error) => {
console.error('Failed to remove collection', error)
this.processing = false
this.$toast.error(this.$strings.ToastRemoveFailed)
})
const payload = {
message: this.$getString('MessageConfirmRemoveCollection', [this.collectionName]),
callback: (confirmed) => {
if (confirmed) {
this.deleteCollection()
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
deleteCollection() {
this.processing = true
this.$axios
.$delete(`/api/collections/${this.collection.id}`)
.then(() => {
this.show = false
this.$toast.success(this.$strings.ToastCollectionRemoveSuccess)
})
.catch((error) => {
console.error('Failed to remove collection', error)
this.$toast.error(this.$strings.ToastRemoveFailed)
})
.finally(() => {
this.processing = false
})
},
submitForm() {
if (this.newCollectionName === this.collectionName && this.newCollectionDescription === this.collection.description) {

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

@@ -16,11 +16,12 @@
v-for="(episode, index) in episodesList"
:key="index"
class="relative"
:class="getIsEpisodeDownloaded(episode) ? 'bg-primary bg-opacity-40' : selectedEpisodes[episode.cleanUrl] ? 'cursor-pointer bg-success bg-opacity-10' : index % 2 == 0 ? 'cursor-pointer bg-primary bg-opacity-25 hover:bg-opacity-40' : 'cursor-pointer bg-primary bg-opacity-5 hover:bg-opacity-25'"
:class="episode.isDownloaded || episode.isDownloading ? 'bg-primary bg-opacity-40' : selectedEpisodes[episode.cleanUrl] ? 'cursor-pointer bg-success bg-opacity-10' : index % 2 == 0 ? 'cursor-pointer bg-primary bg-opacity-25 hover:bg-opacity-40' : 'cursor-pointer bg-primary bg-opacity-5 hover:bg-opacity-25'"
@click="toggleSelectEpisode(episode)"
>
<div class="absolute top-0 left-0 h-full flex items-center p-2">
<span v-if="getIsEpisodeDownloaded(episode)" class="material-symbols text-success text-xl">download_done</span>
<span v-if="episode.isDownloaded" class="material-symbols text-success text-xl">download_done</span>
<span v-else-if="episode.isDownloading" class="material-symbols text-warning text-xl">download</span>
<ui-checkbox v-else v-model="selectedEpisodes[episode.cleanUrl]" small checkbox-bg="primary" border-color="gray-600" />
</div>
<div class="px-8 py-2">
@@ -58,6 +59,14 @@ export default {
episodes: {
type: Array,
default: () => []
},
downloadQueue: {
type: Array,
default: () => []
},
episodesDownloading: {
type: Array,
default: () => []
}
},
data() {
@@ -79,6 +88,21 @@ export default {
handler(newVal) {
if (newVal) this.init()
}
},
episodes: {
handler(newVal) {
if (newVal) this.updateEpisodeDownloadStatuses()
}
},
episodesDownloading: {
handler(newVal) {
if (newVal) this.updateEpisodeDownloadStatuses()
}
},
downloadQueue: {
handler(newVal) {
if (newVal) this.updateEpisodeDownloadStatuses()
}
}
},
computed: {
@@ -132,6 +156,13 @@ export default {
}
return false
},
getIsEpisodeDownloadingOrQueued(episode) {
const episodesToCheck = [...this.episodesDownloading, ...this.downloadQueue]
if (episode.guid) {
return episodesToCheck.some((download) => download.guid === episode.guid)
}
return episodesToCheck.some((download) => this.getCleanEpisodeUrl(download.url) === episode.cleanUrl)
},
/**
* UPDATE: As of v2.4.5 guid is used for matching existing downloaded episodes if it is found on the RSS feed.
* Fallback to checking the clean url
@@ -173,13 +204,13 @@ export default {
},
toggleSelectAll(val) {
for (const episode of this.episodesList) {
if (this.getIsEpisodeDownloaded(episode)) this.selectedEpisodes[episode.cleanUrl] = false
if (episode.isDownloaded || episode.isDownloading) this.selectedEpisodes[episode.cleanUrl] = false
else this.$set(this.selectedEpisodes, episode.cleanUrl, val)
}
},
checkSetIsSelectedAll() {
for (const episode of this.episodesList) {
if (!this.getIsEpisodeDownloaded(episode) && !this.selectedEpisodes[episode.cleanUrl]) {
if (!episode.isDownloaded && !episode.isDownloading && !this.selectedEpisodes[episode.cleanUrl]) {
this.selectAll = false
return
}
@@ -187,7 +218,7 @@ export default {
this.selectAll = true
},
toggleSelectEpisode(episode) {
if (this.getIsEpisodeDownloaded(episode)) return
if (episode.isDownloaded || episode.isDownloading) return
this.$set(this.selectedEpisodes, episode.cleanUrl, !this.selectedEpisodes[episode.cleanUrl])
this.checkSetIsSelectedAll()
},
@@ -223,6 +254,23 @@ export default {
})
},
init() {
this.updateDownloadedEpisodeMaps()
this.episodesCleaned = this.episodes
.filter((ep) => ep.enclosure?.url)
.map((_ep) => {
return {
..._ep,
cleanUrl: this.getCleanEpisodeUrl(_ep.enclosure.url),
isDownloading: this.getIsEpisodeDownloadingOrQueued(_ep),
isDownloaded: this.getIsEpisodeDownloaded(_ep)
}
})
this.episodesCleaned.sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : -1))
this.selectAll = false
this.selectedEpisodes = {}
},
updateDownloadedEpisodeMaps() {
this.downloadedEpisodeGuidMap = {}
this.downloadedEpisodeUrlMap = {}
@@ -230,18 +278,16 @@ export default {
if (episode.guid) this.downloadedEpisodeGuidMap[episode.guid] = episode.id
if (episode.enclosure?.url) this.downloadedEpisodeUrlMap[this.getCleanEpisodeUrl(episode.enclosure.url)] = episode.id
})
this.episodesCleaned = this.episodes
.filter((ep) => ep.enclosure?.url)
.map((_ep) => {
return {
..._ep,
cleanUrl: this.getCleanEpisodeUrl(_ep.enclosure.url)
}
})
this.episodesCleaned.sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : -1))
this.selectAll = false
this.selectedEpisodes = {}
},
updateEpisodeDownloadStatuses() {
this.updateDownloadedEpisodeMaps()
this.episodesCleaned = this.episodesCleaned.map((ep) => {
return {
...ep,
isDownloading: this.getIsEpisodeDownloadingOrQueued(ep),
isDownloaded: this.getIsEpisodeDownloaded(ep)
}
})
}
},
mounted() {}

View File

@@ -28,7 +28,7 @@
<button aria-label="Download Backup" class="inline-flex material-symbols text-xl mx-1 mt-1 text-white/70 hover:text-white/100" @click.stop="downloadBackup(backup)">download</button>
<button aria-label="Delete Backup" class="inline-flex material-symbols text-xl mx-1 text-white/70 hover:text-error" @click="deleteBackupClick(backup)">delete</button>
<button aria-label="Delete Backup" class="inline-flex material-symbols text-xl mx-1 text-white/70 hover:text-error" @click.stop="deleteBackupClick(backup)">delete</button>
</div>
</td>
</tr>
@@ -107,21 +107,32 @@ export default {
})
},
deleteBackupClick(backup) {
if (confirm(this.$getString('MessageConfirmDeleteBackup', [this.$formatDatetime(backup.createdAt, this.dateFormat, this.timeFormat)]))) {
this.processing = true
this.$axios
.$delete(`/api/backups/${backup.id}`)
.then((data) => {
this.setBackups(data.backups || [])
this.$toast.success(this.$strings.ToastBackupDeleteSuccess)
this.processing = false
})
.catch((error) => {
console.error(error)
this.$toast.error(this.$strings.ToastBackupDeleteFailed)
this.processing = false
})
const payload = {
message: this.$getString('MessageConfirmDeleteBackup', [this.$formatDatetime(backup.createdAt, this.dateFormat, this.timeFormat)]),
callback: (confirmed) => {
if (confirmed) {
this.deleteBackup(backup)
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
deleteBackup(backup) {
this.processing = true
this.$axios
.$delete(`/api/backups/${backup.id}`)
.then((data) => {
this.setBackups(data.backups || [])
this.$toast.success(this.$strings.ToastBackupDeleteSuccess)
})
.catch((error) => {
console.error(error)
this.$toast.error(this.$strings.ToastBackupDeleteFailed)
})
.finally(() => {
this.processing = false
})
},
applyBackup(backup) {
this.selectedBackup = backup

View File

@@ -91,24 +91,36 @@ export default {
},
deleteUserClick(user) {
if (this.isDeletingUser) return
if (confirm(this.$getString('MessageRemoveUserWarning', [user.username]))) {
this.isDeletingUser = true
this.$axios
.$delete(`/api/users/${user.id}`)
.then((data) => {
this.isDeletingUser = false
if (data.error) {
this.$toast.error(data.error)
} else {
this.$toast.success(this.$strings.ToastUserDeleteSuccess)
}
})
.catch((error) => {
console.error('Failed to delete user', error)
this.$toast.error(this.$strings.ToastUserDeleteFailed)
this.isDeletingUser = false
})
const payload = {
message: this.$getString('MessageRemoveUserWarning', [user.username]),
callback: (confirmed) => {
if (confirmed) {
this.deleteUser(user)
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
deleteUser(user) {
this.isDeletingUser = true
this.$axios
.$delete(`/api/users/${user.id}`)
.then((data) => {
if (data.error) {
this.$toast.error(data.error)
} else {
this.$toast.success(this.$strings.ToastUserDeleteSuccess)
}
})
.catch((error) => {
console.error('Failed to delete user', error)
this.$toast.error(this.$strings.ToastUserDeleteFailed)
})
.finally(() => {
this.isDeletingUser = false
})
},
editUser(user) {
this.$emit('edit', user)

View File

@@ -10,8 +10,13 @@
<div class="h-10 flex items-center mt-1.5 mb-0.5 overflow-hidden">
<p class="text-sm text-gray-200 line-clamp-2" v-html="episodeSubtitle"></p>
</div>
<div class="h-8 flex items-center">
<div class="w-full inline-flex justify-between max-w-xl">
<p v-if="sortKey === 'audioFile.metadata.filename'" class="text-sm text-gray-300 truncate font-light">
<strong className="font-bold">{{ $strings.LabelFilename }}</strong
>: {{ episode.audioFile.metadata.filename }}
</p>
<div v-else class="w-full inline-flex justify-between max-w-xl">
<p v-if="episode?.season" class="text-sm text-gray-300">{{ $getString('LabelSeasonNumber', [episode.season]) }}</p>
<p v-if="episode?.episode" class="text-sm text-gray-300">{{ $getString('LabelEpisodeNumber', [episode.episode]) }}</p>
<p v-if="episode?.chapters?.length" class="text-sm text-gray-300">{{ $getString('LabelChapterCount', [episode.chapters.length]) }}</p>
@@ -65,7 +70,8 @@ export default {
episode: {
type: Object,
default: () => null
}
},
sortKey: String
},
data() {
return {

View File

@@ -1,3 +1,4 @@
<template>
<div id="lazy-episodes-table" class="w-full py-6">
<div class="flex flex-wrap flex-col md:flex-row md:items-center mb-4">
@@ -123,6 +124,10 @@ export default {
{
text: this.$strings.LabelEpisode,
value: 'episode'
},
{
text: this.$strings.LabelFilename,
value: 'audioFile.metadata.filename'
}
]
},
@@ -171,8 +176,17 @@ export default {
return episodeProgress && !episodeProgress.isFinished
})
.sort((a, b) => {
let aValue = a[this.sortKey]
let bValue = b[this.sortKey]
let aValue
let bValue
if (this.sortKey.includes('.')) {
const getNestedValue = (ob, s) => s.split('.').reduce((o, k) => o?.[k], ob)
aValue = getNestedValue(a, this.sortKey)
bValue = getNestedValue(b, this.sortKey)
} else {
aValue = a[this.sortKey]
bValue = b[this.sortKey]
}
// Sort episodes with no pub date as the oldest
if (this.sortKey === 'publishedAt') {
@@ -361,20 +375,20 @@ export default {
playEpisode(episode) {
const queueItems = []
const episodesInListeningOrder = this.episodesCopy.map((ep) => ({ ...ep })).sort((a, b) => String(a.publishedAt).localeCompare(String(b.publishedAt), undefined, { numeric: true, sensitivity: 'base' }))
const episodesInListeningOrder = this.episodesList
const episodeIndex = episodesInListeningOrder.findIndex((e) => e.id === episode.id)
for (let i = episodeIndex; i < episodesInListeningOrder.length; i++) {
const episode = episodesInListeningOrder[i]
const podcastProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItem.id, episode.id)
if (!podcastProgress || !podcastProgress.isFinished) {
const _episode = episodesInListeningOrder[i]
const podcastProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItem.id, _episode.id)
if (!podcastProgress?.isFinished || episode.id === _episode.id) {
queueItems.push({
libraryItemId: this.libraryItem.id,
libraryId: this.libraryItem.libraryId,
episodeId: episode.id,
title: episode.title,
episodeId: _episode.id,
title: _episode.title,
subtitle: this.mediaMetadata.title,
caption: episode.publishedAt ? this.$getString('LabelPublishedDate', [this.$formatDate(episode.publishedAt, this.dateFormat)]) : this.$strings.LabelUnknownPublishDate,
duration: episode.audioFile.duration || null,
caption: _episode.publishedAt ? this.$getString('LabelPublishedDate', [this.$formatDate(_episode.publishedAt, this.dateFormat)]) : this.$strings.LabelUnknownPublishDate,
duration: _episode.audioFile.duration || null,
coverPath: this.media.coverPath || null
})
}
@@ -440,7 +454,8 @@ export default {
propsData: {
index,
libraryItemId: this.libraryItem.id,
episode: this.episodesList[index]
episode: this.episodesList[index],
sortKey: this.sortKey
},
created() {
this.$on('selected', (payload) => {

View File

@@ -5,8 +5,8 @@
<ui-tooltip v-if="tasksRunning" :text="$strings.LabelTasks" direction="bottom" class="flex items-center">
<widgets-loading-spinner />
</ui-tooltip>
<ui-tooltip v-else text="Activities" direction="bottom" class="flex items-center">
<span class="material-symbols text-1.5xl" aria-label="Activities" role="button">notifications</span>
<ui-tooltip v-else :text="$strings.LabelActivities" direction="bottom" class="flex items-center">
<span class="material-symbols text-1.5xl" :aria-label="$strings.LabelActivities" role="button">notifications</span>
</ui-tooltip>
</div>
<div v-if="showUnseenSuccessIndicator" class="w-2 h-2 rounded-full bg-success pointer-events-none absolute -top-1 -right-0.5" />

View File

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

View File

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

View File

@@ -176,21 +176,31 @@ export default {
this.$store.commit('globals/setEditCollection', this.collection)
},
removeClick() {
if (confirm(this.$getString('MessageConfirmRemoveCollection', [this.collectionName]))) {
this.processing = true
this.$axios
.$delete(`/api/collections/${this.collection.id}`)
.then(() => {
this.$toast.success(this.$strings.ToastCollectionRemoveSuccess)
})
.catch((error) => {
console.error('Failed to remove collection', error)
this.$toast.error(this.$strings.ToastCollectionRemoveFailed)
})
.finally(() => {
this.processing = false
})
const payload = {
message: this.$getString('MessageConfirmRemoveCollection', [this.collectionName]),
callback: (confirmed) => {
if (confirmed) {
this.deleteCollection()
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
deleteCollection() {
this.processing = true
this.$axios
.$delete(`/api/collections/${this.collection.id}`)
.then(() => {
this.$toast.success(this.$strings.ToastCollectionRemoveSuccess)
})
.catch((error) => {
console.error('Failed to remove collection', error)
this.$toast.error(this.$strings.ToastCollectionRemoveFailed)
})
.finally(() => {
this.processing = false
})
},
clickPlay() {
const queueItems = []

View File

@@ -122,7 +122,7 @@ export default {
},
scheduleDescription() {
if (!this.cronExpression) return ''
const parsed = this.$parseCronExpression(this.cronExpression)
const parsed = this.$parseCronExpression(this.cronExpression, this)
return parsed ? parsed.description : `${this.$strings.LabelCustomCronExpression} ${this.cronExpression}`
},
nextBackupDate() {

View File

@@ -67,7 +67,7 @@
<div class="flex-grow" />
</div>
<div v-if="newServerSettings.scannerFindCovers" class="w-44 ml-14 mb-2">
<ui-dropdown v-model="newServerSettings.scannerCoverProvider" small :items="providers" label="Cover Provider" @input="updateScannerCoverProvider" :disabled="updatingServerSettings" />
<ui-dropdown v-model="newServerSettings.scannerCoverProvider" small :items="providers" :label="$strings.LabelCoverProvider" @input="updateScannerCoverProvider" :disabled="updatingServerSettings" />
</div>
<div role="article" :aria-label="$strings.LabelSettingsPreferMatchedMetadataHelp" class="flex items-center py-2">

View File

@@ -41,7 +41,7 @@
<p v-if="isPodcast" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">{{ $getString('LabelByAuthor', [podcastAuthor]) }}</p>
<p v-else-if="authors.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl max-w-[calc(100vw-2rem)] overflow-hidden overflow-ellipsis">
by <nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">,&nbsp;</span></nuxt-link>
{{ $getString('LabelByAuthor', ['']) }}<nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">,&nbsp;</span></nuxt-link>
</p>
<p v-else class="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</p>
@@ -132,7 +132,7 @@
<tables-tracks-table v-if="tracks.length" :title="$strings.LabelStatsAudioTracks" :tracks="tracksWithAudioFile" :is-file="isFile" :library-item-id="libraryItemId" class="mt-6" />
<tables-podcast-lazy-episodes-table v-if="isPodcast" :library-item="libraryItem" />
<tables-podcast-lazy-episodes-table ref="episodesTable" v-if="isPodcast" :library-item="libraryItem" />
<tables-ebook-files-table v-if="ebookFiles.length" :library-item="libraryItem" class="mt-6" />
@@ -141,7 +141,7 @@
</div>
</div>
<modals-podcast-episode-feed v-model="showPodcastEpisodeFeed" :library-item="libraryItem" :episodes="podcastFeedEpisodes" />
<modals-podcast-episode-feed v-model="showPodcastEpisodeFeed" :library-item="libraryItem" :episodes="podcastFeedEpisodes" :download-queue="episodeDownloadsQueued" :episodes-downloading="episodesDownloading" />
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :playback-rate="1" :library-item-id="libraryItemId" hide-create @select="selectBookmark" />
</div>
</template>
@@ -534,13 +534,15 @@ export default {
let episodeId = null
const queueItems = []
if (this.isPodcast) {
const episodesInListeningOrder = this.podcastEpisodes.map((ep) => ({ ...ep })).sort((a, b) => String(a.publishedAt).localeCompare(String(b.publishedAt), undefined, { numeric: true, sensitivity: 'base' }))
// Uses the sorting and filtering from the episode table component
const episodesInListeningOrder = this.$refs.episodesTable?.episodesList || []
// Find most recent episode unplayed
let episodeIndex = episodesInListeningOrder.findLastIndex((ep) => {
// Find the first unplayed episode from the table
let episodeIndex = episodesInListeningOrder.findIndex((ep) => {
const podcastProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, ep.id)
return !podcastProgress || !podcastProgress.isFinished
})
// If all episodes are played, use the first episode
if (episodeIndex < 0) episodeIndex = 0
episodeId = episodesInListeningOrder[episodeIndex].id
@@ -599,19 +601,31 @@ export default {
},
clearProgressClick() {
if (!this.userMediaProgress) return
if (confirm(this.$strings.MessageConfirmResetProgress)) {
this.resettingProgress = true
this.$axios
.$delete(`/api/me/progress/${this.userMediaProgress.id}`)
.then(() => {
console.log('Progress reset complete')
this.resettingProgress = false
})
.catch((error) => {
console.error('Progress reset failed', error)
this.resettingProgress = false
})
const payload = {
message: this.$strings.MessageConfirmResetProgress,
callback: (confirmed) => {
if (confirmed) {
this.clearProgress()
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
clearProgress() {
this.resettingProgress = true
this.$axios
.$delete(`/api/me/progress/${this.userMediaProgress.id}`)
.then(() => {
console.log('Progress reset complete')
})
.catch((error) => {
console.error('Progress reset failed', error)
})
.finally(() => {
this.resettingProgress = false
})
},
clickRSSFeed() {
this.$store.commit('globals/setRSSFeedOpenCloseModal', {
@@ -646,13 +660,11 @@ export default {
},
rssFeedOpen(data) {
if (data.entityId === this.libraryItemId) {
console.log('RSS Feed Opened', data)
this.rssFeed = data
}
},
rssFeedClosed(data) {
if (data.entityId === this.libraryItemId) {
console.log('RSS Feed Closed', data)
this.rssFeed = null
}
},

View File

@@ -107,6 +107,19 @@ Vue.prototype.$formatNumber = (num) => {
return Intl.NumberFormat(Vue.prototype.$languageCodes.current).format(num)
}
/**
* Get the days of the week for the current language
* Starts with Sunday
* @returns {string[]}
*/
Vue.prototype.$getDaysOfWeek = () => {
const days = []
for (let i = 0; i < 7; i++) {
days.push(new Date(2025, 0, 5 + i).toLocaleString(Vue.prototype.$languageCodes.current, { weekday: 'long' }))
}
return days
}
const translations = {
[defaultCode]: enUsStrings
}
@@ -148,6 +161,7 @@ async function loadi18n(code) {
Vue.prototype.$setDateFnsLocale(languageCodeMap[code].dateFnsLocale)
this?.$eventBus?.$emit('change-lang', code)
return true
}

View File

@@ -93,7 +93,7 @@ Vue.prototype.$elapsedPrettyExtended = (seconds, useDays = true, showSeconds = t
return strs.join(' ')
}
Vue.prototype.$parseCronExpression = (expression) => {
Vue.prototype.$parseCronExpression = (expression, context) => {
if (!expression) return null
const pieces = expression.split(' ')
if (pieces.length !== 5) {
@@ -102,31 +102,31 @@ Vue.prototype.$parseCronExpression = (expression) => {
const commonPatterns = [
{
text: 'Every 12 hours',
text: context.$strings.LabelIntervalEvery12Hours,
value: '0 */12 * * *'
},
{
text: 'Every 6 hours',
text: context.$strings.LabelIntervalEvery6Hours,
value: '0 */6 * * *'
},
{
text: 'Every 2 hours',
text: context.$strings.LabelIntervalEvery2Hours,
value: '0 */2 * * *'
},
{
text: 'Every hour',
text: context.$strings.LabelIntervalEveryHour,
value: '0 * * * *'
},
{
text: 'Every 30 minutes',
text: context.$strings.LabelIntervalEvery30Minutes,
value: '*/30 * * * *'
},
{
text: 'Every 15 minutes',
text: context.$strings.LabelIntervalEvery15Minutes,
value: '*/15 * * * *'
},
{
text: 'Every minute',
text: context.$strings.LabelIntervalEveryMinute,
value: '* * * * *'
}
]
@@ -147,7 +147,7 @@ Vue.prototype.$parseCronExpression = (expression) => {
return null
}
const weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
const weekdays = context.$getDaysOfWeek()
var weekdayText = 'day'
if (pieces[4] !== '*')
weekdayText = pieces[4]
@@ -156,7 +156,7 @@ Vue.prototype.$parseCronExpression = (expression) => {
.join(', ')
return {
description: `Run every ${weekdayText} at ${pieces[1]}:${pieces[0].padStart(2, '0')}`
description: context.$getString('MessageScheduleRunEveryWeekdayAtTime', [weekdayText, `${pieces[1]}:${pieces[0].padStart(2, '0')}`])
}
}

View File

@@ -43,7 +43,7 @@
"ButtonLatest": "Апошняе",
"ButtonLibrary": "Бібліятэка",
"ButtonLogout": "Выйсці",
"ButtonLookup": "",
"ButtonLookup": "Пошук",
"ButtonManageTracks": "Кіраванне дарожкамі",
"ButtonMapChapterTitles": "Супаставіць назвы раздзелаў",
"ButtonMatchAllAuthors": "Супадзенне ўсіх аўтараў",
@@ -159,6 +159,15 @@
"HeaderNotificationUpdate": "Абнавіць апавяшчэнне",
"HeaderNotifications": "Апавяшчэнні",
"HeaderOpenListeningSessions": "Адкрыць сеансы праслухоўвання",
"HeaderOpenRSSFeed": "Адкрыць RSS-стужку",
"HeaderPlaylist": "Плэйліст",
"HeaderPlaylistItems": "Элементы плэйліста",
"HeaderRSSFeedGeneral": "Падрабязнасці RSS",
"HeaderRSSFeedIsOpen": "RSS-стужка адкрыта",
"HeaderRSSFeeds": "RSS-стужкі",
"HeaderRemoveEpisode": "Выдаліць эпізод",
"HeaderRemoveEpisodes": "Выдаліць {0} эпізодаў",
"HeaderSavedMediaProgress": "Захаваны прагрэс медыя",
"HeaderScheduleEpisodeDownloads": "Расклад аўтаматычных спамповак эпізодаў",
"HeaderSettings": "Налады",
"HeaderSettingsDisplay": "Дысплей",
@@ -166,50 +175,167 @@
"HeaderSettingsGeneral": "Агульныя",
"HeaderSettingsScanner": "Сканер",
"HeaderSettingsWebClient": "Вэб-кліент",
"HeaderSleepTimer": "Таймер сну",
"HeaderStatsMinutesListeningChart": "Хвіліны праслухоўвання (апошнія 7 дзён)",
"HeaderStatsTop10Authors": "10 лепшых аўтараў",
"HeaderStatsTop5Genres": "5 лепшых жанраў",
"HeaderTableOfContents": "Змест",
"HeaderTools": "Інструменты",
"HeaderUpdateAccount": "Абнавіць уліковы запіс",
"HeaderYourStats": "Ваша статыстыка",
"LabelAccountType": "Тып уліковага запіса",
"LabelAccountTypeAdmin": "Адміністратар",
"LabelAccountTypeGuest": "Госць",
"LabelAccountTypeUser": "Карыстальнік",
"LabelAddToPlaylist": "Дадаць у плэйліст",
"LabelAddedDate": "Дададзена {0}",
"LabelAll": "Усе",
"LabelAudioBitrate": "Бітрэйт аўдыё (напрыклад, 128к)",
"LabelAudioChannels": "Аўдыёканалы (1 або 2)",
"LabelAudioCodec": "Аўдыёкодэк",
"LabelAuthor": "Аўтар",
"LabelAuthorFirstLast": "Аўтар (Імя Прозвішча)",
"LabelAuthorLastFirst": "Аўтар (Прозвішча, Імя)",
"LabelAuthors": "Аўтары",
"LabelAutoDownloadEpisodes": "Аўтаматычнае спампаванне эпізодаў",
"LabelBackupAudioFiles": "Рэзервовае капіраванне аўдыёфайлаў",
"LabelBooks": "Кнігі",
"LabelChapters": "Раздзелы",
"LabelClosePlayer": "Зачыніць прайгравальнік",
"LabelCollapseSeries": "Згарнуць серыі",
"LabelComplete": "Завершана",
"LabelContinueListening": "Працягваць слухаць",
"LabelContinueReading": "Працягнуць чытанне",
"LabelContinueSeries": "Працягнуць серыі",
"LabelDescription": "Апісанне",
"LabelDiscover": "Знайсці",
"LabelDownload": "Спампаваць",
"LabelDownloadNEpisodes": "Спампована {0} эпізодаў",
"LabelDownloadable": "Спампоўваецца",
"LabelDuration": "Працягласць",
"LabelEbook": "Электронная кніга",
"LabelEbooks": "Электронныя кнігі",
"LabelEnable": "Уключыць",
"LabelEncodingBackupLocation": "Рэзервовая копія вашых арыгінальных аўдыёфайлаў будзе захавана ў:",
"LabelEncodingChaptersNotEmbedded": "Раздзелы не ўбудаваны ў шматдарожкавыя аўдыякнігі.",
"LabelEncodingFinishedM4B": "Гатовы файл M4B будзе змешчаны ў вашу тэчку з аўдыякнігамі па адрасе:",
"LabelEncodingInfoEmbedded": "Метаданыя будуць убудаваны ў аўдыядарожкі ўнутры вашай тэчкі з аўдыякнігамі.",
"LabelEnd": "Канец",
"LabelEndOfChapter": "Канец раздзела",
"LabelEpisode": "Эпізод",
"LabelEpisodeNotLinkedToRssFeed": "Эпізод не звязаны з RSS-стужкай",
"LabelEpisodeUrlFromRssFeed": "URL эпізоду з RSS-стужкі",
"LabelFeedURL": "URL стужкі",
"LabelFile": "Файл",
"LabelFileBirthtime": "Час стварэння файла",
"LabelFileModified": "Час змянення файла",
"LabelFilename": "Імя файла",
"LabelFinished": "Скончана",
"LabelFolder": "Тэчка",
"LabelFontBoldness": "Таўшчыня шрыфта",
"LabelFontScale": "Памер шрыфту",
"LabelGenre": "Жанр",
"LabelGenres": "Жанры",
"LabelHasEbook": "Мае электронную кнігу",
"LabelHasSupplementaryEbook": "Мае дадатковую электронную кнігу",
"LabelHost": "Хост",
"LabelInProgress": "У працэсе",
"LabelIncomplete": "Незавершана",
"LabelLanguage": "Мова",
"LabelLayoutSinglePage": "Аднабаковы",
"LabelLineSpacing": "Міжрадковы інтэрвал",
"LabelListenAgain": "Паслухаць зноў",
"LabelMaxEpisodesToDownload": "Максімальная колькасць эпізодаў для спампоўкі. Выкарыстоўвайце 0 для неабмежаванай колькасці.",
"LabelMaxEpisodesToDownloadPerCheck": "Максімальная колькасць новых эпізодаў для спампоўкі за праверку",
"LabelMaxEpisodesToKeepHelp": "Значэнне 0 не ўстанаўлівае максімальнага абмежавання. Пасля аўтаматычнай спампоўкі новага эпізоду будзе выдалены самы стары эпізод, калі ў вас больш за X эпізодаў. Пры кожнай новай спампоўцы будзе выдаляцца толькі 1 эпізод.",
"LabelMediaPlayer": "Медыяплэер",
"LabelMediaType": "Тып медыя",
"LabelMissing": "Адсутнічае",
"LabelMore": "Больш",
"LabelMoreInfo": "Больш інфармацыі",
"LabelName": "Імя",
"LabelNarrator": "Чытальнік",
"LabelNarrators": "Чытальнікі",
"LabelOpenRSSFeed": "Адкрыць RSS-стужку",
"LabelPermissionsDownload": "Можна спампаваць",
"LabelPreventIndexing": "Прадухіліць індэксацыю вашай стужкі каталогамі падкастаў iTunes і Google",
"LabelRSSFeedCustomOwnerEmail": "Карыстальніцкая электронная пошта ўладальніка",
"LabelRSSFeedCustomOwnerName": "Карыстальніцкае імя ўладальніка",
"LabelRSSFeedOpen": "RSS-стужка адкрытая",
"LabelRSSFeedPreventIndexing": "Прадухіліць індэксацыю",
"LabelRSSFeedURL": "URL RSS-стужкі",
"LabelReAddSeriesToContinueListening": "Дадаць серыю зноў у Працягваць слухаць",
"LabelRecentSeries": "Апошнія серыі",
"LabelSeries": "Серыі",
"LabelSetEbookAsPrimary": "Зрабіць асноўным",
"LabelSetEbookAsSupplementary": "Зрабіць дадатковым",
"LabelSettingsExperimentalFeaturesHelp": "Функцыі ў распрацоўцы, для якіх вашы водгукі і дапамога ў тэставанні будуць карыснымі. Націсніце, каб адкрыць абмеркаванне на GitHub.",
"LabelSettingsLibraryMarkAsFinishedWhen": "Пазначыць элемент медыя як скончаны, калі",
"LabelShareDownloadableHelp": "Дазваляе карыстальнікам, якія маюць спасылку на доступ, спампаваць ZIP-файл элемента бібліятэкі.",
"LabelShowAll": "Паказаць усё",
"LabelSize": "Памер",
"LabelStatsAudioTracks": "Аўдыядарожкі",
"LabelTracks": "Дарожкі",
"MessageBookshelfNoRSSFeeds": "Няма адкрытых RSS-стужак",
"MessageConfirmCloseFeed": "Вы ўпэўнены, што жадаеце закрыць гэтую стужку?",
"MessageConfirmRemoveListeningSessions": "Вы ўпэўнены, што жадаеце выдаліць {0} сеансаў праслухоўвання?",
"MessageDownloadingEpisode": "Спампоўка эпізоду",
"MessageEpisodesQueuedForDownload": "{0} эпізод(аў) у чарзе для спампоўкі",
"MessageFeedURLWillBe": "URL стужкі будзе {0}",
"MessageNoChapters": "Няма раздзелаў",
"MessageNoDownloadsInProgress": "Зараз няма актыўных спамповак",
"MessageNoDownloadsQueued": "Няма спамповак у чарзе",
"MessageNoListeningSessions": "Няма сеансаў праслухоўвання",
"MessageNoMediaProgress": "Няма прагрэсу медыя",
"MessageNoPodcastFeed": "Няправільны падкаст: Няма стужкі",
"MessageOpmlPreviewNote": "Заўвага: гэта папярэдні прагляд разабранага OPML-файла. Фактычная назва падкаста будзе ўзятая з RSS-стужкі.",
"MessagePodcastHasNoRSSFeedForMatching": "У падкаста няма URL RSS-стужкі для супадзення",
"MessagePodcastSearchField": "Увядзіце пошукавы запыт або URL RSS-стужкі",
"MessageTaskDownloadingEpisodeDescription": "Спампоўка эпізоду \"{0}\"",
"MessageTaskOpmlImportDescription": "Стварэнне падкастаў з {0} RSS-стужак",
"MessageTaskOpmlImportFeed": "Імпарт стужкі з OPML",
"MessageTaskOpmlImportFeedDescription": "Імпартаванне RSS-стужкі \"{0}\"",
"MessageTaskOpmlImportFeedFailed": "Не ўдалося атрымаць стужку падкаста",
"MessageTaskOpmlImportFeedPodcastDescription": "Стварэнне падкаста \"{0}\"",
"MessageTaskOpmlImportFeedPodcastExists": "Падкаст ужо існуе па гэтым шляху",
"MessageTaskOpmlImportFeedPodcastFailed": "Не ўдалося стварыць падкаст",
"MessageTaskOpmlParseNoneFound": "У OPML-файле не знойдзена стужак",
"NoteRSSFeedPodcastAppsHttps": "Папярэджанне: большасць праграм для падкастаў патрабуюць, каб URL RSS-стужкі выкарыстоўваў HTTPS",
"NoteRSSFeedPodcastAppsPubDate": "Папярэджанне: адзін ці больш вашых эпізодаў не маюць даты публікацыі. Некаторыя праграмы для падкастаў патрабуюць гэтага.",
"NoteUploaderFoldersWithMediaFiles": "Тэчкі з медыяфайламі будуць апрацоўвацца як асобныя элементы бібліятэкі.",
"NotificationOnEpisodeDownloadedDescription": "Выклікаецца, калі эпізод падкаста аўтаматычна спампоўваецца",
"ToastAccountUpdateSuccess": "Уліковы запіс абноўлены",
"ToastEpisodeDownloadQueueClearFailed": "Не ўдалося ачысціць чаргу",
"ToastEpisodeDownloadQueueClearSuccess": "Чарга спампоўкі эпізодаў ачышчана",
"ToastInvalidMaxEpisodesToDownload": "Няправільная максімальная колькасць эпізодаў для спампоўкі",
"ToastItemMarkedAsFinishedFailed": "Не ўдалося пазначыць як Скончана",
"ToastItemMarkedAsFinishedSuccess": "Элемент пазначаны як Завершаны",
"ToastItemMarkedAsNotFinishedFailed": "Не ўдалося пазначыць як Незавершанае",
"ToastItemMarkedAsNotFinishedSuccess": "Элемент пазначаны як Незавершаны",
"ToastItemUpdateSuccess": "Элемент абноўлены",
"ToastLibraryCreateFailed": "Не ўдалося стварыць бібліятэку",
"ToastLibraryCreateSuccess": "Бібліятэка \"{0}\" створана",
"ToastLibraryDeleteFailed": "Не ўдалося выдаліць бібліятэку",
"ToastLibraryDeleteSuccess": "Бібліятэка выдалена",
"ToastLibraryScanFailedToStart": "Не ўдалося запусціць сканаванне",
"ToastLibraryScanStarted": "Сканаванне бібліятэкі запушчана",
"ToastLibraryUpdateSuccess": "Бібліятэка \"{0}\" абноўлена",
"ToastMatchAllAuthorsFailed": "Не ўдалося знайсці адпаведнасць для ўсіх аўтараў",
"ToastMetadataFilesRemovedError": "Памылка пры выдаленні metadata.{0} файлаў",
"ToastMetadataFilesRemovedNoneFound": "У бібліятэцы не знойдзены metadata.{0} файлаў",
"ToastMetadataFilesRemovedNoneRemoved": "Не выдалена metadata.{0} файлаў",
"ToastMetadataFilesRemovedSuccess": "{0} metadata.{1} файлаў выдалена",
"ToastMustHaveAtLeastOnePath": "Павінен быць хаця б адзін шлях",
"ToastNameEmailRequired": "Імя і электронная пошта абавязковыя",
"ToastNameRequired": "Імя абавязковае",
"ToastNewUserCreatedFailed": "Не ўдалося стварыць уліковы запіс: \"{0}\"",
"ToastNewUserCreatedSuccess": "Новы ўліковы запіс створаны",
"ToastNoRSSFeed": "У падкаста няма RSS-стужкі",
"ToastPodcastGetFeedFailed": "Не ўдалося атрымаць стужку падкаста",
"ToastPodcastNoEpisodesInFeed": "У RSS-стужцы не знойдзена эпізодаў",
"ToastPodcastNoRssFeed": "У падкаста няма RSS-стужкі",
"ToastRSSFeedCloseFailed": "Не ўдалося закрыць RSS-стужку",
"ToastRSSFeedCloseSuccess": "RSS-стужка закрыта",
"ToastUserPasswordMustChange": "Новы пароль не можа супадаць са старым",
"ToastUserRootRequireName": "Неабходна ўвесці імя карыстальніка адміністратара"
}

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

@@ -217,6 +217,7 @@
"LabelAccountTypeAdmin": "Správce",
"LabelAccountTypeGuest": "Host",
"LabelAccountTypeUser": "Uživatel",
"LabelActivities": "Aktivity",
"LabelActivity": "Aktivita",
"LabelAddToCollection": "Přidat do kolekce",
"LabelAddToCollectionBatch": "Přidat {0} knihy do kolekce",
@@ -389,6 +390,7 @@
"LabelIntervalEvery6Hours": "Každých 6 hodin",
"LabelIntervalEveryDay": "Každý den",
"LabelIntervalEveryHour": "Každou hodinu",
"LabelIntervalEveryMinute": "Každou minutu",
"LabelInvert": "Invertovat",
"LabelItem": "Položka",
"LabelJumpBackwardAmount": "Přeskočit zpět o",
@@ -484,6 +486,7 @@
"LabelPersonalYearReview": "Váš přehled roku ({0})",
"LabelPhotoPathURL": "Cesta k fotografii/URL",
"LabelPlayMethod": "Metoda přehrávání",
"LabelPlaybackRateIncrementDecrement": "Velikost kroku pro změnu rychlosti přehrávání",
"LabelPlayerChapterNumberMarker": "{0} z {1}",
"LabelPlaylists": "Seznamy skladeb",
"LabelPodcast": "Podcast",
@@ -706,6 +709,7 @@
"MessageBackupsLocationPathEmpty": "Umístění záloh nemůže být prázdné",
"MessageBatchQuickMatchDescription": "Rychlá párování se pokusí přidat chybějící obálky a metadata pro vybrané položky. Povolením níže uvedených možností umožníte funkci Rychlé párování přepsat stávající obálky a/nebo metadata.",
"MessageBookshelfNoCollections": "Ještě jste nevytvořili žádnou sbírku",
"MessageBookshelfNoCollectionsHelp": "Kolekce jsou veřejné. Mohou je zobrazit všichni uživatelé s přístupem do knihovny.",
"MessageBookshelfNoRSSFeeds": "Nejsou otevřeny žádné RSS kanály",
"MessageBookshelfNoResultsForFilter": "Filtr \"{0}: {1}\"",
"MessageBookshelfNoResultsForQuery": "Žádné výsledky pro dotaz",
@@ -816,6 +820,7 @@
"MessageNoTasksRunning": "Nejsou spuštěny žádné úlohy",
"MessageNoUpdatesWereNecessary": "Nebyly nutné žádné aktualizace",
"MessageNoUserPlaylists": "Nemáte žádné seznamy skladeb",
"MessageNoUserPlaylistsHelp": "Seznamy skladeb jsou soukromé. Zobrazit je může pouze uživatel, který je vytvořil.",
"MessageNotYetImplemented": "Ještě není implementováno",
"MessageOpmlPreviewNote": "Poznámka: Toto je náhled načteného OMPL souboru. Aktuální název podcastu bude načten z RSS feedu.",
"MessageOr": "nebo",

View File

@@ -219,7 +219,8 @@
"LabelAccountTypeAdmin": "Admin",
"LabelAccountTypeGuest": "Gast",
"LabelAccountTypeUser": "Benutzer",
"LabelActivity": "Aktivitäten",
"LabelActivities": "Aktivitäten",
"LabelActivity": "Aktivität",
"LabelAddToCollection": "Zur Sammlung hinzufügen",
"LabelAddToCollectionBatch": "Füge {0} Hörbüch(er)/Podcast(s) der Sammlung hinzu",
"LabelAddToPlaylist": "Zur Wiedergabeliste hinzufügen",
@@ -283,6 +284,7 @@
"LabelContinueSeries": "Serien fortsetzen",
"LabelCover": "Titelbild",
"LabelCoverImageURL": "URL des Titelbildes",
"LabelCoverProvider": "Titelbildanbieter",
"LabelCreatedAt": "Erstellt am",
"LabelCronExpression": "Cron-Ausdruck",
"LabelCurrent": "Aktuell",
@@ -391,6 +393,7 @@
"LabelIntervalEvery6Hours": "Alle 6 Stunden",
"LabelIntervalEveryDay": "Jeden Tag",
"LabelIntervalEveryHour": "Jede Stunde",
"LabelIntervalEveryMinute": "Jede Minute",
"LabelInvert": "Umkehren",
"LabelItem": "Medium",
"LabelJumpBackwardAmount": "Zurückspringen Zeit",
@@ -844,6 +847,7 @@
"MessageRestoreBackupConfirm": "Bist du dir sicher, dass du die Sicherung wiederherstellen willst, welche am",
"MessageRestoreBackupWarning": "Bei der Wiederherstellung einer Sicherung wird die gesamte Datenbank unter /config und die Titelbilder in /metadata/items und /metadata/authors überschrieben.<br /><br />Bei der Sicherung werden keine Dateien in deinen Bibliotheksordnern verändert. Wenn du die Servereinstellungen aktiviert hast, um Cover und Metadaten in deinen Bibliotheksordnern zu speichern, werden diese nicht gesichert oder überschrieben.<br /><br />Alle Clients, die Ihren Server nutzen, werden automatisch aktualisiert.",
"MessageScheduleLibraryScanNote": "Für die meisten Nutzer wird empfohlen, diese Funktion deaktiviert zu lassen und stattdessen die Ordnerüberwachung aktiviert zu lassen. Die Ordnerüberwachung erkennt automatisch Änderungen in deinen Bibliotheksordnern. Da die Ordnerüberwachung jedoch nicht mit jedem Dateisystem (z.B. NFS) funktioniert, können alternativ hier geplante Bibliotheks-Scans aktiviert werden.",
"MessageScheduleRunEveryWeekdayAtTime": "Immer {0} um {1} ausführen",
"MessageSearchResultsFor": "Suchergebnisse für",
"MessageSelected": "{0} ausgewählt",
"MessageServerCouldNotBeReached": "Server kann nicht erreicht werden",

View File

@@ -219,6 +219,7 @@
"LabelAccountTypeAdmin": "Admin",
"LabelAccountTypeGuest": "Guest",
"LabelAccountTypeUser": "User",
"LabelActivities": "Activities",
"LabelActivity": "Activity",
"LabelAddToCollection": "Add to Collection",
"LabelAddToCollectionBatch": "Add {0} Books to Collection",
@@ -283,6 +284,7 @@
"LabelContinueSeries": "Continue Series",
"LabelCover": "Cover",
"LabelCoverImageURL": "Cover Image URL",
"LabelCoverProvider": "Cover Provider",
"LabelCreatedAt": "Created At",
"LabelCronExpression": "Cron Expression",
"LabelCurrent": "Current",
@@ -391,6 +393,7 @@
"LabelIntervalEvery6Hours": "Every 6 hours",
"LabelIntervalEveryDay": "Every day",
"LabelIntervalEveryHour": "Every hour",
"LabelIntervalEveryMinute": "Every minute",
"LabelInvert": "Invert",
"LabelItem": "Item",
"LabelJumpBackwardAmount": "Jump backward amount",
@@ -845,6 +848,7 @@
"MessageRestoreBackupConfirm": "Are you sure you want to restore the backup created on",
"MessageRestoreBackupWarning": "Restoring a backup will overwrite the entire database located at /config and cover images in /metadata/items & /metadata/authors.<br /><br />Backups do not modify any files in your library folders. If you have enabled server settings to store cover art and metadata in your library folders then those are not backed up or overwritten.<br /><br />All clients using your server will be automatically refreshed.",
"MessageScheduleLibraryScanNote": "For most users, it is recommended to leave this feature disabled and keep the folder watcher setting enabled. The folder watcher will automatically detect changes in your library folders. The folder watcher doesn't work for every file system (like NFS) so scheduled library scans can be used instead.",
"MessageScheduleRunEveryWeekdayAtTime": "Run every {0} at {1}",
"MessageSearchResultsFor": "Search results for",
"MessageSelected": "{0} selected",
"MessageServerCouldNotBeReached": "Server could not be reached",

View File

@@ -707,7 +707,7 @@
"MessageBackupsLocationEditNote": "Remarque: Mettre à jour l'emplacement de sauvegarde ne déplacera pas ou ne modifiera pas les sauvegardes existantes",
"MessageBackupsLocationNoEditNote": "Remarque: lemplacement de sauvegarde est défini via une variable denvironnement et ne peut pas être modifié ici.",
"MessageBackupsLocationPathEmpty": "L'emplacement de secours ne peut pas être vide",
"MessageBatchEditPopulateMapDetailsAllHelp": "Remplir les champs disponibles avec les données de tous les éléments. les champs avec des valeurs multiples seront fusionnés",
"MessageBatchEditPopulateMapDetailsAllHelp": "Remplir les champs disponibles avec les données de tous les éléments. Les champs avec des valeurs multiples seront fusionnés.",
"MessageBatchQuickMatchDescription": "La recherche par correspondance rapide tentera dajouter les couvertures et métadonnées manquantes pour les éléments sélectionnés. Activez les options ci-dessous pour permettre la Recherche par correspondance décraser les couvertures et/ou métadonnées existantes.",
"MessageBookshelfNoCollections": "Vous navez pas encore de collections",
"MessageBookshelfNoRSSFeeds": "Aucun flux RSS nest ouvert",

View File

@@ -16,7 +16,7 @@
"ButtonCancel": "Odustani",
"ButtonCancelEncode": "Otkaži kodiranje",
"ButtonChangeRootPassword": "Promijeni zaporku root korisnika",
"ButtonCheckAndDownloadNewEpisodes": "Provjeri i preuzmi nove epizode",
"ButtonCheckAndDownloadNewEpisodes": "Provjeri i preuzmi nove nastavke",
"ButtonChooseAFolder": "Odaberi mapu",
"ButtonChooseFiles": "Odaberi datoteke",
"ButtonClearFilter": "Poništi filter",
@@ -219,6 +219,7 @@
"LabelAccountTypeAdmin": "Administrator",
"LabelAccountTypeGuest": "Gost",
"LabelAccountTypeUser": "Korisnik",
"LabelActivities": "Aktivnosti",
"LabelActivity": "Aktivnost",
"LabelAddToCollection": "Dodaj u zbirku",
"LabelAddToCollectionBatch": "Dodaj {0} knjiga u zbirku",
@@ -283,6 +284,7 @@
"LabelContinueSeries": "Nastavi serijal",
"LabelCover": "Naslovnica",
"LabelCoverImageURL": "URL naslovnice",
"LabelCoverProvider": "Pružatelj naslovnica",
"LabelCreatedAt": "Izrađen",
"LabelCronExpression": "Cron izraz",
"LabelCurrent": "Trenutan",
@@ -355,7 +357,7 @@
"LabelFileModifiedDate": "Izmijenjeno {0}",
"LabelFilename": "Naziv datoteke",
"LabelFilterByUser": "Filtriraj po korisniku",
"LabelFindEpisodes": "Pronađi epizode",
"LabelFindEpisodes": "Pronađi nastavke",
"LabelFinished": "Dovršeno",
"LabelFolder": "Mapa",
"LabelFolders": "Mape",
@@ -391,6 +393,7 @@
"LabelIntervalEvery6Hours": "Svakih 6 sati",
"LabelIntervalEveryDay": "Svaki dan",
"LabelIntervalEveryHour": "Svaki sat",
"LabelIntervalEveryMinute": "Svaku minutu",
"LabelInvert": "Obrni",
"LabelItem": "Stavka",
"LabelJumpBackwardAmount": "Dužina skoka unatrag",
@@ -400,8 +403,8 @@
"LabelLanguages": "Jezici",
"LabelLastBookAdded": "Zadnja dodana knjiga",
"LabelLastBookUpdated": "Zadnja ažurirana knjiga",
"LabelLastSeen": "Zadnji puta viđen",
"LabelLastTime": "Zadnje vrijeme",
"LabelLastSeen": "Zadnje gledano",
"LabelLastTime": "Vrijeme zadnjeg slušanja",
"LabelLastUpdate": "Zadnje ažuriranje",
"LabelLayout": "Prikaz",
"LabelLayoutSinglePage": "Jedna stranica",
@@ -418,7 +421,7 @@
"LabelLogLevelDebug": "Debug",
"LabelLogLevelInfo": "Info",
"LabelLogLevelWarn": "Warn",
"LabelLookForNewEpisodesAfterDate": "Traži nove epizode nakon ovog datuma",
"LabelLookForNewEpisodesAfterDate": "Traži nove nastavke nakon ovog datuma",
"LabelLowestPriority": "Najniži prioritet",
"LabelMatchExistingUsersBy": "Prepoznaj postojeće korisnike pomoću",
"LabelMatchExistingUsersByDescription": "Rabi se za povezivanje postojećih korisnika. Nakon što se spoje, korisnike se prepoznaje temeljem jedinstvene oznake vašeg pružatelja SSO usluga",
@@ -447,7 +450,7 @@
"LabelNew": "Novo",
"LabelNewPassword": "Nova zaporka",
"LabelNewestAuthors": "Najnoviji autori",
"LabelNewestEpisodes": "Najnovije epizode",
"LabelNewestEpisodes": "Najnoviji nastavci",
"LabelNextBackupDate": "Sljedeća izrada sigurnosne kopije",
"LabelNextScheduledRun": "Sljedeće zakazano izvođenje",
"LabelNoCustomMetadataProviders": "Nema prilagođenih pružatelja meta-podataka",
@@ -678,7 +681,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",
@@ -845,6 +848,7 @@
"MessageRestoreBackupConfirm": "Sigurno želite vratiti sigurnosnu kopiju izrađenu",
"MessageRestoreBackupWarning": "Vraćanjem sigurnosne kopije prepisat ćete cijelu bazu podataka koja se nalazi u /config i slike naslovnice u /metadata/items i /metadata/authors.<br /><br />Sigurnosne kopije ne mijenjaju datoteke koje se nalaze u mapama vaših knjižnica. Ako ste u postavkama poslužitelja uključili mogućnost spremanja naslovnica i meta-podataka u mape knjižnice, te se datoteke neće niti sigurnosno pohraniti niti prepisati. <br /><br />Svi klijenti koji se spajaju na vaš poslužitelj automatski će se osvježiti.",
"MessageScheduleLibraryScanNote": "Za većinu korisnika se preporučuje ostaviti ovu funkciju deaktiviranom i ostaviti postavku promatrača mape aktiviranom. Promatrač mapa će automatski otkriti promjene u mapama vaše knjižnice. Promatrač mapa ne radi na svakom datotečnom sustavu (kao što je NFS) pa se umjesto njega mogu koristiti planirana pretraživanja knjižnice.",
"MessageScheduleRunEveryWeekdayAtTime": "Pokreni svaki {0} u {1}",
"MessageSearchResultsFor": "Rezultati pretrage za",
"MessageSelected": "{0} odabrano",
"MessageServerCouldNotBeReached": "Nije moguće pristupiti poslužitelju",

View File

@@ -219,6 +219,7 @@
"LabelAccountTypeAdmin": "Amministratore",
"LabelAccountTypeGuest": "Ospite",
"LabelAccountTypeUser": "Utente",
"LabelActivities": "Attività",
"LabelActivity": "Attività",
"LabelAddToCollection": "Aggiungi alla Raccolta",
"LabelAddToCollectionBatch": "Aggiungi {0} Libri alla Raccolta",
@@ -283,6 +284,7 @@
"LabelContinueSeries": "Continua serie",
"LabelCover": "Copertina",
"LabelCoverImageURL": "Indirizzo della cover URL",
"LabelCoverProvider": "Cover Provider",
"LabelCreatedAt": "Creato A",
"LabelCronExpression": "Espressione Cron",
"LabelCurrent": "Attuale",
@@ -391,6 +393,7 @@
"LabelIntervalEvery6Hours": "Ogni 6 ore",
"LabelIntervalEveryDay": "Ogni Giorno",
"LabelIntervalEveryHour": "Ogni ora",
"LabelIntervalEveryMinute": "Ogni minuto",
"LabelInvert": "Inverti",
"LabelItem": "Oggetti",
"LabelJumpBackwardAmount": "secondi di avvolgimento",

View File

@@ -10,6 +10,8 @@
"ButtonApplyChapters": "Zatwierdź rozdziały",
"ButtonAuthors": "Autorzy",
"ButtonBack": "Wstecz",
"ButtonBatchEditPopulateFromExisting": "Powiel z poprzednich",
"ButtonBatchEditPopulateMapDetails": "Powiel szczegóły mapy",
"ButtonBrowseForFolder": "Wyszukaj folder",
"ButtonCancel": "Anuluj",
"ButtonCancelEncode": "Anuluj enkodowanie",
@@ -31,6 +33,7 @@
"ButtonEditPodcast": "Edytuj podcast",
"ButtonEnable": "Włącz",
"ButtonFireAndFail": "Fail start",
"ButtonFireOnTest": "Uruchom po zdarzeniu testowym",
"ButtonForceReScan": "Wymuś ponowne skanowanie",
"ButtonFullPath": "Pełna ścieżka",
"ButtonHide": "Ukryj",
@@ -87,6 +90,8 @@
"ButtonSaveTracklist": "Zapisz listę odtwarzania",
"ButtonScan": "Zeskanuj",
"ButtonScanLibrary": "Skanuj bibliotekę",
"ButtonScrollLeft": "Przewiń w lewo",
"ButtonScrollRight": "Przewiń w prawo",
"ButtonSearch": "Szukaj",
"ButtonSelectFolderPath": "Wybierz ścieżkę folderu",
"ButtonSeries": "Seria",
@@ -155,13 +160,14 @@
"HeaderMapDetails": "Szczegóły mapowania",
"HeaderMatch": "Dopasuj",
"HeaderMetadataOrderOfPrecedence": "Kolejność metadanych",
"HeaderMetadataToEmbed": "Osadź metadane",
"HeaderMetadataToEmbed": "Metadane do osadzenia",
"HeaderNewAccount": "Nowe konto",
"HeaderNewLibrary": "Nowa biblioteka",
"HeaderNotificationCreate": "Utwórz powiadomienie",
"HeaderNotificationUpdate": "Zaktualizuj powiadomienie",
"HeaderNotifications": "Powiadomienia",
"HeaderOpenIDConnectAuthentication": "Uwierzytelnianie OpenID Connect",
"HeaderOpenListeningSessions": "Otwarte sesje słuchania",
"HeaderOpenRSSFeed": "Utwórz kanał RSS",
"HeaderOtherFiles": "Inne pliki",
"HeaderPasswordAuthentication": "Uwierzytelnianie hasłem",
@@ -188,6 +194,7 @@
"HeaderSettingsExperimental": "Funkcje eksperymentalne",
"HeaderSettingsGeneral": "Ogólne",
"HeaderSettingsScanner": "Skanowanie",
"HeaderSettingsWebClient": "Klient webowy",
"HeaderSleepTimer": "Wyłącznik czasowy",
"HeaderStatsLargestItems": "Największe pozycje",
"HeaderStatsLongestItems": "Najdłuższe pozycje (godziny)",
@@ -438,7 +445,7 @@
"LabelNotificationsMaxQueueSize": "Maksymalny rozmiar kolejki dla powiadomień",
"LabelNotificationsMaxQueueSizeHelp": "Zdarzenia są ograniczone do 1 na sekundę. Zdarzenia będą ignorowane jeśli kolejka ma maksymalny rozmiar. Zapobiega to spamowaniu powiadomieniami.",
"LabelNumberOfBooks": "Liczba książek",
"LabelNumberOfEpisodes": "# odcinków",
"LabelNumberOfEpisodes": "# Odcinków",
"LabelOpenRSSFeed": "Otwórz kanał RSS",
"LabelOverwrite": "Nadpisz",
"LabelPassword": "Hasło",

1
client/strings/ro.json Normal file
View File

@@ -0,0 +1 @@
{}

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",
@@ -206,6 +206,7 @@
"LabelAccountTypeAdmin": "Administratör",
"LabelAccountTypeGuest": "Gäst",
"LabelAccountTypeUser": "Användare",
"LabelActivities": "Aktiviteter",
"LabelActivity": "Aktivitet",
"LabelAddToCollection": "Lägg till i en samling",
"LabelAddToCollectionBatch": "Lägg till {0} böcker i samlingen",
@@ -231,6 +232,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 +244,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}",
@@ -266,6 +268,7 @@
"LabelContinueSeries": "Fortsätt med serien",
"LabelCover": "Omslag",
"LabelCoverImageURL": "URL till omslagsbild",
"LabelCoverProvider": "Källa för omslag",
"LabelCreatedAt": "Skapad",
"LabelCronExpression": "Schemaläggning med hjälp av Cron (Cron Expression)",
"LabelCurrent": "Nuvarande",
@@ -312,9 +315,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 +332,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 +347,7 @@
"LabelFontItalic": "Kursiv",
"LabelFontScale": "Skala på typsnitt",
"LabelFontStrikethrough": "Genomstruken",
"LabelFull": "Komplett",
"LabelGenre": "Kategori",
"LabelGenres": "Kategorier",
"LabelHardDeleteFile": "Hård radering av fil",
@@ -355,7 +362,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",
@@ -365,6 +372,7 @@
"LabelIntervalEvery6Hours": "Var 6:e timme",
"LabelIntervalEveryDay": "Varje dag",
"LabelIntervalEveryHour": "Varje timme",
"LabelIntervalEveryMinute": "Varje minut",
"LabelInvert": "Invertera",
"LabelItem": "Objekt",
"LabelJumpBackwardAmount": "Inställning för \"hopp bakåt\"",
@@ -416,7 +424,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",
@@ -459,20 +467,22 @@
"LabelPodcasts": "Podcasts",
"LabelPort": "Port",
"LabelPrefixesToIgnore": "Prefix att ignorera (skiftlägesokänsligt)",
"LabelPreventIndexing": "Förhindra att ditt flöde indexeras av iTunes och Google-podcastsökmotorer",
"LabelPreventIndexing": "Förhindra att ditt flöde indexeras av sökmotorer från iTunes och Google",
"LabelPrimaryEbook": "Primär e-bok",
"LabelProgress": "Framsteg",
"LabelProvider": "Källa",
"LabelPubDate": "Publiceringsdatum",
"LabelPublishYear": "Publiceringsår",
"LabelPublishedDate": "Publicerad {0}",
"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 +560,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 +704,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 +717,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 +747,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",
@@ -786,23 +798,32 @@
"MessageRestoreBackupConfirm": "Är du säker på att du vill läsa in säkerhetskopian som skapades den",
"MessageRestoreBackupWarning": "Att återställa en säkerhetskopia kommer att skriva över hela databasen som finns i /config och omslagsbilder i /metadata/items & /metadata/authors.<br /><br />Säkerhetskopior ändrar inte några filer i dina biblioteksmappar. Om du har aktiverat serverinställningar för att lagra omslagskonst och metadata i dina biblioteksmappar säkerhetskopieras eller skrivs de inte över.<br /><br />Alla klienter som använder din server kommer att uppdateras automatiskt.",
"MessageScheduleLibraryScanNote": "För de flesta användare rekommenderas att denna funktion ej aktiveras. Istället bör funktionen 'Watcher' vara aktiverad. Watcher kommer då automatiskt identifiera förändringar i biblioteket. För vissa filsystem (som t.ex. NFS) fungerar inte Watcher. Då kan schemalagda skanningar av biblioteken användas istället.",
"MessageScheduleRunEveryWeekdayAtTime": "Startar varje {0} klockan {1}",
"MessageSearchResultsFor": "Sökresultat för",
"MessageSelected": "{0} valda",
"MessageServerCouldNotBeReached": "Servern kunde inte nås",
"MessageSetChaptersFromTracksDescription": "Ställ in kapitel med varje ljudfil som ett kapitel och kapitelrubrik som ljudfilens namn",
"MessageStartPlaybackAtTime": "Starta uppspelning av \"{0}\" vid tidpunkt {1}?",
"MessageTaskAudioFileNotWritable": "Det går inte att skriva till ljudfilen \"{0}\"",
"MessageTaskCanceledByUser": "Uppgiften avslutades av användaren",
"MessageTaskDownloadingEpisodeDescription": "Laddar ner avsnitt \"{0}\"",
"MessageTaskEmbeddingMetadata": "Infogar metadata",
"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}\"",
"MessageTaskNoFilesToScan": "Inga filer finns tillgängliga för skanning",
"MessageTaskOpmlImportDescription": "Skapar podcasts från {0} RSS-flöden",
"MessageTaskOpmlImportFeedDescription": "Importerar RSS-flödet \"{0}\"",
"MessageTaskOpmlImportFeedPodcastDescription": "Skapar podcast \"{0}\"",
"MessageTaskOpmlImportFeedPodcastExists": "En podcast finns redan med den adressen",
"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",
@@ -812,9 +833,10 @@
"MessageTaskScanItemsUpdated": "{0} uppdaterades",
"MessageTaskScanNoChangesNeeded": "Inget adderades eller uppdaterades",
"MessageTaskScanningLibrary": "Biblioteket \"{0}\" har skannats",
"MessageTaskTargetDirectoryNotWritable": "Det är inte tillåtet att skriva i den angivna katalogen",
"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 +851,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",
@@ -870,6 +895,7 @@
"ToastBackupRestoreFailed": "Det gick inte att återställa säkerhetskopian",
"ToastBackupUploadFailed": "Det gick inte att ladda upp säkerhetskopian",
"ToastBackupUploadSuccess": "Säkerhetskopian uppladdad",
"ToastBatchQuickMatchStarted": "Snabbmatchning av {0} böcker har påbörjats!",
"ToastBatchUpdateFailed": "Batchuppdateringen misslyckades",
"ToastBatchUpdateSuccess": "Batchuppdateringen lyckades",
"ToastBookmarkCreateFailed": "Det gick inte att skapa bokmärket",
@@ -888,9 +914,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 +960,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 +972,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"
}

View File

@@ -219,6 +219,7 @@
"LabelAccountTypeAdmin": "Адміністратор",
"LabelAccountTypeGuest": "Гість",
"LabelAccountTypeUser": "Користувач",
"LabelActivities": "Діяльність",
"LabelActivity": "Активність",
"LabelAddToCollection": "Додати у добірку",
"LabelAddToCollectionBatch": "Додати книги до добірки: {0}",
@@ -283,6 +284,7 @@
"LabelContinueSeries": "Продовжити серії",
"LabelCover": "Обкладинка",
"LabelCoverImageURL": "URL-адреса обкладинки",
"LabelCoverProvider": "Постачальник покриття",
"LabelCreatedAt": "Дата створення",
"LabelCronExpression": "Команда cron",
"LabelCurrent": "Поточне",
@@ -391,6 +393,7 @@
"LabelIntervalEvery6Hours": "Кожні 6 годин",
"LabelIntervalEveryDay": "Щодня",
"LabelIntervalEveryHour": "Щогодини",
"LabelIntervalEveryMinute": "Кожну хвилину",
"LabelInvert": "Інвертувати",
"LabelItem": "Елемент",
"LabelJumpBackwardAmount": "Час переходу назад",
@@ -845,6 +848,7 @@
"MessageRestoreBackupConfirm": "Ви дійсно бажаєте відновити резервну копію від",
"MessageRestoreBackupWarning": "Відновлення резервної копії перезапише всю базу даних, розташовану в /config, і зображення обкладинок в /metadata/items та /metadata/authors.<br /><br />Резервні копії не змінюють жодних файлів у теках бібліотеки. Якщо у налаштуваннях сервера увімкнено збереження обкладинок і метаданих у теках бібліотеки, вони не створюються під час резервного копіювання і не перезаписуються..<br /><br />Всі клієнти, що користуються вашим сервером, будуть автоматично оновлені.",
"MessageScheduleLibraryScanNote": "Для більшості користувачів рекомендується залишити цю функцію вимкненою та залишити параметр перегляду папок увімкненим. Засіб спостереження за папками автоматично виявить зміни в папках вашої бібліотеки. Засіб спостереження за папками не працює для кожної файлової системи (наприклад, NFS), тому замість нього можна використовувати сканування бібліотек за розкладом.",
"MessageScheduleRunEveryWeekdayAtTime": "Запуск кожні {0} о {1}",
"MessageSearchResultsFor": "Результати пошуку для",
"MessageSelected": "Вибрано: {0}",
"MessageServerCouldNotBeReached": "Не вдалося підключитися до сервера",

4
package-lock.json generated
View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
"version": "2.19.2",
"version": "2.19.5",
"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

@@ -5,7 +5,7 @@ const Logger = require('./Logger')
const Task = require('./objects/Task')
const TaskManager = require('./managers/TaskManager')
const { filePathToPOSIX, isSameOrSubPath, getFileMTimeMs } = require('./utils/fileUtils')
const { filePathToPOSIX, isSameOrSubPath, getFileMTimeMs, shouldIgnoreFile } = require('./utils/fileUtils')
/**
* @typedef PendingFileUpdate
@@ -286,15 +286,10 @@ class FolderWatcher extends EventEmitter {
const relPath = path.replace(folderPath, '')
if (Path.extname(relPath).toLowerCase() === '.part') {
Logger.debug(`[Watcher] Ignoring .part file "${relPath}"`)
return false
}
// Ignore files/folders starting with "."
const hasDotPath = relPath.split('/').find((p) => p.startsWith('.'))
if (hasDotPath) {
Logger.debug(`[Watcher] Ignoring dot path "${relPath}" | Piece "${hasDotPath}"`)
// Check for ignored extensions or directories, such as dotfiles and hidden directories
const shouldIgnore = shouldIgnoreFile(relPath)
if (shouldIgnore) {
Logger.debug(`[Watcher] Ignoring ${shouldIgnore} - "${relPath}"`)
return false
}

View File

@@ -254,6 +254,11 @@ class LibraryController {
* @param {Response} res
*/
async update(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[LibraryController] Non-admin user "${req.user.username}" attempted to update library`)
return res.sendStatus(403)
}
// Validation
const updatePayload = {}
const keysToCheck = ['name', 'provider', 'mediaType', 'icon']
@@ -519,6 +524,11 @@ class LibraryController {
* @param {Response} res
*/
async delete(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[LibraryController] Non-admin user "${req.user.username}" attempted to delete library`)
return res.sendStatus(403)
}
// Remove library watcher
Watcher.removeLibrary(req.library)
@@ -639,6 +649,11 @@ class LibraryController {
* @param {Response} res
*/
async removeLibraryItemsWithIssues(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[LibraryController] Non-admin user "${req.user.username}" attempted to delete library items missing or invalid`)
return res.sendStatus(403)
}
const libraryItemsWithIssues = await Database.libraryItemModel.findAll({
where: {
libraryId: req.library.id,

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

@@ -130,7 +130,21 @@ class MigrationManager {
async initUmzug(umzugStorage = new SequelizeStorage({ sequelize: this.sequelize })) {
// This check is for dependency injection in tests
const files = (await fs.readdir(this.migrationsDir)).filter((file) => !file.startsWith('.')).map((file) => path.join(this.migrationsDir, file))
const files = (await fs.readdir(this.migrationsDir))
.filter((file) => {
// Only include .js files and exclude dot files
return !file.startsWith('.') && path.extname(file).toLowerCase() === '.js'
})
.map((file) => path.join(this.migrationsDir, file))
// Validate migration names
for (const file of files) {
const migrationName = path.basename(file, path.extname(file))
const migrationVersion = this.extractVersionFromTag(migrationName)
if (!migrationVersion) {
throw new Error(`Invalid migration file: "${migrationName}". Unable to extract version from filename.`)
}
}
const parent = new Umzug({
migrations: {

View File

@@ -72,6 +72,15 @@ class PodcastManager {
*/
async startPodcastEpisodeDownload(podcastEpisodeDownload) {
if (this.currentDownload) {
// Prevent downloading episodes from the same URL for the same library item.
// Allow downloading for different library items in case of the same podcast existing in multiple libraries (e.g. different folders)
if (this.downloadQueue.some((d) => d.url === podcastEpisodeDownload.url && d.libraryItem.id === podcastEpisodeDownload.libraryItem.id)) {
Logger.warn(`[PodcastManager] Episode already in queue: "${this.currentDownload.episodeTitle}"`)
return
} else if (this.currentDownload.url === podcastEpisodeDownload.url && this.currentDownload.libraryItem.id === podcastEpisodeDownload.libraryItem.id) {
Logger.warn(`[PodcastManager] Episode download already in progress for "${podcastEpisodeDownload.episodeTitle}"`)
return
}
this.downloadQueue.push(podcastEpisodeDownload)
SocketAuthority.emitter('episode_download_queued', podcastEpisodeDownload.toJSONForClient())
return
@@ -232,6 +241,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 +636,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),
@@ -694,13 +705,14 @@ class User extends Model {
ebookLocation: progressPayload.ebookLocation || null,
ebookProgress: isNullOrNaN(progressPayload.ebookProgress) ? 0 : Number(progressPayload.ebookProgress),
finishedAt: progressPayload.finishedAt || null,
createdAt: progressPayload.createdAt || new Date(),
extraData: {
libraryItemId: progressPayload.libraryItemId,
progress: isNullOrNaN(progressPayload.progress) ? 0 : Number(progressPayload.progress)
}
}
if (newMediaProgressPayload.isFinished) {
newMediaProgressPayload.finishedAt = new Date()
newMediaProgressPayload.finishedAt = newMediaProgressPayload.finishedAt || new Date()
newMediaProgressPayload.extraData.progress = 1
} else {
newMediaProgressPayload.finishedAt = null

View File

@@ -43,7 +43,8 @@ class PodcastEpisodeDownload {
season: this.rssPodcastEpisode?.season ?? null,
episode: this.rssPodcastEpisode?.episode ?? null,
episodeType: this.rssPodcastEpisode?.episodeType ?? 'full',
publishedAt: this.rssPodcastEpisode?.publishedAt ?? null
publishedAt: this.rssPodcastEpisode?.publishedAt ?? null,
guid: this.rssPodcastEpisode?.guid ?? null
}
}

View File

@@ -41,6 +41,9 @@ class CustomProviderAdapter {
}
const queryString = new URLSearchParams(queryObj).toString()
const url = `${provider.url}/search?${queryString}`
Logger.debug(`[CustomMetadataProvider] Search url: ${url}`)
// Setup headers
const axiosOptions = {
timeout
@@ -52,7 +55,7 @@ class CustomProviderAdapter {
}
const matches = await axios
.get(`${provider.url}/search?${queryString}`, axiosOptions)
.get(url, axiosOptions)
.then((res) => {
if (!res?.data || !Array.isArray(res.data.matches)) return null
return res.data.matches
@@ -66,25 +69,57 @@ class CustomProviderAdapter {
throw new Error('Custom provider returned malformed response')
}
const toStringOrUndefined = (value) => {
if (typeof value === 'string' || typeof value === 'number') return String(value)
if (Array.isArray(value) && value.every((v) => typeof v === 'string' || typeof v === 'number')) return value.join(',')
return undefined
}
const validateSeriesArray = (series) => {
if (!Array.isArray(series) || !series.length) return undefined
return series
.map((s) => {
if (!s?.series || typeof s.series !== 'string') return undefined
const _series = {
series: s.series
}
if (s.sequence && (typeof s.sequence === 'string' || typeof s.sequence === 'number')) {
_series.sequence = String(s.sequence)
}
return _series
})
.filter((s) => s !== undefined)
}
// re-map keys to throw out
return matches.map(({ title, subtitle, author, narrator, publisher, publishedYear, description, cover, isbn, asin, genres, tags, series, language, duration }) => {
return {
title,
subtitle,
author,
narrator,
publisher,
publishedYear,
description: htmlSanitizer.sanitize(description),
cover,
isbn,
asin,
genres,
tags: tags?.join(',') || null,
series: series?.length ? series : null,
language,
duration
return matches.map((match) => {
const { title, subtitle, author, narrator, publisher, publishedYear, description, cover, isbn, asin, genres, tags, series, language, duration } = match
const payload = {
title: toStringOrUndefined(title),
subtitle: toStringOrUndefined(subtitle),
author: toStringOrUndefined(author),
narrator: toStringOrUndefined(narrator),
publisher: toStringOrUndefined(publisher),
publishedYear: toStringOrUndefined(publishedYear),
description: description && typeof description === 'string' ? htmlSanitizer.sanitize(description) : undefined,
cover: toStringOrUndefined(cover),
isbn: toStringOrUndefined(isbn),
asin: toStringOrUndefined(asin),
genres: Array.isArray(genres) && genres.every((g) => typeof g === 'string') ? genres : undefined,
tags: toStringOrUndefined(tags),
series: validateSeriesArray(series),
language: toStringOrUndefined(language),
duration: !isNaN(duration) && duration !== null ? Number(duration) : undefined
}
// Remove undefined values
for (const key in payload) {
if (payload[key] === undefined) {
delete payload[key]
}
}
return payload
})
}
}

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

@@ -131,6 +131,40 @@ async function readTextFile(path) {
}
module.exports.readTextFile = readTextFile
/**
* Check if file or directory should be ignored. Returns a string of the reason to ignore, or null if not ignored
*
* @param {string} path
* @returns {string}
*/
module.exports.shouldIgnoreFile = (path) => {
// Check if directory or file name starts with "."
if (Path.basename(path).startsWith('.')) {
return 'dotfile'
}
if (path.split('/').find((p) => p.startsWith('.'))) {
return 'dotpath'
}
// If these strings exist anywhere in the filename or directory name, ignore. Vendor specific hidden directories
const includeAnywhereIgnore = ['@eaDir']
const filteredInclude = includeAnywhereIgnore.filter((str) => path.includes(str))
if (filteredInclude.length) {
return `${filteredInclude[0]} directory`
}
const extensionIgnores = ['.part', '.tmp', '.crdownload', '.download', '.bak', '.old', '.temp', '.tempfile', '.tempfile~']
// Check extension
if (extensionIgnores.includes(Path.extname(path).toLowerCase())) {
// Return the extension that is ignored
return `${Path.extname(path)} file`
}
// Should not ignore this file or directory
return null
}
/**
* @typedef FilePathItem
* @property {string} name - file name e.g. "audiofile.m4b"
@@ -147,7 +181,7 @@ module.exports.readTextFile = readTextFile
* @param {string} [relPathToReplace]
* @returns {FilePathItem[]}
*/
async function recurseFiles(path, relPathToReplace = null) {
module.exports.recurseFiles = async (path, relPathToReplace = null) => {
path = filePathToPOSIX(path)
if (!path.endsWith('/')) path = path + '/'
@@ -197,14 +231,10 @@ async function recurseFiles(path, relPathToReplace = null) {
return false
}
if (item.extension === '.part') {
Logger.debug(`[fileUtils] Ignoring .part file "${relpath}"`)
return false
}
// Ignore any file if a directory or the filename starts with "."
if (relpath.split('/').find((p) => p.startsWith('.'))) {
Logger.debug(`[fileUtils] Ignoring path has . "${relpath}"`)
// Check for ignored extensions or directories
const shouldIgnore = this.shouldIgnoreFile(relpath)
if (shouldIgnore) {
Logger.debug(`[fileUtils] Ignoring ${shouldIgnore} - "${relpath}"`)
return false
}
@@ -235,7 +265,6 @@ async function recurseFiles(path, relPathToReplace = null) {
return list
}
module.exports.recurseFiles = recurseFiles
/**
*

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

@@ -5,7 +5,7 @@ const authorFilters = require('./authorFilters')
const ShareManager = require('../../managers/ShareManager')
const { profile } = require('../profiler')
const stringifySequelizeQuery = require('../stringifySequelizeQuery')
const countCache = new Map()
module.exports = {
@@ -344,21 +344,28 @@ module.exports = {
countCache.clear()
},
async findAndCountAll(findOptions, limit, offset) {
const findOptionsKey = JSON.stringify(findOptions)
Logger.debug(`[LibraryItemsBookFilters] findOptionsKey: ${findOptionsKey}`)
async findAndCountAll(findOptions, limit, offset, useCountCache) {
const model = Database.bookModel
if (useCountCache) {
const countCacheKey = stringifySequelizeQuery(findOptions)
Logger.debug(`[LibraryItemsBookFilters] countCacheKey: ${countCacheKey}`)
if (!countCache.has(countCacheKey)) {
const count = await model.count(findOptions)
countCache.set(countCacheKey, count)
}
findOptions.limit = limit || null
findOptions.offset = offset
const rows = await model.findAll(findOptions)
return { rows, count: countCache.get(countCacheKey) }
}
findOptions.limit = limit || null
findOptions.offset = offset
if (countCache.has(findOptionsKey)) {
const rows = await Database.bookModel.findAll(findOptions)
return { rows, count: countCache.get(findOptionsKey) }
} else {
const result = await Database.bookModel.findAndCountAll(findOptions)
countCache.set(findOptionsKey, result.count)
return result
}
return await model.findAndCountAll(findOptions)
},
/**
@@ -433,19 +440,17 @@ module.exports = {
const libraryItemIncludes = []
const bookIncludes = []
if (includeRSSFeed) {
if (filterGroup === 'feed-open' || includeRSSFeed) {
const rssFeedRequired = filterGroup === 'feed-open'
libraryItemIncludes.push({
model: Database.feedModel,
required: filterGroup === 'feed-open',
separate: true
required: rssFeedRequired,
separate: !rssFeedRequired
})
}
if (filterGroup === 'feed-open' && !includeRSSFeed) {
libraryItemIncludes.push({
model: Database.feedModel,
required: true
})
} else if (filterGroup === 'share-open') {
if (filterGroup === 'share-open') {
bookIncludes.push({
model: Database.mediaItemShareModel,
required: true
@@ -607,7 +612,7 @@ module.exports = {
}
const findAndCountAll = process.env.QUERY_PROFILING ? profile(this.findAndCountAll) : this.findAndCountAll
const { rows: books, count } = await findAndCountAll(findOptions, limit, offset)
const { rows: books, count } = await findAndCountAll(findOptions, limit, offset, !filterGroup)
const libraryItems = books.map((bookExpanded) => {
const libraryItem = bookExpanded.libraryItem

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,34 @@ module.exports = {
return []
},
clearCountCache(model, hook) {
Logger.debug(`[LibraryItemsPodcastFilters] ${model}.${hook}: Clearing count cache`)
countCache.clear()
},
async findAndCountAll(findOptions, model, limit, offset, useCountCache) {
if (useCountCache) {
const countCacheKey = stringifySequelizeQuery(findOptions)
Logger.debug(`[LibraryItemsPodcastFilters] countCacheKey: ${countCacheKey}`)
if (!countCache.has(countCacheKey)) {
const count = await model.count(findOptions)
countCache.set(countCacheKey, count)
}
findOptions.limit = limit || null
findOptions.offset = offset
const rows = await model.findAll(findOptions)
return { rows, count: countCache.get(countCacheKey) }
}
findOptions.limit = limit || null
findOptions.offset = offset
return await model.findAndCountAll(findOptions)
},
/**
* Get library items for podcast media type using filter and sort
* @param {string} libraryId
@@ -120,7 +152,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 +172,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 +183,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 +199,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, !filterGroup)
const libraryItems = podcasts.map((podcastExpanded) => {
const libraryItem = podcastExpanded.libraryItem
@@ -183,11 +215,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 +304,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, !filterGroup)
const libraryItems = podcastEpisodes.map((ep) => {
const libraryItem = ep.podcast.libraryItem

View File

@@ -0,0 +1,25 @@
function stringifySequelizeQuery(findOptions) {
function isClass(func) {
return typeof func === 'function' && /^class\s/.test(func.toString())
}
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

@@ -126,9 +126,9 @@ describe('migration-v2.15.0-series-column-unique', () => {
it('upgrade with duplicate series and no sequence', async () => {
// Add some entries to the Series table using the UUID for the ids
await queryInterface.bulkInsert('Series', [
{ id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() },
{ id: series2Id, name: 'Series 2', libraryId: library2Id, createdAt: new Date(), updatedAt: new Date() },
{ id: series3Id, name: 'Series 3', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() },
{ id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(7), updatedAt: new Date(7) },
{ id: series2Id, name: 'Series 2', libraryId: library2Id, createdAt: new Date(7), updatedAt: new Date(8) },
{ id: series3Id, name: 'Series 3', libraryId: library1Id, createdAt: new Date(7), updatedAt: new Date(9) },
{ id: series1Id_dup, name: 'Series 1', libraryId: library1Id, createdAt: new Date(0), updatedAt: new Date(0) },
{ id: series3Id_dup, name: 'Series 3', libraryId: library1Id, createdAt: new Date(0), updatedAt: new Date(0) },
{ id: series1Id_dup2, name: 'Series 1', libraryId: library1Id, createdAt: new Date(0), updatedAt: new Date(0) }
@@ -203,8 +203,8 @@ describe('migration-v2.15.0-series-column-unique', () => {
it('upgrade with one book in two of the same series, both sequence are null', async () => {
// Create two different series with the same name in the same library
await queryInterface.bulkInsert('Series', [
{ id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() },
{ id: series2Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }
{ id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(8), updatedAt: new Date(20) },
{ id: series2Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(5), updatedAt: new Date(10) }
])
// Create a book that is in both series
await queryInterface.bulkInsert('BookSeries', [
@@ -236,8 +236,8 @@ describe('migration-v2.15.0-series-column-unique', () => {
it('upgrade with one book in two of the same series, one sequence is null', async () => {
// Create two different series with the same name in the same library
await queryInterface.bulkInsert('Series', [
{ id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() },
{ id: series2Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }
{ id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(5), updatedAt: new Date(9) },
{ id: series2Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(5), updatedAt: new Date(7) }
])
// Create a book that is in both series
await queryInterface.bulkInsert('BookSeries', [
@@ -268,8 +268,8 @@ describe('migration-v2.15.0-series-column-unique', () => {
it('upgrade with one book in two of the same series, both sequence are not null', async () => {
// Create two different series with the same name in the same library
await queryInterface.bulkInsert('Series', [
{ id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() },
{ id: series2Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }
{ id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(1), updatedAt: new Date(3) },
{ id: series2Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(2), updatedAt: new Date(2) }
])
// Create a book that is in both series
await queryInterface.bulkInsert('BookSeries', [

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,127 @@
const chai = require('chai')
const expect = chai.expect
const sinon = require('sinon')
const fileUtils = require('../../../server/utils/fileUtils')
const fs = require('fs')
const Logger = require('../../../server/Logger')
describe('fileUtils', () => {
it('shouldIgnoreFile', () => {
global.isWin = process.platform === 'win32'
const testCases = [
{ path: 'test.txt', expected: null },
{ path: 'folder/test.mp3', expected: null },
{ path: 'normal/path/file.m4b', expected: null },
{ path: 'test.txt.part', expected: '.part file' },
{ path: 'test.txt.tmp', expected: '.tmp file' },
{ path: 'test.txt.crdownload', expected: '.crdownload file' },
{ path: 'test.txt.download', expected: '.download file' },
{ path: 'test.txt.bak', expected: '.bak file' },
{ path: 'test.txt.old', expected: '.old file' },
{ path: 'test.txt.temp', expected: '.temp file' },
{ path: 'test.txt.tempfile', expected: '.tempfile file' },
{ path: 'test.txt.tempfile~', expected: '.tempfile~ file' },
{ path: '.gitignore', expected: 'dotfile' },
{ path: 'folder/.hidden', expected: 'dotfile' },
{ path: '.git/config', expected: 'dotpath' },
{ path: 'path/.hidden/file.txt', expected: 'dotpath' },
{ path: '@eaDir', expected: '@eaDir directory' },
{ path: 'folder/@eaDir', expected: '@eaDir directory' },
{ path: 'path/@eaDir/file.txt', expected: '@eaDir directory' },
{ path: '.hidden/test.tmp', expected: 'dotpath' },
{ path: '@eaDir/test.part', expected: '@eaDir directory' }
]
testCases.forEach(({ path, expected }) => {
const result = fileUtils.shouldIgnoreFile(path)
expect(result).to.equal(expected)
})
})
describe('recurseFiles', () => {
let readdirStub, realpathStub, statStub
beforeEach(() => {
global.isWin = process.platform === 'win32'
// Mock file structure with normalized paths
const mockDirContents = new Map([
['/test', ['file1.mp3', 'subfolder', 'ignoreme', 'temp.mp3.tmp']],
['/test/subfolder', ['file2.m4b']],
['/test/ignoreme', ['.ignore', 'ignored.mp3']]
])
const mockStats = new Map([
['/test/file1.mp3', { isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '1' }],
['/test/subfolder', { isDirectory: () => true, size: 0, mtimeMs: Date.now(), ino: '2' }],
['/test/subfolder/file2.m4b', { isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '3' }],
['/test/ignoreme', { isDirectory: () => true, size: 0, mtimeMs: Date.now(), ino: '4' }],
['/test/ignoreme/.ignore', { isDirectory: () => false, size: 0, mtimeMs: Date.now(), ino: '5' }],
['/test/ignoreme/ignored.mp3', { isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '6' }],
['/test/temp.mp3.tmp', { isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '7' }]
])
// Stub fs.readdir
readdirStub = sinon.stub(fs, 'readdir')
readdirStub.callsFake((path, callback) => {
const contents = mockDirContents.get(path)
if (contents) {
callback(null, contents)
} else {
callback(new Error(`ENOENT: no such file or directory, scandir '${path}'`))
}
})
// Stub fs.realpath
realpathStub = sinon.stub(fs, 'realpath')
realpathStub.callsFake((path, callback) => {
// Return normalized path
callback(null, fileUtils.filePathToPOSIX(path).replace(/\/$/, ''))
})
// Stub fs.stat
statStub = sinon.stub(fs, 'stat')
statStub.callsFake((path, callback) => {
const normalizedPath = fileUtils.filePathToPOSIX(path).replace(/\/$/, '')
const stats = mockStats.get(normalizedPath)
if (stats) {
callback(null, stats)
} else {
callback(new Error(`ENOENT: no such file or directory, stat '${normalizedPath}'`))
}
})
// Stub Logger
sinon.stub(Logger, 'debug')
})
afterEach(() => {
sinon.restore()
})
it('should return filtered file list', async () => {
const files = await fileUtils.recurseFiles('/test')
expect(files).to.be.an('array')
expect(files).to.have.lengthOf(2)
expect(files[0]).to.deep.equal({
name: 'file1.mp3',
path: 'file1.mp3',
reldirpath: '',
fullpath: '/test/file1.mp3',
extension: '.mp3',
deep: 0
})
expect(files[1]).to.deep.equal({
name: 'file2.m4b',
path: 'subfolder/file2.m4b',
reldirpath: 'subfolder',
fullpath: '/test/subfolder/file2.m4b',
extension: '.m4b',
deep: 1
})
})
})
})

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('{}')
})
})