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
+2 -1
View File
@@ -11,6 +11,7 @@ test/
/client/.nuxt/
/client/dist/
/dist/
library/
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 class="flex h-full items-center">
<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 to="/">
@@ -12,7 +12,7 @@
<ui-libraries-dropdown />
<controls-global-search v-if="currentLibrary" class="hidden md:block" />
<controls-global-search v-if="currentLibrary" class="" />
<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>
@@ -20,11 +20,11 @@
<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>
</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>
</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>
</nuxt-link>
+1 -1
View File
@@ -25,7 +25,7 @@
</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">
<p class="transform text-sm">{{ shelf.label }}</p>
</div>
+22 -4
View File
@@ -2,13 +2,19 @@
<div class="w-full h-20 md:h-10 relative">
<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'">
<p>Home</p>
<p class="text-sm">Home</p>
</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'">
<p>Library</p>
<p class="text-sm">Library</p>
</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'">
<p>Series</p>
<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 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>
</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">
@@ -98,6 +104,9 @@ export default {
}
},
computed: {
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
@@ -129,6 +138,12 @@ export default {
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
currentLibraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType']
},
isPodcastLibrary() {
return this.currentLibraryMediaType === 'podcast'
},
homePage() {
return this.$route.name === 'library-library'
},
@@ -156,6 +171,9 @@ export default {
},
isIssuesFilter() {
return this.filterBy === 'issues' && this.$route.query.filter === 'issues'
},
isPodcastSearchPage() {
return this.$route.name === 'library-library-podcast-search'
}
},
methods: {
+3 -3
View File
@@ -4,7 +4,7 @@
<span class="material-icons text-2xl">arrow_back</span>
</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>
<div v-show="routeName === route.iod" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
@@ -66,7 +66,7 @@ export default {
},
{
id: 'config-sessions',
title: 'Sessions',
title: 'Listening Sessions',
path: '/config/sessions'
},
{
@@ -76,7 +76,7 @@ export default {
},
{
id: 'config-log',
title: 'Log',
title: 'Logs',
path: '/config/log'
}
]
+8 -8
View File
@@ -1,30 +1,30 @@
<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 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" />
</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>
<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 }}
</nuxt-link>
<div v-if="!playerHandler.isVideo" class="text-gray-400 flex items-center">
<span class="material-icons text-sm">person</span>
<p v-if="podcastAuthor">{{ podcastAuthor }}</p>
<p v-else-if="authors.length" class="pl-1.5 text-sm sm:text-base">
<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 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>
</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 class="text-gray-400 flex items-center">
<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 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>
<player-ui
ref="audioPlayer"
+9 -2
View File
@@ -6,11 +6,18 @@
</div>
<div v-if="!isPodcast" class="px-4 flex-grow">
<div class="flex items-center">
<h1>{{ book.title }}</h1>
<h1 class="text-base">{{ book.title }}</h1>
<div class="flex-grow" />
<p>{{ book.publishedYear }}</p>
</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">
<p class="text-gray-500 text-xs">{{ book.description }}</p>
</div>
+3 -14
View File
@@ -62,21 +62,10 @@ export default {
matchHtml() {
if (!this.matchText || !this.search) return ''
if (this.matchKey === 'subtitle') return ''
var matchSplit = this.matchText.toLowerCase().split(this.search.toLowerCase().trim())
if (matchSplit.length < 2) return ''
var html = ''
var totalLenSoFar = 0
for (let i = 0; i < matchSplit.length - 1; i++) {
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
// This used to highlight the part of the search found
// but with removing commas periods etc this is no longer plausible
const html = this.matchText
if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>`
if (this.matchKey === 'authors') return `by ${html}`
+8 -3
View File
@@ -144,6 +144,9 @@ export default {
}
},
computed: {
dateFormat() {
return this.store.state.serverSettings.dateFormat
},
showExperimentalFeatures() {
return this.store.state.showExperimentalFeatures
},
@@ -245,12 +248,14 @@ export default {
return this.mediaMetadata.authorNameLF
},
displayTitle() {
if (this.recentEpisode) return this.recentEpisode.title
if (this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix) {
return this.mediaMetadata.titleIgnorePrefix
}
return this.title
},
displayLineTwo() {
if (this.recentEpisode) return this.title
if (this.isPodcast) return this.author
if (this.isAuthorBookshelfView) {
return this.mediaMetadata.publishedYear || ''
@@ -259,9 +264,9 @@ export default {
return this.author
},
displaySortLine() {
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs)
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs)
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt)
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs, this.dateFormat)
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs, this.dateFormat)
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 === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size)
if (this.orderBy === 'media.numTracks') return `${this.numEpisodes} Episodes`
+10 -7
View File
@@ -1,5 +1,5 @@
<template>
<div class="w-80 ml-6 relative">
<div class="sm:w-80 w-full sm:ml-6 relative">
<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" />
</form>
@@ -7,7 +7,7 @@
<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>
</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">
<li v-if="isTyping" class="py-2 px-2">
<p>Thinking...</p>
@@ -21,7 +21,7 @@
<template v-else>
<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">
<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}`">
<cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
</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>
<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}`">
<cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
</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>
<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)}`">
<cards-author-search-card :author="item" />
</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>
<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}`">
<cards-series-search-card :series="item.series" :book-items="item.books" />
</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>
<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)}`">
<cards-tag-search-card :tag="item.name" />
</nuxt-link>
@@ -97,6 +97,9 @@ export default {
}
},
methods: {
clickOption() {
this.clearResults()
},
submitSearch() {
if (!this.search) return
var search = this.search
@@ -1,10 +1,10 @@
<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)">
<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 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 class="absolute -bottom-2 left-0 right-0 w-full flex justify-center">
<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-1.5 right-0 w-full flex justify-center" :style="{ left: arrowLeft + 'px' }">
<div class="arrow-down" />
</div>
<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="flex items-center justify-between">
<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" />
</div>
</div>
@@ -40,7 +40,9 @@ export default {
showMenu: false,
currentPlaybackRate: 0,
MIN_SPEED: 0.5,
MAX_SPEED: 3
MAX_SPEED: 3,
menuLeft: -92,
arrowLeft: 0
}
},
computed: {
@@ -80,8 +82,22 @@ export default {
var newPlaybackRate = this.playbackRate - 0.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) {
if (val) {
this.updateMenuPositions()
this.currentPlaybackRate = this.playbackRate
} else if (this.currentPlaybackRate !== this.playbackRate) {
this.$emit('change', this.playbackRate)
+2 -2
View File
@@ -1,7 +1,7 @@
<template>
<div class="relative" v-click-outside="clickOutside" @mouseover="mouseover" @mouseleave="mouseleave">
<div class="cursor-pointer" @mousedown.prevent @mouseup.prevent @click="clickVolumeIcon">
<span class="material-icons text-3xl">{{ volumeIcon }}</span>
<div class="cursor-pointer text-gray-300 hover:text-white" @mousedown.prevent @mouseup.prevent @click="clickVolumeIcon">
<span class="material-icons text-2xl sm:text-3xl">{{ volumeIcon }}</span>
</div>
<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">
+19 -5
View File
@@ -1,11 +1,14 @@
<template>
<modals-modal v-model="show" name="chapters" :width="500" :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">
<modals-modal v-model="show" name="chapters" :width="600" :height="'unset'">
<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">
<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="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>
@@ -70,4 +73,15 @@ 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>
<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">
<span class="material-icons text-4xl">close</span>
<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-2xl md:text-4xl">close</span>
</div>
<div ref="content" class="text-white">
<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-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" />
</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" />
</div>
</div>
@@ -5,7 +5,7 @@
<p class="font-book text-3xl text-white truncate">Session {{ _session.id }}</p>
</div>
</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">
<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>
+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 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">
<span class="material-icons text-4xl">close</span>
<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-2xl landscape:text-2xl md:portrait:text-4xl lg:text-4xl">close</span>
</div>
<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 />
<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 />
+8 -10
View File
@@ -25,6 +25,9 @@
<ui-text-input-with-label v-model="authorCopy.asin" :disabled="processing" label="ASIN" />
</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">
<ui-textarea-with-label v-model="authorCopy.description" :disabled="processing" label="Description" :rows="8" />
</div>
@@ -43,19 +46,13 @@
<script>
export default {
// props: {
// value: Boolean,
// author: {
// type: Object,
// default: () => {}
// }
// },
data() {
return {
authorCopy: {
name: '',
asin: '',
description: ''
description: '',
imagePath: ''
},
processing: false
}
@@ -95,9 +92,10 @@ export default {
this.authorCopy.name = this.author.name
this.authorCopy.asin = this.author.asin
this.authorCopy.description = this.author.description
this.authorCopy.imagePath = this.author.imagePath
},
async submitForm() {
var keysToCheck = ['name', 'asin', 'description']
var keysToCheck = ['name', 'asin', 'description', 'imagePath']
var updatePayload = {}
keysToCheck.forEach((key) => {
if (this.authorCopy[key] !== this.author[key]) {
@@ -167,4 +165,4 @@ export default {
mounted() {},
beforeDestroy() {}
}
</script>
</script>
+22 -7
View File
@@ -1,13 +1,13 @@
<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>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none">
<p class="font-book text-3xl text-white truncate pointer-events-none">{{ title }}</p>
<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-xl md:portrait:text-3xl md:landscape:text-lg lg:text-3xl text-white truncate pointer-events-none">{{ title }}</p>
</div>
</template>
<div class="absolute -top-10 left-0 z-10 w-full flex">
<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>
</div>
@@ -30,6 +30,8 @@ export default {
return {
processing: false,
libraryItem: null,
availableHeight: 0,
marginTop: 0,
tabs: [
{
id: 'details',
@@ -133,8 +135,7 @@ export default {
})
},
height() {
var maxHeightAllowed = window.innerHeight - 150
return Math.min(maxHeightAllowed, 650)
return Math.min(this.availableHeight, 650)
},
tabName() {
var _tab = this.tabs.find((t) => t.id === this.selectedTab)
@@ -246,15 +247,29 @@ export default {
}
},
registerListeners() {
window.addEventListener('orientationchange', this.orientationChange)
this.$eventBus.$on('modal-hotkey', this.hotkey)
this.$eventBus.$on(`${this.selectedLibraryItemId}_updated`, this.libraryItemUpdated)
},
unregisterListeners() {
window.removeEventListener('orientationchange', this.orientationChange)
this.$eventBus.$off('modal-hotkey', this.hotkey)
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() {
this.unregisterListeners()
}
+6 -6
View File
@@ -1,6 +1,6 @@
<template>
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6 relative">
<div class="flex">
<div class="w-full h-full overflow-hidden overflow-y-auto px-2 sm:px-4 py-6 relative">
<div class="flex flex-wrap">
<div class="relative">
<covers-book-cover :library-item="libraryItem" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<!-- book cover overlay -->
@@ -11,14 +11,14 @@
</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 v-if="userCanUpload" class="w-40 pr-2 pt-4" style="min-width: 160px">
<ui-file-input ref="fileInput" @change="fileUploadSelected">Upload Cover</ui-file-input>
<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"><span class="hidden md:inline-block">Upload Cover</span><span class="material-icons inline-block md:!hidden">upload</span></ui-file-input>
</div>
<form @submit.prevent="submitForm" class="flex flex-grow">
<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>
</div>
@@ -5,23 +5,24 @@
<widgets-podcast-details-edit v-else ref="itemDetailsEdit" :library-item="libraryItem" @submit="saveAndClose" />
</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">
<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" />
<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-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-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>
@@ -2,13 +2,13 @@
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
<!-- Merge to m4b -->
<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>
<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>
</div>
<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.READY" class="text-success mb-2">Download Ready!</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">
<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 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
} 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') {
updatePayload.metadata.genres = this.selectedMatch[key].split(',').map((v) => v.trim())
} else if (key === 'tags') {
@@ -3,21 +3,21 @@
<div class="flex-grow" />
<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">
<span class="material-icons text-3xl">first_page</span>
<span class="material-icons text-2xl sm:text-3xl">first_page</span>
</div>
<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 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>
</div>
<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 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>
<controls-playback-speed-control v-model="playbackRate" @input="playbackRateUpdated" @change="playbackRateChanged" />
<controls-playback-speed-control v-model="playbackRateInput" @input="playbackRateUpdated" @change="playbackRateChanged" />
</template>
<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">
@@ -40,7 +40,16 @@ export default {
data() {
return {}
},
computed: {},
computed: {
playbackRateInput: {
get() {
return this.playbackRate
},
set(val) {
this.$emit('update:playbackRate', val)
}
}
},
methods: {
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 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 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">
<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>
@@ -34,6 +34,10 @@ export default {
chapters: {
type: Array,
default: () => []
},
currentChapter: {
type: Object,
default: () => {}
}
},
data() {
@@ -46,7 +50,8 @@ export default {
trackOffsetLeft: 16, // Track is 16px from edge
playedTrackWidth: 0,
readyTrackWidth: 0,
bufferTrackWidth: 0
bufferTrackWidth: 0,
useChapterTrack: false
}
},
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: {
setUseChapterTrack(useChapterTrack) {
this.useChapterTrack = useChapterTrack
this.updateBufferTrack()
this.updatePlayedTrackWidth()
},
clickTrack(e) {
if (this.loading) return
var offsetX = e.offsetX
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) {
console.error('Invalid time', perc, time)
return
@@ -76,7 +97,10 @@ export default {
this.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)
if (this.bufferTrackWidth === bufferlen || !this.$refs.bufferTrack) return
if (this.$refs.bufferTrack) this.$refs.bufferTrack.style.width = bufferlen + 'px'
@@ -97,8 +121,10 @@ export default {
this.updatePlayedTrackWidth()
},
updatePlayedTrackWidth() {
var perc = this.currentTime / this.duration
var ptWidth = Math.round(perc * this.trackWidth)
const time = this.useChapterTrack ? Math.max(0, this.currentTime - this.currentChapterStart) : this.currentTime
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
var ptWidth = Math.round((time / duration) * this.trackWidth)
if (this.playedTrackWidth === ptWidth) {
return
}
@@ -116,9 +142,11 @@ export default {
},
mousemoveTrack(e) {
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) {
var width = this.$refs.hoverTimestamp.clientWidth
@@ -139,9 +167,9 @@ export default {
this.$refs.hoverTimestampArrow.style.left = posLeft + 'px'
}
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) {
hoverText += ` - ${chapter.title}`
}
+54 -18
View File
@@ -1,40 +1,48 @@
<template>
<div class="w-full -mt-6">
<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> -->
<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')">
<span v-if="!sleepTimerSet" class="material-icons" style="font-size: 1.7rem">snooze</span>
<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 text-2xl sm:text-2.5xl">snooze</span>
<div v-else class="flex items-center">
<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>
</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')">
<span class="material-icons" style="font-size: 1.7rem">{{ bookmarks.length ? 'bookmarks' : 'bookmark_border' }}</span>
<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 text-2xl sm:text-2.5xl">{{ bookmarks.length ? 'bookmarks' : 'bookmark_border' }}</span>
</div>
<div v-if="chapters.length" class="cursor-pointer text-gray-300 mx-1 md:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="showChapters">
<span class="material-icons text-3xl">format_list_bulleted</span>
<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-2xl sm:text-3xl">format_list_bulleted</span>
</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>
<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>
<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">
<p ref="currentTimestamp" class="font-mono 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 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 hidden sm:block text-gray-100 pointer-events-auto">&nbsp;/&nbsp;{{ progressPercent }}%</p>
<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" />
<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>
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
@@ -66,7 +74,8 @@ export default {
seekLoading: false,
showChaptersModal: false,
currentTime: 0,
duration: 0
duration: 0,
useChapterTrack: false
}
},
computed: {
@@ -86,6 +95,10 @@ export default {
return this.$store.getters['user/getToken']
},
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
},
timeRemainingPretty() {
@@ -95,8 +108,11 @@ export default {
return '-' + this.$secondsToTimestamp(this.timeRemaining)
},
progressPercent() {
if (!this.duration) return 0
return Math.round((100 * this.currentTime) / this.duration)
const duration = this.useChapterTrack ? this.currentChapterDuration : 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() {
return this.chapters.find((chapter) => chapter.start <= this.currentTime && this.currentTime < chapter.end)
@@ -104,6 +120,14 @@ export default {
currentChapterName() {
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() {
return this.$store.state.playerIsFullscreen
},
@@ -192,6 +216,16 @@ export default {
this.seek(chapter.start)
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) {
this.$emit('seek', time)
},
@@ -239,10 +273,10 @@ export default {
console.error('No timestamp el')
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
},
setBufferTime(bufferTime) {
if (this.$refs.trackbar) this.$refs.trackbar.setBufferTime(bufferTime)
},
@@ -252,6 +286,8 @@ export default {
},
init() {
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)
},
settingsUpdated(settings) {
+1 -1
View File
@@ -2,7 +2,7 @@
<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)">
<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 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) {
background-color: #3a3a3a;
background-color: #373838;
}
#accounts tr:nth-child(odd) {
background-color: #2f2f2f;
}
#accounts tr:hover {
@@ -204,6 +208,6 @@ export default {
font-weight: 600;
padding-top: 5px;
padding-bottom: 5px;
background-color: #333;
background-color: #272727
}
</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">
<template v-for="library in libraryCopies">
<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>
</template>
</draggable>
@@ -7,18 +7,26 @@
</svg>
<p class="text-xl font-book pl-4 hover:underline cursor-pointer" @click.stop="$emit('click', library)">{{ library.name }}</p>
<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" small color="bg" class="ml-2" @click.stop="forceScan">Force Re-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 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-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="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-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">
<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" />
</svg>
</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>
</template>
@@ -30,22 +38,19 @@ export default {
default: () => {}
},
selected: Boolean,
showEdit: Boolean,
dragging: Boolean
},
data() {
return {
mouseover: false,
isDeleting: false
isDeleting: false,
showMobileMenu: false
}
},
computed: {
isHovering() {
return this.mouseover && !this.dragging
},
isMain() {
return this.library.id === 'main'
},
libraryScan() {
return this.$store.getters['scanners/getLibraryScan'](this.library.id)
},
@@ -54,9 +59,50 @@ export default {
},
isBookLibrary() {
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: {
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() {
this.$axios
.$post(`/api/libraries/${this.library.id}/matchall`)
@@ -97,7 +143,6 @@ export default {
}
},
deleteClick() {
if (this.isMain) return
if (confirm(`Are you sure you want to permanently delete library "${this.library.name}"?`)) {
this.isDeleting = true
this.$axios
+2 -1
View File
@@ -1,7 +1,8 @@
<template>
<div>
<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>
</template>
+6 -6
View File
@@ -1,14 +1,14 @@
<template>
<div v-if="currentLibrary" class="relative w-36 h-8" 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">
<span class="flex items-center">
<widgets-library-icon :icon="currentLibraryIcon" class="mr-2" />
<span class="block truncate text-sm">{{ currentLibrary.name }}</span>
<div v-if="currentLibrary" class="relative sm:w-36 h-8 px-1.5" v-click-outside="clickOutsideObj">
<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 justify-center sm:justify-start">
<widgets-library-icon :icon="currentLibraryIcon" class="sm:mr-2" />
<span class="hidden sm:block">{{ currentLibrary.name }}</span>
</span>
</button>
<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">
<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">
+1 -1
View File
@@ -4,7 +4,7 @@
<div ref="wrapper" class="relative">
<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 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">
<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>
+1 -1
View File
@@ -53,7 +53,7 @@ export default {
var tooltip = document.createElement('div')
this.tooltipId = String(Math.floor(Math.random() * 10000))
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.backgroundColor = 'rgba(0,0,0,0.85)'
tooltip.innerHTML = this.text
+18 -18
View File
@@ -1,21 +1,21 @@
<template>
<div class="w-full h-full relative">
<form class="w-full h-full px-4 py-6" @submit.prevent="submitForm">
<div class="flex -mx-1">
<div class="w-1/2 px-1">
<form class="w-full h-full px-2 md:px-4 py-6" @submit.prevent="submitForm">
<div class="flex flex-wrap -mx-1">
<div class="w-full md:w-1/2 px-1">
<ui-text-input-with-label ref="titleInput" v-model="details.title" label="Title" />
</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" />
</div>
</div>
<div class="flex mt-2 -mx-1">
<div class="w-3/4 px-1">
<div class="flex flex-wrap mt-2 -mx-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 -->
<ui-multi-select-query-input ref="authorsSelect" v-model="details.authors" label="Authors" endpoint="authors/search" />
</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" />
</div>
</div>
@@ -28,35 +28,35 @@
<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="w-1/2 px-1">
<div class="flex flex-wrap mt-2 -mx-1">
<div class="w-full md:w-1/2 px-1">
<ui-multi-select ref="genresSelect" v-model="details.genres" label="Genres" :items="genres" />
</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" />
</div>
</div>
<div class="flex mt-2 -mx-1">
<div class="w-1/2 px-1">
<div class="flex flex-wrap mt-2 -mx-1">
<div class="w-full md:w-1/2 px-1">
<ui-multi-select ref="narratorsSelect" v-model="details.narrators" label="Narrators" :items="narrators" />
</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" />
</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" />
</div>
</div>
<div class="flex mt-2 -mx-1">
<div class="w-1/2 px-1">
<div class="flex flex-wrap mt-2 -mx-1">
<div class="w-full md:w-1/2 px-1">
<ui-text-input-with-label ref="publisherInput" v-model="details.publisher" label="Publisher" />
</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" />
</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">
<ui-checkbox v-model="details.explicit" label="Explicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
</div>
+5 -11
View File
@@ -110,13 +110,7 @@ export default {
}
console.log('Init Payload', payload)
if (payload.session) {
if (this.$refs.streamContainer) {
this.$refs.streamContainer.sessionOpen(payload.session)
} else {
console.warn('Stream Container not mounted')
}
}
if (payload.serverSettings) {
this.$refs.streamContainer.sessionOpen(payload.session)
}
// Start scans currently running
@@ -243,9 +237,9 @@ export default {
var existingScan = this.$store.getters['scanners/getLibraryScan'](data.id)
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 {
this.$toast.success(message, { timeout: 5000, position: 'bottom-center' })
this.$toast.success(message, { timeout: 5000 })
}
this.$store.commit('scanners/remove', data)
@@ -254,7 +248,7 @@ export default {
this.$root.socket.emit('cancel_scan', id)
},
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)
},
scanProgress(data) {
@@ -263,7 +257,7 @@ export default {
data.toastId = existingScan.toastId
this.$toast.update(existingScan.toastId, { content: `Scanning "${existingScan.name}"... ${data.progress.progress || 0}%`, options: { timeout: false } }, true)
} 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)
+41 -11
View File
@@ -28,11 +28,11 @@ export default {
var validOtherFiles = []
var ignoredFiles = []
files.forEach((file) => {
var filetype = this.checkFileType(file.name)
if (!filetype) ignoredFiles.push(file)
// var filetype = this.checkFileType(file.name)
if (!file.filetype) ignoredFiles.push(file)
else {
file.filetype = filetype
if (filetype === 'audio' || (filetype === 'ebook' && mediaType === 'book')) validItemFiles.push(file)
// file.filetype = filetype
if (file.filetype === 'audio' || (file.filetype === 'ebook' && mediaType === 'book')) validItemFiles.push(file)
else validOtherFiles.push(file)
}
})
@@ -82,12 +82,18 @@ export default {
items: itemResults,
ignoredFiles: ignoredFilesInRoot
}
} else {
} else if (filetree.some((f) => f.filetype !== 'audio') || mediaType !== 'book') {
// Single Book drop
return {
items: this.itemFromTreeItems(filetree, mediaType),
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) {
@@ -95,11 +101,12 @@ export default {
path: '/',
items: []
}
function traverseFileTreePromise(item, currtreemap) {
function traverseFileTreePromise(item, currtreemap, checkFileType) {
return new Promise((resolve) => {
if (item.isFile) {
item.file((file) => {
file.filepath = currtreemap.path + file.name //save full path
file.filetype = checkFileType(file.name)
currtreemap.items.push(file)
resolve(file)
})
@@ -119,7 +126,7 @@ export default {
dirReader.readEntries((entries) => {
if (entries.length > 0) {
for (let entr of entries) {
entriesPromises.push(traverseFileTreePromise(entr, newtreemap))
entriesPromises.push(traverseFileTreePromise(entr, newtreemap, checkFileType))
}
readEntries()
} else {
@@ -135,7 +142,7 @@ export default {
return new Promise((resolve, reject) => {
let entriesPromises = []
for (let it of dataTransferItems) {
var filetree = traverseFileTreePromise(it.webkitGetAsEntry(), treemap)
var filetree = traverseFileTreePromise(it.webkitGetAsEntry(), treemap, this.checkFileType)
entriesPromises.push(filetree)
}
Promise.all(entriesPromises).then(() => {
@@ -152,7 +159,9 @@ export default {
...book
}
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)
@@ -165,6 +174,9 @@ export default {
if (dirs.length) {
audiobook.author = dirs.pop()
}
} else {
// Use file basename as title
audiobook.title = Path.basename(firstBookFile.name, Path.extname(firstBookFile.name))
}
return audiobook
},
@@ -178,7 +190,12 @@ export default {
if (!firstAudioFile.filepath) return podcast // No path
var firstPath = Path.dirname(firstAudioFile.filepath)
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
},
cleanItem(item, mediaType, index) {
@@ -188,6 +205,7 @@ export default {
async getItemsFromDataTransferItems(dataTransferItems, mediaType) {
var files = await this.getFilesDropped(dataTransferItems)
if (!files || !files.length) return { error: 'No files found ' }
var itemData = this.fileTreeToItems(files, mediaType)
if (!itemData.items.length && !itemData.ignoredFiles.length) {
return { error: 'Invalid file drop' }
@@ -218,9 +236,12 @@ export default {
else {
file.filetype = filetype
if (file.webkitRelativePath) file.filepath = file.webkitRelativePath
else file.filepath = file.name
if (filetype === 'audio' || (filetype === 'ebook' && mediaType === 'book')) {
var dir = file.filepath ? Path.dirname(file.filepath) : ''
if (dir === '.') dir = ''
if (!itemMap[dir]) {
itemMap[dir] = {
path: dir,
@@ -246,8 +267,17 @@ export default {
}
})
var items = []
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 {
items,
ignoredFiles: ignoredFiles
+22 -3
View File
@@ -58,7 +58,8 @@ module.exports = {
buildModules: [
// https://go.nuxtjs.dev/tailwindcss
'@nuxtjs/tailwindcss',
'@nuxtjs/pwa'
'@nuxtjs/pwa',
'@nuxt/postcss8'
],
// Modules: https://go.nuxtjs.dev/config-modules
@@ -119,11 +120,21 @@ module.exports = {
sizes: "512x512"
}
]
},
workbox: {
enabled: false,
}
},
// Build Configuration: https://go.nuxtjs.dev/config-build
build: {},
build: {
postcss: {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
},
watchers: {
webpack: {
aggregateTimeout: 300,
@@ -133,5 +144,13 @@ module.exports = {
server: {
port: process.env.NODE_ENV === 'production' ? 80 : 3000,
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",
"version": "2.0.21",
"version": "2.0.23",
"description": "Self-hosted audiobook and podcast client",
"main": "index.js",
"scripts": {
@@ -29,8 +29,10 @@
"vuedraggable": "^2.24.3"
},
"devDependencies": {
"@nuxt/postcss8": "^1.1.3",
"@nuxtjs/pwa": "^3.3.5",
"@nuxtjs/tailwindcss": "^4.2.1",
"autoprefixer": "^10.4.7",
"postcss": "^8.3.6"
}
}
+179 -154
View File
@@ -1,150 +1,190 @@
<template>
<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-8">
<div class="flex items-center mb-2">
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-2">
<div class="mb-2">
<h1 class="text-xl">Settings</h1>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.storeCoverWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeCoverWithItem', val)" />
<ui-tooltip :text="tooltips.storeCoverWithItem">
<p class="pl-4 text-lg">
Store covers with item
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="lg:flex">
<div class="flex-1">
<div class="pt-4">
<h2 class="font-semibold">General</h2>
</div>
<div class="flex items-end py-2">
<ui-toggle-switch v-model="newServerSettings.storeCoverWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeCoverWithItem', val)" />
<ui-tooltip :text="tooltips.storeCoverWithItem">
<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">
<ui-toggle-switch v-model="newServerSettings.storeMetadataWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeMetadataWithItem', val)" />
<ui-tooltip :text="tooltips.storeMetadataWithItem">
<p class="pl-4 text-lg">
Store metadata with item
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.storeMetadataWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeMetadataWithItem', val)" />
<ui-tooltip :text="tooltips.storeMetadataWithItem">
<p class="pl-4">
Store metadata with item
<span class="material-icons icon-text text-sm">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="useSquareBookCovers" :disabled="updatingServerSettings" @input="updateBookCoverAspectRatio" />
<ui-tooltip :text="tooltips.coverAspectRatio">
<p class="pl-4 text-lg">
Use square book covers
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.sortingIgnorePrefix" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('sortingIgnorePrefix', val)" />
<ui-tooltip :text="tooltips.sortingIgnorePrefix">
<p class="pl-4">
Ignore prefixes when sorting
<span class="material-icons icon-text text-sm">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">
<ui-toggle-switch v-model="useAlternativeBookshelfView" :disabled="updatingServerSettings" @input="updateAlternativeBookshelfView" />
<ui-tooltip :text="tooltips.bookshelfView">
<p class="pl-4 text-lg">
Use alternative bookshelf view
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.chromecastEnabled" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('chromecastEnabled', val)" />
<p class="pl-4">Chromecast support</p>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.sortingIgnorePrefix" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('sortingIgnorePrefix', val)" />
<ui-tooltip :text="tooltips.sortingIgnorePrefix">
<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="pt-4">
<h2 class="font-semibold">Display</h2>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.chromecastEnabled" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('chromecastEnabled', val)" />
<p class="pl-4 text-lg">Enable Chromecast</p>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="useSquareBookCovers" :disabled="updatingServerSettings" @input="updateBookCoverAspectRatio" />
<ui-tooltip :text="tooltips.coverAspectRatio">
<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">
<h1 class="text-xl">Scanner Settings</h1>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="useAlternativeBookshelfView" :disabled="updatingServerSettings" @input="updateAlternativeBookshelfView" />
<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">
<ui-toggle-switch v-model="newServerSettings.scannerParseSubtitle" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerParseSubtitle', val)" />
<ui-tooltip :text="tooltips.scannerParseSubtitle">
<p class="pl-4 text-lg">
Scanner parse subtitles
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<p class="pr-4">Date Format</p>
<ui-dropdown v-model="newServerSettings.dateFormat" :items="dateFormats" small class="max-w-40" @input="(val) => updateSettingsKey('dateFormat', val)" />
</div>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerFindCovers" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerFindCovers', val)" />
<ui-tooltip :text="tooltips.scannerFindCovers">
<p class="pl-4 text-lg">
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-1">
<div class="pt-4">
<h2 class="font-semibold">Scanner</h2>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerPreferAudioMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferAudioMetadata', val)" />
<ui-tooltip :text="tooltips.scannerPreferAudioMetadata">
<p class="pl-4 text-lg">
Scanner prefer audio metadata
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerParseSubtitle" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerParseSubtitle', val)" />
<ui-tooltip :text="tooltips.scannerParseSubtitle">
<p class="pl-4">
Parse subtitles
<span class="material-icons icon-text text-sm">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerPreferOpfMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferOpfMetadata', val)" />
<ui-tooltip :text="tooltips.scannerPreferOpfMetadata">
<p class="pl-4 text-lg">
Scanner prefer OPF metadata
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerFindCovers" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerFindCovers', val)" />
<ui-tooltip :text="tooltips.scannerFindCovers">
<p class="pl-4">
Find covers
<span class="material-icons icon-text text-sm">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">
<ui-toggle-switch v-model="newServerSettings.scannerPreferMatchedMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferMatchedMetadata', val)" />
<ui-tooltip :text="tooltips.scannerPreferMatchedMetadata">
<p class="pl-4 text-lg">
Scanner prefer matched metadata
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerPreferOverdriveMediaMarker" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferOverdriveMediaMarker', val)" />
<ui-tooltip :text="tooltips.scannerPreferOverdriveMediaMarker">
<p class="pl-4">
Use Overdrive Media Markers for chapters
<span class="material-icons icon-text text-sm">info_outlined</span>
</p>
</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 text-lg">
Disable Watcher
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerPreferAudioMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferAudioMetadata', val)" />
<ui-tooltip :text="tooltips.scannerPreferAudioMetadata">
<p class="pl-4">
Prefer audio metadata
<span class="material-icons icon-text text-sm">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="flex items-center mb-2 mt-8">
<h1 class="text-xl">Experimental Feature Settings</h1>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerPreferOpfMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferOpfMetadata', val)" />
<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">
<ui-toggle-switch v-model="newServerSettings.enableEReader" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('enableEReader', val)" />
<ui-tooltip :text="tooltips.enableEReader">
<p class="pl-4 text-lg">
Enable e-reader for all users
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerPreferMatchedMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferMatchedMetadata', val)" />
<ui-tooltip :text="tooltips.scannerPreferMatchedMetadata">
<p class="pl-4">
Prefer matched metadata
<span class="material-icons icon-text text-sm">info_outlined</span>
</p>
</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>
@@ -152,7 +192,7 @@
<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" :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" />
<p class="pr-2 text-sm font-book text-yellow-400">
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="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">
<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-lg my-2 text-center">Purge cache will delete the entire directory at <span class="font-mono">/metadata/cache</span>.</p>
<p class="text-error font-semibold">Important Notice!</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">
<ui-btn color="primary" @click="showConfirmPurgeCache = false">Nevermind</ui-btn>
<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',
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',
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
}
@@ -271,12 +294,12 @@ export default {
set(val) {
this.$store.commit('setExperimentalFeatures', val)
}
},
dateFormats() {
return this.$store.state.globals.dateFormats
}
},
methods: {
updateEnableChromecast(val) {
this.updateServerSettings({ enableChromecast: val })
},
updateSortingPrefixes(val) {
if (!val || !val.length) {
this.$toast.error('Must have at least 1 prefix')
@@ -314,10 +337,12 @@ export default {
.then((success) => {
console.log('Updated Server Settings', success)
this.updatingServerSettings = false
this.$toast.success('Server settings updated')
})
.catch((error) => {
console.error('Failed to update server settings', error)
this.updatingServerSettings = false
this.$toast.error('Failed to update server settings')
})
},
initServerSettings() {
+3 -4
View File
@@ -1,10 +1,9 @@
<template>
<div>
<p class="text-xl">Stats for library {{ currentLibraryName }}</p>
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
<h1 class="text-xl">Stats for library {{ currentLibraryName }}</h1>
<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">
<h1 class="text-2xl mb-4 font-book">Top 5 Genres</h1>
<p v-if="!top5Genres.length">No Genres</p>
+11 -14
View File
@@ -1,24 +1,21 @@
<template>
<div id="page-wrapper" class="page overflow-y-auto" :class="streamLibraryItem ? 'streaming' : ''">
<div class="w-full max-w-4xl mx-auto">
<div class="mb-4 flex flex-col sm:flex-row items-start sm:items-end">
<p class="text-2xl mr-4 mb-2 sm:mb-0">Logger</p>
<div class="w-full h-full">
<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">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" />
<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>
<ui-dropdown v-model="newServerSettings.logLevel" label="Server Log Level" :items="logLevelItems" @input="logLevelUpdated" class="w-full sm:w-44" />
</div>
<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">
<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="font-semibold w-12 text-right" :class="`text-${logColors[log.level]}`">{{ log.levelName }}</p>
<p class="text-gray-400 w-36 font-mono text-xs">{{ log.timestamp }}</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>
</div>
</template>
+56 -56
View File
@@ -1,63 +1,63 @@
<template>
<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="py-2">
<div class="flex items-center mb-1">
<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 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">Listening Sessions</h1>
</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>
<modals-listening-session-modal v-model="showSessionModal" :session="selectedSession" />
</div>
</template>
+7 -2
View File
@@ -1,5 +1,7 @@
<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 p-2">
<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">
<div :key="item.id" class="w-full py-0.5">
<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">
<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>
@@ -84,6 +86,9 @@ export default {
user() {
return this.$store.state.user.user
},
username() {
return this.user.username
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
+5 -2
View File
@@ -118,9 +118,9 @@
<!-- 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' : ''">
<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 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">
<span class="material-icons text-sm">close</span>
@@ -226,6 +226,9 @@ export default {
}
},
computed: {
dateFormat() {
return this.$store.state.serverSettings.dateFormat
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
@@ -2,25 +2,26 @@
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
<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">
<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-btn type="submit" :disabled="processing">Submit</ui-btn>
<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" class="hidden md:block">Submit</ui-btn>
<ui-btn type="submit" :disabled="processing" class="block md:hidden" small>Submit</ui-btn>
</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 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>
<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 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" />
</div>
<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>
<p class="text-base text-gray-300 whitespace-nowrap truncate">by {{ podcast.artistName }}</p>
<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-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.trackCount }} Episodes</p>
</div>
+3 -1
View File
@@ -29,7 +29,9 @@
</div>
</div>
<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>
<!-- Item list header -->
+2 -1
View File
@@ -177,12 +177,13 @@ export default class PlayerHandler {
}
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.isVideo = session.libraryItem.mediaType === 'video'
this.playWhenReady = false
this.initialPlaybackRate = playbackRate
if (!this.player) this.switchPlayer()
this.prepareSession(session)
}
+1 -1
View File
@@ -23,7 +23,7 @@ export default (ctx) => {
var castContext = cast.framework.CastContext.getInstance()
castContext.setOptions({
receiverApplicationId: process.env.chromecastReceiver,
autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED
autoJoinPolicy: chrome.cast ? chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED : null
});
castContext.addEventListener(
+1 -2
View File
@@ -169,7 +169,7 @@ Vue.prototype.$sanitizeSlug = (str) => {
str = str.replace(new RegExp(from.charAt(i), 'g'), to.charAt(i))
}
str = str.replace('.', '-') // replace a dot by a dash
str = str.replace('.', '-') // replace a dot by a dash
.replace(/[^a-z0-9 -_]/g, '') // remove invalid chars
.replace(/\s+/g, '-') // collapse whitespace and replace by a dash
.replace(/-+/g, '-') // collapse dashes
@@ -204,7 +204,6 @@ Vue.prototype.$copyToClipboard = (str, ctx) => {
})
}
function xmlToJson(xml) {
const json = {};
for (const res of xml.matchAll(/(?:<(\w*)(?:\s[^>]*)*>)((?:(?!<\1).)*)(?:<\/\1>)|<(\w*)(?:\s*)*\/>/gm)) {
+15 -2
View File
@@ -1,4 +1,3 @@
export const state = () => ({
isMobile: false,
isMobileLandscape: false,
@@ -12,7 +11,21 @@ export const state = () => ({
selectedCollection: null,
selectedAuthor: null,
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 = {
+19 -12
View File
@@ -2,18 +2,23 @@ const defaultTheme = require('tailwindcss/defaultTheme')
module.exports = {
purge: {
options: {
safelist: [
'bg-success',
'bg-red-600',
'text-green-500',
'py-1.5',
'bg-info',
'px-1.5'
]
}
content: [
'components/**/*.vue',
'layouts/**/*.vue',
'pages/**/*.vue',
'templates/**/*.vue',
'plugins/**/*.js',
'nuxt.config.js'
],
safelist: [
'bg-success',
'bg-red-600',
'text-green-500',
'py-1.5',
'bg-info',
'px-1.5'
],
},
darkMode: false,
theme: {
extend: {
height: {
@@ -31,6 +36,7 @@ module.exports = {
'20': '5rem',
'24': '6rem',
'32': '8rem',
'40': '10rem',
'48': '12rem',
'64': '16rem',
'80': '20rem'
@@ -79,7 +85,8 @@ module.exports = {
book: ['Gentium Book Basic', 'serif']
},
fontSize: {
xxs: '0.625rem'
xxs: '0.625rem',
'2.5xl': '1.6875rem'
},
zIndex: {
'50': 50
+3 -3
View File
@@ -1,18 +1,18 @@
{
"name": "audiobookshelf",
"version": "2.0.21",
"version": "2.0.23",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"version": "2.0.20",
"version": "2.0.21",
"license": "GPL-3.0",
"dependencies": {
"archiver": "^5.3.0",
"axios": "^0.26.1",
"bcryptjs": "^2.4.3",
"command-line-args": "^5.2.0",
"date-and-time": "^2.0.1",
"date-and-time": "^2.3.1",
"express": "^4.17.1",
"express-fileupload": "^1.2.1",
"express-rate-limit": "^5.3.0",
+5 -2
View File
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
"version": "2.0.21",
"version": "2.0.23",
"description": "Self-hosted audiobook and podcast server",
"main": "index.js",
"scripts": {
@@ -11,6 +11,9 @@
"build-win": "npm run client && pkg -t node16-win-x64 -o ./dist/win/audiobookshelf -C GZip .",
"build-linux": "build/linuxpackager",
"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"
},
"bin": "prod.js",
@@ -31,7 +34,7 @@
"axios": "^0.26.1",
"bcryptjs": "^2.4.3",
"command-line-args": "^5.2.0",
"date-and-time": "^2.0.1",
"date-and-time": "^2.3.1",
"express": "^4.17.1",
"express-fileupload": "^1.2.1",
"express-rate-limit": "^5.3.0",
+3 -1
View File
@@ -20,7 +20,9 @@ class Auth {
cors(req, res, next) {
res.header('Access-Control-Allow-Origin', '*')
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)
if (req.method === 'OPTIONS') {
res.sendStatus(200)
+2 -2
View File
@@ -1,16 +1,16 @@
const date = require('date-and-time')
const { LogLevel } = require('./utils/constants')
class Logger {
constructor() {
this.logLevel = process.env.NODE_ENV === 'production' ? LogLevel.INFO : LogLevel.TRACE
// this.logFileLevel = LogLevel.INFO
this.socketListeners = []
this.logManager = null
}
get timestamp() {
return (new Date()).toISOString()
return date.format(new Date(), 'YYYY-MM-DD HH:mm:ss')
}
get levelString() {
+2 -1
View File
@@ -140,7 +140,7 @@ class Server {
await this.purgeMetadata() // Remove metadata folders without library item
await this.cacheManager.ensureCachePaths()
await this.abMergeManager.ensureDownloadDirPath()
await this.backupManager.init()
await this.logManager.init()
await this.rssFeedManager.init()
@@ -451,6 +451,7 @@ class Server {
} else {
Logger.debug(`[Server] User Online ${client.user.username}`)
}
this.io.emit('user_online', client.user.toJSONForPublic(this.playbackSessionManager.sessions, this.db.libraryItems))
user.lastSeen = Date.now()
+12
View File
@@ -63,15 +63,27 @@ class AuthorController {
// If updating or removing cover image then clear cache
if (payload.imagePath !== undefined && req.author.imagePath && payload.imagePath !== req.author.imagePath) {
this.cacheManager.purgeImageCache(req.author.id)
if (!payload.imagePath) { // If removing image then remove file
var currentImagePath = req.author.imagePath
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 hasUpdated = req.author.update(payload)
if (hasUpdated) {
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))
+1
View File
@@ -57,6 +57,7 @@ class MeController {
if (!libraryItem) {
return res.status(404).send('Item not found')
}
var wasUpdated = req.user.createUpdateMediaProgress(libraryItem, req.body)
if (wasUpdated) {
await this.db.updateEntity('user', req.user)
+1 -1
View File
@@ -103,7 +103,7 @@ class PodcastController {
Logger.error('Invalid podcast feed request response')
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)
if (!payload) {
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 { downloadFile } = require('../utils/fileUtils')
const filePerms = require('../utils/filePerms')
class AuthorFinder {
constructor() {
@@ -38,7 +39,11 @@ class AuthorFinder {
async saveAuthorImage(authorId, url) {
var authorDir = this.AuthorPath
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 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}`)
if (provider === 'google') {
return this.getGoogleBooksResults(title, author)
books = await this.getGoogleBooksResults(title, author)
} else if (provider === 'audible') {
return this.getAudibleResults(title, author, asin)
books = await this.getAudibleResults(title, author, asin)
} else if (provider === 'itunes') {
return this.getiTunesAudiobooksResults(title, author)
books = await this.getiTunesAudiobooksResults(title, author)
} else if (provider === 'libgen') {
books = await this.getLibGenResults(title, author, maxTitleDistance, maxAuthorDistance)
} 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 a.totalDistance - b.totalDistance
})
+1 -1
View File
@@ -1,5 +1,5 @@
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 {
constructor(download) {
this.id = null
+2 -2
View File
@@ -7,7 +7,7 @@ const LibraryFile = require('./files/LibraryFile')
const Book = require('./mediaTypes/Book')
const Podcast = require('./mediaTypes/Podcast')
const Video = require('./mediaTypes/Video')
const { areEquivalent, copyValue, getId } = require('../utils/index')
const { areEquivalent, copyValue, getId, cleanStringForSearch } = require('../utils/index')
class LibraryItem {
constructor(libraryItem = null) {
@@ -451,7 +451,7 @@ class LibraryItem {
}
searchQuery(query) {
query = query.toLowerCase()
query = cleanStringForSearch(query)
return this.media.searchQuery(query)
}
+14 -5
View File
@@ -1,8 +1,9 @@
const Path = require('path')
const Logger = require('../../Logger')
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 { overdriveMediaMarkersExist, parseOverdriveMediaMarkersAsChapters } = require('../../utils/parsers/parseOverdriveMediaMarkers')
const abmetadataGenerator = require('../../utils/abmetadataGenerator')
const { readTextFile } = require('../../utils/fileUtils')
const AudioFile = require('../files/AudioFile')
@@ -303,7 +304,7 @@ class Book {
searchQuery(query) {
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),
authors: this.metadata.searchAuthors(query),
matchKey: null,
@@ -360,10 +361,11 @@ class Book {
this.rebuildTracks()
}
rebuildTracks() {
rebuildTracks(preferOverdriveMediaMarker) {
Logger.debug(`[Book] Tracks being rebuilt...!`)
this.audioFiles.sort((a, b) => a.index - b.index)
this.missingParts = []
this.setChapters()
this.setChapters(preferOverdriveMediaMarker)
this.checkUpdateMissingTracks()
}
@@ -395,9 +397,16 @@ class Book {
return wasUpdated
}
setChapters() {
setChapters(preferOverdriveMediaMarker = false) {
// If 1 audio file without chapters, then no chapters will be set
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) {
// 1 audio file with chapters
if (includedAudioFiles[0].chapters) {
+5 -1
View File
@@ -20,6 +20,7 @@ class AudioMetaTags {
this.tagIsbn = null
this.tagLanguage = null
this.tagASIN = null
this.tagOverdriveMediaMarker = null
if (metadata) {
this.construct(metadata)
@@ -58,6 +59,7 @@ class AudioMetaTags {
this.tagIsbn = metadata.tagIsbn || null
this.tagLanguage = metadata.tagLanguage || null
this.tagASIN = metadata.tagASIN || null
this.tagOverdriveMediaMarker = metadata.tagOverdriveMediaMarker || null
}
// Data parsed in prober.js
@@ -82,6 +84,7 @@ class AudioMetaTags {
this.tagIsbn = payload.file_tag_isbn || null
this.tagLanguage = payload.file_tag_language || null
this.tagASIN = payload.file_tag_asin || null
this.tagOverdriveMediaMarker = payload.file_tag_overdrive_media_marker || null
}
updateData(payload) {
@@ -105,7 +108,8 @@ class AudioMetaTags {
tagEncodedBy: payload.file_tag_encodedby || null,
tagIsbn: payload.file_tag_isbn || 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
+8 -4
View File
@@ -1,5 +1,5 @@
const Logger = require('../../Logger')
const { areEquivalent, copyValue } = require('../../utils/index')
const { areEquivalent, copyValue, cleanStringForSearch } = require('../../utils/index')
const parseNameString = require('../../utils/parsers/parseNameString')
class BookMetadata {
constructor(metadata) {
@@ -262,6 +262,10 @@ class BookMetadata {
{
tag: 'tagASIN',
key: 'asin'
},
{
tag: 'tagOverdriveMediaMarker',
key: 'overdriveMediaMarker'
}
]
@@ -338,15 +342,15 @@ class BookMetadata {
}
searchSeries(query) {
return this.series.filter(se => se.name.toLowerCase().includes(query))
return this.series.filter(se => cleanStringForSearch(se.name).includes(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
var keysToCheck = ['title', 'asin', 'isbn']
for (var key of keysToCheck) {
if (this[key] && this[key].toLowerCase().includes(query)) {
if (this[key] && cleanStringForSearch(String(this[key])).includes(query)) {
return {
matchKey: key,
matchText: this[key]
+2 -2
View File
@@ -1,5 +1,5 @@
const Logger = require('../../Logger')
const { areEquivalent, copyValue } = require('../../utils/index')
const { areEquivalent, copyValue, cleanStringForSearch } = require('../../utils/index')
class PodcastMetadata {
constructor(metadata) {
@@ -94,7 +94,7 @@ class PodcastMetadata {
searchQuery(query) { // Returns key if match is found
var keysToCheck = ['title', 'author', 'itunesId', 'itunesArtistId']
for (var key of keysToCheck) {
if (this[key] && String(this[key]).toLowerCase().includes(query)) {
if (this[key] && cleanStringForSearch(String(this[key])).includes(query)) {
return {
matchKey: key,
matchText: this[key]
+7 -1
View File
@@ -12,7 +12,8 @@ class ServerSettings {
this.scannerPreferAudioMetadata = false
this.scannerPreferOpfMetadata = false
this.scannerPreferMatchedMetadata = false
this.scannerDisableWatcher = false
this.scannerDisableWatcher = false
this.scannerPreferOverdriveMediaMarker = false
// Metadata - choose to store inside users library item folder
this.storeCoverWithItem = false
@@ -47,6 +48,7 @@ class ServerSettings {
// Misc Flags
this.chromecastEnabled = false
this.enableEReader = false
this.dateFormat = 'MM/dd/yyyy'
this.logLevel = Logger.logLevel
@@ -65,6 +67,7 @@ class ServerSettings {
this.scannerPreferOpfMetadata = !!settings.scannerPreferOpfMetadata
this.scannerPreferMatchedMetadata = !!settings.scannerPreferMatchedMetadata
this.scannerDisableWatcher = !!settings.scannerDisableWatcher
this.scannerPreferOverdriveMediaMarker = !!settings.scannerPreferOverdriveMediaMarker
this.storeCoverWithItem = !!settings.storeCoverWithItem
if (settings.storeCoverWithBook != undefined) { // storeCoverWithBook was old name of setting < v2
@@ -93,6 +96,7 @@ class ServerSettings {
this.sortingPrefixes = settings.sortingPrefixes || ['the', 'a']
this.chromecastEnabled = !!settings.chromecastEnabled
this.enableEReader = !!settings.enableEReader
this.dateFormat = settings.dateFormat || 'MM/dd/yyyy'
this.logLevel = settings.logLevel || Logger.logLevel
this.version = settings.version || null
@@ -111,6 +115,7 @@ class ServerSettings {
scannerPreferOpfMetadata: this.scannerPreferOpfMetadata,
scannerPreferMatchedMetadata: this.scannerPreferMatchedMetadata,
scannerDisableWatcher: this.scannerDisableWatcher,
scannerPreferOverdriveMediaMarker: this.scannerPreferOverdriveMediaMarker,
storeCoverWithItem: this.storeCoverWithItem,
storeMetadataWithItem: this.storeMetadataWithItem,
rateLimitLoginRequests: this.rateLimitLoginRequests,
@@ -127,6 +132,7 @@ class ServerSettings {
sortingPrefixes: [...this.sortingPrefixes],
chromecastEnabled: this.chromecastEnabled,
enableEReader: this.enableEReader,
dateFormat: this.dateFormat,
logLevel: this.logLevel,
version: this.version
}
+1 -1
View File
@@ -91,7 +91,7 @@ class MediaProgress {
var timeRemaining = this.duration - this.currentTime
// 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.finishedAt = Date.now()
this.progress = 1
+1
View File
@@ -34,6 +34,7 @@ class LibraryScan {
get forceRescan() { return !!this._scanOptions.forceRescan }
get preferAudioMetadata() { return !!this._scanOptions.preferAudioMetadata }
get preferOpfMetadata() { return !!this._scanOptions.preferOpfMetadata }
get preferOverdriveMediaMarker() { return !!this._scanOptions.preferOverdriveMediaMarker }
get findCovers() { return !!this._scanOptions.findCovers }
get timestamp() {
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 mediaScanResult = await this.executeMediaFileScans(libraryItem.mediaType, mediaLibraryFiles, scanData)
@@ -208,6 +208,7 @@ class MediaFileScanner {
} else if (mediaScanResult.audioFiles.length) {
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`)
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
@@ -247,7 +248,7 @@ class MediaFileScanner {
}
if (hasUpdated) {
libraryItem.media.rebuildTracks()
libraryItem.media.rebuildTracks(preferOverdriveMediaMarker)
}
} else { // Podcast Media Type
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.preferOpfMetadata = false
this.preferMatchedMetadata = false
this.preferOverdriveMediaMarker = false
if (options) {
this.construct(options)
@@ -34,7 +35,8 @@ class ScanOptions {
storeCoverWithItem: this.storeCoverWithItem,
preferAudioMetadata: this.preferAudioMetadata,
preferOpfMetadata: this.preferOpfMetadata,
preferMatchedMetadata: this.preferMatchedMetadata
preferMatchedMetadata: this.preferMatchedMetadata,
preferOverdriveMediaMarker: this.preferOverdriveMediaMarker
}
}
@@ -47,6 +49,7 @@ class ScanOptions {
this.preferAudioMetadata = serverSettings.scannerPreferAudioMetadata
this.preferOpfMetadata = serverSettings.scannerPreferOpfMetadata
this.scannerPreferMatchedMetadata = serverSettings.scannerPreferMatchedMetadata
this.preferOverdriveMediaMarker = serverSettings.scannerPreferOverdriveMediaMarker
}
}
module.exports = ScanOptions
+7 -7
View File
@@ -80,7 +80,7 @@ class Scanner {
// Scan all audio files
if (libraryItem.hasAudioFiles) {
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
}
@@ -310,7 +310,7 @@ class Scanner {
async scanNewLibraryItemDataChunk(newLibraryItemsData, libraryScan) {
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
@@ -337,7 +337,7 @@ class Scanner {
// forceRescan all existing audio files - will probe and update ID3 tag metadata
var existingAudioFiles = existingLibraryFiles.filter(lf => lf.fileType === 'audio')
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
}
}
@@ -345,7 +345,7 @@ class Scanner {
var newAudioFiles = newLibraryFiles.filter(lf => lf.fileType === 'audio')
var removedAudioFiles = filesRemoved.filter(lf => lf.fileType === 'audio')
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
}
}
@@ -379,7 +379,7 @@ class Scanner {
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}"`)
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')
if (mediaFiles.length) {
await MediaFileScanner.scanMediaFiles(mediaFiles, libraryItemData, libraryItem, preferAudioMetadata, libraryScan)
await MediaFileScanner.scanMediaFiles(mediaFiles, libraryItemData, libraryItem, preferAudioMetadata, preferOverdriveMediaMarker, libraryScan)
}
await libraryItem.syncFiles(preferOpfMetadata)
@@ -608,7 +608,7 @@ class Scanner {
var libraryItemData = await getLibraryItemFileData(libraryMediaType, folder, fullPath, isSingleMediaItem, this.db.serverSettings)
if (!libraryItemData) return null
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) {
+2 -2
View File
@@ -273,8 +273,8 @@ function parseAbMetadataText(text, mediaType) {
} else if (!metadataMapper[keyValue[0].trim()]) {
Logger.warn(`abmetadata key "${keyValue[0].trim()}" is not a valid ${mediaType} metadata key`)
} else {
var key = keyValue[0].trim()
var value = keyValue[1].trim()
var key = keyValue.shift().trim()
var value = keyValue.join('=').trim()
mediaMetadataDetails[key] = metadataMapper[key].from(value)
}
}
+1 -1
View File
@@ -210,4 +210,4 @@ module.exports.sanitizeFilename = (filename, colonReplacement = ' - ') => {
}
return sanitized
}
}
+6
View File
@@ -129,4 +129,10 @@ module.exports.encodeUriPath = (path) => {
module.exports.toNumber = (val, fallback = 0) => {
if (isNaN(val) || val === null) return fallback
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_genre1: tryGrabTags(format, 'tmp_genre1', 'genre1'),
file_tag_genre2: tryGrabTags(format, 'tmp_genre2', 'genre2'),
file_tag_overdrive_media_marker: tryGrabTags(format, 'OverDrive MediaMarkers'),
}
for (const key in tags) {
if (!tags[key]) {