Compare commits

...

120 Commits

Author SHA1 Message Date
advplyr 95ebe0f087 Version bump 2.0.23 2022-07-02 19:15:23 -05:00
advplyr 0a6aa43b07 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-07-02 09:10:52 -05:00
advplyr 806a8cf659 Update:Library config page for mobile #753 and style updates 2022-07-02 09:10:47 -05:00
advplyr 1a32fbfeec Merge pull request #795 from jmt-gh/issue_794
Fix issue with unecessary empty search during match (Issue #794)
2022-06-30 15:48:07 -05:00
jmt-gh 67396c16dd formatting update 2022-06-29 19:25:59 -07:00
jmt-gh b0684b6f1b Await the responses from googlebooks and itunes 2022-06-29 19:19:58 -07:00
jmt-gh 661778c02c Await the response from audible for book search by ASIN 2022-06-29 19:18:00 -07:00
advplyr 5c4241aefe Merge pull request #792 from jmt-gh/issue_760
Fix truncation on your stats page (Issue #760)
2022-06-29 19:24:11 -05:00
jmt-gh 3f6bc90824 remove truncation from number column 2022-06-29 08:25:12 -07:00
advplyr 4ade6e04a8 Merge pull request #791 from alexmaras/master
fix: disable workbox to prevent failure with service worker
2022-06-29 08:23:07 -05:00
Alex Maras 49d0835236 fix: disable workbox to prevent failure with service worker 2022-06-29 21:11:03 +08:00
advplyr d90bd92bcc Fix:Item edit modal for mobile landscape #754 2022-06-28 18:29:11 -05:00
advplyr 41c016b8c7 Update:Match card show series and series sequence if available #762 2022-06-28 17:32:46 -05:00
advplyr 5b4d3f71f9 Update:Global library search strips periods, commas and other characters when matching #750 2022-06-26 15:46:16 -05:00
advplyr 256a9322ef Fix:Mobile toolbar for podcasts and add collections for books #693 2022-06-26 11:34:58 -05:00
advplyr 793f82e445 Update:Edit modal for mobile screen sizes and update tailwind 2022-06-26 11:15:19 -05:00
advplyr ab6da3914b Merge pull request #777 from alexmaras/fix/chapter-seek-bar
Fix/chapter seek bar
2022-06-25 11:03:23 -05:00
advplyr 0b53f0ebf3 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-06-25 11:01:06 -05:00
advplyr 76d668514e Fix:Mark as not finished when duration is not set in media progress #771 2022-06-25 11:01:01 -05:00
Alex Maras 3c347bef7d fix: better variable naming 2022-06-26 00:00:52 +08:00
Alex Maras e837e5f780 fix: reuse existing variable 2022-06-25 23:58:55 +08:00
Alex Maras 26348ccc74 Merge remote-tracking branch 'fork/master' into fix/chapter-seek-bar 2022-06-25 23:56:42 +08:00
Alex Maras 729a756e21 fix: use total time for chapter name resolution when in chapter track mode 2022-06-25 23:53:40 +08:00
advplyr 4dbddcf179 Merge pull request #776 from alexmaras/fix/chapter-seek-bar
fix: use chapter duration when seeking on track bar
2022-06-25 10:40:55 -05:00
Alex Maras f2fff34d4d fix: use chapter start as a base for the seek time if seeking within chapters 2022-06-25 23:37:18 +08:00
advplyr 59c5e2c1d9 Allow custom headers in requests 2022-06-25 10:36:37 -05:00
Alex Maras 067006f406 fix: use chapter duration when seeking on track bar 2022-06-25 23:24:40 +08:00
advplyr 93d82b973e Merge pull request #770 from jmt-gh/relative_chapter_times
Show chapter duration in the "now playing" chapter modal (Issue #767)
2022-06-23 17:51:56 -05:00
advplyr a9a3423b58 Update chapter modal for mobile 2022-06-23 17:50:25 -05:00
advplyr f4ee215ad8 Update chapters modal truncate long chapter titles and show chapter duration 2022-06-23 17:36:55 -05:00
jmt-gh 48431b1c35 add support for showing chapter duration in chapters modal 2022-06-22 18:58:57 -07:00
advplyr ce961f90ba Merge pull request #759 from jmt-gh/update_stats_uis
Update Library Stats and Your Stats UIs to match other Settings UIs
2022-06-22 17:50:05 -05:00
advplyr 916d2f6bb3 Merge pull request #758 from jmt-gh/update_settings
Update Settings page UI
2022-06-22 17:48:06 -05:00
advplyr 01e7098f00 Updates to setting formatting and copy 2022-06-22 17:47:21 -05:00
advplyr e02fbac4cd Merge pull request #757 from jmt-gh/update_users_table
Update UsersTable styling to match other tables
2022-06-22 17:30:46 -05:00
advplyr a8fce32e70 Merge pull request #749 from jmt-gh/add_reorder_icons_to_libraries
Add reorder icon to libraries table
2022-06-20 10:27:06 -05:00
jmt-gh) d0637c1e3d update library and your stat UIs to match 2022-06-19 19:25:44 -07:00
jmt-gh) f6702d299d update html formatting 2022-06-19 18:08:04 -07:00
jmt-gh) 033b7ece28 update text formatting 2022-06-19 17:46:38 -07:00
jmt-gh) 5f5dce6d53 initial Settings update 2022-06-19 17:31:52 -07:00
jmt-gh) 82c5c7518b remove unecessary css styling 2022-06-19 17:13:24 -07:00
jmt-gh) 7a60ffb3c4 update UsersTable styling 2022-06-19 17:10:15 -07:00
advplyr 2795f657b5 Merge pull request #755 from jmt-gh/update_sessions_table
Update Sessions page to have a matching "settings UI"
2022-06-19 18:28:35 -05:00
advplyr 9ef5b5830e Merge pull request #752 from jmt-gh/issue_702
Move matching toasts to top right (Issue #702)
2022-06-19 18:26:16 -05:00
advplyr 879adfa633 Remove last bottom-center for toast 2022-06-19 18:25:59 -05:00
advplyr b12a344776 Fix:Chromecast button on mobile screen sizes #756 2022-06-19 15:43:45 -05:00
jmt-gh 50b1098797 add back in empty state 2022-06-19 10:07:09 -07:00
jmt-gh fdfaa7eba4 unify on 'Listening Sessions' 2022-06-19 10:01:06 -07:00
jmt-gh 5525587513 update sessions table to match other settings tables UI 2022-06-19 09:54:22 -07:00
jmt-gh 1f20ed7640 Move matching toasts to top right 2022-06-19 09:31:51 -07:00
advplyr f741064843 Merge pull request #748 from jmt-gh/update_log_page
Update Log page to have a matching "settings UI"
2022-06-19 10:07:56 -05:00
advplyr d5138e4c0a Merge pull request #747 from jmt-gh/fix_remove_button_padding
Fix padding on "Remove All Library Items" button
2022-06-19 10:06:26 -05:00
advplyr 42a30c33db Merge pull request #746 from jmt-gh/update_placard_size
Fix placard sizes so "Continue Listening" fits
2022-06-19 10:06:04 -05:00
advplyr e5d978f8e8 Merge pull request #744 from jmt-gh/issue_741
Add toggle for switching between Chapter and Book Duration in player (issue #741)
2022-06-19 10:05:06 -05:00
advplyr ccc82520a9 Update chapter track progress bar, timestamps, hide chapter ticks. Update mobile responsiveness for player 2022-06-19 10:04:15 -05:00
jmt-gh) 22acf52a26 remove unecessary space 2022-06-19 02:06:15 -07:00
jmt-gh) 2ccd2786f4 add reorder icon to the library items 2022-06-19 02:03:05 -07:00
jmt-gh 0028136935 update height of content to optimize screen space 2022-06-19 01:53:23 -07:00
jmt-gh 0edc46b771 update log page to have a matching UI 2022-06-19 01:46:42 -07:00
jmt-gh 2261f3d1c3 fix right padding on remove all items button 2022-06-19 01:09:02 -07:00
jmt-gh 5c0e792782 fix placard size so continue listening fits 2022-06-19 01:03:56 -07:00
jmt-gh 644882e04f add support for swapping progress bar between current chapter duration and book duration 2022-06-18 23:55:34 -07:00
advplyr 67f51c6de9 Version bump 2.0.22 2022-06-18 18:43:19 -05:00
advplyr 0c8fd6ab0e Update:Uploader to treat audio files as separate audiobooks if uploading only audio files #670 2022-06-18 16:44:20 -05:00
advplyr 5452a57a14 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-06-18 13:11:20 -05:00
advplyr 19f020e7a6 Fix:Open playback session on init #743 2022-06-18 13:11:15 -05:00
advplyr 825641f2a9 Merge pull request #742 from mrdth/feature/fetch-author-image
Fetch author photo from external URL
2022-06-18 12:10:53 -05:00
advplyr 35ab4cb2fe Update photo url input to photo path/url to be consistent with item covers 2022-06-18 12:05:30 -05:00
advplyr fd13607d89 Update:Logs to use server local timezone instead of UTC #656 2022-06-18 11:34:28 -05:00
mrdth f79b4d44b9 Fetch author photo from external URL
Add a new text field 'Photo URL' on the author edit modal, if there
is no existing image for an author.

