Compare commits

...

98 Commits

Author SHA1 Message Date
advplyr b39268ccb0 Remove old Feed/FeedEpisode/FeedMeta objects 2024-12-15 17:54:36 -06:00
advplyr de8a9304d2 Remove unused old feed methods 2024-12-15 17:05:57 -06:00
advplyr f8fbd3ac8c Migrate Feed updating and build xml to new model 2024-12-15 16:56:59 -06:00
advplyr 369c05936b Fix feed create entityUpdatedAt value 2024-12-15 14:07:46 -06:00
advplyr 837a180dc1 Refactor RssFeedManager.init to use new model only 2024-12-15 13:14:55 -06:00
advplyr 302b651e7b Fix library item unit test 2024-12-15 12:38:50 -06:00
advplyr 4c68ad46f4 Refactor RssFeedManager to use new model when closing feeds, fix close series feed when series is removed, update RssFeedManager to singleton 2024-12-15 12:37:01 -06:00
advplyr e50bd93958 Refactor Feed model to create new feed for series 2024-12-15 11:44:07 -06:00
advplyr d576625cb7 Refactor Feed model to create new feed for collection 2024-12-15 10:53:31 -06:00
advplyr ca2327aba3 Merge pull request #3721 from advplyr/refactor-feeds-from-item
Refactor Feed model to create new feed for library item
2024-12-14 17:25:10 -06:00
advplyr 9bd1f9e3d5 Refactor Feed model to create new feed for library item 2024-12-14 16:55:56 -06:00
advplyr c4610e6102 Update:Remove outline for focused modal content 2024-12-13 16:22:32 -06:00
advplyr 329bbea043 Fix:Downloading podcast episode when file extension is mp3 but enclosure type is not mp3 #3711 2024-12-13 16:06:00 -06:00
advplyr e616b53877 Accessibility update for book & series cards, home page shelf scroll #2268 #3699 2024-12-12 16:51:36 -06:00
advplyr eab86f90a8 Accessibility update for config side nav and modal, set focus on modal content on open 2024-12-12 15:16:49 -06:00
advplyr f97389cb2b More accessibility updates: adding roles for toolbars, bookshelf cards, author sort #2268 #3699 2024-12-11 17:24:48 -06:00
advplyr c5c3aab130 Update:Accessibility for buttons on item page, context menu dropdown, library filter/sort #3699 2024-12-10 17:20:13 -06:00
advplyr 4610e58337 Update:Home shelf labels use h2 tag, play & edit buttons overlaying item page updated to button tag with aria-label for accessibility #3699 2024-12-09 17:24:21 -06:00
advplyr 190a1000d9 Version bump v2.17.5 2024-12-08 09:03:05 -06:00
advplyr 455b96d1ab Merge pull request #3694 from weblate/weblate-audiobookshelf-abs-web-client
Translations update from Hosted Weblate
2024-12-08 09:02:14 -06:00
thehijacker 8aaf62f243 Translated using Weblate (Slovenian)
Currently translated at 100.0% (1074 of 1074 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/
2024-12-08 15:57:55 +01:00
Bezruchenko Simon e6d754113e Translated using Weblate (Ukrainian)
Currently translated at 100.0% (1074 of 1074 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/uk/
2024-12-08 15:57:55 +01:00
Clara Papke 5f72e30e63 Translated using Weblate (German)
Currently translated at 100.0% (1074 of 1074 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/
2024-12-08 15:57:55 +01:00
advplyr 57906540fe Add:Server setting to allow iframe & update UI to differentiate web client settings #3684 2024-12-08 08:57:45 -06:00
advplyr 726adbb3bf Merge pull request #3692 from mikiher/rss-remove-server-address
Remove serverAddress from Feeds and FeedEpisodes URLs
2024-12-08 08:24:41 -06:00
advplyr f7b7b85673 Add v2.17.5 migration to changelog 2024-12-08 08:19:23 -06:00
advplyr 5646466aa3 Update JSDocs for feeds endpoints 2024-12-08 08:05:33 -06:00
mikiher b38ce41731 Remove xml cache from Feed object 2024-12-08 09:48:58 +02:00
mikiher a8ab8badd5 always set req.originalHostPrefix 2024-12-08 09:23:39 +02:00
advplyr 5eca43082e Merge pull request #3687 from jaumet/Catalan-version
Catalan translation added
2024-12-07 15:19:27 -06:00
advplyr 6fa11934be Add:Catalan language option 2024-12-07 15:15:47 -06:00
advplyr ff7edc32a1 Merge pull request #3689 from Vito0912/feat/fixServercrashPlaybacksession
Resolved a server crash when a playback session lacked media metadata
2024-12-07 15:02:20 -06:00
mikiher 9b8e059efe Remove serverAddress from Feeds and FeedEpisodes URLs 2024-12-07 19:27:37 +02:00
Vito0912 7486d6345d Resolved a server crash when a playback session lacked associated media metadata. 2024-12-07 09:34:06 +01:00
Jaume 835490a9fc Catalan translation added
new file 
client/strings/ca.json
2024-12-07 01:45:41 +01:00
advplyr 3b4a5b8785 Support ALLOW_IFRAME env variable to not include frame-ancestors header #3684 2024-12-06 17:17:32 -06:00
advplyr 9a1c773b7a Fix:Server crash on uploadCover temp file mv failed #3685 2024-12-06 16:59:34 -06:00
advplyr 890b0b949e Version bump v2.17.4 2024-12-05 16:50:30 -06:00
advplyr b19e360bbb Merge pull request #3674 from weblate/weblate-audiobookshelf-abs-web-client
Translations update from Hosted Weblate
2024-12-05 16:32:58 -06:00
SunSpring 1ff7952074 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (1074 of 1074 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/
2024-12-05 23:15:39 +01:00
SunSpring 259d93d882 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (1074 of 1074 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/
2024-12-05 23:15:38 +01:00
Tamanegii 14f60a593b Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (1074 of 1074 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/
2024-12-05 23:15:38 +01:00
SunSpring 7334580c8c Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (1074 of 1074 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/
2024-12-05 23:15:37 +01:00
Tamanegii f467c44543 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 99.9% (1073 of 1074 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/
2024-12-05 23:15:37 +01:00
Milo Ivir 867354e59d Translated using Weblate (Croatian)
Currently translated at 100.0% (1074 of 1074 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/
2024-12-05 23:15:36 +01:00
gallegonovato 67952cc577 Translated using Weblate (Spanish)
Currently translated at 100.0% (1074 of 1074 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/es/
2024-12-05 23:15:35 +01:00
Milo Ivir 079a15541c Translated using Weblate (Croatian)
Currently translated at 100.0% (1072 of 1072 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/
2024-12-05 23:15:35 +01:00
Mario 658ac04268 Translated using Weblate (German)
Currently translated at 100.0% (1072 of 1072 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/
2024-12-05 23:15:34 +01:00
Mario cbee6d8f5e Translated using Weblate (German)
Currently translated at 100.0% (1072 of 1072 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/
2024-12-05 23:15:33 +01:00
thehijacker 68413ae2f6 Translated using Weblate (Slovenian)
Currently translated at 100.0% (1072 of 1072 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/
2024-12-05 23:15:33 +01:00
Henning 252a233282 Translated using Weblate (German)
Currently translated at 100.0% (1072 of 1072 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/
2024-12-05 23:15:32 +01:00
advplyr c35185fff7 Update prober to accept grp1 as an alternative tag to grouping #3681 2024-12-05 16:15:23 -06:00
advplyr 9774b2cfa5 Update JSDocs for groupFileItemsIntoLibraryItemDirs 2024-12-04 16:30:35 -06:00
advplyr 344890fb45 Update watcher files changed function to use the same grouping function as other scans 2024-12-04 16:25:17 -06:00
advplyr 5fa0897ad7 Merge pull request #3665 from mikiher/subdirectory-fixes-3
Subdirectory support for OIDC and SocketIO
2024-12-03 17:29:57 -06:00
advplyr 95c80a5b18 Merge pull request #3672 from Techwolfy/disc-folder-support
Support additional disc folder names
2024-12-03 17:28:32 -06:00
advplyr 0f1b64b883 Add test for grouping book library items 2024-12-03 17:21:57 -06:00
advplyr 615ed26f0f Update:Users table show count next to header 2024-12-02 17:35:35 -06:00
advplyr 84803cef82 Fix:Load year in review stats for playback sessions with null mediaMetadata 2024-12-02 17:23:25 -06:00
Techwolf 605bd73c11 Fix third instance of regex 2024-12-01 23:57:47 -08:00
Techwolf cc89db059b Fix second instance of regex 2024-12-01 18:41:38 -08:00
Techwolf a03146e09c Support additional disc folder names 2024-12-01 18:10:44 -08:00
advplyr 33aa4f1952 Merge master 2024-12-01 13:27:20 -06:00
advplyr c03f18b90a Merge pull request #3670 from advplyr/fix_remove_authors_no_books
Fix:Remove authors with no books when a books is removed #3668
2024-12-01 12:56:57 -06:00
advplyr 0dedb09a07 Update:batchUpdate endpoint validate req.body is an array of objects 2024-12-01 12:49:39 -06:00
advplyr 2b5484243b Add LibraryItemController test for delete/batchDelete/updateMedia endpoint functions to correctly remove authors & series with no books 2024-12-01 12:44:21 -06:00
advplyr c496db7c95 Fix:Remove authors with no books when a books is removed #3668
- Handles bulk delete, single delete, deleting library folder, and removing items with issues
- Also handles bulk editing and removing authors
2024-12-01 09:51:26 -06:00
advplyr ea4d5ff665 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2024-11-30 16:26:52 -06:00
advplyr 468a547864 Version bump v2.17.3 2024-11-30 16:26:48 -06:00
advplyr cd9999d192 Merge pull request #3643 from weblate/weblate-audiobookshelf-abs-web-client
Translations update from Hosted Weblate
2024-11-30 16:23:45 -06:00
Charlie 31e302ea59 Translated using Weblate (French)
Currently translated at 100.0% (1072 of 1072 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fr/
2024-11-30 23:10:44 +01:00
Dmitry 1ff1ba66fd Translated using Weblate (Russian)
Currently translated at 100.0% (1072 of 1072 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ru/
2024-11-30 23:10:43 +01:00
Pierrick Guillaume a5457d7e22 Translated using Weblate (French)
Currently translated at 100.0% (1072 of 1072 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fr/
2024-11-30 23:10:43 +01:00
Soaibuzzaman ddcbfd4500 Translated using Weblate (Bengali)
Currently translated at 100.0% (1072 of 1072 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/bn/
2024-11-30 23:10:42 +01:00
biuklija 293e530297 Translated using Weblate (Croatian)
Currently translated at 100.0% (1072 of 1072 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/
2024-11-30 23:10:41 +01:00
thehijacker 7278ad4ee7 Translated using Weblate (Slovenian)
Currently translated at 100.0% (1072 of 1072 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/
2024-11-30 23:10:40 +01:00
Bezruchenko Simon 0449fb5ef9 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (1072 of 1072 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/uk/
2024-11-30 23:10:40 +01:00
gallegonovato d2c28fc69c Translated using Weblate (Spanish)
Currently translated at 100.0% (1072 of 1072 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/es/
2024-11-30 23:10:39 +01:00
Vito0912 60ba0163af Translated using Weblate (German)
Currently translated at 99.9% (1071 of 1072 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/
2024-11-30 23:10:39 +01:00
advplyr 02ca926d88 Merge pull request #3664 from advplyr/v2.17.3-fk-constraints-migration
Add migration to fix dropped foreign key constraints dropped in v2.17.0 migration
2024-11-30 16:10:30 -06:00
advplyr 4b52f31d58 Update v2.17.3 migration file to first check if constraints need to be updated, add unit test 2024-11-30 15:48:20 -06:00
mikiher 9917f2d358 Change migration to v2.17.4 2024-11-29 09:01:03 +02:00
mikiher 8c3ba67583 Fix label order 2024-11-29 05:48:04 +02:00
mikiher 6d8720b404 Subfolder support for OIDC auth 2024-11-29 04:28:50 +02:00
mikiher 843dd0b1b2 Keep original socket.io server for non-subdir clients 2024-11-29 04:13:00 +02:00
advplyr 70f466d03c Add migration for v2.17.3 to fix dropped fk constraints 2024-11-28 17:18:34 -06:00
advplyr ef82e8b0d0 Fix:Server crash deleting user with sessions 2024-11-27 16:48:07 -06:00
advplyr c643d4cec8 Merge pull request #3655 from glorenzen/fix/player-settings-modal
Fix player settings modal on share page
2024-11-26 17:12:17 -06:00
advplyr 718d8b5999 Update jump backward amount for share player 2024-11-26 17:05:50 -06:00
advplyr 2ba0f9157d Update share player to load user settings 2024-11-26 17:03:01 -06:00
Greg Lorenzen 53fdb5273c Remove player settings modal from MediaPlayerContainer 2024-11-26 04:04:55 +00:00
Greg Lorenzen fabdfd5517 Add player settings modal to PlayerUi 2024-11-26 04:04:44 +00:00
advplyr 950993f652 Update:View episode modal includes audio filename and size #3648 2024-11-25 17:26:06 -06:00
advplyr 5a968b002a Update readme.md 2024-11-25 13:29:06 -06:00
advplyr 3acd29fab3 Update readme.md 2024-11-25 13:27:33 -06:00
advplyr 315b21db00 Fix:API get media progress for episode 2024-11-24 15:05:19 -06:00
advplyr f9aaeb3a34 Update:Set Content-Security-Policy header to disallow iframes 2024-11-23 11:17:13 -06:00
advplyr d19bb909b3 Fix:Server crash deleting library that has playback sessions #3634 2024-11-22 17:20:31 -06:00
108 changed files with 4345 additions and 1766 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="w-full h-16 bg-primary relative"> <div class="w-full h-16 bg-primary relative">
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-60"> <div id="appbar" role="toolbar" aria-label="Appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-60">
<div class="flex h-full items-center"> <div class="flex h-full items-center">
<nuxt-link to="/"> <nuxt-link to="/">
<img src="~static/icon.svg" :alt="$strings.ButtonHome" class="w-8 min-w-8 h-8 mr-2 sm:w-10 sm:min-w-10 sm:h-10 sm:mr-4" /> <img src="~static/icon.svg" :alt="$strings.ButtonHome" class="w-8 min-w-8 h-8 mr-2 sm:w-10 sm:min-w-10 sm:h-10 sm:mr-4" />
@@ -17,7 +17,7 @@
<div v-else-if="isAlternativeBookshelfView" class="w-full mb-24e"> <div v-else-if="isAlternativeBookshelfView" class="w-full mb-24e">
<template v-for="(shelf, index) in supportedShelves"> <template v-for="(shelf, index) in supportedShelves">
<widgets-item-slider :shelf-id="shelf.id" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening' || shelf.id === 'continue-reading'" :type="shelf.type" class="bookshelf-row pl-8e my-6e" @selectEntity="(payload) => selectEntity(payload, index)"> <widgets-item-slider :shelf-id="shelf.id" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening' || shelf.id === 'continue-reading'" :type="shelf.type" class="bookshelf-row pl-8e my-6e" @selectEntity="(payload) => selectEntity(payload, index)">
<p class="font-semibold text-gray-100">{{ $strings[shelf.labelStringKey] }}</p> <h2 class="font-semibold text-gray-100">{{ $strings[shelf.labelStringKey] }}</h2>
</widgets-item-slider> </widgets-item-slider>
</template> </template>
</div> </div>
+5 -5
View File
@@ -37,18 +37,18 @@
<div class="relative"> <div class="relative">
<div class="relative text-center categoryPlacard transform z-30 top-0 left-4e md:left-8e w-44e rounded-md"> <div class="relative text-center categoryPlacard transform z-30 top-0 left-4e md:left-8e w-44e rounded-md">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0em 0.5em` }"> <div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0em 0.5em` }">
<p :style="{ fontSize: 0.9 + 'em' }">{{ $strings[shelf.labelStringKey] }}</p> <h2 :style="{ fontSize: 0.9 + 'em' }">{{ $strings[shelf.labelStringKey] }}</h2>
</div> </div>
</div> </div>
<div class="bookshelfDividerCategorized h-6e w-full absolute top-0 left-0 right-0 z-20"></div> <div class="bookshelfDividerCategorized h-6e w-full absolute top-0 left-0 right-0 z-20"></div>
</div> </div>
<div v-show="canScrollLeft && !isScrolling" class="hidden sm:flex absolute top-0 left-0 w-32 pr-8 bg-black book-shelf-arrow-left items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-40" @click="scrollLeft"> <button v-show="canScrollLeft && !isScrolling" :aria-label="$strings.ButtonScrollLeft" class="hidden sm:flex absolute top-0 left-0 w-32 pr-8 bg-black book-shelf-arrow-left items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-40" @click="scrollLeft">
<span class="material-symbols text-white" :style="{ fontSize: 3.75 + 'em' }">chevron_left</span> <span class="material-symbols text-white" :style="{ fontSize: 3.75 + 'em' }">chevron_left</span>
</div> </button>
<div v-show="canScrollRight && !isScrolling" class="hidden sm:flex absolute top-0 right-0 w-32 pl-8 bg-black book-shelf-arrow-right items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-40" @click="scrollRight"> <button v-show="canScrollRight && !isScrolling" :aria-label="$strings.ButtonScrollRight" class="hidden sm:flex absolute top-0 right-0 w-32 pl-8 bg-black book-shelf-arrow-right items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-40" @click="scrollRight">
<span class="material-symbols text-white" :style="{ fontSize: 3.75 + 'em' }">chevron_right</span> <span class="material-symbols text-white" :style="{ fontSize: 3.75 + 'em' }">chevron_right</span>
</div> </button>
</div> </div>
</template> </template>
+1 -1
View File
@@ -43,7 +43,7 @@
<p class="text-sm">{{ $strings.ButtonAdd }}</p> <p class="text-sm">{{ $strings.ButtonAdd }}</p>
</nuxt-link> </nuxt-link>
</div> </div>
<div id="toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-40 flex items-center justify-end md:justify-start px-2 md:px-8"> <div id="toolbar" role="toolbar" aria-label="Library Toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-40 flex items-center justify-end md:justify-start px-2 md:px-8">
<!-- Series books page --> <!-- Series books page -->
<template v-if="selectedSeries"> <template v-if="selectedSeries">
<p class="pl-2 text-base md:text-lg"> <p class="pl-2 text-base md:text-lg">
+2 -2
View File
@@ -1,6 +1,6 @@
<template> <template>
<div> <div role="toolbar" aria-orientation="vertical" aria-label="Config Sidebar">
<div class="w-44 fixed left-0 top-16 bg-bg bg-opacity-100 md:bg-opacity-70 shadow-lg border-r border-white border-opacity-5 py-3 transform transition-transform mb-12 overflow-y-auto" :class="wrapperClass + ' ' + (streamLibraryItem ? 'h-[calc(100%-270px)]' : 'h-[calc(100%-110px)]')" v-click-outside="clickOutside"> <div role="navigation" aria-label="Config Navigation" class="w-44 fixed left-0 top-16 bg-bg bg-opacity-100 md:bg-opacity-70 shadow-lg border-r border-white border-opacity-5 py-3 transform transition-transform mb-12 overflow-y-auto" :class="wrapperClass + ' ' + (streamLibraryItem ? 'h-[calc(100%-270px)]' : 'h-[calc(100%-110px)]')" v-click-outside="clickOutside">
<div v-show="isMobilePortrait" class="flex items-center justify-end pb-2 px-4 mb-1" @click="closeDrawer"> <div v-show="isMobilePortrait" class="flex items-center justify-end pb-2 px-4 mb-1" @click="closeDrawer">
<span class="material-symbols text-2xl">arrow_back</span> <span class="material-symbols text-2xl">arrow_back</span>
</div> </div>
@@ -53,7 +53,6 @@
@showBookmarks="showBookmarks" @showBookmarks="showBookmarks"
@showSleepTimer="showSleepTimerModal = true" @showSleepTimer="showSleepTimerModal = true"
@showPlayerQueueItems="showPlayerQueueItemsModal = true" @showPlayerQueueItems="showPlayerQueueItemsModal = true"
@showPlayerSettings="showPlayerSettingsModal = true"
/> />
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :current-time="bookmarkCurrentTime" :library-item-id="libraryItemId" @select="selectBookmark" /> <modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :current-time="bookmarkCurrentTime" :library-item-id="libraryItemId" @select="selectBookmark" />
@@ -61,8 +60,6 @@
<modals-sleep-timer-modal v-model="showSleepTimerModal" :timer-set="sleepTimerSet" :timer-type="sleepTimerType" :remaining="sleepTimerRemaining" :has-chapters="!!chapters.length" @set="setSleepTimer" @cancel="cancelSleepTimer" @increment="incrementSleepTimer" @decrement="decrementSleepTimer" /> <modals-sleep-timer-modal v-model="showSleepTimerModal" :timer-set="sleepTimerSet" :timer-type="sleepTimerType" :remaining="sleepTimerRemaining" :has-chapters="!!chapters.length" @set="setSleepTimer" @cancel="cancelSleepTimer" @increment="incrementSleepTimer" @decrement="decrementSleepTimer" />
<modals-player-queue-items-modal v-model="showPlayerQueueItemsModal" /> <modals-player-queue-items-modal v-model="showPlayerQueueItemsModal" />
<modals-player-settings-modal v-model="showPlayerSettingsModal" />
</div> </div>
</template> </template>
@@ -81,7 +78,6 @@ export default {
currentTime: 0, currentTime: 0,
showSleepTimerModal: false, showSleepTimerModal: false,
showPlayerQueueItemsModal: false, showPlayerQueueItemsModal: false,
showPlayerSettingsModal: false,
sleepTimerSet: false, sleepTimerSet: false,
sleepTimerRemaining: 0, sleepTimerRemaining: 0,
sleepTimerType: null, sleepTimerType: null,
+2 -2
View File
@@ -1,9 +1,9 @@
<template> <template>
<div class="w-20 bg-bg h-full fixed left-0 box-shadow-side z-50" style="min-width: 80px" :style="{ top: offsetTop + 'px' }"> <div role="toolbar" aria-orientation="vertical" aria-label="Library Sidebar" class="w-20 bg-bg h-full fixed left-0 box-shadow-side z-50" style="min-width: 80px" :style="{ top: offsetTop + 'px' }">
<!-- ugly little workaround to cover up the shadow overlapping the bookshelf toolbar --> <!-- ugly little workaround to cover up the shadow overlapping the bookshelf toolbar -->
<div v-if="isShowingBookshelfToolbar" class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" /> <div v-if="isShowingBookshelfToolbar" class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" />
<div id="siderail-buttons-container" :class="{ 'player-open': streamLibraryItem }" class="w-full overflow-y-auto overflow-x-hidden"> <div id="siderail-buttons-container" role="navigation" aria-label="Library Navigation" :class="{ 'player-open': streamLibraryItem }" class="w-full overflow-y-auto overflow-x-hidden">
<nuxt-link :to="`/library/${currentLibraryId}`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> <nuxt-link :to="`/library/${currentLibraryId}`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
+2 -2
View File
@@ -1,5 +1,5 @@
<template> <template>
<div class="pb-3e" :style="{ minWidth: cardWidth + 'px', maxWidth: cardWidth + 'px' }"> <article class="pb-3e" :style="{ minWidth: cardWidth + 'px', maxWidth: cardWidth + 'px' }">
<nuxt-link :to="`/author/${author?.id}`"> <nuxt-link :to="`/author/${author?.id}`">
<div cy-id="card" @mouseover="mouseover" @mouseleave="mouseleave"> <div cy-id="card" @mouseover="mouseover" @mouseleave="mouseleave">
<div cy-id="imageArea" :style="{ height: cardHeight + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden"> <div cy-id="imageArea" :style="{ height: cardHeight + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
@@ -34,7 +34,7 @@
</div> </div>
</div> </div>
</nuxt-link> </nuxt-link>
</div> </article>
</template> </template>
<script> <script>
+10 -10
View File
@@ -1,5 +1,5 @@
<template> <template>
<div ref="card" :id="`book-card-${index}`" :style="{ minWidth: coverWidth + 'px', maxWidth: coverWidth + 'px' }" class="absolute rounded-sm z-10 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard"> <article ref="card" :id="`book-card-${index}`" tabindex="0" :aria-label="displayTitle" :style="{ minWidth: coverWidth + 'px', maxWidth: coverWidth + 'px' }" class="absolute rounded-sm z-10 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
<div :id="`cover-area-${index}`" class="relative w-full top-0 left-0 rounded overflow-hidden z-10 bg-primary box-shadow-book" :style="{ height: coverHeight + 'px ' }"> <div :id="`cover-area-${index}`" class="relative w-full top-0 left-0 rounded overflow-hidden z-10 bg-primary box-shadow-book" :style="{ height: coverHeight + 'px ' }">
<!-- When cover image does not fill --> <!-- When cover image does not fill -->
<div cy-id="coverBg" v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary"> <div cy-id="coverBg" v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary">
@@ -14,21 +14,21 @@
</div> </div>
<div class="w-full h-full absolute top-0 left-0 rounded overflow-hidden z-10"> <div class="w-full h-full absolute top-0 left-0 rounded overflow-hidden z-10">
<div cy-id="titleImageNotReady" v-show="libraryItem && !imageReady" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: 0.5 + 'em' }"> <div cy-id="titleImageNotReady" v-show="libraryItem && !imageReady" aria-hidden="true" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: 0.5 + 'em' }">
<p :style="{ fontSize: 0.8 + 'em' }" class="text-gray-300 text-center">{{ title }}</p> <p :style="{ fontSize: 0.8 + 'em' }" class="text-gray-300 text-center">{{ title }}</p>
</div> </div>
<!-- Cover Image --> <!-- Cover Image -->
<img cy-id="coverImage" v-show="libraryItem" ref="cover" :src="bookCoverSrc" class="relative w-full h-full transition-opacity duration-300" :class="showCoverBg ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" /> <img cy-id="coverImage" v-show="libraryItem" :alt="`${displayTitle}, ${$strings.LabelCover}`" ref="cover" aria-hidden="true" :src="bookCoverSrc" class="relative w-full h-full transition-opacity duration-300" :class="showCoverBg ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" />
<!-- Placeholder Cover Title & Author --> <!-- Placeholder Cover Title & Author -->
<div cy-id="placeholderTitle" v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'em' }"> <div cy-id="placeholderTitle" v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'em' }">
<div> <div>
<p cy-id="placeholderTitleText" class="text-center" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'em' }">{{ titleCleaned }}</p> <p cy-id="placeholderTitleText" aria-hidden="true" class="text-center" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'em' }">{{ titleCleaned }}</p>
</div> </div>
</div> </div>
<div cy-id="placeholderAuthor" v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'em', bottom: authorBottom + 'em' }"> <div cy-id="placeholderAuthor" v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'em', bottom: authorBottom + 'em' }">
<p cy-id="placeholderAuthorText" class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'em' }">{{ authorCleaned }}</p> <p cy-id="placeholderAuthorText" aria-hidden="true" class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'em' }">{{ authorCleaned }}</p>
</div> </div>
<div v-if="seriesSequenceList" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20 text-right" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `${0.1}em ${0.25}em` }" style="background-color: #78350f"> <div v-if="seriesSequenceList" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20 text-right" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `${0.1}em ${0.25}em` }" style="background-color: #78350f">
@@ -93,11 +93,11 @@
<!-- rss feed icon --> <!-- rss feed icon -->
<div cy-id="rssFeed" v-if="rssFeed && !isSelectionMode && !isHovering" class="absolute text-success top-0 left-0 z-10" :style="{ padding: 0.375 + 'em' }"> <div cy-id="rssFeed" v-if="rssFeed && !isSelectionMode && !isHovering" class="absolute text-success top-0 left-0 z-10" :style="{ padding: 0.375 + 'em' }">
<span class="material-symbols" :style="{ fontSize: 1.5 + 'em' }">rss_feed</span> <span class="material-symbols" aria-hidden="true" :style="{ fontSize: 1.5 + 'em' }">rss_feed</span>
</div> </div>
<!-- media item shared icon --> <!-- media item shared icon -->
<div cy-id="mediaItemShare" v-if="mediaItemShare && !isSelectionMode && !isHovering" class="absolute text-success left-0 z-10" :style="{ padding: 0.375 + 'em', top: rssFeed ? '2em' : '0px' }"> <div cy-id="mediaItemShare" v-if="mediaItemShare && !isSelectionMode && !isHovering" class="absolute text-success left-0 z-10" :style="{ padding: 0.375 + 'em', top: rssFeed ? '2em' : '0px' }">
<span class="material-symbols" :style="{ fontSize: 1.5 + 'em' }">public</span> <span class="material-symbols" aria-hidden="true" :style="{ fontSize: 1.5 + 'em' }">public</span>
</div> </div>
<!-- Series sequence --> <!-- Series sequence -->
@@ -114,7 +114,7 @@
<!-- Podcast Num Episodes --> <!-- Podcast Num Episodes -->
<div cy-id="numEpisodes" v-else-if="!numEpisodesIncomplete && numEpisodes && !isHovering && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', width: 1.25 + 'em', height: 1.25 + 'em' }"> <div cy-id="numEpisodes" v-else-if="!numEpisodesIncomplete && numEpisodes && !isHovering && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', width: 1.25 + 'em', height: 1.25 + 'em' }">
<p :style="{ fontSize: 0.8 + 'em' }">{{ numEpisodes }}</p> <p :style="{ fontSize: 0.8 + 'em' }" role="status" :aria-label="$strings.LabelNumberOfEpisodes">{{ numEpisodes }}</p>
</div> </div>
<!-- Podcast Num Episodes --> <!-- Podcast Num Episodes -->
@@ -128,7 +128,7 @@
<div cy-id="detailBottom" :id="`description-area-${index}`" v-if="isAlternativeBookshelfView || isAuthorBookshelfView" dir="auto" class="relative mt-2e mb-2e left-0 z-50 w-full"> <div cy-id="detailBottom" :id="`description-area-${index}`" v-if="isAlternativeBookshelfView || isAuthorBookshelfView" dir="auto" class="relative mt-2e mb-2e left-0 z-50 w-full">
<div :style="{ fontSize: 0.9 + 'em' }"> <div :style="{ fontSize: 0.9 + 'em' }">
<ui-tooltip v-if="displayTitle" :text="displayTitle" :disabled="!displayTitleTruncated" direction="bottom" :delayOnShow="500" class="flex items-center"> <ui-tooltip v-if="displayTitle" :text="displayTitle" :disabled="!displayTitleTruncated" direction="bottom" :delayOnShow="500" class="flex items-center">
<p cy-id="title" ref="displayTitle" class="truncate">{{ displayTitle }}</p> <p cy-id="title" ref="displayTitle" aria-hidden="true" class="truncate">{{ displayTitle }}</p>
<widgets-explicit-indicator cy-id="explicitIndicator" v-if="isExplicit" /> <widgets-explicit-indicator cy-id="explicitIndicator" v-if="isExplicit" />
</ui-tooltip> </ui-tooltip>
</div> </div>
@@ -138,7 +138,7 @@
<p cy-id="line2" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displayLineTwo || '&nbsp;' }}</p> <p cy-id="line2" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displayLineTwo || '&nbsp;' }}</p>
<p cy-id="line3" v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displaySortLine }}</p> <p cy-id="line3" v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displaySortLine }}</p>
</div> </div>
</div> </article>
</template> </template>
<script> <script>
@@ -1,5 +1,5 @@
<template> <template>
<div ref="card" :id="`collection-card-${index}`" :style="{ width: cardWidth + 'px' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard"> <div ref="card" :id="`collection-card-${index}`" role="button" :style="{ width: cardWidth + 'px' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
<div class="relative" :style="{ height: coverHeight + 'px' }"> <div class="relative" :style="{ height: coverHeight + 'px' }">
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" /> <div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
<div class="w-full h-full bg-primary relative rounded overflow-hidden"> <div class="w-full h-full bg-primary relative rounded overflow-hidden">
+1 -1
View File
@@ -1,5 +1,5 @@
<template> <template>
<div ref="card" :id="`playlist-card-${index}`" :style="{ width: cardWidth + 'px', fontSize: sizeMultiplier + 'rem' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard"> <div ref="card" :id="`playlist-card-${index}`" role="button" :style="{ width: cardWidth + 'px', fontSize: sizeMultiplier + 'rem' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
<div class="relative" :style="{ height: coverHeight + 'px' }"> <div class="relative" :style="{ height: coverHeight + 'px' }">
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" /> <div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
<div class="w-full h-full bg-primary relative rounded overflow-hidden"> <div class="w-full h-full bg-primary relative rounded overflow-hidden">
+6 -6
View File
@@ -1,5 +1,5 @@
<template> <template>
<div cy-id="card" ref="card" :id="`series-card-${index}`" :style="{ width: cardWidth + 'px' }" class="absolute rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard"> <article cy-id="card" ref="card" :id="`series-card-${index}`" tabindex="0" :aria-label="displayTitle" :style="{ width: cardWidth + 'px' }" class="absolute rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
<div cy-id="covers-area" class="relative" :style="{ height: coverHeight + 'px' }"> <div cy-id="covers-area" class="relative" :style="{ height: coverHeight + 'px' }">
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" /> <div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
<div class="w-full h-full bg-primary relative rounded overflow-hidden z-0"> <div class="w-full h-full bg-primary relative rounded overflow-hidden z-0">
@@ -7,12 +7,12 @@
</div> </div>
<div cy-id="seriesLengthMarker" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `0.1em 0.25em` }" style="background-color: #cd9d49dd"> <div cy-id="seriesLengthMarker" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `0.1em 0.25em` }" style="background-color: #cd9d49dd">
<p :style="{ fontSize: 0.8 + 'em' }">{{ books.length }}</p> <p :style="{ fontSize: 0.8 + 'em' }" role="status" :aria-label="$strings.LabelNumberOfBooks">{{ books.length }}</p>
</div> </div>
<div cy-id="seriesProgressBar" v-if="seriesPercentInProgress > 0" class="absolute bottom-0 left-0 h-1e shadow-sm max-w-full z-10 rounded-b w-full" :class="isSeriesFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: seriesPercentInProgress * 100 + '%' }" /> <div cy-id="seriesProgressBar" v-if="seriesPercentInProgress > 0" class="absolute bottom-0 left-0 h-1e shadow-sm max-w-full z-10 rounded-b w-full" :class="isSeriesFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: seriesPercentInProgress * 100 + '%' }" />
<div cy-id="hoveringDisplayTitle" v-if="hasValidCovers" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: '1em' }"> <div cy-id="hoveringDisplayTitle" v-if="hasValidCovers" aria-hidden="true" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: '1em' }">
<p :style="{ fontSize: 1.2 + 'em' }">{{ displayTitle }}</p> <p :style="{ fontSize: 1.2 + 'em' }">{{ displayTitle }}</p>
</div> </div>
@@ -21,14 +21,14 @@
<div cy-id="standardBottomText" v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-10 left-0 right-0 mx-auto -bottom-6e h-6e rounded-md text-center" :style="{ width: Math.min(200, cardWidth) + 'px' }"> <div cy-id="standardBottomText" v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-10 left-0 right-0 mx-auto -bottom-6e h-6e rounded-md text-center" :style="{ width: Math.min(200, cardWidth) + 'px' }">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0em 0.5em` }"> <div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0em 0.5em` }">
<p cy-id="standardBottomDisplayTitle" class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ displayTitle }}</p> <p cy-id="standardBottomDisplayTitle" class="truncate" aria-hidden="true" :style="{ fontSize: labelFontSize + 'em' }">{{ displayTitle }}</p>
</div> </div>
</div> </div>
<div cy-id="detailBottomText" v-else class="relative z-30 left-0 right-0 mx-auto py-1e rounded-md text-center"> <div cy-id="detailBottomText" v-else class="relative z-30 left-0 right-0 mx-auto py-1e rounded-md text-center">
<p cy-id="detailBottomDisplayTitle" class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ displayTitle }}</p> <p cy-id="detailBottomDisplayTitle" class="truncate" aria-hidden="true" :style="{ fontSize: labelFontSize + 'em' }">{{ displayTitle }}</p>
<p cy-id="detailBottomSortLine" v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displaySortLine }}</p> <p cy-id="detailBottomSortLine" v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displaySortLine }}</p>
</div> </div>
</div> </article>
</template> </template>
<script> <script>
+3 -3
View File
@@ -1,13 +1,13 @@
<template> <template>
<div class=""> <div class="">
<div class="w-full relative sm:w-80"> <div class="w-full relative sm:w-80">
<form @submit.prevent="submitSearch"> <form role="search" @submit.prevent="submitSearch">
<ui-text-input ref="input" v-model="search" :placeholder="$strings.PlaceholderSearch" @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" /> <ui-text-input ref="input" v-model="search" :placeholder="$strings.PlaceholderSearch" @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
</form> </form>
<div class="absolute top-0 right-0 bottom-0 h-full flex items-center px-2 text-gray-400 cursor-pointer" @click="clickClear"> <button :aria-hidden="!search" class="absolute top-0 right-0 bottom-0 h-full flex items-center px-2 text-gray-400 cursor-pointer" @click="clickClear">
<span v-if="!search" class="material-symbols" style="font-size: 1.2rem">&#xe8b6;</span> <span v-if="!search" class="material-symbols" style="font-size: 1.2rem">&#xe8b6;</span>
<span v-else class="material-symbols" style="font-size: 1.2rem">close</span> <span v-else class="material-symbols" style="font-size: 1.2rem">close</span>
</div> </button>
</div> </div>
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-full max-w-64 sm:max-w-80 sm:w-80 bg-bg border border-black-200 shadow-lg rounded-md py-1 px-2 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalSearchMenu" @mousedown.stop.prevent> <div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-full max-w-64 sm:max-w-80 sm:w-80 bg-bg border border-black-200 shadow-lg rounded-md py-1 px-2 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalSearchMenu" @mousedown.stop.prevent>
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label"> <ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
@@ -1,28 +1,30 @@
<template> <template>
<div ref="wrapper" class="relative" v-click-outside="clickOutside"> <div ref="wrapper" class="relative" v-click-outside="clickOutside">
<button type="button" class="relative w-full h-full bg-bg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu"> <div class="relative h-7">
<button type="button" class="relative w-full h-full bg-bg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="menu" :aria-expanded="showMenu" @click.prevent="showMenu = !showMenu">
<span class="flex items-center justify-between"> <span class="flex items-center justify-between">
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span> <span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
</span> </span>
</button>
<span v-if="selected === 'all'" class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none"> <span v-if="selected === 'all'" class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" /> <path fill-rule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg> </svg>
</span> </span>
<div v-else class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 cursor-pointer text-gray-400 hover:text-gray-200" @mousedown.stop @mouseup.stop @click.stop.prevent="clearSelected"> <button v-else :aria-label="$strings.ButtonClearFilter" class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 cursor-pointer text-gray-400 hover:text-gray-200" @mousedown.stop @mouseup.stop @click.stop.prevent="clearSelected">
<span class="material-symbols" style="font-size: 1.1rem">close</span> <span class="material-symbols" style="font-size: 1.1rem">close</span>
</div>
</button> </button>
</div>
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm libraryFilterMenu"> <div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm libraryFilterMenu">
<ul v-show="!sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label"> <ul v-show="!sublist" class="h-full w-full" role="menu">
<template v-for="item in selectItems"> <template v-for="item in selectItems">
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item)"> <li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="menuitem" :aria-haspopup="item.sublist ? '' : 'menu'" @click="clickedOption(item)">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="font-normal ml-3 block truncate text-sm">{{ item.text }}</span> <span class="font-normal ml-3 block truncate text-sm">{{ item.text }}</span>
</div> </div>
<div v-if="item.sublist" class="absolute right-1 top-0 bottom-0 h-full flex items-center"> <div v-if="item.sublist" class="absolute right-1 top-0 bottom-0 h-full flex items-center">
<span class="material-symbols text-2xl">arrow_right</span> <span class="material-symbols text-2xl" :aria-label="$strings.LabelMore">arrow_right</span>
</div> </div>
<!-- selected checkmark icon --> <!-- selected checkmark icon -->
<div v-if="item.value === selected" class="absolute inset-y-0 right-2 h-full flex items-center pointer-events-none"> <div v-if="item.value === selected" class="absolute inset-y-0 right-2 h-full flex items-center pointer-events-none">
@@ -31,8 +33,8 @@
</li> </li>
</template> </template>
</ul> </ul>
<ul v-show="sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label"> <ul v-show="sublist" class="h-full w-full" role="menu">
<li class="text-gray-50 select-none relative py-2 pl-9 cursor-pointer hover:bg-white/5" role="option" @click="sublist = null"> <li class="text-gray-50 select-none relative py-2 pl-9 cursor-pointer hover:bg-white/5" role="menuitem" @click="sublist = null">
<div class="absolute left-1 top-0 bottom-0 h-full flex items-center"> <div class="absolute left-1 top-0 bottom-0 h-full flex items-center">
<span class="material-symbols text-2xl">arrow_left</span> <span class="material-symbols text-2xl">arrow_left</span>
</div> </div>
@@ -40,13 +42,13 @@
<span class="font-normal block truncate">{{ $strings.ButtonBack }}</span> <span class="font-normal block truncate">{{ $strings.ButtonBack }}</span>
</div> </div>
</li> </li>
<li v-if="!sublistItems.length" class="text-gray-400 select-none relative px-2" role="option"> <li v-if="!sublistItems.length" class="text-gray-400 select-none relative px-2" role="menuitem">
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
<span class="font-normal block truncate py-2">{{ $getString('LabelLibraryFilterSublistEmpty', [selectedSublistText]) }}</span> <span class="font-normal block truncate py-2">{{ $getString('LabelLibraryFilterSublistEmpty', [selectedSublistText]) }}</span>
</div> </div>
</li> </li>
<template v-for="item in sublistItems"> <template v-for="item in sublistItems">
<li :key="item.value" class="select-none relative px-2 cursor-pointer hover:bg-white/5" :class="`${sublist}.${item.value}` === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedSublistOption(item.value)"> <li :key="item.value" class="select-none relative px-2 cursor-pointer hover:bg-white/5" :class="`${sublist}.${item.value}` === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="menuitem" @click="clickedSublistOption(item.value)">
<div class="flex items-center"> <div class="flex items-center">
<span class="font-normal truncate py-2 text-xs">{{ item.text }}</span> <span class="font-normal truncate py-2 text-xs">{{ item.text }}</span>
</div> </div>
@@ -1,20 +1,20 @@
<template> <template>
<div ref="wrapper" class="relative" v-click-outside="clickOutside"> <div ref="wrapper" class="relative" v-click-outside="clickOutside">
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu"> <button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="menu" :aria-expanded="showMenu" @click.prevent="showMenu = !showMenu">
<span class="flex items-center justify-between"> <span class="flex items-center justify-between">
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span> <span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
<span class="material-symbols text-lg text-yellow-400">{{ descending ? 'expand_more' : 'expand_less' }}</span> <span class="material-symbols text-lg text-yellow-400" :aria-label="descending ? $strings.LabelSortDescending : $strings.LabelSortAscending">{{ descending ? 'expand_more' : 'expand_less' }}</span>
</span> </span>
</button> </button>
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm" role="listbox" aria-labelledby="listbox-label"> <ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm" role="menu">
<template v-for="item in selectItems"> <template v-for="item in selectItems">
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item.value)"> <li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="menuitem" @click="clickedOption(item.value)">
<div class="flex items-center"> <div class="flex items-center">
<span class="font-normal ml-3 block truncate">{{ item.text }}</span> <span class="font-normal ml-3 block truncate">{{ item.text }}</span>
</div> </div>
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4"> <span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
<span class="material-symbols text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span> <span class="material-symbols text-xl" :aria-label="descending ? $strings.LabelSortDescending : $strings.LabelSortAscending">{{ descending ? 'expand_more' : 'expand_less' }}</span>
</span> </span>
</li> </li>
</template> </template>
+5 -5
View File
@@ -1,20 +1,20 @@
<template> <template>
<div ref="wrapper" class="relative" v-click-outside="clickOutside"> <div ref="wrapper" class="relative" v-click-outside="clickOutside">
<button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu"> <button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none cursor-pointer" aria-haspopup="menu" :aria-expanded="showMenu" @click.prevent="showMenu = !showMenu">
<span class="flex items-center justify-between"> <span class="flex items-center justify-between">
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span> <span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
<span class="material-symbols text-lg text-yellow-400">{{ descending ? 'expand_more' : 'expand_less' }}</span> <span class="material-symbols text-lg text-yellow-400" :aria-label="descending ? $strings.LabelSortDescending : $strings.LabelSortAscending">{{ descending ? 'expand_more' : 'expand_less' }}</span>
</span> </span>
</button> </button>
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm" role="listbox" aria-labelledby="listbox-label"> <ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm" role="menu">
<template v-for="item in items"> <template v-for="item in items">
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item.value)"> <li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="menuitem" @click="clickedOption(item.value)">
<div class="flex items-center"> <div class="flex items-center">
<span class="font-normal ml-3 block truncate">{{ item.text }}</span> <span class="font-normal ml-3 block truncate">{{ item.text }}</span>
</div> </div>
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4"> <span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
<span class="material-symbols text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span> <span class="material-symbols text-xl" :aria-label="descending ? $strings.LabelSortDescending : $strings.LabelSortAscending">{{ descending ? 'expand_more' : 'expand_less' }}</span>
</span> </span>
</li> </li>
</template> </template>
+2
View File
@@ -121,6 +121,8 @@ export default {
var img = document.createElement('img') var img = document.createElement('img')
img.src = src img.src = src
img.alt = `${this.name}, ${this.$strings.LabelCover}`
img.ariaHidden = true
img.className = 'absolute top-0 left-0 w-full h-full' img.className = 'absolute top-0 left-0 w-full h-full'
img.style.objectFit = showCoverBg ? 'contain' : 'cover' img.style.objectFit = showCoverBg ? 'contain' : 'cover'
+5 -2
View File
@@ -1,12 +1,12 @@
<template> <template>
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary items-center justify-center opacity-0 hidden" :class="`z-${zIndex} bg-opacity-${bgOpacity}`"> <div ref="wrapper" role="dialog" aria-modal="true" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary items-center justify-center opacity-0 hidden" :class="`z-${zIndex} bg-opacity-${bgOpacity}`">
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" /> <div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
<button class="absolute top-4 right-4 landscape:top-4 landscape:right-4 md:portrait:top-5 md:portrait:right-5 lg:top-5 lg:right-5 inline-flex text-gray-200 hover:text-white" aria-label="Close modal" @click="clickClose"> <button class="absolute top-4 right-4 landscape:top-4 landscape:right-4 md:portrait:top-5 md:portrait:right-5 lg:top-5 lg:right-5 inline-flex text-gray-200 hover:text-white" aria-label="Close modal" @click="clickClose">
<span class="material-symbols text-2xl landscape:text-2xl md:portrait:text-4xl lg:text-4xl">close</span> <span class="material-symbols text-2xl landscape:text-2xl md:portrait:text-4xl lg:text-4xl">close</span>
</button> </button>
<slot name="outer" /> <slot name="outer" />
<div ref="content" style="min-width: 380px; min-height: 200px; max-width: 100vw" class="relative text-white" aria-modal="true" :style="{ height: modalHeight, width: modalWidth, marginTop: contentMarginTop + 'px' }" @mousedown="mousedownModal" @mouseup="mouseupModal" v-click-outside="clickBg"> <div ref="content" tabindex="0" style="min-width: 380px; min-height: 200px; max-width: 100vw" class="relative text-white outline-none" :style="{ height: modalHeight, width: modalWidth, marginTop: contentMarginTop + 'px' }" @mousedown="mousedownModal" @mouseup="mouseupModal" v-click-outside="clickBg">
<slot /> <slot />
<div v-if="processing" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-black bg-opacity-60 rounded-lg flex items-center justify-center"> <div v-if="processing" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-black bg-opacity-60 rounded-lg flex items-center justify-center">
<ui-loading-indicator /> <ui-loading-indicator />
@@ -126,6 +126,9 @@ export default {
this.$eventBus.$on('modal-hotkey', this.hotkey) this.$eventBus.$on('modal-hotkey', this.hotkey)
this.$store.commit('setOpenModal', this.name) this.$store.commit('setOpenModal', this.name)
// Set focus to the modal content
this.content.focus()
}, },
setHide() { setHide() {
if (this.content) this.content.style.transform = 'scale(0)' if (this.content) this.content.style.transform = 'scale(0)'
@@ -59,12 +59,19 @@ export default {
setJumpBackwardAmount(val) { setJumpBackwardAmount(val) {
this.jumpBackwardAmount = val this.jumpBackwardAmount = val
this.$store.dispatch('user/updateUserSettings', { jumpBackwardAmount: val }) this.$store.dispatch('user/updateUserSettings', { jumpBackwardAmount: val })
}
}, },
mounted() { settingsUpdated() {
this.useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack') this.useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack')
this.jumpForwardAmount = this.$store.getters['user/getUserSetting']('jumpForwardAmount') this.jumpForwardAmount = this.$store.getters['user/getUserSetting']('jumpForwardAmount')
this.jumpBackwardAmount = this.$store.getters['user/getUserSetting']('jumpBackwardAmount') this.jumpBackwardAmount = this.$store.getters['user/getUserSetting']('jumpBackwardAmount')
} }
},
mounted() {
this.settingsUpdated()
this.$eventBus.$on('user-settings', this.settingsUpdated)
},
beforeDestroy() {
this.$eventBus.$off('user-settings', this.settingsUpdated)
}
} }
</script> </script>
@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="changelog" :width="800" :height="'unset'"> <modals-modal v-model="show" name="changelog" :width="800" :height="'unset'">
<template #outer> <template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden"> <div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="text-3xl text-white truncate">Changelog</p> <h1 class="text-3xl text-white truncate">Changelog</h1>
</div> </div>
</template> </template>
<div class="px-8 py-6 w-full rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-y-scroll" style="max-height: 80vh"> <div class="px-8 py-6 w-full rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-y-scroll" style="max-height: 80vh">
@@ -13,7 +13,7 @@
</p> </p>
<div class="custom-text" v-html="getChangelog(release)" /> <div class="custom-text" v-html="getChangelog(release)" />
</div> </div>
<div v-if="release !== releasesToShow[releasesToShow.length - 1]" class="border-b border-black-300 my-8" /> <div v-if="release !== releasesToShow[releasesToShow.length - 1]" :key="`${release.name}-divider`" class="border-b border-black-300 my-8" />
</template> </template>
</div> </div>
</modals-modal> </modals-modal>
@@ -18,6 +18,23 @@
<p dir="auto" class="text-lg font-semibold mb-6">{{ title }}</p> <p dir="auto" class="text-lg font-semibold mb-6">{{ title }}</p>
<div v-if="description" dir="auto" class="default-style" v-html="description" /> <div v-if="description" dir="auto" class="default-style" v-html="description" />
<p v-else class="mb-2">{{ $strings.MessageNoDescription }}</p> <p v-else class="mb-2">{{ $strings.MessageNoDescription }}</p>
<div class="w-full h-px bg-white/5 my-4" />
<div class="flex items-center">
<div class="flex-grow">
<p class="font-semibold text-xs mb-1">{{ $strings.LabelFilename }}</p>
<p class="mb-2 text-xs">
{{ audioFileFilename }}
</p>
</div>
<div class="flex-grow">
<p class="font-semibold text-xs mb-1">{{ $strings.LabelSize }}</p>
<p class="mb-2 text-xs">
{{ audioFileSize }}
</p>
</div>
</div>
</div> </div>
</modals-modal> </modals-modal>
</template> </template>
@@ -54,7 +71,7 @@ export default {
return this.episode.description || '' return this.episode.description || ''
}, },
media() { media() {
return this.libraryItem ? this.libraryItem.media || {} : {} return this.libraryItem?.media || {}
}, },
mediaMetadata() { mediaMetadata() {
return this.media.metadata || {} return this.media.metadata || {}
@@ -65,6 +82,14 @@ export default {
podcastAuthor() { podcastAuthor() {
return this.mediaMetadata.author return this.mediaMetadata.author
}, },
audioFileFilename() {
return this.episode.audioFile?.metadata?.filename || ''
},
audioFileSize() {
const size = this.episode.audioFile?.metadata?.size || 0
return this.$bytesPretty(size)
},
bookCoverAspectRatio() { bookCoverAspectRatio() {
return this.$store.getters['libraries/getBookCoverAspectRatio'] return this.$store.getters['libraries/getBookCoverAspectRatio']
} }
@@ -10,9 +10,9 @@
<p class="text-lg font-semibold mb-4">{{ $strings.HeaderRSSFeedIsOpen }}</p> <p class="text-lg font-semibold mb-4">{{ $strings.HeaderRSSFeedIsOpen }}</p>
<div class="w-full relative"> <div class="w-full relative">
<ui-text-input v-model="currentFeed.feedUrl" readonly /> <ui-text-input :value="feedUrl" readonly />
<span class="material-symbols absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(currentFeed.feedUrl)">content_copy</span> <span class="material-symbols absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(feedUrl)">content_copy</span>
</div> </div>
<div v-if="currentFeed.meta" class="mt-5"> <div v-if="currentFeed.meta" class="mt-5">
@@ -111,8 +111,11 @@ export default {
userIsAdminOrUp() { userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp'] return this.$store.getters['user/getIsAdminOrUp']
}, },
feedUrl() {
return this.currentFeed ? `${window.origin}${this.$config.routerBasePath}${this.currentFeed.feedUrl}` : ''
},
demoFeedUrl() { demoFeedUrl() {
return `${window.origin}/feed/${this.newFeedSlug}` return `${window.origin}${this.$config.routerBasePath}/feed/${this.newFeedSlug}`
}, },
isHttp() { isHttp() {
return window.origin.startsWith('http://') return window.origin.startsWith('http://')
@@ -5,8 +5,8 @@
<p class="text-lg font-semibold mb-4">{{ $strings.HeaderRSSFeedGeneral }}</p> <p class="text-lg font-semibold mb-4">{{ $strings.HeaderRSSFeedGeneral }}</p>
<div class="w-full relative"> <div class="w-full relative">
<ui-text-input v-model="feed.feedUrl" readonly /> <ui-text-input :value="feedUrl" readonly />
<span class="material-symbols absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(feed.feedUrl)">content_copy</span> <span class="material-symbols absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(feedUrl)">content_copy</span>
</div> </div>
<div v-if="feed.meta" class="mt-5"> <div v-if="feed.meta" class="mt-5">
@@ -70,6 +70,9 @@ export default {
}, },
_feed() { _feed() {
return this.feed || {} return this.feed || {}
},
feedUrl() {
return this.feed ? `${window.origin}${this.$config.routerBasePath}${this.feed.feedUrl}` : ''
} }
}, },
methods: { methods: {
+7 -1
View File
@@ -37,7 +37,7 @@
</ui-tooltip> </ui-tooltip>
<ui-tooltip direction="top" :text="$strings.LabelViewPlayerSettings"> <ui-tooltip direction="top" :text="$strings.LabelViewPlayerSettings">
<button :aria-label="$strings.LabelViewPlayerSettings" class="outline-none text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showPlayerSettings')"> <button :aria-label="$strings.LabelViewPlayerSettings" class="outline-none text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="showPlayerSettings">
<span class="material-symbols text-2xl sm:text-2.5xl">settings_slow_motion</span> <span class="material-symbols text-2xl sm:text-2.5xl">settings_slow_motion</span>
</button> </button>
</ui-tooltip> </ui-tooltip>
@@ -64,6 +64,8 @@
</div> </div>
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :playback-rate="playbackRate" :chapters="chapters" @select="selectChapter" /> <modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :playback-rate="playbackRate" :chapters="chapters" @select="selectChapter" />
<modals-player-settings-modal v-model="showPlayerSettingsModal" />
</div> </div>
</template> </template>
@@ -96,6 +98,7 @@ export default {
audioEl: null, audioEl: null,
seekLoading: false, seekLoading: false,
showChaptersModal: false, showChaptersModal: false,
showPlayerSettingsModal: false,
currentTime: 0, currentTime: 0,
duration: 0 duration: 0
} }
@@ -315,6 +318,9 @@ export default {
if (!this.chapters.length) return if (!this.chapters.length) return
this.showChaptersModal = !this.showChaptersModal this.showChaptersModal = !this.showChaptersModal
}, },
showPlayerSettings() {
this.showPlayerSettingsModal = !this.showPlayerSettingsModal
},
init() { init() {
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1 this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
+1
View File
@@ -120,6 +120,7 @@ export default {
this.users = res.users.sort((a, b) => { this.users = res.users.sort((a, b) => {
return a.createdAt - b.createdAt return a.createdAt - b.createdAt
}) })
this.$emit('numUsers', this.users.length)
}) })
.catch((error) => { .catch((error) => {
console.error('Failed', error) console.error('Failed', error)
+8 -8
View File
@@ -1,7 +1,7 @@
<template> <template>
<div class="relative h-9 w-9" v-click-outside="clickOutsideObj"> <div class="relative h-9 w-9" v-click-outside="clickOutsideObj">
<slot :disabled="disabled" :showMenu="showMenu" :clickShowMenu="clickShowMenu" :processing="processing"> <slot :disabled="disabled" :showMenu="showMenu" :clickShowMenu="clickShowMenu" :processing="processing">
<button v-if="!processing" type="button" :disabled="disabled" class="relative h-full w-full flex items-center justify-center shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu"> <button v-if="!processing" type="button" :disabled="disabled" class="relative h-full w-full flex items-center justify-center shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5" :aria-label="$strings.LabelMore" aria-haspopup="menu" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
<span class="material-symbols text-2xl" :class="iconClass">&#xe5d4;</span> <span class="material-symbols text-2xl" :class="iconClass">&#xe5d4;</span>
</button> </button>
<div v-else class="h-full w-full flex items-center justify-center"> <div v-else class="h-full w-full flex items-center justify-center">
@@ -10,12 +10,12 @@
</slot> </slot>
<transition name="menu"> <transition name="menu">
<div v-show="showMenu" ref="menuWrapper" class="absolute right-0 mt-1 z-10 bg-bg border border-black-200 shadow-lg rounded-md py-1 focus:outline-none sm:text-sm" :style="{ width: menuWidth + 'px' }"> <div v-show="showMenu" ref="menuWrapper" role="menu" class="absolute right-0 mt-1 z-10 bg-bg border border-black-200 shadow-lg rounded-md py-1 focus:outline-none sm:text-sm" :style="{ width: menuWidth + 'px' }">
<template v-for="(item, index) in items"> <template v-for="(item, index) in items">
<template v-if="item.subitems"> <template v-if="item.subitems">
<div :key="index" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-default" :class="{ 'bg-white/5': mouseoverItemIndex == index }" @mouseover="mouseoverItem(index)" @mouseleave="mouseleaveItem(index)" @click.stop> <button :key="index" role="menuitem" aria-haspopup="menu" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-default w-full" :class="{ 'bg-white/5': mouseoverItemIndex == index }" @mouseover="mouseoverItem(index)" @mouseleave="mouseleaveItem(index)" @click.stop>
<p>{{ item.text }}</p> <p>{{ item.text }}</p>
</div> </button>
<div <div
v-if="mouseoverItemIndex === index" v-if="mouseoverItemIndex === index"
:key="`subitems-${index}`" :key="`subitems-${index}`"
@@ -25,14 +25,14 @@
:class="openSubMenuLeft ? 'rounded-l-md' : 'rounded-r-md'" :class="openSubMenuLeft ? 'rounded-l-md' : 'rounded-r-md'"
:style="{ left: submenuLeftPos + 'px', top: index * 28 + 'px', width: submenuWidth + 'px' }" :style="{ left: submenuLeftPos + 'px', top: index * 28 + 'px', width: submenuWidth + 'px' }"
> >
<div v-for="(subitem, subitemindex) in item.subitems" :key="`subitem-${subitemindex}`" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer" @click.stop="clickAction(subitem.action, subitem.data)"> <button v-for="(subitem, subitemindex) in item.subitems" :key="`subitem-${subitemindex}`" role="menuitem" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer w-full" @click.stop="clickAction(subitem.action, subitem.data)">
<p>{{ subitem.text }}</p> <p>{{ subitem.text }}</p>
</div> </button>
</div> </div>
</template> </template>
<div v-else :key="index" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer" @click.stop="clickAction(item.action)"> <button v-else :key="index" role="menuitem" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer w-full" @click.stop="clickAction(item.action)">
<p class="text-left">{{ item.text }}</p> <p class="text-left">{{ item.text }}</p>
</div> </button>
</template> </template>
</div> </div>
</transition> </transition>
+3 -2
View File
@@ -1,5 +1,5 @@
<template> <template>
<button class="icon-btn rounded-md flex items-center justify-center relative" @mousedown.prevent :disabled="disabled || loading" :class="className" @click="clickBtn"> <button :aria-label="ariaLabel" class="icon-btn rounded-md flex items-center justify-center relative" @mousedown.prevent :disabled="disabled || loading" :class="className" @click="clickBtn">
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100"> <div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24"> <svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" /> <path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
@@ -28,7 +28,8 @@ export default {
size: { size: {
type: Number, type: Number,
default: 9 default: 9
} },
ariaLabel: String
}, },
data() { data() {
return {} return {}
+3 -3
View File
@@ -4,7 +4,7 @@
type="button" type="button"
:disabled="disabled" :disabled="disabled"
class="w-10 sm:w-full relative h-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm px-2 text-left text-sm cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200" class="w-10 sm:w-full relative h-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm px-2 text-left text-sm cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200"
aria-haspopup="listbox" aria-haspopup="menu"
:aria-expanded="showMenu" :aria-expanded="showMenu"
:aria-label="$strings.ButtonLibrary + ': ' + currentLibrary.name" :aria-label="$strings.ButtonLibrary + ': ' + currentLibrary.name"
@click.stop.prevent="clickShowMenu" @click.stop.prevent="clickShowMenu"
@@ -16,9 +16,9 @@
</button> </button>
<transition name="menu"> <transition name="menu">
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full min-w-48 bg-primary border border-black-200 shadow-lg rounded-b-md py-1 overflow-auto focus:outline-none sm:text-sm librariesDropdownMenu" tabindex="-1" role="listbox"> <ul v-show="showMenu" class="absolute z-10 -mt-px w-full min-w-48 bg-primary border border-black-200 shadow-lg rounded-b-md py-1 overflow-auto focus:outline-none sm:text-sm librariesDropdownMenu" tabindex="-1" role="menu">
<template v-for="library in librariesFiltered"> <template v-for="library in librariesFiltered">
<li :key="library.id" class="text-gray-400 hover:text-white relative py-2 cursor-pointer hover:bg-black-400" role="option" tabindex="0" @keydown.enter="selectLibrary(library)" @click="selectLibrary(library)"> <li :key="library.id" class="text-gray-400 hover:text-white relative py-2 cursor-pointer hover:bg-black-400" role="menuitem" tabindex="0" @keydown.enter="selectLibrary(library)" @click="selectLibrary(library)">
<div class="flex items-center px-2"> <div class="flex items-center px-2">
<ui-library-icon :icon="library.icon" class="mr-1.5" /> <ui-library-icon :icon="library.icon" class="mr-1.5" />
<span class="font-normal block truncate font-sans text-sm">{{ library.name }}</span> <span class="font-normal block truncate font-sans text-sm">{{ library.name }}</span>
+1 -1
View File
@@ -1,5 +1,5 @@
<template> <template>
<button class="icon-btn rounded-md flex items-center justify-center h-9 w-9 relative" :class="borderless ? '' : 'bg-primary border border-gray-600'" @click="clickBtn"> <button :aria-label="isRead ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" class="icon-btn rounded-md flex items-center justify-center h-9 w-9 relative" :class="borderless ? '' : 'bg-primary border border-gray-600'" @click="clickBtn">
<div class="w-5 h-5 text-white relative"> <div class="w-5 h-5 text-white relative">
<svg v-if="isRead" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="rgb(63, 181, 68)"> <svg v-if="isRead" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="rgb(63, 181, 68)">
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z" /> <path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z" />
@@ -1,6 +1,6 @@
<template> <template>
<div> <div>
<div class="rounded-full py-1 bg-primary px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent> <div aria-hidden="true" class="rounded-full py-1 bg-primary px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent>
<span class="material-symbols" :class="selectedSizeIndex === 0 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="decreaseSize" aria-label="Decrease Cover Size" role="button">&#xe15b;</span> <span class="material-symbols" :class="selectedSizeIndex === 0 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="decreaseSize" aria-label="Decrease Cover Size" role="button">&#xe15b;</span>
<p class="px-2 font-mono" style="font-size: 1rem">{{ bookCoverWidth }}</p> <p class="px-2 font-mono" style="font-size: 1rem">{{ bookCoverWidth }}</p>
<span class="material-symbols" :class="selectedSizeIndex === availableSizes.length - 1 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="increaseSize" aria-label="Increase Cover Size" role="button">&#xe145;</span> <span class="material-symbols" :class="selectedSizeIndex === availableSizes.length - 1 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="increaseSize" aria-label="Increase Cover Size" role="button">&#xe145;</span>
+2 -2
View File
@@ -3,10 +3,10 @@
<div class="flex items-center py-3e"> <div class="flex items-center py-3e">
<slot /> <slot />
<div class="flex-grow" /> <div class="flex-grow" />
<button cy-id="leftScrollButton" v-if="isScrollable" class="w-8e h-8e mx-1e flex items-center justify-center rounded-full" :class="canScrollLeft ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollLeft"> <button cy-id="leftScrollButton" v-if="isScrollable" :aria-label="$strings.ButtonScrollLeft" class="w-8e h-8e mx-1e flex items-center justify-center rounded-full" :class="canScrollLeft ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollLeft">
<span class="material-symbols" :style="{ fontSize: 1.5 + 'em' }">chevron_left</span> <span class="material-symbols" :style="{ fontSize: 1.5 + 'em' }">chevron_left</span>
</button> </button>
<button cy-id="rightScrollButton" v-if="isScrollable" class="w-8e h-8e mx-1e flex items-center justify-center rounded-full" :class="canScrollRight ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollRight"> <button cy-id="rightScrollButton" v-if="isScrollable" :aria-label="$strings.ButtonScrollRight" class="w-8e h-8e mx-1e flex items-center justify-center rounded-full" :class="canScrollRight ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollRight">
<span class="material-symbols" :style="{ fontSize: 1.5 + 'em' }">chevron_right</span> <span class="material-symbols" :style="{ fontSize: 1.5 + 'em' }">chevron_right</span>
</button> </button>
</div> </div>
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.17.2", "version": "2.17.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.17.2", "version": "2.17.5",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@nuxtjs/axios": "^5.13.6", "@nuxtjs/axios": "^5.13.6",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.17.2", "version": "2.17.5",
"buildNumber": 1, "buildNumber": 1,
"description": "Self-hosted audiobook and podcast client", "description": "Self-hosted audiobook and podcast client",
"main": "index.js", "main": "index.js",
+37 -1
View File
@@ -64,6 +64,20 @@
<ui-multi-select ref="redirectUris" v-model="newAuthSettings.authOpenIDMobileRedirectURIs" :items="newAuthSettings.authOpenIDMobileRedirectURIs" :label="$strings.LabelMobileRedirectURIs" class="mb-2" :menuDisabled="true" :disabled="savingSettings" /> <ui-multi-select ref="redirectUris" v-model="newAuthSettings.authOpenIDMobileRedirectURIs" :items="newAuthSettings.authOpenIDMobileRedirectURIs" :label="$strings.LabelMobileRedirectURIs" class="mb-2" :menuDisabled="true" :disabled="savingSettings" />
<p class="sm:pl-4 text-sm text-gray-300 mb-2" v-html="$strings.LabelMobileRedirectURIsDescription" /> <p class="sm:pl-4 text-sm text-gray-300 mb-2" v-html="$strings.LabelMobileRedirectURIsDescription" />
<div class="flex sm:items-center flex-col sm:flex-row pt-1 mb-2">
<div class="w-44">
<ui-dropdown v-model="newAuthSettings.authOpenIDSubfolderForRedirectURLs" small :items="subfolderOptions" :label="$strings.LabelWebRedirectURLsSubfolder" :disabled="savingSettings" />
</div>
<div class="mt-2 sm:mt-5">
<p class="sm:pl-4 text-sm text-gray-300">{{ $strings.LabelWebRedirectURLsDescription }}</p>
<p class="sm:pl-4 text-sm text-gray-300 mb-2">
<code>{{ webCallbackURL }}</code>
<br />
<code>{{ mobileAppCallbackURL }}</code>
</p>
</div>
</div>
<ui-text-input-with-label ref="buttonTextInput" v-model="newAuthSettings.authOpenIDButtonText" :disabled="savingSettings" :label="$strings.LabelButtonText" class="mb-2" /> <ui-text-input-with-label ref="buttonTextInput" v-model="newAuthSettings.authOpenIDButtonText" :disabled="savingSettings" :label="$strings.LabelButtonText" class="mb-2" />
<div class="flex sm:items-center flex-col sm:flex-row pt-1 mb-2"> <div class="flex sm:items-center flex-col sm:flex-row pt-1 mb-2">
@@ -164,6 +178,27 @@ export default {
value: 'username' value: 'username'
} }
] ]
},
subfolderOptions() {
const options = [
{
text: 'None',
value: ''
}
]
if (this.$config.routerBasePath) {
options.push({
text: this.$config.routerBasePath,
value: this.$config.routerBasePath
})
}
return options
},
webCallbackURL() {
return `https://<your.server.com>${this.newAuthSettings.authOpenIDSubfolderForRedirectURLs ? this.newAuthSettings.authOpenIDSubfolderForRedirectURLs : ''}/auth/openid/callback`
},
mobileAppCallbackURL() {
return `https://<your.server.com>${this.newAuthSettings.authOpenIDSubfolderForRedirectURLs ? this.newAuthSettings.authOpenIDSubfolderForRedirectURLs : ''}/auth/openid/mobile-redirect`
} }
}, },
methods: { methods: {
@@ -325,7 +360,8 @@ export default {
}, },
init() { init() {
this.newAuthSettings = { this.newAuthSettings = {
...this.authSettings ...this.authSettings,
authOpenIDSubfolderForRedirectURLs: this.authSettings.authOpenIDSubfolderForRedirectURLs === undefined ? this.$config.routerBasePath : this.authSettings.authOpenIDSubfolderForRedirectURLs
} }
this.enableLocalAuth = this.authMethods.includes('local') this.enableLocalAuth = this.authMethods.includes('local')
this.enableOpenIDAuth = this.authMethods.includes('openid') this.enableOpenIDAuth = this.authMethods.includes('openid')
+22 -13
View File
@@ -42,11 +42,6 @@
</div> </div>
</div> </div>
<div class="flex items-center py-2 mb-2">
<ui-toggle-switch labeledBy="settings-chromecast-support" v-model="newServerSettings.chromecastEnabled" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('chromecastEnabled', val)" />
<p class="pl-4" id="settings-chromecast-support">{{ $strings.LabelSettingsChromecastSupport }}</p>
</div>
<div class="pt-4"> <div class="pt-4">
<h2 class="font-semibold">{{ $strings.HeaderSettingsScanner }}</h2> <h2 class="font-semibold">{{ $strings.HeaderSettingsScanner }}</h2>
</div> </div>
@@ -94,6 +89,20 @@
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> </div>
<div class="pt-4">
<h2 class="font-semibold">{{ $strings.HeaderSettingsWebClient }}</h2>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch labeledBy="settings-chromecast-support" v-model="newServerSettings.chromecastEnabled" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('chromecastEnabled', val)" />
<p class="pl-4" id="settings-chromecast-support">{{ $strings.LabelSettingsChromecastSupport }}</p>
</div>
<div class="flex items-center py-2 mb-2">
<ui-toggle-switch labeledBy="settings-allow-iframe" v-model="newServerSettings.allowIframe" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('allowIframe', val)" />
<p class="pl-4" id="settings-allow-iframe">{{ $strings.LabelSettingsAllowIframe }}</p>
</div>
</div> </div>
<div class="flex-1"> <div class="flex-1">
@@ -324,21 +333,21 @@ export default {
}, },
updateServerSettings(payload) { updateServerSettings(payload) {
this.updatingServerSettings = true this.updatingServerSettings = true
this.$store this.$store.dispatch('updateServerSettings', payload).then((response) => {
.dispatch('updateServerSettings', payload)
.then(() => {
this.updatingServerSettings = false this.updatingServerSettings = false
if (response.error) {
console.error('Failed to update server settins', response.error)
this.$toast.error(response.error)
this.initServerSettings()
return
}
if (payload.language) { if (payload.language) {
// Updating language after save allows for re-rendering // Updating language after save allows for re-rendering
this.$setLanguageCode(payload.language) this.$setLanguageCode(payload.language)
} }
}) })
.catch((error) => {
console.error('Failed to update server settings', error)
this.updatingServerSettings = false
this.$toast.error(this.$strings.ToastFailedToUpdate)
})
}, },
initServerSettings() { initServerSettings() {
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {} this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
+1 -1
View File
@@ -126,7 +126,7 @@ export default {
}, },
coverUrl(feed) { coverUrl(feed) {
if (!feed.coverPath) return `${this.$config.routerBasePath}/Logo.png` if (!feed.coverPath) return `${this.$config.routerBasePath}/Logo.png`
return `${feed.feedUrl}/cover` return `${this.$config.routerBasePath}${feed.feedUrl}/cover`
}, },
async loadFeeds() { async loadFeeds() {
const data = await this.$axios.$get(`/api/feeds`).catch((err) => { const data = await this.$axios.$get(`/api/feeds`).catch((err) => {
+7 -2
View File
@@ -2,6 +2,10 @@
<div> <div>
<app-settings-content :header-text="$strings.HeaderUsers"> <app-settings-content :header-text="$strings.HeaderUsers">
<template #header-items> <template #header-items>
<div v-if="numUsers" class="mx-2 px-1.5 rounded-lg bg-primary/50 text-gray-300/90 text-sm inline-flex items-center justify-center">
<span>{{ numUsers }}</span>
</div>
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2"> <ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
<a href="https://www.audiobookshelf.org/guides/users" target="_blank" class="inline-flex"> <a href="https://www.audiobookshelf.org/guides/users" target="_blank" class="inline-flex">
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span> <span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
@@ -13,7 +17,7 @@
<ui-btn color="primary" small @click="setShowUserModal()">{{ $strings.ButtonAddUser }}</ui-btn> <ui-btn color="primary" small @click="setShowUserModal()">{{ $strings.ButtonAddUser }}</ui-btn>
</template> </template>
<tables-users-table class="pt-2" @edit="setShowUserModal" /> <tables-users-table class="pt-2" @edit="setShowUserModal" @numUsers="(count) => (numUsers = count)" />
</app-settings-content> </app-settings-content>
<modals-account-modal ref="accountModal" v-model="showAccountModal" :account="selectedAccount" /> <modals-account-modal ref="accountModal" v-model="showAccountModal" :account="selectedAccount" />
</div> </div>
@@ -29,7 +33,8 @@ export default {
data() { data() {
return { return {
selectedAccount: null, selectedAccount: null,
showAccountModal: false showAccountModal: false,
numUsers: 0
} }
}, },
computed: {}, computed: {},
+8 -8
View File
@@ -12,12 +12,12 @@
<!-- Item Cover Overlay --> <!-- Item Cover Overlay -->
<div class="absolute top-0 left-0 w-full h-full z-10 opacity-0 group-hover:opacity-100 pointer-events-none"> <div class="absolute top-0 left-0 w-full h-full z-10 opacity-0 group-hover:opacity-100 pointer-events-none">
<div v-show="showPlayButton && !isStreaming" class="h-full flex items-center justify-center pointer-events-none"> <div v-show="showPlayButton && !isStreaming" class="h-full flex items-center justify-center pointer-events-none">
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto cursor-pointer" @click.stop.prevent="playItem"> <button class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto cursor-pointer" :aria-label="$strings.ButtonPlay" @click.stop.prevent="playItem">
<span class="material-symbols fill text-4xl">play_arrow</span> <span class="material-symbols fill text-4xl">play_arrow</span>
</div> </button>
</div> </div>
<span class="absolute bottom-2.5 right-2.5 z-10 material-symbols text-lg cursor-pointer text-white text-opacity-75 hover:text-opacity-100 hover:scale-110 transform duration-200 pointer-events-auto" @click="showEditCover">edit</span> <button class="absolute bottom-2.5 right-2.5 z-10 material-symbols text-lg cursor-pointer text-white text-opacity-75 hover:text-opacity-100 hover:scale-110 transform duration-200 pointer-events-auto" :aria-label="$strings.ButtonEdit" @click="showEditCover">edit</button>
</div> </div>
</div> </div>
</div> </div>
@@ -87,7 +87,7 @@
</ui-btn> </ui-btn>
<ui-btn v-else-if="isMissing || isInvalid" color="error" :padding-x="4" small class="flex items-center h-9 mr-2"> <ui-btn v-else-if="isMissing || isInvalid" color="error" :padding-x="4" small class="flex items-center h-9 mr-2">
<span v-show="!isStreaming" class="material-symbols text-2xl -ml-2 pr-1 text-white">error</span> <span class="material-symbols text-2xl -ml-2 pr-1 text-white">error</span>
{{ isMissing ? $strings.LabelMissing : $strings.LabelIncomplete }} {{ isMissing ? $strings.LabelMissing : $strings.LabelIncomplete }}
</ui-btn> </ui-btn>
@@ -96,12 +96,12 @@
</ui-tooltip> </ui-tooltip>
<ui-btn v-if="showReadButton" color="info" :padding-x="4" small class="flex items-center h-9 mr-2" @click="openEbook"> <ui-btn v-if="showReadButton" color="info" :padding-x="4" small class="flex items-center h-9 mr-2" @click="openEbook">
<span class="material-symbols text-2xl -ml-2 pr-2 text-white">auto_stories</span> <span class="material-symbols text-2xl -ml-2 pr-2 text-white" aria-hidden="true">auto_stories</span>
{{ $strings.ButtonRead }} {{ $strings.ButtonRead }}
</ui-btn> </ui-btn>
<ui-tooltip v-if="userCanUpdate" :text="$strings.LabelEdit" direction="top"> <ui-tooltip v-if="userCanUpdate" :text="$strings.LabelEdit" direction="top">
<ui-icon-btn icon="&#xe3c9;" outlined class="mx-0.5" @click="editClick" /> <ui-icon-btn icon="&#xe3c9;" outlined class="mx-0.5" :aria-label="$strings.LabelEdit" @click="editClick" />
</ui-tooltip> </ui-tooltip>
<ui-tooltip v-if="!isPodcast" :text="userIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="top"> <ui-tooltip v-if="!isPodcast" :text="userIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="top">
@@ -110,12 +110,12 @@
<!-- Only admin or root user can download new episodes --> <!-- Only admin or root user can download new episodes -->
<ui-tooltip v-if="isPodcast && userIsAdminOrUp" :text="$strings.LabelFindEpisodes" direction="top"> <ui-tooltip v-if="isPodcast && userIsAdminOrUp" :text="$strings.LabelFindEpisodes" direction="top">
<ui-icon-btn icon="search" class="mx-0.5" :loading="fetchingRSSFeed" outlined @click="findEpisodesClick" /> <ui-icon-btn icon="search" class="mx-0.5" :aria-label="$strings.LabelFindEpisodes" :loading="fetchingRSSFeed" outlined @click="findEpisodesClick" />
</ui-tooltip> </ui-tooltip>
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="148" @action="contextMenuAction"> <ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="148" @action="contextMenuAction">
<template #default="{ showMenu, clickShowMenu, disabled }"> <template #default="{ showMenu, clickShowMenu, disabled }">
<button type="button" :disabled="disabled" class="mx-0.5 icon-btn bg-primary border border-gray-600 w-9 h-9 rounded-md flex items-center justify-center relative" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu"> <button type="button" :disabled="disabled" class="mx-0.5 icon-btn bg-primary border border-gray-600 w-9 h-9 rounded-md flex items-center justify-center relative" aria-haspopup="listbox" :aria-expanded="showMenu" :aria-label="$strings.LabelMore" @click.stop.prevent="clickShowMenu">
<span class="material-symbols text-2xl">&#xe5d3;</span> <span class="material-symbols text-2xl">&#xe5d3;</span>
</button> </button>
</template> </template>
+6 -2
View File
@@ -126,12 +126,14 @@ export default {
if (!this.localAudioPlayer || !this.hasLoaded) return if (!this.localAudioPlayer || !this.hasLoaded) return
const currentTime = this.localAudioPlayer.getCurrentTime() const currentTime = this.localAudioPlayer.getCurrentTime()
const duration = this.localAudioPlayer.getDuration() const duration = this.localAudioPlayer.getDuration()
this.seek(Math.min(currentTime + 10, duration)) const jumpForwardAmount = this.$store.getters['user/getUserSetting']('jumpForwardAmount') || 10
this.seek(Math.min(currentTime + jumpForwardAmount, duration))
}, },
jumpBackward() { jumpBackward() {
if (!this.localAudioPlayer || !this.hasLoaded) return if (!this.localAudioPlayer || !this.hasLoaded) return
const currentTime = this.localAudioPlayer.getCurrentTime() const currentTime = this.localAudioPlayer.getCurrentTime()
this.seek(Math.max(currentTime - 10, 0)) const jumpBackwardAmount = this.$store.getters['user/getUserSetting']('jumpBackwardAmount') || 10
this.seek(Math.max(currentTime - jumpBackwardAmount, 0))
}, },
setVolume(volume) { setVolume(volume) {
if (!this.localAudioPlayer || !this.hasLoaded) return if (!this.localAudioPlayer || !this.hasLoaded) return
@@ -248,6 +250,8 @@ export default {
} }
}, },
mounted() { mounted() {
this.$store.dispatch('user/loadUserSettings')
this.resize() this.resize()
window.addEventListener('resize', this.resize) window.addEventListener('resize', this.resize)
window.addEventListener('keydown', this.keyDown) window.addEventListener('keydown', this.keyDown)
+1
View File
@@ -7,6 +7,7 @@ const defaultCode = 'en-us'
const languageCodeMap = { const languageCodeMap = {
bg: { label: 'Български', dateFnsLocale: 'bg' }, bg: { label: 'Български', dateFnsLocale: 'bg' },
bn: { label: 'বাংলা', dateFnsLocale: 'bn' }, bn: { label: 'বাংলা', dateFnsLocale: 'bn' },
ca: { label: 'Català', dateFnsLocale: 'ca' },
cs: { label: 'Čeština', dateFnsLocale: 'cs' }, cs: { label: 'Čeština', dateFnsLocale: 'cs' },
da: { label: 'Dansk', dateFnsLocale: 'da' }, da: { label: 'Dansk', dateFnsLocale: 'da' },
de: { label: 'Deutsch', dateFnsLocale: 'de' }, de: { label: 'Deutsch', dateFnsLocale: 'de' },
+6 -5
View File
@@ -72,16 +72,17 @@ export const actions = {
return this.$axios return this.$axios
.$patch('/api/settings', updatePayload) .$patch('/api/settings', updatePayload)
.then((result) => { .then((result) => {
if (result.success) { if (result.serverSettings) {
commit('setServerSettings', result.serverSettings) commit('setServerSettings', result.serverSettings)
return true
} else {
return false
} }
return result
}) })
.catch((error) => { .catch((error) => {
console.error('Failed to update server settings', error) console.error('Failed to update server settings', error)
return false const errorMsg = error.response?.data || 'Unknown error'
return {
error: errorMsg
}
}) })
}, },
checkForUpdate({ commit }) { checkForUpdate({ commit }) {
+82
View File
@@ -66,6 +66,7 @@
"ButtonPurgeItemsCache": "আইটেম ক্যাশে পরিষ্কার করুন", "ButtonPurgeItemsCache": "আইটেম ক্যাশে পরিষ্কার করুন",
"ButtonQueueAddItem": "সারিতে যোগ করুন", "ButtonQueueAddItem": "সারিতে যোগ করুন",
"ButtonQueueRemoveItem": "সারি থেকে মুছে ফেলুন", "ButtonQueueRemoveItem": "সারি থেকে মুছে ফেলুন",
"ButtonQuickEmbed": "দ্রুত এম্বেড করুন",
"ButtonQuickEmbedMetadata": "মেটাডেটা দ্রুত এম্বেড করুন", "ButtonQuickEmbedMetadata": "মেটাডেটা দ্রুত এম্বেড করুন",
"ButtonQuickMatch": "দ্রুত ম্যাচ", "ButtonQuickMatch": "দ্রুত ম্যাচ",
"ButtonReScan": "পুনরায় স্ক্যান", "ButtonReScan": "পুনরায় স্ক্যান",
@@ -162,6 +163,7 @@
"HeaderNotificationUpdate": "বিজ্ঞপ্তি আপডেট করুন", "HeaderNotificationUpdate": "বিজ্ঞপ্তি আপডেট করুন",
"HeaderNotifications": "বিজ্ঞপ্তি", "HeaderNotifications": "বিজ্ঞপ্তি",
"HeaderOpenIDConnectAuthentication": "ওপেনআইডি সংযোগ প্রমাণীকরণ", "HeaderOpenIDConnectAuthentication": "ওপেনআইডি সংযোগ প্রমাণীকরণ",
"HeaderOpenListeningSessions": "শোনার সেশন খুলুন",
"HeaderOpenRSSFeed": "আরএসএস ফিড খুলুন", "HeaderOpenRSSFeed": "আরএসএস ফিড খুলুন",
"HeaderOtherFiles": "অন্যান্য ফাইল", "HeaderOtherFiles": "অন্যান্য ফাইল",
"HeaderPasswordAuthentication": "পাসওয়ার্ড প্রমাণীকরণ", "HeaderPasswordAuthentication": "পাসওয়ার্ড প্রমাণীকরণ",
@@ -179,6 +181,7 @@
"HeaderRemoveEpisodes": "{0}টি পর্ব সরান", "HeaderRemoveEpisodes": "{0}টি পর্ব সরান",
"HeaderSavedMediaProgress": "মিডিয়া সংরক্ষণের অগ্রগতি", "HeaderSavedMediaProgress": "মিডিয়া সংরক্ষণের অগ্রগতি",
"HeaderSchedule": "সময়সূচী", "HeaderSchedule": "সময়সূচী",
"HeaderScheduleEpisodeDownloads": "স্বয়ংক্রিয় পর্ব ডাউনলোডের সময়সূচী নির্ধারন করুন",
"HeaderScheduleLibraryScans": "স্বয়ংক্রিয় লাইব্রেরি স্ক্যানের সময়সূচী", "HeaderScheduleLibraryScans": "স্বয়ংক্রিয় লাইব্রেরি স্ক্যানের সময়সূচী",
"HeaderSession": "সেশন", "HeaderSession": "সেশন",
"HeaderSetBackupSchedule": "ব্যাকআপ সময়সূচী সেট করুন", "HeaderSetBackupSchedule": "ব্যাকআপ সময়সূচী সেট করুন",
@@ -224,7 +227,11 @@
"LabelAllUsersExcludingGuests": "অতিথি ব্যতীত সকল ব্যবহারকারী", "LabelAllUsersExcludingGuests": "অতিথি ব্যতীত সকল ব্যবহারকারী",
"LabelAllUsersIncludingGuests": "অতিথি সহ সকল ব্যবহারকারী", "LabelAllUsersIncludingGuests": "অতিথি সহ সকল ব্যবহারকারী",
"LabelAlreadyInYourLibrary": "ইতিমধ্যেই আপনার লাইব্রেরিতে রয়েছে", "LabelAlreadyInYourLibrary": "ইতিমধ্যেই আপনার লাইব্রেরিতে রয়েছে",
"LabelApiToken": "API টোকেন",
"LabelAppend": "সংযোজন", "LabelAppend": "সংযোজন",
"LabelAudioBitrate": "অডিও বিটরেট (যেমন- 128k)",
"LabelAudioChannels": "অডিও চ্যানেল (১ বা ২)",
"LabelAudioCodec": "অডিও কোডেক",
"LabelAuthor": "লেখক", "LabelAuthor": "লেখক",
"LabelAuthorFirstLast": "লেখক (প্রথম শেষ)", "LabelAuthorFirstLast": "লেখক (প্রথম শেষ)",
"LabelAuthorLastFirst": "লেখক (শেষ, প্রথম)", "LabelAuthorLastFirst": "লেখক (শেষ, প্রথম)",
@@ -237,6 +244,7 @@
"LabelAutoRegister": "স্বয়ংক্রিয় নিবন্ধন", "LabelAutoRegister": "স্বয়ংক্রিয় নিবন্ধন",
"LabelAutoRegisterDescription": "লগ ইন করার পর স্বয়ংক্রিয়ভাবে নতুন ব্যবহারকারী তৈরি করুন", "LabelAutoRegisterDescription": "লগ ইন করার পর স্বয়ংক্রিয়ভাবে নতুন ব্যবহারকারী তৈরি করুন",
"LabelBackToUser": "ব্যবহারকারীর কাছে ফিরে যান", "LabelBackToUser": "ব্যবহারকারীর কাছে ফিরে যান",
"LabelBackupAudioFiles": "অডিও ফাইলগুলো ব্যাকআপ",
"LabelBackupLocation": "ব্যাকআপ অবস্থান", "LabelBackupLocation": "ব্যাকআপ অবস্থান",
"LabelBackupsEnableAutomaticBackups": "স্বয়ংক্রিয় ব্যাকআপ সক্ষম করুন", "LabelBackupsEnableAutomaticBackups": "স্বয়ংক্রিয় ব্যাকআপ সক্ষম করুন",
"LabelBackupsEnableAutomaticBackupsHelp": "ব্যাকআপগুলি /মেটাডাটা/ব্যাকআপে সংরক্ষিত", "LabelBackupsEnableAutomaticBackupsHelp": "ব্যাকআপগুলি /মেটাডাটা/ব্যাকআপে সংরক্ষিত",
@@ -245,15 +253,18 @@
"LabelBackupsNumberToKeep": "ব্যাকআপের সংখ্যা রাখুন", "LabelBackupsNumberToKeep": "ব্যাকআপের সংখ্যা রাখুন",
"LabelBackupsNumberToKeepHelp": "এক সময়ে শুধুমাত্র ১ টি ব্যাকআপ সরানো হবে তাই যদি আপনার কাছে ইতিমধ্যে এর চেয়ে বেশি ব্যাকআপ থাকে তাহলে আপনাকে ম্যানুয়ালি সেগুলি সরিয়ে ফেলতে হবে।", "LabelBackupsNumberToKeepHelp": "এক সময়ে শুধুমাত্র ১ টি ব্যাকআপ সরানো হবে তাই যদি আপনার কাছে ইতিমধ্যে এর চেয়ে বেশি ব্যাকআপ থাকে তাহলে আপনাকে ম্যানুয়ালি সেগুলি সরিয়ে ফেলতে হবে।",
"LabelBitrate": "বিটরেট", "LabelBitrate": "বিটরেট",
"LabelBonus": "উপরিলাভ",
"LabelBooks": "বইগুলো", "LabelBooks": "বইগুলো",
"LabelButtonText": "ঘর পাঠ্য", "LabelButtonText": "ঘর পাঠ্য",
"LabelByAuthor": "দ্বারা {0}", "LabelByAuthor": "দ্বারা {0}",
"LabelChangePassword": "পাসওয়ার্ড পরিবর্তন করুন", "LabelChangePassword": "পাসওয়ার্ড পরিবর্তন করুন",
"LabelChannels": "চ্যানেল", "LabelChannels": "চ্যানেল",
"LabelChapterCount": "{0} অধ্যায়",
"LabelChapterTitle": "অধ্যায়ের শিরোনাম", "LabelChapterTitle": "অধ্যায়ের শিরোনাম",
"LabelChapters": "অধ্যায়", "LabelChapters": "অধ্যায়",
"LabelChaptersFound": "অধ্যায় পাওয়া গেছে", "LabelChaptersFound": "অধ্যায় পাওয়া গেছে",
"LabelClickForMoreInfo": "আরো তথ্যের জন্য ক্লিক করুন", "LabelClickForMoreInfo": "আরো তথ্যের জন্য ক্লিক করুন",
"LabelClickToUseCurrentValue": "বর্তমান মান ব্যবহার করতে ক্লিক করুন",
"LabelClosePlayer": "প্লেয়ার বন্ধ করুন", "LabelClosePlayer": "প্লেয়ার বন্ধ করুন",
"LabelCodec": "কোডেক", "LabelCodec": "কোডেক",
"LabelCollapseSeries": "সিরিজ সঙ্কুচিত করুন", "LabelCollapseSeries": "সিরিজ সঙ্কুচিত করুন",
@@ -303,12 +314,25 @@
"LabelEmailSettingsTestAddress": "পরীক্ষার ঠিকানা", "LabelEmailSettingsTestAddress": "পরীক্ষার ঠিকানা",
"LabelEmbeddedCover": "এম্বেডেড কভার", "LabelEmbeddedCover": "এম্বেডেড কভার",
"LabelEnable": "সক্ষম করুন", "LabelEnable": "সক্ষম করুন",
"LabelEncodingBackupLocation": "আপনার আসল অডিও ফাইলগুলোর একটি ব্যাকআপ এখানে সংরক্ষণ করা হবে:",
"LabelEncodingChaptersNotEmbedded": "মাল্টি-ট্র্যাক অডিওবুকগুলোতে অধ্যায় এম্বেড করা হয় না।",
"LabelEncodingClearItemCache": "পর্যায়ক্রমে আইটেম ক্যাশে পরিষ্কার করতে ভুলবেন না।",
"LabelEncodingFinishedM4B": "সমাপ্ত হওয়া M4B-গুলো আপনার অডিওবুক ফোল্ডারে এখানে রাখা হবে:",
"LabelEncodingInfoEmbedded": "আপনার অডিওবুক ফোল্ডারের ভিতরে অডিও ট্র্যাকগুলোতে মেটাডেটা এমবেড করা হবে।",
"LabelEncodingStartedNavigation": "একবার টাস্ক শুরু হলে আপনি এই পৃষ্ঠা থেকে অন্যত্র যেতে পারেন।",
"LabelEncodingTimeWarning": "এনকোডিং ৩০ মিনিট পর্যন্ত সময় নিতে পারে।",
"LabelEncodingWarningAdvancedSettings": "সতর্কতা: এই সেটিংস আপডেট করবেন না, যদি না আপনি ffmpeg এনকোডিং বিকল্পগুলোর সাথে পরিচিত হন।",
"LabelEncodingWatcherDisabled": "আপনার যদি পর্যবেক্ষক অক্ষম থাকে তবে আপনাকে পরে এই অডিওবুকটি পুনরায় স্ক্যান করতে হবে।",
"LabelEnd": "সমাপ্ত", "LabelEnd": "সমাপ্ত",
"LabelEndOfChapter": "অধ্যায়ের সমাপ্তি", "LabelEndOfChapter": "অধ্যায়ের সমাপ্তি",
"LabelEpisode": "পর্ব", "LabelEpisode": "পর্ব",
"LabelEpisodeNotLinkedToRssFeed": "পর্বটি আরএসএস ফিডের সাথে সংযুক্ত করা হয়নি",
"LabelEpisodeNumber": "পর্ব #{0}",
"LabelEpisodeTitle": "পর্বের শিরোনাম", "LabelEpisodeTitle": "পর্বের শিরোনাম",
"LabelEpisodeType": "পর্বের ধরন", "LabelEpisodeType": "পর্বের ধরন",
"LabelEpisodeUrlFromRssFeed": "আরএসএস ফিড থেকে পর্ব URL",
"LabelEpisodes": "পর্বগুলো", "LabelEpisodes": "পর্বগুলো",
"LabelEpisodic": "প্রাসঙ্গিক",
"LabelExample": "উদাহরণ", "LabelExample": "উদাহরণ",
"LabelExpandSeries": "সিরিজ প্রসারিত করুন", "LabelExpandSeries": "সিরিজ প্রসারিত করুন",
"LabelExpandSubSeries": "সাব সিরিজ প্রসারিত করুন", "LabelExpandSubSeries": "সাব সিরিজ প্রসারিত করুন",
@@ -336,6 +360,7 @@
"LabelFontScale": "ফন্ট স্কেল", "LabelFontScale": "ফন্ট স্কেল",
"LabelFontStrikethrough": "অবচ্ছেদন রেখা", "LabelFontStrikethrough": "অবচ্ছেদন রেখা",
"LabelFormat": "ফরম্যাট", "LabelFormat": "ফরম্যাট",
"LabelFull": "পূর্ণ",
"LabelGenre": "ঘরানা", "LabelGenre": "ঘরানা",
"LabelGenres": "ঘরানাগুলো", "LabelGenres": "ঘরানাগুলো",
"LabelHardDeleteFile": "জোরপূর্বক ফাইল মুছে ফেলুন", "LabelHardDeleteFile": "জোরপূর্বক ফাইল মুছে ফেলুন",
@@ -391,6 +416,10 @@
"LabelLowestPriority": "সর্বনিম্ন অগ্রাধিকার", "LabelLowestPriority": "সর্বনিম্ন অগ্রাধিকার",
"LabelMatchExistingUsersBy": "বিদ্যমান ব্যবহারকারীদের দ্বারা মিলিত করুন", "LabelMatchExistingUsersBy": "বিদ্যমান ব্যবহারকারীদের দ্বারা মিলিত করুন",
"LabelMatchExistingUsersByDescription": "বিদ্যমান ব্যবহারকারীদের সংযোগ করার জন্য ব্যবহৃত হয়। একবার সংযুক্ত হলে, ব্যবহারকারীদের আপনার SSO প্রদানকারীর থেকে একটি অনন্য আইডি দ্বারা মিলিত হবে", "LabelMatchExistingUsersByDescription": "বিদ্যমান ব্যবহারকারীদের সংযোগ করার জন্য ব্যবহৃত হয়। একবার সংযুক্ত হলে, ব্যবহারকারীদের আপনার SSO প্রদানকারীর থেকে একটি অনন্য আইডি দ্বারা মিলিত হবে",
"LabelMaxEpisodesToDownload": "সর্বাধিক # টি পর্ব ডাউনলোড করা হবে। অসীমের জন্য 0 ব্যবহার করুন।",
"LabelMaxEpisodesToDownloadPerCheck": "প্রতি কিস্তিতে সর্বাধিক # টি নতুন পর্ব ডাউনলোড করা হবে",
"LabelMaxEpisodesToKeep": "সর্বোচ্চ # টি পর্ব রাখা হবে",
"LabelMaxEpisodesToKeepHelp": "০ কোন সর্বোচ্চ সীমা সেট করে না। একটি নতুন পর্ব স্বয়ংক্রিয়-ডাউনলোড হওয়ার পরে আপনার যদি X-এর বেশি পর্ব থাকে তবে এটি সবচেয়ে পুরানো পর্বটি মুছে ফেলবে। এটি প্রতি নতুন ডাউনলোডের জন্য শুধুমাত্র ১ টি পর্ব মুছে ফেলবে।",
"LabelMediaPlayer": "মিডিয়া প্লেয়ার", "LabelMediaPlayer": "মিডিয়া প্লেয়ার",
"LabelMediaType": "মিডিয়ার ধরন", "LabelMediaType": "মিডিয়ার ধরন",
"LabelMetaTag": "মেটা ট্যাগ", "LabelMetaTag": "মেটা ট্যাগ",
@@ -436,12 +465,14 @@
"LabelOpenIDGroupClaimDescription": "ওপেনআইডি দাবির নাম যাতে ব্যবহারকারীর গোষ্ঠীর একটি তালিকা থাকে। সাধারণত <code>গ্রুপ</code> হিসাবে উল্লেখ করা হয়। <b>কনফিগার করা থাকলে</b>, অ্যাপ্লিকেশনটি স্বয়ংক্রিয়ভাবে এর উপর ভিত্তি করে ব্যবহারকারীর গোষ্ঠীর সদস্যপদ নির্ধারণ করবে, শর্ত এই যে এই গোষ্ঠীগুলি কেস-অসংবেদনশীলভাবে দাবিতে 'অ্যাডমিন', 'ব্যবহারকারী' বা 'অতিথি' নাম দেওয়া হয়৷ দাবিতে একটি তালিকা থাকা উচিত এবং যদি একজন ব্যবহারকারী একাধিক গোষ্ঠীর অন্তর্গত হয় তবে অ্যাপ্লিকেশনটি বরাদ্দ করবে সর্বোচ্চ স্তরের অ্যাক্সেসের সাথে সঙ্গতিপূর্ণ ভূমিকা৷ যদি কোনও গোষ্ঠীর সাথে মেলে না, তবে অ্যাক্সেস অস্বীকার করা হবে।", "LabelOpenIDGroupClaimDescription": "ওপেনআইডি দাবির নাম যাতে ব্যবহারকারীর গোষ্ঠীর একটি তালিকা থাকে। সাধারণত <code>গ্রুপ</code> হিসাবে উল্লেখ করা হয়। <b>কনফিগার করা থাকলে</b>, অ্যাপ্লিকেশনটি স্বয়ংক্রিয়ভাবে এর উপর ভিত্তি করে ব্যবহারকারীর গোষ্ঠীর সদস্যপদ নির্ধারণ করবে, শর্ত এই যে এই গোষ্ঠীগুলি কেস-অসংবেদনশীলভাবে দাবিতে 'অ্যাডমিন', 'ব্যবহারকারী' বা 'অতিথি' নাম দেওয়া হয়৷ দাবিতে একটি তালিকা থাকা উচিত এবং যদি একজন ব্যবহারকারী একাধিক গোষ্ঠীর অন্তর্গত হয় তবে অ্যাপ্লিকেশনটি বরাদ্দ করবে সর্বোচ্চ স্তরের অ্যাক্সেসের সাথে সঙ্গতিপূর্ণ ভূমিকা৷ যদি কোনও গোষ্ঠীর সাথে মেলে না, তবে অ্যাক্সেস অস্বীকার করা হবে।",
"LabelOpenRSSFeed": "আরএসএস ফিড খুলুন", "LabelOpenRSSFeed": "আরএসএস ফিড খুলুন",
"LabelOverwrite": "পুনঃলিখিত", "LabelOverwrite": "পুনঃলিখিত",
"LabelPaginationPageXOfY": "{1} টির মধ্যে {0} পৃষ্ঠা",
"LabelPassword": "পাসওয়ার্ড", "LabelPassword": "পাসওয়ার্ড",
"LabelPath": "পথ", "LabelPath": "পথ",
"LabelPermanent": "স্থায়ী", "LabelPermanent": "স্থায়ী",
"LabelPermissionsAccessAllLibraries": "সমস্ত লাইব্রেরি অ্যাক্সেস করতে পারবে", "LabelPermissionsAccessAllLibraries": "সমস্ত লাইব্রেরি অ্যাক্সেস করতে পারবে",
"LabelPermissionsAccessAllTags": "সমস্ত ট্যাগ অ্যাক্সেস করতে পারবে", "LabelPermissionsAccessAllTags": "সমস্ত ট্যাগ অ্যাক্সেস করতে পারবে",
"LabelPermissionsAccessExplicitContent": "স্পষ্ট বিষয়বস্তু অ্যাক্সেস করতে পারে", "LabelPermissionsAccessExplicitContent": "স্পষ্ট বিষয়বস্তু অ্যাক্সেস করতে পারে",
"LabelPermissionsCreateEreader": "ইরিডার তৈরি করতে পারেন",
"LabelPermissionsDelete": "মুছে দিতে পারবে", "LabelPermissionsDelete": "মুছে দিতে পারবে",
"LabelPermissionsDownload": "ডাউনলোড করতে পারবে", "LabelPermissionsDownload": "ডাউনলোড করতে পারবে",
"LabelPermissionsUpdate": "আপডেট করতে পারবে", "LabelPermissionsUpdate": "আপডেট করতে পারবে",
@@ -465,6 +496,8 @@
"LabelPubDate": "প্রকাশের তারিখ", "LabelPubDate": "প্রকাশের তারিখ",
"LabelPublishYear": "প্রকাশের বছর", "LabelPublishYear": "প্রকাশের বছর",
"LabelPublishedDate": "প্রকাশিত {0}", "LabelPublishedDate": "প্রকাশিত {0}",
"LabelPublishedDecade": "প্রকাশনার দশক",
"LabelPublishedDecades": "প্রকাশনার দশকগুলো",
"LabelPublisher": "প্রকাশক", "LabelPublisher": "প্রকাশক",
"LabelPublishers": "প্রকাশকরা", "LabelPublishers": "প্রকাশকরা",
"LabelRSSFeedCustomOwnerEmail": "কাস্টম মালিকের ইমেইল", "LabelRSSFeedCustomOwnerEmail": "কাস্টম মালিকের ইমেইল",
@@ -484,21 +517,28 @@
"LabelRedo": "পুনরায় করুন", "LabelRedo": "পুনরায় করুন",
"LabelRegion": "অঞ্চল", "LabelRegion": "অঞ্চল",
"LabelReleaseDate": "উন্মোচনের তারিখ", "LabelReleaseDate": "উন্মোচনের তারিখ",
"LabelRemoveAllMetadataAbs": "সমস্ত metadata.abs ফাইল সরান",
"LabelRemoveAllMetadataJson": "সমস্ত metadata.json ফাইল সরান",
"LabelRemoveCover": "কভার সরান", "LabelRemoveCover": "কভার সরান",
"LabelRemoveMetadataFile": "লাইব্রেরি আইটেম ফোল্ডারে মেটাডেটা ফাইল সরান",
"LabelRemoveMetadataFileHelp": "আপনার {0} ফোল্ডারের সমস্ত metadata.json এবং metadata.abs ফাইলগুলি সরান।",
"LabelRowsPerPage": "প্রতি পৃষ্ঠায় সারি", "LabelRowsPerPage": "প্রতি পৃষ্ঠায় সারি",
"LabelSearchTerm": "অনুসন্ধান শব্দ", "LabelSearchTerm": "অনুসন্ধান শব্দ",
"LabelSearchTitle": "অনুসন্ধান শিরোনাম", "LabelSearchTitle": "অনুসন্ধান শিরোনাম",
"LabelSearchTitleOrASIN": "অনুসন্ধান শিরোনাম বা ASIN", "LabelSearchTitleOrASIN": "অনুসন্ধান শিরোনাম বা ASIN",
"LabelSeason": "সেশন", "LabelSeason": "সেশন",
"LabelSeasonNumber": "মরসুম #{0}",
"LabelSelectAll": "সব নির্বাচন করুন", "LabelSelectAll": "সব নির্বাচন করুন",
"LabelSelectAllEpisodes": "সমস্ত পর্ব নির্বাচন করুন", "LabelSelectAllEpisodes": "সমস্ত পর্ব নির্বাচন করুন",
"LabelSelectEpisodesShowing": "দেখানো {0}টি পর্ব নির্বাচন করুন", "LabelSelectEpisodesShowing": "দেখানো {0}টি পর্ব নির্বাচন করুন",
"LabelSelectUsers": "ব্যবহারকারী নির্বাচন করুন", "LabelSelectUsers": "ব্যবহারকারী নির্বাচন করুন",
"LabelSendEbookToDevice": "ই-বই পাঠান...", "LabelSendEbookToDevice": "ই-বই পাঠান...",
"LabelSequence": "ক্রম", "LabelSequence": "ক্রম",
"LabelSerial": "ধারাবাহিক",
"LabelSeries": "সিরিজ", "LabelSeries": "সিরিজ",
"LabelSeriesName": "সিরিজের নাম", "LabelSeriesName": "সিরিজের নাম",
"LabelSeriesProgress": "সিরিজের অগ্রগতি", "LabelSeriesProgress": "সিরিজের অগ্রগতি",
"LabelServerLogLevel": "সার্ভার লগ লেভেল",
"LabelServerYearReview": "সার্ভারের বাৎসরিক পর্যালোচনা ({0})", "LabelServerYearReview": "সার্ভারের বাৎসরিক পর্যালোচনা ({0})",
"LabelSetEbookAsPrimary": "প্রাথমিক হিসাবে সেট করুন", "LabelSetEbookAsPrimary": "প্রাথমিক হিসাবে সেট করুন",
"LabelSetEbookAsSupplementary": "পরিপূরক হিসেবে সেট করুন", "LabelSetEbookAsSupplementary": "পরিপূরক হিসেবে সেট করুন",
@@ -523,6 +563,9 @@
"LabelSettingsHideSingleBookSeriesHelp": "যে সিরিজগুলোতে একটি বই আছে সেগুলো সিরিজের পাতা এবং নীড় পেজের তাক থেকে লুকিয়ে রাখা হবে।", "LabelSettingsHideSingleBookSeriesHelp": "যে সিরিজগুলোতে একটি বই আছে সেগুলো সিরিজের পাতা এবং নীড় পেজের তাক থেকে লুকিয়ে রাখা হবে।",
"LabelSettingsHomePageBookshelfView": "নীড় পেজে বুকশেলফ ভিউ ব্যবহার করুন", "LabelSettingsHomePageBookshelfView": "নীড় পেজে বুকশেলফ ভিউ ব্যবহার করুন",
"LabelSettingsLibraryBookshelfView": "লাইব্রেরি বুকশেলফ ভিউ ব্যবহার করুন", "LabelSettingsLibraryBookshelfView": "লাইব্রেরি বুকশেলফ ভিউ ব্যবহার করুন",
"LabelSettingsLibraryMarkAsFinishedPercentComplete": "শতকরা সম্পূর্ণ এর চেয়ে বেশি",
"LabelSettingsLibraryMarkAsFinishedTimeRemaining": "বাকি সময় (সেকেন্ড) এর চেয়ে কম",
"LabelSettingsLibraryMarkAsFinishedWhen": "মিডিয়া আইটেমকে সমাপ্ত হিসাবে চিহ্নিত করুন যখন",
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "কন্টিনিউ সিরিজে আগের বইগুলো এড়িয়ে যান", "LabelSettingsOnlyShowLaterBooksInContinueSeries": "কন্টিনিউ সিরিজে আগের বইগুলো এড়িয়ে যান",
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "কন্টিনিউ সিরিজের নীড় পেজ শেল্ফ দেখায় যে সিরিজে শুরু হয়নি এমন প্রথম বই যার অন্তত একটি বই শেষ হয়েছে এবং কোনো বই চলছে না। এই সেটিংটি সক্ষম করলে শুরু না হওয়া প্রথম বইটির পরিবর্তে সবচেয়ে দূরের সম্পূর্ণ বই থেকে সিরিজ চলতে থাকবে।", "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "কন্টিনিউ সিরিজের নীড় পেজ শেল্ফ দেখায় যে সিরিজে শুরু হয়নি এমন প্রথম বই যার অন্তত একটি বই শেষ হয়েছে এবং কোনো বই চলছে না। এই সেটিংটি সক্ষম করলে শুরু না হওয়া প্রথম বইটির পরিবর্তে সবচেয়ে দূরের সম্পূর্ণ বই থেকে সিরিজ চলতে থাকবে।",
"LabelSettingsParseSubtitles": "সাবটাইটেল পার্স করুন", "LabelSettingsParseSubtitles": "সাবটাইটেল পার্স করুন",
@@ -587,6 +630,7 @@
"LabelTimeDurationXMinutes": "{0} মিনিট", "LabelTimeDurationXMinutes": "{0} মিনিট",
"LabelTimeDurationXSeconds": "{0} সেকেন্ড", "LabelTimeDurationXSeconds": "{0} সেকেন্ড",
"LabelTimeInMinutes": "মিনিটে সময়", "LabelTimeInMinutes": "মিনিটে সময়",
"LabelTimeLeft": "{0} বাকি",
"LabelTimeListened": "সময় শোনা হয়েছে", "LabelTimeListened": "সময় শোনা হয়েছে",
"LabelTimeListenedToday": "আজ শোনার সময়", "LabelTimeListenedToday": "আজ শোনার সময়",
"LabelTimeRemaining": "{0}টি অবশিষ্ট", "LabelTimeRemaining": "{0}টি অবশিষ্ট",
@@ -594,6 +638,7 @@
"LabelTitle": "শিরোনাম", "LabelTitle": "শিরোনাম",
"LabelToolsEmbedMetadata": "মেটাডেটা এম্বেড করুন", "LabelToolsEmbedMetadata": "মেটাডেটা এম্বেড করুন",
"LabelToolsEmbedMetadataDescription": "কভার ইমেজ এবং অধ্যায় সহ অডিও ফাইলগুলিতে মেটাডেটা এম্বেড করুন।", "LabelToolsEmbedMetadataDescription": "কভার ইমেজ এবং অধ্যায় সহ অডিও ফাইলগুলিতে মেটাডেটা এম্বেড করুন।",
"LabelToolsM4bEncoder": "M4B এনকোডার",
"LabelToolsMakeM4b": "M4B অডিওবুক ফাইল তৈরি করুন", "LabelToolsMakeM4b": "M4B অডিওবুক ফাইল তৈরি করুন",
"LabelToolsMakeM4bDescription": "এমবেডেড মেটাডেটা, কভার ইমেজ এবং অধ্যায় সহ একটি .M4B অডিওবুক ফাইল তৈরি করুন।", "LabelToolsMakeM4bDescription": "এমবেডেড মেটাডেটা, কভার ইমেজ এবং অধ্যায় সহ একটি .M4B অডিওবুক ফাইল তৈরি করুন।",
"LabelToolsSplitM4b": "M4B কে MP3 তে বিভক্ত করুন", "LabelToolsSplitM4b": "M4B কে MP3 তে বিভক্ত করুন",
@@ -606,6 +651,7 @@
"LabelTracksMultiTrack": "মাল্টি-ট্র্যাক", "LabelTracksMultiTrack": "মাল্টি-ট্র্যাক",
"LabelTracksNone": "কোন ট্র্যাক নেই", "LabelTracksNone": "কোন ট্র্যাক নেই",
"LabelTracksSingleTrack": "একক-ট্র্যাক", "LabelTracksSingleTrack": "একক-ট্র্যাক",
"LabelTrailer": "আনুগমিক",
"LabelType": "টাইপ", "LabelType": "টাইপ",
"LabelUnabridged": "অসংলগ্ন", "LabelUnabridged": "অসংলগ্ন",
"LabelUndo": "পূর্বাবস্থা", "LabelUndo": "পূর্বাবস্থা",
@@ -617,10 +663,13 @@
"LabelUpdateDetailsHelp": "একটি মিল থাকা অবস্থায় নির্বাচিত বইগুলির বিদ্যমান বিবরণ ওভাররাইট করার অনুমতি দিন", "LabelUpdateDetailsHelp": "একটি মিল থাকা অবস্থায় নির্বাচিত বইগুলির বিদ্যমান বিবরণ ওভাররাইট করার অনুমতি দিন",
"LabelUpdatedAt": "আপডেট করা হয়েছে", "LabelUpdatedAt": "আপডেট করা হয়েছে",
"LabelUploaderDragAndDrop": "ফাইল বা ফোল্ডার টেনে আনুন এবং ফেলে দিন", "LabelUploaderDragAndDrop": "ফাইল বা ফোল্ডার টেনে আনুন এবং ফেলে দিন",
"LabelUploaderDragAndDropFilesOnly": "ফাইল টেনে আনুন",
"LabelUploaderDropFiles": "ফাইলগুলো ফেলে দিন", "LabelUploaderDropFiles": "ফাইলগুলো ফেলে দিন",
"LabelUploaderItemFetchMetadataHelp": "স্বয়ংক্রিয়ভাবে শিরোনাম, লেখক এবং সিরিজ আনুন", "LabelUploaderItemFetchMetadataHelp": "স্বয়ংক্রিয়ভাবে শিরোনাম, লেখক এবং সিরিজ আনুন",
"LabelUseAdvancedOptions": "উন্নত বিকল্প ব্যবহার করুন",
"LabelUseChapterTrack": "অধ্যায় ট্র্যাক ব্যবহার করুন", "LabelUseChapterTrack": "অধ্যায় ট্র্যাক ব্যবহার করুন",
"LabelUseFullTrack": "সম্পূর্ণ ট্র্যাক ব্যবহার করুন", "LabelUseFullTrack": "সম্পূর্ণ ট্র্যাক ব্যবহার করুন",
"LabelUseZeroForUnlimited": "অসীমের জন্য 0 ব্যবহার করুন",
"LabelUser": "ব্যবহারকারী", "LabelUser": "ব্যবহারকারী",
"LabelUsername": "ব্যবহারকারীর নাম", "LabelUsername": "ব্যবহারকারীর নাম",
"LabelValue": "মান", "LabelValue": "মান",
@@ -667,6 +716,7 @@
"MessageConfirmDeleteMetadataProvider": "আপনি কি নিশ্চিতভাবে কাস্টম মেটাডেটা প্রদানকারী \"{0}\" মুছতে চান?", "MessageConfirmDeleteMetadataProvider": "আপনি কি নিশ্চিতভাবে কাস্টম মেটাডেটা প্রদানকারী \"{0}\" মুছতে চান?",
"MessageConfirmDeleteNotification": "আপনি কি নিশ্চিতভাবে এই বিজ্ঞপ্তিটি মুছতে চান?", "MessageConfirmDeleteNotification": "আপনি কি নিশ্চিতভাবে এই বিজ্ঞপ্তিটি মুছতে চান?",
"MessageConfirmDeleteSession": "আপনি কি নিশ্চিত আপনি এই অধিবেশন মুছে দিতে চান?", "MessageConfirmDeleteSession": "আপনি কি নিশ্চিত আপনি এই অধিবেশন মুছে দিতে চান?",
"MessageConfirmEmbedMetadataInAudioFiles": "আপনি কি {0}টি অডিও ফাইলে মেটাডেটা এম্বেড করার বিষয়ে নিশ্চিত?",
"MessageConfirmForceReScan": "আপনি কি নিশ্চিত যে আপনি জোর করে পুনরায় স্ক্যান করতে চান?", "MessageConfirmForceReScan": "আপনি কি নিশ্চিত যে আপনি জোর করে পুনরায় স্ক্যান করতে চান?",
"MessageConfirmMarkAllEpisodesFinished": "আপনি কি নিশ্চিত যে আপনি সমস্ত পর্ব সমাপ্ত হিসাবে চিহ্নিত করতে চান?", "MessageConfirmMarkAllEpisodesFinished": "আপনি কি নিশ্চিত যে আপনি সমস্ত পর্ব সমাপ্ত হিসাবে চিহ্নিত করতে চান?",
"MessageConfirmMarkAllEpisodesNotFinished": "আপনি কি নিশ্চিত যে আপনি সমস্ত পর্বকে শেষ হয়নি বলে চিহ্নিত করতে চান?", "MessageConfirmMarkAllEpisodesNotFinished": "আপনি কি নিশ্চিত যে আপনি সমস্ত পর্বকে শেষ হয়নি বলে চিহ্নিত করতে চান?",
@@ -678,6 +728,7 @@
"MessageConfirmPurgeCache": "ক্যাশে পরিষ্কারক <code>/metadata/cache</code>-এ সম্পূর্ণ ডিরেক্টরি মুছে ফেলবে। <br /><br />আপনি কি নিশ্চিত আপনি ক্যাশে ডিরেক্টরি সরাতে চান?", "MessageConfirmPurgeCache": "ক্যাশে পরিষ্কারক <code>/metadata/cache</code>-এ সম্পূর্ণ ডিরেক্টরি মুছে ফেলবে। <br /><br />আপনি কি নিশ্চিত আপনি ক্যাশে ডিরেক্টরি সরাতে চান?",
"MessageConfirmPurgeItemsCache": "আইটেম ক্যাশে পরিষ্কারক <code>/metadata/cache/items</code>-এ সম্পূর্ণ ডিরেক্টরি মুছে ফেলবে।<br />আপনি কি নিশ্চিত?", "MessageConfirmPurgeItemsCache": "আইটেম ক্যাশে পরিষ্কারক <code>/metadata/cache/items</code>-এ সম্পূর্ণ ডিরেক্টরি মুছে ফেলবে।<br />আপনি কি নিশ্চিত?",
"MessageConfirmQuickEmbed": "সতর্কতা! দ্রুত এম্বেড আপনার অডিও ফাইলের ব্যাকআপ করবে না। নিশ্চিত করুন যে আপনার অডিও ফাইলগুলির একটি ব্যাকআপ আছে। <br><br>আপনি কি চালিয়ে যেতে চান?", "MessageConfirmQuickEmbed": "সতর্কতা! দ্রুত এম্বেড আপনার অডিও ফাইলের ব্যাকআপ করবে না। নিশ্চিত করুন যে আপনার অডিও ফাইলগুলির একটি ব্যাকআপ আছে। <br><br>আপনি কি চালিয়ে যেতে চান?",
"MessageConfirmQuickMatchEpisodes": "একটি মিল পাওয়া গেলে দ্রুত ম্যাচিং পর্বগুলি বিস্তারিত ওভাররাইট করবে। শুধুমাত্র অতুলনীয় পর্ব আপডেট করা হবে। আপনি কি নিশ্চিত?",
"MessageConfirmReScanLibraryItems": "আপনি কি নিশ্চিত যে আপনি {0}টি আইটেম পুনরায় স্ক্যান করতে চান?", "MessageConfirmReScanLibraryItems": "আপনি কি নিশ্চিত যে আপনি {0}টি আইটেম পুনরায় স্ক্যান করতে চান?",
"MessageConfirmRemoveAllChapters": "আপনি কি নিশ্চিত যে আপনি সমস্ত অধ্যায় সরাতে চান?", "MessageConfirmRemoveAllChapters": "আপনি কি নিশ্চিত যে আপনি সমস্ত অধ্যায় সরাতে চান?",
"MessageConfirmRemoveAuthor": "আপনি কি নিশ্চিত যে আপনি লেখক \"{0}\" অপসারণ করতে চান?", "MessageConfirmRemoveAuthor": "আপনি কি নিশ্চিত যে আপনি লেখক \"{0}\" অপসারণ করতে চান?",
@@ -685,6 +736,7 @@
"MessageConfirmRemoveEpisode": "আপনি কি নিশ্চিত আপনি \"{0}\" পর্বটি সরাতে চান?", "MessageConfirmRemoveEpisode": "আপনি কি নিশ্চিত আপনি \"{0}\" পর্বটি সরাতে চান?",
"MessageConfirmRemoveEpisodes": "আপনি কি নিশ্চিত যে আপনি {0}টি পর্ব সরাতে চান?", "MessageConfirmRemoveEpisodes": "আপনি কি নিশ্চিত যে আপনি {0}টি পর্ব সরাতে চান?",
"MessageConfirmRemoveListeningSessions": "আপনি কি নিশ্চিত যে আপনি {0}টি শোনার সেশন সরাতে চান?", "MessageConfirmRemoveListeningSessions": "আপনি কি নিশ্চিত যে আপনি {0}টি শোনার সেশন সরাতে চান?",
"MessageConfirmRemoveMetadataFiles": "আপনি কি আপনার লাইব্রেরি আইটেম ফোল্ডারে থাকা সমস্ত মেটাডেটা {0} ফাইল মুছে ফেলার বিষয়ে নিশ্চিত?",
"MessageConfirmRemoveNarrator": "আপনি কি \"{0}\" বর্ণনাকারীকে সরানোর বিষয়ে নিশ্চিত?", "MessageConfirmRemoveNarrator": "আপনি কি \"{0}\" বর্ণনাকারীকে সরানোর বিষয়ে নিশ্চিত?",
"MessageConfirmRemovePlaylist": "আপনি কি নিশ্চিত যে আপনি আপনার প্লেলিস্ট \"{0}\" সরাতে চান?", "MessageConfirmRemovePlaylist": "আপনি কি নিশ্চিত যে আপনি আপনার প্লেলিস্ট \"{0}\" সরাতে চান?",
"MessageConfirmRenameGenre": "আপনি কি নিশ্চিত যে আপনি সমস্ত আইটেমের জন্য \"{0}\" ধারার নাম পরিবর্তন করে \"{1}\" করতে চান?", "MessageConfirmRenameGenre": "আপনি কি নিশ্চিত যে আপনি সমস্ত আইটেমের জন্য \"{0}\" ধারার নাম পরিবর্তন করে \"{1}\" করতে চান?",
@@ -700,6 +752,7 @@
"MessageDragFilesIntoTrackOrder": "সঠিক ট্র্যাক অর্ডারে ফাইল টেনে আনুন", "MessageDragFilesIntoTrackOrder": "সঠিক ট্র্যাক অর্ডারে ফাইল টেনে আনুন",
"MessageEmbedFailed": "এম্বেড ব্যর্থ হয়েছে!", "MessageEmbedFailed": "এম্বেড ব্যর্থ হয়েছে!",
"MessageEmbedFinished": "এম্বেড করা শেষ!", "MessageEmbedFinished": "এম্বেড করা শেষ!",
"MessageEmbedQueue": "মেটাডেটা এম্বেডের জন্য সারিবদ্ধ ({0} সারিতে)",
"MessageEpisodesQueuedForDownload": "{0} পর্ব(গুলি) ডাউনলোডের জন্য সারিবদ্ধ", "MessageEpisodesQueuedForDownload": "{0} পর্ব(গুলি) ডাউনলোডের জন্য সারিবদ্ধ",
"MessageEreaderDevices": "ই-বুক সরবরাহ নিশ্চিত করতে, আপনাকে নীচে তালিকাভুক্ত প্রতিটি ডিভাইসের জন্য একটি বৈধ প্রেরক হিসাবে উপরের ইমেল ঠিকানাটি যুক্ত করতে হতে পারে।", "MessageEreaderDevices": "ই-বুক সরবরাহ নিশ্চিত করতে, আপনাকে নীচে তালিকাভুক্ত প্রতিটি ডিভাইসের জন্য একটি বৈধ প্রেরক হিসাবে উপরের ইমেল ঠিকানাটি যুক্ত করতে হতে পারে।",
"MessageFeedURLWillBe": "ফিড URL হবে {0}", "MessageFeedURLWillBe": "ফিড URL হবে {0}",
@@ -744,6 +797,7 @@
"MessageNoLogs": "কোনও লগ নেই", "MessageNoLogs": "কোনও লগ নেই",
"MessageNoMediaProgress": "মিডিয়া অগ্রগতি নেই", "MessageNoMediaProgress": "মিডিয়া অগ্রগতি নেই",
"MessageNoNotifications": "কোনো বিজ্ঞপ্তি নেই", "MessageNoNotifications": "কোনো বিজ্ঞপ্তি নেই",
"MessageNoPodcastFeed": "অবৈধ পডকাস্ট: কোনো ফিড নেই",
"MessageNoPodcastsFound": "কোন পডকাস্ট পাওয়া যায়নি", "MessageNoPodcastsFound": "কোন পডকাস্ট পাওয়া যায়নি",
"MessageNoResults": "কোন ফলাফল নেই", "MessageNoResults": "কোন ফলাফল নেই",
"MessageNoSearchResultsFor": "\"{0}\" এর জন্য কোন অনুসন্ধান ফলাফল নেই", "MessageNoSearchResultsFor": "\"{0}\" এর জন্য কোন অনুসন্ধান ফলাফল নেই",
@@ -760,6 +814,10 @@
"MessagePlaylistCreateFromCollection": "সংগ্রহ থেকে প্লেলিস্ট তৈরি করুন", "MessagePlaylistCreateFromCollection": "সংগ্রহ থেকে প্লেলিস্ট তৈরি করুন",
"MessagePleaseWait": "অনুগ্রহ করে অপেক্ষা করুন..।", "MessagePleaseWait": "অনুগ্রহ করে অপেক্ষা করুন..।",
"MessagePodcastHasNoRSSFeedForMatching": "পডকাস্টের সাথে মিলের জন্য ব্যবহার করার জন্য কোন RSS ফিড ইউআরএল নেই", "MessagePodcastHasNoRSSFeedForMatching": "পডকাস্টের সাথে মিলের জন্য ব্যবহার করার জন্য কোন RSS ফিড ইউআরএল নেই",
"MessagePodcastSearchField": "অনুসন্ধান শব্দ বা RSS ফিড URL লিখুন",
"MessageQuickEmbedInProgress": "দ্রুত এম্বেড করা হচ্ছে",
"MessageQuickEmbedQueue": "দ্রুত এম্বেড করার জন্য সারিবদ্ধ ({0} সারিতে)",
"MessageQuickMatchAllEpisodes": "দ্রুত ম্যাচ সব পর্ব",
"MessageQuickMatchDescription": "খালি আইটেমের বিশদ বিবরণ এবং '{0}' থেকে প্রথম ম্যাচের ফলাফলের সাথে কভার করুন। সার্ভার সেটিং সক্ষম না থাকলে বিশদ ওভাররাইট করে না।", "MessageQuickMatchDescription": "খালি আইটেমের বিশদ বিবরণ এবং '{0}' থেকে প্রথম ম্যাচের ফলাফলের সাথে কভার করুন। সার্ভার সেটিং সক্ষম না থাকলে বিশদ ওভাররাইট করে না।",
"MessageRemoveChapter": "অধ্যায় সরান", "MessageRemoveChapter": "অধ্যায় সরান",
"MessageRemoveEpisodes": "{0}টি পর্ব(গুলি) সরান", "MessageRemoveEpisodes": "{0}টি পর্ব(গুলি) সরান",
@@ -802,6 +860,9 @@
"MessageTaskOpmlImportFeedPodcastExists": "পডকাস্ট আগে থেকেই পাথে বিদ্যমান", "MessageTaskOpmlImportFeedPodcastExists": "পডকাস্ট আগে থেকেই পাথে বিদ্যমান",
"MessageTaskOpmlImportFeedPodcastFailed": "পডকাস্ট তৈরি করতে ব্যর্থ", "MessageTaskOpmlImportFeedPodcastFailed": "পডকাস্ট তৈরি করতে ব্যর্থ",
"MessageTaskOpmlImportFinished": "{0}টি পডকাস্ট যোগ করা হয়েছে", "MessageTaskOpmlImportFinished": "{0}টি পডকাস্ট যোগ করা হয়েছে",
"MessageTaskOpmlParseFailed": "OPML ফাইল পার্স করতে ব্যর্থ হয়েছে",
"MessageTaskOpmlParseFastFail": "অবৈধ OPML ফাইল <opml> ট্যাগ পাওয়া যায়নি বা একটি <outline> ট্যাগ পাওয়া যায়নি",
"MessageTaskOpmlParseNoneFound": "OPML ফাইলে কোনো ফিড পাওয়া যায়নি",
"MessageTaskScanItemsAdded": "{0}টি করা হয়েছে", "MessageTaskScanItemsAdded": "{0}টি করা হয়েছে",
"MessageTaskScanItemsMissing": "{0}টি অনুপস্থিত", "MessageTaskScanItemsMissing": "{0}টি অনুপস্থিত",
"MessageTaskScanItemsUpdated": "{0} টি আপডেট করা হয়েছে", "MessageTaskScanItemsUpdated": "{0} টি আপডেট করা হয়েছে",
@@ -826,6 +887,10 @@
"NoteUploaderFoldersWithMediaFiles": "মিডিয়া ফাইল সহ ফোল্ডারগুলি আলাদা লাইব্রেরি আইটেম হিসাবে পরিচালনা করা হবে।", "NoteUploaderFoldersWithMediaFiles": "মিডিয়া ফাইল সহ ফোল্ডারগুলি আলাদা লাইব্রেরি আইটেম হিসাবে পরিচালনা করা হবে।",
"NoteUploaderOnlyAudioFiles": "যদি শুধুমাত্র অডিও ফাইল আপলোড করা হয় তবে প্রতিটি অডিও ফাইল একটি পৃথক অডিওবুক হিসাবে পরিচালনা করা হবে।", "NoteUploaderOnlyAudioFiles": "যদি শুধুমাত্র অডিও ফাইল আপলোড করা হয় তবে প্রতিটি অডিও ফাইল একটি পৃথক অডিওবুক হিসাবে পরিচালনা করা হবে।",
"NoteUploaderUnsupportedFiles": "অসমর্থিত ফাইলগুলি উপেক্ষা করা হয়। একটি ফোল্ডার বেছে নেওয়া বা ফেলে দেওয়ার সময়, আইটেম ফোল্ডারে নেই এমন অন্যান্য ফাইলগুলি উপেক্ষা করা হয়।", "NoteUploaderUnsupportedFiles": "অসমর্থিত ফাইলগুলি উপেক্ষা করা হয়। একটি ফোল্ডার বেছে নেওয়া বা ফেলে দেওয়ার সময়, আইটেম ফোল্ডারে নেই এমন অন্যান্য ফাইলগুলি উপেক্ষা করা হয়।",
"NotificationOnBackupCompletedDescription": "ব্যাকআপ সম্পূর্ণ হলে ট্রিগার হবে",
"NotificationOnBackupFailedDescription": "ব্যাকআপ ব্যর্থ হলে ট্রিগার হবে",
"NotificationOnEpisodeDownloadedDescription": "একটি পডকাস্ট পর্ব স্বয়ংক্রিয়ভাবে ডাউনলোড হলে ট্রিগার হবে",
"NotificationOnTestDescription": "বিজ্ঞপ্তি সিস্টেম পরীক্ষার জন্য ইভেন্ট",
"PlaceholderNewCollection": "নতুন সংগ্রহের নাম", "PlaceholderNewCollection": "নতুন সংগ্রহের নাম",
"PlaceholderNewFolderPath": "নতুন ফোল্ডার পথ", "PlaceholderNewFolderPath": "নতুন ফোল্ডার পথ",
"PlaceholderNewPlaylist": "নতুন প্লেলিস্টের নাম", "PlaceholderNewPlaylist": "নতুন প্লেলিস্টের নাম",
@@ -851,6 +916,7 @@
"StatsYearInReview": "বাৎসরিক পর্যালোচনা", "StatsYearInReview": "বাৎসরিক পর্যালোচনা",
"ToastAccountUpdateSuccess": "অ্যাকাউন্ট আপডেট করা হয়েছে", "ToastAccountUpdateSuccess": "অ্যাকাউন্ট আপডেট করা হয়েছে",
"ToastAppriseUrlRequired": "একটি Apprise ইউআরএল লিখতে হবে", "ToastAppriseUrlRequired": "একটি Apprise ইউআরএল লিখতে হবে",
"ToastAsinRequired": "ASIN প্রয়োজন",
"ToastAuthorImageRemoveSuccess": "লেখকের ছবি সরানো হয়েছে", "ToastAuthorImageRemoveSuccess": "লেখকের ছবি সরানো হয়েছে",
"ToastAuthorNotFound": "লেখক \"{0}\" খুঁজে পাওয়া যায়নি", "ToastAuthorNotFound": "লেখক \"{0}\" খুঁজে পাওয়া যায়নি",
"ToastAuthorRemoveSuccess": "লেখক সরানো হয়েছে", "ToastAuthorRemoveSuccess": "লেখক সরানো হয়েছে",
@@ -870,6 +936,8 @@
"ToastBackupUploadSuccess": "ব্যাকআপ আপলোড হয়েছে", "ToastBackupUploadSuccess": "ব্যাকআপ আপলোড হয়েছে",
"ToastBatchDeleteFailed": "ব্যাচ মুছে ফেলতে ব্যর্থ হয়েছে", "ToastBatchDeleteFailed": "ব্যাচ মুছে ফেলতে ব্যর্থ হয়েছে",
"ToastBatchDeleteSuccess": "ব্যাচ মুছে ফেলা সফল হয়েছে", "ToastBatchDeleteSuccess": "ব্যাচ মুছে ফেলা সফল হয়েছে",
"ToastBatchQuickMatchFailed": "ব্যাচ কুইক ম্যাচ ব্যর্থ!",
"ToastBatchQuickMatchStarted": "{0}টি বইয়ের ব্যাচ কুইক ম্যাচ শুরু হয়েছে!",
"ToastBatchUpdateFailed": "ব্যাচ আপডেট ব্যর্থ হয়েছে", "ToastBatchUpdateFailed": "ব্যাচ আপডেট ব্যর্থ হয়েছে",
"ToastBatchUpdateSuccess": "ব্যাচ আপডেট সাফল্য", "ToastBatchUpdateSuccess": "ব্যাচ আপডেট সাফল্য",
"ToastBookmarkCreateFailed": "বুকমার্ক তৈরি করতে ব্যর্থ", "ToastBookmarkCreateFailed": "বুকমার্ক তৈরি করতে ব্যর্থ",
@@ -881,6 +949,7 @@
"ToastChaptersHaveErrors": "অধ্যায়ে ত্রুটি আছে", "ToastChaptersHaveErrors": "অধ্যায়ে ত্রুটি আছে",
"ToastChaptersMustHaveTitles": "অধ্যায়ের শিরোনাম থাকতে হবে", "ToastChaptersMustHaveTitles": "অধ্যায়ের শিরোনাম থাকতে হবে",
"ToastChaptersRemoved": "অধ্যায়গুলো মুছে ফেলা হয়েছে", "ToastChaptersRemoved": "অধ্যায়গুলো মুছে ফেলা হয়েছে",
"ToastChaptersUpdated": "অধ্যায় আপডেট করা হয়েছে",
"ToastCollectionItemsAddFailed": "আইটেম(গুলি) সংগ্রহে যোগ করা ব্যর্থ হয়েছে", "ToastCollectionItemsAddFailed": "আইটেম(গুলি) সংগ্রহে যোগ করা ব্যর্থ হয়েছে",
"ToastCollectionItemsAddSuccess": "আইটেম(গুলি) সংগ্রহে যোগ করা সফল হয়েছে", "ToastCollectionItemsAddSuccess": "আইটেম(গুলি) সংগ্রহে যোগ করা সফল হয়েছে",
"ToastCollectionItemsRemoveSuccess": "আইটেম(গুলি) সংগ্রহ থেকে সরানো হয়েছে", "ToastCollectionItemsRemoveSuccess": "আইটেম(গুলি) সংগ্রহ থেকে সরানো হয়েছে",
@@ -898,11 +967,14 @@
"ToastEncodeCancelSucces": "এনকোড বাতিল করা হয়েছে", "ToastEncodeCancelSucces": "এনকোড বাতিল করা হয়েছে",
"ToastEpisodeDownloadQueueClearFailed": "সারি সাফ করতে ব্যর্থ হয়েছে", "ToastEpisodeDownloadQueueClearFailed": "সারি সাফ করতে ব্যর্থ হয়েছে",
"ToastEpisodeDownloadQueueClearSuccess": "পর্ব ডাউনলোড সারি পরিষ্কার করা হয়েছে", "ToastEpisodeDownloadQueueClearSuccess": "পর্ব ডাউনলোড সারি পরিষ্কার করা হয়েছে",
"ToastEpisodeUpdateSuccess": "{0}টি পর্ব আপডেট করা হয়েছে",
"ToastErrorCannotShare": "এই ডিভাইসে স্থানীয়ভাবে শেয়ার করা যাবে না", "ToastErrorCannotShare": "এই ডিভাইসে স্থানীয়ভাবে শেয়ার করা যাবে না",
"ToastFailedToLoadData": "ডেটা লোড করা যায়নি", "ToastFailedToLoadData": "ডেটা লোড করা যায়নি",
"ToastFailedToMatch": "মেলাতে ব্যর্থ হয়েছে",
"ToastFailedToShare": "শেয়ার করতে ব্যর্থ", "ToastFailedToShare": "শেয়ার করতে ব্যর্থ",
"ToastFailedToUpdate": "আপডেট করতে ব্যর্থ হয়েছে", "ToastFailedToUpdate": "আপডেট করতে ব্যর্থ হয়েছে",
"ToastInvalidImageUrl": "অকার্যকর ছবির ইউআরএল", "ToastInvalidImageUrl": "অকার্যকর ছবির ইউআরএল",
"ToastInvalidMaxEpisodesToDownload": "ডাউনলোড করার জন্য অবৈধ সর্বোচ্চ পর্ব",
"ToastInvalidUrl": "অকার্যকর ইউআরএল", "ToastInvalidUrl": "অকার্যকর ইউআরএল",
"ToastItemCoverUpdateSuccess": "আইটেম কভার আপডেট করা হয়েছে", "ToastItemCoverUpdateSuccess": "আইটেম কভার আপডেট করা হয়েছে",
"ToastItemDeletedFailed": "আইটেম মুছে ফেলতে ব্যর্থ", "ToastItemDeletedFailed": "আইটেম মুছে ফেলতে ব্যর্থ",
@@ -920,14 +992,22 @@
"ToastLibraryScanFailedToStart": "স্ক্যান শুরু করতে ব্যর্থ", "ToastLibraryScanFailedToStart": "স্ক্যান শুরু করতে ব্যর্থ",
"ToastLibraryScanStarted": "লাইব্রেরি স্ক্যান শুরু হয়েছে", "ToastLibraryScanStarted": "লাইব্রেরি স্ক্যান শুরু হয়েছে",
"ToastLibraryUpdateSuccess": "লাইব্রেরি \"{0}\" আপডেট করা হয়েছে", "ToastLibraryUpdateSuccess": "লাইব্রেরি \"{0}\" আপডেট করা হয়েছে",
"ToastMatchAllAuthorsFailed": "সমস্ত লেখকের সাথে মিলতে ব্যর্থ হয়েছে",
"ToastMetadataFilesRemovedError": "মেটাডেটা সরানোর সময় ত্রুটি {0} ফাইল",
"ToastMetadataFilesRemovedNoneFound": "কোনো মেটাডেটা নেই।লাইব্রেরিতে {0} ফাইল পাওয়া গেছে",
"ToastMetadataFilesRemovedNoneRemoved": "কোনো মেটাডেটা নেই।{0} ফাইল সরানো হয়েছে",
"ToastMetadataFilesRemovedSuccess": "{0} মেটাডেটা৷{1} ফাইল সরানো হয়েছে",
"ToastMustHaveAtLeastOnePath": "অন্তত একটি পথ থাকতে হবে",
"ToastNameEmailRequired": "নাম এবং ইমেইল আবশ্যক", "ToastNameEmailRequired": "নাম এবং ইমেইল আবশ্যক",
"ToastNameRequired": "নাম আবশ্যক", "ToastNameRequired": "নাম আবশ্যক",
"ToastNewEpisodesFound": "{0}টি নতুন পর্ব পাওয়া গেছে",
"ToastNewUserCreatedFailed": "অ্যাকাউন্ট তৈরি করতে ব্যর্থ: \"{0}\"", "ToastNewUserCreatedFailed": "অ্যাকাউন্ট তৈরি করতে ব্যর্থ: \"{0}\"",
"ToastNewUserCreatedSuccess": "নতুন একাউন্ট তৈরি হয়েছে", "ToastNewUserCreatedSuccess": "নতুন একাউন্ট তৈরি হয়েছে",
"ToastNewUserLibraryError": "অন্তত একটি লাইব্রেরি নির্বাচন করতে হবে", "ToastNewUserLibraryError": "অন্তত একটি লাইব্রেরি নির্বাচন করতে হবে",
"ToastNewUserPasswordError": "অন্তত একটি পাসওয়ার্ড থাকতে হবে, শুধুমাত্র রুট ব্যবহারকারীর একটি খালি পাসওয়ার্ড থাকতে পারে", "ToastNewUserPasswordError": "অন্তত একটি পাসওয়ার্ড থাকতে হবে, শুধুমাত্র রুট ব্যবহারকারীর একটি খালি পাসওয়ার্ড থাকতে পারে",
"ToastNewUserTagError": "অন্তত একটি ট্যাগ নির্বাচন করতে হবে", "ToastNewUserTagError": "অন্তত একটি ট্যাগ নির্বাচন করতে হবে",
"ToastNewUserUsernameError": "একটি ব্যবহারকারীর নাম লিখুন", "ToastNewUserUsernameError": "একটি ব্যবহারকারীর নাম লিখুন",
"ToastNoNewEpisodesFound": "কোন নতুন পর্ব পাওয়া যায়নি",
"ToastNoUpdatesNecessary": "কোন আপডেটের প্রয়োজন নেই", "ToastNoUpdatesNecessary": "কোন আপডেটের প্রয়োজন নেই",
"ToastNotificationCreateFailed": "বিজ্ঞপ্তি তৈরি করতে ব্যর্থ", "ToastNotificationCreateFailed": "বিজ্ঞপ্তি তৈরি করতে ব্যর্থ",
"ToastNotificationDeleteFailed": "বিজ্ঞপ্তি মুছে ফেলতে ব্যর্থ", "ToastNotificationDeleteFailed": "বিজ্ঞপ্তি মুছে ফেলতে ব্যর্থ",
@@ -946,6 +1026,7 @@
"ToastPodcastGetFeedFailed": "পডকাস্ট ফিড পেতে ব্যর্থ হয়েছে", "ToastPodcastGetFeedFailed": "পডকাস্ট ফিড পেতে ব্যর্থ হয়েছে",
"ToastPodcastNoEpisodesInFeed": "আরএসএস ফিডে কোনো পর্ব পাওয়া যায়নি", "ToastPodcastNoEpisodesInFeed": "আরএসএস ফিডে কোনো পর্ব পাওয়া যায়নি",
"ToastPodcastNoRssFeed": "পডকাস্টের কোন আরএসএস ফিড নেই", "ToastPodcastNoRssFeed": "পডকাস্টের কোন আরএসএস ফিড নেই",
"ToastProgressIsNotBeingSynced": "অগ্রগতি সিঙ্ক হচ্ছে না, প্লেব্যাক পুনরায় চালু করুন",
"ToastProviderCreatedFailed": "প্রদানকারী যোগ করতে ব্যর্থ হয়েছে", "ToastProviderCreatedFailed": "প্রদানকারী যোগ করতে ব্যর্থ হয়েছে",
"ToastProviderCreatedSuccess": "নতুন প্রদানকারী যোগ করা হয়েছে", "ToastProviderCreatedSuccess": "নতুন প্রদানকারী যোগ করা হয়েছে",
"ToastProviderNameAndUrlRequired": "নাম এবং ইউআরএল আবশ্যক", "ToastProviderNameAndUrlRequired": "নাম এবং ইউআরএল আবশ্যক",
@@ -972,6 +1053,7 @@
"ToastSessionCloseFailed": "অধিবেশন বন্ধ করতে ব্যর্থ হয়েছে", "ToastSessionCloseFailed": "অধিবেশন বন্ধ করতে ব্যর্থ হয়েছে",
"ToastSessionDeleteFailed": "সেশন মুছে ফেলতে ব্যর্থ", "ToastSessionDeleteFailed": "সেশন মুছে ফেলতে ব্যর্থ",
"ToastSessionDeleteSuccess": "সেশন মুছে ফেলা হয়েছে", "ToastSessionDeleteSuccess": "সেশন মুছে ফেলা হয়েছে",
"ToastSleepTimerDone": "স্লিপ টাইমার হয়ে গেছে... zZzzZz",
"ToastSlugMustChange": "স্লাগে অবৈধ অক্ষর রয়েছে", "ToastSlugMustChange": "স্লাগে অবৈধ অক্ষর রয়েছে",
"ToastSlugRequired": "স্লাগ আবশ্যক", "ToastSlugRequired": "স্লাগ আবশ্যক",
"ToastSocketConnected": "সকেট সংযুক্ত", "ToastSocketConnected": "সকেট সংযুক্ত",
File diff suppressed because it is too large Load Diff
+7 -4
View File
@@ -584,7 +584,7 @@
"LabelSettingsStoreMetadataWithItemHelp": "Standardmäßig werden die Metadaten in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Metadaten als OPF-Datei (Textdatei) in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet", "LabelSettingsStoreMetadataWithItemHelp": "Standardmäßig werden die Metadaten in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Metadaten als OPF-Datei (Textdatei) in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet",
"LabelSettingsTimeFormat": "Zeitformat", "LabelSettingsTimeFormat": "Zeitformat",
"LabelShare": "Freigeben", "LabelShare": "Freigeben",
"LabelShareOpen": "Freigabe", "LabelShareOpen": "Freigeben",
"LabelShareURL": "Freigabe URL", "LabelShareURL": "Freigabe URL",
"LabelShowAll": "Alles anzeigen", "LabelShowAll": "Alles anzeigen",
"LabelShowSeconds": "Zeige Sekunden", "LabelShowSeconds": "Zeige Sekunden",
@@ -663,6 +663,7 @@
"LabelUpdateDetailsHelp": "Erlaube das Überschreiben bestehender Details für die ausgewählten Hörbücher, wenn eine Übereinstimmung gefunden wird", "LabelUpdateDetailsHelp": "Erlaube das Überschreiben bestehender Details für die ausgewählten Hörbücher, wenn eine Übereinstimmung gefunden wird",
"LabelUpdatedAt": "Aktualisiert am", "LabelUpdatedAt": "Aktualisiert am",
"LabelUploaderDragAndDrop": "Ziehen und Ablegen von Dateien oder Ordnern", "LabelUploaderDragAndDrop": "Ziehen und Ablegen von Dateien oder Ordnern",
"LabelUploaderDragAndDropFilesOnly": "Dateien per Drag & Drop hierher ziehen",
"LabelUploaderDropFiles": "Dateien löschen", "LabelUploaderDropFiles": "Dateien löschen",
"LabelUploaderItemFetchMetadataHelp": "Automatisches Aktualisieren von Titel, Autor und Serie", "LabelUploaderItemFetchMetadataHelp": "Automatisches Aktualisieren von Titel, Autor und Serie",
"LabelUseAdvancedOptions": "Nutze Erweiterte Optionen", "LabelUseAdvancedOptions": "Nutze Erweiterte Optionen",
@@ -678,6 +679,8 @@
"LabelViewPlayerSettings": "Zeige player Einstellungen", "LabelViewPlayerSettings": "Zeige player Einstellungen",
"LabelViewQueue": "Player-Warteschlange anzeigen", "LabelViewQueue": "Player-Warteschlange anzeigen",
"LabelVolume": "Lautstärke", "LabelVolume": "Lautstärke",
"LabelWebRedirectURLsDescription": "Autorisieren Sie diese URLs bei ihrem OAuth-Anbieter, um die Weiterleitung zurück zur Webanwendung nach dem Login zu ermöglichen:",
"LabelWebRedirectURLsSubfolder": "Unterordner für Weiterleitung-URLs",
"LabelWeekdaysToRun": "Wochentage für die Ausführung", "LabelWeekdaysToRun": "Wochentage für die Ausführung",
"LabelXBooks": "{0} Bücher", "LabelXBooks": "{0} Bücher",
"LabelXItems": "{0} Medien", "LabelXItems": "{0} Medien",
@@ -727,7 +730,7 @@
"MessageConfirmPurgeCache": "Cache leeren wird das ganze Verzeichnis <code>/metadata/cache</code> löschen. <br /><br />Bist du dir sicher, dass das Cache Verzeichnis gelöscht werden soll?", "MessageConfirmPurgeCache": "Cache leeren wird das ganze Verzeichnis <code>/metadata/cache</code> löschen. <br /><br />Bist du dir sicher, dass das Cache Verzeichnis gelöscht werden soll?",
"MessageConfirmPurgeItemsCache": "Durch Elementcache leeren wird das gesamte Verzeichnis unter <code>/metadata/cache/items</code> gelöscht.<br />Bist du dir sicher?", "MessageConfirmPurgeItemsCache": "Durch Elementcache leeren wird das gesamte Verzeichnis unter <code>/metadata/cache/items</code> gelöscht.<br />Bist du dir sicher?",
"MessageConfirmQuickEmbed": "Warnung! Audiodateien werden bei der Schnelleinbettung nicht gesichert! Achte darauf, dass du eine Sicherungskopie der Audiodateien besitzt. <br><br>Möchtest du fortfahren?", "MessageConfirmQuickEmbed": "Warnung! Audiodateien werden bei der Schnelleinbettung nicht gesichert! Achte darauf, dass du eine Sicherungskopie der Audiodateien besitzt. <br><br>Möchtest du fortfahren?",
"MessageConfirmQuickMatchEpisodes": "Schnelles Zuordnen von Episoden überschreibt die Details, wenn eine Übereinstimmung gefunden wird. Nur nicht zugeordnete Episoden werden aktualisiert. Bist du sicher?", "MessageConfirmQuickMatchEpisodes": "Schnellabgleich von Episoden überschreibt deren Details, wenn ein passender Eintrag gefunden wurde, wird aber nur auf bisher unbearbeitete Episoden angewendet. Wirklich fortfahren?",
"MessageConfirmReScanLibraryItems": "{0} Elemente werden erneut gescannt! Bist du dir sicher?", "MessageConfirmReScanLibraryItems": "{0} Elemente werden erneut gescannt! Bist du dir sicher?",
"MessageConfirmRemoveAllChapters": "Alle Kapitel werden entfernt! Bist du dir sicher?", "MessageConfirmRemoveAllChapters": "Alle Kapitel werden entfernt! Bist du dir sicher?",
"MessageConfirmRemoveAuthor": "Autor \"{0}\" wird enfernt! Bist du dir sicher?", "MessageConfirmRemoveAuthor": "Autor \"{0}\" wird enfernt! Bist du dir sicher?",
@@ -832,7 +835,7 @@
"MessageSetChaptersFromTracksDescription": "Kaitelerstellung basiert auf den existierenden einzelnen Audiodateien. Pro existierende Audiodatei wird 1 Kapitel erstellt, wobei deren Kapitelname aus dem Audiodateinamen extrahiert wird", "MessageSetChaptersFromTracksDescription": "Kaitelerstellung basiert auf den existierenden einzelnen Audiodateien. Pro existierende Audiodatei wird 1 Kapitel erstellt, wobei deren Kapitelname aus dem Audiodateinamen extrahiert wird",
"MessageShareExpirationWillBe": "Läuft am <strong>{0}</strong> ab", "MessageShareExpirationWillBe": "Läuft am <strong>{0}</strong> ab",
"MessageShareExpiresIn": "Läuft in {0} ab", "MessageShareExpiresIn": "Läuft in {0} ab",
"MessageShareURLWillBe": "Der Freigabe Link wird <strong>{0}</strong> sein.", "MessageShareURLWillBe": "Der Freigabe Link wird <strong>{0}</strong> sein",
"MessageStartPlaybackAtTime": "Start der Wiedergabe für \"{0}\" bei {1}?", "MessageStartPlaybackAtTime": "Start der Wiedergabe für \"{0}\" bei {1}?",
"MessageTaskAudioFileNotWritable": "Die Audiodatei \"{0}\" ist schreibgeschützt", "MessageTaskAudioFileNotWritable": "Die Audiodatei \"{0}\" ist schreibgeschützt",
"MessageTaskCanceledByUser": "Aufgabe vom Benutzer abgebrochen", "MessageTaskCanceledByUser": "Aufgabe vom Benutzer abgebrochen",
@@ -1040,7 +1043,7 @@
"ToastRenameFailed": "Umbenennen fehlgeschlagen", "ToastRenameFailed": "Umbenennen fehlgeschlagen",
"ToastRescanFailed": "Erneut scannen fehlgeschlagen für {0}", "ToastRescanFailed": "Erneut scannen fehlgeschlagen für {0}",
"ToastRescanRemoved": "Erneut scannen erledigt, Artikel wurde entfernt", "ToastRescanRemoved": "Erneut scannen erledigt, Artikel wurde entfernt",
"ToastRescanUpToDate": "Erneut scannen erledigt, Artikel wahr auf dem neusten Stand", "ToastRescanUpToDate": "Erneut scannen erledigt, Artikel war auf dem neusten Stand",
"ToastRescanUpdated": "Erneut scannen erledigt, Artikel wurde verändert", "ToastRescanUpdated": "Erneut scannen erledigt, Artikel wurde verändert",
"ToastScanFailed": "Fehler beim scannen des Artikels der Bibliothek", "ToastScanFailed": "Fehler beim scannen des Artikels der Bibliothek",
"ToastSelectAtLeastOneUser": "Wähle mindestens einen Benutzer aus", "ToastSelectAtLeastOneUser": "Wähle mindestens einen Benutzer aus",
+8
View File
@@ -88,6 +88,8 @@
"ButtonSaveTracklist": "Save Tracklist", "ButtonSaveTracklist": "Save Tracklist",
"ButtonScan": "Scan", "ButtonScan": "Scan",
"ButtonScanLibrary": "Scan Library", "ButtonScanLibrary": "Scan Library",
"ButtonScrollLeft": "Scroll Left",
"ButtonScrollRight": "Scroll Right",
"ButtonSearch": "Search", "ButtonSearch": "Search",
"ButtonSelectFolderPath": "Select Folder Path", "ButtonSelectFolderPath": "Select Folder Path",
"ButtonSeries": "Series", "ButtonSeries": "Series",
@@ -190,6 +192,7 @@
"HeaderSettingsExperimental": "Experimental Features", "HeaderSettingsExperimental": "Experimental Features",
"HeaderSettingsGeneral": "General", "HeaderSettingsGeneral": "General",
"HeaderSettingsScanner": "Scanner", "HeaderSettingsScanner": "Scanner",
"HeaderSettingsWebClient": "Web Client",
"HeaderSleepTimer": "Sleep Timer", "HeaderSleepTimer": "Sleep Timer",
"HeaderStatsLargestItems": "Largest Items", "HeaderStatsLargestItems": "Largest Items",
"HeaderStatsLongestItems": "Longest Items (hrs)", "HeaderStatsLongestItems": "Longest Items (hrs)",
@@ -542,6 +545,7 @@
"LabelServerYearReview": "Server Year in Review ({0})", "LabelServerYearReview": "Server Year in Review ({0})",
"LabelSetEbookAsPrimary": "Set as primary", "LabelSetEbookAsPrimary": "Set as primary",
"LabelSetEbookAsSupplementary": "Set as supplementary", "LabelSetEbookAsSupplementary": "Set as supplementary",
"LabelSettingsAllowIframe": "Allow embedding in an iframe",
"LabelSettingsAudiobooksOnly": "Audiobooks only", "LabelSettingsAudiobooksOnly": "Audiobooks only",
"LabelSettingsAudiobooksOnlyHelp": "Enabling this setting will ignore ebook files unless they are inside an audiobook folder in which case they will be set as supplementary ebooks", "LabelSettingsAudiobooksOnlyHelp": "Enabling this setting will ignore ebook files unless they are inside an audiobook folder in which case they will be set as supplementary ebooks",
"LabelSettingsBookshelfViewHelp": "Skeumorphic design with wooden shelves", "LabelSettingsBookshelfViewHelp": "Skeumorphic design with wooden shelves",
@@ -592,6 +596,8 @@
"LabelSize": "Size", "LabelSize": "Size",
"LabelSleepTimer": "Sleep timer", "LabelSleepTimer": "Sleep timer",
"LabelSlug": "Slug", "LabelSlug": "Slug",
"LabelSortAscending": "Ascending",
"LabelSortDescending": "Descending",
"LabelStart": "Start", "LabelStart": "Start",
"LabelStartTime": "Start Time", "LabelStartTime": "Start Time",
"LabelStarted": "Started", "LabelStarted": "Started",
@@ -679,6 +685,8 @@
"LabelViewPlayerSettings": "View player settings", "LabelViewPlayerSettings": "View player settings",
"LabelViewQueue": "View player queue", "LabelViewQueue": "View player queue",
"LabelVolume": "Volume", "LabelVolume": "Volume",
"LabelWebRedirectURLsDescription": "Authorize these URLs in your OAuth provider to allow redirection back to the web app after login:",
"LabelWebRedirectURLsSubfolder": "Subfolder for Redirect URLs",
"LabelWeekdaysToRun": "Weekdays to run", "LabelWeekdaysToRun": "Weekdays to run",
"LabelXBooks": "{0} books", "LabelXBooks": "{0} books",
"LabelXItems": "{0} items", "LabelXItems": "{0} items",
+3
View File
@@ -663,6 +663,7 @@
"LabelUpdateDetailsHelp": "Permitir sobrescribir detalles existentes de los libros seleccionados cuando sean encontrados", "LabelUpdateDetailsHelp": "Permitir sobrescribir detalles existentes de los libros seleccionados cuando sean encontrados",
"LabelUpdatedAt": "Actualizado En", "LabelUpdatedAt": "Actualizado En",
"LabelUploaderDragAndDrop": "Arrastre y suelte archivos o carpetas", "LabelUploaderDragAndDrop": "Arrastre y suelte archivos o carpetas",
"LabelUploaderDragAndDropFilesOnly": "Arrastrar y soltar archivos",
"LabelUploaderDropFiles": "Suelte los Archivos", "LabelUploaderDropFiles": "Suelte los Archivos",
"LabelUploaderItemFetchMetadataHelp": "Buscar título, autor y series automáticamente", "LabelUploaderItemFetchMetadataHelp": "Buscar título, autor y series automáticamente",
"LabelUseAdvancedOptions": "Usar opciones avanzadas", "LabelUseAdvancedOptions": "Usar opciones avanzadas",
@@ -678,6 +679,8 @@
"LabelViewPlayerSettings": "Ver los ajustes del reproductor", "LabelViewPlayerSettings": "Ver los ajustes del reproductor",
"LabelViewQueue": "Ver Fila del Reproductor", "LabelViewQueue": "Ver Fila del Reproductor",
"LabelVolume": "Volumen", "LabelVolume": "Volumen",
"LabelWebRedirectURLsDescription": "Autorice estas URL en su proveedor OAuth para permitir la redirección a la aplicación web después de iniciar sesión:",
"LabelWebRedirectURLsSubfolder": "Subcarpeta para URL de redireccionamiento",
"LabelWeekdaysToRun": "Correr en Días de la Semana", "LabelWeekdaysToRun": "Correr en Días de la Semana",
"LabelXBooks": "{0} libros", "LabelXBooks": "{0} libros",
"LabelXItems": "{0} elementos", "LabelXItems": "{0} elementos",
+3 -2
View File
@@ -663,6 +663,7 @@
"LabelUpdateDetailsHelp": "Autoriser la mise à jour des détails existants lorsquune correspondance est trouvée", "LabelUpdateDetailsHelp": "Autoriser la mise à jour des détails existants lorsquune correspondance est trouvée",
"LabelUpdatedAt": "Mis à jour à", "LabelUpdatedAt": "Mis à jour à",
"LabelUploaderDragAndDrop": "Glisser et déposer des fichiers ou dossiers", "LabelUploaderDragAndDrop": "Glisser et déposer des fichiers ou dossiers",
"LabelUploaderDragAndDropFilesOnly": "Glisser & déposer des fichiers",
"LabelUploaderDropFiles": "Déposer des fichiers", "LabelUploaderDropFiles": "Déposer des fichiers",
"LabelUploaderItemFetchMetadataHelp": "Récupérer automatiquement le titre, lauteur et la série", "LabelUploaderItemFetchMetadataHelp": "Récupérer automatiquement le titre, lauteur et la série",
"LabelUseAdvancedOptions": "Utiliser les options avancées", "LabelUseAdvancedOptions": "Utiliser les options avancées",
@@ -869,10 +870,10 @@
"MessageTaskScanningFileChanges": "Analyse des modifications du fichier dans « {0} »", "MessageTaskScanningFileChanges": "Analyse des modifications du fichier dans « {0} »",
"MessageTaskScanningLibrary": "Analyse de la bibliothèque « {0} »", "MessageTaskScanningLibrary": "Analyse de la bibliothèque « {0} »",
"MessageTaskTargetDirectoryNotWritable": "Le répertoire cible nest pas accessible en écriture", "MessageTaskTargetDirectoryNotWritable": "Le répertoire cible nest pas accessible en écriture",
"MessageThinking": "Je cherche…", "MessageThinking": "À la recherche de…",
"MessageUploaderItemFailed": "Échec du téléversement", "MessageUploaderItemFailed": "Échec du téléversement",
"MessageUploaderItemSuccess": "Téléversement effectué !", "MessageUploaderItemSuccess": "Téléversement effectué !",
"MessageUploading": "Téléversement…", "MessageUploading": "Téléchargement…",
"MessageValidCronExpression": "Expression cron valide", "MessageValidCronExpression": "Expression cron valide",
"MessageWatcherIsDisabledGlobally": "La surveillance est désactivée par un paramètre global du serveur", "MessageWatcherIsDisabledGlobally": "La surveillance est désactivée par un paramètre global du serveur",
"MessageXLibraryIsEmpty": "La bibliothèque {0} est vide !", "MessageXLibraryIsEmpty": "La bibliothèque {0} est vide !",
+6 -3
View File
@@ -271,7 +271,7 @@
"LabelCollapseSubSeries": "Podserijale prikaži sažeto", "LabelCollapseSubSeries": "Podserijale prikaži sažeto",
"LabelCollection": "Zbirka", "LabelCollection": "Zbirka",
"LabelCollections": "Zbirke", "LabelCollections": "Zbirke",
"LabelComplete": "Dovršeno", "LabelComplete": "Potpuno",
"LabelConfirmPassword": "Potvrda zaporke", "LabelConfirmPassword": "Potvrda zaporke",
"LabelContinueListening": "Nastavi slušati", "LabelContinueListening": "Nastavi slušati",
"LabelContinueReading": "Nastavi čitati", "LabelContinueReading": "Nastavi čitati",
@@ -532,7 +532,7 @@
"LabelSelectAllEpisodes": "Označi sve nastavke", "LabelSelectAllEpisodes": "Označi sve nastavke",
"LabelSelectEpisodesShowing": "Prikazujem {0} odabranih nastavaka", "LabelSelectEpisodesShowing": "Prikazujem {0} odabranih nastavaka",
"LabelSelectUsers": "Označi korisnike", "LabelSelectUsers": "Označi korisnike",
"LabelSendEbookToDevice": "Pošalji e-knjigu", "LabelSendEbookToDevice": "Pošalji e-knjigu",
"LabelSequence": "Slijed", "LabelSequence": "Slijed",
"LabelSerial": "Serijal", "LabelSerial": "Serijal",
"LabelSeries": "Serijal", "LabelSeries": "Serijal",
@@ -567,7 +567,7 @@
"LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Preostalo vrijeme je manje od (sekundi)", "LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Preostalo vrijeme je manje od (sekundi)",
"LabelSettingsLibraryMarkAsFinishedWhen": "Označi medij dovršenim kada", "LabelSettingsLibraryMarkAsFinishedWhen": "Označi medij dovršenim kada",
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Preskoči ranije knjige u funkciji Nastavi serijal", "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Preskoči ranije knjige u funkciji Nastavi serijal",
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Na polici početne stranice Nastavi serijal prikazuje se prva nezapočeta knjiga serijala koji imaju barem jednu dovršenu knjigu i nijednu započetu knjigu. Ako uključite ovu opciju, serijal će vam se nastaviti od zadnje dovršene knjige umjesto od prve nezapočete knjige.", "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Na polici početne stranice Nastavi serijal prikazuje se prva nezapočeta knjiga serijala koji imaju barem jednu dovršenu knjigu i nijednu započetu knjigu. Ako se ova opcija uključi serijal će nastaviti od zadnje dovršene knjige umjesto od prve nezapočete knjige.",
"LabelSettingsParseSubtitles": "Raščlani podnaslove", "LabelSettingsParseSubtitles": "Raščlani podnaslove",
"LabelSettingsParseSubtitlesHelp": "Iz naziva mape zvučne knjige raščlanjuje podnaslov.<br>Podnaslov mora biti odvojen s \" - \"<br>npr. \"Naslov knjige - Ovo je podnaslov\" imat će podnaslov \"Ovo je podnaslov\"", "LabelSettingsParseSubtitlesHelp": "Iz naziva mape zvučne knjige raščlanjuje podnaslov.<br>Podnaslov mora biti odvojen s \" - \"<br>npr. \"Naslov knjige - Ovo je podnaslov\" imat će podnaslov \"Ovo je podnaslov\"",
"LabelSettingsPreferMatchedMetadata": "Daj prednost meta-podatcima prepoznatih stavki", "LabelSettingsPreferMatchedMetadata": "Daj prednost meta-podatcima prepoznatih stavki",
@@ -663,6 +663,7 @@
"LabelUpdateDetailsHelp": "Dopusti prepisivanje postojećih podataka za odabrane knjige kada se prepoznaju", "LabelUpdateDetailsHelp": "Dopusti prepisivanje postojećih podataka za odabrane knjige kada se prepoznaju",
"LabelUpdatedAt": "Ažurirano", "LabelUpdatedAt": "Ažurirano",
"LabelUploaderDragAndDrop": "Pritisni i prevuci datoteke ili mape", "LabelUploaderDragAndDrop": "Pritisni i prevuci datoteke ili mape",
"LabelUploaderDragAndDropFilesOnly": "Pritisni i prevuci datoteke",
"LabelUploaderDropFiles": "Ispusti datoteke", "LabelUploaderDropFiles": "Ispusti datoteke",
"LabelUploaderItemFetchMetadataHelp": "Automatski dohvati naslov, autora i serijal", "LabelUploaderItemFetchMetadataHelp": "Automatski dohvati naslov, autora i serijal",
"LabelUseAdvancedOptions": "Koristi se naprednim opcijama", "LabelUseAdvancedOptions": "Koristi se naprednim opcijama",
@@ -678,6 +679,8 @@
"LabelViewPlayerSettings": "Pogledaj postavke reproduktora", "LabelViewPlayerSettings": "Pogledaj postavke reproduktora",
"LabelViewQueue": "Pogledaj redoslijed izvođenja reproduktora", "LabelViewQueue": "Pogledaj redoslijed izvođenja reproduktora",
"LabelVolume": "Glasnoća", "LabelVolume": "Glasnoća",
"LabelWebRedirectURLsDescription": "Autoriziraj ove URL-ove u svom pružatelju OAuth ovjere kako bi omogućio preusmjeravanje natrag na web-aplikaciju nakon prijave:",
"LabelWebRedirectURLsSubfolder": "Podmapa za URL-ove preusmjeravanja",
"LabelWeekdaysToRun": "Dani u tjednu za pokretanje", "LabelWeekdaysToRun": "Dani u tjednu za pokretanje",
"LabelXBooks": "{0} knjiga", "LabelXBooks": "{0} knjiga",
"LabelXItems": "{0} stavki", "LabelXItems": "{0} stavki",
+1
View File
@@ -663,6 +663,7 @@
"LabelUpdateDetailsHelp": "Позволяет перезаписывать текущие подробности для выбранных книг если будут найдены", "LabelUpdateDetailsHelp": "Позволяет перезаписывать текущие подробности для выбранных книг если будут найдены",
"LabelUpdatedAt": "Обновлено в", "LabelUpdatedAt": "Обновлено в",
"LabelUploaderDragAndDrop": "Перетащите файлы или каталоги", "LabelUploaderDragAndDrop": "Перетащите файлы или каталоги",
"LabelUploaderDragAndDropFilesOnly": "Перетаскивание файлов",
"LabelUploaderDropFiles": "Перетащите файлы", "LabelUploaderDropFiles": "Перетащите файлы",
"LabelUploaderItemFetchMetadataHelp": "Автоматическое извлечение названия, автора и серии", "LabelUploaderItemFetchMetadataHelp": "Автоматическое извлечение названия, автора и серии",
"LabelUseAdvancedOptions": "Используйте расширенные опции", "LabelUseAdvancedOptions": "Используйте расширенные опции",
+5 -2
View File
@@ -184,7 +184,7 @@
"HeaderScheduleEpisodeDownloads": "Načrtovanje samodejnega prenosa epizod", "HeaderScheduleEpisodeDownloads": "Načrtovanje samodejnega prenosa epizod",
"HeaderScheduleLibraryScans": "Načrtuj samodejno pregledovanje knjižnice", "HeaderScheduleLibraryScans": "Načrtuj samodejno pregledovanje knjižnice",
"HeaderSession": "Seja", "HeaderSession": "Seja",
"HeaderSetBackupSchedule": "Nastavite urnik varnostnega kopiranja", "HeaderSetBackupSchedule": "Nastavi urnik varnostnega kopiranja",
"HeaderSettings": "Nastavitve", "HeaderSettings": "Nastavitve",
"HeaderSettingsDisplay": "Zaslon", "HeaderSettingsDisplay": "Zaslon",
"HeaderSettingsExperimental": "Eksperimentalne funkcije", "HeaderSettingsExperimental": "Eksperimentalne funkcije",
@@ -663,6 +663,7 @@
"LabelUpdateDetailsHelp": "Dovoli prepisovanje obstoječih podrobnosti za izbrane knjige, ko se najde ujemanje", "LabelUpdateDetailsHelp": "Dovoli prepisovanje obstoječih podrobnosti za izbrane knjige, ko se najde ujemanje",
"LabelUpdatedAt": "Posodobljeno ob", "LabelUpdatedAt": "Posodobljeno ob",
"LabelUploaderDragAndDrop": "Povleci in spusti datoteke ali mape", "LabelUploaderDragAndDrop": "Povleci in spusti datoteke ali mape",
"LabelUploaderDragAndDropFilesOnly": "Povleci in spusti datoteke",
"LabelUploaderDropFiles": "Spusti datoteke", "LabelUploaderDropFiles": "Spusti datoteke",
"LabelUploaderItemFetchMetadataHelp": "Samodejno pridobi naslov, avtorja in serijo", "LabelUploaderItemFetchMetadataHelp": "Samodejno pridobi naslov, avtorja in serijo",
"LabelUseAdvancedOptions": "Uporabi napredne možnosti", "LabelUseAdvancedOptions": "Uporabi napredne možnosti",
@@ -678,6 +679,8 @@
"LabelViewPlayerSettings": "Ogled nastavitev predvajalnika", "LabelViewPlayerSettings": "Ogled nastavitev predvajalnika",
"LabelViewQueue": "Ogled čakalno vrsto predvajalnika", "LabelViewQueue": "Ogled čakalno vrsto predvajalnika",
"LabelVolume": "Glasnost", "LabelVolume": "Glasnost",
"LabelWebRedirectURLsDescription": "Avtorizirajte URL-je pri svojem ponudniku OAuth ter s tem omogočite preusmeritev nazaj v spletno aplikacijo po prijavi:",
"LabelWebRedirectURLsSubfolder": "Podmapa za URL-je preusmeritve",
"LabelWeekdaysToRun": "Delovni dnevi predvajanja", "LabelWeekdaysToRun": "Delovni dnevi predvajanja",
"LabelXBooks": "{0} knjig", "LabelXBooks": "{0} knjig",
"LabelXItems": "{0} elementov", "LabelXItems": "{0} elementov",
@@ -829,7 +832,7 @@
"MessageSearchResultsFor": "Rezultati iskanja za", "MessageSearchResultsFor": "Rezultati iskanja za",
"MessageSelected": "{0} izbrano", "MessageSelected": "{0} izbrano",
"MessageServerCouldNotBeReached": "Strežnika ni bilo mogoče doseči", "MessageServerCouldNotBeReached": "Strežnika ni bilo mogoče doseči",
"MessageSetChaptersFromTracksDescription": "Nastavite poglavja z uporabo vsake zvočne datoteke kot poglavja in naslova poglavja kot imena zvočne datoteke", "MessageSetChaptersFromTracksDescription": "Nastavi poglavja z uporabo vsake zvočne datoteke kot poglavja in naslova poglavja kot imena zvočne datoteke",
"MessageShareExpirationWillBe": "Potečeno bo <strong>{0}</strong>", "MessageShareExpirationWillBe": "Potečeno bo <strong>{0}</strong>",
"MessageShareExpiresIn": "Poteče čez {0}", "MessageShareExpiresIn": "Poteče čez {0}",
"MessageShareURLWillBe": "URL za skupno rabo bo <strong>{0}</strong>", "MessageShareURLWillBe": "URL za skupno rabo bo <strong>{0}</strong>",
+3
View File
@@ -663,6 +663,7 @@
"LabelUpdateDetailsHelp": "Дозволити перезапис наявних подробиць обраних книг після віднайдення", "LabelUpdateDetailsHelp": "Дозволити перезапис наявних подробиць обраних книг після віднайдення",
"LabelUpdatedAt": "Оновлення", "LabelUpdatedAt": "Оновлення",
"LabelUploaderDragAndDrop": "Перетягніть файли або теки", "LabelUploaderDragAndDrop": "Перетягніть файли або теки",
"LabelUploaderDragAndDropFilesOnly": "Перетягніть і скиньте файли",
"LabelUploaderDropFiles": "Перетягніть файли", "LabelUploaderDropFiles": "Перетягніть файли",
"LabelUploaderItemFetchMetadataHelp": "Автоматично шукати назву, автора та серію", "LabelUploaderItemFetchMetadataHelp": "Автоматично шукати назву, автора та серію",
"LabelUseAdvancedOptions": "Використовувати розширені налаштування", "LabelUseAdvancedOptions": "Використовувати розширені налаштування",
@@ -678,6 +679,8 @@
"LabelViewPlayerSettings": "Переглянути налаштування програвача", "LabelViewPlayerSettings": "Переглянути налаштування програвача",
"LabelViewQueue": "Переглянути чергу відтворення", "LabelViewQueue": "Переглянути чергу відтворення",
"LabelVolume": "Гучність", "LabelVolume": "Гучність",
"LabelWebRedirectURLsDescription": "Авторизуйте ці URL у вашому OAuth постачальнику, щоб дозволити редирекцію назад до веб-додатку після входу:",
"LabelWebRedirectURLsSubfolder": "Підпапка для Redirect URL",
"LabelWeekdaysToRun": "Виконувати у дні", "LabelWeekdaysToRun": "Виконувати у дні",
"LabelXBooks": "{0} книг", "LabelXBooks": "{0} книг",
"LabelXItems": "{0} елементів", "LabelXItems": "{0} елементів",
+3
View File
@@ -663,6 +663,7 @@
"LabelUpdateDetailsHelp": "找到匹配项时允许覆盖所选书籍存在的详细信息", "LabelUpdateDetailsHelp": "找到匹配项时允许覆盖所选书籍存在的详细信息",
"LabelUpdatedAt": "更新时间", "LabelUpdatedAt": "更新时间",
"LabelUploaderDragAndDrop": "拖放文件或文件夹", "LabelUploaderDragAndDrop": "拖放文件或文件夹",
"LabelUploaderDragAndDropFilesOnly": "拖放文件",
"LabelUploaderDropFiles": "删除文件", "LabelUploaderDropFiles": "删除文件",
"LabelUploaderItemFetchMetadataHelp": "自动获取标题, 作者和系列", "LabelUploaderItemFetchMetadataHelp": "自动获取标题, 作者和系列",
"LabelUseAdvancedOptions": "使用高级选项", "LabelUseAdvancedOptions": "使用高级选项",
@@ -678,6 +679,8 @@
"LabelViewPlayerSettings": "查看播放器设置", "LabelViewPlayerSettings": "查看播放器设置",
"LabelViewQueue": "查看播放列表", "LabelViewQueue": "查看播放列表",
"LabelVolume": "音量", "LabelVolume": "音量",
"LabelWebRedirectURLsDescription": "在你的 OAuth 提供商中授权这些链接,以允许在登录后重定向回 Web 应用程序:",
"LabelWebRedirectURLsSubfolder": "重定向 URL 的子文件夹",
"LabelWeekdaysToRun": "工作日运行", "LabelWeekdaysToRun": "工作日运行",
"LabelXBooks": "{0} 本书", "LabelXBooks": "{0} 本书",
"LabelXItems": "{0} 项目", "LabelXItems": "{0} 项目",
+1
View File
@@ -11,6 +11,7 @@ if (isDev) {
if (devEnv.FFProbePath) process.env.FFPROBE_PATH = devEnv.FFProbePath if (devEnv.FFProbePath) process.env.FFPROBE_PATH = devEnv.FFProbePath
if (devEnv.NunicodePath) process.env.NUSQLITE3_PATH = devEnv.NunicodePath if (devEnv.NunicodePath) process.env.NUSQLITE3_PATH = devEnv.NunicodePath
if (devEnv.SkipBinariesCheck) process.env.SKIP_BINARIES_CHECK = '1' if (devEnv.SkipBinariesCheck) process.env.SKIP_BINARIES_CHECK = '1'
if (devEnv.AllowIframe) process.env.ALLOW_IFRAME = '1'
if (devEnv.BackupPath) process.env.BACKUP_PATH = devEnv.BackupPath if (devEnv.BackupPath) process.env.BACKUP_PATH = devEnv.BackupPath
process.env.SOURCE = 'local' process.env.SOURCE = 'local'
process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath || '' process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath || ''
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.17.2", "version": "2.17.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.17.2", "version": "2.17.5",
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"axios": "^0.27.2", "axios": "^0.27.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.17.2", "version": "2.17.5",
"buildNumber": 1, "buildNumber": 1,
"description": "Self-hosted audiobook and podcast server", "description": "Self-hosted audiobook and podcast server",
"main": "index.js", "main": "index.js",
+7
View File
@@ -41,6 +41,13 @@ Is there a feature you are looking for? [Suggest it](https://github.com/advplyr/
Join us on [Discord](https://discord.gg/HQgCbd6E75) Join us on [Discord](https://discord.gg/HQgCbd6E75)
### Demo
Check out the web client demo: https://audiobooks.dev/ (thanks for hosting [@Vito0912](https://github.com/Vito0912)!)
Username/password: `demo`/`demo` (user account)
### Android App (beta) ### Android App (beta)
Try it out on the [Google Play Store](https://play.google.com/store/apps/details?id=com.audiobookshelf.app) Try it out on the [Google Play Store](https://play.google.com/store/apps/details?id=com.audiobookshelf.app)
+4 -4
View File
@@ -131,7 +131,7 @@ class Auth {
{ {
client: openIdClient, client: openIdClient,
params: { params: {
redirect_uri: '/auth/openid/callback', redirect_uri: `${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`,
scope: 'openid profile email' scope: 'openid profile email'
} }
}, },
@@ -480,9 +480,9 @@ class Auth {
// for the request to mobile-redirect and as such the session is not shared // for the request to mobile-redirect and as such the session is not shared
this.openIdAuthSession.set(state, { mobile_redirect_uri: req.query.redirect_uri }) this.openIdAuthSession.set(state, { mobile_redirect_uri: req.query.redirect_uri })
redirectUri = new URL('/auth/openid/mobile-redirect', hostUrl).toString() redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/mobile-redirect`, hostUrl).toString()
} else { } else {
redirectUri = new URL('/auth/openid/callback', hostUrl).toString() redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`, hostUrl).toString()
if (req.query.state) { if (req.query.state) {
Logger.debug(`[Auth] Invalid state - not allowed on web openid flow`) Logger.debug(`[Auth] Invalid state - not allowed on web openid flow`)
@@ -733,7 +733,7 @@ class Auth {
const host = req.get('host') const host = req.get('host')
// TODO: ABS does currently not support subfolders for installation // TODO: ABS does currently not support subfolders for installation
// If we want to support it we need to include a config for the serverurl // If we want to support it we need to include a config for the serverurl
postLogoutRedirectUri = `${protocol}://${host}/login` postLogoutRedirectUri = `${protocol}://${host}${global.RouterBasePath}/login`
} }
// else for openid-mobile we keep postLogoutRedirectUri on null // else for openid-mobile we keep postLogoutRedirectUri on null
// nice would be to redirect to the app here, but for example Authentik does not implement // nice would be to redirect to the app here, but for example Authentik does not implement
-20
View File
@@ -406,11 +406,6 @@ class Database {
return Promise.all(oldBooks.map((oldBook) => this.models.book.saveFromOld(oldBook))) return Promise.all(oldBooks.map((oldBook) => this.models.book.saveFromOld(oldBook)))
} }
removeLibrary(libraryId) {
if (!this.sequelize) return false
return this.models.library.removeById(libraryId)
}
createBulkCollectionBooks(collectionBooks) { createBulkCollectionBooks(collectionBooks) {
if (!this.sequelize) return false if (!this.sequelize) return false
return this.models.collectionBook.bulkCreate(collectionBooks) return this.models.collectionBook.bulkCreate(collectionBooks)
@@ -449,21 +444,6 @@ class Database {
return updated return updated
} }
async createFeed(oldFeed) {
if (!this.sequelize) return false
await this.models.feed.fullCreateFromOld(oldFeed)
}
updateFeed(oldFeed) {
if (!this.sequelize) return false
return this.models.feed.fullUpdateFromOld(oldFeed)
}
async removeFeed(feedId) {
if (!this.sequelize) return false
await this.models.feed.removeById(feedId)
}
async createBulkBookAuthors(bookAuthors) { async createBulkBookAuthors(bookAuthors) {
if (!this.sequelize) return false if (!this.sequelize) return false
await this.models.bookAuthor.bulkCreate(bookAuthors) await this.models.bookAuthor.bulkCreate(bookAuthors)
+21 -22
View File
@@ -71,7 +71,6 @@ class Server {
this.playbackSessionManager = new PlaybackSessionManager() this.playbackSessionManager = new PlaybackSessionManager()
this.podcastManager = new PodcastManager() this.podcastManager = new PodcastManager()
this.audioMetadataManager = new AudioMetadataMangaer() this.audioMetadataManager = new AudioMetadataMangaer()
this.rssFeedManager = new RssFeedManager()
this.cronManager = new CronManager(this.podcastManager, this.playbackSessionManager) this.cronManager = new CronManager(this.podcastManager, this.playbackSessionManager)
this.apiCacheManager = new ApiCacheManager() this.apiCacheManager = new ApiCacheManager()
this.binaryManager = new BinaryManager() this.binaryManager = new BinaryManager()
@@ -84,7 +83,6 @@ class Server {
Logger.logManager = new LogManager() Logger.logManager = new LogManager()
this.server = null this.server = null
this.io = null
} }
/** /**
@@ -138,7 +136,7 @@ class Server {
await ShareManager.init() await ShareManager.init()
await this.backupManager.init() await this.backupManager.init()
await this.rssFeedManager.init() await RssFeedManager.init()
const libraries = await Database.libraryModel.getAllWithFolders() const libraries = await Database.libraryModel.getAllWithFolders()
await this.cronManager.init(libraries) await this.cronManager.init(libraries)
@@ -194,6 +192,12 @@ class Server {
const app = express() const app = express()
app.use((req, res, next) => {
if (!global.ServerSettings.allowIframe) {
// Prevent clickjacking by disallowing iframes
res.setHeader('Content-Security-Policy', "frame-ancestors 'self'")
}
/** /**
* @temporary * @temporary
* This is necessary for the ebook & cover API endpoint in the mobile apps * This is necessary for the ebook & cover API endpoint in the mobile apps
@@ -205,7 +209,6 @@ class Server {
* Running in development allows cors to allow testing the mobile apps in the browser * Running in development allows cors to allow testing the mobile apps in the browser
* or env variable ALLOW_CORS = '1' * or env variable ALLOW_CORS = '1'
*/ */
app.use((req, res, next) => {
if (Logger.isDev || req.path.match(/\/api\/items\/([a-z0-9-]{36})\/(ebook|cover)(\/[0-9]+)?/)) { if (Logger.isDev || req.path.match(/\/api\/items\/([a-z0-9-]{36})\/(ebook|cover)(\/[0-9]+)?/)) {
const allowedOrigins = ['capacitor://localhost', 'http://localhost'] const allowedOrigins = ['capacitor://localhost', 'http://localhost']
if (global.AllowCors || Logger.isDev || allowedOrigins.some((o) => o === req.get('origin'))) { if (global.AllowCors || Logger.isDev || allowedOrigins.some((o) => o === req.get('origin'))) {
@@ -246,14 +249,17 @@ class Server {
const router = express.Router() const router = express.Router()
// if RouterBasePath is set, modify all requests to include the base path // if RouterBasePath is set, modify all requests to include the base path
if (global.RouterBasePath) {
app.use((req, res, next) => { app.use((req, res, next) => {
if (!req.url.startsWith(global.RouterBasePath)) { const urlStartsWithRouterBasePath = req.url.startsWith(global.RouterBasePath)
const host = req.get('host')
const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
const prefix = urlStartsWithRouterBasePath ? global.RouterBasePath : ''
req.originalHostPrefix = `${protocol}://${host}${prefix}`
if (!urlStartsWithRouterBasePath) {
req.url = `${global.RouterBasePath}${req.url}` req.url = `${global.RouterBasePath}${req.url}`
} }
next() next()
}) })
}
app.use(global.RouterBasePath, router) app.use(global.RouterBasePath, router)
app.disable('x-powered-by') app.disable('x-powered-by')
@@ -284,14 +290,14 @@ class Server {
// RSS Feed temp route // RSS Feed temp route
router.get('/feed/:slug', (req, res) => { router.get('/feed/:slug', (req, res) => {
Logger.info(`[Server] Requesting rss feed ${req.params.slug}`) Logger.info(`[Server] Requesting rss feed ${req.params.slug}`)
this.rssFeedManager.getFeed(req, res) RssFeedManager.getFeed(req, res)
}) })
router.get('/feed/:slug/cover*', (req, res) => { router.get('/feed/:slug/cover*', (req, res) => {
this.rssFeedManager.getFeedCover(req, res) RssFeedManager.getFeedCover(req, res)
}) })
router.get('/feed/:slug/item/:episodeId/*', (req, res) => { router.get('/feed/:slug/item/:episodeId/*', (req, res) => {
Logger.debug(`[Server] Requesting rss feed episode ${req.params.slug}/${req.params.episodeId}`) Logger.debug(`[Server] Requesting rss feed episode ${req.params.slug}/${req.params.episodeId}`)
this.rssFeedManager.getFeedItem(req, res) RssFeedManager.getFeedItem(req, res)
}) })
// Auth routes // Auth routes
@@ -438,18 +444,11 @@ class Server {
async stop() { async stop() {
Logger.info('=== Stopping Server ===') Logger.info('=== Stopping Server ===')
Watcher.close() Watcher.close()
Logger.info('Watcher Closed') Logger.info('[Server] Watcher Closed')
await SocketAuthority.close()
return new Promise((resolve) => { Logger.info('[Server] Closing HTTP Server')
SocketAuthority.close((err) => { await new Promise((resolve) => this.server.close(resolve))
if (err) { Logger.info('[Server] HTTP Server Closed')
Logger.error('Failed to close server', err)
} else {
Logger.info('Server successfully closed')
}
resolve()
})
})
} }
} }
module.exports = Server module.exports = Server
+34 -12
View File
@@ -14,7 +14,7 @@ const Auth = require('./Auth')
class SocketAuthority { class SocketAuthority {
constructor() { constructor() {
this.Server = null this.Server = null
this.io = null this.socketIoServers = []
/** @type {Object.<string, SocketClient>} */ /** @type {Object.<string, SocketClient>} */
this.clients = {} this.clients = {}
@@ -89,25 +89,46 @@ class SocketAuthority {
* *
* @param {Function} callback * @param {Function} callback
*/ */
close(callback) { async close() {
Logger.info('[SocketAuthority] Shutting down') Logger.info('[SocketAuthority] closing...')
// This will close all open socket connections, and also close the underlying http server const closePromises = this.socketIoServers.map((io) => {
if (this.io) this.io.close(callback) return new Promise((resolve) => {
else callback() Logger.info(`[SocketAuthority] Closing Socket.IO server: ${io.path}`)
io.close(() => {
Logger.info(`[SocketAuthority] Socket.IO server closed: ${io.path}`)
resolve()
})
})
})
await Promise.all(closePromises)
Logger.info('[SocketAuthority] closed')
this.socketIoServers = []
} }
initialize(Server) { initialize(Server) {
this.Server = Server this.Server = Server
this.io = new SocketIO.Server(this.Server.server, { const socketIoOptions = {
cors: { cors: {
origin: '*', origin: '*',
methods: ['GET', 'POST'] methods: ['GET', 'POST']
}, }
path: `${global.RouterBasePath}/socket.io` }
})
this.io.on('connection', (socket) => { const ioServer = new SocketIO.Server(Server.server, socketIoOptions)
ioServer.path = '/socket.io'
this.socketIoServers.push(ioServer)
if (global.RouterBasePath) {
// open a separate socket.io server for the router base path, keeping the original server open for legacy clients
const ioBasePath = `${global.RouterBasePath}/socket.io`
const ioBasePathServer = new SocketIO.Server(Server.server, { ...socketIoOptions, path: ioBasePath })
ioBasePathServer.path = ioBasePath
this.socketIoServers.push(ioBasePathServer)
}
this.socketIoServers.forEach((io) => {
io.on('connection', (socket) => {
this.clients[socket.id] = { this.clients[socket.id] = {
id: socket.id, id: socket.id,
socket, socket,
@@ -115,7 +136,7 @@ class SocketAuthority {
} }
socket.sheepClient = this.clients[socket.id] socket.sheepClient = this.clients[socket.id]
Logger.info('[SocketAuthority] Socket Connected', socket.id) Logger.info(`[SocketAuthority] Socket Connected to ${io.path}`, socket.id)
// Required for associating a User with a socket // Required for associating a User with a socket
socket.on('auth', (token) => this.authenticateSocket(socket, token)) socket.on('auth', (token) => this.authenticateSocket(socket, token))
@@ -167,6 +188,7 @@ class SocketAuthority {
socket.emit('pong') socket.emit('pong')
}) })
}) })
})
} }
/** /**
+12 -2
View File
@@ -4,6 +4,7 @@ const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority') const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database') const Database = require('../Database')
const RssFeedManager = require('../managers/RssFeedManager')
const Collection = require('../objects/Collection') const Collection = require('../objects/Collection')
/** /**
@@ -115,6 +116,7 @@ class CollectionController {
} }
// If books array is passed in then update order in collection // If books array is passed in then update order in collection
let collectionBooksUpdated = false
if (req.body.books?.length) { if (req.body.books?.length) {
const collectionBooks = await req.collection.getCollectionBooks({ const collectionBooks = await req.collection.getCollectionBooks({
include: { include: {
@@ -133,9 +135,15 @@ class CollectionController {
await collectionBooks[i].update({ await collectionBooks[i].update({
order: i + 1 order: i + 1
}) })
wasUpdated = true collectionBooksUpdated = true
} }
} }
if (collectionBooksUpdated) {
req.collection.changed('updatedAt', true)
await req.collection.save()
wasUpdated = true
}
} }
const jsonExpanded = await req.collection.getOldJsonExpanded() const jsonExpanded = await req.collection.getOldJsonExpanded()
@@ -148,6 +156,8 @@ class CollectionController {
/** /**
* DELETE: /api/collections/:id * DELETE: /api/collections/:id
* *
* @this {import('../routers/ApiRouter')}
*
* @param {RequestWithUser} req * @param {RequestWithUser} req
* @param {Response} res * @param {Response} res
*/ */
@@ -155,7 +165,7 @@ class CollectionController {
const jsonExpanded = await req.collection.getOldJsonExpanded() const jsonExpanded = await req.collection.getOldJsonExpanded()
// Close rss feed - remove from db and emit socket event // Close rss feed - remove from db and emit socket event
await this.rssFeedManager.closeFeedForEntityId(req.collection.id) await RssFeedManager.closeFeedForEntityId(req.collection.id)
await req.collection.destroy() await req.collection.destroy()
+81 -6
View File
@@ -18,6 +18,8 @@ const LibraryScanner = require('../scanner/LibraryScanner')
const Scanner = require('../scanner/Scanner') const Scanner = require('../scanner/Scanner')
const Database = require('../Database') const Database = require('../Database')
const Watcher = require('../Watcher') const Watcher = require('../Watcher')
const RssFeedManager = require('../managers/RssFeedManager')
const libraryFilters = require('../utils/queries/libraryFilters') const libraryFilters = require('../utils/queries/libraryFilters')
const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters') const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters')
const authorFilters = require('../utils/queries/authorFilters') const authorFilters = require('../utils/queries/authorFilters')
@@ -400,19 +402,48 @@ class LibraryController {
model: Database.podcastEpisodeModel, model: Database.podcastEpisodeModel,
attributes: ['id'] attributes: ['id']
} }
},
{
model: Database.bookModel,
attributes: ['id'],
include: [
{
model: Database.bookAuthorModel,
attributes: ['authorId']
},
{
model: Database.bookSeriesModel,
attributes: ['seriesId']
}
]
} }
] ]
}) })
Logger.info(`[LibraryController] Removed folder "${folder.path}" from library "${req.library.name}" with ${libraryItemsInFolder.length} library items`) Logger.info(`[LibraryController] Removed folder "${folder.path}" from library "${req.library.name}" with ${libraryItemsInFolder.length} library items`)
const seriesIds = []
const authorIds = []
for (const libraryItem of libraryItemsInFolder) { for (const libraryItem of libraryItemsInFolder) {
let mediaItemIds = [] let mediaItemIds = []
if (req.library.isPodcast) { if (req.library.isPodcast) {
mediaItemIds = libraryItem.media.podcastEpisodes.map((pe) => pe.id) mediaItemIds = libraryItem.media.podcastEpisodes.map((pe) => pe.id)
} else { } else {
mediaItemIds.push(libraryItem.mediaId) mediaItemIds.push(libraryItem.mediaId)
if (libraryItem.media.bookAuthors.length) {
authorIds.push(...libraryItem.media.bookAuthors.map((ba) => ba.authorId))
}
if (libraryItem.media.bookSeries.length) {
seriesIds.push(...libraryItem.media.bookSeries.map((bs) => bs.seriesId))
}
} }
Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from folder "${folder.path}"`) Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from folder "${folder.path}"`)
await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds) await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
}
if (authorIds.length) {
await this.checkRemoveAuthorsWithNoBooks(authorIds)
}
if (seriesIds.length) {
await this.checkRemoveEmptySeries(seriesIds)
} }
// Remove folder // Remove folder
@@ -501,11 +532,24 @@ class LibraryController {
mediaItemIds.push(libraryItem.mediaId) mediaItemIds.push(libraryItem.mediaId)
} }
Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from library "${req.library.name}"`) Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from library "${req.library.name}"`)
await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds) await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
} }
// Set PlaybackSessions libraryId to null
const [sessionsUpdated] = await Database.playbackSessionModel.update(
{
libraryId: null
},
{
where: {
libraryId: req.library.id
}
}
)
Logger.info(`[LibraryController] Updated ${sessionsUpdated} playback sessions to remove library id`)
const libraryJson = req.library.toOldJSON() const libraryJson = req.library.toOldJSON()
await Database.removeLibrary(req.library.id) await req.library.destroy()
// Re-order libraries // Re-order libraries
await Database.libraryModel.resetDisplayOrder() await Database.libraryModel.resetDisplayOrder()
@@ -567,6 +611,8 @@ class LibraryController {
* DELETE: /api/libraries/:id/issues * DELETE: /api/libraries/:id/issues
* Remove all library items missing or invalid * Remove all library items missing or invalid
* *
* @this {import('../routers/ApiRouter')}
*
* @param {LibraryControllerRequest} req * @param {LibraryControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
@@ -592,6 +638,20 @@ class LibraryController {
model: Database.podcastEpisodeModel, model: Database.podcastEpisodeModel,
attributes: ['id'] attributes: ['id']
} }
},
{
model: Database.bookModel,
attributes: ['id'],
include: [
{
model: Database.bookAuthorModel,
attributes: ['authorId']
},
{
model: Database.bookSeriesModel,
attributes: ['seriesId']
}
]
} }
] ]
}) })
@@ -602,15 +662,30 @@ class LibraryController {
} }
Logger.info(`[LibraryController] Removing ${libraryItemsWithIssues.length} items with issues`) Logger.info(`[LibraryController] Removing ${libraryItemsWithIssues.length} items with issues`)
const authorIds = []
const seriesIds = []
for (const libraryItem of libraryItemsWithIssues) { for (const libraryItem of libraryItemsWithIssues) {
let mediaItemIds = [] let mediaItemIds = []
if (req.library.isPodcast) { if (req.library.isPodcast) {
mediaItemIds = libraryItem.media.podcastEpisodes.map((pe) => pe.id) mediaItemIds = libraryItem.media.podcastEpisodes.map((pe) => pe.id)
} else { } else {
mediaItemIds.push(libraryItem.mediaId) mediaItemIds.push(libraryItem.mediaId)
if (libraryItem.media.bookAuthors.length) {
authorIds.push(...libraryItem.media.bookAuthors.map((ba) => ba.authorId))
}
if (libraryItem.media.bookSeries.length) {
seriesIds.push(...libraryItem.media.bookSeries.map((bs) => bs.seriesId))
}
} }
Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" with issue`) Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" with issue`)
await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds) await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
}
if (authorIds.length) {
await this.checkRemoveAuthorsWithNoBooks(authorIds)
}
if (seriesIds.length) {
await this.checkRemoveEmptySeries(seriesIds)
} }
// Set numIssues to 0 for library filter data // Set numIssues to 0 for library filter data
@@ -686,8 +761,8 @@ class LibraryController {
} }
if (include.includes('rssfeed')) { if (include.includes('rssfeed')) {
const feedObj = await this.rssFeedManager.findFeedForEntityId(seriesJson.id) const feedObj = await RssFeedManager.findFeedForEntityId(seriesJson.id)
seriesJson.rssFeed = feedObj?.toJSONMinified() || null seriesJson.rssFeed = feedObj?.toOldJSONMinified() || null
} }
res.json(seriesJson) res.json(seriesJson)
+102 -36
View File
@@ -13,6 +13,8 @@ const { getAudioMimeTypeFromExtname, encodeUriPath } = require('../utils/fileUti
const LibraryItemScanner = require('../scanner/LibraryItemScanner') const LibraryItemScanner = require('../scanner/LibraryItemScanner')
const AudioFileScanner = require('../scanner/AudioFileScanner') const AudioFileScanner = require('../scanner/AudioFileScanner')
const Scanner = require('../scanner/Scanner') const Scanner = require('../scanner/Scanner')
const RssFeedManager = require('../managers/RssFeedManager')
const CacheManager = require('../managers/CacheManager') const CacheManager = require('../managers/CacheManager')
const CoverManager = require('../managers/CoverManager') const CoverManager = require('../managers/CoverManager')
const ShareManager = require('../managers/ShareManager') const ShareManager = require('../managers/ShareManager')
@@ -48,8 +50,8 @@ class LibraryItemController {
} }
if (includeEntities.includes('rssfeed')) { if (includeEntities.includes('rssfeed')) {
const feedData = await this.rssFeedManager.findFeedForEntityId(item.id) const feedData = await RssFeedManager.findFeedForEntityId(item.id)
item.rssFeed = feedData?.toJSONMinified() || null item.rssFeed = feedData?.toOldJSONMinified() || null
} }
if (item.mediaType === 'book' && req.user.isAdminOrUp && includeEntities.includes('share')) { if (item.mediaType === 'book' && req.user.isAdminOrUp && includeEntities.includes('share')) {
@@ -96,6 +98,8 @@ class LibraryItemController {
* Optional query params: * Optional query params:
* ?hard=1 * ?hard=1
* *
* @this {import('../routers/ApiRouter')}
*
* @param {RequestWithUser} req * @param {RequestWithUser} req
* @param {Response} res * @param {Response} res
*/ */
@@ -103,14 +107,36 @@ class LibraryItemController {
const hardDelete = req.query.hard == 1 // Delete from file system const hardDelete = req.query.hard == 1 // Delete from file system
const libraryItemPath = req.libraryItem.path const libraryItemPath = req.libraryItem.path
const mediaItemIds = req.libraryItem.mediaType === 'podcast' ? req.libraryItem.media.episodes.map((ep) => ep.id) : [req.libraryItem.media.id] const mediaItemIds = []
await this.handleDeleteLibraryItem(req.libraryItem.mediaType, req.libraryItem.id, mediaItemIds) const authorIds = []
const seriesIds = []
if (req.libraryItem.isPodcast) {
mediaItemIds.push(...req.libraryItem.media.episodes.map((ep) => ep.id))
} else {
mediaItemIds.push(req.libraryItem.media.id)
if (req.libraryItem.media.metadata.authors?.length) {
authorIds.push(...req.libraryItem.media.metadata.authors.map((au) => au.id))
}
if (req.libraryItem.media.metadata.series?.length) {
seriesIds.push(...req.libraryItem.media.metadata.series.map((se) => se.id))
}
}
await this.handleDeleteLibraryItem(req.libraryItem.id, mediaItemIds)
if (hardDelete) { if (hardDelete) {
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`) Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
await fs.remove(libraryItemPath).catch((error) => { await fs.remove(libraryItemPath).catch((error) => {
Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error) Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error)
}) })
} }
if (authorIds.length) {
await this.checkRemoveAuthorsWithNoBooks(authorIds)
}
if (seriesIds.length) {
await this.checkRemoveEmptySeries(seriesIds)
}
await Database.resetLibraryIssuesFilterData(req.libraryItem.libraryId) await Database.resetLibraryIssuesFilterData(req.libraryItem.libraryId)
res.sendStatus(200) res.sendStatus(200)
} }
@@ -212,15 +238,6 @@ class LibraryItemController {
if (hasUpdates) { if (hasUpdates) {
libraryItem.updatedAt = Date.now() libraryItem.updatedAt = Date.now()
if (seriesRemoved.length) {
// Check remove empty series
Logger.debug(`[LibraryItemController] Series was removed from book. Check if series is now empty.`)
await this.checkRemoveEmptySeries(
libraryItem.media.id,
seriesRemoved.map((se) => se.id)
)
}
if (isPodcastAutoDownloadUpdated) { if (isPodcastAutoDownloadUpdated) {
this.cronManager.checkUpdatePodcastCron(libraryItem) this.cronManager.checkUpdatePodcastCron(libraryItem)
} }
@@ -232,10 +249,12 @@ class LibraryItemController {
if (authorsRemoved.length) { if (authorsRemoved.length) {
// Check remove empty authors // Check remove empty authors
Logger.debug(`[LibraryItemController] Authors were removed from book. Check if authors are now empty.`) Logger.debug(`[LibraryItemController] Authors were removed from book. Check if authors are now empty.`)
await this.checkRemoveAuthorsWithNoBooks( await this.checkRemoveAuthorsWithNoBooks(authorsRemoved.map((au) => au.id))
libraryItem.libraryId, }
authorsRemoved.map((au) => au.id) if (seriesRemoved.length) {
) // Check remove empty series
Logger.debug(`[LibraryItemController] Series were removed from book. Check if series are now empty.`)
await this.checkRemoveEmptySeries(seriesRemoved.map((se) => se.id))
} }
} }
res.json({ res.json({
@@ -450,6 +469,8 @@ class LibraryItemController {
* Optional query params: * Optional query params:
* ?hard=1 * ?hard=1
* *
* @this {import('../routers/ApiRouter')}
*
* @param {RequestWithUser} req * @param {RequestWithUser} req
* @param {Response} res * @param {Response} res
*/ */
@@ -477,14 +498,33 @@ class LibraryItemController {
for (const libraryItem of itemsToDelete) { for (const libraryItem of itemsToDelete) {
const libraryItemPath = libraryItem.path const libraryItemPath = libraryItem.path
Logger.info(`[LibraryItemController] (${hardDelete ? 'Hard' : 'Soft'}) deleting Library Item "${libraryItem.media.metadata.title}" with id "${libraryItem.id}"`) Logger.info(`[LibraryItemController] (${hardDelete ? 'Hard' : 'Soft'}) deleting Library Item "${libraryItem.media.metadata.title}" with id "${libraryItem.id}"`)
const mediaItemIds = libraryItem.mediaType === 'podcast' ? libraryItem.media.episodes.map((ep) => ep.id) : [libraryItem.media.id] const mediaItemIds = []
await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds) const seriesIds = []
const authorIds = []
if (libraryItem.isPodcast) {
mediaItemIds.push(...libraryItem.media.episodes.map((ep) => ep.id))
} else {
mediaItemIds.push(libraryItem.media.id)
if (libraryItem.media.metadata.series?.length) {
seriesIds.push(...libraryItem.media.metadata.series.map((se) => se.id))
}
if (libraryItem.media.metadata.authors?.length) {
authorIds.push(...libraryItem.media.metadata.authors.map((au) => au.id))
}
}
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
if (hardDelete) { if (hardDelete) {
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`) Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
await fs.remove(libraryItemPath).catch((error) => { await fs.remove(libraryItemPath).catch((error) => {
Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error) Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error)
}) })
} }
if (seriesIds.length) {
await this.checkRemoveEmptySeries(seriesIds)
}
if (authorIds.length) {
await this.checkRemoveAuthorsWithNoBooks(authorIds)
}
} }
await Database.resetLibraryIssuesFilterData(libraryId) await Database.resetLibraryIssuesFilterData(libraryId)
@@ -494,48 +534,74 @@ class LibraryItemController {
/** /**
* POST: /api/items/batch/update * POST: /api/items/batch/update
* *
* @this {import('../routers/ApiRouter')}
*
* @param {RequestWithUser} req * @param {RequestWithUser} req
* @param {Response} res * @param {Response} res
*/ */
async batchUpdate(req, res) { async batchUpdate(req, res) {
const updatePayloads = req.body const updatePayloads = req.body
if (!updatePayloads?.length) { if (!Array.isArray(updatePayloads) || !updatePayloads.length) {
return res.sendStatus(500) Logger.error(`[LibraryItemController] Batch update failed. Invalid payload`)
return res.sendStatus(400)
}
// Ensure that each update payload has a unique library item id
const libraryItemIds = [...new Set(updatePayloads.map((up) => up?.id).filter((id) => id))]
if (!libraryItemIds.length || libraryItemIds.length !== updatePayloads.length) {
Logger.error(`[LibraryItemController] Batch update failed. Each update payload must have a unique library item id`)
return res.sendStatus(400)
}
// Get all library items to update
const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({
id: libraryItemIds
})
if (updatePayloads.length !== libraryItems.length) {
Logger.error(`[LibraryItemController] Batch update failed. Not all library items found`)
return res.sendStatus(404)
} }
let itemsUpdated = 0 let itemsUpdated = 0
const seriesIdsRemoved = []
const authorIdsRemoved = []
for (const updatePayload of updatePayloads) { for (const updatePayload of updatePayloads) {
const mediaPayload = updatePayload.mediaPayload const mediaPayload = updatePayload.mediaPayload
const libraryItem = await Database.libraryItemModel.getOldById(updatePayload.id) const libraryItem = libraryItems.find((li) => li.id === updatePayload.id)
if (!libraryItem) return null
await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryItem.libraryId) await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryItem.libraryId)
let seriesRemoved = [] if (libraryItem.isBook) {
if (libraryItem.isBook && mediaPayload.metadata?.series) { if (Array.isArray(mediaPayload.metadata?.series)) {
const seriesIdsInUpdate = (mediaPayload.metadata?.series || []).map((se) => se.id) const seriesIdsInUpdate = mediaPayload.metadata.series.map((se) => se.id)
seriesRemoved = libraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id)) const seriesRemoved = libraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id))
seriesIdsRemoved.push(...seriesRemoved.map((se) => se.id))
}
if (Array.isArray(mediaPayload.metadata?.authors)) {
const authorIdsInUpdate = mediaPayload.metadata.authors.map((au) => au.id)
const authorsRemoved = libraryItem.media.metadata.authors.filter((au) => !authorIdsInUpdate.includes(au.id))
authorIdsRemoved.push(...authorsRemoved.map((au) => au.id))
}
} }
if (libraryItem.media.update(mediaPayload)) { if (libraryItem.media.update(mediaPayload)) {
Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`) Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`)
if (seriesRemoved.length) {
// Check remove empty series
Logger.debug(`[LibraryItemController] Series was removed from book. Check if series is now empty.`)
await this.checkRemoveEmptySeries(
libraryItem.media.id,
seriesRemoved.map((se) => se.id)
)
}
await Database.updateLibraryItem(libraryItem) await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
itemsUpdated++ itemsUpdated++
} }
} }
if (seriesIdsRemoved.length) {
await this.checkRemoveEmptySeries(seriesIdsRemoved)
}
if (authorIdsRemoved.length) {
await this.checkRemoveAuthorsWithNoBooks(authorIdsRemoved)
}
res.json({ res.json({
success: true, success: true,
updates: itemsUpdated updates: itemsUpdated
+6 -3
View File
@@ -126,6 +126,10 @@ class MiscController {
if (!isObject(settingsUpdate)) { if (!isObject(settingsUpdate)) {
return res.status(400).send('Invalid settings update object') return res.status(400).send('Invalid settings update object')
} }
if (settingsUpdate.allowIframe == false && process.env.ALLOW_IFRAME === '1') {
Logger.warn('Cannot disable iframe when ALLOW_IFRAME is enabled in environment')
return res.status(400).send('Cannot disable iframe when ALLOW_IFRAME is enabled in environment')
}
const madeUpdates = Database.serverSettings.update(settingsUpdate) const madeUpdates = Database.serverSettings.update(settingsUpdate)
if (madeUpdates) { if (madeUpdates) {
@@ -137,7 +141,6 @@ class MiscController {
} }
} }
return res.json({ return res.json({
success: true,
serverSettings: Database.serverSettings.toJSONForBrowser() serverSettings: Database.serverSettings.toJSONForBrowser()
}) })
} }
@@ -679,9 +682,9 @@ class MiscController {
continue continue
} }
let updatedValue = settingsUpdate[key] let updatedValue = settingsUpdate[key]
if (updatedValue === '') updatedValue = null if (updatedValue === '' && key != 'authOpenIDSubfolderForRedirectURLs') updatedValue = null
let currentValue = currentAuthenticationSettings[key] let currentValue = currentAuthenticationSettings[key]
if (currentValue === '') currentValue = null if (currentValue === '' && key != 'authOpenIDSubfolderForRedirectURLs') currentValue = null
if (updatedValue !== currentValue) { if (updatedValue !== currentValue) {
Logger.debug(`[MiscController] Updating auth settings key "${key}" from "${currentValue}" to "${updatedValue}"`) Logger.debug(`[MiscController] Updating auth settings key "${key}" from "${currentValue}" to "${updatedValue}"`)
+61 -45
View File
@@ -1,7 +1,8 @@
const { Request, Response, NextFunction } = require('express') const { Request, Response, NextFunction } = require('express')
const Logger = require('../Logger') const Logger = require('../Logger')
const Database = require('../Database') const Database = require('../Database')
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
const RssFeedManager = require('../managers/RssFeedManager')
/** /**
* @typedef RequestUserObject * @typedef RequestUserObject
@@ -22,10 +23,10 @@ class RSSFeedController {
* @param {Response} res * @param {Response} res
*/ */
async getAll(req, res) { async getAll(req, res) {
const feeds = await this.rssFeedManager.getFeeds() const feeds = await RssFeedManager.getFeeds()
res.json({ res.json({
feeds: feeds.map((f) => f.toJSON()), feeds: feeds.map((f) => f.toOldJSON()),
minified: feeds.map((f) => f.toJSONMinified()) minified: feeds.map((f) => f.toOldJSONMinified())
}) })
} }
@@ -38,38 +39,43 @@ class RSSFeedController {
* @param {Response} res * @param {Response} res
*/ */
async openRSSFeedForItem(req, res) { async openRSSFeedForItem(req, res) {
const options = req.body || {} const reqBody = req.body || {}
const item = await Database.libraryItemModel.getOldById(req.params.itemId) const itemExpanded = await Database.libraryItemModel.getExpandedById(req.params.itemId)
if (!item) return res.sendStatus(404) if (!itemExpanded) return res.sendStatus(404)
// Check user can access this library item // Check user can access this library item
if (!req.user.checkCanAccessLibraryItem(item)) { if (!req.user.checkCanAccessLibraryItem(itemExpanded)) {
Logger.error(`[RSSFeedController] User "${req.user.username}" attempted to open an RSS feed for item "${item.media.metadata.title}" that they don\'t have access to`) Logger.error(`[RSSFeedController] User "${req.user.username}" attempted to open an RSS feed for item "${itemExpanded.media.title}" that they don\'t have access to`)
return res.sendStatus(403) return res.sendStatus(403)
} }
// Check request body options exist // Check request body options exist
if (!options.serverAddress || !options.slug) { if (!reqBody.serverAddress || !reqBody.slug || typeof reqBody.serverAddress !== 'string' || typeof reqBody.slug !== 'string') {
Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`) Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`)
return res.status(400).send('Invalid request body') return res.status(400).send('Invalid request body')
} }
// Check item has audio tracks // Check item has audio tracks
if (!item.media.numTracks) { if (!itemExpanded.hasAudioTracks()) {
Logger.error(`[RSSFeedController] Cannot open RSS feed for item "${item.media.metadata.title}" because it has no audio tracks`) Logger.error(`[RSSFeedController] Cannot open RSS feed for item "${itemExpanded.media.title}" because it has no audio tracks`)
return res.status(400).send('Item has no audio tracks') return res.status(400).send('Item has no audio tracks')
} }
// Check that this slug is not being used for another feed (slug will also be the Feed id) // Check that this slug is not being used for another feed (slug will also be the Feed id)
if (await this.rssFeedManager.findFeedBySlug(options.slug)) { if (await RssFeedManager.checkExistsBySlug(reqBody.slug)) {
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`) Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${reqBody.slug}" is already in use`)
return res.status(400).send('Slug already in use') return res.status(400).send('Slug already in use')
} }
const feed = await this.rssFeedManager.openFeedForItem(req.user.id, item, req.body) const feed = await RssFeedManager.openFeedForItem(req.user.id, itemExpanded, reqBody)
if (!feed) {
Logger.error(`[RSSFeedController] Failed to open RSS feed for item "${itemExpanded.media.title}"`)
return res.status(500).send('Failed to open RSS feed')
}
res.json({ res.json({
feed: feed.toJSONMinified() feed: feed.toOldJSONMinified()
}) })
} }
@@ -82,35 +88,37 @@ class RSSFeedController {
* @param {Response} res * @param {Response} res
*/ */
async openRSSFeedForCollection(req, res) { async openRSSFeedForCollection(req, res) {
const options = req.body || {} const reqBody = req.body || {}
const collection = await Database.collectionModel.findByPk(req.params.collectionId)
if (!collection) return res.sendStatus(404)
// Check request body options exist // Check request body options exist
if (!options.serverAddress || !options.slug) { if (!reqBody.serverAddress || !reqBody.slug || typeof reqBody.serverAddress !== 'string' || typeof reqBody.slug !== 'string') {
Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`) Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`)
return res.status(400).send('Invalid request body') return res.status(400).send('Invalid request body')
} }
// Check that this slug is not being used for another feed (slug will also be the Feed id) // Check that this slug is not being used for another feed (slug will also be the Feed id)
if (await this.rssFeedManager.findFeedBySlug(options.slug)) { if (await RssFeedManager.checkExistsBySlug(reqBody.slug)) {
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`) Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${reqBody.slug}" is already in use`)
return res.status(400).send('Slug already in use') return res.status(400).send('Slug already in use')
} }
const collectionExpanded = await collection.getOldJsonExpanded() const collection = await Database.collectionModel.getExpandedById(req.params.collectionId)
const collectionItemsWithTracks = collectionExpanded.books.filter((li) => li.media.tracks.length) if (!collection) return res.sendStatus(404)
// Check collection has audio tracks // Check collection has audio tracks
if (!collectionItemsWithTracks.length) { if (!collection.books.some((book) => book.includedAudioFiles.length)) {
Logger.error(`[RSSFeedController] Cannot open RSS feed for collection "${collection.name}" because it has no audio tracks`) Logger.error(`[RSSFeedController] Cannot open RSS feed for collection "${collection.name}" because it has no audio tracks`)
return res.status(400).send('Collection has no audio tracks') return res.status(400).send('Collection has no audio tracks')
} }
const feed = await this.rssFeedManager.openFeedForCollection(req.user.id, collectionExpanded, req.body) const feed = await RssFeedManager.openFeedForCollection(req.user.id, collection, reqBody)
if (!feed) {
Logger.error(`[RSSFeedController] Failed to open RSS feed for collection "${collection.name}"`)
return res.status(500).send('Failed to open RSS feed')
}
res.json({ res.json({
feed: feed.toJSONMinified() feed: feed.toOldJSONMinified()
}) })
} }
@@ -123,37 +131,37 @@ class RSSFeedController {
* @param {Response} res * @param {Response} res
*/ */
async openRSSFeedForSeries(req, res) { async openRSSFeedForSeries(req, res) {
const options = req.body || {} const reqBody = req.body || {}
const series = await Database.seriesModel.findByPk(req.params.seriesId)
if (!series) return res.sendStatus(404)
// Check request body options exist // Check request body options exist
if (!options.serverAddress || !options.slug) { if (!reqBody.serverAddress || !reqBody.slug || typeof reqBody.serverAddress !== 'string' || typeof reqBody.slug !== 'string') {
Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`) Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`)
return res.status(400).send('Invalid request body') return res.status(400).send('Invalid request body')
} }
// Check that this slug is not being used for another feed (slug will also be the Feed id) // Check that this slug is not being used for another feed (slug will also be the Feed id)
if (await this.rssFeedManager.findFeedBySlug(options.slug)) { if (await RssFeedManager.checkExistsBySlug(reqBody.slug)) {
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`) Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${reqBody.slug}" is already in use`)
return res.status(400).send('Slug already in use') return res.status(400).send('Slug already in use')
} }
const seriesJson = series.toOldJSON() const series = await Database.seriesModel.getExpandedById(req.params.seriesId)
if (!series) return res.sendStatus(404)
// Get books in series that have audio tracks
seriesJson.books = (await libraryItemsBookFilters.getLibraryItemsForSeries(series)).filter((li) => li.media.numTracks)
// Check series has audio tracks // Check series has audio tracks
if (!seriesJson.books.length) { if (!series.books.some((book) => book.includedAudioFiles.length)) {
Logger.error(`[RSSFeedController] Cannot open RSS feed for series "${seriesJson.name}" because it has no audio tracks`) Logger.error(`[RSSFeedController] Cannot open RSS feed for series "${series.name}" because it has no audio tracks`)
return res.status(400).send('Series has no audio tracks') return res.status(400).send('Series has no audio tracks')
} }
const feed = await this.rssFeedManager.openFeedForSeries(req.user.id, seriesJson, req.body) const feed = await RssFeedManager.openFeedForSeries(req.user.id, series, req.body)
if (!feed) {
Logger.error(`[RSSFeedController] Failed to open RSS feed for series "${series.name}"`)
return res.status(500).send('Failed to open RSS feed')
}
res.json({ res.json({
feed: feed.toJSONMinified() feed: feed.toOldJSONMinified()
}) })
} }
@@ -165,8 +173,16 @@ class RSSFeedController {
* @param {RequestWithUser} req * @param {RequestWithUser} req
* @param {Response} res * @param {Response} res
*/ */
closeRSSFeed(req, res) { async closeRSSFeed(req, res) {
this.rssFeedManager.closeRssFeed(req, res) const feed = await Database.feedModel.findByPk(req.params.id)
if (!feed) {
Logger.error(`[RSSFeedController] Cannot close RSS feed because feed "${req.params.id}" does not exist`)
return res.sendStatus(404)
}
await RssFeedManager.handleCloseFeed(feed)
res.sendStatus(200)
} }
/** /**
+5 -2
View File
@@ -2,6 +2,9 @@ const { Request, Response, NextFunction } = require('express')
const Logger = require('../Logger') const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority') const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database') const Database = require('../Database')
const RssFeedManager = require('../managers/RssFeedManager')
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters') const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
/** /**
@@ -51,8 +54,8 @@ class SeriesController {
} }
if (include.includes('rssfeed')) { if (include.includes('rssfeed')) {
const feedObj = await this.rssFeedManager.findFeedForEntityId(seriesJson.id) const feedObj = await RssFeedManager.findFeedForEntityId(seriesJson.id)
seriesJson.rssFeed = feedObj?.toJSONMinified() || null seriesJson.rssFeed = feedObj?.toOldJSONMinified() || null
} }
res.json(seriesJson) res.json(seriesJson)
+13
View File
@@ -368,6 +368,19 @@ class UserController {
await playlist.destroy() await playlist.destroy()
} }
// Set PlaybackSessions userId to null
const [sessionsUpdated] = await Database.playbackSessionModel.update(
{
userId: null
},
{
where: {
userId: user.id
}
}
)
Logger.info(`[UserController] Updated ${sessionsUpdated} playback sessions to remove user id`)
const userJson = user.toOldJSONForBrowser() const userJson = user.toOldJSONForBrowser()
await user.destroy() await user.destroy()
SocketAuthority.adminEmitter('user_removed', userJson) SocketAuthority.adminEmitter('user_removed', userJson)
+1
View File
@@ -86,6 +86,7 @@ class CacheManager {
} }
async purgeEntityCache(entityId, cachePath) { async purgeEntityCache(entityId, cachePath) {
if (!entityId || !cachePath) return []
return Promise.all( return Promise.all(
(await fs.readdir(cachePath)).reduce((promises, file) => { (await fs.readdir(cachePath)).reduce((promises, file) => {
if (file.startsWith(entityId)) { if (file.startsWith(entityId)) {
+19 -9
View File
@@ -12,7 +12,7 @@ const parseEbookMetadata = require('../utils/parsers/parseEbookMetadata')
const CacheManager = require('../managers/CacheManager') const CacheManager = require('../managers/CacheManager')
class CoverManager { class CoverManager {
constructor() { } constructor() {}
getCoverDirectory(libraryItem) { getCoverDirectory(libraryItem) {
if (global.ServerSettings.storeCoverWithItem && !libraryItem.isFile) { if (global.ServerSettings.storeCoverWithItem && !libraryItem.isFile) {
@@ -93,8 +93,11 @@ class CoverManager {
const coverFullPath = Path.posix.join(coverDirPath, `cover${extname}`) const coverFullPath = Path.posix.join(coverDirPath, `cover${extname}`)
// Move cover from temp upload dir to destination // Move cover from temp upload dir to destination
const success = await coverFile.mv(coverFullPath).then(() => true).catch((error) => { const success = await coverFile
Logger.error('[CoverManager] Failed to move cover file', path, error) .mv(coverFullPath)
.then(() => true)
.catch((error) => {
Logger.error('[CoverManager] Failed to move cover file', coverFullPath, error)
return false return false
}) })
@@ -124,7 +127,9 @@ class CoverManager {
var temppath = Path.posix.join(coverDirPath, 'cover') var temppath = Path.posix.join(coverDirPath, 'cover')
let errorMsg = '' let errorMsg = ''
let success = await downloadImageFile(url, temppath).then(() => true).catch((err) => { let success = await downloadImageFile(url, temppath)
.then(() => true)
.catch((err) => {
errorMsg = err.message || 'Unknown error' errorMsg = err.message || 'Unknown error'
Logger.error(`[CoverManager] Download image file failed for "${url}"`, errorMsg) Logger.error(`[CoverManager] Download image file failed for "${url}"`, errorMsg)
return false return false
@@ -180,7 +185,7 @@ class CoverManager {
} }
// Cover path does not exist // Cover path does not exist
if (!await fs.pathExists(coverPath)) { if (!(await fs.pathExists(coverPath))) {
Logger.error(`[CoverManager] validate cover path does not exist "${coverPath}"`) Logger.error(`[CoverManager] validate cover path does not exist "${coverPath}"`)
return { return {
error: 'Cover path does not exist' error: 'Cover path does not exist'
@@ -188,7 +193,7 @@ class CoverManager {
} }
// Cover path is not a file // Cover path is not a file
if (!await checkPathIsFile(coverPath)) { if (!(await checkPathIsFile(coverPath))) {
Logger.error(`[CoverManager] validate cover path is not a file "${coverPath}"`) Logger.error(`[CoverManager] validate cover path is not a file "${coverPath}"`)
return { return {
error: 'Cover path is not a file' error: 'Cover path is not a file'
@@ -211,7 +216,10 @@ class CoverManager {
var newCoverPath = Path.posix.join(coverDirPath, coverFilename) var newCoverPath = Path.posix.join(coverDirPath, coverFilename)
Logger.debug(`[CoverManager] validate cover path copy cover from "${coverPath}" to "${newCoverPath}"`) Logger.debug(`[CoverManager] validate cover path copy cover from "${coverPath}" to "${newCoverPath}"`)
var copySuccess = await fs.copy(coverPath, newCoverPath, { overwrite: true }).then(() => true).catch((error) => { var copySuccess = await fs
.copy(coverPath, newCoverPath, { overwrite: true })
.then(() => true)
.catch((error) => {
Logger.error(`[CoverManager] validate cover path failed to copy cover`, error) Logger.error(`[CoverManager] validate cover path failed to copy cover`, error)
return false return false
}) })
@@ -243,7 +251,7 @@ class CoverManager {
* @returns {Promise<string>} returns cover path * @returns {Promise<string>} returns cover path
*/ */
async saveEmbeddedCoverArt(audioFiles, libraryItemId, libraryItemPath) { async saveEmbeddedCoverArt(audioFiles, libraryItemId, libraryItemPath) {
let audioFileWithCover = audioFiles.find(af => af.embeddedCoverArt) let audioFileWithCover = audioFiles.find((af) => af.embeddedCoverArt)
if (!audioFileWithCover) return null if (!audioFileWithCover) return null
let coverDirPath = null let coverDirPath = null
@@ -328,7 +336,9 @@ class CoverManager {
await fs.ensureDir(coverDirPath) await fs.ensureDir(coverDirPath)
const temppath = Path.posix.join(coverDirPath, 'cover') const temppath = Path.posix.join(coverDirPath, 'cover')
const success = await downloadImageFile(url, temppath).then(() => true).catch((err) => { const success = await downloadImageFile(url, temppath)
.then(() => true)
.catch((err) => {
Logger.error(`[CoverManager] Download image file failed for "${url}"`, err) Logger.error(`[CoverManager] Download image file failed for "${url}"`, err)
return false return false
}) })
+8 -1
View File
@@ -25,7 +25,9 @@ const LibraryItem = require('../objects/LibraryItem')
class PodcastManager { class PodcastManager {
constructor() { constructor() {
/** @type {PodcastEpisodeDownload[]} */
this.downloadQueue = [] this.downloadQueue = []
/** @type {PodcastEpisodeDownload} */
this.currentDownload = null this.currentDownload = null
this.failedCheckMap = {} this.failedCheckMap = {}
@@ -63,6 +65,11 @@ class PodcastManager {
} }
} }
/**
*
* @param {PodcastEpisodeDownload} podcastEpisodeDownload
* @returns
*/
async startPodcastEpisodeDownload(podcastEpisodeDownload) { async startPodcastEpisodeDownload(podcastEpisodeDownload) {
if (this.currentDownload) { if (this.currentDownload) {
this.downloadQueue.push(podcastEpisodeDownload) this.downloadQueue.push(podcastEpisodeDownload)
@@ -106,7 +113,7 @@ class PodcastManager {
} }
let success = false let success = false
if (this.currentDownload.urlFileExtension === 'mp3') { if (this.currentDownload.isMp3) {
// Download episode and tag it // Download episode and tag it
success = await ffmpegHelpers.downloadPodcastEpisode(this.currentDownload).catch((error) => { success = await ffmpegHelpers.downloadPodcastEpisode(this.currentDownload).catch((error) => {
Logger.error(`[PodcastManager] Podcast Episode download failed`, error) Logger.error(`[PodcastManager] Podcast Episode download failed`, error)
+257 -182
View File
@@ -1,3 +1,4 @@
const { Request, Response } = require('express')
const Path = require('path') const Path = require('path')
const Logger = require('../Logger') const Logger = require('../Logger')
@@ -5,170 +6,190 @@ const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database') const Database = require('../Database')
const fs = require('../libs/fsExtra') const fs = require('../libs/fsExtra')
const Feed = require('../objects/Feed')
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
class RssFeedManager { class RssFeedManager {
constructor() {} constructor() {}
async validateFeedEntity(feedObj) {
if (feedObj.entityType === 'collection') {
const collection = await Database.collectionModel.getOldById(feedObj.entityId)
if (!collection) {
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Collection "${feedObj.entityId}" not found`)
return false
}
} else if (feedObj.entityType === 'libraryItem') {
const libraryItemExists = await Database.libraryItemModel.checkExistsById(feedObj.entityId)
if (!libraryItemExists) {
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Library item "${feedObj.entityId}" not found`)
return false
}
} else if (feedObj.entityType === 'series') {
const series = await Database.seriesModel.findByPk(feedObj.entityId)
if (!series) {
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Series "${feedObj.entityId}" not found`)
return false
}
} else {
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Invalid entityType "${feedObj.entityType}"`)
return false
}
return true
}
/** /**
* Validate all feeds and remove invalid * Remove invalid feeds (invalid if the entity does not exist)
*/ */
async init() { async init() {
const feeds = await Database.feedModel.getOldFeeds() const feeds = await Database.feedModel.findAll({
for (const feed of feeds) { attributes: ['id', 'entityId', 'entityType', 'title'],
// Remove invalid feeds include: [
if (!(await this.validateFeedEntity(feed))) { {
await Database.removeFeed(feed.id) model: Database.libraryItemModel,
attributes: ['id']
},
{
model: Database.collectionModel,
attributes: ['id']
},
{
model: Database.seriesModel,
attributes: ['id']
} }
]
})
const feedIdsToRemove = []
for (const feed of feeds) {
if (!feed.entity) {
Logger.error(`[RssFeedManager] Removing feed "${feed.title}". Entity not found`)
feedIdsToRemove.push(feed.id)
}
}
if (feedIdsToRemove.length) {
Logger.info(`[RssFeedManager] Removing ${feedIdsToRemove.length} invalid feeds`)
await Database.feedModel.destroy({
where: {
id: feedIdsToRemove
}
})
} }
} }
/** /**
* Find open feed for an entity (e.g. collection id, playlist id, library item id) * Find open feed for an entity (e.g. collection id, playlist id, library item id)
* @param {string} entityId * @param {string} entityId
* @returns {Promise<objects.Feed>} oldFeed * @returns {Promise<import('../models/Feed')>}
*/ */
findFeedForEntityId(entityId) { findFeedForEntityId(entityId) {
return Database.feedModel.findOneOld({ entityId }) return Database.feedModel.findOne({
where: {
entityId
}
})
} }
/** /**
* Find open feed for a slug *
* @param {string} slug * @param {string} slug
* @returns {Promise<objects.Feed>} oldFeed * @returns {Promise<boolean>}
*/ */
findFeedBySlug(slug) { checkExistsBySlug(slug) {
return Database.feedModel.findOneOld({ slug }) return Database.feedModel
.count({
where: {
slug
}
})
.then((count) => count > 0)
} }
/** /**
* Find open feed for a slug * Feed requires update if the entity (or child entities) has been updated since the feed was last updated
* @param {string} slug *
* @returns {Promise<objects.Feed>} oldFeed * @param {import('../models/Feed')} feed
* @returns {Promise<boolean>}
*/ */
findFeed(id) { async checkFeedRequiresUpdate(feed) {
return Database.feedModel.findByPkOld(id) if (feed.entityType === 'libraryItem') {
feed.entity = await feed.getEntity({
attributes: ['id', 'updatedAt', 'mediaId', 'mediaType']
})
let newEntityUpdatedAt = feed.entity.updatedAt
if (feed.entity.mediaType === 'podcast') {
const mostRecentPodcastEpisode = await Database.podcastEpisodeModel.findOne({
where: {
podcastId: feed.entity.mediaId
},
attributes: ['id', 'updatedAt'],
order: [['createdAt', 'DESC']]
})
if (mostRecentPodcastEpisode && mostRecentPodcastEpisode.updatedAt > newEntityUpdatedAt) {
newEntityUpdatedAt = mostRecentPodcastEpisode.updatedAt
}
} }
return newEntityUpdatedAt > feed.entityUpdatedAt
} else if (feed.entityType === 'collection' || feed.entityType === 'series') {
feed.entity = await feed.getEntity({
attributes: ['id', 'updatedAt'],
include: {
model: Database.bookModel,
attributes: ['id'],
through: {
attributes: []
},
include: {
model: Database.libraryItemModel,
attributes: ['id', 'updatedAt']
}
}
})
let newEntityUpdatedAt = feed.entity.updatedAt
const mostRecentItemUpdatedAt = feed.entity.books.reduce((mostRecent, book) => {
if (book.libraryItem.updatedAt > mostRecent) {
return book.libraryItem.updatedAt
}
return mostRecent
}, 0)
if (mostRecentItemUpdatedAt > newEntityUpdatedAt) {
newEntityUpdatedAt = mostRecentItemUpdatedAt
}
return newEntityUpdatedAt > feed.entityUpdatedAt
} else {
throw new Error('Invalid feed entity type')
}
}
/**
* GET: /feed/:slug
*
* @param {Request} req
* @param {Response} res
*/
async getFeed(req, res) { async getFeed(req, res) {
const feed = await this.findFeedBySlug(req.params.slug) let feed = await Database.feedModel.findOne({
where: {
slug: req.params.slug
}
})
if (!feed) { if (!feed) {
Logger.warn(`[RssFeedManager] Feed not found ${req.params.slug}`) Logger.warn(`[RssFeedManager] Feed not found ${req.params.slug}`)
res.sendStatus(404) res.sendStatus(404)
return return
} }
// Check if feed needs to be updated const feedRequiresUpdate = await this.checkFeedRequiresUpdate(feed)
if (feed.entityType === 'libraryItem') { if (feedRequiresUpdate) {
const libraryItem = await Database.libraryItemModel.getOldById(feed.entityId) Logger.info(`[RssFeedManager] Feed "${feed.title}" requires update - updating feed`)
feed = await feed.updateFeedForEntity()
let mostRecentlyUpdatedAt = libraryItem.updatedAt } else {
if (libraryItem.isPodcast) { feed.feedEpisodes = await feed.getFeedEpisodes()
libraryItem.media.episodes.forEach((episode) => {
if (episode.updatedAt > mostRecentlyUpdatedAt) mostRecentlyUpdatedAt = episode.updatedAt
})
} }
if (libraryItem && (!feed.entityUpdatedAt || mostRecentlyUpdatedAt > feed.entityUpdatedAt)) { const xml = feed.buildXml(req.originalHostPrefix)
Logger.debug(`[RssFeedManager] Updating RSS feed for item ${libraryItem.id} "${libraryItem.media.metadata.title}"`)
feed.updateFromItem(libraryItem)
await Database.updateFeed(feed)
}
} else if (feed.entityType === 'collection') {
const collection = await Database.collectionModel.findByPk(feed.entityId, {
include: Database.collectionBookModel
})
if (collection) {
const collectionExpanded = await collection.getOldJsonExpanded()
// Find most recently updated item in collection
let mostRecentlyUpdatedAt = collectionExpanded.lastUpdate
// Check for most recently updated book
collectionExpanded.books.forEach((libraryItem) => {
if (libraryItem.media.tracks.length && libraryItem.updatedAt > mostRecentlyUpdatedAt) {
mostRecentlyUpdatedAt = libraryItem.updatedAt
}
})
// Check for most recently added collection book
collection.collectionBooks.forEach((collectionBook) => {
if (collectionBook.createdAt.valueOf() > mostRecentlyUpdatedAt) {
mostRecentlyUpdatedAt = collectionBook.createdAt.valueOf()
}
})
const hasBooksRemoved = collection.collectionBooks.length < feed.episodes.length
if (!feed.entityUpdatedAt || hasBooksRemoved || mostRecentlyUpdatedAt > feed.entityUpdatedAt) {
Logger.debug(`[RssFeedManager] Updating RSS feed for collection "${collection.name}"`)
feed.updateFromCollection(collectionExpanded)
await Database.updateFeed(feed)
}
}
} else if (feed.entityType === 'series') {
const series = await Database.seriesModel.findByPk(feed.entityId)
if (series) {
const seriesJson = series.toOldJSON()
// Get books in series that have audio tracks
seriesJson.books = (await libraryItemsBookFilters.getLibraryItemsForSeries(series)).filter((li) => li.media.numTracks)
// Find most recently updated item in series
let mostRecentlyUpdatedAt = seriesJson.updatedAt
let totalTracks = 0 // Used to detect series items removed
seriesJson.books.forEach((libraryItem) => {
totalTracks += libraryItem.media.tracks.length
if (libraryItem.media.tracks.length && libraryItem.updatedAt > mostRecentlyUpdatedAt) {
mostRecentlyUpdatedAt = libraryItem.updatedAt
}
})
if (totalTracks !== feed.episodes.length) {
mostRecentlyUpdatedAt = Date.now()
}
if (!feed.entityUpdatedAt || mostRecentlyUpdatedAt > feed.entityUpdatedAt) {
Logger.debug(`[RssFeedManager] Updating RSS feed for series "${seriesJson.name}"`)
feed.updateFromSeries(seriesJson)
await Database.updateFeed(feed)
}
}
}
const xml = feed.buildXml()
res.set('Content-Type', 'text/xml') res.set('Content-Type', 'text/xml')
res.send(xml) res.send(xml)
} }
/**
* GET: /feed/:slug/item/:episodeId/*
*
* @param {Request} req
* @param {Response} res
*/
async getFeedItem(req, res) { async getFeedItem(req, res) {
const feed = await this.findFeedBySlug(req.params.slug) const feed = await Database.feedModel.findOne({
where: {
slug: req.params.slug
},
attributes: ['id', 'slug'],
include: {
model: Database.feedEpisodeModel,
attributes: ['id', 'filePath']
}
})
if (!feed) { if (!feed) {
Logger.debug(`[RssFeedManager] Feed not found ${req.params.slug}`) Logger.debug(`[RssFeedManager] Feed not found ${req.params.slug}`)
res.sendStatus(404) res.sendStatus(404)
@@ -183,8 +204,19 @@ class RssFeedManager {
res.sendFile(episodePath) res.sendFile(episodePath)
} }
/**
* GET: /feed/:slug/cover*
*
* @param {Request} req
* @param {Response} res
*/
async getFeedCover(req, res) { async getFeedCover(req, res) {
const feed = await this.findFeedBySlug(req.params.slug) const feed = await Database.feedModel.findOne({
where: {
slug: req.params.slug
},
attributes: ['coverPath']
})
if (!feed) { if (!feed) {
Logger.debug(`[RssFeedManager] Feed not found ${req.params.slug}`) Logger.debug(`[RssFeedManager] Feed not found ${req.params.slug}`)
res.sendStatus(404) res.sendStatus(404)
@@ -204,100 +236,143 @@ class RssFeedManager {
/** /**
* *
* @param {string} userId
* @param {*} libraryItem
* @param {*} options * @param {*} options
* @returns * @returns {import('../models/Feed').FeedOptions}
*/
getFeedOptionsFromReqOptions(options) {
const metadataDetails = options.metadataDetails || {}
if (metadataDetails.preventIndexing !== false) {
metadataDetails.preventIndexing = true
}
return {
preventIndexing: metadataDetails.preventIndexing,
ownerName: metadataDetails.ownerName && typeof metadataDetails.ownerName === 'string' ? metadataDetails.ownerName : null,
ownerEmail: metadataDetails.ownerEmail && typeof metadataDetails.ownerEmail === 'string' ? metadataDetails.ownerEmail : null
}
}
/**
*
* @param {string} userId
* @param {import('../models/LibraryItem')} libraryItem
* @param {*} options
* @returns {Promise<import('../models/Feed').FeedExpanded>}
*/ */
async openFeedForItem(userId, libraryItem, options) { async openFeedForItem(userId, libraryItem, options) {
const serverAddress = options.serverAddress const serverAddress = options.serverAddress
const slug = options.slug const slug = options.slug
const preventIndexing = options.metadataDetails?.preventIndexing ?? true const feedOptions = this.getFeedOptionsFromReqOptions(options)
const ownerName = options.metadataDetails?.ownerName
const ownerEmail = options.metadataDetails?.ownerEmail
const feed = new Feed() Logger.info(`[RssFeedManager] Creating RSS feed for item ${libraryItem.id} "${libraryItem.media.title}"`)
feed.setFromItem(userId, slug, libraryItem, serverAddress, preventIndexing, ownerName, ownerEmail) const feedExpanded = await Database.feedModel.createFeedForLibraryItem(userId, libraryItem, slug, serverAddress, feedOptions)
if (feedExpanded) {
Logger.info(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`) Logger.info(`[RssFeedManager] Opened RSS feed "${feedExpanded.feedURL}"`)
await Database.createFeed(feed) SocketAuthority.emitter('rss_feed_open', feedExpanded.toOldJSONMinified())
SocketAuthority.emitter('rss_feed_open', feed.toJSONMinified()) }
return feed return feedExpanded
} }
/** /**
* *
* @param {string} userId * @param {string} userId
* @param {*} collectionExpanded * @param {import('../models/Collection')} collectionExpanded
* @param {*} options * @param {*} options
* @returns * @returns {Promise<import('../models/Feed').FeedExpanded>}
*/ */
async openFeedForCollection(userId, collectionExpanded, options) { async openFeedForCollection(userId, collectionExpanded, options) {
const serverAddress = options.serverAddress const serverAddress = options.serverAddress
const slug = options.slug const slug = options.slug
const preventIndexing = options.metadataDetails?.preventIndexing ?? true const feedOptions = this.getFeedOptionsFromReqOptions(options)
const ownerName = options.metadataDetails?.ownerName
const ownerEmail = options.metadataDetails?.ownerEmail
const feed = new Feed() Logger.info(`[RssFeedManager] Creating RSS feed for collection "${collectionExpanded.name}"`)
feed.setFromCollection(userId, slug, collectionExpanded, serverAddress, preventIndexing, ownerName, ownerEmail) const feedExpanded = await Database.feedModel.createFeedForCollection(userId, collectionExpanded, slug, serverAddress, feedOptions)
if (feedExpanded) {
Logger.info(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`) Logger.info(`[RssFeedManager] Opened RSS feed "${feedExpanded.feedURL}"`)
await Database.createFeed(feed) SocketAuthority.emitter('rss_feed_open', feedExpanded.toOldJSONMinified())
SocketAuthority.emitter('rss_feed_open', feed.toJSONMinified()) }
return feed return feedExpanded
} }
/** /**
* *
* @param {string} userId * @param {string} userId
* @param {*} seriesExpanded * @param {import('../models/Series')} seriesExpanded
* @param {*} options * @param {*} options
* @returns * @returns {Promise<import('../models/Feed').FeedExpanded>}
*/ */
async openFeedForSeries(userId, seriesExpanded, options) { async openFeedForSeries(userId, seriesExpanded, options) {
const serverAddress = options.serverAddress const serverAddress = options.serverAddress
const slug = options.slug const slug = options.slug
const preventIndexing = options.metadataDetails?.preventIndexing ?? true const feedOptions = this.getFeedOptionsFromReqOptions(options)
const ownerName = options.metadataDetails?.ownerName
const ownerEmail = options.metadataDetails?.ownerEmail
const feed = new Feed() Logger.info(`[RssFeedManager] Creating RSS feed for series "${seriesExpanded.name}"`)
feed.setFromSeries(userId, slug, seriesExpanded, serverAddress, preventIndexing, ownerName, ownerEmail) const feedExpanded = await Database.feedModel.createFeedForSeries(userId, seriesExpanded, slug, serverAddress, feedOptions)
if (feedExpanded) {
Logger.info(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`) Logger.info(`[RssFeedManager] Opened RSS feed "${feedExpanded.feedURL}"`)
await Database.createFeed(feed) SocketAuthority.emitter('rss_feed_open', feedExpanded.toOldJSONMinified())
SocketAuthority.emitter('rss_feed_open', feed.toJSONMinified()) }
return feed return feedExpanded
} }
/**
* Close Feed and emit Socket event
*
* @param {import('../models/Feed')} feed
* @returns {Promise<boolean>} - true if feed was closed
*/
async handleCloseFeed(feed) { async handleCloseFeed(feed) {
if (!feed) return if (!feed) return false
await Database.removeFeed(feed.id) const wasRemoved = await Database.feedModel.removeById(feed.id)
SocketAuthority.emitter('rss_feed_closed', feed.toJSONMinified()) SocketAuthority.emitter('rss_feed_closed', feed.toOldJSONMinified())
Logger.info(`[RssFeedManager] Closed RSS feed "${feed.feedUrl}"`) Logger.info(`[RssFeedManager] Closed RSS feed "${feed.feedURL}"`)
} return wasRemoved
async closeRssFeed(req, res) {
const feed = await this.findFeed(req.params.id)
if (!feed) {
Logger.error(`[RssFeedManager] RSS feed not found with id "${req.params.id}"`)
return res.sendStatus(404)
}
await this.handleCloseFeed(feed)
res.sendStatus(200)
} }
/**
*
* @param {string} entityId
* @returns {Promise<boolean>} - true if feed was closed
*/
async closeFeedForEntityId(entityId) { async closeFeedForEntityId(entityId) {
const feed = await this.findFeedForEntityId(entityId) const feed = await Database.feedModel.findOne({
if (!feed) return where: {
entityId
}
})
if (!feed) {
Logger.warn(`[RssFeedManager] closeFeedForEntityId: Feed not found for entity id ${entityId}`)
return false
}
return this.handleCloseFeed(feed) return this.handleCloseFeed(feed)
} }
async getFeeds() { /**
const feeds = await Database.models.feed.getOldFeeds() *
Logger.info(`[RssFeedManager] Fetched all feeds`) * @param {string[]} entityIds
return feeds */
async closeFeedsForEntityIds(entityIds) {
const feeds = await Database.feedModel.findAll({
where: {
entityId: entityIds
}
})
for (const feed of feeds) {
await this.handleCloseFeed(feed)
}
}
/**
*
* @returns {Promise<import('../models/Feed').FeedExpanded[]>}
*/
getFeeds() {
return Database.feedModel.findAll({
include: {
model: Database.feedEpisodeModel
}
})
} }
} }
module.exports = RssFeedManager module.exports = new RssFeedManager()
+4 -1
View File
@@ -3,8 +3,11 @@
Please add a record of every database migration that you create to this file. This will help us keep track of changes to the database schema over time. Please add a record of every database migration that you create to this file. This will help us keep track of changes to the database schema over time.
| Server Version | Migration Script Name | Description | | Server Version | Migration Script Name | Description |
| -------------- | ---------------------------- | ------------------------------------------------------------------------------------ | | -------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| v2.15.0 | v2.15.0-series-column-unique | Series must have unique names in the same library | | v2.15.0 | v2.15.0-series-column-unique | Series must have unique names in the same library |
| v2.15.1 | v2.15.1-reindex-nocase | Fix potential db corruption issues due to bad sqlite extension introduced in v2.12.0 | | v2.15.1 | v2.15.1-reindex-nocase | Fix potential db corruption issues due to bad sqlite extension introduced in v2.12.0 |
| v2.15.2 | v2.15.2-index-creation | Creates author, series, and podcast episode indexes | | v2.15.2 | v2.15.2-index-creation | Creates author, series, and podcast episode indexes |
| v2.17.0 | v2.17.0-uuid-replacement | Changes the data type of columns with UUIDv4 to UUID matching the associated model | | v2.17.0 | v2.17.0-uuid-replacement | Changes the data type of columns with UUIDv4 to UUID matching the associated model |
| v2.17.3 | v2.17.3-fk-constraints | Changes the foreign key constraints for tables due to sequelize bug dropping constraints in v2.17.0 migration |
| v2.17.4 | v2.17.4-use-subfolder-for-oidc-redirect-uris | Save subfolder to OIDC redirect URIs to support existing installations |
| v2.17.5 | v2.17.5-remove-host-from-feed-urls | removes the host (serverAddress) from URL columns in the feeds and feedEpisodes tables |
+259
View File
@@ -0,0 +1,259 @@
/**
* @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.
*/
/**
* This upward migration script changes foreign key constraints for the
* libraryItems, feeds, mediaItemShares, playbackSessions, playlistMediaItems, and mediaProgresses tables.
*
* @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('[2.17.3 migration] UPGRADE BEGIN: 2.17.3-fk-constraints')
const execQuery = queryInterface.sequelize.query.bind(queryInterface.sequelize)
// Disable foreign key constraints for the next sequence of operations
await execQuery(`PRAGMA foreign_keys = OFF;`)
try {
await execQuery(`BEGIN TRANSACTION;`)
logger.info('[2.17.3 migration] Updating libraryItems constraints')
const libraryItemsConstraints = [
{ field: 'libraryId', onDelete: 'SET NULL', onUpdate: 'CASCADE' },
{ field: 'libraryFolderId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }
]
if (await changeConstraints(queryInterface, 'libraryItems', libraryItemsConstraints)) {
logger.info('[2.17.3 migration] Finished updating libraryItems constraints')
} else {
logger.info('[2.17.3 migration] No changes needed for libraryItems constraints')
}
logger.info('[2.17.3 migration] Updating feeds constraints')
const feedsConstraints = [{ field: 'userId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }]
if (await changeConstraints(queryInterface, 'feeds', feedsConstraints)) {
logger.info('[2.17.3 migration] Finished updating feeds constraints')
} else {
logger.info('[2.17.3 migration] No changes needed for feeds constraints')
}
if (await queryInterface.tableExists('mediaItemShares')) {
logger.info('[2.17.3 migration] Updating mediaItemShares constraints')
const mediaItemSharesConstraints = [{ field: 'userId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }]
if (await changeConstraints(queryInterface, 'mediaItemShares', mediaItemSharesConstraints)) {
logger.info('[2.17.3 migration] Finished updating mediaItemShares constraints')
} else {
logger.info('[2.17.3 migration] No changes needed for mediaItemShares constraints')
}
} else {
logger.info('[2.17.3 migration] mediaItemShares table does not exist, skipping column change')
}
logger.info('[2.17.3 migration] Updating playbackSessions constraints')
const playbackSessionsConstraints = [
{ field: 'deviceId', onDelete: 'SET NULL', onUpdate: 'CASCADE' },
{ field: 'libraryId', onDelete: 'SET NULL', onUpdate: 'CASCADE' },
{ field: 'userId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }
]
if (await changeConstraints(queryInterface, 'playbackSessions', playbackSessionsConstraints)) {
logger.info('[2.17.3 migration] Finished updating playbackSessions constraints')
} else {
logger.info('[2.17.3 migration] No changes needed for playbackSessions constraints')
}
logger.info('[2.17.3 migration] Updating playlistMediaItems constraints')
const playlistMediaItemsConstraints = [{ field: 'playlistId', onDelete: 'CASCADE', onUpdate: 'CASCADE' }]
if (await changeConstraints(queryInterface, 'playlistMediaItems', playlistMediaItemsConstraints)) {
logger.info('[2.17.3 migration] Finished updating playlistMediaItems constraints')
} else {
logger.info('[2.17.3 migration] No changes needed for playlistMediaItems constraints')
}
logger.info('[2.17.3 migration] Updating mediaProgresses constraints')
const mediaProgressesConstraints = [{ field: 'userId', onDelete: 'CASCADE', onUpdate: 'CASCADE' }]
if (await changeConstraints(queryInterface, 'mediaProgresses', mediaProgressesConstraints)) {
logger.info('[2.17.3 migration] Finished updating mediaProgresses constraints')
} else {
logger.info('[2.17.3 migration] No changes needed for mediaProgresses constraints')
}
await execQuery(`COMMIT;`)
} catch (error) {
logger.error(`[2.17.3 migration] Migration failed - rolling back. Error:`, error)
await execQuery(`ROLLBACK;`)
}
await execQuery(`PRAGMA foreign_keys = ON;`)
// Completed migration
logger.info('[2.17.3 migration] UPGRADE END: 2.17.3-fk-constraints')
}
/**
* This downward migration script is a no-op.
*
* @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('[2.17.3 migration] DOWNGRADE BEGIN: 2.17.3-fk-constraints')
// This migration is a no-op
logger.info('[2.17.3 migration] No action required for downgrade')
// Completed migration
logger.info('[2.17.3 migration] DOWNGRADE END: 2.17.3-fk-constraints')
}
/**
* @typedef ConstraintUpdateObj
* @property {string} field - The field to update
* @property {string} onDelete - The onDelete constraint
* @property {string} onUpdate - The onUpdate constraint
*/
/**
* @typedef SequelizeFKObj
* @property {{ model: string, key: string }} references
* @property {string} onDelete
* @property {string} onUpdate
*/
/**
* @param {Object} fk - The foreign key object from PRAGMA foreign_key_list
* @returns {SequelizeFKObj} - The foreign key object formatted for Sequelize
*/
const formatFKsPragmaToSequelizeFK = (fk) => {
return {
references: {
model: fk.table,
key: fk.to
},
onDelete: fk['on_delete'],
onUpdate: fk['on_update']
}
}
/**
*
* @param {import('sequelize').QueryInterface} queryInterface
* @param {string} tableName
* @param {ConstraintUpdateObj[]} constraints
* @returns {Promise<Record<string, SequelizeFKObj>|null>}
*/
async function getUpdatedForeignKeys(queryInterface, tableName, constraints) {
const execQuery = queryInterface.sequelize.query.bind(queryInterface.sequelize)
const quotedTableName = queryInterface.quoteIdentifier(tableName)
const foreignKeys = await execQuery(`PRAGMA foreign_key_list(${quotedTableName});`)
let hasUpdates = false
const foreignKeysByColName = foreignKeys.reduce((prev, curr) => {
const fk = formatFKsPragmaToSequelizeFK(curr)
const constraint = constraints.find((c) => c.field === curr.from)
if (constraint && (constraint.onDelete !== fk.onDelete || constraint.onUpdate !== fk.onUpdate)) {
fk.onDelete = constraint.onDelete
fk.onUpdate = constraint.onUpdate
hasUpdates = true
}
return { ...prev, [curr.from]: fk }
}, {})
return hasUpdates ? foreignKeysByColName : null
}
/**
* Extends the Sequelize describeTable function to include the updated foreign key constraints
*
* @param {import('sequelize').QueryInterface} queryInterface
* @param {String} tableName
* @param {Record<string, SequelizeFKObj>} updatedForeignKeys
*/
async function describeTableWithFKs(queryInterface, tableName, updatedForeignKeys) {
const tableDescription = await queryInterface.describeTable(tableName)
const tableDescriptionWithFks = Object.entries(tableDescription).reduce((prev, [col, attributes]) => {
let extendedAttributes = attributes
if (updatedForeignKeys[col]) {
extendedAttributes = {
...extendedAttributes,
...updatedForeignKeys[col]
}
}
return { ...prev, [col]: extendedAttributes }
}, {})
return tableDescriptionWithFks
}
/**
* @see https://www.sqlite.org/lang_altertable.html#otheralter
* @see https://sequelize.org/docs/v6/other-topics/query-interface/#changing-and-removing-columns-in-sqlite
*
* @param {import('sequelize').QueryInterface} queryInterface
* @param {string} tableName
* @param {ConstraintUpdateObj[]} constraints
* @returns {Promise<boolean>} - Return false if no changes are needed, true otherwise
*/
async function changeConstraints(queryInterface, tableName, constraints) {
const updatedForeignKeys = await getUpdatedForeignKeys(queryInterface, tableName, constraints)
if (!updatedForeignKeys) {
return false
}
const execQuery = queryInterface.sequelize.query.bind(queryInterface.sequelize)
const quotedTableName = queryInterface.quoteIdentifier(tableName)
const backupTableName = `${tableName}_${Math.round(Math.random() * 100)}_backup`
const quotedBackupTableName = queryInterface.quoteIdentifier(backupTableName)
try {
const tableDescriptionWithFks = await describeTableWithFKs(queryInterface, tableName, updatedForeignKeys)
const attributes = queryInterface.queryGenerator.attributesToSQL(tableDescriptionWithFks)
// Create the backup table
await queryInterface.createTable(backupTableName, attributes)
const attributeNames = Object.keys(attributes)
.map((attr) => queryInterface.quoteIdentifier(attr))
.join(', ')
// Copy all data from the target table to the backup table
await execQuery(`INSERT INTO ${quotedBackupTableName} SELECT ${attributeNames} FROM ${quotedTableName};`)
// Drop the old (original) table
await queryInterface.dropTable(tableName)
// Rename the backup table to the original table's name
await queryInterface.renameTable(backupTableName, tableName)
// Validate that all foreign key constraints are correct
const result = await execQuery(`PRAGMA foreign_key_check(${quotedTableName});`, {
type: queryInterface.sequelize.Sequelize.QueryTypes.SELECT
})
// There are foreign key violations, exit
if (result.length) {
return Promise.reject(`Foreign key violations detected: ${JSON.stringify(result, null, 2)}`)
}
return true
} catch (error) {
return Promise.reject(error)
}
}
module.exports = { up, down }
@@ -0,0 +1,84 @@
/**
* @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.
*/
/**
* This upward migration adds an subfolder setting for OIDC redirect URIs.
* It updates existing OIDC setups to set this option to None (empty subfolder), so they continue to work as before.
* IF OIDC is not enabled, no action is taken (i.e. the subfolder is left undefined),
* so that future OIDC setups will use the default subfolder.
*
* @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('[2.17.4 migration] UPGRADE BEGIN: 2.17.4-use-subfolder-for-oidc-redirect-uris')
const serverSettings = await getServerSettings(queryInterface, logger)
if (serverSettings.authActiveAuthMethods?.includes('openid')) {
logger.info('[2.17.4 migration] OIDC is enabled, adding authOpenIDSubfolderForRedirectURLs to server settings')
serverSettings.authOpenIDSubfolderForRedirectURLs = ''
await updateServerSettings(queryInterface, logger, serverSettings)
} else {
logger.info('[2.17.4 migration] OIDC is not enabled, no action required')
}
logger.info('[2.17.4 migration] UPGRADE END: 2.17.4-use-subfolder-for-oidc-redirect-uris')
}
/**
* This downward migration script removes the subfolder setting for OIDC redirect URIs.
*
* @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('[2.17.4 migration] DOWNGRADE BEGIN: 2.17.4-use-subfolder-for-oidc-redirect-uris ')
// Remove the OIDC subfolder option from the server settings
const serverSettings = await getServerSettings(queryInterface, logger)
if (serverSettings.authOpenIDSubfolderForRedirectURLs !== undefined) {
logger.info('[2.17.4 migration] Removing authOpenIDSubfolderForRedirectURLs from server settings')
delete serverSettings.authOpenIDSubfolderForRedirectURLs
await updateServerSettings(queryInterface, logger, serverSettings)
} else {
logger.info('[2.17.4 migration] authOpenIDSubfolderForRedirectURLs not found in server settings, no action required')
}
logger.info('[2.17.4 migration] DOWNGRADE END: 2.17.4-use-subfolder-for-oidc-redirect-uris ')
}
async function getServerSettings(queryInterface, logger) {
const result = await queryInterface.sequelize.query('SELECT value FROM settings WHERE key = "server-settings";')
if (!result[0].length) {
logger.error('[2.17.4 migration] Server settings not found')
throw new Error('Server settings not found')
}
let serverSettings = null
try {
serverSettings = JSON.parse(result[0][0].value)
} catch (error) {
logger.error('[2.17.4 migration] Error parsing server settings:', error)
throw error
}
return serverSettings
}
async function updateServerSettings(queryInterface, logger, serverSettings) {
await queryInterface.sequelize.query('UPDATE settings SET value = :value WHERE key = "server-settings";', {
replacements: {
value: JSON.stringify(serverSettings)
}
})
}
module.exports = { up, down }
@@ -0,0 +1,74 @@
/**
* @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.17.5'
const migrationName = `${migrationVersion}-remove-host-from-feed-urls`
const loggerPrefix = `[${migrationVersion} migration]`
/**
* This upward migration removes the host (serverAddress) from URL columns in the feeds and feedEpisodes tables.
*
* @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}`)
logger.info(`${loggerPrefix} Removing serverAddress from Feeds table URLs`)
await queryInterface.sequelize.query(`
UPDATE Feeds
SET feedUrl = REPLACE(feedUrl, COALESCE(serverAddress, ''), ''),
imageUrl = REPLACE(imageUrl, COALESCE(serverAddress, ''), ''),
siteUrl = REPLACE(siteUrl, COALESCE(serverAddress, ''), '');
`)
logger.info(`${loggerPrefix} Removed serverAddress from Feeds table URLs`)
logger.info(`${loggerPrefix} Removing serverAddress from FeedEpisodes table URLs`)
await queryInterface.sequelize.query(`
UPDATE FeedEpisodes
SET siteUrl = REPLACE(siteUrl, (SELECT COALESCE(serverAddress, '') FROM Feeds WHERE Feeds.id = FeedEpisodes.feedId), ''),
enclosureUrl = REPLACE(enclosureUrl, (SELECT COALESCE(serverAddress, '') FROM Feeds WHERE Feeds.id = FeedEpisodes.feedId), '');
`)
logger.info(`${loggerPrefix} Removed serverAddress from FeedEpisodes table URLs`)
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
}
/**
* This downward migration script adds the host (serverAddress) back to URL columns in the feeds and feedEpisodes tables.
*
* @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}`)
logger.info(`${loggerPrefix} Adding serverAddress back to Feeds table URLs`)
await queryInterface.sequelize.query(`
UPDATE Feeds
SET feedUrl = COALESCE(serverAddress, '') || feedUrl,
imageUrl = COALESCE(serverAddress, '') || imageUrl,
siteUrl = COALESCE(serverAddress, '') || siteUrl;
`)
logger.info(`${loggerPrefix} Added serverAddress back to Feeds table URLs`)
logger.info(`${loggerPrefix} Adding serverAddress back to FeedEpisodes table URLs`)
await queryInterface.sequelize.query(`
UPDATE FeedEpisodes
SET siteUrl = (SELECT COALESCE(serverAddress, '') || FeedEpisodes.siteUrl FROM Feeds WHERE Feeds.id = FeedEpisodes.feedId),
enclosureUrl = (SELECT COALESCE(serverAddress, '') || FeedEpisodes.enclosureUrl FROM Feeds WHERE Feeds.id = FeedEpisodes.feedId);
`)
logger.info(`${loggerPrefix} Added serverAddress back to FeedEpisodes table URLs`)
logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
}
module.exports = { up, down }
+35
View File
@@ -29,6 +29,12 @@ const Logger = require('../Logger')
* @property {SeriesExpanded[]} series * @property {SeriesExpanded[]} series
* *
* @typedef {Book & BookExpandedProperties} BookExpanded * @typedef {Book & BookExpandedProperties} BookExpanded
*
* Collections use BookExpandedWithLibraryItem
* @typedef BookExpandedWithLibraryItemProperties
* @property {import('./LibraryItem')} libraryItem
*
* @typedef {BookExpanded & BookExpandedWithLibraryItemProperties} BookExpandedWithLibraryItem
*/ */
/** /**
@@ -106,6 +112,9 @@ class Book extends Model {
this.updatedAt this.updatedAt
/** @type {Date} */ /** @type {Date} */
this.createdAt this.createdAt
/** @type {import('./Author')[]} - optional if expanded */
this.authors
} }
static getOldBook(libraryItemExpanded) { static getOldBook(libraryItemExpanded) {
@@ -320,6 +329,32 @@ class Book extends Model {
} }
) )
} }
/**
* Comma separated array of author names
* Requires authors to be loaded
*
* @returns {string}
*/
get authorName() {
if (this.authors === undefined) {
Logger.error(`[Book] authorName: Cannot get authorName because authors are not loaded`)
return ''
}
return this.authors.map((au) => au.name).join(', ')
}
get includedAudioFiles() {
return this.audioFiles.filter((af) => !af.exclude)
}
get trackList() {
let startOffset = 0
return this.includedAudioFiles.map((af) => {
const track = structuredClone(af)
track.startOffset = startOffset
startOffset += track.duration
return track
})
}
} }
module.exports = Book module.exports = Book
+68 -2
View File
@@ -18,6 +18,11 @@ class Collection extends Model {
this.updatedAt this.updatedAt
/** @type {Date} */ /** @type {Date} */
this.createdAt this.createdAt
// Expanded properties
/** @type {import('./Book').BookExpandedWithLibraryItem[]} - only set when expanded */
this.books
} }
/** /**
@@ -107,7 +112,7 @@ class Collection extends Model {
// Map feed if found // Map feed if found
if (c.feeds?.length) { if (c.feeds?.length) {
collectionExpanded.rssFeed = this.sequelize.models.feed.getOldFeed(c.feeds[0]) collectionExpanded.rssFeed = c.feeds[0].toOldJSON()
} }
return collectionExpanded return collectionExpanded
@@ -115,6 +120,39 @@ class Collection extends Model {
.filter((c) => c) .filter((c) => c)
} }
/**
*
* @param {string} collectionId
* @returns {Promise<Collection>}
*/
static async getExpandedById(collectionId) {
return this.findByPk(collectionId, {
include: [
{
model: this.sequelize.models.book,
include: [
{
model: this.sequelize.models.libraryItem
},
{
model: this.sequelize.models.author,
through: {
attributes: []
}
},
{
model: this.sequelize.models.series,
through: {
attributes: ['sequence']
}
}
]
}
],
order: [[this.sequelize.models.book, this.sequelize.models.collectionBook, 'order', 'ASC']]
})
}
/** /**
* Get old collection from Collection * Get old collection from Collection
* @param {Collection} collectionExpanded * @param {Collection} collectionExpanded
@@ -219,6 +257,34 @@ class Collection extends Model {
Collection.belongsTo(library) Collection.belongsTo(library)
} }
/**
* Get all books in collection expanded with library item
*
* @returns {Promise<import('./Book').BookExpandedWithLibraryItem[]>}
*/
getBooksExpandedWithLibraryItem() {
return this.getBooks({
include: [
{
model: this.sequelize.models.libraryItem
},
{
model: this.sequelize.models.author,
through: {
attributes: []
}
},
{
model: this.sequelize.models.series,
through: {
attributes: ['sequence']
}
}
],
order: [Sequelize.literal('`collectionBook.order` ASC')]
})
}
/** /**
* Get old collection toJSONExpanded, items filtered for user permissions * Get old collection toJSONExpanded, items filtered for user permissions
* *
@@ -282,7 +348,7 @@ class Collection extends Model {
if (include?.includes('rssfeed')) { if (include?.includes('rssfeed')) {
const feeds = await this.getFeeds() const feeds = await this.getFeeds()
if (feeds?.length) { if (feeds?.length) {
collectionExpanded.rssFeed = this.sequelize.models.feed.getOldFeed(feeds[0]) collectionExpanded.rssFeed = feeds[0].toOldJSON()
} }
} }
+451 -171
View File
@@ -1,6 +1,22 @@
const Path = require('path')
const { DataTypes, Model } = require('sequelize') const { DataTypes, Model } = require('sequelize')
const oldFeed = require('../objects/Feed') const Logger = require('../Logger')
const areEquivalent = require('../utils/areEquivalent')
const RSS = require('../libs/rss')
/**
* @typedef FeedOptions
* @property {boolean} preventIndexing
* @property {string} ownerName
* @property {string} ownerEmail
*/
/**
* @typedef FeedExpandedProperties
* @property {import('./FeedEpisode')} feedEpisodes
*
* @typedef {Feed & FeedExpandedProperties} FeedExpanded
*/
class Feed extends Model { class Feed extends Model {
constructor(values, options) { constructor(values, options) {
@@ -50,210 +66,288 @@ class Feed extends Model {
this.createdAt this.createdAt
/** @type {Date} */ /** @type {Date} */
this.updatedAt this.updatedAt
}
static async getOldFeeds() { // Expanded properties
const feeds = await this.findAll({
include: { /** @type {import('./FeedEpisode')[]} - only set if expanded */
model: this.sequelize.models.feedEpisode this.feedEpisodes
}
})
return feeds.map((f) => this.getOldFeed(f))
} }
/** /**
* Get old feed from Feed and optionally Feed with FeedEpisodes * @param {string} feedId
* @param {Feed} feedExpanded * @returns {Promise<boolean>} - true if feed was removed
* @returns {oldFeed}
*/ */
static getOldFeed(feedExpanded) { static async removeById(feedId) {
const episodes = feedExpanded.feedEpisodes?.map((feedEpisode) => feedEpisode.getOldEpisode()) return (
return new oldFeed({ (await this.destroy({
id: feedExpanded.id,
slug: feedExpanded.slug,
userId: feedExpanded.userId,
entityType: feedExpanded.entityType,
entityId: feedExpanded.entityId,
entityUpdatedAt: feedExpanded.entityUpdatedAt?.valueOf() || null,
coverPath: feedExpanded.coverPath || null,
meta: {
title: feedExpanded.title,
description: feedExpanded.description,
author: feedExpanded.author,
imageUrl: feedExpanded.imageURL,
feedUrl: feedExpanded.feedURL,
link: feedExpanded.siteURL,
explicit: feedExpanded.explicit,
type: feedExpanded.podcastType,
language: feedExpanded.language,
preventIndexing: feedExpanded.preventIndexing,
ownerName: feedExpanded.ownerName,
ownerEmail: feedExpanded.ownerEmail
},
serverAddress: feedExpanded.serverAddress,
feedUrl: feedExpanded.feedURL,
episodes: episodes || [],
createdAt: feedExpanded.createdAt.valueOf(),
updatedAt: feedExpanded.updatedAt.valueOf()
})
}
static removeById(feedId) {
return this.destroy({
where: { where: {
id: feedId id: feedId
} }
}) })) > 0
)
} }
/** /**
* Find all library item ids that have an open feed (used in library filter) *
* @returns {Promise<string[]>} array of library item ids * @param {string} userId
* @param {import('./LibraryItem').LibraryItemExpanded} libraryItem
* @param {string} slug
* @param {string} serverAddress
* @param {FeedOptions} [feedOptions=null]
*
* @returns {Feed}
*/ */
static async findAllLibraryItemIds() { static getFeedObjForLibraryItem(userId, libraryItem, slug, serverAddress, feedOptions = null) {
const feeds = await this.findAll({ const media = libraryItem.media
attributes: ['entityId'],
where: { let entityUpdatedAt = libraryItem.updatedAt
entityType: 'libraryItem'
// Podcast feeds should use the most recent episode updatedAt if more recent
if (libraryItem.mediaType === 'podcast') {
entityUpdatedAt = libraryItem.media.podcastEpisodes.reduce((mostRecent, episode) => {
return episode.updatedAt > mostRecent ? episode.updatedAt : mostRecent
}, entityUpdatedAt)
} }
})
return feeds.map((f) => f.entityId).filter((f) => f) || [] const feedObj = {
slug,
entityType: 'libraryItem',
entityId: libraryItem.id,
entityUpdatedAt,
serverAddress,
feedURL: `/feed/${slug}`,
imageURL: media.coverPath ? `/feed/${slug}/cover${Path.extname(media.coverPath)}` : `/Logo.png`,
siteURL: `/item/${libraryItem.id}`,
title: media.title,
description: media.description,
author: libraryItem.mediaType === 'podcast' ? media.author : media.authorName,
podcastType: libraryItem.mediaType === 'podcast' ? media.podcastType : 'serial',
language: media.language,
explicit: media.explicit,
coverPath: media.coverPath,
userId
}
if (feedOptions) {
feedObj.preventIndexing = feedOptions.preventIndexing
feedObj.ownerName = feedOptions.ownerName
feedObj.ownerEmail = feedOptions.ownerEmail
}
return feedObj
} }
/** /**
* Find feed where and return oldFeed *
* @param {Object} where sequelize where object * @param {string} userId
* @returns {Promise<oldFeed>} oldFeed * @param {import('./LibraryItem').LibraryItemExpanded} libraryItem
* @param {string} slug
* @param {string} serverAddress
* @param {FeedOptions} feedOptions
*
* @returns {Promise<FeedExpanded>}
*/ */
static async findOneOld(where) { static async createFeedForLibraryItem(userId, libraryItem, slug, serverAddress, feedOptions) {
if (!where) return null const feedObj = this.getFeedObjForLibraryItem(userId, libraryItem, slug, serverAddress, feedOptions)
const feedExpanded = await this.findOne({
where,
include: {
model: this.sequelize.models.feedEpisode
}
})
if (!feedExpanded) return null
return this.getOldFeed(feedExpanded)
}
/** /** @type {typeof import('./FeedEpisode')} */
* Find feed and return oldFeed const feedEpisodeModel = this.sequelize.models.feedEpisode
* @param {string} id
* @returns {Promise<oldFeed>} oldFeed
*/
static async findByPkOld(id) {
if (!id) return null
const feedExpanded = await this.findByPk(id, {
include: {
model: this.sequelize.models.feedEpisode
}
})
if (!feedExpanded) return null
return this.getOldFeed(feedExpanded)
}
static async fullCreateFromOld(oldFeed) { const transaction = await this.sequelize.transaction()
const feedObj = this.getFromOld(oldFeed) try {
const newFeed = await this.create(feedObj) const feed = await this.create(feedObj, { transaction })
if (oldFeed.episodes?.length) { if (libraryItem.mediaType === 'podcast') {
for (const oldFeedEpisode of oldFeed.episodes) { feed.feedEpisodes = await feedEpisodeModel.createFromPodcastEpisodes(libraryItem, feed, slug, transaction)
const feedEpisode = this.sequelize.models.feedEpisode.getFromOld(oldFeedEpisode)
feedEpisode.feedId = newFeed.id
await this.sequelize.models.feedEpisode.create(feedEpisode)
}
}
}
static async fullUpdateFromOld(oldFeed) {
const oldFeedEpisodes = oldFeed.episodes || []
const feedObj = this.getFromOld(oldFeed)
const existingFeed = await this.findByPk(feedObj.id, {
include: this.sequelize.models.feedEpisode
})
if (!existingFeed) return false
let hasUpdates = false
// Remove and update existing feed episodes
for (const feedEpisode of existingFeed.feedEpisodes) {
const oldFeedEpisode = oldFeedEpisodes.find((ep) => ep.id === feedEpisode.id)
// Episode removed
if (!oldFeedEpisode) {
feedEpisode.destroy()
} else { } else {
let episodeHasUpdates = false feed.feedEpisodes = await feedEpisodeModel.createFromAudiobookTracks(libraryItem, feed, slug, transaction)
const oldFeedEpisodeCleaned = this.sequelize.models.feedEpisode.getFromOld(oldFeedEpisode)
for (const key in oldFeedEpisodeCleaned) {
if (!areEquivalent(oldFeedEpisodeCleaned[key], feedEpisode[key])) {
episodeHasUpdates = true
}
}
if (episodeHasUpdates) {
await feedEpisode.update(oldFeedEpisodeCleaned)
hasUpdates = true
} }
await transaction.commit()
return feed
} catch (error) {
Logger.error(`[Feed] Error creating feed for library item ${libraryItem.id}`, error)
await transaction.rollback()
return null
} }
} }
// Add new feed episodes /**
for (const episode of oldFeedEpisodes) { *
if (!existingFeed.feedEpisodes.some((fe) => fe.id === episode.id)) { * @param {string} userId
await this.sequelize.models.feedEpisode.createFromOld(feedObj.id, episode) * @param {import('./Collection')} collectionExpanded
hasUpdates = true * @param {string} slug
} * @param {string} serverAddress
* @param {FeedOptions} [feedOptions=null]
*
* @returns {{ feedObj: Feed, booksWithTracks: import('./Book').BookExpandedWithLibraryItem[] }}
*/
static getFeedObjForCollection(userId, collectionExpanded, slug, serverAddress, feedOptions = null) {
const booksWithTracks = collectionExpanded.books.filter((book) => book.includedAudioFiles.length)
const entityUpdatedAt = booksWithTracks.reduce((mostRecent, book) => {
return book.libraryItem.updatedAt > mostRecent ? book.libraryItem.updatedAt : mostRecent
}, collectionExpanded.updatedAt)
const firstBookWithCover = booksWithTracks.find((book) => book.coverPath)
const allBookAuthorNames = booksWithTracks.reduce((authorNames, book) => {
const bookAuthorsToAdd = book.authors.filter((author) => !authorNames.includes(author.name)).map((author) => author.name)
return authorNames.concat(bookAuthorsToAdd)
}, [])
let author = allBookAuthorNames.slice(0, 3).join(', ')
if (allBookAuthorNames.length > 3) {
author += ' & more'
} }
let feedHasUpdates = false const feedObj = {
for (const key in feedObj) { slug,
let existingValue = existingFeed[key] entityType: 'collection',
if (existingValue instanceof Date) existingValue = existingValue.valueOf() entityId: collectionExpanded.id,
entityUpdatedAt,
if (!areEquivalent(existingValue, feedObj[key])) { serverAddress,
feedHasUpdates = true feedURL: `/feed/${slug}`,
} imageURL: firstBookWithCover?.coverPath ? `/feed/${slug}/cover${Path.extname(firstBookWithCover.coverPath)}` : `/Logo.png`,
siteURL: `/collection/${collectionExpanded.id}`,
title: collectionExpanded.name,
description: collectionExpanded.description || '',
author,
podcastType: 'serial',
explicit: booksWithTracks.some((book) => book.explicit), // If any book is explicit, the feed is explicit
coverPath: firstBookWithCover?.coverPath || null,
userId
} }
if (feedHasUpdates) { if (feedOptions) {
await existingFeed.update(feedObj) feedObj.preventIndexing = feedOptions.preventIndexing
hasUpdates = true feedObj.ownerName = feedOptions.ownerName
feedObj.ownerEmail = feedOptions.ownerEmail
} }
return hasUpdates
}
static getFromOld(oldFeed) {
const oldFeedMeta = oldFeed.meta || {}
return { return {
id: oldFeed.id, feedObj,
slug: oldFeed.slug, booksWithTracks
entityType: oldFeed.entityType,
entityId: oldFeed.entityId,
entityUpdatedAt: oldFeed.entityUpdatedAt,
serverAddress: oldFeed.serverAddress,
feedURL: oldFeed.feedUrl,
coverPath: oldFeed.coverPath || null,
imageURL: oldFeedMeta.imageUrl,
siteURL: oldFeedMeta.link,
title: oldFeedMeta.title,
description: oldFeedMeta.description,
author: oldFeedMeta.author,
podcastType: oldFeedMeta.type || null,
language: oldFeedMeta.language || null,
ownerName: oldFeedMeta.ownerName || null,
ownerEmail: oldFeedMeta.ownerEmail || null,
explicit: !!oldFeedMeta.explicit,
preventIndexing: !!oldFeedMeta.preventIndexing,
userId: oldFeed.userId
} }
} }
getEntity(options) { /**
if (!this.entityType) return Promise.resolve(null) *
const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.entityType)}` * @param {string} userId
return this[mixinMethodName](options) * @param {import('./Collection')} collectionExpanded
* @param {string} slug
* @param {string} serverAddress
* @param {FeedOptions} feedOptions
*
* @returns {Promise<FeedExpanded>}
*/
static async createFeedForCollection(userId, collectionExpanded, slug, serverAddress, feedOptions) {
const { feedObj, booksWithTracks } = this.getFeedObjForCollection(userId, collectionExpanded, slug, serverAddress, feedOptions)
/** @type {typeof import('./FeedEpisode')} */
const feedEpisodeModel = this.sequelize.models.feedEpisode
const transaction = await this.sequelize.transaction()
try {
const feed = await this.create(feedObj, { transaction })
feed.feedEpisodes = await feedEpisodeModel.createFromBooks(booksWithTracks, feed, slug, transaction)
await transaction.commit()
return feed
} catch (error) {
Logger.error(`[Feed] Error creating feed for collection ${collectionExpanded.id}`, error)
await transaction.rollback()
return null
}
}
/**
*
* @param {string} userId
* @param {import('./Series')} seriesExpanded
* @param {string} slug
* @param {string} serverAddress
* @param {FeedOptions} [feedOptions=null]
*
* @returns {{ feedObj: Feed, booksWithTracks: import('./Book').BookExpandedWithLibraryItem[] }}
*/
static getFeedObjForSeries(userId, seriesExpanded, slug, serverAddress, feedOptions = null) {
const booksWithTracks = seriesExpanded.books.filter((book) => book.includedAudioFiles.length)
const entityUpdatedAt = booksWithTracks.reduce((mostRecent, book) => {
return book.libraryItem.updatedAt > mostRecent ? book.libraryItem.updatedAt : mostRecent
}, seriesExpanded.updatedAt)
const firstBookWithCover = booksWithTracks.find((book) => book.coverPath)
const allBookAuthorNames = booksWithTracks.reduce((authorNames, book) => {
const bookAuthorsToAdd = book.authors.filter((author) => !authorNames.includes(author.name)).map((author) => author.name)
return authorNames.concat(bookAuthorsToAdd)
}, [])
let author = allBookAuthorNames.slice(0, 3).join(', ')
if (allBookAuthorNames.length > 3) {
author += ' & more'
}
const feedObj = {
slug,
entityType: 'series',
entityId: seriesExpanded.id,
entityUpdatedAt,
serverAddress,
feedURL: `/feed/${slug}`,
imageURL: firstBookWithCover?.coverPath ? `/feed/${slug}/cover${Path.extname(firstBookWithCover.coverPath)}` : `/Logo.png`,
siteURL: `/library/${booksWithTracks[0].libraryItem.libraryId}/series/${seriesExpanded.id}`,
title: seriesExpanded.name,
description: seriesExpanded.description || '',
author,
podcastType: 'serial',
explicit: booksWithTracks.some((book) => book.explicit), // If any book is explicit, the feed is explicit
coverPath: firstBookWithCover?.coverPath || null,
userId
}
if (feedOptions) {
feedObj.preventIndexing = feedOptions.preventIndexing
feedObj.ownerName = feedOptions.ownerName
feedObj.ownerEmail = feedOptions.ownerEmail
}
return {
feedObj,
booksWithTracks
}
}
/**
*
* @param {string} userId
* @param {import('./Series')} seriesExpanded
* @param {string} slug
* @param {string} serverAddress
* @param {FeedOptions} feedOptions
*
* @returns {Promise<FeedExpanded>}
*/
static async createFeedForSeries(userId, seriesExpanded, slug, serverAddress, feedOptions) {
const { feedObj, booksWithTracks } = this.getFeedObjForSeries(userId, seriesExpanded, slug, serverAddress, feedOptions)
/** @type {typeof import('./FeedEpisode')} */
const feedEpisodeModel = this.sequelize.models.feedEpisode
const transaction = await this.sequelize.transaction()
try {
const feed = await this.create(feedObj, { transaction })
feed.feedEpisodes = await feedEpisodeModel.createFromBooks(booksWithTracks, feed, slug, transaction)
await transaction.commit()
return feed
} catch (error) {
Logger.error(`[Feed] Error creating feed for series ${seriesExpanded.id}`, error)
await transaction.rollback()
return null
}
} }
/** /**
@@ -369,6 +463,192 @@ class Feed extends Model {
} }
}) })
} }
/**
*
* @returns {Promise<FeedExpanded>}
*/
async updateFeedForEntity() {
/** @type {typeof import('./FeedEpisode')} */
const feedEpisodeModel = this.sequelize.models.feedEpisode
let feedObj = null
let feedEpisodeCreateFunc = null
let feedEpisodeCreateFuncEntity = null
if (this.entityType === 'libraryItem') {
/** @type {typeof import('./LibraryItem')} */
const libraryItemModel = this.sequelize.models.libraryItem
const itemExpanded = await libraryItemModel.getExpandedById(this.entityId)
feedObj = Feed.getFeedObjForLibraryItem(this.userId, itemExpanded, this.slug, this.serverAddress)
feedEpisodeCreateFuncEntity = itemExpanded
if (itemExpanded.mediaType === 'podcast') {
feedEpisodeCreateFunc = feedEpisodeModel.createFromPodcastEpisodes.bind(feedEpisodeModel)
} else {
feedEpisodeCreateFunc = feedEpisodeModel.createFromAudiobookTracks.bind(feedEpisodeModel)
}
} else if (this.entityType === 'collection') {
/** @type {typeof import('./Collection')} */
const collectionModel = this.sequelize.models.collection
const collectionExpanded = await collectionModel.getExpandedById(this.entityId)
const feedObjData = Feed.getFeedObjForCollection(this.userId, collectionExpanded, this.slug, this.serverAddress)
feedObj = feedObjData.feedObj
feedEpisodeCreateFuncEntity = feedObjData.booksWithTracks
feedEpisodeCreateFunc = feedEpisodeModel.createFromBooks.bind(feedEpisodeModel)
} else if (this.entityType === 'series') {
/** @type {typeof import('./Series')} */
const seriesModel = this.sequelize.models.series
const seriesExpanded = await seriesModel.getExpandedById(this.entityId)
const feedObjData = Feed.getFeedObjForSeries(this.userId, seriesExpanded, this.slug, this.serverAddress)
feedObj = feedObjData.feedObj
feedEpisodeCreateFuncEntity = feedObjData.booksWithTracks
feedEpisodeCreateFunc = feedEpisodeModel.createFromBooks.bind(feedEpisodeModel)
} else {
Logger.error(`[Feed] Invalid entity type ${this.entityType} for feed ${this.id}`)
return null
}
const transaction = await this.sequelize.transaction()
try {
const updatedFeed = await this.update(feedObj, { transaction })
// Remove existing feed episodes
await feedEpisodeModel.destroy({
where: {
feedId: this.id
},
transaction
})
// Create new feed episodes
updatedFeed.feedEpisodes = await feedEpisodeCreateFunc(feedEpisodeCreateFuncEntity, updatedFeed, this.slug, transaction)
await transaction.commit()
return updatedFeed
} catch (error) {
Logger.error(`[Feed] Error updating feed ${this.entityId}`, error)
await transaction.rollback()
return null
}
}
getEntity(options) {
if (!this.entityType) return Promise.resolve(null)
const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.entityType)}`
return this[mixinMethodName](options)
}
/**
*
* @param {string} hostPrefix
*/
buildXml(hostPrefix) {
const blockTags = [{ 'itunes:block': 'yes' }, { 'googleplay:block': 'yes' }]
const rssData = {
title: this.title,
description: this.description || '',
generator: 'Audiobookshelf',
feed_url: `${hostPrefix}${this.feedURL}`,
site_url: `${hostPrefix}${this.siteURL}`,
image_url: `${hostPrefix}${this.imageURL}`,
custom_namespaces: {
itunes: 'http://www.itunes.com/dtds/podcast-1.0.dtd',
psc: 'http://podlove.org/simple-chapters',
podcast: 'https://podcastindex.org/namespace/1.0',
googleplay: 'http://www.google.com/schemas/play-podcasts/1.0'
},
custom_elements: [
{ language: this.language || 'en' },
{ author: this.author || 'advplyr' },
{ 'itunes:author': this.author || 'advplyr' },
{ 'itunes:summary': this.description || '' },
{ 'itunes:type': this.podcastType },
{
'itunes:image': {
_attr: {
href: `${hostPrefix}${this.imageURL}`
}
}
},
{
'itunes:owner': [{ 'itunes:name': this.ownerName || this.author || '' }, { 'itunes:email': this.ownerEmail || '' }]
},
{ 'itunes:explicit': !!this.explicit },
...(this.preventIndexing ? blockTags : [])
]
}
const rssfeed = new RSS(rssData)
this.feedEpisodes.forEach((ep) => {
rssfeed.item(ep.getRSSData(hostPrefix))
})
return rssfeed.xml()
}
/**
*
* @param {string} id
* @returns {string}
*/
getEpisodePath(id) {
const episode = this.feedEpisodes.find((ep) => ep.id === id)
if (!episode) return null
return episode.filePath
}
toOldJSON() {
const episodes = this.feedEpisodes?.map((feedEpisode) => feedEpisode.getOldEpisode())
return {
id: this.id,
slug: this.slug,
userId: this.userId,
entityType: this.entityType,
entityId: this.entityId,
entityUpdatedAt: this.entityUpdatedAt?.valueOf() || null,
coverPath: this.coverPath || null,
meta: {
title: this.title,
description: this.description,
author: this.author,
imageUrl: this.imageURL,
feedUrl: this.feedURL,
link: this.siteURL,
explicit: this.explicit,
type: this.podcastType,
language: this.language,
preventIndexing: this.preventIndexing,
ownerName: this.ownerName,
ownerEmail: this.ownerEmail
},
serverAddress: this.serverAddress,
feedUrl: this.feedURL,
episodes: episodes || [],
createdAt: this.createdAt.valueOf(),
updatedAt: this.updatedAt.valueOf()
}
}
toOldJSONMinified() {
return {
id: this.id,
entityType: this.entityType,
entityId: this.entityId,
feedUrl: this.feedURL,
meta: {
title: this.title,
description: this.description,
preventIndexing: this.preventIndexing,
ownerName: this.ownerName,
ownerEmail: this.ownerEmail
}
}
}
} }
module.exports = Feed module.exports = Feed
+212 -44
View File
@@ -1,4 +1,9 @@
const Path = require('path')
const { DataTypes, Model } = require('sequelize') const { DataTypes, Model } = require('sequelize')
const uuidv4 = require('uuid').v4
const Logger = require('../Logger')
const date = require('../libs/dateAndTime')
const { secondsToTimestamp } = require('../utils')
class FeedEpisode extends Model { class FeedEpisode extends Model {
constructor(values, options) { constructor(values, options) {
@@ -9,6 +14,8 @@ class FeedEpisode extends Model {
/** @type {string} */ /** @type {string} */
this.title this.title
/** @type {string} */ /** @type {string} */
this.author
/** @type {string} */
this.description this.description
/** @type {string} */ /** @type {string} */
this.siteURL this.siteURL
@@ -40,60 +47,167 @@ class FeedEpisode extends Model {
this.updatedAt this.updatedAt
} }
getOldEpisode() { /**
const enclosure = { *
url: this.enclosureURL, * @param {import('./LibraryItem').LibraryItemExpanded} libraryItemExpanded
size: this.enclosureSize, * @param {import('./Feed')} feed
type: this.enclosureType * @param {string} slug
} * @param {import('./PodcastEpisode')} episode
*/
static getFeedEpisodeObjFromPodcastEpisode(libraryItemExpanded, feed, slug, episode) {
const episodeId = uuidv4()
return { return {
id: this.id, id: episodeId,
title: this.title, title: episode.title,
description: this.description, author: feed.author,
enclosure, description: episode.description,
pubDate: this.pubDate, siteURL: feed.siteURL,
link: this.siteURL, enclosureURL: `/feed/${slug}/item/${episodeId}/media${Path.extname(episode.audioFile.metadata.filename)}`,
author: this.author, enclosureType: episode.audioFile.mimeType,
explicit: this.explicit, enclosureSize: episode.audioFile.metadata.size,
duration: this.duration, pubDate: episode.pubDate,
season: this.season, season: episode.season,
episode: this.episode, episode: episode.episode,
episodeType: this.episodeType, episodeType: episode.episodeType,
fullPath: this.filePath duration: episode.audioFile.duration,
filePath: episode.audioFile.metadata.path,
explicit: libraryItemExpanded.media.explicit,
feedId: feed.id
} }
} }
/** /**
* Create feed episode from old model
* *
* @param {string} feedId * @param {import('./LibraryItem').LibraryItemExpanded} libraryItemExpanded
* @param {Object} oldFeedEpisode * @param {import('./Feed')} feed
* @returns {Promise<FeedEpisode>} * @param {string} slug
* @param {import('sequelize').Transaction} transaction
* @returns {Promise<FeedEpisode[]>}
*/ */
static createFromOld(feedId, oldFeedEpisode) { static async createFromPodcastEpisodes(libraryItemExpanded, feed, slug, transaction) {
const newEpisode = this.getFromOld(oldFeedEpisode) const feedEpisodeObjs = []
newEpisode.feedId = feedId
return this.create(newEpisode) // Sort podcastEpisodes by pubDate. episodic is newest to oldest. serial is oldest to newest.
if (feed.podcastType === 'episodic') {
libraryItemExpanded.media.podcastEpisodes.sort((a, b) => new Date(b.pubDate) - new Date(a.pubDate))
} else {
libraryItemExpanded.media.podcastEpisodes.sort((a, b) => new Date(a.pubDate) - new Date(b.pubDate))
} }
static getFromOld(oldFeedEpisode) { for (const episode of libraryItemExpanded.media.podcastEpisodes) {
return { feedEpisodeObjs.push(this.getFeedEpisodeObjFromPodcastEpisode(libraryItemExpanded, feed, slug, episode))
id: oldFeedEpisode.id,
title: oldFeedEpisode.title,
author: oldFeedEpisode.author,
description: oldFeedEpisode.description,
siteURL: oldFeedEpisode.link,
enclosureURL: oldFeedEpisode.enclosure?.url || null,
enclosureType: oldFeedEpisode.enclosure?.type || null,
enclosureSize: oldFeedEpisode.enclosure?.size || null,
pubDate: oldFeedEpisode.pubDate,
season: oldFeedEpisode.season || null,
episode: oldFeedEpisode.episode || null,
episodeType: oldFeedEpisode.episodeType || null,
duration: oldFeedEpisode.duration,
filePath: oldFeedEpisode.fullPath,
explicit: !!oldFeedEpisode.explicit
} }
Logger.info(`[FeedEpisode] Creating ${feedEpisodeObjs.length} episodes for feed ${feed.id}`)
return this.bulkCreate(feedEpisodeObjs, { transaction })
}
/**
* If chapters for an audiobook match the audio tracks then use chapter titles instead of audio file names
*
* @param {import('./Book')} book
* @returns {boolean}
*/
static checkUseChapterTitlesForEpisodes(book) {
const tracks = book.trackList || []
const chapters = book.chapters || []
if (tracks.length !== chapters.length) return false
for (let i = 0; i < tracks.length; i++) {
if (Math.abs(chapters[i].start - tracks[i].startOffset) >= 1) {
return false
}
}
return true
}
/**
*
* @param {import('./Book')} book
* @param {Date} pubDateStart
* @param {import('./Feed')} feed
* @param {string} slug
* @param {import('./Book').AudioFileObject} audioTrack
* @param {boolean} useChapterTitles
*/
static getFeedEpisodeObjFromAudiobookTrack(book, pubDateStart, feed, slug, audioTrack, useChapterTitles) {
// Example: <pubDate>Fri, 04 Feb 2015 00:00:00 GMT</pubDate>
let timeOffset = isNaN(audioTrack.index) ? 0 : Number(audioTrack.index) * 1000 // Offset pubdate to ensure correct order
let episodeId = uuidv4()
// e.g. Track 1 will have a pub date before Track 2
const audiobookPubDate = date.format(new Date(pubDateStart.valueOf() + timeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]')
const contentUrl = `/feed/${slug}/item/${episodeId}/media${Path.extname(audioTrack.metadata.filename)}`
let title = audioTrack.title
if (book.trackList.length == 1) {
// If audiobook is a single file, use book title instead of chapter/file title
title = book.title
} else {
if (useChapterTitles) {
// If audio track start and chapter start are within 1 seconds of eachother then use the chapter title
const matchingChapter = book.chapters.find((ch) => Math.abs(ch.start - audioTrack.startOffset) < 1)
if (matchingChapter?.title) title = matchingChapter.title
}
}
return {
id: episodeId,
title,
author: feed.author,
description: book.description || '',
siteURL: feed.siteURL,
enclosureURL: contentUrl,
enclosureType: audioTrack.mimeType,
enclosureSize: audioTrack.metadata.size,
pubDate: audiobookPubDate,
duration: audioTrack.duration,
filePath: audioTrack.metadata.path,
explicit: book.explicit,
feedId: feed.id
}
}
/**
*
* @param {import('./LibraryItem').LibraryItemExpanded} libraryItemExpanded
* @param {import('./Feed')} feed
* @param {string} slug
* @param {import('sequelize').Transaction} transaction
* @returns {Promise<FeedEpisode[]>}
*/
static async createFromAudiobookTracks(libraryItemExpanded, feed, slug, transaction) {
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(libraryItemExpanded.media)
const feedEpisodeObjs = []
for (const track of libraryItemExpanded.media.trackList) {
feedEpisodeObjs.push(this.getFeedEpisodeObjFromAudiobookTrack(libraryItemExpanded.media, libraryItemExpanded.createdAt, feed, slug, track, useChapterTitles))
}
Logger.info(`[FeedEpisode] Creating ${feedEpisodeObjs.length} episodes for feed ${feed.id}`)
return this.bulkCreate(feedEpisodeObjs, { transaction })
}
/**
*
* @param {import('./Book')[]} books
* @param {import('./Feed')} feed
* @param {string} slug
* @param {import('sequelize').Transaction} transaction
* @returns {Promise<FeedEpisode[]>}
*/
static async createFromBooks(books, feed, slug, transaction) {
const earliestLibraryItemCreatedAt = books.reduce((earliest, book) => {
return book.libraryItem.createdAt < earliest.libraryItem.createdAt ? book : earliest
}).libraryItem.createdAt
const feedEpisodeObjs = []
for (const book of books) {
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(book)
for (const track of book.trackList) {
feedEpisodeObjs.push(this.getFeedEpisodeObjFromAudiobookTrack(book, earliestLibraryItemCreatedAt, feed, slug, track, useChapterTitles))
}
}
Logger.info(`[FeedEpisode] Creating ${feedEpisodeObjs.length} episodes for feed ${feed.id}`)
return this.bulkCreate(feedEpisodeObjs, { transaction })
} }
/** /**
@@ -136,6 +250,60 @@ class FeedEpisode extends Model {
}) })
FeedEpisode.belongsTo(feed) FeedEpisode.belongsTo(feed)
} }
getOldEpisode() {
const enclosure = {
url: this.enclosureURL,
size: this.enclosureSize,
type: this.enclosureType
}
return {
id: this.id,
title: this.title,
description: this.description,
enclosure,
pubDate: this.pubDate,
link: this.siteURL,
author: this.author,
explicit: this.explicit,
duration: this.duration,
season: this.season,
episode: this.episode,
episodeType: this.episodeType,
fullPath: this.filePath
}
}
/**
*
* @param {string} hostPrefix
*/
getRSSData(hostPrefix) {
return {
title: this.title,
description: this.description || '',
url: `${hostPrefix}${this.siteURL}`,
guid: `${hostPrefix}${this.enclosureURL}`,
author: this.author,
date: this.pubDate,
enclosure: {
url: `${hostPrefix}${this.enclosureURL}`,
type: this.enclosureType,
size: this.enclosureSize
},
custom_elements: [
{ 'itunes:author': this.author },
{ 'itunes:duration': secondsToTimestamp(this.duration) },
{ 'itunes:summary': this.description || '' },
{
'itunes:explicit': !!this.explicit
},
{ 'itunes:episodeType': this.episodeType },
{ 'itunes:season': this.season },
{ 'itunes:episode': this.episode }
]
}
}
} }
module.exports = FeedEpisode module.exports = FeedEpisode
-13
View File
@@ -107,19 +107,6 @@ class Library extends Model {
}) })
} }
/**
* Destroy library by id
* @param {string} libraryId
* @returns
*/
static removeById(libraryId) {
return this.destroy({
where: {
id: libraryId
}
})
}
/** /**
* Get all library ids * Get all library ids
* @returns {Promise<string[]>} array of library ids * @returns {Promise<string[]>} array of library ids
+22 -1
View File
@@ -73,6 +73,9 @@ class LibraryItem extends Model {
this.createdAt this.createdAt
/** @type {Date} */ /** @type {Date} */
this.updatedAt this.updatedAt
/** @type {Book.BookExpanded|Podcast.PodcastExpanded} - only set when expanded */
this.media
} }
/** /**
@@ -565,7 +568,7 @@ class LibraryItem extends Model {
oldLibraryItem.media.metadata.series = li.series oldLibraryItem.media.metadata.series = li.series
} }
if (li.rssFeed) { if (li.rssFeed) {
oldLibraryItem.rssFeed = this.sequelize.models.feed.getOldFeed(li.rssFeed).toJSONMinified() oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()
} }
if (li.media.numEpisodes) { if (li.media.numEpisodes) {
oldLibraryItem.media.numEpisodes = li.media.numEpisodes oldLibraryItem.media.numEpisodes = li.media.numEpisodes
@@ -1124,6 +1127,24 @@ class LibraryItem extends Model {
} }
}) })
} }
/**
* Check if book or podcast library item has audio tracks
* Requires expanded library item
*
* @returns {boolean}
*/
hasAudioTracks() {
if (!this.media) {
Logger.error(`[LibraryItem] hasAudioTracks: Library item "${this.id}" does not have media`)
return false
}
if (this.mediaType === 'book') {
return this.media.audioFiles?.length > 0
} else {
return this.media.podcastEpisodes?.length > 0
}
}
} }
module.exports = LibraryItem module.exports = LibraryItem
-7
View File
@@ -84,13 +84,6 @@ class Playlist extends Model {
const playlistExpanded = oldPlaylist.toJSONExpanded(libraryItems) const playlistExpanded = oldPlaylist.toJSONExpanded(libraryItems)
if (include?.includes('rssfeed')) {
const feeds = await this.getFeeds()
if (feeds?.length) {
playlistExpanded.rssFeed = this.sequelize.models.feed.getOldFeed(feeds[0])
}
}
return playlistExpanded return playlistExpanded
} }
+47 -1
View File
@@ -1,4 +1,4 @@
const { DataTypes, Model, where, fn, col } = require('sequelize') const { DataTypes, Model, where, fn, col, literal } = require('sequelize')
const { getTitlePrefixAtEnd } = require('../utils/index') const { getTitlePrefixAtEnd } = require('../utils/index')
@@ -20,6 +20,11 @@ class Series extends Model {
this.createdAt this.createdAt
/** @type {Date} */ /** @type {Date} */
this.updatedAt this.updatedAt
// Expanded properties
/** @type {import('./Book').BookExpandedWithLibraryItem[]} - only set when expanded */
this.books
} }
/** /**
@@ -49,6 +54,18 @@ class Series extends Model {
}) })
} }
/**
*
* @param {string} seriesId
* @returns {Promise<Series>}
*/
static async getExpandedById(seriesId) {
const series = await this.findByPk(seriesId)
if (!series) return null
series.books = await series.getBooksExpandedWithLibraryItem()
return series
}
/** /**
* Initialize model * Initialize model
* @param {import('../Database').sequelize} sequelize * @param {import('../Database').sequelize} sequelize
@@ -103,6 +120,35 @@ class Series extends Model {
Series.belongsTo(library) Series.belongsTo(library)
} }
/**
* Get all books in collection expanded with library item
*
* @returns {Promise<import('./Book').BookExpandedWithLibraryItem[]>}
*/
getBooksExpandedWithLibraryItem() {
return this.getBooks({
joinTableAttributes: ['sequence'],
include: [
{
model: this.sequelize.models.libraryItem
},
{
model: this.sequelize.models.author,
through: {
attributes: []
}
},
{
model: this.sequelize.models.series,
through: {
attributes: ['sequence']
}
}
],
order: [[literal('CAST(`bookSeries.sequence` AS FLOAT) ASC NULLS LAST')]]
})
}
toOldJSON() { toOldJSON() {
return { return {
id: this.id, id: this.id,
+1 -1
View File
@@ -611,7 +611,7 @@ class User extends Model {
*/ */
getOldMediaProgress(libraryItemId, episodeId = null) { getOldMediaProgress(libraryItemId, episodeId = null) {
const mediaProgress = this.mediaProgresses?.find((mp) => { const mediaProgress = this.mediaProgresses?.find((mp) => {
if (episodeId && mp.mediaItemId === episodeId) return true if (episodeId && mp.mediaItemId !== episodeId) return false
return mp.extraData?.libraryItemId === libraryItemId return mp.extraData?.libraryItemId === libraryItemId
}) })
return mediaProgress?.getOldMediaProgress() || null return mediaProgress?.getOldMediaProgress() || null
-427
View File
@@ -1,427 +0,0 @@
const Path = require('path')
const uuidv4 = require('uuid').v4
const FeedMeta = require('./FeedMeta')
const FeedEpisode = require('./FeedEpisode')
const date = require('../libs/dateAndTime')
const RSS = require('../libs/rss')
const { createNewSortInstance } = require('../libs/fastSort')
const naturalSort = createNewSortInstance({
comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
})
class Feed {
constructor(feed) {
this.id = null
this.slug = null
this.userId = null
this.entityType = null
this.entityId = null
this.entityUpdatedAt = null
this.coverPath = null
this.serverAddress = null
this.feedUrl = null
this.meta = null
this.episodes = null
this.createdAt = null
this.updatedAt = null
// Cached xml
this.xml = null
if (feed) {
this.construct(feed)
}
}
construct(feed) {
this.id = feed.id
this.slug = feed.slug
this.userId = feed.userId
this.entityType = feed.entityType
this.entityId = feed.entityId
this.entityUpdatedAt = feed.entityUpdatedAt
this.coverPath = feed.coverPath
this.serverAddress = feed.serverAddress
this.feedUrl = feed.feedUrl
this.meta = new FeedMeta(feed.meta)
this.episodes = feed.episodes.map((ep) => new FeedEpisode(ep))
this.createdAt = feed.createdAt
this.updatedAt = feed.updatedAt
}
toJSON() {
return {
id: this.id,
slug: this.slug,
userId: this.userId,
entityType: this.entityType,
entityId: this.entityId,
coverPath: this.coverPath,
serverAddress: this.serverAddress,
feedUrl: this.feedUrl,
meta: this.meta.toJSON(),
episodes: this.episodes.map((ep) => ep.toJSON()),
createdAt: this.createdAt,
updatedAt: this.updatedAt
}
}
toJSONMinified() {
return {
id: this.id,
entityType: this.entityType,
entityId: this.entityId,
feedUrl: this.feedUrl,
meta: this.meta.toJSONMinified()
}
}
getEpisodePath(id) {
var episode = this.episodes.find((ep) => ep.id === id)
if (!episode) return null
return episode.fullPath
}
/**
* If chapters for an audiobook match the audio tracks then use chapter titles instead of audio file names
*
* @param {import('../objects/LibraryItem')} libraryItem
* @returns {boolean}
*/
checkUseChapterTitlesForEpisodes(libraryItem) {
const tracks = libraryItem.media.tracks
const chapters = libraryItem.media.chapters
if (tracks.length !== chapters.length) return false
for (let i = 0; i < tracks.length; i++) {
if (Math.abs(chapters[i].start - tracks[i].startOffset) >= 1) {
return false
}
}
return true
}
setFromItem(userId, slug, libraryItem, serverAddress, preventIndexing = true, ownerName = null, ownerEmail = null) {
const media = libraryItem.media
const mediaMetadata = media.metadata
const isPodcast = libraryItem.mediaType === 'podcast'
const feedUrl = `${serverAddress}/feed/${slug}`
const author = isPodcast ? mediaMetadata.author : mediaMetadata.authorName
this.id = uuidv4()
this.slug = slug
this.userId = userId
this.entityType = 'libraryItem'
this.entityId = libraryItem.id
this.entityUpdatedAt = libraryItem.updatedAt
this.coverPath = media.coverPath || null
this.serverAddress = serverAddress
this.feedUrl = feedUrl
const coverFileExtension = this.coverPath ? Path.extname(media.coverPath) : null
this.meta = new FeedMeta()
this.meta.title = mediaMetadata.title
this.meta.description = mediaMetadata.description
this.meta.author = author
this.meta.imageUrl = media.coverPath ? `${serverAddress}/feed/${slug}/cover${coverFileExtension}` : `${serverAddress}/Logo.png`
this.meta.feedUrl = feedUrl
this.meta.link = `${serverAddress}/item/${libraryItem.id}`
this.meta.explicit = !!mediaMetadata.explicit
this.meta.type = mediaMetadata.type
this.meta.language = mediaMetadata.language
this.meta.preventIndexing = preventIndexing
this.meta.ownerName = ownerName
this.meta.ownerEmail = ownerEmail
this.episodes = []
if (isPodcast) {
// PODCAST EPISODES
media.episodes.forEach((episode) => {
if (episode.updatedAt > this.entityUpdatedAt) this.entityUpdatedAt = episode.updatedAt
const feedEpisode = new FeedEpisode()
feedEpisode.setFromPodcastEpisode(libraryItem, serverAddress, slug, episode, this.meta)
this.episodes.push(feedEpisode)
})
} else {
// AUDIOBOOK EPISODES
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(libraryItem)
media.tracks.forEach((audioTrack) => {
const feedEpisode = new FeedEpisode()
feedEpisode.setFromAudiobookTrack(libraryItem, serverAddress, slug, audioTrack, this.meta, useChapterTitles)
this.episodes.push(feedEpisode)
})
}
this.createdAt = Date.now()
this.updatedAt = Date.now()
}
updateFromItem(libraryItem) {
const media = libraryItem.media
const mediaMetadata = media.metadata
const isPodcast = libraryItem.mediaType === 'podcast'
const author = isPodcast ? mediaMetadata.author : mediaMetadata.authorName
this.entityUpdatedAt = libraryItem.updatedAt
this.coverPath = media.coverPath || null
const coverFileExtension = this.coverPath ? Path.extname(media.coverPath) : null
this.meta.title = mediaMetadata.title
this.meta.description = mediaMetadata.description
this.meta.author = author
this.meta.imageUrl = media.coverPath ? `${this.serverAddress}/feed/${this.slug}/cover${coverFileExtension}` : `${this.serverAddress}/Logo.png`
this.meta.explicit = !!mediaMetadata.explicit
this.meta.type = mediaMetadata.type
this.meta.language = mediaMetadata.language
this.episodes = []
if (isPodcast) {
// PODCAST EPISODES
media.episodes.forEach((episode) => {
if (episode.updatedAt > this.entityUpdatedAt) this.entityUpdatedAt = episode.updatedAt
const feedEpisode = new FeedEpisode()
feedEpisode.setFromPodcastEpisode(libraryItem, this.serverAddress, this.slug, episode, this.meta)
this.episodes.push(feedEpisode)
})
} else {
// AUDIOBOOK EPISODES
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(libraryItem)
media.tracks.forEach((audioTrack) => {
const feedEpisode = new FeedEpisode()
feedEpisode.setFromAudiobookTrack(libraryItem, this.serverAddress, this.slug, audioTrack, this.meta, useChapterTitles)
this.episodes.push(feedEpisode)
})
}
this.updatedAt = Date.now()
this.xml = null
}
setFromCollection(userId, slug, collectionExpanded, serverAddress, preventIndexing = true, ownerName = null, ownerEmail = null) {
const feedUrl = `${serverAddress}/feed/${slug}`
const itemsWithTracks = collectionExpanded.books.filter((libraryItem) => libraryItem.media.tracks.length)
const firstItemWithCover = itemsWithTracks.find((item) => item.media.coverPath)
this.id = uuidv4()
this.slug = slug
this.userId = userId
this.entityType = 'collection'
this.entityId = collectionExpanded.id
this.entityUpdatedAt = collectionExpanded.lastUpdate // This will be set to the most recently updated library item
this.coverPath = firstItemWithCover?.media.coverPath || null
this.serverAddress = serverAddress
this.feedUrl = feedUrl
const coverFileExtension = this.coverPath ? Path.extname(this.coverPath) : null
this.meta = new FeedMeta()
this.meta.title = collectionExpanded.name
this.meta.description = collectionExpanded.description || ''
this.meta.author = this.getAuthorsStringFromLibraryItems(itemsWithTracks)
this.meta.imageUrl = this.coverPath ? `${serverAddress}/feed/${slug}/cover${coverFileExtension}` : `${serverAddress}/Logo.png`
this.meta.feedUrl = feedUrl
this.meta.link = `${serverAddress}/collection/${collectionExpanded.id}`
this.meta.explicit = !!itemsWithTracks.some((li) => li.media.metadata.explicit) // explicit if any item is explicit
this.meta.preventIndexing = preventIndexing
this.meta.ownerName = ownerName
this.meta.ownerEmail = ownerEmail
this.episodes = []
// Used for calculating pubdate
const earliestItemAddedAt = itemsWithTracks.reduce((earliest, item) => (item.addedAt < earliest ? item.addedAt : earliest), itemsWithTracks[0].addedAt)
itemsWithTracks.forEach((item, index) => {
if (item.updatedAt > this.entityUpdatedAt) this.entityUpdatedAt = item.updatedAt
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(item)
item.media.tracks.forEach((audioTrack) => {
const feedEpisode = new FeedEpisode()
// Offset pubdate to ensure correct order
let trackTimeOffset = isNaN(audioTrack.index) ? 0 : Number(audioTrack.index) * 1000 // Offset track
trackTimeOffset += index * 1000 // Offset item
const episodePubDateOverride = date.format(new Date(earliestItemAddedAt + trackTimeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]')
feedEpisode.setFromAudiobookTrack(item, serverAddress, slug, audioTrack, this.meta, useChapterTitles, episodePubDateOverride)
this.episodes.push(feedEpisode)
})
})
this.createdAt = Date.now()
this.updatedAt = Date.now()
}
updateFromCollection(collectionExpanded) {
const itemsWithTracks = collectionExpanded.books.filter((libraryItem) => libraryItem.media.tracks.length)
const firstItemWithCover = itemsWithTracks.find((item) => item.media.coverPath)
this.entityUpdatedAt = collectionExpanded.lastUpdate
this.coverPath = firstItemWithCover?.media.coverPath || null
const coverFileExtension = this.coverPath ? Path.extname(this.coverPath) : null
this.meta.title = collectionExpanded.name
this.meta.description = collectionExpanded.description || ''
this.meta.author = this.getAuthorsStringFromLibraryItems(itemsWithTracks)
this.meta.imageUrl = this.coverPath ? `${this.serverAddress}/feed/${this.slug}/cover${coverFileExtension}` : `${this.serverAddress}/Logo.png`
this.meta.explicit = !!itemsWithTracks.some((li) => li.media.metadata.explicit) // explicit if any item is explicit
this.episodes = []
// Used for calculating pubdate
const earliestItemAddedAt = itemsWithTracks.reduce((earliest, item) => (item.addedAt < earliest ? item.addedAt : earliest), itemsWithTracks[0].addedAt)
itemsWithTracks.forEach((item, index) => {
if (item.updatedAt > this.entityUpdatedAt) this.entityUpdatedAt = item.updatedAt
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(item)
item.media.tracks.forEach((audioTrack) => {
const feedEpisode = new FeedEpisode()
// Offset pubdate to ensure correct order
let trackTimeOffset = isNaN(audioTrack.index) ? 0 : Number(audioTrack.index) * 1000 // Offset track
trackTimeOffset += index * 1000 // Offset item
const episodePubDateOverride = date.format(new Date(earliestItemAddedAt + trackTimeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]')
feedEpisode.setFromAudiobookTrack(item, this.serverAddress, this.slug, audioTrack, this.meta, useChapterTitles, episodePubDateOverride)
this.episodes.push(feedEpisode)
})
})
this.updatedAt = Date.now()
this.xml = null
}
setFromSeries(userId, slug, seriesExpanded, serverAddress, preventIndexing = true, ownerName = null, ownerEmail = null) {
const feedUrl = `${serverAddress}/feed/${slug}`
let itemsWithTracks = seriesExpanded.books.filter((libraryItem) => libraryItem.media.tracks.length)
// Sort series items by series sequence
itemsWithTracks = naturalSort(itemsWithTracks).asc((li) => li.media.metadata.getSeriesSequence(seriesExpanded.id))
const libraryId = itemsWithTracks[0].libraryId
const firstItemWithCover = itemsWithTracks.find((li) => li.media.coverPath)
this.id = uuidv4()
this.slug = slug
this.userId = userId
this.entityType = 'series'
this.entityId = seriesExpanded.id
this.entityUpdatedAt = seriesExpanded.updatedAt // This will be set to the most recently updated library item
this.coverPath = firstItemWithCover?.media.coverPath || null
this.serverAddress = serverAddress
this.feedUrl = feedUrl
const coverFileExtension = this.coverPath ? Path.extname(this.coverPath) : null
this.meta = new FeedMeta()
this.meta.title = seriesExpanded.name
this.meta.description = seriesExpanded.description || ''
this.meta.author = this.getAuthorsStringFromLibraryItems(itemsWithTracks)
this.meta.imageUrl = this.coverPath ? `${serverAddress}/feed/${slug}/cover${coverFileExtension}` : `${serverAddress}/Logo.png`
this.meta.feedUrl = feedUrl
this.meta.link = `${serverAddress}/library/${libraryId}/series/${seriesExpanded.id}`
this.meta.explicit = !!itemsWithTracks.some((li) => li.media.metadata.explicit) // explicit if any item is explicit
this.meta.preventIndexing = preventIndexing
this.meta.ownerName = ownerName
this.meta.ownerEmail = ownerEmail
this.episodes = []
// Used for calculating pubdate
const earliestItemAddedAt = itemsWithTracks.reduce((earliest, item) => (item.addedAt < earliest ? item.addedAt : earliest), itemsWithTracks[0].addedAt)
itemsWithTracks.forEach((item, index) => {
if (item.updatedAt > this.entityUpdatedAt) this.entityUpdatedAt = item.updatedAt
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(item)
item.media.tracks.forEach((audioTrack) => {
const feedEpisode = new FeedEpisode()
// Offset pubdate to ensure correct order
let trackTimeOffset = isNaN(audioTrack.index) ? 0 : Number(audioTrack.index) * 1000 // Offset track
trackTimeOffset += index * 1000 // Offset item
const episodePubDateOverride = date.format(new Date(earliestItemAddedAt + trackTimeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]')
feedEpisode.setFromAudiobookTrack(item, serverAddress, slug, audioTrack, this.meta, useChapterTitles, episodePubDateOverride)
this.episodes.push(feedEpisode)
})
})
this.createdAt = Date.now()
this.updatedAt = Date.now()
}
updateFromSeries(seriesExpanded) {
let itemsWithTracks = seriesExpanded.books.filter((libraryItem) => libraryItem.media.tracks.length)
// Sort series items by series sequence
itemsWithTracks = naturalSort(itemsWithTracks).asc((li) => li.media.metadata.getSeriesSequence(seriesExpanded.id))
const firstItemWithCover = itemsWithTracks.find((item) => item.media.coverPath)
this.entityUpdatedAt = seriesExpanded.updatedAt
this.coverPath = firstItemWithCover?.media.coverPath || null
const coverFileExtension = this.coverPath ? Path.extname(this.coverPath) : null
this.meta.title = seriesExpanded.name
this.meta.description = seriesExpanded.description || ''
this.meta.author = this.getAuthorsStringFromLibraryItems(itemsWithTracks)
this.meta.imageUrl = this.coverPath ? `${this.serverAddress}/feed/${this.slug}/cover${coverFileExtension}` : `${this.serverAddress}/Logo.png`
this.meta.explicit = !!itemsWithTracks.some((li) => li.media.metadata.explicit) // explicit if any item is explicit
this.episodes = []
// Used for calculating pubdate
const earliestItemAddedAt = itemsWithTracks.reduce((earliest, item) => (item.addedAt < earliest ? item.addedAt : earliest), itemsWithTracks[0].addedAt)
itemsWithTracks.forEach((item, index) => {
if (item.updatedAt > this.entityUpdatedAt) this.entityUpdatedAt = item.updatedAt
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(item)
item.media.tracks.forEach((audioTrack) => {
const feedEpisode = new FeedEpisode()
// Offset pubdate to ensure correct order
let trackTimeOffset = isNaN(audioTrack.index) ? 0 : Number(audioTrack.index) * 1000 // Offset track
trackTimeOffset += index * 1000 // Offset item
const episodePubDateOverride = date.format(new Date(earliestItemAddedAt + trackTimeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]')
feedEpisode.setFromAudiobookTrack(item, this.serverAddress, this.slug, audioTrack, this.meta, useChapterTitles, episodePubDateOverride)
this.episodes.push(feedEpisode)
})
})
this.updatedAt = Date.now()
this.xml = null
}
buildXml() {
if (this.xml) return this.xml
var rssfeed = new RSS(this.meta.getRSSData())
this.episodes.forEach((ep) => {
rssfeed.item(ep.getRSSData())
})
this.xml = rssfeed.xml()
return this.xml
}
getAuthorsStringFromLibraryItems(libraryItems) {
let itemAuthors = []
libraryItems.forEach((item) => itemAuthors.push(...item.media.metadata.authors.map((au) => au.name)))
itemAuthors = [...new Set(itemAuthors)] // Filter out dupes
let author = itemAuthors.slice(0, 3).join(', ')
if (itemAuthors.length > 3) {
author += ' & more'
}
return author
}
}
module.exports = Feed
-177
View File
@@ -1,177 +0,0 @@
const Path = require('path')
const uuidv4 = require('uuid').v4
const date = require('../libs/dateAndTime')
const { secondsToTimestamp } = require('../utils/index')
class FeedEpisode {
constructor(episode) {
this.id = null
this.title = null
this.description = null
this.enclosure = null
this.pubDate = null
this.link = null
this.author = null
this.explicit = null
this.duration = null
this.season = null
this.episode = null
this.episodeType = null
this.libraryItemId = null
this.episodeId = null
this.trackIndex = null
this.fullPath = null
if (episode) {
this.construct(episode)
}
}
construct(episode) {
this.id = episode.id
this.title = episode.title
this.description = episode.description
this.enclosure = episode.enclosure ? { ...episode.enclosure } : null
this.pubDate = episode.pubDate
this.link = episode.link
this.author = episode.author
this.explicit = episode.explicit
this.duration = episode.duration
this.season = episode.season
this.episode = episode.episode
this.episodeType = episode.episodeType
this.libraryItemId = episode.libraryItemId
this.episodeId = episode.episodeId || null
this.trackIndex = episode.trackIndex || 0
this.fullPath = episode.fullPath
}
toJSON() {
return {
id: this.id,
title: this.title,
description: this.description,
enclosure: this.enclosure ? { ...this.enclosure } : null,
pubDate: this.pubDate,
link: this.link,
author: this.author,
explicit: this.explicit,
duration: this.duration,
season: this.season,
episode: this.episode,
episodeType: this.episodeType,
libraryItemId: this.libraryItemId,
episodeId: this.episodeId,
trackIndex: this.trackIndex,
fullPath: this.fullPath
}
}
setFromPodcastEpisode(libraryItem, serverAddress, slug, episode, meta) {
const contentFileExtension = Path.extname(episode.audioFile.metadata.filename)
const contentUrl = `/feed/${slug}/item/${episode.id}/media${contentFileExtension}`
const media = libraryItem.media
const mediaMetadata = media.metadata
this.id = episode.id
this.title = episode.title
this.description = episode.description || ''
this.enclosure = {
url: `${serverAddress}${contentUrl}`,
type: episode.audioTrack.mimeType,
size: episode.size
}
this.pubDate = episode.pubDate
this.link = meta.link
this.author = meta.author
this.explicit = mediaMetadata.explicit
this.duration = episode.duration
this.season = episode.season
this.episode = episode.episode
this.episodeType = episode.episodeType
this.libraryItemId = libraryItem.id
this.episodeId = episode.id
this.trackIndex = 0
this.fullPath = episode.audioFile.metadata.path
}
/**
*
* @param {import('../objects/LibraryItem')} libraryItem
* @param {string} serverAddress
* @param {string} slug
* @param {import('../objects/files/AudioTrack')} audioTrack
* @param {Object} meta
* @param {boolean} useChapterTitles
* @param {string} [pubDateOverride] Used for series & collections to ensure correct episode order
*/
setFromAudiobookTrack(libraryItem, serverAddress, slug, audioTrack, meta, useChapterTitles, pubDateOverride = null) {
// Example: <pubDate>Fri, 04 Feb 2015 00:00:00 GMT</pubDate>
let timeOffset = isNaN(audioTrack.index) ? 0 : Number(audioTrack.index) * 1000 // Offset pubdate to ensure correct order
let episodeId = uuidv4()
// e.g. Track 1 will have a pub date before Track 2
const audiobookPubDate = pubDateOverride || date.format(new Date(libraryItem.addedAt + timeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]')
const contentFileExtension = Path.extname(audioTrack.metadata.filename)
const contentUrl = `/feed/${slug}/item/${episodeId}/media${contentFileExtension}`
const media = libraryItem.media
const mediaMetadata = media.metadata
let title = audioTrack.title
if (libraryItem.media.tracks.length == 1) {
// If audiobook is a single file, use book title instead of chapter/file title
title = libraryItem.media.metadata.title
} else {
if (useChapterTitles) {
// If audio track start and chapter start are within 1 seconds of eachother then use the chapter title
const matchingChapter = libraryItem.media.chapters.find((ch) => Math.abs(ch.start - audioTrack.startOffset) < 1)
if (matchingChapter?.title) title = matchingChapter.title
}
}
this.id = episodeId
this.title = title
this.description = mediaMetadata.description || ''
this.enclosure = {
url: `${serverAddress}${contentUrl}`,
type: audioTrack.mimeType,
size: audioTrack.metadata.size
}
this.pubDate = audiobookPubDate
this.link = meta.link
this.author = meta.author
this.explicit = mediaMetadata.explicit
this.duration = audioTrack.duration
this.libraryItemId = libraryItem.id
this.episodeId = null
this.trackIndex = audioTrack.index
this.fullPath = audioTrack.metadata.path
}
getRSSData() {
return {
title: this.title,
description: this.description || '',
url: this.link,
guid: this.enclosure.url,
author: this.author,
date: this.pubDate,
enclosure: this.enclosure,
custom_elements: [
{ 'itunes:author': this.author },
{ 'itunes:duration': secondsToTimestamp(this.duration) },
{ 'itunes:summary': this.description || '' },
{
'itunes:explicit': !!this.explicit
},
{ 'itunes:episodeType': this.episodeType },
{ 'itunes:season': this.season },
{ 'itunes:episode': this.episode }
]
}
}
}
module.exports = FeedEpisode
-106
View File
@@ -1,106 +0,0 @@
class FeedMeta {
constructor(meta) {
this.title = null
this.description = null
this.author = null
this.imageUrl = null
this.feedUrl = null
this.link = null
this.explicit = null
this.type = null
this.language = null
this.preventIndexing = null
this.ownerName = null
this.ownerEmail = null
if (meta) {
this.construct(meta)
}
}
construct(meta) {
this.title = meta.title
this.description = meta.description
this.author = meta.author
this.imageUrl = meta.imageUrl
this.feedUrl = meta.feedUrl
this.link = meta.link
this.explicit = meta.explicit
this.type = meta.type
this.language = meta.language
this.preventIndexing = meta.preventIndexing
this.ownerName = meta.ownerName
this.ownerEmail = meta.ownerEmail
}
toJSON() {
return {
title: this.title,
description: this.description,
author: this.author,
imageUrl: this.imageUrl,
feedUrl: this.feedUrl,
link: this.link,
explicit: this.explicit,
type: this.type,
language: this.language,
preventIndexing: this.preventIndexing,
ownerName: this.ownerName,
ownerEmail: this.ownerEmail
}
}
toJSONMinified() {
return {
title: this.title,
description: this.description,
preventIndexing: this.preventIndexing,
ownerName: this.ownerName,
ownerEmail: this.ownerEmail
}
}
getRSSData() {
const blockTags = [
{ 'itunes:block': 'yes' },
{ 'googleplay:block': 'yes' }
]
return {
title: this.title,
description: this.description || '',
generator: 'Audiobookshelf',
feed_url: this.feedUrl,
site_url: this.link,
image_url: this.imageUrl,
custom_namespaces: {
'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd',
'psc': 'http://podlove.org/simple-chapters',
'podcast': 'https://podcastindex.org/namespace/1.0',
'googleplay': 'http://www.google.com/schemas/play-podcasts/1.0'
},
custom_elements: [
{ 'language': this.language || 'en' },
{ 'author': this.author || 'advplyr' },
{ 'itunes:author': this.author || 'advplyr' },
{ 'itunes:summary': this.description || '' },
{ 'itunes:type': this.type },
{
'itunes:image': {
_attr: {
href: this.imageUrl
}
}
},
{
'itunes:owner': [
{ 'itunes:name': this.ownerName || this.author || '' },
{ 'itunes:email': this.ownerEmail || '' }
]
},
{ 'itunes:explicit': !!this.explicit },
...(this.preventIndexing ? blockTags : [])
]
}
}
}
module.exports = FeedMeta
+1 -1
View File
@@ -262,7 +262,7 @@ class LibraryItem {
* @returns {Promise<LibraryFile>} null if not saved * @returns {Promise<LibraryFile>} null if not saved
*/ */
async saveMetadata() { async saveMetadata() {
if (this.isSavingMetadata) return null if (this.isSavingMetadata || !global.MetadataPath) return null
this.isSavingMetadata = true this.isSavingMetadata = true
+14
View File
@@ -53,6 +53,20 @@ class PodcastEpisodeDownload {
if (globals.SupportedAudioTypes.includes(extname)) return extname if (globals.SupportedAudioTypes.includes(extname)) return extname
return 'mp3' return 'mp3'
} }
get enclosureType() {
const enclosureType = this.podcastEpisode?.enclosure?.type
return typeof enclosureType === 'string' ? enclosureType : null
}
/**
* RSS feed may have an episode with file extension of mp3 but the specified enclosure type is not mpeg.
* @see https://github.com/advplyr/audiobookshelf/issues/3711
*
* @returns {boolean}
*/
get isMp3() {
if (this.enclosureType && !this.enclosureType.includes('mpeg')) return false
return this.fileExtension === 'mp3'
}
get targetFilename() { get targetFilename() {
const appendage = this.appendEpisodeId ? ` (${this.podcastEpisode.id})` : '' const appendage = this.appendEpisodeId ? ` (${this.podcastEpisode.id})` : ''
+13 -1
View File
@@ -24,6 +24,7 @@ class ServerSettings {
// Security/Rate limits // Security/Rate limits
this.rateLimitLoginRequests = 10 this.rateLimitLoginRequests = 10
this.rateLimitLoginWindow = 10 * 60 * 1000 // 10 Minutes this.rateLimitLoginWindow = 10 * 60 * 1000 // 10 Minutes
this.allowIframe = false
// Backups // Backups
this.backupPath = Path.join(global.MetadataPath, 'backups') this.backupPath = Path.join(global.MetadataPath, 'backups')
@@ -78,6 +79,7 @@ class ServerSettings {
this.authOpenIDMobileRedirectURIs = ['audiobookshelf://oauth'] this.authOpenIDMobileRedirectURIs = ['audiobookshelf://oauth']
this.authOpenIDGroupClaim = '' this.authOpenIDGroupClaim = ''
this.authOpenIDAdvancedPermsClaim = '' this.authOpenIDAdvancedPermsClaim = ''
this.authOpenIDSubfolderForRedirectURLs = undefined
if (settings) { if (settings) {
this.construct(settings) this.construct(settings)
@@ -98,6 +100,7 @@ class ServerSettings {
this.rateLimitLoginRequests = !isNaN(settings.rateLimitLoginRequests) ? Number(settings.rateLimitLoginRequests) : 10 this.rateLimitLoginRequests = !isNaN(settings.rateLimitLoginRequests) ? Number(settings.rateLimitLoginRequests) : 10
this.rateLimitLoginWindow = !isNaN(settings.rateLimitLoginWindow) ? Number(settings.rateLimitLoginWindow) : 10 * 60 * 1000 // 10 Minutes this.rateLimitLoginWindow = !isNaN(settings.rateLimitLoginWindow) ? Number(settings.rateLimitLoginWindow) : 10 * 60 * 1000 // 10 Minutes
this.allowIframe = !!settings.allowIframe
this.backupPath = settings.backupPath || Path.join(global.MetadataPath, 'backups') this.backupPath = settings.backupPath || Path.join(global.MetadataPath, 'backups')
this.backupSchedule = settings.backupSchedule || false this.backupSchedule = settings.backupSchedule || false
@@ -139,6 +142,7 @@ class ServerSettings {
this.authOpenIDMobileRedirectURIs = settings.authOpenIDMobileRedirectURIs || ['audiobookshelf://oauth'] this.authOpenIDMobileRedirectURIs = settings.authOpenIDMobileRedirectURIs || ['audiobookshelf://oauth']
this.authOpenIDGroupClaim = settings.authOpenIDGroupClaim || '' this.authOpenIDGroupClaim = settings.authOpenIDGroupClaim || ''
this.authOpenIDAdvancedPermsClaim = settings.authOpenIDAdvancedPermsClaim || '' this.authOpenIDAdvancedPermsClaim = settings.authOpenIDAdvancedPermsClaim || ''
this.authOpenIDSubfolderForRedirectURLs = settings.authOpenIDSubfolderForRedirectURLs
if (!Array.isArray(this.authActiveAuthMethods)) { if (!Array.isArray(this.authActiveAuthMethods)) {
this.authActiveAuthMethods = ['local'] this.authActiveAuthMethods = ['local']
@@ -188,6 +192,11 @@ class ServerSettings {
Logger.info(`[ServerSettings] Using backup path from environment variable ${process.env.BACKUP_PATH}`) Logger.info(`[ServerSettings] Using backup path from environment variable ${process.env.BACKUP_PATH}`)
this.backupPath = process.env.BACKUP_PATH this.backupPath = process.env.BACKUP_PATH
} }
if (process.env.ALLOW_IFRAME === '1' && !this.allowIframe) {
Logger.info(`[ServerSettings] Using allowIframe from environment variable`)
this.allowIframe = true
}
} }
toJSON() { toJSON() {
@@ -205,6 +214,7 @@ class ServerSettings {
metadataFileFormat: this.metadataFileFormat, metadataFileFormat: this.metadataFileFormat,
rateLimitLoginRequests: this.rateLimitLoginRequests, rateLimitLoginRequests: this.rateLimitLoginRequests,
rateLimitLoginWindow: this.rateLimitLoginWindow, rateLimitLoginWindow: this.rateLimitLoginWindow,
allowIframe: this.allowIframe,
backupPath: this.backupPath, backupPath: this.backupPath,
backupSchedule: this.backupSchedule, backupSchedule: this.backupSchedule,
backupsToKeep: this.backupsToKeep, backupsToKeep: this.backupsToKeep,
@@ -240,7 +250,8 @@ class ServerSettings {
authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy, authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy,
authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs, // Do not return to client authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs, // Do not return to client
authOpenIDGroupClaim: this.authOpenIDGroupClaim, // Do not return to client authOpenIDGroupClaim: this.authOpenIDGroupClaim, // Do not return to client
authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim // Do not return to client authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim, // Do not return to client
authOpenIDSubfolderForRedirectURLs: this.authOpenIDSubfolderForRedirectURLs
} }
} }
@@ -286,6 +297,7 @@ class ServerSettings {
authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs, // Do not return to client authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs, // Do not return to client
authOpenIDGroupClaim: this.authOpenIDGroupClaim, // Do not return to client authOpenIDGroupClaim: this.authOpenIDGroupClaim, // Do not return to client
authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim, // Do not return to client authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim, // Do not return to client
authOpenIDSubfolderForRedirectURLs: this.authOpenIDSubfolderForRedirectURLs,
authOpenIDSamplePermissions: User.getSampleAbsPermissions() authOpenIDSamplePermissions: User.getSampleAbsPermissions()
} }
+22 -50
View File
@@ -10,6 +10,7 @@ const fs = require('../libs/fsExtra')
const date = require('../libs/dateAndTime') const date = require('../libs/dateAndTime')
const CacheManager = require('../managers/CacheManager') const CacheManager = require('../managers/CacheManager')
const RssFeedManager = require('../managers/RssFeedManager')
const LibraryController = require('../controllers/LibraryController') const LibraryController = require('../controllers/LibraryController')
const UserController = require('../controllers/UserController') const UserController = require('../controllers/UserController')
@@ -49,8 +50,6 @@ class ApiRouter {
this.podcastManager = Server.podcastManager this.podcastManager = Server.podcastManager
/** @type {import('../managers/AudioMetadataManager')} */ /** @type {import('../managers/AudioMetadataManager')} */
this.audioMetadataManager = Server.audioMetadataManager this.audioMetadataManager = Server.audioMetadataManager
/** @type {import('../managers/RssFeedManager')} */
this.rssFeedManager = Server.rssFeedManager
/** @type {import('../managers/CronManager')} */ /** @type {import('../managers/CronManager')} */
this.cronManager = Server.cronManager this.cronManager = Server.cronManager
/** @type {import('../managers/EmailManager')} */ /** @type {import('../managers/EmailManager')} */
@@ -348,11 +347,10 @@ class ApiRouter {
// //
/** /**
* Remove library item and associated entities * Remove library item and associated entities
* @param {string} mediaType
* @param {string} libraryItemId * @param {string} libraryItemId
* @param {string[]} mediaItemIds array of bookId or podcastEpisodeId * @param {string[]} mediaItemIds array of bookId or podcastEpisodeId
*/ */
async handleDeleteLibraryItem(mediaType, libraryItemId, mediaItemIds) { async handleDeleteLibraryItem(libraryItemId, mediaItemIds) {
const numProgressRemoved = await Database.mediaProgressModel.destroy({ const numProgressRemoved = await Database.mediaProgressModel.destroy({
where: { where: {
mediaItemId: mediaItemIds mediaItemId: mediaItemIds
@@ -362,29 +360,6 @@ class ApiRouter {
Logger.info(`[ApiRouter] Removed ${numProgressRemoved} media progress entries for library item "${libraryItemId}"`) Logger.info(`[ApiRouter] Removed ${numProgressRemoved} media progress entries for library item "${libraryItemId}"`)
} }
// TODO: Remove open sessions for library item
// Remove series if empty
if (mediaType === 'book') {
// TODO: update filter data
const bookSeries = await Database.bookSeriesModel.findAll({
where: {
bookId: mediaItemIds[0]
},
include: {
model: Database.seriesModel,
include: {
model: Database.bookModel
}
}
})
for (const bs of bookSeries) {
if (bs.series.books.length === 1) {
await this.removeEmptySeries(bs.series)
}
}
}
// remove item from playlists // remove item from playlists
const playlistsWithItem = await Database.playlistModel.getPlaylistsForMediaItemIds(mediaItemIds) const playlistsWithItem = await Database.playlistModel.getPlaylistsForMediaItemIds(mediaItemIds)
for (const playlist of playlistsWithItem) { for (const playlist of playlistsWithItem) {
@@ -418,16 +393,19 @@ class ApiRouter {
} }
// Close rss feed - remove from db and emit socket event // Close rss feed - remove from db and emit socket event
await this.rssFeedManager.closeFeedForEntityId(libraryItemId) await RssFeedManager.closeFeedForEntityId(libraryItemId)
// purge cover cache // purge cover cache
await CacheManager.purgeCoverCache(libraryItemId) await CacheManager.purgeCoverCache(libraryItemId)
// Remove metadata file if in /metadata/items dir
if (global.MetadataPath) {
const itemMetadataPath = Path.join(global.MetadataPath, 'items', libraryItemId) const itemMetadataPath = Path.join(global.MetadataPath, 'items', libraryItemId)
if (await fs.pathExists(itemMetadataPath)) { if (await fs.pathExists(itemMetadataPath)) {
Logger.info(`[ApiRouter] Removing item metadata at "${itemMetadataPath}"`) Logger.info(`[ApiRouter] Removing item metadata at "${itemMetadataPath}"`)
await fs.remove(itemMetadataPath) await fs.remove(itemMetadataPath)
} }
}
await Database.libraryItemModel.removeById(libraryItemId) await Database.libraryItemModel.removeById(libraryItemId)
@@ -437,32 +415,27 @@ class ApiRouter {
} }
/** /**
* Used when a series is removed from a book * After deleting book(s), remove empty series
* Series is removed if it only has 1 book
* *
* @param {string} bookId
* @param {string[]} seriesIds * @param {string[]} seriesIds
*/ */
async checkRemoveEmptySeries(bookId, seriesIds) { async checkRemoveEmptySeries(seriesIds) {
if (!seriesIds?.length) return if (!seriesIds?.length) return
const bookSeries = await Database.bookSeriesModel.findAll({ const series = await Database.seriesModel.findAll({
where: { where: {
bookId, id: seriesIds
seriesId: seriesIds
}, },
include: [ attributes: ['id', 'name', 'libraryId'],
{
model: Database.seriesModel,
include: { include: {
model: Database.bookModel model: Database.bookModel,
attributes: ['id']
} }
}
]
}) })
for (const bs of bookSeries) {
if (bs.series.books.length === 1) { for (const s of series) {
await this.removeEmptySeries(bs.series) if (!s.books.length) {
await this.removeEmptySeries(s)
} }
} }
} }
@@ -471,11 +444,10 @@ class ApiRouter {
* Remove authors with no books and unset asin, description and imagePath * Remove authors with no books and unset asin, description and imagePath
* Note: Other implementation is in BookScanner.checkAuthorsRemovedFromBooks (can be merged) * Note: Other implementation is in BookScanner.checkAuthorsRemovedFromBooks (can be merged)
* *
* @param {string} libraryId
* @param {string[]} authorIds * @param {string[]} authorIds
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async checkRemoveAuthorsWithNoBooks(libraryId, authorIds) { async checkRemoveAuthorsWithNoBooks(authorIds) {
if (!authorIds?.length) return if (!authorIds?.length) return
const bookAuthorsToRemove = ( const bookAuthorsToRemove = (
@@ -495,10 +467,10 @@ class ApiRouter {
}, },
sequelize.where(sequelize.literal('(SELECT count(*) FROM bookAuthors ba WHERE ba.authorId = author.id)'), 0) sequelize.where(sequelize.literal('(SELECT count(*) FROM bookAuthors ba WHERE ba.authorId = author.id)'), 0)
], ],
attributes: ['id', 'name'], attributes: ['id', 'name', 'libraryId'],
raw: true raw: true
}) })
).map((au) => ({ id: au.id, name: au.name })) ).map((au) => ({ id: au.id, name: au.name, libraryId: au.libraryId }))
if (bookAuthorsToRemove.length) { if (bookAuthorsToRemove.length) {
await Database.authorModel.destroy({ await Database.authorModel.destroy({
@@ -506,7 +478,7 @@ class ApiRouter {
id: bookAuthorsToRemove.map((au) => au.id) id: bookAuthorsToRemove.map((au) => au.id)
} }
}) })
bookAuthorsToRemove.forEach(({ id, name }) => { bookAuthorsToRemove.forEach(({ id, name, libraryId }) => {
Database.removeAuthorFromFilterData(libraryId, id) Database.removeAuthorFromFilterData(libraryId, id)
// TODO: Clients were expecting full author in payload but its unnecessary // TODO: Clients were expecting full author in payload but its unnecessary
SocketAuthority.emitter('author_removed', { id, libraryId }) SocketAuthority.emitter('author_removed', { id, libraryId })
@@ -520,7 +492,7 @@ class ApiRouter {
* @param {import('../models/Series')} series * @param {import('../models/Series')} series
*/ */
async removeEmptySeries(series) { async removeEmptySeries(series) {
await this.rssFeedManager.closeFeedForEntityId(series.id) await RssFeedManager.closeFeedForEntityId(series.id)
Logger.info(`[ApiRouter] Series "${series.name}" is now empty. Removing series`) Logger.info(`[ApiRouter] Series "${series.name}" is now empty. Removing series`)
// Remove series from library filter data // Remove series from library filter data
+2 -2
View File
@@ -133,8 +133,8 @@ class AudioFileScanner {
// Look for disc number in folder path e.g. /Book Title/CD01/audiofile.mp3 // Look for disc number in folder path e.g. /Book Title/CD01/audiofile.mp3
const pathdir = Path.dirname(path).split('/').pop() const pathdir = Path.dirname(path).split('/').pop()
if (pathdir && /^cd\d{1,3}$/i.test(pathdir)) { if (pathdir && /^(cd|dis[ck])\s*\d{1,3}$/i.test(pathdir)) {
const discFromFolder = Number(pathdir.replace(/cd/i, '')) const discFromFolder = Number(pathdir.replace(/^(cd|dis[ck])\s*/i, ''))
if (!isNaN(discFromFolder) && discFromFolder !== null) discNumber = discFromFolder if (!isNaN(discFromFolder) && discFromFolder !== null) discNumber = discFromFolder
} }
+12 -6
View File
@@ -6,21 +6,24 @@ const { getTitleIgnorePrefix, areEquivalent } = require('../utils/index')
const parseNameString = require('../utils/parsers/parseNameString') const parseNameString = require('../utils/parsers/parseNameString')
const parseEbookMetadata = require('../utils/parsers/parseEbookMetadata') const parseEbookMetadata = require('../utils/parsers/parseEbookMetadata')
const globals = require('../utils/globals') const globals = require('../utils/globals')
const { readTextFile, filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils')
const AudioFileScanner = require('./AudioFileScanner') const AudioFileScanner = require('./AudioFileScanner')
const Database = require('../Database') const Database = require('../Database')
const { readTextFile, filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils')
const AudioFile = require('../objects/files/AudioFile')
const CoverManager = require('../managers/CoverManager')
const LibraryFile = require('../objects/files/LibraryFile')
const SocketAuthority = require('../SocketAuthority') const SocketAuthority = require('../SocketAuthority')
const fsExtra = require('../libs/fsExtra')
const BookFinder = require('../finders/BookFinder') const BookFinder = require('../finders/BookFinder')
const fsExtra = require('../libs/fsExtra')
const EBookFile = require('../objects/files/EBookFile')
const AudioFile = require('../objects/files/AudioFile')
const LibraryFile = require('../objects/files/LibraryFile')
const RssFeedManager = require('../managers/RssFeedManager')
const CoverManager = require('../managers/CoverManager')
const LibraryScan = require('./LibraryScan') const LibraryScan = require('./LibraryScan')
const OpfFileScanner = require('./OpfFileScanner') const OpfFileScanner = require('./OpfFileScanner')
const NfoFileScanner = require('./NfoFileScanner') const NfoFileScanner = require('./NfoFileScanner')
const AbsMetadataFileScanner = require('./AbsMetadataFileScanner') const AbsMetadataFileScanner = require('./AbsMetadataFileScanner')
const EBookFile = require('../objects/files/EBookFile')
/** /**
* Metadata for books pulled from files * Metadata for books pulled from files
@@ -941,6 +944,9 @@ class BookScanner {
id: bookSeriesToRemove id: bookSeriesToRemove
} }
}) })
// Close any open feeds for series
await RssFeedManager.closeFeedsForEntityIds(bookSeriesToRemove)
bookSeriesToRemove.forEach((seriesId) => { bookSeriesToRemove.forEach((seriesId) => {
Database.removeSeriesFromFilterData(libraryId, seriesId) Database.removeSeriesFromFilterData(libraryId, seriesId)
SocketAuthority.emitter('series_removed', { id: seriesId, libraryId }) SocketAuthority.emitter('series_removed', { id: seriesId, libraryId })
+2 -2
View File
@@ -424,8 +424,8 @@ class LibraryScanner {
} }
const folder = library.libraryFolders[0] const folder = library.libraryFolders[0]
const relFilePaths = folderGroups[folderId].fileUpdates.map((fileUpdate) => fileUpdate.relPath) const filePathItems = folderGroups[folderId].fileUpdates.map((fileUpdate) => fileUtils.getFilePathItemFromFileUpdate(fileUpdate))
const fileUpdateGroup = scanUtils.groupFilesIntoLibraryItemPaths(library.mediaType, relFilePaths) const fileUpdateGroup = scanUtils.groupFileItemsIntoLibraryItemDirs(library.mediaType, filePathItems, !!library.settings?.audiobooksOnly)
if (!Object.keys(fileUpdateGroup).length) { if (!Object.keys(fileUpdateGroup).length) {
Logger.info(`[LibraryScanner] No important changes to scan for in folder "${folderId}"`) Logger.info(`[LibraryScanner] No important changes to scan for in folder "${folderId}"`)
+31 -2
View File
@@ -131,11 +131,21 @@ async function readTextFile(path) {
} }
module.exports.readTextFile = readTextFile module.exports.readTextFile = readTextFile
/**
* @typedef FilePathItem
* @property {string} name - file name e.g. "audiofile.m4b"
* @property {string} path - fullpath excluding folder e.g. "Author/Book/audiofile.m4b"
* @property {string} reldirpath - path excluding file name e.g. "Author/Book"
* @property {string} fullpath - full path e.g. "/audiobooks/Author/Book/audiofile.m4b"
* @property {string} extension - file extension e.g. ".m4b"
* @property {number} deep - depth of file in directory (0 is file in folder root)
*/
/** /**
* Get array of files inside dir * Get array of files inside dir
* @param {string} path * @param {string} path
* @param {string} [relPathToReplace] * @param {string} [relPathToReplace]
* @returns {{name:string, path:string, dirpath:string, reldirpath:string, fullpath:string, extension:string, deep:number}[]} * @returns {FilePathItem[]}
*/ */
async function recurseFiles(path, relPathToReplace = null) { async function recurseFiles(path, relPathToReplace = null) {
path = filePathToPOSIX(path) path = filePathToPOSIX(path)
@@ -213,7 +223,6 @@ async function recurseFiles(path, relPathToReplace = null) {
return { return {
name: item.name, name: item.name,
path: item.fullname.replace(relPathToReplace, ''), path: item.fullname.replace(relPathToReplace, ''),
dirpath: item.path,
reldirpath: isInRoot ? '' : item.path.replace(relPathToReplace, ''), reldirpath: isInRoot ? '' : item.path.replace(relPathToReplace, ''),
fullpath: item.fullname, fullpath: item.fullname,
extension: item.extension, extension: item.extension,
@@ -228,6 +237,26 @@ async function recurseFiles(path, relPathToReplace = null) {
} }
module.exports.recurseFiles = recurseFiles module.exports.recurseFiles = recurseFiles
/**
*
* @param {import('../Watcher').PendingFileUpdate} fileUpdate
* @returns {FilePathItem}
*/
module.exports.getFilePathItemFromFileUpdate = (fileUpdate) => {
let relPath = fileUpdate.relPath
if (relPath.startsWith('/')) relPath = relPath.slice(1)
const dirname = Path.dirname(relPath)
return {
name: Path.basename(relPath),
path: relPath,
reldirpath: dirname === '.' ? '' : dirname,
fullpath: fileUpdate.path,
extension: Path.extname(relPath),
deep: relPath.split('/').length - 1
}
}
/** /**
* Download file from web to local file system * Download file from web to local file system
* Uses SSRF filter to prevent internal URLs * Uses SSRF filter to prevent internal URLs
+1 -1
View File
@@ -189,7 +189,7 @@ function parseTags(format, verbose) {
file_tag_genre: tryGrabTags(format, 'genre', 'tcon', 'tco'), file_tag_genre: tryGrabTags(format, 'genre', 'tcon', 'tco'),
file_tag_series: tryGrabTags(format, 'series', 'show', 'mvnm'), file_tag_series: tryGrabTags(format, 'series', 'show', 'mvnm'),
file_tag_seriespart: tryGrabTags(format, 'series-part', 'episode_id', 'mvin', 'part'), file_tag_seriespart: tryGrabTags(format, 'series-part', 'episode_id', 'mvin', 'part'),
file_tag_grouping: tryGrabTags(format, 'grouping'), file_tag_grouping: tryGrabTags(format, 'grouping', 'grp1'),
file_tag_isbn: tryGrabTags(format, 'isbn'), // custom file_tag_isbn: tryGrabTags(format, 'isbn'), // custom
file_tag_language: tryGrabTags(format, 'language', 'lang'), file_tag_language: tryGrabTags(format, 'language', 'lang'),
file_tag_asin: tryGrabTags(format, 'asin', 'audible_asin'), // custom file_tag_asin: tryGrabTags(format, 'asin', 'audible_asin'), // custom
+23 -14
View File
@@ -75,7 +75,7 @@ module.exports = {
for (const book of booksAdded) { for (const book of booksAdded) {
// Grab first 25 that have a cover // Grab first 25 that have a cover
if (book.coverPath && !booksWithCovers.includes(book.libraryItem.id) && booksWithCovers.length < 25 && await fsExtra.pathExists(book.coverPath)) { if (book.coverPath && !booksWithCovers.includes(book.libraryItem.id) && booksWithCovers.length < 25 && (await fsExtra.pathExists(book.coverPath))) {
booksWithCovers.push(book.libraryItem.id) booksWithCovers.push(book.libraryItem.id)
} }
if (book.duration && !isNaN(book.duration)) { if (book.duration && !isNaN(book.duration)) {
@@ -95,45 +95,54 @@ module.exports = {
const listeningSessions = await this.getListeningSessionsForYear(year) const listeningSessions = await this.getListeningSessionsForYear(year)
let totalListeningTime = 0 let totalListeningTime = 0
for (const ls of listeningSessions) { for (const ls of listeningSessions) {
totalListeningTime += (ls.timeListening || 0) totalListeningTime += ls.timeListening || 0
const authors = ls.mediaMetadata.authors || [] const authors = ls.mediaMetadata?.authors || []
authors.forEach((au) => { authors.forEach((au) => {
if (!authorListeningMap[au.name]) authorListeningMap[au.name] = 0 if (!authorListeningMap[au.name]) authorListeningMap[au.name] = 0
authorListeningMap[au.name] += (ls.timeListening || 0) authorListeningMap[au.name] += ls.timeListening || 0
}) })
const narrators = ls.mediaMetadata.narrators || [] const narrators = ls.mediaMetadata?.narrators || []
narrators.forEach((narrator) => { narrators.forEach((narrator) => {
if (!narratorListeningMap[narrator]) narratorListeningMap[narrator] = 0 if (!narratorListeningMap[narrator]) narratorListeningMap[narrator] = 0
narratorListeningMap[narrator] += (ls.timeListening || 0) narratorListeningMap[narrator] += ls.timeListening || 0
}) })
// Filter out bad genres like "audiobook" and "audio book" // Filter out bad genres like "audiobook" and "audio book"
const genres = (ls.mediaMetadata.genres || []).filter(g => g && !g.toLowerCase().includes('audiobook') && !g.toLowerCase().includes('audio book')) const genres = (ls.mediaMetadata?.genres || []).filter((g) => g && !g.toLowerCase().includes('audiobook') && !g.toLowerCase().includes('audio book'))
genres.forEach((genre) => { genres.forEach((genre) => {
if (!genreListeningMap[genre]) genreListeningMap[genre] = 0 if (!genreListeningMap[genre]) genreListeningMap[genre] = 0
genreListeningMap[genre] += (ls.timeListening || 0) genreListeningMap[genre] += ls.timeListening || 0
}) })
} }
let topAuthors = null let topAuthors = null
topAuthors = Object.keys(authorListeningMap).map(authorName => ({ topAuthors = Object.keys(authorListeningMap)
.map((authorName) => ({
name: authorName, name: authorName,
time: Math.round(authorListeningMap[authorName]) time: Math.round(authorListeningMap[authorName])
})).sort((a, b) => b.time - a.time).slice(0, 3) }))
.sort((a, b) => b.time - a.time)
.slice(0, 3)
let topNarrators = null let topNarrators = null
topNarrators = Object.keys(narratorListeningMap).map(narratorName => ({ topNarrators = Object.keys(narratorListeningMap)
.map((narratorName) => ({
name: narratorName, name: narratorName,
time: Math.round(narratorListeningMap[narratorName]) time: Math.round(narratorListeningMap[narratorName])
})).sort((a, b) => b.time - a.time).slice(0, 3) }))
.sort((a, b) => b.time - a.time)
.slice(0, 3)
let topGenres = null let topGenres = null
topGenres = Object.keys(genreListeningMap).map(genre => ({ topGenres = Object.keys(genreListeningMap)
.map((genre) => ({
genre, genre,
time: Math.round(genreListeningMap[genre]) time: Math.round(genreListeningMap[genre])
})).sort((a, b) => b.time - a.time).slice(0, 3) }))
.sort((a, b) => b.time - a.time)
.slice(0, 3)
// Stats for total books, size and duration for everything added this year or earlier // Stats for total books, size and duration for everything added this year or earlier
const [totalStatResultsRow] = await Database.sequelize.query(`SELECT SUM(li.size) AS totalSize, SUM(b.duration) AS totalDuration, COUNT(*) AS totalItems FROM libraryItems li, books b WHERE b.id = li.mediaId AND li.mediaType = 'book' AND li.createdAt < ":nextYear-01-01";`, { const [totalStatResultsRow] = await Database.sequelize.query(`SELECT SUM(li.size) AS totalSize, SUM(b.duration) AS totalDuration, COUNT(*) AS totalItems FROM libraryItems li, books b WHERE b.id = li.mediaId AND li.mediaType = 'book' AND li.createdAt < ":nextYear-01-01";`, {
+7 -7
View File
@@ -54,7 +54,7 @@ module.exports = {
items: libraryItems.map((li) => { items: libraryItems.map((li) => {
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified() const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
if (li.rssFeed) { if (li.rssFeed) {
oldLibraryItem.rssFeed = Database.feedModel.getOldFeed(li.rssFeed).toJSONMinified() oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()
} }
if (li.mediaItemShare) { if (li.mediaItemShare) {
oldLibraryItem.mediaItemShare = li.mediaItemShare oldLibraryItem.mediaItemShare = li.mediaItemShare
@@ -91,7 +91,7 @@ module.exports = {
libraryItems: libraryItems.map((li) => { libraryItems: libraryItems.map((li) => {
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified() const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
if (li.rssFeed) { if (li.rssFeed) {
oldLibraryItem.rssFeed = Database.feedModel.getOldFeed(li.rssFeed).toJSONMinified() oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()
} }
if (li.size && !oldLibraryItem.media.size) { if (li.size && !oldLibraryItem.media.size) {
oldLibraryItem.media.size = li.size oldLibraryItem.media.size = li.size
@@ -109,7 +109,7 @@ module.exports = {
libraryItems: libraryItems.map((li) => { libraryItems: libraryItems.map((li) => {
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified() const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
if (li.rssFeed) { if (li.rssFeed) {
oldLibraryItem.rssFeed = Database.feedModel.getOldFeed(li.rssFeed).toJSONMinified() oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()
} }
if (li.size && !oldLibraryItem.media.size) { if (li.size && !oldLibraryItem.media.size) {
oldLibraryItem.media.size = li.size oldLibraryItem.media.size = li.size
@@ -138,7 +138,7 @@ module.exports = {
libraryItems: libraryItems.map((li) => { libraryItems: libraryItems.map((li) => {
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified() const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
if (li.rssFeed) { if (li.rssFeed) {
oldLibraryItem.rssFeed = Database.feedModel.getOldFeed(li.rssFeed).toJSONMinified() oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()
} }
if (li.series) { if (li.series) {
oldLibraryItem.media.metadata.series = li.series oldLibraryItem.media.metadata.series = li.series
@@ -168,7 +168,7 @@ module.exports = {
items: libraryItems.map((li) => { items: libraryItems.map((li) => {
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified() const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
if (li.rssFeed) { if (li.rssFeed) {
oldLibraryItem.rssFeed = Database.feedModel.getOldFeed(li.rssFeed).toJSONMinified() oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()
} }
if (li.mediaItemShare) { if (li.mediaItemShare) {
oldLibraryItem.mediaItemShare = li.mediaItemShare oldLibraryItem.mediaItemShare = li.mediaItemShare
@@ -279,7 +279,7 @@ module.exports = {
const oldSeries = s.toOldJSON() const oldSeries = s.toOldJSON()
if (s.feeds?.length) { if (s.feeds?.length) {
oldSeries.rssFeed = Database.feedModel.getOldFeed(s.feeds[0]).toJSONMinified() oldSeries.rssFeed = s.feeds[0].toOldJSONMinified()
} }
// TODO: Sort books by sequence in query // TODO: Sort books by sequence in query
@@ -375,7 +375,7 @@ module.exports = {
libraryItems: libraryItems.map((li) => { libraryItems: libraryItems.map((li) => {
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified() const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
if (li.rssFeed) { if (li.rssFeed) {
oldLibraryItem.rssFeed = Database.feedModel.getOldFeed(li.rssFeed).toJSONMinified() oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()
} }
if (li.mediaItemShare) { if (li.mediaItemShare) {
oldLibraryItem.mediaItemShare = li.mediaItemShare oldLibraryItem.mediaItemShare = li.mediaItemShare
@@ -615,8 +615,8 @@ module.exports = {
} }
} }
if (libraryItem.feeds?.length) { if (bookExpanded.libraryItem.feeds?.length) {
libraryItem.rssFeed = libraryItem.feeds[0] libraryItem.rssFeed = bookExpanded.libraryItem.feeds[0]
} }
if (includeMediaItemShare) { if (includeMediaItemShare) {
@@ -766,8 +766,8 @@ module.exports = {
name: s.name, name: s.name,
sequence: s.bookSeries[bookIndex].sequence sequence: s.bookSeries[bookIndex].sequence
} }
if (libraryItem.feeds?.length) { if (s.bookSeries[bookIndex].book.libraryItem.feeds?.length) {
libraryItem.rssFeed = libraryItem.feeds[0] libraryItem.rssFeed = s.bookSeries[bookIndex].book.libraryItem.feeds[0]
} }
libraryItem.media = book libraryItem.media = book
return libraryItem return libraryItem
@@ -900,8 +900,8 @@ module.exports = {
delete book.libraryItem delete book.libraryItem
libraryItem.media = book libraryItem.media = book
if (libraryItem.feeds?.length) { if (bookExpanded.libraryItem.feeds?.length) {
libraryItem.rssFeed = libraryItem.feeds[0] libraryItem.rssFeed = bookExpanded.libraryItem.feeds[0]
} }
return libraryItem return libraryItem
@@ -180,8 +180,8 @@ module.exports = {
delete podcast.libraryItem delete podcast.libraryItem
if (libraryItem.feeds?.length) { if (podcastExpanded.libraryItem.feeds?.length) {
libraryItem.rssFeed = libraryItem.feeds[0] libraryItem.rssFeed = podcastExpanded.libraryItem.feeds[0]
} }
if (podcast.numEpisodesIncomplete) { if (podcast.numEpisodesIncomplete) {
libraryItem.numEpisodesIncomplete = podcast.numEpisodesIncomplete libraryItem.numEpisodesIncomplete = podcast.numEpisodesIncomplete

Some files were not shown because too many files have changed in this diff Show More