When submitted, the image is saved from the URL provided
2022-06-18 17:04:38 +01:00
advplyr 91e30a6e84 Merge pull request #736 from cassieesposito/dateFormat
Date format
2022-06-18 10:34:32 -05:00
advplyr 8ab0f164a6 Update server settings ui and add display settings section 2022-06-18 10:32:12 -05:00
advplyr 578bb03404 Merge pull request #730 from jmt-gh/issue_719
Properly support multiple narrators when Matching (#719)
2022-06-18 09:32:46 -05:00
advplyr 06582b5371 Merge pull request #739 from jmt-gh/mobile_responsive_search
Show search bar on mobile responsive views (fix #715)
2022-06-18 09:30:17 -05:00
Cassie Esposito 7f6baf35b7 Merge branch 'advplyr:master' into dateFormat 2022-06-17 20:58:05 -07:00
advplyr 6227d0baa1 Update 2022-06-17 19:09:03 -05:00
advplyr e334b585be Libraries dropdown updates for mobile 2022-06-17 16:50:46 -05:00
advplyr c83b3f19f7 Merge pull request #735 from samosbor/master
Update BookFinder.js: Retry Search with Clean Title and Author if No Books Found
2022-06-17 15:46:57 -05:00
Sam d31ec055f9 check if cleantitle and cleanauthor are different 2022-06-17 10:31:31 -07:00
jmt-gh 38c259a45e initial commit for responsive search bar on mobile 2022-06-17 07:48:42 -07:00
Cassie Esposito b2ee24de98 Applied dateFormat setting to alternative bookshelf view 2022-06-17 01:35:24 -07:00
Cassie Esposito 9ba0e52bb7 Added an option to change date format 2022-06-17 01:26:10 -07:00
Sam edc712e6f6 Merge branch 'master' of https://github.com/samosbor/audiobookshelf 2022-06-17 01:14:19 -07:00
Sam 485888b2d9 list includes syntax 2022-06-16 22:37:36 -07:00
advplyr c2e90d4d83 Merge pull request #734 from jmt-gh/add_local_docker_build_commands
Add npm commands to build local docker containers
2022-06-16 17:09:48 -05:00
advplyr f5d89b8f52 Merge pull request #733 from jmt-gh/multiline_pills
Update pills so that they handle multi-line properly
2022-06-16 17:08:59 -05:00
advplyr 378b40790a Merge branch 'master' into multiline_pills 2022-06-16 17:08:47 -05:00
advplyr be3d38392d Merge pull request #732 from jmt-gh/update_pills
Update details "pills" so they don't overlap when they flow to new lines
2022-06-16 17:07:21 -05:00
Sam Osborne 27fef50983 retry search with clean title author 2022-06-16 13:48:37 -07:00
jmt-gh 167df85c1e add npm commands to build local docker containers 2022-06-15 22:23:36 -07:00
jmt-gh 009e16c9a4 add break-all for multiline support for long entries 2022-06-15 22:12:56 -07:00
jmt-gh b40cc767b2 add margin on y so pills don't touch when they overflow 2022-06-15 20:49:27 -07:00
jmt-gh 4f5f2d32be Properly support multiple narrators when Matching
This commit adds proper support for multiple narrators when matching. We
were accidentally missing a split in the same way we handle it for
genres and tags
2022-06-15 20:23:56 -07:00
advplyr 66be3e0281 Update:Clear global search input when clicking dropdown item #723 2022-06-15 12:35:50 -05:00
advplyr 987f188f00 Merge pull request #720 from jmt-gh/fix_formatting
Fix indents from 4 spaces to 2 in parseOverdriveMediaMarkers.js
2022-06-14 13:28:11 -05:00
jmt-gh daca2bdf2a fix spacing from 4 to 2 2022-06-13 22:23:02 -07:00
advplyr 8894f52439 Merge pull request #716 from jmt-gh/abs_overdrive
Add support for leveraging chapter data directly from Overdrive mp3s during scanning
2022-06-13 18:25:44 -05:00
jmt-gh 863f81e55a remove logger 2022-06-12 20:43:20 -07:00
jmt-gh d03d3735e5 Add chapter end time support 2022-06-12 20:28:09 -07:00
advplyr 3bb2df6e12 Fix:Abs metadata parser to allow second equals sign in value #634 2022-06-12 17:47:14 -05:00
advplyr 80c9efc618 Update:Make m4b timeout to 30 mins 2022-06-12 15:59:36 -05:00
jmt-gh 3279901ab0 remove clientside changes 2022-06-12 12:24:29 -07:00
jmt-gh d43d351721 remove loggers 2022-06-12 02:03:26 -07:00
jmt-gh 8210eba439 clean up loggers 2022-06-12 01:57:00 -07:00
jmt-gh cbd7294b0b add getter to libraryscan.js for overdrivemediamarker 2022-06-12 01:54:58 -07:00
jmt-gh 6064e8af87 fix using OMMs with regular scan option 2022-06-12 01:46:50 -07:00
jmt-gh 8754f0c25f Cleaning up server code
Doing some literal cleaning
2022-06-12 01:38:09 -07:00
jmt-gh f31700f668 update comments 2022-06-12 00:54:34 -07:00
jmt-gh 9877b139f6 add new parser for overdrive media markers 2022-06-12 00:53:56 -07:00
jmt-gh 5643c846ee Fix bug for certain scan types
Needed to look in to scanOptions to access the properties I wanted.
It's..... unclear to me if this needs to be done for those other ones as
well. I think so?
2022-06-12 00:47:54 -07:00
jmt-gh 5a071babe9 added library/ to .gitignore 2022-06-12 00:22:04 -07:00
jmt-gh 3d85d0bce6 add settings page setting 2022-06-12 00:21:37 -07:00
jmt-gh 68afc2c718 Add support for various scan types
This commit adds support for the various scan types, and ensures that we
only run Overdrive parsing on files that can actually support it
2022-06-11 23:56:36 -07:00
jmt-gh b3d9323f66 Initial commit for server side approach
This is the first commit for bringing this over to the server side.

It works! Right now it fails if the autoscanner or or the manual
individual book scanner try to do it's thing. I'll need to update those
2022-06-11 23:17:22 -07:00
jmt-gh effc63755b remove unused console.log 2022-06-11 11:07:49 -07:00
jmt-gh b90934a72a remove unused modal 2022-06-11 11:06:19 -07:00
jmt-gh e01748eb2f Full support for generating/applying chapter data
This commit adds the rest of the support for actually being able to use
Overdrive MediaMarkers.

Shoutout to benonymity's project OverdriveChapterize, where I was able
to port over the logic to actually do the timestamp conversions

https://github.com/benonymity/OverdriveChapterizer/blob/main/chapters.py

I still need to do a lot of cleanup of the actual code, and finish the UI.
2022-06-11 10:57:17 -07:00
jmt-gh 430fbf5e46 Parse out mediamarkers from all files
This commit updates the logic in
generateChaptersFromOverdriveMediaMarkers to create a single array of
objects that holds all of the clean MediaMarker data. it still needs to
be conveted to the NewChapters format.

I should also rename this function to "cleanChaptersFromOMM", but I"ll
do that later
2022-06-11 09:34:22 -07:00
jmt-gh 27e6b9ce0d DRAFT client support for overdrive media markers
Initial client side support. Still a good amount to do. Specifically
around actually parsing out all of the media markers, and generating a
single chapter object that can be applied
2022-06-11 02:01:37 -07:00
jmt-gh fc614b9833 Add support for overdrive media marker file tag
This commit adds serverside support for grabbing the
overdrive_media_marker file tag that exists on mp3 files from overdrive
2022-06-11 02:00:07 -07:00
80 changed files with 1640 additions and 902 deletions
+1
View File
@@ -11,6 +11,7 @@ test/
/client/.nuxt/ /client/.nuxt/
/client/dist/ /client/dist/
/dist/ /dist/
library/
sw.* sw.*
.DS_STORE .DS_STORE
+4 -4
View File
@@ -3,7 +3,7 @@
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-50"> <div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-50">
<div class="flex h-full items-center"> <div class="flex h-full items-center">
<nuxt-link to="/"> <nuxt-link to="/">
<img src="/icon48.png" class="w-10 h-10 md:w-12 md:h-12 mr-4" /> <img src="/icon48.png" class="w-8 h-8 mr-8 sm:w-12 sm:h-12 sm:mr-4" />
</nuxt-link> </nuxt-link>
<nuxt-link to="/"> <nuxt-link to="/">
@@ -12,7 +12,7 @@
<ui-libraries-dropdown /> <ui-libraries-dropdown />
<controls-global-search v-if="currentLibrary" class="hidden md:block" /> <controls-global-search v-if="currentLibrary" class="" />
<div class="flex-grow" /> <div class="flex-grow" />
<span v-if="showExperimentalFeatures" class="material-icons text-4xl text-warning pr-0 sm:pr-2 md:pr-4">logo_dev</span> <span v-if="showExperimentalFeatures" class="material-icons text-4xl text-warning pr-0 sm:pr-2 md:pr-4">logo_dev</span>
@@ -20,11 +20,11 @@
<ui-tooltip v-if="isChromecastInitialized && !isHttps" direction="bottom" text="Casting requires a secure connection" class="flex items-center"> <ui-tooltip v-if="isChromecastInitialized && !isHttps" direction="bottom" text="Casting requires a secure connection" class="flex items-center">
<span class="material-icons-outlined text-warning text-opacity-50"> cast </span> <span class="material-icons-outlined text-warning text-opacity-50"> cast </span>
</ui-tooltip> </ui-tooltip>
<div v-if="isChromecastInitialized" class="w-6 h-6 mr-2 cursor-pointer"> <div v-if="isChromecastInitialized" class="w-6 min-w-6 h-6 ml-2 mr-1 sm:mx-2 cursor-pointer">
<google-cast-launcher></google-cast-launcher> <google-cast-launcher></google-cast-launcher>
</div> </div>
<nuxt-link v-if="currentLibrary" to="/config/stats" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1"> <nuxt-link v-if="currentLibrary" to="/config/stats" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 hidden sm:flex items-center justify-center mx-1">
<span class="material-icons" aria-label="User Stats" role="button">equalizer</span> <span class="material-icons" aria-label="User Stats" role="button">equalizer</span>
</nuxt-link> </nuxt-link>
+1 -1
View File
@@ -25,7 +25,7 @@
</div> </div>
</div> </div>
<div class="absolute text-center categoryPlacard font-book transform z-30 bottom-0.5 left-4 md:left-8 w-36 rounded-md" style="height: 22px"> <div class="absolute text-center categoryPlacard font-book transform z-30 bottom-px left-4 md:left-8 w-44 rounded-md" style="height: 22px">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border"> <div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border">
<p class="transform text-sm">{{ shelf.label }}</p> <p class="transform text-sm">{{ shelf.label }}</p>
</div> </div>
+22 -4
View File
@@ -2,13 +2,19 @@
<div class="w-full h-20 md:h-10 relative"> <div class="w-full h-20 md:h-10 relative">
<div class="flex md:hidden h-10 items-center"> <div class="flex md:hidden h-10 items-center">
<nuxt-link :to="`/library/${currentLibraryId}`" class="flex-grow h-full flex justify-center items-center" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'"> <nuxt-link :to="`/library/${currentLibraryId}`" class="flex-grow h-full flex justify-center items-center" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<p>Home</p> <p class="text-sm">Home</p>
</nuxt-link> </nuxt-link>
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf`" class="flex-grow h-full flex justify-center items-center" :class="showLibrary ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'"> <nuxt-link :to="`/library/${currentLibraryId}/bookshelf`" class="flex-grow h-full flex justify-center items-center" :class="showLibrary ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<p>Library</p> <p class="text-sm">Library</p>
</nuxt-link> </nuxt-link>
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf/series`" class="flex-grow h-full flex justify-center items-center" :class="paramId === 'series' ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'"> <nuxt-link v-if="!isPodcastLibrary" :to="`/library/${currentLibraryId}/bookshelf/series`" class="flex-grow h-full flex justify-center items-center" :class="paramId === 'series' ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<p>Series</p> <p class="text-sm">Series</p>
</nuxt-link>
<nuxt-link v-if="!isPodcastLibrary" :to="`/library/${currentLibraryId}/bookshelf/collections`" class="flex-grow h-full flex justify-center items-center" :class="paramId === 'collections' ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<p class="text-sm">Collections</p>
</nuxt-link>
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="flex-grow h-full flex justify-center items-center" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<p class="text-sm">Search</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-30 flex items-center justify-end md:justify-start px-2 md:px-8"> <div id="toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-30 flex items-center justify-end md:justify-start px-2 md:px-8">
@@ -98,6 +104,9 @@ export default {
} }
}, },
computed: { computed: {
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
userCanDelete() { userCanDelete() {
return this.$store.getters['user/getUserCanDelete'] return this.$store.getters['user/getUserCanDelete']
}, },
@@ -129,6 +138,12 @@ export default {
currentLibraryId() { currentLibraryId() {
return this.$store.state.libraries.currentLibraryId return this.$store.state.libraries.currentLibraryId
}, },
currentLibraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType']
},
isPodcastLibrary() {
return this.currentLibraryMediaType === 'podcast'
},
homePage() { homePage() {
return this.$route.name === 'library-library' return this.$route.name === 'library-library'
}, },
@@ -156,6 +171,9 @@ export default {
}, },
isIssuesFilter() { isIssuesFilter() {
return this.filterBy === 'issues' && this.$route.query.filter === 'issues' return this.filterBy === 'issues' && this.$route.query.filter === 'issues'
},
isPodcastSearchPage() {
return this.$route.name === 'library-library-podcast-search'
} }
}, },
methods: { methods: {
+3 -3
View File
@@ -4,7 +4,7 @@
<span class="material-icons text-2xl">arrow_back</span> <span class="material-icons text-2xl">arrow_back</span>
</div> </div>
<nuxt-link v-for="route in configRoutes" :key="route.id" :to="route.path" class="w-full px-4 h-12 border-b border-opacity-0 flex items-center cursor-pointer relative" :class="routeName === route.id ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'"> <nuxt-link v-for="route in configRoutes" :key="route.id" :to="route.path" class="w-full px-4 h-12 border-b border-primary border-opacity-30 flex items-center cursor-pointer relative" :class="routeName === route.id ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
<p>{{ route.title }}</p> <p>{{ route.title }}</p>
<div v-show="routeName === route.iod" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> <div v-show="routeName === route.iod" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link> </nuxt-link>
@@ -66,7 +66,7 @@ export default {
}, },
{ {
id: 'config-sessions', id: 'config-sessions',
title: 'Sessions', title: 'Listening Sessions',
path: '/config/sessions' path: '/config/sessions'
}, },
{ {
@@ -76,7 +76,7 @@ export default {
}, },
{ {
id: 'config-log', id: 'config-log',
title: 'Log', title: 'Logs',
path: '/config/log' path: '/config/log'
} }
] ]
+8 -8
View File
@@ -1,30 +1,30 @@
<template> <template>
<div v-if="streamLibraryItem" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 sm:h-44 md:h-40 z-40 bg-primary px-4 pb-1 md:pb-4 pt-2"> <div v-if="streamLibraryItem" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 sm:h-44 md:h-40 z-40 bg-primary px-4 pb-1 md:pb-4 pt-2">
<div id="videoDock" /> <div id="videoDock" />
<nuxt-link v-if="!playerHandler.isVideo" :to="`/item/${streamLibraryItem.id}`" class="absolute left-4 cursor-pointer" :style="{ top: bookCoverPosTop + 'px' }"> <nuxt-link v-if="!playerHandler.isVideo" :to="`/item/${streamLibraryItem.id}`" class="absolute left-1 sm:left-4 cursor-pointer" :style="{ top: bookCoverPosTop + 'px' }">
<covers-book-cover :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" /> <covers-book-cover :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</nuxt-link> </nuxt-link>
<div class="flex items-start mb-6 md:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : 'pl-24'"> <div class="flex items-start mb-6 md:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : 'pl-20 sm:pl-24'">
<div> <div>
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-base sm:text-lg"> <nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-sm sm:text-lg">
{{ title }} {{ title }}
</nuxt-link> </nuxt-link>
<div v-if="!playerHandler.isVideo" class="text-gray-400 flex items-center"> <div v-if="!playerHandler.isVideo" class="text-gray-400 flex items-center">
<span class="material-icons text-sm">person</span> <span class="material-icons text-sm">person</span>
<p v-if="podcastAuthor">{{ podcastAuthor }}</p> <p v-if="podcastAuthor" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ podcastAuthor }}</p>
<p v-else-if="authors.length" class="pl-1.5 text-sm sm:text-base"> <p v-else-if="authors.length" class="pl-1 sm:pl-1.5 text-xs sm:text-base">
<nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">,&nbsp;</span></nuxt-link> <nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">,&nbsp;</span></nuxt-link>
</p> </p>
<p v-else class="text-sm sm:text-base cursor-pointer pl-2">Unknown</p> <p v-else class="text-xs sm:text-base cursor-pointer pl-1 sm:pl-1.5">Unknown</p>
</div> </div>
<div class="text-gray-400 flex items-center"> <div class="text-gray-400 flex items-center">
<span class="material-icons text-xs">schedule</span> <span class="material-icons text-xs">schedule</span>
<p class="font-mono text-sm pl-2 pb-px">{{ totalDurationPretty }}</p> <p class="font-mono text-xs sm:text-sm pl-1 sm:pl-1.5 pb-px">{{ totalDurationPretty }}</p>
</div> </div>
</div> </div>
<div class="flex-grow" /> <div class="flex-grow" />
<span class="material-icons px-2 py-1 md:p-4 cursor-pointer" @click="closePlayer">close</span> <span class="material-icons sm:px-2 py-1 md:p-4 cursor-pointer text-xl sm:text-2xl" @click="closePlayer">close</span>
</div> </div>
<player-ui <player-ui
ref="audioPlayer" ref="audioPlayer"
+9 -2
View File
@@ -6,11 +6,18 @@
</div> </div>
<div v-if="!isPodcast" class="px-4 flex-grow"> <div v-if="!isPodcast" class="px-4 flex-grow">
<div class="flex items-center"> <div class="flex items-center">
<h1>{{ book.title }}</h1> <h1 class="text-base">{{ book.title }}</h1>
<div class="flex-grow" /> <div class="flex-grow" />
<p>{{ book.publishedYear }}</p> <p>{{ book.publishedYear }}</p>
</div> </div>
<p class="text-gray-400">{{ book.author }}</p> <p class="text-gray-300 text-sm">{{ book.author }}</p>
<div v-if="book.series && book.series.length" class="flex py-1 -mx-1">
<div v-for="(series, index) in book.series" :key="index" class="bg-white bg-opacity-10 rounded-full px-1 py-0.5 mx-1">
<p class="leading-3 text-xs text-gray-400">
{{ series.series }}<span v-if="series.volumeNumber">&nbsp;#{{ series.volumeNumber }}</span>
</p>
</div>
</div>
<div class="w-full max-h-12 overflow-hidden"> <div class="w-full max-h-12 overflow-hidden">
<p class="text-gray-500 text-xs">{{ book.description }}</p> <p class="text-gray-500 text-xs">{{ book.description }}</p>
</div> </div>
+3 -14
View File
@@ -62,21 +62,10 @@ export default {
matchHtml() { matchHtml() {
if (!this.matchText || !this.search) return '' if (!this.matchText || !this.search) return ''
if (this.matchKey === 'subtitle') return '' if (this.matchKey === 'subtitle') return ''
var matchSplit = this.matchText.toLowerCase().split(this.search.toLowerCase().trim())
if (matchSplit.length < 2) return ''
var html = '' // This used to highlight the part of the search found
var totalLenSoFar = 0 // but with removing commas periods etc this is no longer plausible
for (let i = 0; i < matchSplit.length - 1; i++) { const html = this.matchText
var indexOf = matchSplit[i].length
var firstPart = this.matchText.substr(totalLenSoFar, indexOf)
var actualWasThere = this.matchText.substr(totalLenSoFar + indexOf, this.search.length)
totalLenSoFar += indexOf + this.search.length
html += `${firstPart}<strong class="text-warning">${actualWasThere}</strong>`
}
var lastPart = this.matchText.substr(totalLenSoFar)
html += lastPart
if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>` if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>`
if (this.matchKey === 'authors') return `by ${html}` if (this.matchKey === 'authors') return `by ${html}`
+8 -3
View File
@@ -144,6 +144,9 @@ export default {
} }
}, },
computed: { computed: {
dateFormat() {
return this.store.state.serverSettings.dateFormat
},
showExperimentalFeatures() { showExperimentalFeatures() {
return this.store.state.showExperimentalFeatures return this.store.state.showExperimentalFeatures
}, },
@@ -245,12 +248,14 @@ export default {
return this.mediaMetadata.authorNameLF return this.mediaMetadata.authorNameLF
}, },
displayTitle() { displayTitle() {
if (this.recentEpisode) return this.recentEpisode.title
if (this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix) { if (this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix) {
return this.mediaMetadata.titleIgnorePrefix return this.mediaMetadata.titleIgnorePrefix
} }
return this.title return this.title
}, },
displayLineTwo() { displayLineTwo() {
if (this.recentEpisode) return this.title
if (this.isPodcast) return this.author if (this.isPodcast) return this.author
if (this.isAuthorBookshelfView) { if (this.isAuthorBookshelfView) {
return this.mediaMetadata.publishedYear || '' return this.mediaMetadata.publishedYear || ''
@@ -259,9 +264,9 @@ export default {
return this.author return this.author
}, },
displaySortLine() { displaySortLine() {
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs) if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs, this.dateFormat)
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs) if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs, this.dateFormat)
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt) if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt, this.dateFormat)
if (this.orderBy === 'media.duration') return 'Duration: ' + this.$elapsedPrettyExtended(this.media.duration, false) if (this.orderBy === 'media.duration') return 'Duration: ' + this.$elapsedPrettyExtended(this.media.duration, false)
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size) if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size)
if (this.orderBy === 'media.numTracks') return `${this.numEpisodes} Episodes` if (this.orderBy === 'media.numTracks') return `${this.numEpisodes} Episodes`
+10 -7
View File
@@ -1,5 +1,5 @@
<template> <template>
<div class="w-80 ml-6 relative"> <div class="sm:w-80 w-full sm:ml-6 relative">
<form @submit.prevent="submitSearch"> <form @submit.prevent="submitSearch">
<ui-text-input ref="input" v-model="search" placeholder="Search.." @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" /> <ui-text-input ref="input" v-model="search" placeholder="Search.." @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
</form> </form>
@@ -7,7 +7,7 @@
<span v-if="!search" class="material-icons" style="font-size: 1.2rem">search</span> <span v-if="!search" class="material-icons" style="font-size: 1.2rem">search</span>
<span v-else class="material-icons" style="font-size: 1.2rem">close</span> <span v-else class="material-icons" style="font-size: 1.2rem">close</span>
</div> </div>
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-full 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"> <div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-40 sm:w-full 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">
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label"> <ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<li v-if="isTyping" class="py-2 px-2"> <li v-if="isTyping" class="py-2 px-2">
<p>Thinking...</p> <p>Thinking...</p>
@@ -21,7 +21,7 @@
<template v-else> <template v-else>
<p v-if="bookResults.length" class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">Books</p> <p v-if="bookResults.length" class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">Books</p>
<template v-for="item in bookResults"> <template v-for="item in bookResults">
<li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option"> <li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
<nuxt-link :to="`/item/${item.libraryItem.id}`"> <nuxt-link :to="`/item/${item.libraryItem.id}`">
<cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" /> <cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
</nuxt-link> </nuxt-link>
@@ -30,7 +30,7 @@
<p v-if="podcastResults.length" class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">Podcasts</p> <p v-if="podcastResults.length" class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">Podcasts</p>
<template v-for="item in podcastResults"> <template v-for="item in podcastResults">
<li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option"> <li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
<nuxt-link :to="`/item/${item.libraryItem.id}`"> <nuxt-link :to="`/item/${item.libraryItem.id}`">
<cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" /> <cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
</nuxt-link> </nuxt-link>
@@ -39,7 +39,7 @@
<p v-if="authorResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Authors</p> <p v-if="authorResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Authors</p>
<template v-for="item in authorResults"> <template v-for="item in authorResults">
<li :key="item.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option"> <li :key="item.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(item.id)}`"> <nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(item.id)}`">
<cards-author-search-card :author="item" /> <cards-author-search-card :author="item" />
</nuxt-link> </nuxt-link>
@@ -48,7 +48,7 @@
<p v-if="seriesResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Series</p> <p v-if="seriesResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Series</p>
<template v-for="item in seriesResults"> <template v-for="item in seriesResults">
<li :key="item.series.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option"> <li :key="item.series.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
<nuxt-link :to="`/library/${currentLibraryId}/series/${item.series.id}`"> <nuxt-link :to="`/library/${currentLibraryId}/series/${item.series.id}`">
<cards-series-search-card :series="item.series" :book-items="item.books" /> <cards-series-search-card :series="item.series" :book-items="item.books" />
</nuxt-link> </nuxt-link>
@@ -57,7 +57,7 @@
<p v-if="tagResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Tags</p> <p v-if="tagResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Tags</p>
<template v-for="item in tagResults"> <template v-for="item in tagResults">
<li :key="item.name" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option"> <li :key="item.name" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=tags.${$encode(item.name)}`"> <nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=tags.${$encode(item.name)}`">
<cards-tag-search-card :tag="item.name" /> <cards-tag-search-card :tag="item.name" />
</nuxt-link> </nuxt-link>
@@ -97,6 +97,9 @@ export default {
} }
}, },
methods: { methods: {
clickOption() {
this.clearResults()
},
submitSearch() { submitSearch() {
if (!this.search) return if (!this.search) return
var search = this.search var search = this.search
@@ -1,10 +1,10 @@
<template> <template>
<div class="relative ml-8" v-click-outside="clickOutside"> <div ref="wrapper" class="relative ml-4 sm:ml-8" v-click-outside="clickOutside">
<div class="flex items-center justify-center text-gray-300 cursor-pointer h-full" @mousedown.prevent @mouseup.prevent @click="setShowMenu(true)"> <div class="flex items-center justify-center text-gray-300 cursor-pointer h-full" @mousedown.prevent @mouseup.prevent @click="setShowMenu(true)">
<span class="font-mono uppercase text-gray-200">{{ playbackRate.toFixed(1) }}<span class="text-lg"></span></span> <span class="font-mono uppercase text-gray-200 text-sm sm:text-base">{{ playbackRate.toFixed(1) }}<span class="text-base sm:text-lg"></span></span>
</div> </div>
<div v-show="showMenu" class="absolute -top-20 left-0 z-20 bg-bg border-black-200 border shadow-xl rounded-lg" style="left: -92px"> <div v-show="showMenu" class="absolute -top-20 z-20 bg-bg border-black-200 border shadow-xl rounded-lg" :style="{ left: menuLeft + 'px' }">
<div class="absolute -bottom-2 left-0 right-0 w-full flex justify-center"> <div class="absolute -bottom-1.5 right-0 w-full flex justify-center" :style="{ left: arrowLeft + 'px' }">
<div class="arrow-down" /> <div class="arrow-down" />
</div> </div>
<div class="flex items-center h-9 relative overflow-hidden rounded-lg" style="width: 220px"> <div class="flex items-center h-9 relative overflow-hidden rounded-lg" style="width: 220px">
@@ -19,7 +19,7 @@
<div class="w-full py-1 px-4"> <div class="w-full py-1 px-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<ui-icon-btn :disabled="!canDecrement" icon="remove" @click="decrement" /> <ui-icon-btn :disabled="!canDecrement" icon="remove" @click="decrement" />
<p class="px-2 text-3xl">{{ playbackRate }}<span class="text-2xl"></span></p> <p class="px-2 text-2xl sm:text-3xl">{{ playbackRate }}<span class="text-2xl"></span></p>
<ui-icon-btn :disabled="!canIncrement" icon="add" @click="increment" /> <ui-icon-btn :disabled="!canIncrement" icon="add" @click="increment" />
</div> </div>
</div> </div>
@@ -40,7 +40,9 @@ export default {
showMenu: false, showMenu: false,
currentPlaybackRate: 0, currentPlaybackRate: 0,
MIN_SPEED: 0.5, MIN_SPEED: 0.5,
MAX_SPEED: 3 MAX_SPEED: 3,
menuLeft: -92,
arrowLeft: 0
} }
}, },
computed: { computed: {
@@ -80,8 +82,22 @@ export default {
var newPlaybackRate = this.playbackRate - 0.1 var newPlaybackRate = this.playbackRate - 0.1
this.playbackRate = Number(newPlaybackRate.toFixed(1)) this.playbackRate = Number(newPlaybackRate.toFixed(1))
}, },
updateMenuPositions() {
if (!this.$refs.wrapper) return
const boundingBox = this.$refs.wrapper.getBoundingClientRect()
if (boundingBox.left + 110 > window.innerWidth - 10) {
this.menuLeft = window.innerWidth - 230 - boundingBox.left
this.arrowLeft = Math.abs(this.menuLeft) - 92
} else {
this.menuLeft = -92
this.arrowLeft = 0
}
},
setShowMenu(val) { setShowMenu(val) {
if (val) { if (val) {
this.updateMenuPositions()
this.currentPlaybackRate = this.playbackRate this.currentPlaybackRate = this.playbackRate
} else if (this.currentPlaybackRate !== this.playbackRate) { } else if (this.currentPlaybackRate !== this.playbackRate) {
this.$emit('change', this.playbackRate) this.$emit('change', this.playbackRate)
+2 -2
View File
@@ -1,7 +1,7 @@
<template> <template>
<div class="relative" v-click-outside="clickOutside" @mouseover="mouseover" @mouseleave="mouseleave"> <div class="relative" v-click-outside="clickOutside" @mouseover="mouseover" @mouseleave="mouseleave">
<div class="cursor-pointer" @mousedown.prevent @mouseup.prevent @click="clickVolumeIcon"> <div class="cursor-pointer text-gray-300 hover:text-white" @mousedown.prevent @mouseup.prevent @click="clickVolumeIcon">
<span class="material-icons text-3xl">{{ volumeIcon }}</span> <span class="material-icons text-2xl sm:text-3xl">{{ volumeIcon }}</span>
</div> </div>
<transition name="menux"> <transition name="menux">
<div v-show="isOpen" class="volumeMenu h-6 absolute bottom-2 w-28 px-2 bg-bg shadow-sm rounded-lg" style="left: -116px"> <div v-show="isOpen" class="volumeMenu h-6 absolute bottom-2 w-28 px-2 bg-bg shadow-sm rounded-lg" style="left: -116px">
+18 -4
View File
@@ -1,11 +1,14 @@
<template> <template>
<modals-modal v-model="show" name="chapters" :width="500" :height="'unset'"> <modals-modal v-model="show" name="chapters" :width="600" :height="'unset'">
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh"> <div id="chapter-modal-wrapper" ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
<template v-for="chap in chapters"> <template v-for="chap in chapters">
<div :key="chap.id" :id="`chapter-row-${chap.id}`" class="flex items-center px-6 py-3 justify-start cursor-pointer hover:bg-bg relative" :class="chap.id === currentChapterId ? 'bg-yellow-400 bg-opacity-10' : chap.end <= currentChapterStart ? 'bg-success bg-opacity-5' : 'bg-opacity-20'" @click="clickChapter(chap)"> <div :key="chap.id" :id="`chapter-row-${chap.id}`" class="flex items-center px-6 py-3 justify-start cursor-pointer hover:bg-bg relative" :class="chap.id === currentChapterId ? 'bg-yellow-400 bg-opacity-10' : chap.end <= currentChapterStart ? 'bg-success bg-opacity-5' : 'bg-opacity-20'" @click="clickChapter(chap)">
{{ chap.title }} <p class="chapter-title truncate text-sm md:text-base">
{{ chap.title }}
</p>
<span class="font-mono text-xxs sm:text-xs text-gray-400 pl-2 whitespace-nowrap">{{ $elapsedPrettyExtended(chap.end - chap.start) }}</span>
<span class="flex-grow" /> <span class="flex-grow" />
<span class="font-mono text-sm text-gray-300">{{ $secondsToTimestamp(chap.start) }}</span> <span class="font-mono text-xs sm:text-sm text-gray-300">{{ $secondsToTimestamp(chap.start) }}</span>
<div v-show="chap.id === currentChapterId" class="w-0.5 h-full absolute top-0 left-0 bg-yellow-400" /> <div v-show="chap.id === currentChapterId" class="w-0.5 h-full absolute top-0 left-0 bg-yellow-400" />
</div> </div>
@@ -71,3 +74,14 @@ export default {
} }
} }
</script> </script>
<style>
#chapter-modal-wrapper .chapter-title {
max-width: calc(100% - 120px);
}
@media (min-width: 640px) {
#chapter-modal-wrapper .chapter-title {
max-width: calc(100% - 150px);
}
}
</style>
+56
View File
@@ -0,0 +1,56 @@
<template>
<modals-modal v-model="show" :width="300" height="100%">
<template #outer>
<div v-if="title" class="absolute top-7 left-4 z-40" style="max-width: 80%">
<p class="text-white text-lg truncate">{{ title }}</p>
</div>
</template>
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in items">
<li :key="item.value" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" :class="selected === item.value ? 'bg-success bg-opacity-10' : ''" role="option" @click="clickedOption(item.value)">
<div class="relative flex items-center px-3">
<p class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</p>
</div>
</li>
</template>
</ul>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
title: String,
items: {
type: Array,
default: () => []
},
selected: String // optional
},
data() {
return {}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
}
},
methods: {
clickedOption(action) {
this.$emit('action', action)
}
},
mounted() {}
}
</script>
@@ -1,16 +1,16 @@
<template> <template>
<div ref="wrapper" class="hidden absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 rounded-lg items-center justify-center" style="z-index: 51" @click="clickClose"> <div ref="wrapper" class="hidden absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 rounded-lg items-center justify-center" style="z-index: 51" @click="clickClose">
<div class="absolute top-5 right-5 h-12 w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300"> <div class="absolute top-3 right-3 md:top-5 md:right-5 h-8 w-8 md:h-12 md:w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300">
<span class="material-icons text-4xl">close</span> <span class="material-icons text-2xl md:text-4xl">close</span>
</div> </div>
<div ref="content" class="text-white"> <div ref="content" class="text-white">
<form v-if="selectedSeries" @submit.prevent="submitSeriesForm"> <form v-if="selectedSeries" @submit.prevent="submitSeriesForm">
<div class="bg-bg rounded-lg p-8" @click.stop> <div class="bg-bg rounded-lg px-2 py-6 sm:p-6 md:p-8" @click.stop>
<div class="flex"> <div class="flex">
<div class="flex-grow p-1 min-w-80"> <div class="flex-grow p-1 min-w-48 sm:min-w-64 md:min-w-80">
<ui-input-dropdown ref="newSeriesSelect" v-model="selectedSeries.name" :items="existingSeriesNames" :disabled="!selectedSeries.id.startsWith('new')" label="Series Name" /> <ui-input-dropdown ref="newSeriesSelect" v-model="selectedSeries.name" :items="existingSeriesNames" :disabled="!selectedSeries.id.startsWith('new')" label="Series Name" />
</div> </div>
<div class="w-40 p-1"> <div class="w-24 sm:w-28 md:w-40 p-1">
<ui-text-input-with-label v-model="selectedSeries.sequence" label="Sequence" /> <ui-text-input-with-label v-model="selectedSeries.sequence" label="Sequence" />
</div> </div>
</div> </div>
@@ -5,7 +5,7 @@
<p class="font-book text-3xl text-white truncate">Session {{ _session.id }}</p> <p class="font-book text-3xl text-white truncate">Session {{ _session.id }}</p>
</div> </div>
</template> </template>
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden p-6" style="max-height: 80vh"> <div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden p-6" style="max-height: 80vh">
<div class="flex items-center"> <div class="flex items-center">
<p class="text-base text-gray-200">{{ _session.displayTitle }}</p> <p class="text-base text-gray-200">{{ _session.displayTitle }}</p>
<p v-if="_session.displayAuthor" class="text-xs text-gray-400 px-4">by {{ _session.displayAuthor }}</p> <p v-if="_session.displayAuthor" class="text-xs text-gray-400 px-4">by {{ _session.displayAuthor }}</p>
+3 -3
View File
@@ -2,11 +2,11 @@
<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" 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" />
<div class="absolute top-5 right-5 h-12 w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="clickClose"> <div class="absolute top-3 right-3 landscape:top-2 landscape:right-2 md:portrait:top-5 md:portrait:right-5 lg:top-5 lg:right-5 h-8 w-8 landscape:h-8 landscape:w-8 md:portrait:h-12 md:portrait:w-12 lg:w-12 lg:h-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="clickClose">
<span class="material-icons text-4xl">close</span> <span class="material-icons text-2xl landscape:text-2xl md:portrait:text-4xl lg:text-4xl">close</span>
</div> </div>
<slot name="outer" /> <slot name="outer" />
<div ref="content" style="min-width: 400px; min-height: 200px" class="relative text-white" :style="{ height: modalHeight, width: modalWidth, marginTop: contentMarginTop + 'px' }" @mousedown="mousedownModal" @mouseup="mouseupModal" v-click-outside="clickBg"> <div ref="content" style="min-width: 380px; min-height: 200px; max-width: 100vw" class="relative text-white" :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 />
@@ -25,6 +25,9 @@
<ui-text-input-with-label v-model="authorCopy.asin" :disabled="processing" label="ASIN" /> <ui-text-input-with-label v-model="authorCopy.asin" :disabled="processing" label="ASIN" />
</div> </div>
</div> </div>
<div class="p-2">
<ui-text-input-with-label v-model="authorCopy.imagePath" :disabled="processing" label="Photo Path/URL" />
</div>
<div class="p-2"> <div class="p-2">
<ui-textarea-with-label v-model="authorCopy.description" :disabled="processing" label="Description" :rows="8" /> <ui-textarea-with-label v-model="authorCopy.description" :disabled="processing" label="Description" :rows="8" />
</div> </div>
@@ -43,19 +46,13 @@
<script> <script>
export default { export default {
// props: {
// value: Boolean,
// author: {
// type: Object,
// default: () => {}
// }
// },
data() { data() {
return { return {
authorCopy: { authorCopy: {
name: '', name: '',
asin: '', asin: '',
description: '' description: '',
imagePath: ''
}, },
processing: false processing: false
} }
@@ -95,9 +92,10 @@ export default {
this.authorCopy.name = this.author.name this.authorCopy.name = this.author.name
this.authorCopy.asin = this.author.asin this.authorCopy.asin = this.author.asin
this.authorCopy.description = this.author.description this.authorCopy.description = this.author.description
this.authorCopy.imagePath = this.author.imagePath
}, },
async submitForm() { async submitForm() {
var keysToCheck = ['name', 'asin', 'description'] var keysToCheck = ['name', 'asin', 'description', 'imagePath']
var updatePayload = {} var updatePayload = {}
keysToCheck.forEach((key) => { keysToCheck.forEach((key) => {
if (this.authorCopy[key] !== this.author[key]) { if (this.authorCopy[key] !== this.author[key]) {
+22 -7
View File
@@ -1,13 +1,13 @@
<template> <template>
<modals-modal v-model="show" name="edit-book" :width="800" :height="height" :processing="processing" :content-margin-top="75"> <modals-modal v-model="show" name="edit-book" :width="800" :height="height" :processing="processing" :content-margin-top="marginTop">
<template #outer> <template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none"> <div class="absolute top-0 left-0 p-4 landscape:px-4 landscape:py-2 md:portrait:p-5 lg:p-5 w-2/3 overflow-hidden pointer-events-none">
<p class="font-book text-3xl text-white truncate pointer-events-none">{{ title }}</p> <p class="font-book text-xl md:portrait:text-3xl md:landscape:text-lg lg:text-3xl text-white truncate pointer-events-none">{{ title }}</p>
</div> </div>
</template> </template>
<div class="absolute -top-10 left-0 z-10 w-full flex"> <div class="absolute -top-10 left-0 z-10 w-full flex">
<template v-for="tab in availableTabs"> <template v-for="tab in availableTabs">
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div> <div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-0.5 sm:mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
</template> </template>
</div> </div>
@@ -30,6 +30,8 @@ export default {
return { return {
processing: false, processing: false,
libraryItem: null, libraryItem: null,
availableHeight: 0,
marginTop: 0,
tabs: [ tabs: [
{ {
id: 'details', id: 'details',
@@ -133,8 +135,7 @@ export default {
}) })
}, },
height() { height() {
var maxHeightAllowed = window.innerHeight - 150 return Math.min(this.availableHeight, 650)
return Math.min(maxHeightAllowed, 650)
}, },
tabName() { tabName() {
var _tab = this.tabs.find((t) => t.id === this.selectedTab) var _tab = this.tabs.find((t) => t.id === this.selectedTab)
@@ -246,15 +247,29 @@ export default {
} }
}, },
registerListeners() { registerListeners() {
window.addEventListener('orientationchange', this.orientationChange)
this.$eventBus.$on('modal-hotkey', this.hotkey) this.$eventBus.$on('modal-hotkey', this.hotkey)
this.$eventBus.$on(`${this.selectedLibraryItemId}_updated`, this.libraryItemUpdated) this.$eventBus.$on(`${this.selectedLibraryItemId}_updated`, this.libraryItemUpdated)
}, },
unregisterListeners() { unregisterListeners() {
window.removeEventListener('orientationchange', this.orientationChange)
this.$eventBus.$off('modal-hotkey', this.hotkey) this.$eventBus.$off('modal-hotkey', this.hotkey)
this.$eventBus.$off(`${this.selectedLibraryItemId}_updated`, this.libraryItemUpdated) this.$eventBus.$off(`${this.selectedLibraryItemId}_updated`, this.libraryItemUpdated)
},
orientationChange() {
setTimeout(this.setHeight, 50)
},
setHeight() {
const smAndBelow = window.innerWidth < 1024 && window.innerWidth > window.innerHeight
this.marginTop = smAndBelow ? 90 : 75
const heightModifier = smAndBelow ? 95 : 150
this.availableHeight = window.innerHeight - heightModifier
} }
}, },
mounted() {}, mounted() {
this.setHeight()
},
beforeDestroy() { beforeDestroy() {
this.unregisterListeners() this.unregisterListeners()
} }
+6 -6
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6 relative"> <div class="w-full h-full overflow-hidden overflow-y-auto px-2 sm:px-4 py-6 relative">
<div class="flex"> <div class="flex flex-wrap">
<div class="relative"> <div class="relative">
<covers-book-cover :library-item="libraryItem" :book-cover-aspect-ratio="bookCoverAspectRatio" /> <covers-book-cover :library-item="libraryItem" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<!-- book cover overlay --> <!-- book cover overlay -->
@@ -11,14 +11,14 @@
</div> </div>
</div> </div>
</div> </div>
<div class="flex-grow pl-6 pr-2"> <div class="flex-grow sm:pl-2 md:pl-6 sm:pr-2 mt-2 md:mt-0">
<div class="flex items-center"> <div class="flex items-center">
<div v-if="userCanUpload" class="w-40 pr-2 pt-4" style="min-width: 160px"> <div v-if="userCanUpload" class="w-10 md:w-40 pr-2 pt-4 md:min-w-32">
<ui-file-input ref="fileInput" @change="fileUploadSelected">Upload Cover</ui-file-input> <ui-file-input ref="fileInput" @change="fileUploadSelected"><span class="hidden md:inline-block">Upload Cover</span><span class="material-icons inline-block md:!hidden">upload</span></ui-file-input>
</div> </div>
<form @submit.prevent="submitForm" class="flex flex-grow"> <form @submit.prevent="submitForm" class="flex flex-grow">
<ui-text-input-with-label v-model="imageUrl" label="Cover Image URL" /> <ui-text-input-with-label v-model="imageUrl" label="Cover Image URL" />
<ui-btn color="success" type="submit" :padding-x="4" class="mt-5 ml-3 w-24">Update</ui-btn> <ui-btn color="success" type="submit" :padding-x="4" class="mt-5 ml-2 sm:ml-3 w-24">Update</ui-btn>
</form> </form>
</div> </div>
@@ -5,23 +5,24 @@
<widgets-podcast-details-edit v-else ref="itemDetailsEdit" :library-item="libraryItem" @submit="saveAndClose" /> <widgets-podcast-details-edit v-else ref="itemDetailsEdit" :library-item="libraryItem" @submit="saveAndClose" />
</div> </div>
<div class="absolute bottom-0 left-0 w-full py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'border-t border-white border-opacity-5'"> <div class="absolute bottom-0 left-0 w-full py-2 md:py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'border-t border-white border-opacity-5'">
<div class="flex items-center px-4"> <div class="flex items-center px-4">
<ui-btn v-if="userCanDelete" color="error" type="button" class="h-8" :padding-x="3" small @click.stop.prevent="removeItem">Remove</ui-btn> <ui-btn v-if="userCanDelete" color="error" type="button" class="h-8 hidden md:block" :padding-x="3" small @click.stop.prevent="removeItem">Remove</ui-btn>
<ui-icon-btn bg-color="error" icon="delete" class="md:hidden" :size="7" icon-font-size="1rem" @click.stop.prevent="removeItem" />
<div class="flex-grow" /> <div class="flex-grow" />
<ui-tooltip v-if="mediaType == 'book'" :disabled="!!quickMatching" :text="`(Root User Only) Populate empty book details & cover with first book result from '${libraryProvider}'. Does not overwrite details.`" direction="bottom" class="mr-4"> <ui-tooltip v-if="mediaType == 'book'" :disabled="!!quickMatching" :text="`(Root User Only) Populate empty book details & cover with first book result from '${libraryProvider}'. Does not overwrite details.`" direction="bottom" class="mr-2 md:mr-4">
<ui-btn v-if="userIsAdminOrUp" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">Quick Match</ui-btn> <ui-btn v-if="userIsAdminOrUp" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">Quick Match</ui-btn>
</ui-tooltip> </ui-tooltip>
<ui-tooltip :disabled="!!libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="mr-4"> <ui-tooltip :disabled="!!libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="mr-2 md:mr-4">
<ui-btn v-if="userIsAdminOrUp && !isFile" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn> <ui-btn v-if="userIsAdminOrUp && !isFile" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn>
</ui-tooltip> </ui-tooltip>
<ui-btn @click="save" class="mx-2">Save</ui-btn> <ui-btn @click="save" class="mx-2 hidden md:block">Save</ui-btn>
<ui-btn @click="saveAndClose">Save & Close</ui-btn> <ui-btn @click="saveAndClose">Save<span class="hidden md:inline-block">&nbsp;& Close</span></ui-btn>
</div> </div>
</div> </div>
</div> </div>
@@ -2,13 +2,13 @@
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6"> <div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
<!-- Merge to m4b --> <!-- Merge to m4b -->
<div v-if="showM4bDownload" class="w-full border border-black-200 p-4 my-8"> <div v-if="showM4bDownload" class="w-full border border-black-200 p-4 my-8">
<div class="flex items-center"> <div class="flex flex-wrap items-center">
<div> <div>
<p class="text-lg">Make M4B Audiobook File <span class="text-error">*</span></p> <p class="text-lg">Make M4B Audiobook File <span class="text-error">*</span></p>
<p class="max-w-sm text-sm pt-2 text-gray-300">Generate a .M4B audiobook file with embedded metadata, cover image, and chapters. <br /><span class="text-warning">*</span> Does not delete existing audio files.</p> <p class="max-w-sm text-sm pt-2 text-gray-300">Generate a .M4B audiobook file with embedded metadata, cover image, and chapters. <br /><span class="text-warning">*</span> Does not delete existing audio files.</p>
</div> </div>
<div class="flex-grow" /> <div class="flex-grow" />
<div> <div class="mt-2 md:mt-0">
<p v-if="abmergeStatus === $constants.DownloadStatus.FAILED" class="text-error mb-2">Download Failed</p> <p v-if="abmergeStatus === $constants.DownloadStatus.FAILED" class="text-error mb-2">Download Failed</p>
<p v-if="abmergeStatus === $constants.DownloadStatus.READY" class="text-success mb-2">Download Ready!</p> <p v-if="abmergeStatus === $constants.DownloadStatus.READY" class="text-success mb-2">Download Ready!</p>
<p v-if="abmergeStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p> <p v-if="abmergeStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
@@ -69,7 +69,7 @@
<p v-if="showM4bDownload" class="text-left text-base mb-4 py-4"> <p v-if="showM4bDownload" class="text-left text-base mb-4 py-4">
<span class="text-error">* <strong>Experimental</strong></span <span class="text-error">* <strong>Experimental</strong></span
>&nbsp;-&nbsp;M4b merge can take several minutes and will be stored in <span class="bg-primary bg-opacity-75 font-mono p-1 text-base">/metadata/downloads</span>. After the download is ready, it will remain available for 60 minutes, then be deleted. Download will timeout after 20 minutes. >&nbsp;-&nbsp;M4b merge can take several minutes and will be stored in <span class="bg-primary bg-opacity-75 font-mono p-1 text-base">/metadata/downloads</span>. After the download is ready, it will remain available for 60 minutes, then be deleted. Download will timeout after 30 minutes.
</p> </p>
<!-- <p v-if="isSingleM4b" class="text-lg text-center my-8">Audiobook is already a single m4b!</p> --> <!-- <p v-if="isSingleM4b" class="text-lg text-center my-8">Audiobook is already a single m4b!</p> -->
+1 -1
View File
@@ -428,7 +428,7 @@ export default {
) )
updatePayload.metadata.authors = authorPayload updatePayload.metadata.authors = authorPayload
} else if (key === 'narrator') { } else if (key === 'narrator') {
updatePayload.metadata.narrators = [this.selectedMatch[key]] updatePayload.metadata.narrators = this.selectedMatch[key].split(',').map((v) => v.trim())
} else if (key === 'genres') { } else if (key === 'genres') {
updatePayload.metadata.genres = this.selectedMatch[key].split(',').map((v) => v.trim()) updatePayload.metadata.genres = this.selectedMatch[key].split(',').map((v) => v.trim())
} else if (key === 'tags') { } else if (key === 'tags') {
@@ -3,21 +3,21 @@
<div class="flex-grow" /> <div class="flex-grow" />
<template v-if="!loading"> <template v-if="!loading">
<div class="cursor-pointer flex items-center justify-center text-gray-300 mr-4 md:mr-8" @mousedown.prevent @mouseup.prevent @click.stop="prevChapter"> <div class="cursor-pointer flex items-center justify-center text-gray-300 mr-4 md:mr-8" @mousedown.prevent @mouseup.prevent @click.stop="prevChapter">
<span class="material-icons text-3xl">first_page</span> <span class="material-icons text-2xl sm:text-3xl">first_page</span>
</div> </div>
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpBackward"> <div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpBackward">
<span class="material-icons text-3xl">replay_10</span> <span class="material-icons text-2xl sm:text-3xl">replay_10</span>
</div> </div>
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-4 md:mx-8" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPause"> <div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-4 md:mx-8" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPause">
<span class="material-icons">{{ seekLoading ? 'autorenew' : paused ? 'play_arrow' : 'pause' }}</span> <span class="material-icons">{{ seekLoading ? 'autorenew' : paused ? 'play_arrow' : 'pause' }}</span>
</div> </div>
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpForward"> <div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpForward">
<span class="material-icons text-3xl">forward_10</span> <span class="material-icons text-2xl sm:text-3xl">forward_10</span>
</div> </div>
<div class="flex items-center justify-center ml-4 md:ml-8" :class="hasNextChapter ? 'text-gray-300 cursor-pointer' : 'text-gray-500'" @mousedown.prevent @mouseup.prevent @click.stop="nextChapter"> <div class="flex items-center justify-center ml-4 md:ml-8" :class="hasNextChapter ? 'text-gray-300 cursor-pointer' : 'text-gray-500'" @mousedown.prevent @mouseup.prevent @click.stop="nextChapter">
<span class="material-icons text-3xl">last_page</span> <span class="material-icons text-2xl sm:text-3xl">last_page</span>
</div> </div>
<controls-playback-speed-control v-model="playbackRate" @input="playbackRateUpdated" @change="playbackRateChanged" /> <controls-playback-speed-control v-model="playbackRateInput" @input="playbackRateUpdated" @change="playbackRateChanged" />
</template> </template>
<template v-else> <template v-else>
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8 animate-spin"> <div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8 animate-spin">
@@ -40,7 +40,16 @@ export default {
data() { data() {
return {} return {}
}, },
computed: {}, computed: {
playbackRateInput: {
get() {
return this.playbackRate
},
set(val) {
this.$emit('update:playbackRate', val)
}
}
},
methods: { methods: {
playPause() { playPause() {
this.$emit('playPause') this.$emit('playPause')
+39 -11
View File
@@ -8,7 +8,7 @@
<div ref="trackCursor" class="h-full w-0.5 bg-gray-50 absolute top-0 left-0 opacity-0 pointer-events-none" /> <div ref="trackCursor" class="h-full w-0.5 bg-gray-50 absolute top-0 left-0 opacity-0 pointer-events-none" />
<div v-if="loading" class="h-full w-1/4 absolute left-0 top-0 loadingTrack pointer-events-none bg-white bg-opacity-25" /> <div v-if="loading" class="h-full w-1/4 absolute left-0 top-0 loadingTrack pointer-events-none bg-white bg-opacity-25" />
</div> </div>
<div ref="track" class="w-full h-2 relative overflow-hidden"> <div class="w-full h-2 relative overflow-hidden" :class="useChapterTrack ? 'opacity-0' : ''">
<template v-for="(tick, index) in chapterTicks"> <template v-for="(tick, index) in chapterTicks">
<div :key="index" :style="{ left: tick.left + 'px' }" class="absolute top-0 w-px bg-white bg-opacity-30 h-1 pointer-events-none" /> <div :key="index" :style="{ left: tick.left + 'px' }" class="absolute top-0 w-px bg-white bg-opacity-30 h-1 pointer-events-none" />
</template> </template>
@@ -34,6 +34,10 @@ export default {
chapters: { chapters: {
type: Array, type: Array,
default: () => [] default: () => []
},
currentChapter: {
type: Object,
default: () => {}
} }
}, },
data() { data() {
@@ -46,7 +50,8 @@ export default {
trackOffsetLeft: 16, // Track is 16px from edge trackOffsetLeft: 16, // Track is 16px from edge
playedTrackWidth: 0, playedTrackWidth: 0,
readyTrackWidth: 0, readyTrackWidth: 0,
bufferTrackWidth: 0 bufferTrackWidth: 0,
useChapterTrack: false
} }
}, },
watch: { watch: {
@@ -57,14 +62,30 @@ export default {
} }
} }
}, },
computed: {}, computed: {
currentChapterDuration() {
if (!this.currentChapter) return 0
return this.currentChapter.end - this.currentChapter.start
},
currentChapterStart() {
if (!this.currentChapter) return 0
return this.currentChapter.start
}
},
methods: { methods: {
setUseChapterTrack(useChapterTrack) {
this.useChapterTrack = useChapterTrack
this.updateBufferTrack()
this.updatePlayedTrackWidth()
},
clickTrack(e) { clickTrack(e) {
if (this.loading) return if (this.loading) return
var offsetX = e.offsetX var offsetX = e.offsetX
var perc = offsetX / this.trackWidth var perc = offsetX / this.trackWidth
var time = perc * this.duration const baseTime = this.useChapterTrack ? this.currentChapterStart : 0;
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration;
const time = baseTime + (perc * duration);
if (isNaN(time) || time === null) { if (isNaN(time) || time === null) {
console.error('Invalid time', perc, time) console.error('Invalid time', perc, time)
return return
@@ -76,7 +97,10 @@ export default {
this.updateBufferTrack() this.updateBufferTrack()
}, },
updateBufferTrack() { updateBufferTrack() {
var bufferlen = (this.bufferTime / this.duration) * this.trackWidth const time = this.useChapterTrack ? Math.max(0, this.bufferTime - this.currentChapterStart) : this.bufferTime
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
var bufferlen = (time / duration) * this.trackWidth
bufferlen = Math.round(bufferlen) bufferlen = Math.round(bufferlen)
if (this.bufferTrackWidth === bufferlen || !this.$refs.bufferTrack) return if (this.bufferTrackWidth === bufferlen || !this.$refs.bufferTrack) return
if (this.$refs.bufferTrack) this.$refs.bufferTrack.style.width = bufferlen + 'px' if (this.$refs.bufferTrack) this.$refs.bufferTrack.style.width = bufferlen + 'px'
@@ -97,8 +121,10 @@ export default {
this.updatePlayedTrackWidth() this.updatePlayedTrackWidth()
}, },
updatePlayedTrackWidth() { updatePlayedTrackWidth() {
var perc = this.currentTime / this.duration const time = this.useChapterTrack ? Math.max(0, this.currentTime - this.currentChapterStart) : this.currentTime
var ptWidth = Math.round(perc * this.trackWidth) const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
var ptWidth = Math.round((time / duration) * this.trackWidth)
if (this.playedTrackWidth === ptWidth) { if (this.playedTrackWidth === ptWidth) {
return return
} }
@@ -116,9 +142,11 @@ export default {
}, },
mousemoveTrack(e) { mousemoveTrack(e) {
var offsetX = e.offsetX var offsetX = e.offsetX
var time = (offsetX / this.trackWidth) * this.duration
console.log('Mousemove track', this.trackWidth, this.duration) const baseTime = this.useChapterTrack ? this.currentChapterStart : 0;
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration;
const progressTime = (offsetX / this.trackWidth) * duration;
const totalTime = baseTime + progressTime;
if (this.$refs.hoverTimestamp) { if (this.$refs.hoverTimestamp) {
var width = this.$refs.hoverTimestamp.clientWidth var width = this.$refs.hoverTimestamp.clientWidth
@@ -139,9 +167,9 @@ export default {
this.$refs.hoverTimestampArrow.style.left = posLeft + 'px' this.$refs.hoverTimestampArrow.style.left = posLeft + 'px'
} }
if (this.$refs.hoverTimestampText) { if (this.$refs.hoverTimestampText) {
var hoverText = this.$secondsToTimestamp(time) var hoverText = this.$secondsToTimestamp(progressTime)
var chapter = this.chapters.find((chapter) => chapter.start <= time && time < chapter.end) var chapter = this.chapters.find((chapter) => chapter.start <= totalTime && totalTime < chapter.end)
if (chapter && chapter.title) { if (chapter && chapter.title) {
hoverText += ` - ${chapter.title}` hoverText += ` - ${chapter.title}`
} }
+54 -18
View File
@@ -1,40 +1,48 @@
<template> <template>
<div class="w-full -mt-6"> <div class="w-full -mt-6">
<div class="w-full relative mb-1"> <div class="w-full relative mb-1">
<div class="absolute -top-10 md:top-0 right-0 md:right-2 flex items-center h-full"> <div class="absolute -top-10 md:top-0 right-0 lg:right-2 flex items-center h-full">
<!-- <span class="material-icons text-2xl cursor-pointer" @click="toggleFullscreen(true)">expand_less</span> --> <!-- <span class="material-icons text-2xl cursor-pointer" @click="toggleFullscreen(true)">expand_less</span> -->
<controls-volume-control ref="volumeControl" v-model="volume" @input="setVolume" class="mx-2 hidden md:block" /> <controls-volume-control ref="volumeControl" v-model="volume" @input="setVolume" class="mx-2 hidden md:block" />
<div class="cursor-pointer text-gray-300 mx-1 md:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showSleepTimer')"> <div class="cursor-pointer text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showSleepTimer')">
<span v-if="!sleepTimerSet" class="material-icons" style="font-size: 1.7rem">snooze</span> <span v-if="!sleepTimerSet" class="material-icons text-2xl sm:text-2.5xl">snooze</span>
<div v-else class="flex items-center"> <div v-else class="flex items-center">
<span class="material-icons text-lg text-warning">snooze</span> <span class="material-icons text-lg text-warning">snooze</span>
<p class="text-xl text-warning font-mono font-semibold text-center px-0.5 pb-0.5" style="min-width: 30px">{{ sleepTimerRemainingString }}</p> <p class="text-xl text-warning font-mono font-semibold text-center px-0.5 pb-0.5" style="min-width: 30px">{{ sleepTimerRemainingString }}</p>
</div> </div>
</div> </div>
<div v-if="!isPodcast" class="cursor-pointer text-gray-300 mx-1 md:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showBookmarks')"> <div v-if="!isPodcast" class="cursor-pointer text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showBookmarks')">
<span class="material-icons" style="font-size: 1.7rem">{{ bookmarks.length ? 'bookmarks' : 'bookmark_border' }}</span> <span class="material-icons text-2xl sm:text-2.5xl">{{ bookmarks.length ? 'bookmarks' : 'bookmark_border' }}</span>
</div> </div>
<div v-if="chapters.length" class="cursor-pointer text-gray-300 mx-1 md:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="showChapters"> <div v-if="chapters.length" class="cursor-pointer text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="showChapters">
<span class="material-icons text-3xl">format_list_bulleted</span> <span class="material-icons text-2xl sm:text-3xl">format_list_bulleted</span>
</div> </div>
<ui-tooltip v-if="chapters.length" direction="top" :text="useChapterTrack ? 'Use full track' : 'Use chapter track'">
<div class="cursor-pointer text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="setUseChapterTrack">
<span class="material-icons text-2xl sm:text-3xl transform transition-transform" :class="useChapterTrack ? 'rotate-180' : ''">timelapse</span>
</div>
</ui-tooltip>
</div> </div>
<player-playback-controls :loading="loading" :seek-loading="seekLoading" :playback-rate="playbackRate" :paused="paused" :has-next-chapter="hasNextChapter" @prevChapter="prevChapter" @nextChapter="nextChapter" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setPlaybackRate="setPlaybackRate" @playPause="playPause" /> <player-playback-controls :loading="loading" :seek-loading="seekLoading" :playback-rate.sync="playbackRate" :paused="paused" :has-next-chapter="hasNextChapter" @prevChapter="prevChapter" @nextChapter="nextChapter" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setPlaybackRate="setPlaybackRate" @playPause="playPause" />
</div> </div>
<player-track-bar ref="trackbar" :loading="loading" :chapters="chapters" :duration="duration" @seek="seek" /> <player-track-bar ref="trackbar" :loading="loading" :chapters="chapters" :duration="duration" :current-chapter="currentChapter" @seek="seek" />
<div class="flex"> <div class="flex">
<p ref="currentTimestamp" class="font-mono text-sm text-gray-100 pointer-events-auto">00:00:00</p> <p ref="currentTimestamp" class="font-mono text-xxs sm:text-sm text-gray-100 pointer-events-auto">00:00:00</p>
<p class="font-mono text-sm text-gray-100 pointer-events-auto">&nbsp;/&nbsp;{{ progressPercent }}%</p> <p class="font-mono text-sm hidden sm:block text-gray-100 pointer-events-auto">&nbsp;/&nbsp;{{ progressPercent }}%</p>
<div class="flex-grow" /> <div class="flex-grow" />
<p class="text-sm text-gray-300 pt-0.5">{{ currentChapterName }}</p> <p class="text-xs sm:text-sm text-gray-300 pt-0.5">
{{ currentChapterName }} <span v-if="useChapterTrack" class="text-xs text-gray-400">&nbsp;({{ currentChapterIndex + 1 }} of {{ chapters.length }})</span>
</p>
<div class="flex-grow" /> <div class="flex-grow" />
<p class="font-mono text-sm text-gray-100 pointer-events-auto">{{ timeRemainingPretty }}</p> <p class="font-mono text-xxs sm:text-sm text-gray-100 pointer-events-auto">{{ timeRemainingPretty }}</p>
</div> </div>
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" /> <modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
@@ -66,7 +74,8 @@ export default {
seekLoading: false, seekLoading: false,
showChaptersModal: false, showChaptersModal: false,
currentTime: 0, currentTime: 0,
duration: 0 duration: 0,
useChapterTrack: false
} }
}, },
computed: { computed: {
@@ -86,6 +95,10 @@ export default {
return this.$store.getters['user/getToken'] return this.$store.getters['user/getToken']
}, },
timeRemaining() { timeRemaining() {
if (this.useChapterTrack && this.currentChapter) {
var currChapTime = this.currentTime - this.currentChapter.start
return (this.currentChapterDuration - currChapTime) / this.playbackRate
}
return (this.duration - this.currentTime) / this.playbackRate return (this.duration - this.currentTime) / this.playbackRate
}, },
timeRemainingPretty() { timeRemainingPretty() {
@@ -95,8 +108,11 @@ export default {
return '-' + this.$secondsToTimestamp(this.timeRemaining) return '-' + this.$secondsToTimestamp(this.timeRemaining)
}, },
progressPercent() { progressPercent() {
if (!this.duration) return 0 const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
return Math.round((100 * this.currentTime) / this.duration) const time = this.useChapterTrack ? Math.max(this.currentTime - this.currentChapterStart) : this.currentTime
if (!duration) return 0
return Math.round((100 * time) / duration)
}, },
currentChapter() { currentChapter() {
return this.chapters.find((chapter) => chapter.start <= this.currentTime && this.currentTime < chapter.end) return this.chapters.find((chapter) => chapter.start <= this.currentTime && this.currentTime < chapter.end)
@@ -104,6 +120,14 @@ export default {
currentChapterName() { currentChapterName() {
return this.currentChapter ? this.currentChapter.title : '' return this.currentChapter ? this.currentChapter.title : ''
}, },
currentChapterDuration() {
if (!this.currentChapter) return 0
return this.currentChapter.end - this.currentChapter.start
},
currentChapterStart() {
if (!this.currentChapter) return 0
return this.currentChapter.start
},
isFullscreen() { isFullscreen() {
return this.$store.state.playerIsFullscreen return this.$store.state.playerIsFullscreen
}, },
@@ -192,6 +216,16 @@ export default {
this.seek(chapter.start) this.seek(chapter.start)
this.showChaptersModal = false this.showChaptersModal = false
}, },
setUseChapterTrack() {
var useChapterTrack = !this.useChapterTrack
this.useChapterTrack = useChapterTrack
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(useChapterTrack)
this.$store.dispatch('user/updateUserSettings', { useChapterTrack }).catch((err) => {
console.error('Failed to update settings', err)
})
this.updateTimestamp()
},
seek(time) { seek(time) {
this.$emit('seek', time) this.$emit('seek', time)
}, },
@@ -239,10 +273,10 @@ export default {
console.error('No timestamp el') console.error('No timestamp el')
return return
} }
var currTimeClean = this.$secondsToTimestamp(this.currentTime) const time = this.useChapterTrack ? Math.max(0, this.currentTime - this.currentChapterStart) : this.currentTime
var currTimeClean = this.$secondsToTimestamp(time)
ts.innerText = currTimeClean ts.innerText = currTimeClean
}, },
setBufferTime(bufferTime) { setBufferTime(bufferTime) {
if (this.$refs.trackbar) this.$refs.trackbar.setBufferTime(bufferTime) if (this.$refs.trackbar) this.$refs.trackbar.setBufferTime(bufferTime)
}, },
@@ -252,6 +286,8 @@ export default {
}, },
init() { init() {
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1 this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
this.useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack') || false
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack)
this.$emit('setPlaybackRate', this.playbackRate) this.$emit('setPlaybackRate', this.playbackRate)
}, },
settingsUpdated(settings) { settingsUpdated(settings) {
+1 -1
View File
@@ -2,7 +2,7 @@
<div id="heatmap" class="w-full"> <div id="heatmap" class="w-full">
<div class="mx-auto" :style="{ height: innerHeight + 160 + 'px', width: innerWidth + 52 + 'px' }" style="background-color: rgba(13, 17, 23, 0)"> <div class="mx-auto" :style="{ height: innerHeight + 160 + 'px', width: innerWidth + 52 + 'px' }" style="background-color: rgba(13, 17, 23, 0)">
<p class="mb-2 px-1 text-sm text-gray-200">{{ Object.values(daysListening).length }} listening sessions in the last year</p> <p class="mb-2 px-1 text-sm text-gray-200">{{ Object.values(daysListening).length }} listening sessions in the last year</p>
<div class="border border-opacity-25 rounded py-2 w-full" style="background-color: #232323" :style="{ height: innerHeight + 80 + 'px' }"> <div class="border border-white border-opacity-25 rounded py-2 w-full" style="background-color: #232323" :style="{ height: innerHeight + 80 + 'px' }">
<div :style="{ width: innerWidth + 'px', height: innerHeight + 'px' }" class="ml-10 mt-5 absolute" @mouseover="mouseover" @mouseout="mouseout"> <div :style="{ width: innerWidth + 'px', height: innerHeight + 'px' }" class="ml-10 mt-5 absolute" @mouseover="mouseover" @mouseout="mouseout">
<div v-for="dayLabel in dayLabels" :key="dayLabel.label" :style="dayLabel.style" class="absolute top-0 left-0 text-gray-300">{{ dayLabel.label }}</div> <div v-for="dayLabel in dayLabels" :key="dayLabel.label" :style="dayLabel.style" class="absolute top-0 left-0 text-gray-300">{{ dayLabel.label }}</div>
+6 -2
View File
@@ -192,7 +192,11 @@ export default {
} }
#accounts tr:nth-child(even) { #accounts tr:nth-child(even) {
background-color: #3a3a3a; background-color: #373838;
}
#accounts tr:nth-child(odd) {
background-color: #2f2f2f;
} }
#accounts tr:hover { #accounts tr:hover {
@@ -204,6 +208,6 @@ export default {
font-weight: 600; font-weight: 600;
padding-top: 5px; padding-top: 5px;
padding-bottom: 5px; padding-bottom: 5px;
background-color: #333; background-color: #272727
} }
</style> </style>
@@ -9,7 +9,7 @@
<draggable v-if="libraryCopies.length" :list="libraryCopies" v-bind="dragOptions" class="list-group" draggable=".item" tag="div" @start="startDrag" @end="endDrag"> <draggable v-if="libraryCopies.length" :list="libraryCopies" v-bind="dragOptions" class="list-group" draggable=".item" tag="div" @start="startDrag" @end="endDrag">
<template v-for="library in libraryCopies"> <template v-for="library in libraryCopies">
<div :key="library.id" class="item"> <div :key="library.id" class="item">
<tables-library-item :library="library" :selected="currentLibraryId === library.id" :show-edit="true" :dragging="drag" @edit="editLibrary" @click="setLibrary" /> <tables-library-item :library="library" :selected="currentLibraryId === library.id" :dragging="drag" @edit="editLibrary" @click="setLibrary" />
</div> </div>
</template> </template>
</draggable> </draggable>
@@ -7,18 +7,26 @@
</svg> </svg>
<p class="text-xl font-book pl-4 hover:underline cursor-pointer" @click.stop="$emit('click', library)">{{ library.name }}</p> <p class="text-xl font-book pl-4 hover:underline cursor-pointer" @click.stop="$emit('click', library)">{{ library.name }}</p>
<div class="flex-grow" /> <div class="flex-grow" />
<ui-btn v-show="isHovering && !libraryScan" small color="success" @click.stop="scan">Scan</ui-btn> <ui-btn v-show="isHovering && !libraryScan" class="hidden md:block" small color="success" @click.stop="scan">Scan</ui-btn>
<ui-btn v-show="isHovering && !libraryScan" small color="bg" class="ml-2" @click.stop="forceScan">Force Re-Scan</ui-btn> <ui-btn v-show="isHovering && !libraryScan" small color="bg" class="ml-2 hidden md:block" @click.stop="forceScan">Force Re-Scan</ui-btn>
<ui-btn v-show="isHovering && !libraryScan && isBookLibrary" small color="bg" class="ml-2" @click.stop="matchAll">Match Books</ui-btn> <ui-btn v-show="isHovering && !libraryScan && isBookLibrary" small color="bg" class="ml-2 hidden md:block" @click.stop="matchAll">Match Books</ui-btn>
<span v-show="isHovering && !libraryScan && showEdit" class="material-icons text-xl text-gray-300 hover:text-gray-50 ml-4 cursor-pointer" @click.stop="editClick">edit</span> <span v-if="isHovering && !libraryScan" class="!hidden md:!block material-icons text-xl text-gray-300 hover:text-gray-50 ml-4 cursor-pointer" @click.stop="editClick">edit</span>
<span v-show="!libraryScan && isHovering && showEdit && !isDeleting" class="material-icons text-xl text-gray-300 ml-3" :class="isMain ? 'text-opacity-5 cursor-not-allowed' : 'hover:text-gray-50 cursor-pointer'" @click.stop="deleteClick">delete</span> <span v-if="!libraryScan && isHovering && !isDeleting" class="!hidden md:!block material-icons text-xl text-gray-300 ml-3 hover:text-gray-50 cursor-pointer" @click.stop="deleteClick">delete</span>
<!-- For mobile -->
<span v-if="!libraryScan" class="!block md:!hidden material-icons text-xl text-gray-300 ml-4 cursor-pointer" @click.stop="editClick">edit</span>
<span v-if="!libraryScan && !isDeleting" class="!block md:!hidden material-icons text-2xl text-gray-300 ml-3 cursor-pointer" @click.stop="showMenu">more_vert</span>
<div v-show="isDeleting" class="text-xl text-gray-300 ml-3 animate-spin"> <div v-show="isDeleting" class="text-xl text-gray-300 ml-3 animate-spin">
<svg viewBox="0 0 24 24" class="w-6 h-6"> <svg viewBox="0 0 24 24" class="w-6 h-6">
<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" />
</svg> </svg>
</div> </div>
<span class="material-icons text-xl text-gray-400 hover:text-gray-50 ml-4">reorder</span>
<!-- For mobile -->
<modals-dialog v-model="showMobileMenu" :title="menuTitle" :items="mobileMenuItems" @action="mobileMenuAction" />
</div> </div>
</template> </template>
@@ -30,22 +38,19 @@ export default {
default: () => {} default: () => {}
}, },
selected: Boolean, selected: Boolean,
showEdit: Boolean,
dragging: Boolean dragging: Boolean
}, },
data() { data() {
return { return {
mouseover: false, mouseover: false,
isDeleting: false isDeleting: false,
showMobileMenu: false
} }
}, },
computed: { computed: {
isHovering() { isHovering() {
return this.mouseover && !this.dragging return this.mouseover && !this.dragging
}, },
isMain() {
return this.library.id === 'main'
},
libraryScan() { libraryScan() {
return this.$store.getters['scanners/getLibraryScan'](this.library.id) return this.$store.getters['scanners/getLibraryScan'](this.library.id)
}, },
@@ -54,9 +59,50 @@ export default {
}, },
isBookLibrary() { isBookLibrary() {
return this.mediaType === 'book' return this.mediaType === 'book'
},
menuTitle() {
return this.library.name
},
mobileMenuItems() {
const items = [
{
text: 'Scan',
value: 'scan'
},
{
text: 'Force Re-Scan',
value: 'force-scan'
}
]
if (this.isBookLibrary) {
items.push({
text: 'Match Books',
value: 'match-books'
})
}
items.push({
text: 'Delete',
value: 'delete'
})
return items
} }
}, },
methods: { methods: {
mobileMenuAction(action) {
this.showMobileMenu = false
if (action === 'scan') {
this.scan()
} else if (action === 'force-scan') {
this.forceScan()
} else if (action === 'match-books') {
this.matchAll()
} else if (action === 'delete') {
this.deleteClick()
}
},
showMenu() {
this.showMobileMenu = true
},
matchAll() { matchAll() {
this.$axios this.$axios
.$post(`/api/libraries/${this.library.id}/matchall`) .$post(`/api/libraries/${this.library.id}/matchall`)
@@ -97,7 +143,6 @@ export default {
} }
}, },
deleteClick() { deleteClick() {
if (this.isMain) return
if (confirm(`Are you sure you want to permanently delete library "${this.library.name}"?`)) { if (confirm(`Are you sure you want to permanently delete library "${this.library.name}"?`)) {
this.isDeleting = true this.isDeleting = true
this.$axios this.$axios
+2 -1
View File
@@ -1,7 +1,8 @@
<template> <template>
<div> <div>
<input ref="fileInput" type="file" :accept="accept" class="hidden" @change="inputChanged" /> <input ref="fileInput" type="file" :accept="accept" class="hidden" @change="inputChanged" />
<ui-btn @click="clickUpload" color="primary" type="text"><slot /></ui-btn> <ui-btn @click="clickUpload" color="primary" class="hidden md:block" type="text"><slot /></ui-btn>
<ui-icon-btn @click="clickUpload" icon="upload" class="block md:hidden" />
</div> </div>
</template> </template>
+6 -6
View File
@@ -1,14 +1,14 @@
<template> <template>
<div v-if="currentLibrary" class="relative w-36 h-8" v-click-outside="clickOutsideObj"> <div v-if="currentLibrary" class="relative sm:w-36 h-8 px-1.5" v-click-outside="clickOutsideObj">
<button type="button" :disabled="disabled" class="relative h-full w-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu"> <button type="button" :disabled="disabled" class="w-10 sm:w-36 relative h-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm px-2 text-left text-sm focus:outline-none cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
<span class="flex items-center"> <span class="flex items-center justify-center sm:justify-start">
<widgets-library-icon :icon="currentLibraryIcon" class="mr-2" /> <widgets-library-icon :icon="currentLibraryIcon" class="sm:mr-2" />
<span class="block truncate text-sm">{{ currentLibrary.name }}</span> <span class="hidden sm:block">{{ currentLibrary.name }}</span>
</span> </span>
</button> </button>
<transition name="menu"> <transition name="menu">
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox"> <ul v-show="showMenu" class="absolute z-10 -mt-px w-36 bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox">
<template v-for="library in librariesFiltered"> <template v-for="library in librariesFiltered">
<li :key="library.id" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="selectLibrary(library)"> <li :key="library.id" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="selectLibrary(library)">
<div class="flex items-center px-3"> <div class="flex items-center px-3">
+1 -1
View File
@@ -4,7 +4,7 @@
<div ref="wrapper" class="relative"> <div ref="wrapper" class="relative">
<form @submit.prevent="submitForm"> <form @submit.prevent="submitForm">
<div ref="inputWrapper" style="min-height: 36px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-1" :class="wrapperClass" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent> <div ref="inputWrapper" style="min-height: 36px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-1" :class="wrapperClass" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
<div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 mx-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center relative"> <div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 mx-0.5 my-0.5 text-xs bg-bg flex flex-nowrap break-all items-center relative">
<div class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg bg-opacity-75 flex items-center justify-end cursor-pointer"> <div class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg bg-opacity-75 flex items-center justify-end cursor-pointer">
<span v-if="showEdit" class="material-icons text-white hover:text-warning" style="font-size: 1.1rem" @click.stop="editItem(item)">edit</span> <span v-if="showEdit" class="material-icons text-white hover:text-warning" style="font-size: 1.1rem" @click.stop="editItem(item)">edit</span>
<span class="material-icons text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item)">close</span> <span class="material-icons text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item)">close</span>
+1 -1
View File
@@ -53,7 +53,7 @@ export default {
var tooltip = document.createElement('div') var tooltip = document.createElement('div')
this.tooltipId = String(Math.floor(Math.random() * 10000)) this.tooltipId = String(Math.floor(Math.random() * 10000))
tooltip.id = this.tooltipId tooltip.id = this.tooltipId
tooltip.className = 'tooltip-wrapper absolute px-2 py-1 text-white pointer-events-none text-xs rounded shadow-lg max-w-xs' tooltip.className = 'tooltip-wrapper absolute px-2 py-1 text-white pointer-events-none text-xs rounded shadow-lg max-w-xs text-center hidden sm:block'
tooltip.style.zIndex = 100 tooltip.style.zIndex = 100
tooltip.style.backgroundColor = 'rgba(0,0,0,0.85)' tooltip.style.backgroundColor = 'rgba(0,0,0,0.85)'
tooltip.innerHTML = this.text tooltip.innerHTML = this.text
+18 -18
View File
@@ -1,21 +1,21 @@
<template> <template>
<div class="w-full h-full relative"> <div class="w-full h-full relative">
<form class="w-full h-full px-4 py-6" @submit.prevent="submitForm"> <form class="w-full h-full px-2 md:px-4 py-6" @submit.prevent="submitForm">
<div class="flex -mx-1"> <div class="flex flex-wrap -mx-1">
<div class="w-1/2 px-1"> <div class="w-full md:w-1/2 px-1">
<ui-text-input-with-label ref="titleInput" v-model="details.title" label="Title" /> <ui-text-input-with-label ref="titleInput" v-model="details.title" label="Title" />
</div> </div>
<div class="flex-grow px-1"> <div class="flex-grow px-1 mt-2 md:mt-0">
<ui-text-input-with-label ref="subtitleInput" v-model="details.subtitle" label="Subtitle" /> <ui-text-input-with-label ref="subtitleInput" v-model="details.subtitle" label="Subtitle" />
</div> </div>
</div> </div>
<div class="flex mt-2 -mx-1"> <div class="flex flex-wrap mt-2 -mx-1">
<div class="w-3/4 px-1"> <div class="w-full md:w-3/4 px-1">
<!-- Authors filter only contains authors in this library, use query input to query all authors --> <!-- Authors filter only contains authors in this library, use query input to query all authors -->
<ui-multi-select-query-input ref="authorsSelect" v-model="details.authors" label="Authors" endpoint="authors/search" /> <ui-multi-select-query-input ref="authorsSelect" v-model="details.authors" label="Authors" endpoint="authors/search" />
</div> </div>
<div class="flex-grow px-1"> <div class="flex-grow px-1 mt-2 md:mt-0">
<ui-text-input-with-label ref="publishYearInput" v-model="details.publishedYear" type="number" label="Publish Year" /> <ui-text-input-with-label ref="publishYearInput" v-model="details.publishedYear" type="number" label="Publish Year" />
</div> </div>
</div> </div>
@@ -28,35 +28,35 @@
<ui-textarea-with-label ref="descriptionInput" v-model="details.description" :rows="3" label="Description" class="mt-2" /> <ui-textarea-with-label ref="descriptionInput" v-model="details.description" :rows="3" label="Description" class="mt-2" />
<div class="flex mt-2 -mx-1"> <div class="flex flex-wrap mt-2 -mx-1">
<div class="w-1/2 px-1"> <div class="w-full md:w-1/2 px-1">
<ui-multi-select ref="genresSelect" v-model="details.genres" label="Genres" :items="genres" /> <ui-multi-select ref="genresSelect" v-model="details.genres" label="Genres" :items="genres" />
</div> </div>
<div class="flex-grow px-1"> <div class="flex-grow px-1 mt-2 md:mt-0">
<ui-multi-select ref="tagsSelect" v-model="newTags" label="Tags" :items="tags" /> <ui-multi-select ref="tagsSelect" v-model="newTags" label="Tags" :items="tags" />
</div> </div>
</div> </div>
<div class="flex mt-2 -mx-1"> <div class="flex flex-wrap mt-2 -mx-1">
<div class="w-1/2 px-1"> <div class="w-full md:w-1/2 px-1">
<ui-multi-select ref="narratorsSelect" v-model="details.narrators" label="Narrators" :items="narrators" /> <ui-multi-select ref="narratorsSelect" v-model="details.narrators" label="Narrators" :items="narrators" />
</div> </div>
<div class="w-1/4 px-1"> <div class="w-1/2 md:w-1/4 px-1 mt-2 md:mt-0">
<ui-text-input-with-label ref="isbnInput" v-model="details.isbn" label="ISBN" /> <ui-text-input-with-label ref="isbnInput" v-model="details.isbn" label="ISBN" />
</div> </div>
<div class="w-1/4 px-1"> <div class="w-1/2 md:w-1/4 px-1 mt-2 md:mt-0">
<ui-text-input-with-label ref="asinInput" v-model="details.asin" label="ASIN" /> <ui-text-input-with-label ref="asinInput" v-model="details.asin" label="ASIN" />
</div> </div>
</div> </div>
<div class="flex mt-2 -mx-1"> <div class="flex flex-wrap mt-2 -mx-1">
<div class="w-1/2 px-1"> <div class="w-full md:w-1/2 px-1">
<ui-text-input-with-label ref="publisherInput" v-model="details.publisher" label="Publisher" /> <ui-text-input-with-label ref="publisherInput" v-model="details.publisher" label="Publisher" />
</div> </div>
<div class="w-1/4 px-1"> <div class="w-1/2 md:w-1/4 px-1 mt-2 md:mt-0">
<ui-text-input-with-label ref="languageInput" v-model="details.language" label="Language" /> <ui-text-input-with-label ref="languageInput" v-model="details.language" label="Language" />
</div> </div>
<div class="flex-grow px-1 pt-6"> <div class="flex-grow px-1 pt-6 mt-2 md:mt-0">
<div class="flex justify-center"> <div class="flex justify-center">
<ui-checkbox v-model="details.explicit" label="Explicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" /> <ui-checkbox v-model="details.explicit" label="Explicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
</div> </div>
+5 -11
View File
@@ -110,13 +110,7 @@ export default {
} }
console.log('Init Payload', payload) console.log('Init Payload', payload)
if (payload.session) { if (payload.session) {
if (this.$refs.streamContainer) { this.$refs.streamContainer.sessionOpen(payload.session)
this.$refs.streamContainer.sessionOpen(payload.session)
} else {
console.warn('Stream Container not mounted')
}
}
if (payload.serverSettings) {
} }
// Start scans currently running // Start scans currently running
@@ -243,9 +237,9 @@ export default {
var existingScan = this.$store.getters['scanners/getLibraryScan'](data.id) var existingScan = this.$store.getters['scanners/getLibraryScan'](data.id)
if (existingScan && !isNaN(existingScan.toastId)) { if (existingScan && !isNaN(existingScan.toastId)) {
this.$toast.update(existingScan.toastId, { content: message, options: { timeout: 5000, type: 'success', closeButton: false, position: 'bottom-center', onClose: () => null } }, true) this.$toast.update(existingScan.toastId, { content: message, options: { timeout: 5000, type: 'success', closeButton: false, onClose: () => null } }, true)
} else { } else {
this.$toast.success(message, { timeout: 5000, position: 'bottom-center' }) this.$toast.success(message, { timeout: 5000 })
} }
this.$store.commit('scanners/remove', data) this.$store.commit('scanners/remove', data)
@@ -254,7 +248,7 @@ export default {
this.$root.socket.emit('cancel_scan', id) this.$root.socket.emit('cancel_scan', id)
}, },
scanStart(data) { scanStart(data) {
data.toastId = this.$toast(`${data.type === 'match' ? 'Matching' : 'Scanning'} "${data.name}"...`, { timeout: false, type: 'info', draggable: false, closeOnClick: false, closeButton: CloseButton, closeButtonClassName: 'cancel-scan-btn', showCloseButtonOnHover: false, position: 'bottom-center', onClose: () => this.onScanToastCancel(data.id) }) data.toastId = this.$toast(`${data.type === 'match' ? 'Matching' : 'Scanning'} "${data.name}"...`, { timeout: false, type: 'info', draggable: false, closeOnClick: false, closeButton: CloseButton, closeButtonClassName: 'cancel-scan-btn', showCloseButtonOnHover: false, onClose: () => this.onScanToastCancel(data.id) })
this.$store.commit('scanners/addUpdate', data) this.$store.commit('scanners/addUpdate', data)
}, },
scanProgress(data) { scanProgress(data) {
@@ -263,7 +257,7 @@ export default {
data.toastId = existingScan.toastId data.toastId = existingScan.toastId
this.$toast.update(existingScan.toastId, { content: `Scanning "${existingScan.name}"... ${data.progress.progress || 0}%`, options: { timeout: false } }, true) this.$toast.update(existingScan.toastId, { content: `Scanning "${existingScan.name}"... ${data.progress.progress || 0}%`, options: { timeout: false } }, true)
} else { } else {
data.toastId = this.$toast(`Scanning "${data.name}"...`, { timeout: false, type: 'info', draggable: false, closeOnClick: false, closeButton: CloseButton, closeButtonClassName: 'cancel-scan-btn', showCloseButtonOnHover: false, position: 'bottom-center', onClose: () => this.onScanToastCancel(data.id) }) data.toastId = this.$toast(`Scanning "${data.name}"...`, { timeout: false, type: 'info', draggable: false, closeOnClick: false, closeButton: CloseButton, closeButtonClassName: 'cancel-scan-btn', showCloseButtonOnHover: false, onClose: () => this.onScanToastCancel(data.id) })
} }
this.$store.commit('scanners/addUpdate', data) this.$store.commit('scanners/addUpdate', data)
+41 -11
View File
@@ -28,11 +28,11 @@ export default {
var validOtherFiles = [] var validOtherFiles = []
var ignoredFiles = [] var ignoredFiles = []
files.forEach((file) => { files.forEach((file) => {
var filetype = this.checkFileType(file.name) // var filetype = this.checkFileType(file.name)
if (!filetype) ignoredFiles.push(file) if (!file.filetype) ignoredFiles.push(file)
else { else {
file.filetype = filetype // file.filetype = filetype
if (filetype === 'audio' || (filetype === 'ebook' && mediaType === 'book')) validItemFiles.push(file) if (file.filetype === 'audio' || (file.filetype === 'ebook' && mediaType === 'book')) validItemFiles.push(file)
else validOtherFiles.push(file) else validOtherFiles.push(file)
} }
}) })
@@ -82,12 +82,18 @@ export default {
items: itemResults, items: itemResults,
ignoredFiles: ignoredFilesInRoot ignoredFiles: ignoredFilesInRoot
} }
} else { } else if (filetree.some((f) => f.filetype !== 'audio') || mediaType !== 'book') {
// Single Book drop // Single Book drop
return { return {
items: this.itemFromTreeItems(filetree, mediaType), items: this.itemFromTreeItems(filetree, mediaType),
ignoredFiles: [] ignoredFiles: []
} }
} else {
// Only audio files dropped so treat each one as an audiobook
return {
items: filetree.map((audioFile) => ({ itemFiles: [audioFile], otherFiles: [], ignoredFiles: [] })),
ignoredFiles: []
}
} }
}, },
getFilesDropped(dataTransferItems) { getFilesDropped(dataTransferItems) {
@@ -95,11 +101,12 @@ export default {
path: '/', path: '/',
items: [] items: []
} }
function traverseFileTreePromise(item, currtreemap) { function traverseFileTreePromise(item, currtreemap, checkFileType) {
return new Promise((resolve) => { return new Promise((resolve) => {
if (item.isFile) { if (item.isFile) {
item.file((file) => { item.file((file) => {
file.filepath = currtreemap.path + file.name //save full path file.filepath = currtreemap.path + file.name //save full path
file.filetype = checkFileType(file.name)
currtreemap.items.push(file) currtreemap.items.push(file)
resolve(file) resolve(file)
}) })
@@ -119,7 +126,7 @@ export default {
dirReader.readEntries((entries) => { dirReader.readEntries((entries) => {
if (entries.length > 0) { if (entries.length > 0) {
for (let entr of entries) { for (let entr of entries) {
entriesPromises.push(traverseFileTreePromise(entr, newtreemap)) entriesPromises.push(traverseFileTreePromise(entr, newtreemap, checkFileType))
} }
readEntries() readEntries()
} else { } else {
@@ -135,7 +142,7 @@ export default {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let entriesPromises = [] let entriesPromises = []
for (let it of dataTransferItems) { for (let it of dataTransferItems) {
var filetree = traverseFileTreePromise(it.webkitGetAsEntry(), treemap) var filetree = traverseFileTreePromise(it.webkitGetAsEntry(), treemap, this.checkFileType)
entriesPromises.push(filetree) entriesPromises.push(filetree)
} }
Promise.all(entriesPromises).then(() => { Promise.all(entriesPromises).then(() => {
@@ -152,7 +159,9 @@ export default {
...book ...book
} }
var firstBookFile = book.itemFiles[0] var firstBookFile = book.itemFiles[0]
if (!firstBookFile.filepath) return audiobook // No path if (!firstBookFile.filepath) {
return audiobook // No path
}
var firstBookPath = Path.dirname(firstBookFile.filepath) var firstBookPath = Path.dirname(firstBookFile.filepath)
@@ -165,6 +174,9 @@ export default {
if (dirs.length) { if (dirs.length) {
audiobook.author = dirs.pop() audiobook.author = dirs.pop()
} }
} else {
// Use file basename as title
audiobook.title = Path.basename(firstBookFile.name, Path.extname(firstBookFile.name))
} }
return audiobook return audiobook
}, },
@@ -178,7 +190,12 @@ export default {
if (!firstAudioFile.filepath) return podcast // No path if (!firstAudioFile.filepath) return podcast // No path
var firstPath = Path.dirname(firstAudioFile.filepath) var firstPath = Path.dirname(firstAudioFile.filepath)
var dirs = firstPath.split('/').filter(d => !!d && d !== '.') var dirs = firstPath.split('/').filter(d => !!d && d !== '.')
podcast.title = dirs.length > 1 ? dirs[1] : dirs[0] if (dirs.length) {
podcast.title = dirs.length > 1 ? dirs[1] : dirs[0]
} else {
podcast.title = Path.basename(firstAudioFile.name, Path.extname(firstAudioFile.name))
}
return podcast return podcast
}, },
cleanItem(item, mediaType, index) { cleanItem(item, mediaType, index) {
@@ -188,6 +205,7 @@ export default {
async getItemsFromDataTransferItems(dataTransferItems, mediaType) { async getItemsFromDataTransferItems(dataTransferItems, mediaType) {
var files = await this.getFilesDropped(dataTransferItems) var files = await this.getFilesDropped(dataTransferItems)
if (!files || !files.length) return { error: 'No files found ' } if (!files || !files.length) return { error: 'No files found ' }
var itemData = this.fileTreeToItems(files, mediaType) var itemData = this.fileTreeToItems(files, mediaType)
if (!itemData.items.length && !itemData.ignoredFiles.length) { if (!itemData.items.length && !itemData.ignoredFiles.length) {
return { error: 'Invalid file drop' } return { error: 'Invalid file drop' }
@@ -218,9 +236,12 @@ export default {
else { else {
file.filetype = filetype file.filetype = filetype
if (file.webkitRelativePath) file.filepath = file.webkitRelativePath if (file.webkitRelativePath) file.filepath = file.webkitRelativePath
else file.filepath = file.name
if (filetype === 'audio' || (filetype === 'ebook' && mediaType === 'book')) { if (filetype === 'audio' || (filetype === 'ebook' && mediaType === 'book')) {
var dir = file.filepath ? Path.dirname(file.filepath) : '' var dir = file.filepath ? Path.dirname(file.filepath) : ''
if (dir === '.') dir = ''
if (!itemMap[dir]) { if (!itemMap[dir]) {
itemMap[dir] = { itemMap[dir] = {
path: dir, path: dir,
@@ -246,8 +267,17 @@ export default {
} }
}) })
var items = []
var index = 1 var index = 1
var items = Object.values(itemMap).map(i => this.cleanItem(i, mediaType, index++)) // If book media type and all files are audio files then treat each one as an audiobook
if (itemMap[''] && !otherFiles.length && mediaType === 'book' && !itemMap[''].itemFiles.some(f => f.filetype !== 'audio')) {
items = itemMap[''].itemFiles.map((audioFile) => {
return this.cleanItem({ itemFiles: [audioFile], otherFiles: [], ignoredFiles: [] }, mediaType, index++)
})
} else {
items = Object.values(itemMap).map(i => this.cleanItem(i, mediaType, index++))
}
return { return {
items, items,
ignoredFiles: ignoredFiles ignoredFiles: ignoredFiles
+22 -3
View File
@@ -58,7 +58,8 @@ module.exports = {
buildModules: [ buildModules: [
// https://go.nuxtjs.dev/tailwindcss // https://go.nuxtjs.dev/tailwindcss
'@nuxtjs/tailwindcss', '@nuxtjs/tailwindcss',
'@nuxtjs/pwa' '@nuxtjs/pwa',
'@nuxt/postcss8'
], ],
// Modules: https://go.nuxtjs.dev/config-modules // Modules: https://go.nuxtjs.dev/config-modules
@@ -119,11 +120,21 @@ module.exports = {
sizes: "512x512" sizes: "512x512"
} }
] ]
},
workbox: {
enabled: false,
} }
}, },
// Build Configuration: https://go.nuxtjs.dev/config-build // Build Configuration: https://go.nuxtjs.dev/config-build
build: {}, build: {
postcss: {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
},
watchers: { watchers: {
webpack: { webpack: {
aggregateTimeout: 300, aggregateTimeout: 300,
@@ -133,5 +144,13 @@ module.exports = {
server: { server: {
port: process.env.NODE_ENV === 'production' ? 80 : 3000, port: process.env.NODE_ENV === 'production' ? 80 : 3000,
host: '0.0.0.0' host: '0.0.0.0'
} },
/**
* Temporary workaround for @nuxt-community/tailwindcss-module.
*
* Reported: 2022-05-23
* See: [Issue tracker](https://github.com/nuxt-community/tailwindcss-module/issues/480)
*/
devServerHandlers: [],
} }
+571 -395
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.0.21", "version": "2.0.23",
"description": "Self-hosted audiobook and podcast client", "description": "Self-hosted audiobook and podcast client",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
@@ -29,8 +29,10 @@
"vuedraggable": "^2.24.3" "vuedraggable": "^2.24.3"
}, },
"devDependencies": { "devDependencies": {
"@nuxt/postcss8": "^1.1.3",
"@nuxtjs/pwa": "^3.3.5", "@nuxtjs/pwa": "^3.3.5",
"@nuxtjs/tailwindcss": "^4.2.1", "@nuxtjs/tailwindcss": "^4.2.1",
"autoprefixer": "^10.4.7",
"postcss": "^8.3.6" "postcss": "^8.3.6"
} }
} }
+179 -154
View File
@@ -1,150 +1,190 @@
<template> <template>
<div> <div>
<!-- <div class="h-0.5 bg-primary bg-opacity-50 w-full" /> --> <div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-2">
<div class="mb-2">
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
<div class="flex items-center mb-2">
<h1 class="text-xl">Settings</h1> <h1 class="text-xl">Settings</h1>
</div> </div>
<div class="flex items-center py-2"> <div class="lg:flex">
<ui-toggle-switch v-model="newServerSettings.storeCoverWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeCoverWithItem', val)" /> <div class="flex-1">
<ui-tooltip :text="tooltips.storeCoverWithItem"> <div class="pt-4">
<p class="pl-4 text-lg"> <h2 class="font-semibold">General</h2>
Store covers with item </div>
<span class="material-icons icon-text">info_outlined</span> <div class="flex items-end py-2">
</p> <ui-toggle-switch v-model="newServerSettings.storeCoverWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeCoverWithItem', val)" />
</ui-tooltip> <ui-tooltip :text="tooltips.storeCoverWithItem">
</div> <p class="pl-4">
Store covers with item
<span class="material-icons icon-text text-sm">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="flex items-center py-2"> <div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.storeMetadataWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeMetadataWithItem', val)" /> <ui-toggle-switch v-model="newServerSettings.storeMetadataWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeMetadataWithItem', val)" />
<ui-tooltip :text="tooltips.storeMetadataWithItem"> <ui-tooltip :text="tooltips.storeMetadataWithItem">
<p class="pl-4 text-lg"> <p class="pl-4">
Store metadata with item Store metadata with item
<span class="material-icons icon-text">info_outlined</span> <span class="material-icons icon-text text-sm">info_outlined</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> </div>
<div class="flex items-center py-2"> <div class="flex items-center py-2">
<ui-toggle-switch v-model="useSquareBookCovers" :disabled="updatingServerSettings" @input="updateBookCoverAspectRatio" /> <ui-toggle-switch v-model="newServerSettings.sortingIgnorePrefix" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('sortingIgnorePrefix', val)" />
<ui-tooltip :text="tooltips.coverAspectRatio"> <ui-tooltip :text="tooltips.sortingIgnorePrefix">
<p class="pl-4 text-lg"> <p class="pl-4">
Use square book covers Ignore prefixes when sorting
<span class="material-icons icon-text">info_outlined</span> <span class="material-icons icon-text text-sm">info_outlined</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> </div>
<div v-if="newServerSettings.sortingIgnorePrefix" class="w-72 ml-14 mb-2">
<ui-multi-select v-model="newServerSettings.sortingPrefixes" small :items="newServerSettings.sortingPrefixes" label="Prefixes to Ignore (case insensitive)" @input="updateSortingPrefixes" :disabled="updatingServerSettings" />
</div>
<div class="flex items-center py-2"> <div class="flex items-center py-2">
<ui-toggle-switch v-model="useAlternativeBookshelfView" :disabled="updatingServerSettings" @input="updateAlternativeBookshelfView" /> <ui-toggle-switch v-model="newServerSettings.chromecastEnabled" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('chromecastEnabled', val)" />
<ui-tooltip :text="tooltips.bookshelfView"> <p class="pl-4">Chromecast support</p>
<p class="pl-4 text-lg"> </div>
Use alternative bookshelf view
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="flex items-center py-2"> <div class="pt-4">
<ui-toggle-switch v-model="newServerSettings.sortingIgnorePrefix" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('sortingIgnorePrefix', val)" /> <h2 class="font-semibold">Display</h2>
<ui-tooltip :text="tooltips.sortingIgnorePrefix"> </div>
<p class="pl-4 text-lg">
Ignore prefixes when sorting title and series
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div v-if="newServerSettings.sortingIgnorePrefix" class="w-72 ml-14 mb-2">
<ui-multi-select v-model="newServerSettings.sortingPrefixes" small :items="newServerSettings.sortingPrefixes" label="Prefixes to Ignore (case insensitive)" @input="updateSortingPrefixes" :disabled="updatingServerSettings" />
</div>
<div class="flex items-center py-2"> <div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.chromecastEnabled" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('chromecastEnabled', val)" /> <ui-toggle-switch v-model="useSquareBookCovers" :disabled="updatingServerSettings" @input="updateBookCoverAspectRatio" />
<p class="pl-4 text-lg">Enable Chromecast</p> <ui-tooltip :text="tooltips.coverAspectRatio">
</div> <p class="pl-4">
Square book covers
<span class="material-icons icon-text text-sm">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="flex items-center mb-2 mt-8"> <div class="flex items-center py-2">
<h1 class="text-xl">Scanner Settings</h1> <ui-toggle-switch v-model="useAlternativeBookshelfView" :disabled="updatingServerSettings" @input="updateAlternativeBookshelfView" />
</div> <ui-tooltip :text="tooltips.bookshelfView">
<p class="pl-4">
Alternative bookshelf view
<span class="material-icons icon-text text-sm">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="flex items-center py-2"> <div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerParseSubtitle" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerParseSubtitle', val)" /> <p class="pr-4">Date Format</p>
<ui-tooltip :text="tooltips.scannerParseSubtitle"> <ui-dropdown v-model="newServerSettings.dateFormat" :items="dateFormats" small class="max-w-40" @input="(val) => updateSettingsKey('dateFormat', val)" />
<p class="pl-4 text-lg"> </div>
Scanner parse subtitles </div>
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="flex items-center py-2"> <div class="flex-1">
<ui-toggle-switch v-model="newServerSettings.scannerFindCovers" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerFindCovers', val)" /> <div class="pt-4">
<ui-tooltip :text="tooltips.scannerFindCovers"> <h2 class="font-semibold">Scanner</h2>
<p class="pl-4 text-lg"> </div>
Scanner find covers
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
<div class="flex-grow" />
</div>
<div v-if="newServerSettings.scannerFindCovers" class="w-44 ml-14 mb-2">
<ui-dropdown v-model="newServerSettings.scannerCoverProvider" small :items="providers" label="Cover Provider" @input="updateScannerCoverProvider" :disabled="updatingServerSettings" />
</div>
<div class="flex items-center py-2"> <div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerPreferAudioMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferAudioMetadata', val)" /> <ui-toggle-switch v-model="newServerSettings.scannerParseSubtitle" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerParseSubtitle', val)" />
<ui-tooltip :text="tooltips.scannerPreferAudioMetadata"> <ui-tooltip :text="tooltips.scannerParseSubtitle">
<p class="pl-4 text-lg"> <p class="pl-4">
Scanner prefer audio metadata Parse subtitles
<span class="material-icons icon-text">info_outlined</span> <span class="material-icons icon-text text-sm">info_outlined</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> </div>
<div class="flex items-center py-2"> <div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerPreferOpfMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferOpfMetadata', val)" /> <ui-toggle-switch v-model="newServerSettings.scannerFindCovers" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerFindCovers', val)" />
<ui-tooltip :text="tooltips.scannerPreferOpfMetadata"> <ui-tooltip :text="tooltips.scannerFindCovers">
<p class="pl-4 text-lg"> <p class="pl-4">
Scanner prefer OPF metadata Find covers
<span class="material-icons icon-text">info_outlined</span> <span class="material-icons icon-text text-sm">info_outlined</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> <div class="flex-grow" />
</div>
<div v-if="newServerSettings.scannerFindCovers" class="w-44 ml-14 mb-2">
<ui-dropdown v-model="newServerSettings.scannerCoverProvider" small :items="providers" label="Cover Provider" @input="updateScannerCoverProvider" :disabled="updatingServerSettings" />
</div>
<div class="flex items-center py-2"> <div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerPreferMatchedMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferMatchedMetadata', val)" /> <ui-toggle-switch v-model="newServerSettings.scannerPreferOverdriveMediaMarker" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferOverdriveMediaMarker', val)" />
<ui-tooltip :text="tooltips.scannerPreferMatchedMetadata"> <ui-tooltip :text="tooltips.scannerPreferOverdriveMediaMarker">
<p class="pl-4 text-lg"> <p class="pl-4">
Scanner prefer matched metadata Use Overdrive Media Markers for chapters
<span class="material-icons icon-text">info_outlined</span> <span class="material-icons icon-text text-sm">info_outlined</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> </div>
<div class="flex items-center py-2"> <div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerDisableWatcher" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerDisableWatcher', val)" /> <ui-toggle-switch v-model="newServerSettings.scannerPreferAudioMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferAudioMetadata', val)" />
<ui-tooltip :text="tooltips.scannerDisableWatcher"> <ui-tooltip :text="tooltips.scannerPreferAudioMetadata">
<p class="pl-4 text-lg"> <p class="pl-4">
Disable Watcher Prefer audio metadata
<span class="material-icons icon-text">info_outlined</span> <span class="material-icons icon-text text-sm">info_outlined</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> </div>
<div class="flex items-center mb-2 mt-8"> <div class="flex items-center py-2">
<h1 class="text-xl">Experimental Feature Settings</h1> <ui-toggle-switch v-model="newServerSettings.scannerPreferOpfMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferOpfMetadata', val)" />
</div> <ui-tooltip :text="tooltips.scannerPreferOpfMetadata">
<p class="pl-4">
Prefer OPF metadata
<span class="material-icons icon-text text-sm">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="flex items-center py-2"> <div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.enableEReader" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('enableEReader', val)" /> <ui-toggle-switch v-model="newServerSettings.scannerPreferMatchedMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferMatchedMetadata', val)" />
<ui-tooltip :text="tooltips.enableEReader"> <ui-tooltip :text="tooltips.scannerPreferMatchedMetadata">
<p class="pl-4 text-lg"> <p class="pl-4">
Enable e-reader for all users Prefer matched metadata
<span class="material-icons icon-text">info_outlined</span> <span class="material-icons icon-text text-sm">info_outlined</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerDisableWatcher" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerDisableWatcher', val)" />
<ui-tooltip :text="tooltips.scannerDisableWatcher">
<p class="pl-4">
Disable Watcher
<span class="material-icons icon-text text-sm">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="pt-4">
<h2 class="font-semibold">Experimental Features</h2>
</div>
<div class="flex items-center py-2">
<div class="flex items-center">
<ui-toggle-switch v-model="showExperimentalFeatures" />
<ui-tooltip :text="tooltips.experimentalFeatures">
<p class="pl-4">
Experimental Features
<a href="https://github.com/advplyr/audiobookshelf/discussions/75" target="_blank">
<span class="material-icons icon-text text-sm">info_outlined</span>
</a>
</p>
</ui-tooltip>
</div>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.enableEReader" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('enableEReader', val)" />
<ui-tooltip :text="tooltips.enableEReader">
<p class="pl-4">
Enable e-reader for all users
<span class="material-icons icon-text text-sm">info_outlined</span>
</p>
</ui-tooltip>
</div>
</div>
</div> </div>
</div> </div>
@@ -152,7 +192,7 @@
<div class="flex items-center py-4"> <div class="flex items-center py-4">
<ui-btn color="bg" small :padding-x="4" class="hidden lg:block mr-2" :loading="isPurgingCache" @click="purgeCache">Purge Cache</ui-btn> <ui-btn color="bg" small :padding-x="4" class="hidden lg:block mr-2" :loading="isPurgingCache" @click="purgeCache">Purge Cache</ui-btn>
<ui-btn color="bg" small :padding-x="4" class="hidden lg:block" :loading="isResettingLibraryItems" @click="resetLibraryItems">Remove All Library Items</ui-btn> <ui-btn color="bg" small :padding-x="4" class="hidden lg:block mr-2" :loading="isResettingLibraryItems" @click="resetLibraryItems">Remove All Library Items</ui-btn>
<div class="flex-grow" /> <div class="flex-grow" />
<p class="pr-2 text-sm font-book text-yellow-400"> <p class="pr-2 text-sm font-book text-yellow-400">
Report bugs, request features, and contribute on Report bugs, request features, and contribute on
@@ -188,30 +228,12 @@
<div class="h-0.5 bg-primary bg-opacity-30 w-full" /> <div class="h-0.5 bg-primary bg-opacity-30 w-full" />
<div class="py-12 mb-4 opacity-60 hover:opacity-100">
<div class="flex items-center">
<div>
<div class="flex items-center">
<ui-toggle-switch v-model="showExperimentalFeatures" />
<ui-tooltip :text="tooltips.experimentalFeatures">
<p class="pl-4 text-lg">
Experimental Features
<a href="https://github.com/advplyr/audiobookshelf/discussions/75" target="_blank">
<span class="material-icons icon-text">info_outlined</span>
</a>
</p>
</ui-tooltip>
</div>
</div>
</div>
</div>
<prompt-dialog v-model="showConfirmPurgeCache" :width="675"> <prompt-dialog v-model="showConfirmPurgeCache" :width="675">
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300"> <div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
<p class="text-error text-lg font-semibold">Important Notice!</p> <p class="text-error font-semibold">Important Notice!</p>
<p class="text-lg my-2 text-center">Purge cache will delete the entire directory at <span class="font-mono">/metadata/cache</span>.</p> <p class="my-2 text-center">Purge cache will delete the entire directory at <span class="font-mono">/metadata/cache</span>.</p>
<p class="text-lg text-center mb-8">Are you sure you want to remove the cache directory?</p> <p class="text-center mb-8">Are you sure you want to remove the cache directory?</p>
<div class="flex px-1 items-center"> <div class="flex px-1 items-center">
<ui-btn color="primary" @click="showConfirmPurgeCache = false">Nevermind</ui-btn> <ui-btn color="primary" @click="showConfirmPurgeCache = false">Nevermind</ui-btn>
<div class="flex-grow" /> <div class="flex-grow" />
@@ -245,7 +267,8 @@ export default {
storeCoverWithItem: 'By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named "cover" will be kept', storeCoverWithItem: 'By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named "cover" will be kept',
storeMetadataWithItem: 'By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders. Uses .abs file extension', storeMetadataWithItem: 'By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders. Uses .abs file extension',
coverAspectRatio: 'Prefer to use square covers over standard 1.6:1 book covers', coverAspectRatio: 'Prefer to use square covers over standard 1.6:1 book covers',
enableEReader: 'E-reader is still a work in progress, but use this setting to open it up to all your users (or use the "Experimental Features" toggle below just for you)' enableEReader: 'E-reader is still a work in progress, but use this setting to open it up to all your users (or use the "Experimental Features" toggle just for use by you)',
scannerPreferOverdriveMediaMarker: 'MP3 files from Overdrive come with chapter timings embedded as custom metadata. Enabling this will use these tags for chapter timings automatically'
}, },
showConfirmPurgeCache: false showConfirmPurgeCache: false
} }
@@ -271,12 +294,12 @@ export default {
set(val) { set(val) {
this.$store.commit('setExperimentalFeatures', val) this.$store.commit('setExperimentalFeatures', val)
} }
},
dateFormats() {
return this.$store.state.globals.dateFormats
} }
}, },
methods: { methods: {
updateEnableChromecast(val) {
this.updateServerSettings({ enableChromecast: val })
},
updateSortingPrefixes(val) { updateSortingPrefixes(val) {
if (!val || !val.length) { if (!val || !val.length) {
this.$toast.error('Must have at least 1 prefix') this.$toast.error('Must have at least 1 prefix')
@@ -314,10 +337,12 @@ export default {
.then((success) => { .then((success) => {
console.log('Updated Server Settings', success) console.log('Updated Server Settings', success)
this.updatingServerSettings = false this.updatingServerSettings = false
this.$toast.success('Server settings updated')
}) })
.catch((error) => { .catch((error) => {
console.error('Failed to update server settings', error) console.error('Failed to update server settings', error)
this.updatingServerSettings = false this.updatingServerSettings = false
this.$toast.error('Failed to update server settings')
}) })
}, },
initServerSettings() { initServerSettings() {
+3 -4
View File
@@ -1,10 +1,9 @@
<template> <template>
<div> <div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
<p class="text-xl">Stats for library {{ currentLibraryName }}</p> <h1 class="text-xl">Stats for library {{ currentLibraryName }}</h1>
<stats-preview-icons v-if="totalItems" :library-stats="libraryStats" /> <stats-preview-icons v-if="totalItems" :library-stats="libraryStats" />
<div class="flex md:flex-row flex-wrap justify-between flex-col mt-12"> <div class="flex md:flex-row flex-wrap justify-between flex-col mt-8">
<div class="w-80 my-6 mx-auto"> <div class="w-80 my-6 mx-auto">
<h1 class="text-2xl mb-4 font-book">Top 5 Genres</h1> <h1 class="text-2xl mb-4 font-book">Top 5 Genres</h1>
<p v-if="!top5Genres.length">No Genres</p> <p v-if="!top5Genres.length">No Genres</p>
+11 -14
View File
@@ -1,24 +1,21 @@
<template> <template>
<div id="page-wrapper" class="page overflow-y-auto" :class="streamLibraryItem ? 'streaming' : ''"> <div class="w-full h-full">
<div class="w-full max-w-4xl mx-auto"> <div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
<div class="mb-4 flex flex-col sm:flex-row items-start sm:items-end"> <div class="flex items-center mb-2">
<p class="text-2xl mr-4 mb-2 sm:mb-0">Logger</p> <h1 class="text-xl">Logs</h1>
</div>
<div class="flex justify-between mb-2 place-items-end">
<ui-text-input ref="input" v-model="search" placeholder="Search filter.." @input="inputUpdate" clearable class="w-full sm:w-40 h-8 text-sm sm:mb-0" />
<ui-text-input ref="input" v-model="search" placeholder="Search filter.." @input="inputUpdate" clearable class="w-full sm:w-40 h-8 text-sm mb-2 sm:mb-0" /> <ui-dropdown v-model="newServerSettings.logLevel" label="Server Log Level" :items="logLevelItems" @input="logLevelUpdated" class="w-full sm:w-44" />
<div class="flex-grow" />
<div class="w-full sm:w-44">
<ui-dropdown v-model="newServerSettings.logLevel" label="Server Log Level" :items="logLevelItems" @input="logLevelUpdated" />
</div>
</div> </div>
<div class="relative"> <div class="relative">
<div ref="container" class="relative w-full h-full bg-primary border-bg overflow-x-hidden overflow-y-auto text-red shadow-inner rounded-md" style="max-height: 550px; min-height: 550px"> <div ref="container" class="relative w-full h-full bg-primary border-bg overflow-x-hidden overflow-y-auto text-red shadow-inner rounded-md" style="max-height: 800px; min-height: 550px">
<template v-for="(log, index) in logs"> <template v-for="(log, index) in logs">
<div :key="index" class="flex flex-nowrap px-2 py-1 items-start text-sm bg-opacity-10" :class="`bg-${logColors[log.level]}`"> <div :key="index" class="flex flex-nowrap px-2 py-1 items-start text-sm bg-opacity-10" :class="`bg-${logColors[log.level]}`">
<p class="text-gray-400 w-40 font-mono">{{ log.timestamp.split('.')[0].split('T').join(' ') }}</p> <p class="text-gray-400 w-36 font-mono text-xs">{{ log.timestamp }}</p>
<p class="font-semibold w-12 text-right" :class="`text-${logColors[log.level]}`">{{ log.levelName }}</p> <p class="font-semibold w-12 text-right text-sm" :class="`text-${logColors[log.level]}`">{{ log.levelName }}</p>
<p class="px-4 logmessage">{{ log.message }}</p> <p class="px-4 logmessage">{{ log.message }}</p>
</div> </div>
</template> </template>
+55 -55
View File
@@ -1,61 +1,61 @@
<template> <template>
<div class="w-full h-full"> <div class="w-full h-full">
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-0 sm:p-4 mb-8"> <div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
<div class="py-2"> <div class="flex items-center mb-2">
<div class="flex items-center mb-1"> <h1 class="text-xl">Listening Sessions</h1>
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Listening Sessions</h1>
<div class="flex-grow" />
<ui-dropdown v-model="selectedUser" :items="userItems" label="Filter by User" small class="max-w-48" @input="updateUserFilter" />
</div>
<div v-if="listeningSessions.length" class="block max-w-full">
<table class="userSessionsTable">
<tr class="bg-primary bg-opacity-40">
<th class="w-48 min-w-48 text-left">Item</th>
<th class="w-20 min-w-20 text-left hidden md:table-cell">User</th>
<th class="w-32 min-w-32 text-left hidden md:table-cell">Play Method</th>
<th class="w-32 min-w-32 text-left hidden sm:table-cell">Device Info</th>
<th class="w-32 min-w-32">Listened</th>
<th class="w-16 min-w-16">Last Time</th>
<th class="flex-grow hidden sm:table-cell">Last Update</th>
</tr>
<tr v-for="session in listeningSessions" :key="session.id" class="cursor-pointer" @click="showSession(session)">
<td class="py-1 max-w-48">
<p class="text-xs text-gray-200 truncate">{{ session.displayTitle }}</p>
<p class="text-xs text-gray-400 truncate">{{ session.displayAuthor }}</p>
</td>
<td class="hidden md:table-cell">
<p v-if="filteredUserUsername" class="text-xs">{{ filteredUserUsername }}</p>
<p v-else class="text-xs">{{ session.user ? session.user.username : 'N/A' }}</p>
</td>
<td class="hidden md:table-cell">
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p>
</td>
<td class="hidden sm:table-cell">
<p class="text-xs" v-html="getDeviceInfoString(session.deviceInfo)" />
</td>
<td class="text-center">
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
</td>
<td class="text-center">
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
</td>
<td class="text-center hidden sm:table-cell">
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDate(session.updatedAt, 'MMMM do, yyyy HH:mm')">
<p class="text-xs text-gray-200">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
</ui-tooltip>
</td>
</tr>
</table>
<div class="flex items-center justify-end py-1">
<ui-icon-btn icon="arrow_back_ios_new" :size="7" icon-font-size="1rem" class="mx-1" :disabled="currentPage === 0" @click="prevPage" />
<p class="text-sm mx-1">Page {{ currentPage + 1 }} of {{ numPages }}</p>
<ui-icon-btn icon="arrow_forward_ios" :size="7" icon-font-size="1rem" class="mx-1" :disabled="currentPage >= numPages - 1" @click="nextPage" />
</div>
</div>
<p v-else class="text-white text-opacity-50">No sessions yet...</p>
</div> </div>
<div class="flex justify-end mb-2">
<ui-dropdown v-model="selectedUser" :items="userItems" label="Filter by User" small class="max-w-48" @input="updateUserFilter" />
</div>
<div v-if="listeningSessions.length" class="block max-w-full">
<table class="userSessionsTable">
<tr class="bg-primary bg-opacity-40">
<th class="w-48 min-w-48 text-left">Item</th>
<th class="w-20 min-w-20 text-left hidden md:table-cell">User</th>
<th class="w-32 min-w-32 text-left hidden md:table-cell">Play Method</th>
<th class="w-32 min-w-32 text-left hidden sm:table-cell">Device Info</th>
<th class="w-32 min-w-32">Listened</th>
<th class="w-16 min-w-16">Last Time</th>
<th class="flex-grow hidden sm:table-cell">Last Update</th>
</tr>
<tr v-for="session in listeningSessions" :key="session.id" class="cursor-pointer" @click="showSession(session)">
<td class="py-1 max-w-48">
<p class="text-xs text-gray-200 truncate">{{ session.displayTitle }}</p>
<p class="text-xs text-gray-400 truncate">{{ session.displayAuthor }}</p>
</td>
<td class="hidden md:table-cell">
<p v-if="filteredUserUsername" class="text-xs">{{ filteredUserUsername }}</p>
<p v-else class="text-xs">{{ session.user ? session.user.username : 'N/A' }}</p>
</td>
<td class="hidden md:table-cell">
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p>
</td>
<td class="hidden sm:table-cell">
<p class="text-xs" v-html="getDeviceInfoString(session.deviceInfo)" />
</td>
<td class="text-center">
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
</td>
<td class="text-center">
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
</td>
<td class="text-center hidden sm:table-cell">
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDate(session.updatedAt, 'MMMM do, yyyy HH:mm')">
<p class="text-xs text-gray-200">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
</ui-tooltip>
</td>
</tr>
</table>
<div class="flex items-center justify-end my-2">
<ui-icon-btn icon="arrow_back_ios_new" :size="7" icon-font-size="1rem" class="mx-1" :disabled="currentPage === 0" @click="prevPage" />
<p class="text-sm mx-1">Page {{ currentPage + 1 }} of {{ numPages }}</p>
<ui-icon-btn icon="arrow_forward_ios" :size="7" icon-font-size="1rem" class="mx-1" :disabled="currentPage >= numPages - 1" @click="nextPage" />
</div>
</div>
<p v-else class="text-white text-opacity-50">No sessions yet...</p>
</div> </div>
<modals-listening-session-modal v-model="showSessionModal" :session="selectedSession" /> <modals-listening-session-modal v-model="showSessionModal" :session="selectedSession" />
+7 -2
View File
@@ -1,5 +1,7 @@
<template> <template>
<div> <div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
<h1 class="text-xl">Stats for {{ username }}</h1>
<div class="flex justify-center"> <div class="flex justify-center">
<div class="flex p-2"> <div class="flex p-2">
<svg class="hidden sm:block h-14 w-14 lg:h-18 lg:w-18" viewBox="0 0 24 24"> <svg class="hidden sm:block h-14 w-14 lg:h-18 lg:w-18" viewBox="0 0 24 24">
@@ -46,7 +48,7 @@
<template v-for="(item, index) in mostRecentListeningSessions"> <template v-for="(item, index) in mostRecentListeningSessions">
<div :key="item.id" class="w-full py-0.5"> <div :key="item.id" class="w-full py-0.5">
<div class="flex items-center mb-1"> <div class="flex items-center mb-1">
<p class="text-sm font-book text-white text-opacity-70 w-6 truncate">{{ index + 1 }}.&nbsp;</p> <p class="text-sm font-book text-white text-opacity-70 w-8">{{ index + 1 }}.&nbsp;</p>
<div class="w-56"> <div class="w-56">
<p class="text-sm font-book text-white text-opacity-80 truncate">{{ item.mediaMetadata ? item.mediaMetadata.title : '' }}</p> <p class="text-sm font-book text-white text-opacity-80 truncate">{{ item.mediaMetadata ? item.mediaMetadata.title : '' }}</p>
<p class="text-xs text-white text-opacity-50">{{ $dateDistanceFromNow(item.updatedAt) }}</p> <p class="text-xs text-white text-opacity-50">{{ $dateDistanceFromNow(item.updatedAt) }}</p>
@@ -84,6 +86,9 @@ export default {
user() { user() {
return this.$store.state.user.user return this.$store.state.user.user
}, },
username() {
return this.user.username
},
currentLibraryId() { currentLibraryId() {
return this.$store.state.libraries.currentLibraryId return this.$store.state.libraries.currentLibraryId
}, },
+5 -2
View File
@@ -118,9 +118,9 @@
<!-- Progress --> <!-- Progress -->
<div v-if="!isPodcast && progressPercent > 0" class="px-4 py-2 mt-4 bg-primary text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0" :class="resettingProgress ? 'opacity-25' : ''"> <div v-if="!isPodcast && progressPercent > 0" class="px-4 py-2 mt-4 bg-primary text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0" :class="resettingProgress ? 'opacity-25' : ''">
<p v-if="progressPercent < 1" class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p> <p v-if="progressPercent < 1" class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p>
<p v-else class="text-xs">Finished {{ $formatDate(userProgressFinishedAt, 'MM/dd/yyyy') }}</p> <p v-else class="text-xs">Finished {{ $formatDate(userProgressFinishedAt, dateFormat) }}</p>
<p v-if="progressPercent < 1" class="text-gray-200 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p> <p v-if="progressPercent < 1" class="text-gray-200 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p>
<p class="text-gray-400 text-xs pt-1">Started {{ $formatDate(userProgressStartedAt, 'MM/dd/yyyy') }}</p> <p class="text-gray-400 text-xs pt-1">Started {{ $formatDate(userProgressStartedAt, dateFormat) }}</p>
<div v-if="!resettingProgress" class="absolute -top-1.5 -right-1.5 p-1 w-5 h-5 rounded-full bg-bg hover:bg-error border border-primary flex items-center justify-center cursor-pointer" @click.stop="clearProgressClick"> <div v-if="!resettingProgress" class="absolute -top-1.5 -right-1.5 p-1 w-5 h-5 rounded-full bg-bg hover:bg-error border border-primary flex items-center justify-center cursor-pointer" @click.stop="clearProgressClick">
<span class="material-icons text-sm">close</span> <span class="material-icons text-sm">close</span>
@@ -226,6 +226,9 @@ export default {
} }
}, },
computed: { computed: {
dateFormat() {
return this.$store.state.serverSettings.dateFormat
},
showExperimentalFeatures() { showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures return this.$store.state.showExperimentalFeatures
}, },
@@ -2,25 +2,26 @@
<div class="page" :class="streamLibraryItem ? 'streaming' : ''"> <div class="page" :class="streamLibraryItem ? 'streaming' : ''">
<app-book-shelf-toolbar page="podcast-search" /> <app-book-shelf-toolbar page="podcast-search" />
<div class="w-full h-full overflow-y-auto p-12 relative"> <div class="w-full h-full overflow-y-auto px-2 py-6 sm:px-4 md:p-12 relative">
<div class="w-full max-w-4xl mx-auto flex"> <div class="w-full max-w-4xl mx-auto flex">
<form @submit.prevent="submit" class="flex flex-grow"> <form @submit.prevent="submit" class="flex flex-grow">
<ui-text-input v-model="searchInput" :disabled="processing" placeholder="Enter search term or RSS feed URL" class="flex-grow mr-2" /> <ui-text-input v-model="searchInput" :disabled="processing" placeholder="Enter search term or RSS feed URL" class="flex-grow mr-2 text-sm md:text-base" />
<ui-btn type="submit" :disabled="processing">Submit</ui-btn> <ui-btn type="submit" :disabled="processing" class="hidden md:block">Submit</ui-btn>
<ui-btn type="submit" :disabled="processing" class="block md:hidden" small>Submit</ui-btn>
</form> </form>
<ui-file-input ref="fileInput" :accept="'.opml, .txt'" class="mx-2" @change="opmlFileUpload"> Upload OPML File </ui-file-input> <ui-file-input ref="fileInput" :accept="'.opml, .txt'" class="ml-2" @change="opmlFileUpload"> Upload OPML File </ui-file-input>
</div> </div>
<div class="w-full max-w-3xl mx-auto py-4"> <div class="w-full max-w-3xl mx-auto py-4">
<p v-if="termSearched && !results.length && !processing" class="text-center text-xl">No podcasts found</p> <p v-if="termSearched && !results.length && !processing" class="text-center text-xl">No podcasts found</p>
<template v-for="podcast in results"> <template v-for="podcast in results">
<div :key="podcast.id" class="flex p-1 hover:bg-primary hover:bg-opacity-25 cursor-pointer" @click="selectPodcast(podcast)"> <div :key="podcast.id" class="flex p-1 hover:bg-primary hover:bg-opacity-25 cursor-pointer" @click="selectPodcast(podcast)">
<div class="w-24 min-w-24 h-24 bg-primary"> <div class="w-20 min-w-20 h-20 md:w-24 md:min-w-24 md:h-24 bg-primary">
<img v-if="podcast.cover" :src="podcast.cover" class="h-full w-full" /> <img v-if="podcast.cover" :src="podcast.cover" class="h-full w-full" />
</div> </div>
<div class="flex-grow pl-4 max-w-2xl"> <div class="flex-grow pl-4 max-w-2xl">
<a :href="podcast.pageUrl" class="text-lg text-gray-200 hover:underline" target="_blank" @click.stop>{{ podcast.title }}</a> <a :href="podcast.pageUrl" class="text-base md:text-lg text-gray-200 hover:underline" target="_blank" @click.stop>{{ podcast.title }}</a>
<p class="text-base text-gray-300 whitespace-nowrap truncate">by {{ podcast.artistName }}</p> <p class="text-sm md:text-base text-gray-300 whitespace-nowrap truncate">by {{ podcast.artistName }}</p>
<p class="text-xs text-gray-400 leading-5">{{ podcast.genres.join(', ') }}</p> <p class="text-xs text-gray-400 leading-5">{{ podcast.genres.join(', ') }}</p>
<p class="text-xs text-gray-400 leading-5">{{ podcast.trackCount }} Episodes</p> <p class="text-xs text-gray-400 leading-5">{{ podcast.trackCount }} Episodes</p>
</div> </div>
+3 -1
View File
@@ -29,7 +29,9 @@
</div> </div>
</div> </div>
<div class="pt-8 text-center"> <div class="pt-8 text-center">
<p class="text-xs text-white text-opacity-50 font-mono"><strong>Supported File Types: </strong>{{ inputAccept.join(', ') }}</p> <p class="text-xs text-white text-opacity-50 font-mono mb-4"><strong>Supported File Types: </strong>{{ inputAccept.join(', ') }}</p>
<p class="text-sm text-white text-opacity-70">Folders with media files will be treated as separate library items. <span v-if="selectedLibraryMediaType === 'book'">If uploading only audio files then each audio file will be treated as a separate audiobook.</span></p>
</div> </div>
</div> </div>
<!-- Item list header --> <!-- Item list header -->
+2 -1
View File
@@ -177,12 +177,13 @@ export default class PlayerHandler {
} }
prepareOpenSession(session, playbackRate) { // Session opened on init socket prepareOpenSession(session, playbackRate) { // Session opened on init socket
if (!this.player) this.switchPlayer() // Must set player first for open sessions
this.libraryItem = session.libraryItem this.libraryItem = session.libraryItem
this.isVideo = session.libraryItem.mediaType === 'video' this.isVideo = session.libraryItem.mediaType === 'video'
this.playWhenReady = false this.playWhenReady = false
this.initialPlaybackRate = playbackRate this.initialPlaybackRate = playbackRate
if (!this.player) this.switchPlayer()
this.prepareSession(session) this.prepareSession(session)
} }
+1 -1
View File
@@ -23,7 +23,7 @@ export default (ctx) => {
var castContext = cast.framework.CastContext.getInstance() var castContext = cast.framework.CastContext.getInstance()
castContext.setOptions({ castContext.setOptions({
receiverApplicationId: process.env.chromecastReceiver, receiverApplicationId: process.env.chromecastReceiver,
autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED autoJoinPolicy: chrome.cast ? chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED : null
}); });
castContext.addEventListener( castContext.addEventListener(
-1
View File
@@ -204,7 +204,6 @@ Vue.prototype.$copyToClipboard = (str, ctx) => {
}) })
} }
function xmlToJson(xml) { function xmlToJson(xml) {
const json = {}; const json = {};
for (const res of xml.matchAll(/(?:<(\w*)(?:\s[^>]*)*>)((?:(?!<\1).)*)(?:<\/\1>)|<(\w*)(?:\s*)*\/>/gm)) { for (const res of xml.matchAll(/(?:<(\w*)(?:\s[^>]*)*>)((?:(?!<\1).)*)(?:<\/\1>)|<(\w*)(?:\s*)*\/>/gm)) {
+15 -2
View File
@@ -1,4 +1,3 @@
export const state = () => ({ export const state = () => ({
isMobile: false, isMobile: false,
isMobileLandscape: false, isMobileLandscape: false,
@@ -12,7 +11,21 @@ export const state = () => ({
selectedCollection: null, selectedCollection: null,
selectedAuthor: null, selectedAuthor: null,
isCasting: false, // Actively casting isCasting: false, // Actively casting
isChromecastInitialized: false // Script loaded isChromecastInitialized: false, // Script loaded
dateFormats: [
{
text: 'MM/DD/YYYY',
value: 'MM/dd/yyyy'
},
{
text: 'DD/MM/YYYY',
value: 'dd/MM/yyyy'
},
{
text: 'YYYY-MM-DD',
value: 'yyyy-MM-dd'
}
]
}) })
export const getters = { export const getters = {
+19 -12
View File
@@ -2,18 +2,23 @@ const defaultTheme = require('tailwindcss/defaultTheme')
module.exports = { module.exports = {
purge: { purge: {
options: { content: [
safelist: [ 'components/**/*.vue',
'bg-success', 'layouts/**/*.vue',
'bg-red-600', 'pages/**/*.vue',
'text-green-500', 'templates/**/*.vue',
'py-1.5', 'plugins/**/*.js',
'bg-info', 'nuxt.config.js'
'px-1.5' ],
] safelist: [
} 'bg-success',
'bg-red-600',
'text-green-500',
'py-1.5',
'bg-info',
'px-1.5'
],
}, },
darkMode: false,
theme: { theme: {
extend: { extend: {
height: { height: {
@@ -31,6 +36,7 @@ module.exports = {
'20': '5rem', '20': '5rem',
'24': '6rem', '24': '6rem',
'32': '8rem', '32': '8rem',
'40': '10rem',
'48': '12rem', '48': '12rem',
'64': '16rem', '64': '16rem',
'80': '20rem' '80': '20rem'
@@ -79,7 +85,8 @@ module.exports = {
book: ['Gentium Book Basic', 'serif'] book: ['Gentium Book Basic', 'serif']
}, },
fontSize: { fontSize: {
xxs: '0.625rem' xxs: '0.625rem',
'2.5xl': '1.6875rem'
}, },
zIndex: { zIndex: {
'50': 50 '50': 50
+3 -3
View File
@@ -1,18 +1,18 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.0.21", "version": "2.0.23",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"version": "2.0.20", "version": "2.0.21",
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"archiver": "^5.3.0", "archiver": "^5.3.0",
"axios": "^0.26.1", "axios": "^0.26.1",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"command-line-args": "^5.2.0", "command-line-args": "^5.2.0",
"date-and-time": "^2.0.1", "date-and-time": "^2.3.1",
"express": "^4.17.1", "express": "^4.17.1",
"express-fileupload": "^1.2.1", "express-fileupload": "^1.2.1",
"express-rate-limit": "^5.3.0", "express-rate-limit": "^5.3.0",
+5 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.0.21", "version": "2.0.23",
"description": "Self-hosted audiobook and podcast server", "description": "Self-hosted audiobook and podcast server",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
@@ -11,6 +11,9 @@
"build-win": "npm run client && pkg -t node16-win-x64 -o ./dist/win/audiobookshelf -C GZip .", "build-win": "npm run client && pkg -t node16-win-x64 -o ./dist/win/audiobookshelf -C GZip .",
"build-linux": "build/linuxpackager", "build-linux": "build/linuxpackager",
"docker": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 --push . -t advplyr/audiobookshelf", "docker": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 --push . -t advplyr/audiobookshelf",
"docker-amd64-local": "docker buildx build --platform linux/amd64 --load . -t advplyr/audiobookshelf-amd64-local",
"docker-arm64-local": "docker buildx build --platform linux/arm64 --load . -t advplyr/audiobookshelf-arm64-local",
"docker-armv7-local": "docker buildx build --platform linux/arm/v7 --load . -t advplyr/audiobookshelf-armv7-local",
"deploy": "node dist/autodeploy" "deploy": "node dist/autodeploy"
}, },
"bin": "prod.js", "bin": "prod.js",
@@ -31,7 +34,7 @@
"axios": "^0.26.1", "axios": "^0.26.1",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"command-line-args": "^5.2.0", "command-line-args": "^5.2.0",
"date-and-time": "^2.0.1", "date-and-time": "^2.3.1",
"express": "^4.17.1", "express": "^4.17.1",
"express-fileupload": "^1.2.1", "express-fileupload": "^1.2.1",
"express-rate-limit": "^5.3.0", "express-rate-limit": "^5.3.0",
+3 -1
View File
@@ -20,7 +20,9 @@ class Auth {
cors(req, res, next) { cors(req, res, next) {
res.header('Access-Control-Allow-Origin', '*') res.header('Access-Control-Allow-Origin', '*')
res.header("Access-Control-Allow-Methods", 'GET, POST, PATCH, PUT, DELETE, OPTIONS') res.header("Access-Control-Allow-Methods", 'GET, POST, PATCH, PUT, DELETE, OPTIONS')
res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Accept-Encoding, Range, Authorization") res.header('Access-Control-Allow-Headers', '*')
// TODO: Make sure allowing all headers is not a security concern. It is required for adding custom headers for SSO
// res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Accept-Encoding, Range, Authorization")
res.header('Access-Control-Allow-Credentials', true) res.header('Access-Control-Allow-Credentials', true)
if (req.method === 'OPTIONS') { if (req.method === 'OPTIONS') {
res.sendStatus(200) res.sendStatus(200)
+2 -2
View File
@@ -1,16 +1,16 @@
const date = require('date-and-time')
const { LogLevel } = require('./utils/constants') const { LogLevel } = require('./utils/constants')
class Logger { class Logger {
constructor() { constructor() {
this.logLevel = process.env.NODE_ENV === 'production' ? LogLevel.INFO : LogLevel.TRACE this.logLevel = process.env.NODE_ENV === 'production' ? LogLevel.INFO : LogLevel.TRACE
// this.logFileLevel = LogLevel.INFO
this.socketListeners = [] this.socketListeners = []
this.logManager = null this.logManager = null
} }
get timestamp() { get timestamp() {
return (new Date()).toISOString() return date.format(new Date(), 'YYYY-MM-DD HH:mm:ss')
} }
get levelString() { get levelString() {
+1
View File
@@ -451,6 +451,7 @@ class Server {
} else { } else {
Logger.debug(`[Server] User Online ${client.user.username}`) Logger.debug(`[Server] User Online ${client.user.username}`)
} }
this.io.emit('user_online', client.user.toJSONForPublic(this.playbackSessionManager.sessions, this.db.libraryItems)) this.io.emit('user_online', client.user.toJSONForPublic(this.playbackSessionManager.sessions, this.db.libraryItems))
user.lastSeen = Date.now() user.lastSeen = Date.now()
+12
View File
@@ -63,15 +63,27 @@ class AuthorController {
// If updating or removing cover image then clear cache // If updating or removing cover image then clear cache
if (payload.imagePath !== undefined && req.author.imagePath && payload.imagePath !== req.author.imagePath) { if (payload.imagePath !== undefined && req.author.imagePath && payload.imagePath !== req.author.imagePath) {
this.cacheManager.purgeImageCache(req.author.id) this.cacheManager.purgeImageCache(req.author.id)
if (!payload.imagePath) { // If removing image then remove file if (!payload.imagePath) { // If removing image then remove file
var currentImagePath = req.author.imagePath var currentImagePath = req.author.imagePath
await this.coverManager.removeFile(currentImagePath) await this.coverManager.removeFile(currentImagePath)
} else if (payload.imagePath.startsWith('http')) { // Check if image path is a url
var imageData = await this.authorFinder.saveAuthorImage(req.author.id, payload.imagePath)
if (imageData) {
req.author.imagePath = imageData.path
req.author.relImagePath = imageData.relPath
hasUpdated = hasUpdated || true;
} else {
req.author.imagePath = null
req.author.relImagePath = null
}
} }
} }
var authorNameUpdate = payload.name !== undefined && payload.name !== req.author.name var authorNameUpdate = payload.name !== undefined && payload.name !== req.author.name
var hasUpdated = req.author.update(payload) var hasUpdated = req.author.update(payload)
if (hasUpdated) { if (hasUpdated) {
if (authorNameUpdate) { // Update author name on all books if (authorNameUpdate) { // Update author name on all books
var itemsWithAuthor = this.db.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasAuthor(req.author.id)) var itemsWithAuthor = this.db.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasAuthor(req.author.id))
+1
View File
@@ -57,6 +57,7 @@ class MeController {
if (!libraryItem) { if (!libraryItem) {
return res.status(404).send('Item not found') return res.status(404).send('Item not found')
} }
var wasUpdated = req.user.createUpdateMediaProgress(libraryItem, req.body) var wasUpdated = req.user.createUpdateMediaProgress(libraryItem, req.body)
if (wasUpdated) { if (wasUpdated) {
await this.db.updateEntity('user', req.user) await this.db.updateEntity('user', req.user)
+1 -1
View File
@@ -103,7 +103,7 @@ class PodcastController {
Logger.error('Invalid podcast feed request response') Logger.error('Invalid podcast feed request response')
return res.status(500).send('Bad response from feed request') return res.status(500).send('Bad response from feed request')
} }
Logger.debug(`[PdocastController] Podcast feed size ${(data.data.length / 1024 / 1024).toFixed(2)}MB`) Logger.debug(`[PodcastController] Podcast feed size ${(data.data.length / 1024 / 1024).toFixed(2)}MB`)
var payload = await parsePodcastRssFeedXml(data.data, false, includeRaw) var payload = await parsePodcastRssFeedXml(data.data, false, includeRaw)
if (!payload) { if (!payload) {
return res.status(500).send('Invalid podcast RSS feed') return res.status(500).send('Invalid podcast RSS feed')
+6 -1
View File
@@ -4,6 +4,7 @@ const Path = require('path')
const Audnexus = require('../providers/Audnexus') const Audnexus = require('../providers/Audnexus')
const { downloadFile } = require('../utils/fileUtils') const { downloadFile } = require('../utils/fileUtils')
const filePerms = require('../utils/filePerms')
class AuthorFinder { class AuthorFinder {
constructor() { constructor() {
@@ -38,7 +39,11 @@ class AuthorFinder {
async saveAuthorImage(authorId, url) { async saveAuthorImage(authorId, url) {
var authorDir = this.AuthorPath var authorDir = this.AuthorPath
var relAuthorDir = Path.posix.join('/metadata', 'authors') var relAuthorDir = Path.posix.join('/metadata', 'authors')
await fs.ensureDir(authorDir)
if (!await fs.pathExists(authorDir)) {
await fs.ensureDir(authorDir)
await filePerms.setDefault(authorDir)
}
var imageExtension = url.toLowerCase().split('.').pop() var imageExtension = url.toLowerCase().split('.').pop()
var ext = imageExtension === 'png' ? 'png' : 'jpg' var ext = imageExtension === 'png' ? 'png' : 'jpg'
+15 -3
View File
@@ -180,11 +180,11 @@ class BookFinder {
Logger.debug(`Book Search: title: "${title}", author: "${author}", provider: ${provider}`) Logger.debug(`Book Search: title: "${title}", author: "${author}", provider: ${provider}`)
if (provider === 'google') { if (provider === 'google') {
return this.getGoogleBooksResults(title, author) books = await this.getGoogleBooksResults(title, author)
} else if (provider === 'audible') { } else if (provider === 'audible') {
return this.getAudibleResults(title, author, asin) books = await this.getAudibleResults(title, author, asin)
} else if (provider === 'itunes') { } else if (provider === 'itunes') {
return this.getiTunesAudiobooksResults(title, author) books = await this.getiTunesAudiobooksResults(title, author)
} else if (provider === 'libgen') { } else if (provider === 'libgen') {
books = await this.getLibGenResults(title, author, maxTitleDistance, maxAuthorDistance) books = await this.getLibGenResults(title, author, maxTitleDistance, maxAuthorDistance)
} else if (provider === 'openlibrary') { } else if (provider === 'openlibrary') {
@@ -208,6 +208,18 @@ class BookFinder {
} }
} }
if (!books.length && !options.currentlyTryingCleaned) {
var cleanedTitle = this.cleanTitleForCompares(title)
var cleanedAuthor = this.cleanAuthorForCompares(author)
if (cleanedTitle == title && cleanedAuthor == author) return books
Logger.debug(`Book Search, no matches.. checking cleaned title and author`)
options.currentlyTryingCleaned = true
return this.search(provider, cleanedTitle, cleanedAuthor, isbn, asin, options)
}
if (["google", "audible", "itunes"].includes(provider)) return books
return books.sort((a, b) => { return books.sort((a, b) => {
return a.totalDistance - b.totalDistance return a.totalDistance - b.totalDistance
}) })
+1 -1
View File
@@ -1,5 +1,5 @@
const DEFAULT_EXPIRATION = 1000 * 60 * 60 // 60 minutes const DEFAULT_EXPIRATION = 1000 * 60 * 60 // 60 minutes
const DEFAULT_TIMEOUT = 1000 * 60 * 20 // 20 minutes const DEFAULT_TIMEOUT = 1000 * 60 * 30 // 30 minutes
class Download { class Download {
constructor(download) { constructor(download) {
this.id = null this.id = null
+2 -2
View File
@@ -7,7 +7,7 @@ const LibraryFile = require('./files/LibraryFile')
const Book = require('./mediaTypes/Book') const Book = require('./mediaTypes/Book')
const Podcast = require('./mediaTypes/Podcast') const Podcast = require('./mediaTypes/Podcast')
const Video = require('./mediaTypes/Video') const Video = require('./mediaTypes/Video')
const { areEquivalent, copyValue, getId } = require('../utils/index') const { areEquivalent, copyValue, getId, cleanStringForSearch } = require('../utils/index')
class LibraryItem { class LibraryItem {
constructor(libraryItem = null) { constructor(libraryItem = null) {
@@ -451,7 +451,7 @@ class LibraryItem {
} }
searchQuery(query) { searchQuery(query) {
query = query.toLowerCase() query = cleanStringForSearch(query)
return this.media.searchQuery(query) return this.media.searchQuery(query)
} }
+14 -5
View File
@@ -1,8 +1,9 @@
const Path = require('path') const Path = require('path')
const Logger = require('../../Logger') const Logger = require('../../Logger')
const BookMetadata = require('../metadata/BookMetadata') const BookMetadata = require('../metadata/BookMetadata')
const { areEquivalent, copyValue } = require('../../utils/index') const { areEquivalent, copyValue, cleanStringForSearch } = require('../../utils/index')
const { parseOpfMetadataXML } = require('../../utils/parsers/parseOpfMetadata') const { parseOpfMetadataXML } = require('../../utils/parsers/parseOpfMetadata')
const { overdriveMediaMarkersExist, parseOverdriveMediaMarkersAsChapters } = require('../../utils/parsers/parseOverdriveMediaMarkers')
const abmetadataGenerator = require('../../utils/abmetadataGenerator') const abmetadataGenerator = require('../../utils/abmetadataGenerator')
const { readTextFile } = require('../../utils/fileUtils') const { readTextFile } = require('../../utils/fileUtils')
const AudioFile = require('../files/AudioFile') const AudioFile = require('../files/AudioFile')
@@ -303,7 +304,7 @@ class Book {
searchQuery(query) { searchQuery(query) {
var payload = { var payload = {
tags: this.tags.filter(t => t.toLowerCase().includes(query)), tags: this.tags.filter(t => cleanStringForSearch(t).includes(query)),
series: this.metadata.searchSeries(query), series: this.metadata.searchSeries(query),
authors: this.metadata.searchAuthors(query), authors: this.metadata.searchAuthors(query),
matchKey: null, matchKey: null,
@@ -360,10 +361,11 @@ class Book {
this.rebuildTracks() this.rebuildTracks()
} }
rebuildTracks() { rebuildTracks(preferOverdriveMediaMarker) {
Logger.debug(`[Book] Tracks being rebuilt...!`)
this.audioFiles.sort((a, b) => a.index - b.index) this.audioFiles.sort((a, b) => a.index - b.index)
this.missingParts = [] this.missingParts = []
this.setChapters() this.setChapters(preferOverdriveMediaMarker)
this.checkUpdateMissingTracks() this.checkUpdateMissingTracks()
} }
@@ -395,9 +397,16 @@ class Book {
return wasUpdated return wasUpdated
} }
setChapters() { setChapters(preferOverdriveMediaMarker = false) {
// If 1 audio file without chapters, then no chapters will be set // If 1 audio file without chapters, then no chapters will be set
var includedAudioFiles = this.audioFiles.filter(af => !af.exclude) var includedAudioFiles = this.audioFiles.filter(af => !af.exclude)
// If overdrive media markers are present and preferred, use those instead
if (preferOverdriveMediaMarker && overdriveMediaMarkersExist(includedAudioFiles)) {
Logger.info('[Book] Overdrive Media Markers and preference found! Using these for chapter definitions')
return this.chapters = parseOverdriveMediaMarkersAsChapters(includedAudioFiles)
}
if (includedAudioFiles.length === 1) { if (includedAudioFiles.length === 1) {
// 1 audio file with chapters // 1 audio file with chapters
if (includedAudioFiles[0].chapters) { if (includedAudioFiles[0].chapters) {
+5 -1
View File
@@ -20,6 +20,7 @@ class AudioMetaTags {
this.tagIsbn = null this.tagIsbn = null
this.tagLanguage = null this.tagLanguage = null
this.tagASIN = null this.tagASIN = null
this.tagOverdriveMediaMarker = null
if (metadata) { if (metadata) {
this.construct(metadata) this.construct(metadata)
@@ -58,6 +59,7 @@ class AudioMetaTags {
this.tagIsbn = metadata.tagIsbn || null this.tagIsbn = metadata.tagIsbn || null
this.tagLanguage = metadata.tagLanguage || null this.tagLanguage = metadata.tagLanguage || null
this.tagASIN = metadata.tagASIN || null this.tagASIN = metadata.tagASIN || null
this.tagOverdriveMediaMarker = metadata.tagOverdriveMediaMarker || null
} }
// Data parsed in prober.js // Data parsed in prober.js
@@ -82,6 +84,7 @@ class AudioMetaTags {
this.tagIsbn = payload.file_tag_isbn || null this.tagIsbn = payload.file_tag_isbn || null
this.tagLanguage = payload.file_tag_language || null this.tagLanguage = payload.file_tag_language || null
this.tagASIN = payload.file_tag_asin || null this.tagASIN = payload.file_tag_asin || null
this.tagOverdriveMediaMarker = payload.file_tag_overdrive_media_marker || null
} }
updateData(payload) { updateData(payload) {
@@ -105,7 +108,8 @@ class AudioMetaTags {
tagEncodedBy: payload.file_tag_encodedby || null, tagEncodedBy: payload.file_tag_encodedby || null,
tagIsbn: payload.file_tag_isbn || null, tagIsbn: payload.file_tag_isbn || null,
tagLanguage: payload.file_tag_language || null, tagLanguage: payload.file_tag_language || null,
tagASIN: payload.file_tag_asin || null tagASIN: payload.file_tag_asin || null,
tagOverdriveMediaMarker: payload.file_tag_overdrive_media_marker || null,
} }
var hasUpdates = false var hasUpdates = false
+8 -4
View File
@@ -1,5 +1,5 @@
const Logger = require('../../Logger') const Logger = require('../../Logger')
const { areEquivalent, copyValue } = require('../../utils/index') const { areEquivalent, copyValue, cleanStringForSearch } = require('../../utils/index')
const parseNameString = require('../../utils/parsers/parseNameString') const parseNameString = require('../../utils/parsers/parseNameString')
class BookMetadata { class BookMetadata {
constructor(metadata) { constructor(metadata) {
@@ -262,6 +262,10 @@ class BookMetadata {
{ {
tag: 'tagASIN', tag: 'tagASIN',
key: 'asin' key: 'asin'
},
{
tag: 'tagOverdriveMediaMarker',
key: 'overdriveMediaMarker'
} }
] ]
@@ -338,15 +342,15 @@ class BookMetadata {
} }
searchSeries(query) { searchSeries(query) {
return this.series.filter(se => se.name.toLowerCase().includes(query)) return this.series.filter(se => cleanStringForSearch(se.name).includes(query))
} }
searchAuthors(query) { searchAuthors(query) {
return this.authors.filter(se => se.name.toLowerCase().includes(query)) return this.authors.filter(au => cleanStringForSearch(au.name).includes(query))
} }
searchQuery(query) { // Returns key if match is found searchQuery(query) { // Returns key if match is found
var keysToCheck = ['title', 'asin', 'isbn'] var keysToCheck = ['title', 'asin', 'isbn']
for (var key of keysToCheck) { for (var key of keysToCheck) {
if (this[key] && this[key].toLowerCase().includes(query)) { if (this[key] && cleanStringForSearch(String(this[key])).includes(query)) {
return { return {
matchKey: key, matchKey: key,
matchText: this[key] matchText: this[key]
+2 -2
View File
@@ -1,5 +1,5 @@
const Logger = require('../../Logger') const Logger = require('../../Logger')
const { areEquivalent, copyValue } = require('../../utils/index') const { areEquivalent, copyValue, cleanStringForSearch } = require('../../utils/index')
class PodcastMetadata { class PodcastMetadata {
constructor(metadata) { constructor(metadata) {
@@ -94,7 +94,7 @@ class PodcastMetadata {
searchQuery(query) { // Returns key if match is found searchQuery(query) { // Returns key if match is found
var keysToCheck = ['title', 'author', 'itunesId', 'itunesArtistId'] var keysToCheck = ['title', 'author', 'itunesId', 'itunesArtistId']
for (var key of keysToCheck) { for (var key of keysToCheck) {
if (this[key] && String(this[key]).toLowerCase().includes(query)) { if (this[key] && cleanStringForSearch(String(this[key])).includes(query)) {
return { return {
matchKey: key, matchKey: key,
matchText: this[key] matchText: this[key]
@@ -13,6 +13,7 @@ class ServerSettings {
this.scannerPreferOpfMetadata = false this.scannerPreferOpfMetadata = false
this.scannerPreferMatchedMetadata = false this.scannerPreferMatchedMetadata = false
this.scannerDisableWatcher = false this.scannerDisableWatcher = false
this.scannerPreferOverdriveMediaMarker = false
// Metadata - choose to store inside users library item folder // Metadata - choose to store inside users library item folder
this.storeCoverWithItem = false this.storeCoverWithItem = false
@@ -47,6 +48,7 @@ class ServerSettings {
// Misc Flags // Misc Flags
this.chromecastEnabled = false this.chromecastEnabled = false
this.enableEReader = false this.enableEReader = false
this.dateFormat = 'MM/dd/yyyy'
this.logLevel = Logger.logLevel this.logLevel = Logger.logLevel
@@ -65,6 +67,7 @@ class ServerSettings {
this.scannerPreferOpfMetadata = !!settings.scannerPreferOpfMetadata this.scannerPreferOpfMetadata = !!settings.scannerPreferOpfMetadata
this.scannerPreferMatchedMetadata = !!settings.scannerPreferMatchedMetadata this.scannerPreferMatchedMetadata = !!settings.scannerPreferMatchedMetadata
this.scannerDisableWatcher = !!settings.scannerDisableWatcher this.scannerDisableWatcher = !!settings.scannerDisableWatcher
this.scannerPreferOverdriveMediaMarker = !!settings.scannerPreferOverdriveMediaMarker
this.storeCoverWithItem = !!settings.storeCoverWithItem this.storeCoverWithItem = !!settings.storeCoverWithItem
if (settings.storeCoverWithBook != undefined) { // storeCoverWithBook was old name of setting < v2 if (settings.storeCoverWithBook != undefined) { // storeCoverWithBook was old name of setting < v2
@@ -93,6 +96,7 @@ class ServerSettings {
this.sortingPrefixes = settings.sortingPrefixes || ['the', 'a'] this.sortingPrefixes = settings.sortingPrefixes || ['the', 'a']
this.chromecastEnabled = !!settings.chromecastEnabled this.chromecastEnabled = !!settings.chromecastEnabled
this.enableEReader = !!settings.enableEReader this.enableEReader = !!settings.enableEReader
this.dateFormat = settings.dateFormat || 'MM/dd/yyyy'
this.logLevel = settings.logLevel || Logger.logLevel this.logLevel = settings.logLevel || Logger.logLevel
this.version = settings.version || null this.version = settings.version || null
@@ -111,6 +115,7 @@ class ServerSettings {
scannerPreferOpfMetadata: this.scannerPreferOpfMetadata, scannerPreferOpfMetadata: this.scannerPreferOpfMetadata,
scannerPreferMatchedMetadata: this.scannerPreferMatchedMetadata, scannerPreferMatchedMetadata: this.scannerPreferMatchedMetadata,
scannerDisableWatcher: this.scannerDisableWatcher, scannerDisableWatcher: this.scannerDisableWatcher,
scannerPreferOverdriveMediaMarker: this.scannerPreferOverdriveMediaMarker,
storeCoverWithItem: this.storeCoverWithItem, storeCoverWithItem: this.storeCoverWithItem,
storeMetadataWithItem: this.storeMetadataWithItem, storeMetadataWithItem: this.storeMetadataWithItem,
rateLimitLoginRequests: this.rateLimitLoginRequests, rateLimitLoginRequests: this.rateLimitLoginRequests,
@@ -127,6 +132,7 @@ class ServerSettings {
sortingPrefixes: [...this.sortingPrefixes], sortingPrefixes: [...this.sortingPrefixes],
chromecastEnabled: this.chromecastEnabled, chromecastEnabled: this.chromecastEnabled,
enableEReader: this.enableEReader, enableEReader: this.enableEReader,
dateFormat: this.dateFormat,
logLevel: this.logLevel, logLevel: this.logLevel,
version: this.version version: this.version
} }
+1 -1
View File
@@ -91,7 +91,7 @@ class MediaProgress {
var timeRemaining = this.duration - this.currentTime var timeRemaining = this.duration - this.currentTime
// If time remaining is less than 5 seconds then mark as finished // If time remaining is less than 5 seconds then mark as finished
if ((this.progress >= 1 || (!isNaN(timeRemaining) && timeRemaining < 5))) { if ((this.progress >= 1 || (this.duration && !isNaN(timeRemaining) && timeRemaining < 5))) {
this.isFinished = true this.isFinished = true
this.finishedAt = Date.now() this.finishedAt = Date.now()
this.progress = 1 this.progress = 1
+1
View File
@@ -34,6 +34,7 @@ class LibraryScan {
get forceRescan() { return !!this._scanOptions.forceRescan } get forceRescan() { return !!this._scanOptions.forceRescan }
get preferAudioMetadata() { return !!this._scanOptions.preferAudioMetadata } get preferAudioMetadata() { return !!this._scanOptions.preferAudioMetadata }
get preferOpfMetadata() { return !!this._scanOptions.preferOpfMetadata } get preferOpfMetadata() { return !!this._scanOptions.preferOpfMetadata }
get preferOverdriveMediaMarker() { return !!this._scanOptions.preferOverdriveMediaMarker }
get findCovers() { return !!this._scanOptions.findCovers } get findCovers() { return !!this._scanOptions.findCovers }
get timestamp() { get timestamp() {
return (new Date()).toISOString() return (new Date()).toISOString()
+3 -2
View File
@@ -195,7 +195,7 @@ class MediaFileScanner {
} }
} }
async scanMediaFiles(mediaLibraryFiles, scanData, libraryItem, preferAudioMetadata, libraryScan = null) { async scanMediaFiles(mediaLibraryFiles, scanData, libraryItem, preferAudioMetadata, preferOverdriveMediaMarker, libraryScan = null) {
var hasUpdated = false var hasUpdated = false
var mediaScanResult = await this.executeMediaFileScans(libraryItem.mediaType, mediaLibraryFiles, scanData) var mediaScanResult = await this.executeMediaFileScans(libraryItem.mediaType, mediaLibraryFiles, scanData)
@@ -208,6 +208,7 @@ class MediaFileScanner {
} else if (mediaScanResult.audioFiles.length) { } else if (mediaScanResult.audioFiles.length) {
if (libraryScan) { if (libraryScan) {
libraryScan.addLog(LogLevel.DEBUG, `Library Item "${scanData.path}" Audio file scan took ${mediaScanResult.elapsed}ms for ${mediaScanResult.audioFiles.length} with average time of ${mediaScanResult.averageScanDuration}ms`) libraryScan.addLog(LogLevel.DEBUG, `Library Item "${scanData.path}" Audio file scan took ${mediaScanResult.elapsed}ms for ${mediaScanResult.audioFiles.length} with average time of ${mediaScanResult.averageScanDuration}ms`)
Logger.debug(`Library Item "${scanData.path}" Audio file scan took ${mediaScanResult.elapsed}ms for ${mediaScanResult.audioFiles.length} with average time of ${mediaScanResult.averageScanDuration}ms`)
} }
var totalAudioFilesToInclude = mediaScanResult.audioFiles.length var totalAudioFilesToInclude = mediaScanResult.audioFiles.length
@@ -247,7 +248,7 @@ class MediaFileScanner {
} }
if (hasUpdated) { if (hasUpdated) {
libraryItem.media.rebuildTracks() libraryItem.media.rebuildTracks(preferOverdriveMediaMarker)
} }
} else { // Podcast Media Type } else { // Podcast Media Type
var existingAudioFiles = mediaScanResult.audioFiles.filter(af => libraryItem.media.findFileWithInode(af.ino)) var existingAudioFiles = mediaScanResult.audioFiles.filter(af => libraryItem.media.findFileWithInode(af.ino))
+4 -1
View File
@@ -9,6 +9,7 @@ class ScanOptions {
this.preferAudioMetadata = false this.preferAudioMetadata = false
this.preferOpfMetadata = false this.preferOpfMetadata = false
this.preferMatchedMetadata = false this.preferMatchedMetadata = false
this.preferOverdriveMediaMarker = false
if (options) { if (options) {
this.construct(options) this.construct(options)
@@ -34,7 +35,8 @@ class ScanOptions {
storeCoverWithItem: this.storeCoverWithItem, storeCoverWithItem: this.storeCoverWithItem,
preferAudioMetadata: this.preferAudioMetadata, preferAudioMetadata: this.preferAudioMetadata,
preferOpfMetadata: this.preferOpfMetadata, preferOpfMetadata: this.preferOpfMetadata,
preferMatchedMetadata: this.preferMatchedMetadata preferMatchedMetadata: this.preferMatchedMetadata,
preferOverdriveMediaMarker: this.preferOverdriveMediaMarker
} }
} }
@@ -47,6 +49,7 @@ class ScanOptions {
this.preferAudioMetadata = serverSettings.scannerPreferAudioMetadata this.preferAudioMetadata = serverSettings.scannerPreferAudioMetadata
this.preferOpfMetadata = serverSettings.scannerPreferOpfMetadata this.preferOpfMetadata = serverSettings.scannerPreferOpfMetadata
this.scannerPreferMatchedMetadata = serverSettings.scannerPreferMatchedMetadata this.scannerPreferMatchedMetadata = serverSettings.scannerPreferMatchedMetadata
this.preferOverdriveMediaMarker = serverSettings.scannerPreferOverdriveMediaMarker
} }
} }
module.exports = ScanOptions module.exports = ScanOptions
+7 -7
View File
@@ -80,7 +80,7 @@ class Scanner {
// Scan all audio files // Scan all audio files
if (libraryItem.hasAudioFiles) { if (libraryItem.hasAudioFiles) {
var libraryAudioFiles = libraryItem.libraryFiles.filter(lf => lf.fileType === 'audio') var libraryAudioFiles = libraryItem.libraryFiles.filter(lf => lf.fileType === 'audio')
if (await MediaFileScanner.scanMediaFiles(libraryAudioFiles, libraryItemData, libraryItem, this.db.serverSettings.scannerPreferAudioMetadata)) { if (await MediaFileScanner.scanMediaFiles(libraryAudioFiles, libraryItemData, libraryItem, this.db.serverSettings.scannerPreferAudioMetadata, this.db.serverSettings.scannerPreferOverdriveMediaMarker)) {
hasUpdated = true hasUpdated = true
} }
@@ -310,7 +310,7 @@ class Scanner {
async scanNewLibraryItemDataChunk(newLibraryItemsData, libraryScan) { async scanNewLibraryItemDataChunk(newLibraryItemsData, libraryScan) {
var newLibraryItems = await Promise.all(newLibraryItemsData.map((lid) => { var newLibraryItems = await Promise.all(newLibraryItemsData.map((lid) => {
return this.scanNewLibraryItem(lid, libraryScan.libraryMediaType, libraryScan.preferAudioMetadata, libraryScan.preferOpfMetadata, libraryScan.findCovers, libraryScan) return this.scanNewLibraryItem(lid, libraryScan.libraryMediaType, libraryScan.preferAudioMetadata, libraryScan.preferOpfMetadata, libraryScan.findCovers, libraryScan.preferOverdriveMediaMarker, libraryScan)
})) }))
newLibraryItems = newLibraryItems.filter(li => li) // Filter out nulls newLibraryItems = newLibraryItems.filter(li => li) // Filter out nulls
@@ -337,7 +337,7 @@ class Scanner {
// forceRescan all existing audio files - will probe and update ID3 tag metadata // forceRescan all existing audio files - will probe and update ID3 tag metadata
var existingAudioFiles = existingLibraryFiles.filter(lf => lf.fileType === 'audio') var existingAudioFiles = existingLibraryFiles.filter(lf => lf.fileType === 'audio')
if (libraryScan.scanOptions.forceRescan && existingAudioFiles.length) { if (libraryScan.scanOptions.forceRescan && existingAudioFiles.length) {
if (await MediaFileScanner.scanMediaFiles(existingAudioFiles, scanData, libraryItem, libraryScan.preferAudioMetadata, libraryScan)) { if (await MediaFileScanner.scanMediaFiles(existingAudioFiles, scanData, libraryItem, libraryScan.preferAudioMetadata, libraryScan.preferOverdriveMediaMarker, libraryScan)) {
hasUpdated = true hasUpdated = true
} }
} }
@@ -345,7 +345,7 @@ class Scanner {
var newAudioFiles = newLibraryFiles.filter(lf => lf.fileType === 'audio') var newAudioFiles = newLibraryFiles.filter(lf => lf.fileType === 'audio')
var removedAudioFiles = filesRemoved.filter(lf => lf.fileType === 'audio') var removedAudioFiles = filesRemoved.filter(lf => lf.fileType === 'audio')
if (newAudioFiles.length || removedAudioFiles.length) { if (newAudioFiles.length || removedAudioFiles.length) {
if (await MediaFileScanner.scanMediaFiles(newAudioFiles, scanData, libraryItem, libraryScan.preferAudioMetadata, libraryScan)) { if (await MediaFileScanner.scanMediaFiles(newAudioFiles, scanData, libraryItem, libraryScan.preferAudioMetadata, libraryScan.preferOverdriveMediaMarker, libraryScan)) {
hasUpdated = true hasUpdated = true
} }
} }
@@ -379,7 +379,7 @@ class Scanner {
return hasUpdated ? libraryItem : null return hasUpdated ? libraryItem : null
} }
async scanNewLibraryItem(libraryItemData, libraryMediaType, preferAudioMetadata, preferOpfMetadata, findCovers, libraryScan = null) { async scanNewLibraryItem(libraryItemData, libraryMediaType, preferAudioMetadata, preferOpfMetadata, findCovers, preferOverdriveMediaMarker, libraryScan = null) {
if (libraryScan) libraryScan.addLog(LogLevel.DEBUG, `Scanning new library item "${libraryItemData.path}"`) if (libraryScan) libraryScan.addLog(LogLevel.DEBUG, `Scanning new library item "${libraryItemData.path}"`)
else Logger.debug(`[Scanner] Scanning new item "${libraryItemData.path}"`) else Logger.debug(`[Scanner] Scanning new item "${libraryItemData.path}"`)
@@ -388,7 +388,7 @@ class Scanner {
var mediaFiles = libraryItemData.libraryFiles.filter(lf => lf.fileType === 'audio' || lf.fileType === 'video') var mediaFiles = libraryItemData.libraryFiles.filter(lf => lf.fileType === 'audio' || lf.fileType === 'video')
if (mediaFiles.length) { if (mediaFiles.length) {
await MediaFileScanner.scanMediaFiles(mediaFiles, libraryItemData, libraryItem, preferAudioMetadata, libraryScan) await MediaFileScanner.scanMediaFiles(mediaFiles, libraryItemData, libraryItem, preferAudioMetadata, preferOverdriveMediaMarker, libraryScan)
} }
await libraryItem.syncFiles(preferOpfMetadata) await libraryItem.syncFiles(preferOpfMetadata)
@@ -608,7 +608,7 @@ class Scanner {
var libraryItemData = await getLibraryItemFileData(libraryMediaType, folder, fullPath, isSingleMediaItem, this.db.serverSettings) var libraryItemData = await getLibraryItemFileData(libraryMediaType, folder, fullPath, isSingleMediaItem, this.db.serverSettings)
if (!libraryItemData) return null if (!libraryItemData) return null
var serverSettings = this.db.serverSettings var serverSettings = this.db.serverSettings
return this.scanNewLibraryItem(libraryItemData, libraryMediaType, serverSettings.scannerPreferAudioMetadata, serverSettings.scannerPreferOpfMetadata, serverSettings.scannerFindCovers) return this.scanNewLibraryItem(libraryItemData, libraryMediaType, serverSettings.scannerPreferAudioMetadata, serverSettings.scannerPreferOpfMetadata, serverSettings.scannerFindCovers, serverSettings.scannerPreferOverdriveMediaMarker)
} }
async searchForCover(libraryItem, libraryScan = null) { async searchForCover(libraryItem, libraryScan = null) {
+2 -2
View File
@@ -273,8 +273,8 @@ function parseAbMetadataText(text, mediaType) {
} else if (!metadataMapper[keyValue[0].trim()]) { } else if (!metadataMapper[keyValue[0].trim()]) {
Logger.warn(`abmetadata key "${keyValue[0].trim()}" is not a valid ${mediaType} metadata key`) Logger.warn(`abmetadata key "${keyValue[0].trim()}" is not a valid ${mediaType} metadata key`)
} else { } else {
var key = keyValue[0].trim() var key = keyValue.shift().trim()
var value = keyValue[1].trim() var value = keyValue.join('=').trim()
mediaMetadataDetails[key] = metadataMapper[key].from(value) mediaMetadataDetails[key] = metadataMapper[key].from(value)
} }
} }
+6
View File
@@ -130,3 +130,9 @@ module.exports.toNumber = (val, fallback = 0) => {
if (isNaN(val) || val === null) return fallback if (isNaN(val) || val === null) return fallback
return Number(val) return Number(val)
} }
module.exports.cleanStringForSearch = (str) => {
if (!str) return ''
// Remove ' . ` " ,
return str.toLowerCase().replace(/[\'\.\`\",]/g, '').trim()
}
@@ -0,0 +1,148 @@
const Logger = require('../../Logger')
// given a list of audio files, extract all of the Overdrive Media Markers metaTags, and return an array of them as XML
function extractOverdriveMediaMarkers(includedAudioFiles) {
Logger.debug('[parseOverdriveMediaMarkers] Extracting overdrive media markers')
var markers = includedAudioFiles.map((af) => af.metaTags.tagOverdriveMediaMarker).filter(notUndefined => notUndefined !== undefined).filter(elem => { return elem !== null }) || []
return markers
}
// given the array of Overdrive Media Markers from generateOverdriveMediaMarkers()
// parse and clean them in to something a bit more usable
function cleanOverdriveMediaMarkers(overdriveMediaMarkers) {
Logger.debug('[parseOverdriveMediaMarkers] Cleaning up overdrive media markers')
/*
returns an array of arrays of objects. Each inner array corresponds to an audio track, with it's objects being a chapter:
[
[
{
"Name": "Chapter 1",
"Time": "0:00.000"
},
{
"Name": "Chapter 2",
"Time": "15:51.000"
},
{ etc }
]
]
*/
var parseString = require('xml2js').parseString; // function to convert xml to JSON
var parsedOverdriveMediaMarkers = []
overdriveMediaMarkers.forEach(function (item, index) {
var parsed_result
parseString(item, function (err, result) {
/*
result.Markers.Marker is the result of parsing the XML for the MediaMarker tags for the MP3 file (Part##.mp3)
it is shaped like this, and needs further cleaning below:
[
{
"Name": [
"Chapter 1: "
],
"Time": [
"0:00.000"
]
},
{
ANOTHER CHAPTER
},
]
*/
// The values for Name and Time in results.Markers.Marker are returned as Arrays from parseString and should be strings
parsed_result = objectValuesArrayToString(result.Markers.Marker)
})
parsedOverdriveMediaMarkers.push(parsed_result)
})
return removeExtraChapters(parsedOverdriveMediaMarkers)
}
// given an array of objects, convert any values that are arrays to strings
function objectValuesArrayToString(arrayOfObjects) {
Logger.debug('[parseOverdriveMediaMarkers] Converting Marker object values from arrays to strings')
arrayOfObjects.forEach((item) => {
Object.keys(item).forEach(key => {
item[key] = item[key].toString()
})
})
return arrayOfObjects
}
// Overdrive sometimes has weird chapters and subchapters defined
// These aren't necessary, so lets remove them
function removeExtraChapters(parsedOverdriveMediaMarkers) {
Logger.debug('[parseOverdriveMediaMarkers] Removing any unnecessary chapters')
const weirdChapterFilterRegex = /([(]\d|[cC]ontinued)/
var cleaned = []
parsedOverdriveMediaMarkers.forEach(function (item) {
cleaned.push(item.filter(chapter => !weirdChapterFilterRegex.test(chapter.Name)))
})
return cleaned
}
// Given a set of chapters from generateParsedChapters, add the end time to each one
function addChapterEndTimes(chapters, totalAudioDuration) {
Logger.debug('[parseOverdriveMediaMarkers] Adding chapter end times')
chapters.forEach((chapter, chapter_index) => {
if (chapter_index < chapters.length - 1) {
chapter.end = chapters[chapter_index + 1].start
} else {
chapter.end = totalAudioDuration
}
})
return chapters
}
// The function that actually generates the Chapters object that we update ABS with
function generateParsedChapters(includedAudioFiles, cleanedOverdriveMediaMarkers) {
Logger.debug('[parseOverdriveMediaMarkers] Generating new chapters for ABS')
// logic ported over from benonymity's OverdriveChapterizer:
// https://github.com/benonymity/OverdriveChapterizer/blob/main/chapters.py
var parsedChapters = []
var length = 0.0
var index = 0
var time = 0.0
// cleanedOverdriveMediaMarkers is an array of array of objects, where the inner array matches to the included audio files tracks
// this allows us to leverage the individual track durations when calculating the start times of chapters in tracks after the first (using length)
includedAudioFiles.forEach((track, track_index) => {
cleanedOverdriveMediaMarkers[track_index].forEach((chapter) => {
time = chapter.Time.split(":")
time = length + parseFloat(time[0]) * 60 + parseFloat(time[1])
var newChapterData = {
id: index++,
start: time,
title: chapter.Name
}
parsedChapters.push(newChapterData)
})
length += track.duration
})
parsedChapters = addChapterEndTimes(parsedChapters, length) // we need all the start times sorted out before we can add the end times
return parsedChapters
}
module.exports.overdriveMediaMarkersExist = (includedAudioFiles) => {
return extractOverdriveMediaMarkers(includedAudioFiles).length > 1
}
module.exports.parseOverdriveMediaMarkersAsChapters = (includedAudioFiles) => {
Logger.info('[parseOverdriveMediaMarkers] Parsing of Overdrive Media Markers started')
var overdriveMediaMarkers = extractOverdriveMediaMarkers(includedAudioFiles)
var cleanedOverdriveMediaMarkers = cleanOverdriveMediaMarkers(overdriveMediaMarkers)
var parsedChapters = generateParsedChapters(includedAudioFiles, cleanedOverdriveMediaMarkers)
return parsedChapters
}
+1
View File
@@ -192,6 +192,7 @@ function parseTags(format, verbose) {
file_tag_movement: tryGrabTags(format, 'movement', 'mvin'), file_tag_movement: tryGrabTags(format, 'movement', 'mvin'),
file_tag_genre1: tryGrabTags(format, 'tmp_genre1', 'genre1'), file_tag_genre1: tryGrabTags(format, 'tmp_genre1', 'genre1'),
file_tag_genre2: tryGrabTags(format, 'tmp_genre2', 'genre2'), file_tag_genre2: tryGrabTags(format, 'tmp_genre2', 'genre2'),
file_tag_overdrive_media_marker: tryGrabTags(format, 'OverDrive MediaMarkers'),
} }
for (const key in tags) { for (const key in tags) {
if (!tags[key]) { if (!tags[key]) {