Compare commits

...

209 Commits

Author SHA1 Message Date
advplyr 372101592c Version bump 2.1.0 2022-07-18 18:41:51 -05:00
advplyr 18123664ee Fix:RSS Feed cover, Update:Remove experimental scanner 2022-07-18 18:39:51 -05:00
advplyr 2e6e4f970c Remove comments from scanner 2022-07-18 18:17:50 -05:00
advplyr 1c9e56ce2e Fix:Libraries table to use draggable handle for items so they can be clicked and edited #839 2022-07-18 17:44:01 -05:00
advplyr 9e7b84f289 Update:JWT signing 2022-07-18 17:19:16 -05:00
advplyr 86ee4dcff2 Update:Scanner adjustable number of parallel audio probes to use less CPU 2022-07-16 18:54:34 -05:00
advplyr 277a5fa37c Version bump 2.0.24 2022-07-14 19:51:33 -05:00
advplyr 51b87912f8 Update:Collapsed series shows series name instead of first book title and only sorts when sorting by title #629 2022-07-14 19:00:52 -05:00
advplyr 653019921e Add:Support for OGA file extension #804, Update:Mime type for m4b and m4a to audio/mp4 2022-07-14 18:32:00 -05:00
advplyr ccc291067d Fix:Truetype fonts format 2022-07-14 18:06:37 -05:00
advplyr af7e3a03f0 Fix:Audio player when using chapter track then playing an item without chapters 2022-07-13 19:38:34 -05:00
advplyr 7c40d26857 Fix:Sync local mobile app progress replacing local media progress id causing duplicate media progress in mobile 2022-07-13 19:18:49 -05:00
advplyr 6c507de501 Remove client marked dependency 2022-07-12 16:21:32 -05:00
advplyr 482a4340f5 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-07-12 16:19:54 -05:00
advplyr 21e704e12c Fix:Show toasts below appbar & toolbar #819 2022-07-12 16:11:23 -05:00
advplyr 2b91bff1af Fix:Ordering newly scanned in audio tracks properly #823 2022-07-12 15:02:08 -05:00
advplyr d11f9608b4 Remove old audio file scanner 2022-07-12 14:35:43 -05:00
advplyr 2b0b691b69 Merge pull request #821 from jmt-gh/whats_new_modal
Add ability to view current version's changelog from within ABS
2022-07-09 17:32:33 -05:00
advplyr 5dfd5c4971 Remove marked dependency 2022-07-09 17:29:30 -05:00
advplyr 201f1bff3e Add marked package in static libs 2022-07-09 17:27:30 -05:00
jmt-gh a22ebb257f add style comments 2022-07-08 20:45:09 -07:00
jmt-gh bf6e87d4bc update formatting 2022-07-08 20:34:32 -07:00
jmt-gh b823a93ae2 integrate modal to sidenavs 2022-07-08 20:29:18 -07:00
jmt-gh 05afd12682 add new changelog modal 2022-07-08 20:28:34 -07:00
jmt-gh 997e23150e extract current version changelog information 2022-07-08 20:26:30 -07:00
advplyr 3c5bf376b5 Remove archiver-utils dependency 2022-07-08 18:11:09 -05:00
advplyr bca2cfda13 Update:Remove log listener for root user & only set when on logger config page 2022-07-07 17:25:52 -05:00
advplyr 916b41d587 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-07-06 20:12:22 -05:00
advplyr ab08d83c04 Remove archiver dependency 2022-07-06 20:12:14 -05:00
advplyr 415e0a7b5a Remove dependency date-and-time 2022-07-06 19:18:27 -05:00
advplyr d301c12acd Remove dependency express-rate-limit 2022-07-06 19:14:47 -05:00
advplyr 7aa7e662b2 Remove dependency express-fileupload 2022-07-06 19:10:25 -05:00
advplyr 1dbfb5637a Remove bcryptjs dependency 2022-07-06 19:01:27 -05:00
advplyr 4e1aacb44f Remove command-line-args dependency 2022-07-06 18:56:13 -05:00
advplyr 954cf3e14e Remove jsonwebtoken dependency 2022-07-06 18:45:43 -05:00
advplyr b61ecefce4 Remove fluent-ffmpeg dependency 2022-07-06 17:38:19 -05:00
advplyr 8562b8d1b3 Remove node-ffprobe dependency 2022-07-06 17:07:08 -05:00
advplyr 06ec2159f5 Update bug.yaml 2022-07-06 07:18:03 -05:00
advplyr 68b565505e Update bug.yaml 2022-07-06 07:13:39 -05:00
advplyr 83ff2752dd Update bug.yaml 2022-07-06 07:10:27 -05:00
advplyr d0af1c3c9a Remove fs-extra dependency 2022-07-05 19:53:01 -05:00
advplyr 1ad46d4fb8 Add missing dependency license files 2022-07-05 19:33:43 -05:00
advplyr d3dd13eae5 Remove node-stream-zip dependency 2022-07-05 19:24:16 -05:00
advplyr f27982d887 Update:Default backup schedule to 1:30 to avoid conflict with new episode checks #761 2022-07-05 17:38:17 -05:00
advplyr 624a44f572 Fix:Quick match split multiple comma separated authors #808 2022-07-05 17:26:14 -05:00
advplyr e623bf7fde Remove rra dependency 2022-07-04 19:19:38 -05:00
advplyr 6fc70b8656 Remove LibGen provider and package 2022-07-04 19:14:52 -05:00
advplyr 354cefb9f4 Update:Update isFile flag on check scan data for library items 2022-07-03 15:04:41 -05:00
advplyr a78aa88dbc Fix:Audiobooks incorrectly flagged as single file root audiobooks #714 2022-07-03 14:41:07 -05:00
advplyr 9ac2453676 Merge pull request #802 from jmt-gh/getMediaProgress_fix
fix getMediaProgress always returning 404
2022-07-03 12:20:24 -05:00
advplyr bb70800b4e Merge pull request #801 from mcdinner/missing-cache-directory
Remove cachePathExists property (Issue #800)
2022-07-03 12:19:51 -05:00
jmt-gh 855272a558 fix getMediaProgress not returning properly 2022-07-03 10:15:40 -07:00
mcdinner ebb2c5f791 Remove cachePathExists property (Issue #800)
Remove cachePathsExist property to ensure missing cache directories are recreated when EnsureCachePaths() called.
2022-07-03 16:35:12 +02:00
advplyr 2e466bb164 Update tailwindcss 2022-07-02 20:02:43 -05:00
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
advplyr a97c102369 Version bump 2.0.21 2022-06-09 18:24:03 -05:00
advplyr 745a491f90 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-06-09 17:56:22 -05:00
advplyr b2880ab0a9 Fix:Match tab #708 2022-06-09 17:56:16 -05:00
advplyr f916454c55 Merge pull request #707 from jmt-gh/fix_library_scanning
Fix library scanning failing
2022-06-09 04:54:08 -05:00
jmt-gh 701b8ea12e Fix bug with library scanning introduced in #697
Looks like #697 missed a reference update that caused scanning libraries
to fail. This fixes that
2022-06-08 19:15:35 -07:00
advplyr 2079942ccd Merge pull request #697 from jvanbruegge/patch-1
Use `show` and `episode_id` tags for audiobook series
2022-06-08 16:10:28 -05:00
advplyr 140b718592 Merge pull request #699 from jmt-gh/698_metadata_downloads_not_created
Update some instances of mkdir to ensureDir (#698)
2022-06-08 16:05:19 -05:00
Jan van Brügge 2de8c72131 Allow show and episode_id tags for audiobook series
FFmpeg only supports a very limited number of tags for m4b files (see https://wiki.multimedia.cx/index.php?title=FFmpeg_Metadata) by default. `series` and `series-part` are only possible by enabling custom tags with `-movflags use_metadata_tags`. To work around that, `show` and `episode_id` are added as second option.
2022-06-08 11:11:17 +02:00
advplyr 089d4b5cee Update:Remove fast-sort dependency 2022-06-07 20:22:23 -05:00
advplyr e06a015d6e Update:Remove proper-lockfile dependency 2022-06-07 20:15:00 -05:00
advplyr b7e546f2f5 Update:Remove node-cron dependency 2022-06-07 20:04:51 -05:00
advplyr 26ef275ab4 Update:Remove image-type dependency 2022-06-07 19:53:05 -05:00
advplyr 416db7c981 Update:Remove read-chunk dependency 2022-06-07 19:44:38 -05:00
advplyr 78079b2e60 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-06-07 19:25:28 -05:00
advplyr 03bffb725a Update:Remove rss feed dependencies add node-xml lib 2022-06-07 19:25:14 -05:00
advplyr 46fc89e247 Update:Cache feed xml 2022-06-07 18:37:37 -05:00
advplyr fbbceaa642 Add:Persist RSS feeds in db #696, Update:RSS feed data model 2022-06-07 18:29:43 -05:00
jmt-gh f5aae25cc8 remove random character 2022-06-06 18:52:08 -07:00
jmt-gh 8d03943acb remove extra debug log 2022-06-06 18:51:49 -07:00
jmt-gh 853513b926 update approach for ensuring download directory always exists 2022-06-06 18:51:08 -07:00
jmt-gh c606a41314 update Cache folder creation to leverage ensureDirs 2022-06-06 08:18:15 -07:00
jmt-gh 35f29ca22b Use ensureDir instead of mkdir to fix 698
This commit updates the mkdir for creating the download location to
ensureDir, which is an alias for mkdirs and mkdirp, meaning they will
create the entire path of the directory if it does not exist.

https://github.com/jprichardson/node-fs-extra/blob/master/docs/ensureDir.md
2022-06-06 08:12:58 -07:00
advplyr ac00f3ebe7 Merge pull request #692 from cassieesposito/getBookDataFromDir-refactor
Fixed bugs that caused getSequence to run twice and broke year recognition
2022-06-05 18:02:39 -05:00
Cassie Esposito 6846de98f8 Fixed bugs that caused getSequence to run twice and broke year recognition 2022-06-05 15:52:18 -07:00
advplyr 881baa818d Fix:Progress filter 2022-06-05 15:26:27 -05:00
advplyr b671145e73 Merge pull request #682 from jmt-gh/update_cover_on_merge
Support embedding cover art metadata in the embed metadata tool
2022-06-05 13:04:12 -05:00
jmt-gh 8809c7b900 Handle null and delete cover cases
This commit adds in supporting if a cover path is null. If this is the
case, we completely remove the video stream from the file, as the user
either:

a) uploaded a file with no video stream (so removing it is a no-op)
b) removed the cover in ABS, so we should respect that on merge
2022-06-05 10:36:42 -07:00
advplyr ae8f3aa918 Version bump 2.0.20 2022-06-05 10:59:01 -05:00
advplyr 5d4047c171 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-06-05 10:16:19 -05:00
advplyr 6f80591afd Fix:Switching to next track pausing player #685 2022-06-05 10:06:07 -05:00
jmt-gh 9b6fa8fe8c Merge branch 'advplyr:master' into update_cover_on_merge 2022-06-04 19:00:41 -07:00
jmt-gh d6c02ebb2c Support embedding cover art metadata
Added support for chapter metadata in #678, but completely missed that
coverart wasn't getting embedded in the embed metadata tool. This commit
adds that in
2022-06-04 18:56:55 -07:00
advplyr 788d867ec3 Merge pull request #681 from jmt-gh/m4b_no_coverart
Fix cover art not being generated for M4B export
2022-06-04 20:56:04 -05:00
jmt-gh 3bc3914fd9 Fix cover art not being generated for m4b export
This commit fixes an issue where cover art was not being generated
properly when creating an M4B audiobook.

More context can be found in discord:
https://discord.com/channels/942908292873723984/981321213882282035/982777444631195681
2022-06-04 17:50:26 -07:00
advplyr 3d821dacb7 Fix:Sessions table cleanup 2022-06-04 15:51:00 -05:00
410 changed files with 41527 additions and 3780 deletions
+7
View File
@@ -9,6 +9,12 @@ body:
- type: markdown - type: markdown
attributes: attributes:
value: "### Mobile app issues report [here](https://github.com/advplyr/audiobookshelf-app/issues/new/choose)." value: "### Mobile app issues report [here](https://github.com/advplyr/audiobookshelf-app/issues/new/choose)."
- type: markdown
attributes:
value: "### Join the [discord server](https://discord.gg/pJsjuNCKRq) for questions or if you are not sure about a bug."
- type: markdown
attributes:
value: "## Be as descriptive as you can. Include screenshots, error logs, browser, file types, everything you can think of that might be relevant."
- type: textarea - type: textarea
id: what-happened id: what-happened
attributes: attributes:
@@ -27,6 +33,7 @@ body:
id: version id: version
attributes: attributes:
label: Audiobookshelf version label: Audiobookshelf version
description: Do not put 'Latest version', please put the actual version here
placeholder: "e.g. v1.6.60" placeholder: "e.g. v1.6.60"
validations: validations:
required: true required: true
+1
View File
@@ -11,6 +11,7 @@ test/
/client/.nuxt/ /client/.nuxt/
/client/dist/ /client/dist/
/dist/ /dist/
library/
sw.* sw.*
.DS_STORE .DS_STORE
+14
View File
@@ -226,3 +226,17 @@ Bookshelf Label
/* number of lines to show */ /* number of lines to show */
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
} }
/* Padding for toastification toasts in the top right to not cover appbar/toolbar */
.app-bar-and-toolbar .Vue-Toastification__container.top-right {
padding-top: 104px;
}
.app-bar .Vue-Toastification__container.top-right {
padding-top: 64px;
}
.no-bars .Vue-Toastification__container.top-right {
padding-top: 8px;
}
+10 -3
View File
@@ -1,34 +1,40 @@
.flip-list-move { .flip-list-move {
transition: transform 0.5s; transition: transform 0.5s;
} }
.no-move { .no-move {
transition: transform 0s; transition: transform 0s;
} }
.ghost { .ghost {
opacity: 0.5; opacity: 0.5;
background-color: rgba(255, 255, 255, 0.25); background-color: rgba(255, 255, 255, 0.25);
} }
.list-group { .list-group {
min-height: 30px; min-height: 30px;
} }
#librariesTable .item {
cursor: n-resize;
}
.drag-handle { .drag-handle {
cursor: n-resize; cursor: n-resize;
} }
.list-group-item:not(.exclude) { .list-group-item:not(.exclude) {
cursor: n-resize; cursor: n-resize;
} }
.list-group-item.exclude { .list-group-item.exclude {
cursor: not-allowed; cursor: not-allowed;
} }
.list-group-item:not(.ghost):not(.exclude):hover { .list-group-item:not(.ghost):not(.exclude):hover {
background-color: rgba(0, 0, 0, 0.1); background-color: rgba(0, 0, 0, 0.1);
} }
.list-group-item:nth-child(even):not(.ghost):not(.exclude) { .list-group-item:nth-child(even):not(.ghost):not(.exclude) {
background-color: rgba(0, 0, 0, 0.25); background-color: rgba(0, 0, 0, 0.25);
} }
.list-group-item:nth-child(even):not(.ghost):not(.exclude):hover { .list-group-item:nth-child(even):not(.ghost):not(.exclude):hover {
background-color: rgba(0, 0, 0, 0.1); background-color: rgba(0, 0, 0, 0.1);
} }
@@ -36,6 +42,7 @@
.list-group-item.exclude:not(.ghost) { .list-group-item.exclude:not(.ghost) {
background-color: rgba(255, 0, 0, 0.25); background-color: rgba(255, 0, 0, 0.25);
} }
.list-group-item.exclude:not(.ghost):hover { .list-group-item.exclude:not(.ghost):hover {
background-color: rgba(223, 0, 0, 0.25); background-color: rgba(223, 0, 0, 0.25);
} }
+26 -26
View File
@@ -74,7 +74,7 @@
font-style: normal; font-style: normal;
font-weight: 300; font-weight: 300;
font-display: swap; font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf'); src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
} }
@@ -84,7 +84,7 @@
font-style: normal; font-style: normal;
font-weight: 300; font-weight: 300;
font-display: swap; font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf'); src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
} }
@@ -94,7 +94,7 @@
font-style: normal; font-style: normal;
font-weight: 300; font-weight: 300;
font-display: swap; font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf'); src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
unicode-range: U+1F00-1FFF; unicode-range: U+1F00-1FFF;
} }
@@ -104,7 +104,7 @@
font-style: normal; font-style: normal;
font-weight: 300; font-weight: 300;
font-display: swap; font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf'); src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
unicode-range: U+0370-03FF; unicode-range: U+0370-03FF;
} }
@@ -114,7 +114,7 @@
font-style: normal; font-style: normal;
font-weight: 300; font-weight: 300;
font-display: swap; font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf'); src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB; unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
} }
@@ -124,7 +124,7 @@
font-style: normal; font-style: normal;
font-weight: 300; font-weight: 300;
font-display: swap; font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf'); src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
} }
@@ -134,7 +134,7 @@
font-style: normal; font-style: normal;
font-weight: 300; font-weight: 300;
font-display: swap; font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf'); src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
} }
@@ -154,7 +154,7 @@
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf'); src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
} }
@@ -164,7 +164,7 @@
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf'); src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
unicode-range: U+1F00-1FFF; unicode-range: U+1F00-1FFF;
} }
@@ -174,7 +174,7 @@
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf'); src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
unicode-range: U+0370-03FF; unicode-range: U+0370-03FF;
} }
@@ -184,7 +184,7 @@
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf'); src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB; unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
} }
@@ -194,7 +194,7 @@
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf'); src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
} }
@@ -204,7 +204,7 @@
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf'); src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
} }
@@ -214,7 +214,7 @@
font-style: normal; font-style: normal;
font-weight: 600; font-weight: 600;
font-display: swap; font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf'); src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
} }
@@ -224,7 +224,7 @@
font-style: normal; font-style: normal;
font-weight: 600; font-weight: 600;
font-display: swap; font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf'); src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
} }
@@ -234,7 +234,7 @@
font-style: normal; font-style: normal;
font-weight: 600; font-weight: 600;
font-display: swap; font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf'); src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
unicode-range: U+1F00-1FFF; unicode-range: U+1F00-1FFF;
} }
@@ -244,7 +244,7 @@
font-style: normal; font-style: normal;
font-weight: 600; font-weight: 600;
font-display: swap; font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf'); src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
unicode-range: U+0370-03FF; unicode-range: U+0370-03FF;
} }
@@ -254,7 +254,7 @@
font-style: normal; font-style: normal;
font-weight: 600; font-weight: 600;
font-display: swap; font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf'); src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB; unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
} }
@@ -264,7 +264,7 @@
font-style: normal; font-style: normal;
font-weight: 600; font-weight: 600;
font-display: swap; font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf'); src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
} }
@@ -274,7 +274,7 @@
font-style: normal; font-style: normal;
font-weight: 600; font-weight: 600;
font-display: swap; font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf'); src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
} }
@@ -284,7 +284,7 @@
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf'); src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
} }
@@ -294,7 +294,7 @@
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf'); src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
} }
@@ -304,7 +304,7 @@
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf'); src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
unicode-range: U+1F00-1FFF; unicode-range: U+1F00-1FFF;
} }
@@ -314,7 +314,7 @@
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf'); src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
unicode-range: U+0370-03FF; unicode-range: U+0370-03FF;
} }
@@ -324,7 +324,7 @@
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf'); src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
} }
@@ -334,6 +334,6 @@
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf'); src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
} }
+4 -4
View File
@@ -3,7 +3,7 @@
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-50"> <div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-50">
<div class="flex h-full items-center"> <div class="flex h-full items-center">
<nuxt-link to="/"> <nuxt-link to="/">
<img src="/icon48.png" class="w-10 h-10 md:w-12 md:h-12 mr-4" /> <img src="/icon48.png" class="w-8 h-8 mr-8 sm:w-12 sm:h-12 sm:mr-4" />
</nuxt-link> </nuxt-link>
<nuxt-link to="/"> <nuxt-link to="/">
@@ -12,7 +12,7 @@
<ui-libraries-dropdown /> <ui-libraries-dropdown />
<controls-global-search v-if="currentLibrary" class="hidden md:block" /> <controls-global-search v-if="currentLibrary" class="" />
<div class="flex-grow" /> <div class="flex-grow" />
<span v-if="showExperimentalFeatures" class="material-icons text-4xl text-warning pr-0 sm:pr-2 md:pr-4">logo_dev</span> <span v-if="showExperimentalFeatures" class="material-icons text-4xl text-warning pr-0 sm:pr-2 md:pr-4">logo_dev</span>
@@ -20,11 +20,11 @@
<ui-tooltip v-if="isChromecastInitialized && !isHttps" direction="bottom" text="Casting requires a secure connection" class="flex items-center"> <ui-tooltip v-if="isChromecastInitialized && !isHttps" direction="bottom" text="Casting requires a secure connection" class="flex items-center">
<span class="material-icons-outlined text-warning text-opacity-50"> cast </span> <span class="material-icons-outlined text-warning text-opacity-50"> cast </span>
</ui-tooltip> </ui-tooltip>
<div v-if="isChromecastInitialized" class="w-6 h-6 mr-2 cursor-pointer"> <div v-if="isChromecastInitialized" class="w-6 min-w-6 h-6 ml-2 mr-1 sm:mx-2 cursor-pointer">
<google-cast-launcher></google-cast-launcher> <google-cast-launcher></google-cast-launcher>
</div> </div>
<nuxt-link v-if="currentLibrary" to="/config/stats" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1"> <nuxt-link v-if="currentLibrary" to="/config/stats" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 hidden sm:flex items-center justify-center mx-1">
<span class="material-icons" aria-label="User Stats" role="button">equalizer</span> <span class="material-icons" aria-label="User Stats" role="button">equalizer</span>
</nuxt-link> </nuxt-link>
+1 -1
View File
@@ -25,7 +25,7 @@
</div> </div>
</div> </div>
<div class="absolute text-center categoryPlacard font-book transform z-30 bottom-0.5 left-4 md:left-8 w-36 rounded-md" style="height: 22px"> <div class="absolute text-center categoryPlacard font-book transform z-30 bottom-px left-4 md:left-8 w-44 rounded-md" style="height: 22px">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border"> <div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border">
<p class="transform text-sm">{{ shelf.label }}</p> <p class="transform text-sm">{{ shelf.label }}</p>
</div> </div>
+22 -4
View File
@@ -2,13 +2,19 @@
<div class="w-full h-20 md:h-10 relative"> <div class="w-full h-20 md:h-10 relative">
<div class="flex md:hidden h-10 items-center"> <div class="flex md:hidden h-10 items-center">
<nuxt-link :to="`/library/${currentLibraryId}`" class="flex-grow h-full flex justify-center items-center" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'"> <nuxt-link :to="`/library/${currentLibraryId}`" class="flex-grow h-full flex justify-center items-center" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<p>Home</p> <p class="text-sm">Home</p>
</nuxt-link> </nuxt-link>
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf`" class="flex-grow h-full flex justify-center items-center" :class="showLibrary ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'"> <nuxt-link :to="`/library/${currentLibraryId}/bookshelf`" class="flex-grow h-full flex justify-center items-center" :class="showLibrary ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<p>Library</p> <p class="text-sm">Library</p>
</nuxt-link> </nuxt-link>
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf/series`" class="flex-grow h-full flex justify-center items-center" :class="paramId === 'series' ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'"> <nuxt-link v-if="!isPodcastLibrary" :to="`/library/${currentLibraryId}/bookshelf/series`" class="flex-grow h-full flex justify-center items-center" :class="paramId === 'series' ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<p>Series</p> <p class="text-sm">Series</p>
</nuxt-link>
<nuxt-link v-if="!isPodcastLibrary" :to="`/library/${currentLibraryId}/bookshelf/collections`" class="flex-grow h-full flex justify-center items-center" :class="paramId === 'collections' ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<p class="text-sm">Collections</p>
</nuxt-link>
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="flex-grow h-full flex justify-center items-center" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<p class="text-sm">Search</p>
</nuxt-link> </nuxt-link>
</div> </div>
<div id="toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-30 flex items-center justify-end md:justify-start px-2 md:px-8"> <div id="toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-30 flex items-center justify-end md:justify-start px-2 md:px-8">
@@ -98,6 +104,9 @@ export default {
} }
}, },
computed: { computed: {
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
userCanDelete() { userCanDelete() {
return this.$store.getters['user/getUserCanDelete'] return this.$store.getters['user/getUserCanDelete']
}, },
@@ -129,6 +138,12 @@ export default {
currentLibraryId() { currentLibraryId() {
return this.$store.state.libraries.currentLibraryId return this.$store.state.libraries.currentLibraryId
}, },
currentLibraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType']
},
isPodcastLibrary() {
return this.currentLibraryMediaType === 'podcast'
},
homePage() { homePage() {
return this.$route.name === 'library-library' return this.$route.name === 'library-library'
}, },
@@ -156,6 +171,9 @@ export default {
}, },
isIssuesFilter() { isIssuesFilter() {
return this.filterBy === 'issues' && this.$route.query.filter === 'issues' return this.filterBy === 'issues' && this.$route.query.filter === 'issues'
},
isPodcastSearchPage() {
return this.$route.name === 'library-library-podcast-search'
} }
}, },
methods: { methods: {
+16 -6
View File
@@ -4,19 +4,21 @@
<span class="material-icons text-2xl">arrow_back</span> <span class="material-icons text-2xl">arrow_back</span>
</div> </div>
<nuxt-link v-for="route in configRoutes" :key="route.id" :to="route.path" class="w-full px-4 h-12 border-b border-opacity-0 flex items-center cursor-pointer relative" :class="routeName === route.id ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'"> <nuxt-link v-for="route in configRoutes" :key="route.id" :to="route.path" class="w-full px-4 h-12 border-b border-primary border-opacity-30 flex items-center cursor-pointer relative" :class="routeName === route.id ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
<p>{{ route.title }}</p> <p>{{ route.title }}</p>
<div v-show="routeName === route.iod" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> <div v-show="routeName === route.iod" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link> </nuxt-link>
<div class="w-full h-12 px-4 border-t border-black border-opacity-20 absolute left-0 flex flex-col justify-center" :style="{ bottom: streamLibraryItem && isMobileLandscape ? '300px' : '65px' }"> <div class="w-full h-12 px-4 border-t border-black border-opacity-20 absolute left-0 flex flex-col justify-center" :style="{ bottom: streamLibraryItem && isMobileLandscape ? '300px' : '65px' }">
<div class="flex justify-between"> <div class="flex justify-between">
<p class="font-mono text-sm">v{{ $config.version }}</p> <p class="underline font-mono text-sm" @click="clickChangelog">v{{ $config.version }}</p>
<p class="font-mono text-xs text-gray-300 italic">{{ Source }}</p> <p class="font-mono text-xs text-gray-300 italic">{{ Source }}</p>
</div> </div>
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xs">Latest: {{ latestVersion }}</a> <a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xs">Latest: {{ latestVersion }}</a>
</div> </div>
<modals-changelog-view-modal v-model="showChangelogModal" :changelog="currentVersionChangelog" :currentVersion="$config.version"/>
</div> </div>
</template> </template>
@@ -26,7 +28,9 @@ export default {
isOpen: Boolean isOpen: Boolean
}, },
data() { data() {
return {} return {
showChangelogModal: false
}
}, },
computed: { computed: {
Source() { Source() {
@@ -66,7 +70,7 @@ export default {
}, },
{ {
id: 'config-sessions', id: 'config-sessions',
title: 'Sessions', title: 'Listening Sessions',
path: '/config/sessions' path: '/config/sessions'
}, },
{ {
@@ -76,7 +80,7 @@ export default {
}, },
{ {
id: 'config-log', id: 'config-log',
title: 'Log', title: 'Logs',
path: '/config/log' path: '/config/log'
} }
] ]
@@ -129,18 +133,24 @@ export default {
githubTagUrl() { githubTagUrl() {
return this.versionData.githubTagUrl return this.versionData.githubTagUrl
}, },
currentVersionChangelog() {
return this.versionData.currentVersionChangelog || 'No Changelog Available'
},
streamLibraryItem() { streamLibraryItem() {
return this.$store.state.streamLibraryItem return this.$store.state.streamLibraryItem
} }
}, },
methods: { methods: {
clickChangelog(){
this.showChangelogModal = true
},
clickOutside() { clickOutside() {
if (!this.isOpen) return if (!this.isOpen) return
this.closeDrawer() this.closeDrawer()
}, },
closeDrawer() { closeDrawer() {
this.$emit('update:isOpen', false) this.$emit('update:isOpen', false)
} },
} }
} }
</script> </script>
+14 -6
View File
@@ -75,17 +75,21 @@
</nuxt-link> </nuxt-link>
<div class="w-full h-12 px-1 py-2 border-t border-black border-opacity-20 absolute left-0" :style="{ bottom: streamLibraryItem ? '240px' : '65px' }"> <div class="w-full h-12 px-1 py-2 border-t border-black border-opacity-20 absolute left-0" :style="{ bottom: streamLibraryItem ? '240px' : '65px' }">
<p class="font-mono text-xs text-center text-gray-300 leading-3 mb-1">v{{ $config.version }}</p> <p class="underline font-mono text-xs text-center text-gray-300 leading-3 mb-1" @click="clickChangelog">v{{ $config.version }}</p>
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xxs text-center block leading-3">Update</a> <a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xxs text-center block leading-3">Update</a>
<p v-else class="text-xxs text-gray-400 leading-3 text-center italic">{{ Source }}</p> <p v-else class="text-xxs text-gray-400 leading-3 text-center italic">{{ Source }}</p>
</div> </div>
<modals-changelog-view-modal v-model="showChangelogModal" :changelog="currentVersionChangelog" :currentVersion="$config.version"/>
</div> </div>
</template> </template>
<script> <script>
export default { export default {
data() { data() {
return {} return {
showChangelogModal: false
}
}, },
computed: { computed: {
Source() { Source() {
@@ -150,17 +154,21 @@ export default {
hasUpdate() { hasUpdate() {
return !!this.versionData.hasUpdate return !!this.versionData.hasUpdate
}, },
latestVersion() {
return this.versionData.latestVersion
},
githubTagUrl() { githubTagUrl() {
return this.versionData.githubTagUrl return this.versionData.githubTagUrl
}, },
currentVersionChangelog() {
return this.versionData.currentVersionChangelog || 'No Changelog Available'
},
streamLibraryItem() { streamLibraryItem() {
return this.$store.state.streamLibraryItem return this.$store.state.streamLibraryItem
} }
}, },
methods: {}, methods: {
clickChangelog(){
this.showChangelogModal = true
}
},
mounted() {} mounted() {}
} }
</script> </script>
+12 -8
View File
@@ -1,30 +1,30 @@
<template> <template>
<div v-if="streamLibraryItem" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 sm:h-44 md:h-40 z-40 bg-primary px-4 pb-1 md:pb-4 pt-2"> <div v-if="streamLibraryItem" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 sm:h-44 md:h-40 z-40 bg-primary px-4 pb-1 md:pb-4 pt-2">
<div id="videoDock" /> <div id="videoDock" />
<nuxt-link v-if="!playerHandler.isVideo" :to="`/item/${streamLibraryItem.id}`" class="absolute left-4 cursor-pointer" :style="{ top: bookCoverPosTop + 'px' }"> <nuxt-link v-if="!playerHandler.isVideo" :to="`/item/${streamLibraryItem.id}`" class="absolute left-1 sm:left-4 cursor-pointer" :style="{ top: bookCoverPosTop + 'px' }">
<covers-book-cover :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" /> <covers-book-cover :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</nuxt-link> </nuxt-link>
<div class="flex items-start mb-6 md:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : 'pl-24'"> <div class="flex items-start mb-6 md:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : 'pl-20 sm:pl-24'">
<div> <div>
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-base sm:text-lg"> <nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-sm sm:text-lg">
{{ title }} {{ title }}
</nuxt-link> </nuxt-link>
<div v-if="!playerHandler.isVideo" class="text-gray-400 flex items-center"> <div v-if="!playerHandler.isVideo" class="text-gray-400 flex items-center">
<span class="material-icons text-sm">person</span> <span class="material-icons text-sm">person</span>
<p v-if="podcastAuthor">{{ podcastAuthor }}</p> <p v-if="podcastAuthor" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ podcastAuthor }}</p>
<p v-else-if="authors.length" class="pl-1.5 text-sm sm:text-base"> <p v-else-if="authors.length" class="pl-1 sm:pl-1.5 text-xs sm:text-base">
<nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">,&nbsp;</span></nuxt-link> <nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">,&nbsp;</span></nuxt-link>
</p> </p>
<p v-else class="text-sm sm:text-base cursor-pointer pl-2">Unknown</p> <p v-else class="text-xs sm:text-base cursor-pointer pl-1 sm:pl-1.5">Unknown</p>
</div> </div>
<div class="text-gray-400 flex items-center"> <div class="text-gray-400 flex items-center">
<span class="material-icons text-xs">schedule</span> <span class="material-icons text-xs">schedule</span>
<p class="font-mono text-sm pl-2 pb-px">{{ totalDurationPretty }}</p> <p class="font-mono text-xs sm:text-sm pl-1 sm:pl-1.5 pb-px">{{ totalDurationPretty }}</p>
</div> </div>
</div> </div>
<div class="flex-grow" /> <div class="flex-grow" />
<span class="material-icons px-2 py-1 md:p-4 cursor-pointer" @click="closePlayer">close</span> <span class="material-icons sm:px-2 py-1 md:p-4 cursor-pointer text-xl sm:text-2xl" @click="closePlayer">close</span>
</div> </div>
<player-ui <player-ui
ref="audioPlayer" ref="audioPlayer"
@@ -377,6 +377,10 @@ export default {
libraryItem, libraryItem,
episodeId episodeId
}) })
this.$nextTick(() => {
if (this.$refs.audioPlayer) this.$refs.audioPlayer.checkUpdateChapterTrack()
})
this.playerHandler.load(libraryItem, episodeId, true, this.initialPlaybackRate) this.playerHandler.load(libraryItem, episodeId, true, this.initialPlaybackRate)
}, },
pauseItem() { pauseItem() {
+9 -2
View File
@@ -6,11 +6,18 @@
</div> </div>
<div v-if="!isPodcast" class="px-4 flex-grow"> <div v-if="!isPodcast" class="px-4 flex-grow">
<div class="flex items-center"> <div class="flex items-center">
<h1>{{ book.title }}</h1> <h1 class="text-base">{{ book.title }}</h1>
<div class="flex-grow" /> <div class="flex-grow" />
<p>{{ book.publishedYear }}</p> <p>{{ book.publishedYear }}</p>
</div> </div>
<p class="text-gray-400">{{ book.author }}</p> <p class="text-gray-300 text-sm">{{ book.author }}</p>
<div v-if="book.series && book.series.length" class="flex py-1 -mx-1">
<div v-for="(series, index) in book.series" :key="index" class="bg-white bg-opacity-10 rounded-full px-1 py-0.5 mx-1">
<p class="leading-3 text-xs text-gray-400">
{{ series.series }}<span v-if="series.volumeNumber">&nbsp;#{{ series.volumeNumber }}</span>
</p>
</div>
</div>
<div class="w-full max-h-12 overflow-hidden"> <div class="w-full max-h-12 overflow-hidden">
<p class="text-gray-500 text-xs">{{ book.description }}</p> <p class="text-gray-500 text-xs">{{ book.description }}</p>
</div> </div>
+3 -14
View File
@@ -62,21 +62,10 @@ export default {
matchHtml() { matchHtml() {
if (!this.matchText || !this.search) return '' if (!this.matchText || !this.search) return ''
if (this.matchKey === 'subtitle') return '' if (this.matchKey === 'subtitle') return ''
var matchSplit = this.matchText.toLowerCase().split(this.search.toLowerCase().trim())
if (matchSplit.length < 2) return ''
var html = '' // This used to highlight the part of the search found
var totalLenSoFar = 0 // but with removing commas periods etc this is no longer plausible
for (let i = 0; i < matchSplit.length - 1; i++) { const html = this.matchText
var indexOf = matchSplit[i].length
var firstPart = this.matchText.substr(totalLenSoFar, indexOf)
var actualWasThere = this.matchText.substr(totalLenSoFar + indexOf, this.search.length)
totalLenSoFar += indexOf + this.search.length
html += `${firstPart}<strong class="text-warning">${actualWasThere}</strong>`
}
var lastPart = this.matchText.substr(totalLenSoFar)
html += lastPart
if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>` if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>`
if (this.matchKey === 'authors') return `by ${html}` if (this.matchKey === 'authors') return `by ${html}`
+11 -3
View File
@@ -144,6 +144,9 @@ export default {
} }
}, },
computed: { computed: {
dateFormat() {
return this.store.state.serverSettings.dateFormat
},
showExperimentalFeatures() { showExperimentalFeatures() {
return this.store.state.showExperimentalFeatures return this.store.state.showExperimentalFeatures
}, },
@@ -245,13 +248,17 @@ export default {
return this.mediaMetadata.authorNameLF return this.mediaMetadata.authorNameLF
}, },
displayTitle() { displayTitle() {
if (this.recentEpisode) return this.recentEpisode.title
if (this.collapsedSeries) return this.collapsedSeries.name
if (this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix) { if (this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix) {
return this.mediaMetadata.titleIgnorePrefix return this.mediaMetadata.titleIgnorePrefix
} }
return this.title return this.title
}, },
displayLineTwo() { displayLineTwo() {
if (this.recentEpisode) return this.title
if (this.isPodcast) return this.author if (this.isPodcast) return this.author
if (this.collapsedSeries) return ''
if (this.isAuthorBookshelfView) { if (this.isAuthorBookshelfView) {
return this.mediaMetadata.publishedYear || '' return this.mediaMetadata.publishedYear || ''
} }
@@ -259,9 +266,10 @@ export default {
return this.author return this.author
}, },
displaySortLine() { displaySortLine() {
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs) if (this.collapsedSeries) return null
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs) if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs, this.dateFormat)
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt) 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 === 'media.duration') return 'Duration: ' + this.$elapsedPrettyExtended(this.media.duration, false)
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size) if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size)
if (this.orderBy === 'media.numTracks') return `${this.numEpisodes} Episodes` if (this.orderBy === 'media.numTracks') return `${this.numEpisodes} Episodes`
+10 -7
View File
@@ -1,5 +1,5 @@
<template> <template>
<div class="w-80 ml-6 relative"> <div class="sm:w-80 w-full sm:ml-6 relative">
<form @submit.prevent="submitSearch"> <form @submit.prevent="submitSearch">
<ui-text-input ref="input" v-model="search" placeholder="Search.." @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" /> <ui-text-input ref="input" v-model="search" placeholder="Search.." @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
</form> </form>
@@ -7,7 +7,7 @@
<span v-if="!search" class="material-icons" style="font-size: 1.2rem">search</span> <span v-if="!search" class="material-icons" style="font-size: 1.2rem">search</span>
<span v-else class="material-icons" style="font-size: 1.2rem">close</span> <span v-else class="material-icons" style="font-size: 1.2rem">close</span>
</div> </div>
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 px-2 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalSearchMenu"> <div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-40 sm:w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 px-2 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalSearchMenu">
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label"> <ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<li v-if="isTyping" class="py-2 px-2"> <li v-if="isTyping" class="py-2 px-2">
<p>Thinking...</p> <p>Thinking...</p>
@@ -21,7 +21,7 @@
<template v-else> <template v-else>
<p v-if="bookResults.length" class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">Books</p> <p v-if="bookResults.length" class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">Books</p>
<template v-for="item in bookResults"> <template v-for="item in bookResults">
<li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option"> <li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
<nuxt-link :to="`/item/${item.libraryItem.id}`"> <nuxt-link :to="`/item/${item.libraryItem.id}`">
<cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" /> <cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
</nuxt-link> </nuxt-link>
@@ -30,7 +30,7 @@
<p v-if="podcastResults.length" class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">Podcasts</p> <p v-if="podcastResults.length" class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">Podcasts</p>
<template v-for="item in podcastResults"> <template v-for="item in podcastResults">
<li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option"> <li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
<nuxt-link :to="`/item/${item.libraryItem.id}`"> <nuxt-link :to="`/item/${item.libraryItem.id}`">
<cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" /> <cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
</nuxt-link> </nuxt-link>
@@ -39,7 +39,7 @@
<p v-if="authorResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Authors</p> <p v-if="authorResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Authors</p>
<template v-for="item in authorResults"> <template v-for="item in authorResults">
<li :key="item.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option"> <li :key="item.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(item.id)}`"> <nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(item.id)}`">
<cards-author-search-card :author="item" /> <cards-author-search-card :author="item" />
</nuxt-link> </nuxt-link>
@@ -48,7 +48,7 @@
<p v-if="seriesResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Series</p> <p v-if="seriesResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Series</p>
<template v-for="item in seriesResults"> <template v-for="item in seriesResults">
<li :key="item.series.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option"> <li :key="item.series.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
<nuxt-link :to="`/library/${currentLibraryId}/series/${item.series.id}`"> <nuxt-link :to="`/library/${currentLibraryId}/series/${item.series.id}`">
<cards-series-search-card :series="item.series" :book-items="item.books" /> <cards-series-search-card :series="item.series" :book-items="item.books" />
</nuxt-link> </nuxt-link>
@@ -57,7 +57,7 @@
<p v-if="tagResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Tags</p> <p v-if="tagResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Tags</p>
<template v-for="item in tagResults"> <template v-for="item in tagResults">
<li :key="item.name" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option"> <li :key="item.name" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=tags.${$encode(item.name)}`"> <nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=tags.${$encode(item.name)}`">
<cards-tag-search-card :tag="item.name" /> <cards-tag-search-card :tag="item.name" />
</nuxt-link> </nuxt-link>
@@ -97,6 +97,9 @@ export default {
} }
}, },
methods: { methods: {
clickOption() {
this.clearResults()
},
submitSearch() { submitSearch() {
if (!this.search) return if (!this.search) return
var search = this.search var search = this.search
@@ -1,10 +1,10 @@
<template> <template>
<div class="relative ml-8" v-click-outside="clickOutside"> <div ref="wrapper" class="relative ml-4 sm:ml-8" v-click-outside="clickOutside">
<div class="flex items-center justify-center text-gray-300 cursor-pointer h-full" @mousedown.prevent @mouseup.prevent @click="setShowMenu(true)"> <div class="flex items-center justify-center text-gray-300 cursor-pointer h-full" @mousedown.prevent @mouseup.prevent @click="setShowMenu(true)">
<span class="font-mono uppercase text-gray-200">{{ playbackRate.toFixed(1) }}<span class="text-lg"></span></span> <span class="font-mono uppercase text-gray-200 text-sm sm:text-base">{{ playbackRate.toFixed(1) }}<span class="text-base sm:text-lg"></span></span>
</div> </div>
<div v-show="showMenu" class="absolute -top-20 left-0 z-20 bg-bg border-black-200 border shadow-xl rounded-lg" style="left: -92px"> <div v-show="showMenu" class="absolute -top-20 z-20 bg-bg border-black-200 border shadow-xl rounded-lg" :style="{ left: menuLeft + 'px' }">
<div class="absolute -bottom-2 left-0 right-0 w-full flex justify-center"> <div class="absolute -bottom-1.5 right-0 w-full flex justify-center" :style="{ left: arrowLeft + 'px' }">
<div class="arrow-down" /> <div class="arrow-down" />
</div> </div>
<div class="flex items-center h-9 relative overflow-hidden rounded-lg" style="width: 220px"> <div class="flex items-center h-9 relative overflow-hidden rounded-lg" style="width: 220px">
@@ -19,7 +19,7 @@
<div class="w-full py-1 px-4"> <div class="w-full py-1 px-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<ui-icon-btn :disabled="!canDecrement" icon="remove" @click="decrement" /> <ui-icon-btn :disabled="!canDecrement" icon="remove" @click="decrement" />
<p class="px-2 text-3xl">{{ playbackRate }}<span class="text-2xl"></span></p> <p class="px-2 text-2xl sm:text-3xl">{{ playbackRate }}<span class="text-2xl"></span></p>
<ui-icon-btn :disabled="!canIncrement" icon="add" @click="increment" /> <ui-icon-btn :disabled="!canIncrement" icon="add" @click="increment" />
</div> </div>
</div> </div>
@@ -40,7 +40,9 @@ export default {
showMenu: false, showMenu: false,
currentPlaybackRate: 0, currentPlaybackRate: 0,
MIN_SPEED: 0.5, MIN_SPEED: 0.5,
MAX_SPEED: 3 MAX_SPEED: 3,
menuLeft: -92,
arrowLeft: 0
} }
}, },
computed: { computed: {
@@ -80,8 +82,22 @@ export default {
var newPlaybackRate = this.playbackRate - 0.1 var newPlaybackRate = this.playbackRate - 0.1
this.playbackRate = Number(newPlaybackRate.toFixed(1)) this.playbackRate = Number(newPlaybackRate.toFixed(1))
}, },
updateMenuPositions() {
if (!this.$refs.wrapper) return
const boundingBox = this.$refs.wrapper.getBoundingClientRect()
if (boundingBox.left + 110 > window.innerWidth - 10) {
this.menuLeft = window.innerWidth - 230 - boundingBox.left
this.arrowLeft = Math.abs(this.menuLeft) - 92
} else {
this.menuLeft = -92
this.arrowLeft = 0
}
},
setShowMenu(val) { setShowMenu(val) {
if (val) { if (val) {
this.updateMenuPositions()
this.currentPlaybackRate = this.playbackRate this.currentPlaybackRate = this.playbackRate
} else if (this.currentPlaybackRate !== this.playbackRate) { } else if (this.currentPlaybackRate !== this.playbackRate) {
this.$emit('change', this.playbackRate) this.$emit('change', this.playbackRate)
+2 -2
View File
@@ -1,7 +1,7 @@
<template> <template>
<div class="relative" v-click-outside="clickOutside" @mouseover="mouseover" @mouseleave="mouseleave"> <div class="relative" v-click-outside="clickOutside" @mouseover="mouseover" @mouseleave="mouseleave">
<div class="cursor-pointer" @mousedown.prevent @mouseup.prevent @click="clickVolumeIcon"> <div class="cursor-pointer text-gray-300 hover:text-white" @mousedown.prevent @mouseup.prevent @click="clickVolumeIcon">
<span class="material-icons text-3xl">{{ volumeIcon }}</span> <span class="material-icons text-2xl sm:text-3xl">{{ volumeIcon }}</span>
</div> </div>
<transition name="menux"> <transition name="menux">
<div v-show="isOpen" class="volumeMenu h-6 absolute bottom-2 w-28 px-2 bg-bg shadow-sm rounded-lg" style="left: -116px"> <div v-show="isOpen" class="volumeMenu h-6 absolute bottom-2 w-28 px-2 bg-bg shadow-sm rounded-lg" style="left: -116px">
+9 -2
View File
@@ -146,7 +146,6 @@ export default {
watch: { watch: {
show: { show: {
handler(newVal) { handler(newVal) {
console.log('accoutn modal show change', newVal)
if (newVal) { if (newVal) {
this.init() this.init()
} }
@@ -162,6 +161,9 @@ export default {
this.$emit('input', val) this.$emit('input', val)
} }
}, },
user() {
return this.$store.state.user.user
},
title() { title() {
return this.isNew ? 'Add New Account' : `Update ${(this.account || {}).username}` return this.isNew ? 'Add New Account' : `Update ${(this.account || {}).username}`
}, },
@@ -250,6 +252,12 @@ export default {
this.$toast.error(`Failed to update account: ${data.error}`) this.$toast.error(`Failed to update account: ${data.error}`)
} else { } else {
console.log('Account updated', data.user) console.log('Account updated', data.user)
if (data.user.id === this.user.id && data.user.token !== this.user.token) {
console.log('Current user token was updated')
this.$store.commit('user/setUserToken', data.user.token)
}
this.$toast.success('Account updated') this.$toast.success('Account updated')
this.show = false this.show = false
} }
@@ -305,7 +313,6 @@ export default {
this.isNew = !this.account this.isNew = !this.account
if (this.account) { if (this.account) {
console.log(this.account)
this.newUser = { this.newUser = {
username: this.account.username, username: this.account.username,
password: this.account.password, password: this.account.password,
+18 -4
View File
@@ -1,11 +1,14 @@
<template> <template>
<modals-modal v-model="show" name="chapters" :width="500" :height="'unset'"> <modals-modal v-model="show" name="chapters" :width="600" :height="'unset'">
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh"> <div id="chapter-modal-wrapper" ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
<template v-for="chap in chapters"> <template v-for="chap in chapters">
<div :key="chap.id" :id="`chapter-row-${chap.id}`" class="flex items-center px-6 py-3 justify-start cursor-pointer hover:bg-bg relative" :class="chap.id === currentChapterId ? 'bg-yellow-400 bg-opacity-10' : chap.end <= currentChapterStart ? 'bg-success bg-opacity-5' : 'bg-opacity-20'" @click="clickChapter(chap)"> <div :key="chap.id" :id="`chapter-row-${chap.id}`" class="flex items-center px-6 py-3 justify-start cursor-pointer hover:bg-bg relative" :class="chap.id === currentChapterId ? 'bg-yellow-400 bg-opacity-10' : chap.end <= currentChapterStart ? 'bg-success bg-opacity-5' : 'bg-opacity-20'" @click="clickChapter(chap)">
{{ chap.title }} <p class="chapter-title truncate text-sm md:text-base">
{{ chap.title }}
</p>
<span class="font-mono text-xxs sm:text-xs text-gray-400 pl-2 whitespace-nowrap">{{ $elapsedPrettyExtended(chap.end - chap.start) }}</span>
<span class="flex-grow" /> <span class="flex-grow" />
<span class="font-mono text-sm text-gray-300">{{ $secondsToTimestamp(chap.start) }}</span> <span class="font-mono text-xs sm:text-sm text-gray-300">{{ $secondsToTimestamp(chap.start) }}</span>
<div v-show="chap.id === currentChapterId" class="w-0.5 h-full absolute top-0 left-0 bg-yellow-400" /> <div v-show="chap.id === currentChapterId" class="w-0.5 h-full absolute top-0 left-0 bg-yellow-400" />
</div> </div>
@@ -71,3 +74,14 @@ export default {
} }
} }
</script> </script>
<style>
#chapter-modal-wrapper .chapter-title {
max-width: calc(100% - 120px);
}
@media (min-width: 640px) {
#chapter-modal-wrapper .chapter-title {
max-width: calc(100% - 150px);
}
}
</style>
+56
View File
@@ -0,0 +1,56 @@
<template>
<modals-modal v-model="show" :width="300" height="100%">
<template #outer>
<div v-if="title" class="absolute top-7 left-4 z-40" style="max-width: 80%">
<p class="text-white text-lg truncate">{{ title }}</p>
</div>
</template>
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in items">
<li :key="item.value" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" :class="selected === item.value ? 'bg-success bg-opacity-10' : ''" role="option" @click="clickedOption(item.value)">
<div class="relative flex items-center px-3">
<p class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</p>
</div>
</li>
</template>
</ul>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
title: String,
items: {
type: Array,
default: () => []
},
selected: String // optional
},
data() {
return {}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
}
},
methods: {
clickedOption(action) {
this.$emit('action', action)
}
},
mounted() {}
}
</script>
@@ -1,16 +1,16 @@
<template> <template>
<div ref="wrapper" class="hidden absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 rounded-lg items-center justify-center" style="z-index: 51" @click="clickClose"> <div ref="wrapper" class="hidden absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 rounded-lg items-center justify-center" style="z-index: 51" @click="clickClose">
<div class="absolute top-5 right-5 h-12 w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300"> <div class="absolute top-3 right-3 md:top-5 md:right-5 h-8 w-8 md:h-12 md:w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300">
<span class="material-icons text-4xl">close</span> <span class="material-icons text-2xl md:text-4xl">close</span>
</div> </div>
<div ref="content" class="text-white"> <div ref="content" class="text-white">
<form v-if="selectedSeries" @submit.prevent="submitSeriesForm"> <form v-if="selectedSeries" @submit.prevent="submitSeriesForm">
<div class="bg-bg rounded-lg p-8" @click.stop> <div class="bg-bg rounded-lg px-2 py-6 sm:p-6 md:p-8" @click.stop>
<div class="flex"> <div class="flex">
<div class="flex-grow p-1 min-w-80"> <div class="flex-grow p-1 min-w-48 sm:min-w-64 md:min-w-80">
<ui-input-dropdown ref="newSeriesSelect" v-model="selectedSeries.name" :items="existingSeriesNames" :disabled="!selectedSeries.id.startsWith('new')" label="Series Name" /> <ui-input-dropdown ref="newSeriesSelect" v-model="selectedSeries.name" :items="existingSeriesNames" :disabled="!selectedSeries.id.startsWith('new')" label="Series Name" />
</div> </div>
<div class="w-40 p-1"> <div class="w-24 sm:w-28 md:w-40 p-1">
<ui-text-input-with-label v-model="selectedSeries.sequence" label="Sequence" /> <ui-text-input-with-label v-model="selectedSeries.sequence" label="Sequence" />
</div> </div>
</div> </div>
@@ -5,7 +5,7 @@
<p class="font-book text-3xl text-white truncate">Session {{ _session.id }}</p> <p class="font-book text-3xl text-white truncate">Session {{ _session.id }}</p>
</div> </div>
</template> </template>
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden p-6" style="max-height: 80vh"> <div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden p-6" style="max-height: 80vh">
<div class="flex items-center"> <div class="flex items-center">
<p class="text-base text-gray-200">{{ _session.displayTitle }}</p> <p class="text-base text-gray-200">{{ _session.displayTitle }}</p>
<p v-if="_session.displayAuthor" class="text-xs text-gray-400 px-4">by {{ _session.displayAuthor }}</p> <p v-if="_session.displayAuthor" class="text-xs text-gray-400 px-4">by {{ _session.displayAuthor }}</p>
@@ -87,7 +87,7 @@
<p class="mb-1">{{ playMethodName }}</p> <p class="mb-1">{{ playMethodName }}</p>
<p class="mb-1">{{ _session.mediaPlayer }}</p> <p class="mb-1">{{ _session.mediaPlayer }}</p>
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">Device</p> <p v-if="hasDeviceInfo" class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">Device</p>
<p v-if="deviceInfo.ipAddress" class="mb-1">{{ deviceInfo.ipAddress }}</p> <p v-if="deviceInfo.ipAddress" class="mb-1">{{ deviceInfo.ipAddress }}</p>
<p v-if="osDisplayName" class="mb-1">{{ osDisplayName }}</p> <p v-if="osDisplayName" class="mb-1">{{ osDisplayName }}</p>
<p v-if="deviceInfo.browserName" class="mb-1">{{ deviceInfo.browserName }}</p> <p v-if="deviceInfo.browserName" class="mb-1">{{ deviceInfo.browserName }}</p>
@@ -127,6 +127,9 @@ export default {
deviceInfo() { deviceInfo() {
return this._session.deviceInfo || {} return this._session.deviceInfo || {}
}, },
hasDeviceInfo() {
return Object.keys(this.deviceInfo).length
},
osDisplayName() { osDisplayName() {
if (!this.deviceInfo.osName) return null if (!this.deviceInfo.osName) return null
return `${this.deviceInfo.osName} ${this.deviceInfo.osVersion}` return `${this.deviceInfo.osName} ${this.deviceInfo.osVersion}`
+3 -3
View File
@@ -2,11 +2,11 @@
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary items-center justify-center opacity-0 hidden" :class="`z-${zIndex} bg-opacity-${bgOpacity}`"> <div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary items-center justify-center opacity-0 hidden" :class="`z-${zIndex} bg-opacity-${bgOpacity}`">
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" /> <div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
<div class="absolute top-5 right-5 h-12 w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="clickClose"> <div class="absolute top-3 right-3 landscape:top-2 landscape:right-2 md:portrait:top-5 md:portrait:right-5 lg:top-5 lg:right-5 h-8 w-8 landscape:h-8 landscape:w-8 md:portrait:h-12 md:portrait:w-12 lg:w-12 lg:h-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="clickClose">
<span class="material-icons text-4xl">close</span> <span class="material-icons text-2xl landscape:text-2xl md:portrait:text-4xl lg:text-4xl">close</span>
</div> </div>
<slot name="outer" /> <slot name="outer" />
<div ref="content" style="min-width: 400px; min-height: 200px" class="relative text-white" :style="{ height: modalHeight, width: modalWidth, marginTop: contentMarginTop + 'px' }" @mousedown="mousedownModal" @mouseup="mouseupModal" v-click-outside="clickBg"> <div ref="content" style="min-width: 380px; min-height: 200px; max-width: 100vw" class="relative text-white" :style="{ height: modalHeight, width: modalWidth, marginTop: contentMarginTop + 'px' }" @mousedown="mousedownModal" @mouseup="mouseupModal" v-click-outside="clickBg">
<slot /> <slot />
<div v-if="processing" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-black bg-opacity-60 rounded-lg flex items-center justify-center"> <div v-if="processing" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-black bg-opacity-60 rounded-lg flex items-center justify-center">
<ui-loading-indicator /> <ui-loading-indicator />
@@ -25,6 +25,9 @@
<ui-text-input-with-label v-model="authorCopy.asin" :disabled="processing" label="ASIN" /> <ui-text-input-with-label v-model="authorCopy.asin" :disabled="processing" label="ASIN" />
</div> </div>
</div> </div>
<div class="p-2">
<ui-text-input-with-label v-model="authorCopy.imagePath" :disabled="processing" label="Photo Path/URL" />
</div>
<div class="p-2"> <div class="p-2">
<ui-textarea-with-label v-model="authorCopy.description" :disabled="processing" label="Description" :rows="8" /> <ui-textarea-with-label v-model="authorCopy.description" :disabled="processing" label="Description" :rows="8" />
</div> </div>
@@ -43,19 +46,13 @@
<script> <script>
export default { export default {
// props: {
// value: Boolean,
// author: {
// type: Object,
// default: () => {}
// }
// },
data() { data() {
return { return {
authorCopy: { authorCopy: {
name: '', name: '',
asin: '', asin: '',
description: '' description: '',
imagePath: ''
}, },
processing: false processing: false
} }
@@ -95,9 +92,10 @@ export default {
this.authorCopy.name = this.author.name this.authorCopy.name = this.author.name
this.authorCopy.asin = this.author.asin this.authorCopy.asin = this.author.asin
this.authorCopy.description = this.author.description this.authorCopy.description = this.author.description
this.authorCopy.imagePath = this.author.imagePath
}, },
async submitForm() { async submitForm() {
var keysToCheck = ['name', 'asin', 'description'] var keysToCheck = ['name', 'asin', 'description', 'imagePath']
var updatePayload = {} var updatePayload = {}
keysToCheck.forEach((key) => { keysToCheck.forEach((key) => {
if (this.authorCopy[key] !== this.author[key]) { if (this.authorCopy[key] !== this.author[key]) {
@@ -0,0 +1,73 @@
<template>
<modals-modal v-model="show" name="changelog" :width="800" :height="'unset'">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">Changelog</p>
</div>
</template>
<div class="px-8 py-6 w-full rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-y-scroll" style="max-height: 80vh">
<p class="text-xl font-bold pb-4">Changelog v{{ currentVersionNumber }}</p>
<div class="custom-text" v-html="compiledMarkedown" />
</div>
</modals-modal>
</template>
<script>
import { marked } from '@/static/libs/marked/index.js'
export default {
props: {
value: Boolean,
changelog: String,
currentVersion: String
},
watch: {
show: {
immediate: true,
handler(newVal) {
if (newVal) {
this.init()
}
}
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
compiledMarkedown() {
return marked.parse(this.changelog, { gfm: true, breaks: true })
},
currentVersionNumber() {
return this.currentVersion
}
},
methods: {
init() {}
},
mounted() {}
}
</script>
<style scoped>
/*
1. we need to manually define styles to apply to the parsed markdown elements,
since we don't have access to the actual elements in this component
2. v-deep allows these to take effect on the content passed in to the v-html in the div above
*/
.custom-text ::v-deep > h2 {
@apply text-lg font-bold;
}
.custom-text ::v-deep > h3 {
@apply text-lg font-bold;
}
.custom-text ::v-deep > ul {
@apply list-disc list-inside pb-4;
}
</style>
+22 -7
View File
@@ -1,13 +1,13 @@
<template> <template>
<modals-modal v-model="show" name="edit-book" :width="800" :height="height" :processing="processing" :content-margin-top="75"> <modals-modal v-model="show" name="edit-book" :width="800" :height="height" :processing="processing" :content-margin-top="marginTop">
<template #outer> <template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none"> <div class="absolute top-0 left-0 p-4 landscape:px-4 landscape:py-2 md:portrait:p-5 lg:p-5 w-2/3 overflow-hidden pointer-events-none">
<p class="font-book text-3xl text-white truncate pointer-events-none">{{ title }}</p> <p class="font-book text-xl md:portrait:text-3xl md:landscape:text-lg lg:text-3xl text-white truncate pointer-events-none">{{ title }}</p>
</div> </div>
</template> </template>
<div class="absolute -top-10 left-0 z-10 w-full flex"> <div class="absolute -top-10 left-0 z-10 w-full flex">
<template v-for="tab in availableTabs"> <template v-for="tab in availableTabs">
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div> <div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-0.5 sm:mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
</template> </template>
</div> </div>
@@ -30,6 +30,8 @@ export default {
return { return {
processing: false, processing: false,
libraryItem: null, libraryItem: null,
availableHeight: 0,
marginTop: 0,
tabs: [ tabs: [
{ {
id: 'details', id: 'details',
@@ -133,8 +135,7 @@ export default {
}) })
}, },
height() { height() {
var maxHeightAllowed = window.innerHeight - 150 return Math.min(this.availableHeight, 650)
return Math.min(maxHeightAllowed, 650)
}, },
tabName() { tabName() {
var _tab = this.tabs.find((t) => t.id === this.selectedTab) var _tab = this.tabs.find((t) => t.id === this.selectedTab)
@@ -246,15 +247,29 @@ export default {
} }
}, },
registerListeners() { registerListeners() {
window.addEventListener('orientationchange', this.orientationChange)
this.$eventBus.$on('modal-hotkey', this.hotkey) this.$eventBus.$on('modal-hotkey', this.hotkey)
this.$eventBus.$on(`${this.selectedLibraryItemId}_updated`, this.libraryItemUpdated) this.$eventBus.$on(`${this.selectedLibraryItemId}_updated`, this.libraryItemUpdated)
}, },
unregisterListeners() { unregisterListeners() {
window.removeEventListener('orientationchange', this.orientationChange)
this.$eventBus.$off('modal-hotkey', this.hotkey) this.$eventBus.$off('modal-hotkey', this.hotkey)
this.$eventBus.$off(`${this.selectedLibraryItemId}_updated`, this.libraryItemUpdated) this.$eventBus.$off(`${this.selectedLibraryItemId}_updated`, this.libraryItemUpdated)
},
orientationChange() {
setTimeout(this.setHeight, 50)
},
setHeight() {
const smAndBelow = window.innerWidth < 1024 && window.innerWidth > window.innerHeight
this.marginTop = smAndBelow ? 90 : 75
const heightModifier = smAndBelow ? 95 : 150
this.availableHeight = window.innerHeight - heightModifier
} }
}, },
mounted() {}, mounted() {
this.setHeight()
},
beforeDestroy() { beforeDestroy() {
this.unregisterListeners() this.unregisterListeners()
} }
+6 -6
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6 relative"> <div class="w-full h-full overflow-hidden overflow-y-auto px-2 sm:px-4 py-6 relative">
<div class="flex"> <div class="flex flex-wrap">
<div class="relative"> <div class="relative">
<covers-book-cover :library-item="libraryItem" :book-cover-aspect-ratio="bookCoverAspectRatio" /> <covers-book-cover :library-item="libraryItem" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<!-- book cover overlay --> <!-- book cover overlay -->
@@ -11,14 +11,14 @@
</div> </div>
</div> </div>
</div> </div>
<div class="flex-grow pl-6 pr-2"> <div class="flex-grow sm:pl-2 md:pl-6 sm:pr-2 mt-2 md:mt-0">
<div class="flex items-center"> <div class="flex items-center">
<div v-if="userCanUpload" class="w-40 pr-2 pt-4" style="min-width: 160px"> <div v-if="userCanUpload" class="w-10 md:w-40 pr-2 pt-4 md:min-w-32">
<ui-file-input ref="fileInput" @change="fileUploadSelected">Upload Cover</ui-file-input> <ui-file-input ref="fileInput" @change="fileUploadSelected"><span class="hidden md:inline-block">Upload Cover</span><span class="material-icons inline-block md:!hidden">upload</span></ui-file-input>
</div> </div>
<form @submit.prevent="submitForm" class="flex flex-grow"> <form @submit.prevent="submitForm" class="flex flex-grow">
<ui-text-input-with-label v-model="imageUrl" label="Cover Image URL" /> <ui-text-input-with-label v-model="imageUrl" label="Cover Image URL" />
<ui-btn color="success" type="submit" :padding-x="4" class="mt-5 ml-3 w-24">Update</ui-btn> <ui-btn color="success" type="submit" :padding-x="4" class="mt-5 ml-2 sm:ml-3 w-24">Update</ui-btn>
</form> </form>
</div> </div>
@@ -5,23 +5,24 @@
<widgets-podcast-details-edit v-else ref="itemDetailsEdit" :library-item="libraryItem" @submit="saveAndClose" /> <widgets-podcast-details-edit v-else ref="itemDetailsEdit" :library-item="libraryItem" @submit="saveAndClose" />
</div> </div>
<div class="absolute bottom-0 left-0 w-full py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'border-t border-white border-opacity-5'"> <div class="absolute bottom-0 left-0 w-full py-2 md:py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'border-t border-white border-opacity-5'">
<div class="flex items-center px-4"> <div class="flex items-center px-4">
<ui-btn v-if="userCanDelete" color="error" type="button" class="h-8" :padding-x="3" small @click.stop.prevent="removeItem">Remove</ui-btn> <ui-btn v-if="userCanDelete" color="error" type="button" class="h-8 hidden md:block" :padding-x="3" small @click.stop.prevent="removeItem">Remove</ui-btn>
<ui-icon-btn bg-color="error" icon="delete" class="md:hidden" :size="7" icon-font-size="1rem" @click.stop.prevent="removeItem" />
<div class="flex-grow" /> <div class="flex-grow" />
<ui-tooltip v-if="mediaType == 'book'" :disabled="!!quickMatching" :text="`(Root User Only) Populate empty book details & cover with first book result from '${libraryProvider}'. Does not overwrite details.`" direction="bottom" class="mr-4"> <ui-tooltip v-if="mediaType == 'book'" :disabled="!!quickMatching" :text="`(Root User Only) Populate empty book details & cover with first book result from '${libraryProvider}'. Does not overwrite details.`" direction="bottom" class="mr-2 md:mr-4">
<ui-btn v-if="userIsAdminOrUp" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">Quick Match</ui-btn> <ui-btn v-if="userIsAdminOrUp" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">Quick Match</ui-btn>
</ui-tooltip> </ui-tooltip>
<ui-tooltip :disabled="!!libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="mr-4"> <ui-tooltip :disabled="!!libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="mr-2 md:mr-4">
<ui-btn v-if="userIsAdminOrUp && !isFile" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn> <ui-btn v-if="userIsAdminOrUp && !isFile" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn>
</ui-tooltip> </ui-tooltip>
<ui-btn @click="save" class="mx-2">Save</ui-btn> <ui-btn @click="save" class="mx-2 hidden md:block">Save</ui-btn>
<ui-btn @click="saveAndClose">Save & Close</ui-btn> <ui-btn @click="saveAndClose">Save<span class="hidden md:inline-block">&nbsp;& Close</span></ui-btn>
</div> </div>
</div> </div>
</div> </div>
@@ -2,13 +2,13 @@
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6"> <div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
<!-- Merge to m4b --> <!-- Merge to m4b -->
<div v-if="showM4bDownload" class="w-full border border-black-200 p-4 my-8"> <div v-if="showM4bDownload" class="w-full border border-black-200 p-4 my-8">
<div class="flex items-center"> <div class="flex flex-wrap items-center">
<div> <div>
<p class="text-lg">Make M4B Audiobook File <span class="text-error">*</span></p> <p class="text-lg">Make M4B Audiobook File <span class="text-error">*</span></p>
<p class="max-w-sm text-sm pt-2 text-gray-300">Generate a .M4B audiobook file with embedded metadata, cover image, and chapters. <br /><span class="text-warning">*</span> Does not delete existing audio files.</p> <p class="max-w-sm text-sm pt-2 text-gray-300">Generate a .M4B audiobook file with embedded metadata, cover image, and chapters. <br /><span class="text-warning">*</span> Does not delete existing audio files.</p>
</div> </div>
<div class="flex-grow" /> <div class="flex-grow" />
<div> <div class="mt-2 md:mt-0">
<p v-if="abmergeStatus === $constants.DownloadStatus.FAILED" class="text-error mb-2">Download Failed</p> <p v-if="abmergeStatus === $constants.DownloadStatus.FAILED" class="text-error mb-2">Download Failed</p>
<p v-if="abmergeStatus === $constants.DownloadStatus.READY" class="text-success mb-2">Download Ready!</p> <p v-if="abmergeStatus === $constants.DownloadStatus.READY" class="text-success mb-2">Download Ready!</p>
<p v-if="abmergeStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p> <p v-if="abmergeStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
@@ -69,7 +69,7 @@
<p v-if="showM4bDownload" class="text-left text-base mb-4 py-4"> <p v-if="showM4bDownload" class="text-left text-base mb-4 py-4">
<span class="text-error">* <strong>Experimental</strong></span <span class="text-error">* <strong>Experimental</strong></span
>&nbsp;-&nbsp;M4b merge can take several minutes and will be stored in <span class="bg-primary bg-opacity-75 font-mono p-1 text-base">/metadata/downloads</span>. After the download is ready, it will remain available for 60 minutes, then be deleted. Download will timeout after 20 minutes. >&nbsp;-&nbsp;M4b merge can take several minutes and will be stored in <span class="bg-primary bg-opacity-75 font-mono p-1 text-base">/metadata/downloads</span>. After the download is ready, it will remain available for 60 minutes, then be deleted. Download will timeout after 30 minutes.
</p> </p>
<!-- <p v-if="isSingleM4b" class="text-lg text-center my-8">Audiobook is already a single m4b!</p> --> <!-- <p v-if="isSingleM4b" class="text-lg text-center my-8">Audiobook is already a single m4b!</p> -->
+27 -18
View File
@@ -365,21 +365,27 @@ export default {
else this.provider = localStorage.getItem('book-provider') || 'google' else this.provider = localStorage.getItem('book-provider') || 'google'
}, },
selectMatch(match) { selectMatch(match) {
if (match && match.series) { if (match) {
if (!match.series.length) { if (match.series) {
delete match.series if (!match.series.length) {
} else { delete match.series
match.series = match.series.map((se) => { } else {
return { match.series = match.series.map((se) => {
id: `new-${Math.floor(Math.random() * 10000)}`, return {
displayName: se.volumeNumber ? `${se.series} #${se.volumeNumber}` : se.series, id: `new-${Math.floor(Math.random() * 10000)}`,
name: se.series, displayName: se.volumeNumber ? `${se.series} #${se.volumeNumber}` : se.series,
sequence: se.volumeNumber || '' name: se.series,
} sequence: se.volumeNumber || ''
}) }
})
}
}
if (match.genres && Array.isArray(match.genres)) {
match.genres = match.genres.join(',')
} }
} }
console.log('Select Match', match)
this.selectedMatch = match this.selectedMatch = match
}, },
buildMatchUpdatePayload() { buildMatchUpdatePayload() {
@@ -409,11 +415,12 @@ export default {
updatePayload.metadata.series = seriesPayload updatePayload.metadata.series = seriesPayload
} else if (key === 'author' && !this.isPodcast) { } else if (key === 'author' && !this.isPodcast) {
if (!Array.isArray(this.selectedMatch[key])) { var authors = this.selectedMatch[key]
this.selectedMatch[key] = this.selectedMatch[key].split(',').map((au) => au.trim()) if (!Array.isArray(authors)) {
authors = authors.split(',').map((au) => au.trim())
} }
var authorPayload = [] var authorPayload = []
this.selectedMatch[key].forEach((authorName) => authors.forEach((authorName) =>
authorPayload.push({ authorPayload.push({
id: `new-${Math.floor(Math.random() * 10000)}`, id: `new-${Math.floor(Math.random() * 10000)}`,
name: authorName name: authorName
@@ -421,11 +428,11 @@ export default {
) )
updatePayload.metadata.authors = authorPayload updatePayload.metadata.authors = authorPayload
} else if (key === 'narrator') { } else if (key === 'narrator') {
updatePayload.metadata.narrators = [this.selectedMatch[key]] updatePayload.metadata.narrators = this.selectedMatch[key].split(',').map((v) => v.trim())
} else if (key === 'genres') { } else if (key === 'genres') {
updatePayload.metadata.genres = this.selectedMatch[key].split(',') updatePayload.metadata.genres = this.selectedMatch[key].split(',').map((v) => v.trim())
} else if (key === 'tags') { } else if (key === 'tags') {
updatePayload.tags = this.selectedMatch[key].split(',') updatePayload.tags = this.selectedMatch[key].split(',').map((v) => v.trim())
} else if (key === 'itunesId') { } else if (key === 'itunesId') {
updatePayload.metadata.itunesId = Number(this.selectedMatch[key]) updatePayload.metadata.itunesId = Number(this.selectedMatch[key])
} else { } else {
@@ -441,6 +448,8 @@ export default {
if (!Object.keys(updatePayload).length) { if (!Object.keys(updatePayload).length) {
return return
} }
console.log('Match payload', updatePayload)
this.isProcessing = true this.isProcessing = true
if (updatePayload.metadata.cover) { if (updatePayload.metadata.cover) {
@@ -27,7 +27,6 @@
<p v-if="hasEpisodesWithoutPubDate" class="w-full pt-2 text-warning text-xs">Warning: 1 or more of your episodes do not have a Pub Date. Some podcast apps require this.</p> <p v-if="hasEpisodesWithoutPubDate" class="w-full pt-2 text-warning text-xs">Warning: 1 or more of your episodes do not have a Pub Date. Some podcast apps require this.</p>
</div> </div>
<div v-show="userIsAdminOrUp" class="flex items-center pt-6"> <div v-show="userIsAdminOrUp" class="flex items-center pt-6">
<p class="text-xs text-gray-300">Note: RSS feed URLs are not authenticated</p>
<div class="flex-grow" /> <div class="flex-grow" />
<ui-btn v-if="currentFeedUrl" color="error" small @click="closeFeed">Close RSS Feed</ui-btn> <ui-btn v-if="currentFeedUrl" color="error" small @click="closeFeed">Close RSS Feed</ui-btn>
<ui-btn v-else color="success" small @click="openFeed">Open RSS Feed</ui-btn> <ui-btn v-else color="success" small @click="openFeed">Open RSS Feed</ui-btn>
@@ -3,21 +3,21 @@
<div class="flex-grow" /> <div class="flex-grow" />
<template v-if="!loading"> <template v-if="!loading">
<div class="cursor-pointer flex items-center justify-center text-gray-300 mr-4 md:mr-8" @mousedown.prevent @mouseup.prevent @click.stop="prevChapter"> <div class="cursor-pointer flex items-center justify-center text-gray-300 mr-4 md:mr-8" @mousedown.prevent @mouseup.prevent @click.stop="prevChapter">
<span class="material-icons text-3xl">first_page</span> <span class="material-icons text-2xl sm:text-3xl">first_page</span>
</div> </div>
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpBackward"> <div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpBackward">
<span class="material-icons text-3xl">replay_10</span> <span class="material-icons text-2xl sm:text-3xl">replay_10</span>
</div> </div>
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-4 md:mx-8" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPause"> <div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-4 md:mx-8" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPause">
<span class="material-icons">{{ seekLoading ? 'autorenew' : paused ? 'play_arrow' : 'pause' }}</span> <span class="material-icons">{{ seekLoading ? 'autorenew' : paused ? 'play_arrow' : 'pause' }}</span>
</div> </div>
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpForward"> <div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpForward">
<span class="material-icons text-3xl">forward_10</span> <span class="material-icons text-2xl sm:text-3xl">forward_10</span>
</div> </div>
<div class="flex items-center justify-center ml-4 md:ml-8" :class="hasNextChapter ? 'text-gray-300 cursor-pointer' : 'text-gray-500'" @mousedown.prevent @mouseup.prevent @click.stop="nextChapter"> <div class="flex items-center justify-center ml-4 md:ml-8" :class="hasNextChapter ? 'text-gray-300 cursor-pointer' : 'text-gray-500'" @mousedown.prevent @mouseup.prevent @click.stop="nextChapter">
<span class="material-icons text-3xl">last_page</span> <span class="material-icons text-2xl sm:text-3xl">last_page</span>
</div> </div>
<controls-playback-speed-control v-model="playbackRate" @input="playbackRateUpdated" @change="playbackRateChanged" /> <controls-playback-speed-control v-model="playbackRateInput" @input="playbackRateUpdated" @change="playbackRateChanged" />
</template> </template>
<template v-else> <template v-else>
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8 animate-spin"> <div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8 animate-spin">
@@ -40,7 +40,16 @@ export default {
data() { data() {
return {} return {}
}, },
computed: {}, computed: {
playbackRateInput: {
get() {
return this.playbackRate
},
set(val) {
this.$emit('update:playbackRate', val)
}
}
},
methods: { methods: {
playPause() { playPause() {
this.$emit('playPause') this.$emit('playPause')
+39 -11
View File
@@ -8,7 +8,7 @@
<div ref="trackCursor" class="h-full w-0.5 bg-gray-50 absolute top-0 left-0 opacity-0 pointer-events-none" /> <div ref="trackCursor" class="h-full w-0.5 bg-gray-50 absolute top-0 left-0 opacity-0 pointer-events-none" />
<div v-if="loading" class="h-full w-1/4 absolute left-0 top-0 loadingTrack pointer-events-none bg-white bg-opacity-25" /> <div v-if="loading" class="h-full w-1/4 absolute left-0 top-0 loadingTrack pointer-events-none bg-white bg-opacity-25" />
</div> </div>
<div ref="track" class="w-full h-2 relative overflow-hidden"> <div class="w-full h-2 relative overflow-hidden" :class="useChapterTrack ? 'opacity-0' : ''">
<template v-for="(tick, index) in chapterTicks"> <template v-for="(tick, index) in chapterTicks">
<div :key="index" :style="{ left: tick.left + 'px' }" class="absolute top-0 w-px bg-white bg-opacity-30 h-1 pointer-events-none" /> <div :key="index" :style="{ left: tick.left + 'px' }" class="absolute top-0 w-px bg-white bg-opacity-30 h-1 pointer-events-none" />
</template> </template>
@@ -34,6 +34,10 @@ export default {
chapters: { chapters: {
type: Array, type: Array,
default: () => [] default: () => []
},
currentChapter: {
type: Object,
default: () => {}
} }
}, },
data() { data() {
@@ -46,7 +50,8 @@ export default {
trackOffsetLeft: 16, // Track is 16px from edge trackOffsetLeft: 16, // Track is 16px from edge
playedTrackWidth: 0, playedTrackWidth: 0,
readyTrackWidth: 0, readyTrackWidth: 0,
bufferTrackWidth: 0 bufferTrackWidth: 0,
useChapterTrack: false
} }
}, },
watch: { watch: {
@@ -57,14 +62,30 @@ export default {
} }
} }
}, },
computed: {}, computed: {
currentChapterDuration() {
if (!this.currentChapter) return 0
return this.currentChapter.end - this.currentChapter.start
},
currentChapterStart() {
if (!this.currentChapter) return 0
return this.currentChapter.start
}
},
methods: { methods: {
setUseChapterTrack(useChapterTrack) {
this.useChapterTrack = useChapterTrack
this.updateBufferTrack()
this.updatePlayedTrackWidth()
},
clickTrack(e) { clickTrack(e) {
if (this.loading) return if (this.loading) return
var offsetX = e.offsetX var offsetX = e.offsetX
var perc = offsetX / this.trackWidth var perc = offsetX / this.trackWidth
var time = perc * this.duration const baseTime = this.useChapterTrack ? this.currentChapterStart : 0;
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration;
const time = baseTime + (perc * duration);
if (isNaN(time) || time === null) { if (isNaN(time) || time === null) {
console.error('Invalid time', perc, time) console.error('Invalid time', perc, time)
return return
@@ -76,7 +97,10 @@ export default {
this.updateBufferTrack() this.updateBufferTrack()
}, },
updateBufferTrack() { updateBufferTrack() {
var bufferlen = (this.bufferTime / this.duration) * this.trackWidth const time = this.useChapterTrack ? Math.max(0, this.bufferTime - this.currentChapterStart) : this.bufferTime
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
var bufferlen = (time / duration) * this.trackWidth
bufferlen = Math.round(bufferlen) bufferlen = Math.round(bufferlen)
if (this.bufferTrackWidth === bufferlen || !this.$refs.bufferTrack) return if (this.bufferTrackWidth === bufferlen || !this.$refs.bufferTrack) return
if (this.$refs.bufferTrack) this.$refs.bufferTrack.style.width = bufferlen + 'px' if (this.$refs.bufferTrack) this.$refs.bufferTrack.style.width = bufferlen + 'px'
@@ -97,8 +121,10 @@ export default {
this.updatePlayedTrackWidth() this.updatePlayedTrackWidth()
}, },
updatePlayedTrackWidth() { updatePlayedTrackWidth() {
var perc = this.currentTime / this.duration const time = this.useChapterTrack ? Math.max(0, this.currentTime - this.currentChapterStart) : this.currentTime
var ptWidth = Math.round(perc * this.trackWidth) const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
var ptWidth = Math.round((time / duration) * this.trackWidth)
if (this.playedTrackWidth === ptWidth) { if (this.playedTrackWidth === ptWidth) {
return return
} }
@@ -116,9 +142,11 @@ export default {
}, },
mousemoveTrack(e) { mousemoveTrack(e) {
var offsetX = e.offsetX var offsetX = e.offsetX
var time = (offsetX / this.trackWidth) * this.duration
console.log('Mousemove track', this.trackWidth, this.duration) const baseTime = this.useChapterTrack ? this.currentChapterStart : 0;
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration;
const progressTime = (offsetX / this.trackWidth) * duration;
const totalTime = baseTime + progressTime;
if (this.$refs.hoverTimestamp) { if (this.$refs.hoverTimestamp) {
var width = this.$refs.hoverTimestamp.clientWidth var width = this.$refs.hoverTimestamp.clientWidth
@@ -139,9 +167,9 @@ export default {
this.$refs.hoverTimestampArrow.style.left = posLeft + 'px' this.$refs.hoverTimestampArrow.style.left = posLeft + 'px'
} }
if (this.$refs.hoverTimestampText) { if (this.$refs.hoverTimestampText) {
var hoverText = this.$secondsToTimestamp(time) var hoverText = this.$secondsToTimestamp(progressTime)
var chapter = this.chapters.find((chapter) => chapter.start <= time && time < chapter.end) var chapter = this.chapters.find((chapter) => chapter.start <= totalTime && totalTime < chapter.end)
if (chapter && chapter.title) { if (chapter && chapter.title) {
hoverText += ` - ${chapter.title}` hoverText += ` - ${chapter.title}`
} }
+64 -18
View File
@@ -1,40 +1,48 @@
<template> <template>
<div class="w-full -mt-6"> <div class="w-full -mt-6">
<div class="w-full relative mb-1"> <div class="w-full relative mb-1">
<div class="absolute -top-10 md:top-0 right-0 md:right-2 flex items-center h-full"> <div class="absolute -top-10 md:top-0 right-0 lg:right-2 flex items-center h-full">
<!-- <span class="material-icons text-2xl cursor-pointer" @click="toggleFullscreen(true)">expand_less</span> --> <!-- <span class="material-icons text-2xl cursor-pointer" @click="toggleFullscreen(true)">expand_less</span> -->
<controls-volume-control ref="volumeControl" v-model="volume" @input="setVolume" class="mx-2 hidden md:block" /> <controls-volume-control ref="volumeControl" v-model="volume" @input="setVolume" class="mx-2 hidden md:block" />
<div class="cursor-pointer text-gray-300 mx-1 md:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showSleepTimer')"> <div class="cursor-pointer text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showSleepTimer')">
<span v-if="!sleepTimerSet" class="material-icons" style="font-size: 1.7rem">snooze</span> <span v-if="!sleepTimerSet" class="material-icons text-2xl sm:text-2.5xl">snooze</span>
<div v-else class="flex items-center"> <div v-else class="flex items-center">
<span class="material-icons text-lg text-warning">snooze</span> <span class="material-icons text-lg text-warning">snooze</span>
<p class="text-xl text-warning font-mono font-semibold text-center px-0.5 pb-0.5" style="min-width: 30px">{{ sleepTimerRemainingString }}</p> <p class="text-xl text-warning font-mono font-semibold text-center px-0.5 pb-0.5" style="min-width: 30px">{{ sleepTimerRemainingString }}</p>
</div> </div>
</div> </div>
<div v-if="!isPodcast" class="cursor-pointer text-gray-300 mx-1 md:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showBookmarks')"> <div v-if="!isPodcast" class="cursor-pointer text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showBookmarks')">
<span class="material-icons" style="font-size: 1.7rem">{{ bookmarks.length ? 'bookmarks' : 'bookmark_border' }}</span> <span class="material-icons text-2xl sm:text-2.5xl">{{ bookmarks.length ? 'bookmarks' : 'bookmark_border' }}</span>
</div> </div>
<div v-if="chapters.length" class="cursor-pointer text-gray-300 mx-1 md:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="showChapters"> <div v-if="chapters.length" class="cursor-pointer text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="showChapters">
<span class="material-icons text-3xl">format_list_bulleted</span> <span class="material-icons text-2xl sm:text-3xl">format_list_bulleted</span>
</div> </div>
<ui-tooltip v-if="chapters.length" direction="top" :text="useChapterTrack ? 'Use full track' : 'Use chapter track'">
<div class="cursor-pointer text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="setUseChapterTrack">
<span class="material-icons text-2xl sm:text-3xl transform transition-transform" :class="useChapterTrack ? 'rotate-180' : ''">timelapse</span>
</div>
</ui-tooltip>
</div> </div>
<player-playback-controls :loading="loading" :seek-loading="seekLoading" :playback-rate="playbackRate" :paused="paused" :has-next-chapter="hasNextChapter" @prevChapter="prevChapter" @nextChapter="nextChapter" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setPlaybackRate="setPlaybackRate" @playPause="playPause" /> <player-playback-controls :loading="loading" :seek-loading="seekLoading" :playback-rate.sync="playbackRate" :paused="paused" :has-next-chapter="hasNextChapter" @prevChapter="prevChapter" @nextChapter="nextChapter" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setPlaybackRate="setPlaybackRate" @playPause="playPause" />
</div> </div>
<player-track-bar ref="trackbar" :loading="loading" :chapters="chapters" :duration="duration" @seek="seek" /> <player-track-bar ref="trackbar" :loading="loading" :chapters="chapters" :duration="duration" :current-chapter="currentChapter" @seek="seek" />
<div class="flex"> <div class="flex">
<p ref="currentTimestamp" class="font-mono text-sm text-gray-100 pointer-events-auto">00:00:00</p> <p ref="currentTimestamp" class="font-mono text-xxs sm:text-sm text-gray-100 pointer-events-auto">00:00:00</p>
<p class="font-mono text-sm text-gray-100 pointer-events-auto">&nbsp;/&nbsp;{{ progressPercent }}%</p> <p class="font-mono text-sm hidden sm:block text-gray-100 pointer-events-auto">&nbsp;/&nbsp;{{ progressPercent }}%</p>
<div class="flex-grow" /> <div class="flex-grow" />
<p class="text-sm text-gray-300 pt-0.5">{{ currentChapterName }}</p> <p class="text-xs sm:text-sm text-gray-300 pt-0.5">
{{ currentChapterName }} <span v-if="useChapterTrack" class="text-xs text-gray-400">&nbsp;({{ currentChapterIndex + 1 }} of {{ chapters.length }})</span>
</p>
<div class="flex-grow" /> <div class="flex-grow" />
<p class="font-mono text-sm text-gray-100 pointer-events-auto">{{ timeRemainingPretty }}</p> <p class="font-mono text-xxs sm:text-sm text-gray-100 pointer-events-auto">{{ timeRemainingPretty }}</p>
</div> </div>
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" /> <modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
@@ -66,7 +74,8 @@ export default {
seekLoading: false, seekLoading: false,
showChaptersModal: false, showChaptersModal: false,
currentTime: 0, currentTime: 0,
duration: 0 duration: 0,
useChapterTrack: false
} }
}, },
computed: { computed: {
@@ -86,6 +95,10 @@ export default {
return this.$store.getters['user/getToken'] return this.$store.getters['user/getToken']
}, },
timeRemaining() { timeRemaining() {
if (this.useChapterTrack && this.currentChapter) {
var currChapTime = this.currentTime - this.currentChapter.start
return (this.currentChapterDuration - currChapTime) / this.playbackRate
}
return (this.duration - this.currentTime) / this.playbackRate return (this.duration - this.currentTime) / this.playbackRate
}, },
timeRemainingPretty() { timeRemainingPretty() {
@@ -95,8 +108,11 @@ export default {
return '-' + this.$secondsToTimestamp(this.timeRemaining) return '-' + this.$secondsToTimestamp(this.timeRemaining)
}, },
progressPercent() { progressPercent() {
if (!this.duration) return 0 const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
return Math.round((100 * this.currentTime) / this.duration) const time = this.useChapterTrack ? Math.max(this.currentTime - this.currentChapterStart) : this.currentTime
if (!duration) return 0
return Math.round((100 * time) / duration)
}, },
currentChapter() { currentChapter() {
return this.chapters.find((chapter) => chapter.start <= this.currentTime && this.currentTime < chapter.end) return this.chapters.find((chapter) => chapter.start <= this.currentTime && this.currentTime < chapter.end)
@@ -104,6 +120,14 @@ export default {
currentChapterName() { currentChapterName() {
return this.currentChapter ? this.currentChapter.title : '' return this.currentChapter ? this.currentChapter.title : ''
}, },
currentChapterDuration() {
if (!this.currentChapter) return 0
return this.currentChapter.end - this.currentChapter.start
},
currentChapterStart() {
if (!this.currentChapter) return 0
return this.currentChapter.start
},
isFullscreen() { isFullscreen() {
return this.$store.state.playerIsFullscreen return this.$store.state.playerIsFullscreen
}, },
@@ -192,6 +216,23 @@ export default {
this.seek(chapter.start) this.seek(chapter.start)
this.showChaptersModal = false this.showChaptersModal = false
}, },
setUseChapterTrack() {
var useChapterTrack = !this.useChapterTrack
this.useChapterTrack = useChapterTrack
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(useChapterTrack)
this.$store.dispatch('user/updateUserSettings', { useChapterTrack }).catch((err) => {
console.error('Failed to update settings', err)
})
this.updateTimestamp()
},
checkUpdateChapterTrack() {
// Changing media in player may not have chapters
if (!this.chapters.length && this.useChapterTrack) {
this.useChapterTrack = false
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack)
}
},
seek(time) { seek(time) {
this.$emit('seek', time) this.$emit('seek', time)
}, },
@@ -239,10 +280,10 @@ export default {
console.error('No timestamp el') console.error('No timestamp el')
return return
} }
var currTimeClean = this.$secondsToTimestamp(this.currentTime) const time = this.useChapterTrack ? Math.max(0, this.currentTime - this.currentChapterStart) : this.currentTime
var currTimeClean = this.$secondsToTimestamp(time)
ts.innerText = currTimeClean ts.innerText = currTimeClean
}, },
setBufferTime(bufferTime) { setBufferTime(bufferTime) {
if (this.$refs.trackbar) this.$refs.trackbar.setBufferTime(bufferTime) if (this.$refs.trackbar) this.$refs.trackbar.setBufferTime(bufferTime)
}, },
@@ -252,6 +293,11 @@ export default {
}, },
init() { init() {
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1 this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
var _useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack') || false
this.useChapterTrack = this.chapters.length ? _useChapterTrack : false
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack)
this.$emit('setPlaybackRate', this.playbackRate) this.$emit('setPlaybackRate', this.playbackRate)
}, },
settingsUpdated(settings) { settingsUpdated(settings) {
+1 -1
View File
@@ -2,7 +2,7 @@
<div id="heatmap" class="w-full"> <div id="heatmap" class="w-full">
<div class="mx-auto" :style="{ height: innerHeight + 160 + 'px', width: innerWidth + 52 + 'px' }" style="background-color: rgba(13, 17, 23, 0)"> <div class="mx-auto" :style="{ height: innerHeight + 160 + 'px', width: innerWidth + 52 + 'px' }" style="background-color: rgba(13, 17, 23, 0)">
<p class="mb-2 px-1 text-sm text-gray-200">{{ Object.values(daysListening).length }} listening sessions in the last year</p> <p class="mb-2 px-1 text-sm text-gray-200">{{ Object.values(daysListening).length }} listening sessions in the last year</p>
<div class="border border-opacity-25 rounded py-2 w-full" style="background-color: #232323" :style="{ height: innerHeight + 80 + 'px' }"> <div class="border border-white border-opacity-25 rounded py-2 w-full" style="background-color: #232323" :style="{ height: innerHeight + 80 + 'px' }">
<div :style="{ width: innerWidth + 'px', height: innerHeight + 'px' }" class="ml-10 mt-5 absolute" @mouseover="mouseover" @mouseout="mouseout"> <div :style="{ width: innerWidth + 'px', height: innerHeight + 'px' }" class="ml-10 mt-5 absolute" @mouseover="mouseover" @mouseout="mouseout">
<div v-for="dayLabel in dayLabels" :key="dayLabel.label" :style="dayLabel.style" class="absolute top-0 left-0 text-gray-300">{{ dayLabel.label }}</div> <div v-for="dayLabel in dayLabels" :key="dayLabel.label" :style="dayLabel.style" class="absolute top-0 left-0 text-gray-300">{{ dayLabel.label }}</div>
+6 -2
View File
@@ -192,7 +192,11 @@ export default {
} }
#accounts tr:nth-child(even) { #accounts tr:nth-child(even) {
background-color: #3a3a3a; background-color: #373838;
}
#accounts tr:nth-child(odd) {
background-color: #2f2f2f;
} }
#accounts tr:hover { #accounts tr:hover {
@@ -204,6 +208,6 @@ export default {
font-weight: 600; font-weight: 600;
padding-top: 5px; padding-top: 5px;
padding-bottom: 5px; padding-bottom: 5px;
background-color: #333; background-color: #272727
} }
</style> </style>
@@ -6,10 +6,10 @@
<span class="material-icons" style="font-size: 1.4rem">add</span> <span class="material-icons" style="font-size: 1.4rem">add</span>
</div> </div>
</div> </div>
<draggable v-if="libraryCopies.length" :list="libraryCopies" v-bind="dragOptions" class="list-group" draggable=".item" tag="div" @start="startDrag" @end="endDrag"> <draggable v-if="libraryCopies.length" :list="libraryCopies" v-bind="dragOptions" class="list-group" handle=".drag-handle" draggable=".item" tag="div" @start="startDrag" @end="endDrag">
<template v-for="library in libraryCopies"> <template v-for="library in libraryCopies">
<div :key="library.id" class="item"> <div :key="library.id" class="item">
<tables-library-item :library="library" :selected="currentLibraryId === library.id" :show-edit="true" :dragging="drag" @edit="editLibrary" @click="setLibrary" /> <tables-library-item :library="library" :selected="currentLibraryId === library.id" :dragging="drag" @edit="editLibrary" @click="setLibrary" />
</div> </div>
</template> </template>
</draggable> </draggable>
@@ -7,18 +7,26 @@
</svg> </svg>
<p class="text-xl font-book pl-4 hover:underline cursor-pointer" @click.stop="$emit('click', library)">{{ library.name }}</p> <p class="text-xl font-book pl-4 hover:underline cursor-pointer" @click.stop="$emit('click', library)">{{ library.name }}</p>
<div class="flex-grow" /> <div class="flex-grow" />
<ui-btn v-show="isHovering && !libraryScan" small color="success" @click.stop="scan">Scan</ui-btn> <ui-btn v-show="isHovering && !libraryScan" class="hidden md:block" small color="success" @click.stop="scan">Scan</ui-btn>
<ui-btn v-show="isHovering && !libraryScan" small color="bg" class="ml-2" @click.stop="forceScan">Force Re-Scan</ui-btn> <ui-btn v-show="isHovering && !libraryScan" small color="bg" class="ml-2 hidden md:block" @click.stop="forceScan">Force Re-Scan</ui-btn>
<ui-btn v-show="isHovering && !libraryScan && isBookLibrary" small color="bg" class="ml-2" @click.stop="matchAll">Match Books</ui-btn> <ui-btn v-show="isHovering && !libraryScan && isBookLibrary" small color="bg" class="ml-2 hidden md:block" @click.stop="matchAll">Match Books</ui-btn>
<span v-show="isHovering && !libraryScan && showEdit" class="material-icons text-xl text-gray-300 hover:text-gray-50 ml-4 cursor-pointer" @click.stop="editClick">edit</span> <span v-if="isHovering && !libraryScan" class="!hidden md:!block material-icons text-xl text-gray-300 hover:text-gray-50 ml-4 cursor-pointer" @click.stop="editClick">edit</span>
<span v-show="!libraryScan && isHovering && showEdit && !isDeleting" class="material-icons text-xl text-gray-300 ml-3" :class="isMain ? 'text-opacity-5 cursor-not-allowed' : 'hover:text-gray-50 cursor-pointer'" @click.stop="deleteClick">delete</span> <span v-if="!libraryScan && isHovering && !isDeleting" class="!hidden md:!block material-icons text-xl text-gray-300 ml-3 hover:text-gray-50 cursor-pointer" @click.stop="deleteClick">delete</span>
<!-- For mobile -->
<span v-if="!libraryScan" class="!block md:!hidden material-icons text-xl text-gray-300 ml-4 cursor-pointer" @click.stop="editClick">edit</span>
<span v-if="!libraryScan && !isDeleting" class="!block md:!hidden material-icons text-2xl text-gray-300 ml-3 cursor-pointer" @click.stop="showMenu">more_vert</span>
<div v-show="isDeleting" class="text-xl text-gray-300 ml-3 animate-spin"> <div v-show="isDeleting" class="text-xl text-gray-300 ml-3 animate-spin">
<svg viewBox="0 0 24 24" class="w-6 h-6"> <svg viewBox="0 0 24 24" class="w-6 h-6">
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" /> <path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
</svg> </svg>
</div> </div>
<span class="material-icons drag-handle text-xl text-gray-400 hover:text-gray-50 ml-4">reorder</span>
<!-- For mobile -->
<modals-dialog v-model="showMobileMenu" :title="menuTitle" :items="mobileMenuItems" @action="mobileMenuAction" />
</div> </div>
</template> </template>
@@ -30,22 +38,19 @@ export default {
default: () => {} default: () => {}
}, },
selected: Boolean, selected: Boolean,
showEdit: Boolean,
dragging: Boolean dragging: Boolean
}, },
data() { data() {
return { return {
mouseover: false, mouseover: false,
isDeleting: false isDeleting: false,
showMobileMenu: false
} }
}, },
computed: { computed: {
isHovering() { isHovering() {
return this.mouseover && !this.dragging return this.mouseover && !this.dragging
}, },
isMain() {
return this.library.id === 'main'
},
libraryScan() { libraryScan() {
return this.$store.getters['scanners/getLibraryScan'](this.library.id) return this.$store.getters['scanners/getLibraryScan'](this.library.id)
}, },
@@ -54,9 +59,50 @@ export default {
}, },
isBookLibrary() { isBookLibrary() {
return this.mediaType === 'book' return this.mediaType === 'book'
},
menuTitle() {
return this.library.name
},
mobileMenuItems() {
const items = [
{
text: 'Scan',
value: 'scan'
},
{
text: 'Force Re-Scan',
value: 'force-scan'
}
]
if (this.isBookLibrary) {
items.push({
text: 'Match Books',
value: 'match-books'
})
}
items.push({
text: 'Delete',
value: 'delete'
})
return items
} }
}, },
methods: { methods: {
mobileMenuAction(action) {
this.showMobileMenu = false
if (action === 'scan') {
this.scan()
} else if (action === 'force-scan') {
this.forceScan()
} else if (action === 'match-books') {
this.matchAll()
} else if (action === 'delete') {
this.deleteClick()
}
},
showMenu() {
this.showMobileMenu = true
},
matchAll() { matchAll() {
this.$axios this.$axios
.$post(`/api/libraries/${this.library.id}/matchall`) .$post(`/api/libraries/${this.library.id}/matchall`)
@@ -97,7 +143,6 @@ export default {
} }
}, },
deleteClick() { deleteClick() {
if (this.isMain) return
if (confirm(`Are you sure you want to permanently delete library "${this.library.name}"?`)) { if (confirm(`Are you sure you want to permanently delete library "${this.library.name}"?`)) {
this.isDeleting = true this.isDeleting = true
this.$axios this.$axios
+2 -1
View File
@@ -1,7 +1,8 @@
<template> <template>
<div> <div>
<input ref="fileInput" type="file" :accept="accept" class="hidden" @change="inputChanged" /> <input ref="fileInput" type="file" :accept="accept" class="hidden" @change="inputChanged" />
<ui-btn @click="clickUpload" color="primary" type="text"><slot /></ui-btn> <ui-btn @click="clickUpload" color="primary" class="hidden md:block" type="text"><slot /></ui-btn>
<ui-icon-btn @click="clickUpload" icon="upload" class="block md:hidden" />
</div> </div>
</template> </template>
+6 -6
View File
@@ -1,14 +1,14 @@
<template> <template>
<div v-if="currentLibrary" class="relative w-36 h-8" v-click-outside="clickOutsideObj"> <div v-if="currentLibrary" class="relative sm:w-36 h-8 px-1.5" v-click-outside="clickOutsideObj">
<button type="button" :disabled="disabled" class="relative h-full w-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu"> <button type="button" :disabled="disabled" class="w-10 sm:w-36 relative h-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm px-2 text-left text-sm focus:outline-none cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
<span class="flex items-center"> <span class="flex items-center justify-center sm:justify-start">
<widgets-library-icon :icon="currentLibraryIcon" class="mr-2" /> <widgets-library-icon :icon="currentLibraryIcon" class="sm:mr-2" />
<span class="block truncate text-sm">{{ currentLibrary.name }}</span> <span class="hidden sm:block">{{ currentLibrary.name }}</span>
</span> </span>
</button> </button>
<transition name="menu"> <transition name="menu">
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox"> <ul v-show="showMenu" class="absolute z-10 -mt-px w-36 bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox">
<template v-for="library in librariesFiltered"> <template v-for="library in librariesFiltered">
<li :key="library.id" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="selectLibrary(library)"> <li :key="library.id" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="selectLibrary(library)">
<div class="flex items-center px-3"> <div class="flex items-center px-3">
+1 -1
View File
@@ -4,7 +4,7 @@
<div ref="wrapper" class="relative"> <div ref="wrapper" class="relative">
<form @submit.prevent="submitForm"> <form @submit.prevent="submitForm">
<div ref="inputWrapper" style="min-height: 36px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-1" :class="wrapperClass" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent> <div ref="inputWrapper" style="min-height: 36px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-1" :class="wrapperClass" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
<div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 mx-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center relative"> <div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 mx-0.5 my-0.5 text-xs bg-bg flex flex-nowrap break-all items-center relative">
<div class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg bg-opacity-75 flex items-center justify-end cursor-pointer"> <div class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg bg-opacity-75 flex items-center justify-end cursor-pointer">
<span v-if="showEdit" class="material-icons text-white hover:text-warning" style="font-size: 1.1rem" @click.stop="editItem(item)">edit</span> <span v-if="showEdit" class="material-icons text-white hover:text-warning" style="font-size: 1.1rem" @click.stop="editItem(item)">edit</span>
<span class="material-icons text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item)">close</span> <span class="material-icons text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item)">close</span>
+1 -1
View File
@@ -53,7 +53,7 @@ export default {
var tooltip = document.createElement('div') var tooltip = document.createElement('div')
this.tooltipId = String(Math.floor(Math.random() * 10000)) this.tooltipId = String(Math.floor(Math.random() * 10000))
tooltip.id = this.tooltipId tooltip.id = this.tooltipId
tooltip.className = 'tooltip-wrapper absolute px-2 py-1 text-white pointer-events-none text-xs rounded shadow-lg max-w-xs' tooltip.className = 'tooltip-wrapper absolute px-2 py-1 text-white pointer-events-none text-xs rounded shadow-lg max-w-xs text-center hidden sm:block'
tooltip.style.zIndex = 100 tooltip.style.zIndex = 100
tooltip.style.backgroundColor = 'rgba(0,0,0,0.85)' tooltip.style.backgroundColor = 'rgba(0,0,0,0.85)'
tooltip.innerHTML = this.text tooltip.innerHTML = this.text
+18 -18
View File
@@ -1,21 +1,21 @@
<template> <template>
<div class="w-full h-full relative"> <div class="w-full h-full relative">
<form class="w-full h-full px-4 py-6" @submit.prevent="submitForm"> <form class="w-full h-full px-2 md:px-4 py-6" @submit.prevent="submitForm">
<div class="flex -mx-1"> <div class="flex flex-wrap -mx-1">
<div class="w-1/2 px-1"> <div class="w-full md:w-1/2 px-1">
<ui-text-input-with-label ref="titleInput" v-model="details.title" label="Title" /> <ui-text-input-with-label ref="titleInput" v-model="details.title" label="Title" />
</div> </div>
<div class="flex-grow px-1"> <div class="flex-grow px-1 mt-2 md:mt-0">
<ui-text-input-with-label ref="subtitleInput" v-model="details.subtitle" label="Subtitle" /> <ui-text-input-with-label ref="subtitleInput" v-model="details.subtitle" label="Subtitle" />
</div> </div>
</div> </div>
<div class="flex mt-2 -mx-1"> <div class="flex flex-wrap mt-2 -mx-1">
<div class="w-3/4 px-1"> <div class="w-full md:w-3/4 px-1">
<!-- Authors filter only contains authors in this library, use query input to query all authors --> <!-- Authors filter only contains authors in this library, use query input to query all authors -->
<ui-multi-select-query-input ref="authorsSelect" v-model="details.authors" label="Authors" endpoint="authors/search" /> <ui-multi-select-query-input ref="authorsSelect" v-model="details.authors" label="Authors" endpoint="authors/search" />
</div> </div>
<div class="flex-grow px-1"> <div class="flex-grow px-1 mt-2 md:mt-0">
<ui-text-input-with-label ref="publishYearInput" v-model="details.publishedYear" type="number" label="Publish Year" /> <ui-text-input-with-label ref="publishYearInput" v-model="details.publishedYear" type="number" label="Publish Year" />
</div> </div>
</div> </div>
@@ -28,35 +28,35 @@
<ui-textarea-with-label ref="descriptionInput" v-model="details.description" :rows="3" label="Description" class="mt-2" /> <ui-textarea-with-label ref="descriptionInput" v-model="details.description" :rows="3" label="Description" class="mt-2" />
<div class="flex mt-2 -mx-1"> <div class="flex flex-wrap mt-2 -mx-1">
<div class="w-1/2 px-1"> <div class="w-full md:w-1/2 px-1">
<ui-multi-select ref="genresSelect" v-model="details.genres" label="Genres" :items="genres" /> <ui-multi-select ref="genresSelect" v-model="details.genres" label="Genres" :items="genres" />
</div> </div>
<div class="flex-grow px-1"> <div class="flex-grow px-1 mt-2 md:mt-0">
<ui-multi-select ref="tagsSelect" v-model="newTags" label="Tags" :items="tags" /> <ui-multi-select ref="tagsSelect" v-model="newTags" label="Tags" :items="tags" />
</div> </div>
</div> </div>
<div class="flex mt-2 -mx-1"> <div class="flex flex-wrap mt-2 -mx-1">
<div class="w-1/2 px-1"> <div class="w-full md:w-1/2 px-1">
<ui-multi-select ref="narratorsSelect" v-model="details.narrators" label="Narrators" :items="narrators" /> <ui-multi-select ref="narratorsSelect" v-model="details.narrators" label="Narrators" :items="narrators" />
</div> </div>
<div class="w-1/4 px-1"> <div class="w-1/2 md:w-1/4 px-1 mt-2 md:mt-0">
<ui-text-input-with-label ref="isbnInput" v-model="details.isbn" label="ISBN" /> <ui-text-input-with-label ref="isbnInput" v-model="details.isbn" label="ISBN" />
</div> </div>
<div class="w-1/4 px-1"> <div class="w-1/2 md:w-1/4 px-1 mt-2 md:mt-0">
<ui-text-input-with-label ref="asinInput" v-model="details.asin" label="ASIN" /> <ui-text-input-with-label ref="asinInput" v-model="details.asin" label="ASIN" />
</div> </div>
</div> </div>
<div class="flex mt-2 -mx-1"> <div class="flex flex-wrap mt-2 -mx-1">
<div class="w-1/2 px-1"> <div class="w-full md:w-1/2 px-1">
<ui-text-input-with-label ref="publisherInput" v-model="details.publisher" label="Publisher" /> <ui-text-input-with-label ref="publisherInput" v-model="details.publisher" label="Publisher" />
</div> </div>
<div class="w-1/4 px-1"> <div class="w-1/2 md:w-1/4 px-1 mt-2 md:mt-0">
<ui-text-input-with-label ref="languageInput" v-model="details.language" label="Language" /> <ui-text-input-with-label ref="languageInput" v-model="details.language" label="Language" />
</div> </div>
<div class="flex-grow px-1 pt-6"> <div class="flex-grow px-1 pt-6 mt-2 md:mt-0">
<div class="flex justify-center"> <div class="flex justify-center">
<ui-checkbox v-model="details.explicit" label="Explicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" /> <ui-checkbox v-model="details.explicit" label="Explicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
</div> </div>
+6 -1
View File
@@ -5,5 +5,10 @@
</template> </template>
<script> <script>
export default {} export default {
mounted() {
document.body.classList.remove('app-bar', 'app-bar-and-toolbar')
document.body.classList.add('no-bars')
}
}
</script> </script>
+19 -11
View File
@@ -40,6 +40,7 @@ export default {
if (this.$store.state.selectedLibraryItems) { if (this.$store.state.selectedLibraryItems) {
this.$store.commit('setSelectedLibraryItems', []) this.$store.commit('setSelectedLibraryItems', [])
} }
this.updateBodyClass()
} }
}, },
computed: { computed: {
@@ -53,11 +54,23 @@ export default {
if (!this.$route.name) return false if (!this.$route.name) return false
return !this.$route.name.startsWith('config') && this.$store.state.libraries.currentLibraryId return !this.$route.name.startsWith('config') && this.$store.state.libraries.currentLibraryId
}, },
isShowingToolbar() {
return this.isShowingSideRail && this.$route.name !== 'upload' && this.$route.name !== 'account'
},
appContentMarginLeft() { appContentMarginLeft() {
return this.isShowingSideRail ? 80 : 0 return this.isShowingSideRail ? 80 : 0
} }
}, },
methods: { methods: {
updateBodyClass() {
if (this.isShowingToolbar) {
document.body.classList.remove('no-bars', 'app-bar')
document.body.classList.add('app-bar-and-toolbar')
} else {
document.body.classList.remove('no-bars', 'app-bar-and-toolbar')
document.body.classList.add('app-bar')
}
},
updateSocketConnectionToast(content, type, timeout) { updateSocketConnectionToast(content, type, timeout) {
if (this.socketConnectionToastId !== null && this.socketConnectionToastId !== undefined) { if (this.socketConnectionToastId !== null && this.socketConnectionToastId !== undefined) {
this.$toast.update(this.socketConnectionToastId, { content: content, options: { timeout: timeout, type: type, closeButton: false, position: 'bottom-center', onClose: () => null, closeOnClick: timeout !== null } }, false) this.$toast.update(this.socketConnectionToastId, { content: content, options: { timeout: timeout, type: type, closeButton: false, position: 'bottom-center', onClose: () => null, closeOnClick: timeout !== null } }, false)
@@ -110,13 +123,7 @@ export default {
} }
console.log('Init Payload', payload) console.log('Init Payload', payload)
if (payload.session) { if (payload.session) {
if (this.$refs.streamContainer) { this.$refs.streamContainer.sessionOpen(payload.session)
this.$refs.streamContainer.sessionOpen(payload.session)
} else {
console.warn('Stream Container not mounted')
}
}
if (payload.serverSettings) {
} }
// Start scans currently running // Start scans currently running
@@ -243,9 +250,9 @@ export default {
var existingScan = this.$store.getters['scanners/getLibraryScan'](data.id) var existingScan = this.$store.getters['scanners/getLibraryScan'](data.id)
if (existingScan && !isNaN(existingScan.toastId)) { if (existingScan && !isNaN(existingScan.toastId)) {
this.$toast.update(existingScan.toastId, { content: message, options: { timeout: 5000, type: 'success', closeButton: false, position: 'bottom-center', onClose: () => null } }, true) this.$toast.update(existingScan.toastId, { content: message, options: { timeout: 5000, type: 'success', closeButton: false, onClose: () => null } }, true)
} else { } else {
this.$toast.success(message, { timeout: 5000, position: 'bottom-center' }) this.$toast.success(message, { timeout: 5000 })
} }
this.$store.commit('scanners/remove', data) this.$store.commit('scanners/remove', data)
@@ -254,7 +261,7 @@ export default {
this.$root.socket.emit('cancel_scan', id) this.$root.socket.emit('cancel_scan', id)
}, },
scanStart(data) { scanStart(data) {
data.toastId = this.$toast(`${data.type === 'match' ? 'Matching' : 'Scanning'} "${data.name}"...`, { timeout: false, type: 'info', draggable: false, closeOnClick: false, closeButton: CloseButton, closeButtonClassName: 'cancel-scan-btn', showCloseButtonOnHover: false, position: 'bottom-center', onClose: () => this.onScanToastCancel(data.id) }) data.toastId = this.$toast(`${data.type === 'match' ? 'Matching' : 'Scanning'} "${data.name}"...`, { timeout: false, type: 'info', draggable: false, closeOnClick: false, closeButton: CloseButton, closeButtonClassName: 'cancel-scan-btn', showCloseButtonOnHover: false, onClose: () => this.onScanToastCancel(data.id) })
this.$store.commit('scanners/addUpdate', data) this.$store.commit('scanners/addUpdate', data)
}, },
scanProgress(data) { scanProgress(data) {
@@ -263,7 +270,7 @@ export default {
data.toastId = existingScan.toastId data.toastId = existingScan.toastId
this.$toast.update(existingScan.toastId, { content: `Scanning "${existingScan.name}"... ${data.progress.progress || 0}%`, options: { timeout: false } }, true) this.$toast.update(existingScan.toastId, { content: `Scanning "${existingScan.name}"... ${data.progress.progress || 0}%`, options: { timeout: false } }, true)
} else { } else {
data.toastId = this.$toast(`Scanning "${data.name}"...`, { timeout: false, type: 'info', draggable: false, closeOnClick: false, closeButton: CloseButton, closeButtonClassName: 'cancel-scan-btn', showCloseButtonOnHover: false, position: 'bottom-center', onClose: () => this.onScanToastCancel(data.id) }) data.toastId = this.$toast(`Scanning "${data.name}"...`, { timeout: false, type: 'info', draggable: false, closeOnClick: false, closeButton: CloseButton, closeButtonClassName: 'cancel-scan-btn', showCloseButtonOnHover: false, onClose: () => this.onScanToastCancel(data.id) })
} }
this.$store.commit('scanners/addUpdate', data) this.$store.commit('scanners/addUpdate', data)
@@ -527,6 +534,7 @@ export default {
this.initializeSocket() this.initializeSocket()
}, },
mounted() { mounted() {
this.updateBodyClass()
this.resize() this.resize()
window.addEventListener('resize', this.resize) window.addEventListener('resize', this.resize)
window.addEventListener('keydown', this.keyDown) window.addEventListener('keydown', this.keyDown)
+41 -11
View File
@@ -28,11 +28,11 @@ export default {
var validOtherFiles = [] var validOtherFiles = []
var ignoredFiles = [] var ignoredFiles = []
files.forEach((file) => { files.forEach((file) => {
var filetype = this.checkFileType(file.name) // var filetype = this.checkFileType(file.name)
if (!filetype) ignoredFiles.push(file) if (!file.filetype) ignoredFiles.push(file)
else { else {
file.filetype = filetype // file.filetype = filetype
if (filetype === 'audio' || (filetype === 'ebook' && mediaType === 'book')) validItemFiles.push(file) if (file.filetype === 'audio' || (file.filetype === 'ebook' && mediaType === 'book')) validItemFiles.push(file)
else validOtherFiles.push(file) else validOtherFiles.push(file)
} }
}) })
@@ -82,12 +82,18 @@ export default {
items: itemResults, items: itemResults,
ignoredFiles: ignoredFilesInRoot ignoredFiles: ignoredFilesInRoot
} }
} else { } else if (filetree.some((f) => f.filetype !== 'audio') || mediaType !== 'book') {
// Single Book drop // Single Book drop
return { return {
items: this.itemFromTreeItems(filetree, mediaType), items: this.itemFromTreeItems(filetree, mediaType),
ignoredFiles: [] ignoredFiles: []
} }
} else {
// Only audio files dropped so treat each one as an audiobook
return {
items: filetree.map((audioFile) => ({ itemFiles: [audioFile], otherFiles: [], ignoredFiles: [] })),
ignoredFiles: []
}
} }
}, },
getFilesDropped(dataTransferItems) { getFilesDropped(dataTransferItems) {
@@ -95,11 +101,12 @@ export default {
path: '/', path: '/',
items: [] items: []
} }
function traverseFileTreePromise(item, currtreemap) { function traverseFileTreePromise(item, currtreemap, checkFileType) {
return new Promise((resolve) => { return new Promise((resolve) => {
if (item.isFile) { if (item.isFile) {
item.file((file) => { item.file((file) => {
file.filepath = currtreemap.path + file.name //save full path file.filepath = currtreemap.path + file.name //save full path
file.filetype = checkFileType(file.name)
currtreemap.items.push(file) currtreemap.items.push(file)
resolve(file) resolve(file)
}) })
@@ -119,7 +126,7 @@ export default {
dirReader.readEntries((entries) => { dirReader.readEntries((entries) => {
if (entries.length > 0) { if (entries.length > 0) {
for (let entr of entries) { for (let entr of entries) {
entriesPromises.push(traverseFileTreePromise(entr, newtreemap)) entriesPromises.push(traverseFileTreePromise(entr, newtreemap, checkFileType))
} }
readEntries() readEntries()
} else { } else {
@@ -135,7 +142,7 @@ export default {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let entriesPromises = [] let entriesPromises = []
for (let it of dataTransferItems) { for (let it of dataTransferItems) {
var filetree = traverseFileTreePromise(it.webkitGetAsEntry(), treemap) var filetree = traverseFileTreePromise(it.webkitGetAsEntry(), treemap, this.checkFileType)
entriesPromises.push(filetree) entriesPromises.push(filetree)
} }
Promise.all(entriesPromises).then(() => { Promise.all(entriesPromises).then(() => {
@@ -152,7 +159,9 @@ export default {
...book ...book
} }
var firstBookFile = book.itemFiles[0] var firstBookFile = book.itemFiles[0]
if (!firstBookFile.filepath) return audiobook // No path if (!firstBookFile.filepath) {
return audiobook // No path
}
var firstBookPath = Path.dirname(firstBookFile.filepath) var firstBookPath = Path.dirname(firstBookFile.filepath)
@@ -165,6 +174,9 @@ export default {
if (dirs.length) { if (dirs.length) {
audiobook.author = dirs.pop() audiobook.author = dirs.pop()
} }
} else {
// Use file basename as title
audiobook.title = Path.basename(firstBookFile.name, Path.extname(firstBookFile.name))
} }
return audiobook return audiobook
}, },
@@ -178,7 +190,12 @@ export default {
if (!firstAudioFile.filepath) return podcast // No path if (!firstAudioFile.filepath) return podcast // No path
var firstPath = Path.dirname(firstAudioFile.filepath) var firstPath = Path.dirname(firstAudioFile.filepath)
var dirs = firstPath.split('/').filter(d => !!d && d !== '.') var dirs = firstPath.split('/').filter(d => !!d && d !== '.')
podcast.title = dirs.length > 1 ? dirs[1] : dirs[0] if (dirs.length) {
podcast.title = dirs.length > 1 ? dirs[1] : dirs[0]
} else {
podcast.title = Path.basename(firstAudioFile.name, Path.extname(firstAudioFile.name))
}
return podcast return podcast
}, },
cleanItem(item, mediaType, index) { cleanItem(item, mediaType, index) {
@@ -188,6 +205,7 @@ export default {
async getItemsFromDataTransferItems(dataTransferItems, mediaType) { async getItemsFromDataTransferItems(dataTransferItems, mediaType) {
var files = await this.getFilesDropped(dataTransferItems) var files = await this.getFilesDropped(dataTransferItems)
if (!files || !files.length) return { error: 'No files found ' } if (!files || !files.length) return { error: 'No files found ' }
var itemData = this.fileTreeToItems(files, mediaType) var itemData = this.fileTreeToItems(files, mediaType)
if (!itemData.items.length && !itemData.ignoredFiles.length) { if (!itemData.items.length && !itemData.ignoredFiles.length) {
return { error: 'Invalid file drop' } return { error: 'Invalid file drop' }
@@ -218,9 +236,12 @@ export default {
else { else {
file.filetype = filetype file.filetype = filetype
if (file.webkitRelativePath) file.filepath = file.webkitRelativePath if (file.webkitRelativePath) file.filepath = file.webkitRelativePath
else file.filepath = file.name
if (filetype === 'audio' || (filetype === 'ebook' && mediaType === 'book')) { if (filetype === 'audio' || (filetype === 'ebook' && mediaType === 'book')) {
var dir = file.filepath ? Path.dirname(file.filepath) : '' var dir = file.filepath ? Path.dirname(file.filepath) : ''
if (dir === '.') dir = ''
if (!itemMap[dir]) { if (!itemMap[dir]) {
itemMap[dir] = { itemMap[dir] = {
path: dir, path: dir,
@@ -246,8 +267,17 @@ export default {
} }
}) })
var items = []
var index = 1 var index = 1
var items = Object.values(itemMap).map(i => this.cleanItem(i, mediaType, index++)) // If book media type and all files are audio files then treat each one as an audiobook
if (itemMap[''] && !otherFiles.length && mediaType === 'book' && !itemMap[''].itemFiles.some(f => f.filetype !== 'audio')) {
items = itemMap[''].itemFiles.map((audioFile) => {
return this.cleanItem({ itemFiles: [audioFile], otherFiles: [], ignoredFiles: [] }, mediaType, index++)
})
} else {
items = Object.values(itemMap).map(i => this.cleanItem(i, mediaType, index++))
}
return { return {
items, items,
ignoredFiles: ignoredFiles ignoredFiles: ignoredFiles
+22 -3
View File
@@ -58,7 +58,8 @@ module.exports = {
buildModules: [ buildModules: [
// https://go.nuxtjs.dev/tailwindcss // https://go.nuxtjs.dev/tailwindcss
'@nuxtjs/tailwindcss', '@nuxtjs/tailwindcss',
'@nuxtjs/pwa' '@nuxtjs/pwa',
'@nuxt/postcss8'
], ],
// Modules: https://go.nuxtjs.dev/config-modules // Modules: https://go.nuxtjs.dev/config-modules
@@ -119,11 +120,21 @@ module.exports = {
sizes: "512x512" sizes: "512x512"
} }
] ]
},
workbox: {
enabled: false,
} }
}, },
// Build Configuration: https://go.nuxtjs.dev/config-build // Build Configuration: https://go.nuxtjs.dev/config-build
build: {}, build: {
postcss: {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
},
watchers: { watchers: {
webpack: { webpack: {
aggregateTimeout: 300, aggregateTimeout: 300,
@@ -133,5 +144,13 @@ module.exports = {
server: { server: {
port: process.env.NODE_ENV === 'production' ? 80 : 3000, port: process.env.NODE_ENV === 'production' ? 80 : 3000,
host: '0.0.0.0' host: '0.0.0.0'
} },
/**
* Temporary workaround for @nuxt-community/tailwindcss-module.
*
* Reported: 2022-05-23
* See: [Issue tracker](https://github.com/nuxt-community/tailwindcss-module/issues/480)
*/
devServerHandlers: [],
} }
+566 -397
View File
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.0.19", "version": "2.1.0",
"description": "Self-hosted audiobook and podcast client", "description": "Self-hosted audiobook and podcast client",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
@@ -29,8 +29,11 @@
"vuedraggable": "^2.24.3" "vuedraggable": "^2.24.3"
}, },
"devDependencies": { "devDependencies": {
"@nuxt/postcss8": "^1.1.3",
"@nuxtjs/pwa": "^3.3.5", "@nuxtjs/pwa": "^3.3.5",
"@nuxtjs/tailwindcss": "^4.2.1", "@nuxtjs/tailwindcss": "^4.2.1",
"postcss": "^8.3.6" "autoprefixer": "^10.4.7",
"postcss": "^8.3.6",
"tailwindcss": "^3.1.4"
} }
} }
+2 -2
View File
@@ -54,7 +54,7 @@ export default {
}, },
computed: { computed: {
dailyBackupsTooltip() { dailyBackupsTooltip() {
return 'Runs at 1am every day (your server time). Saved in /metadata/backups.' return 'Runs at 1:30am every day (your server time). Saved in /metadata/backups.'
}, },
maxBackupSizeTooltip() { maxBackupSizeTooltip() {
return 'As a safeguard against misconfiguration, backups will fail if they exceed the configured size.' return 'As a safeguard against misconfiguration, backups will fail if they exceed the configured size.'
@@ -74,7 +74,7 @@ export default {
return return
} }
var updatePayload = { var updatePayload = {
backupSchedule: this.dailyBackups ? '0 1 * * *' : false, backupSchedule: this.dailyBackups ? '30 1 * * *' : false,
backupsToKeep: Number(this.backupsToKeep), backupsToKeep: Number(this.backupsToKeep),
maxBackupSize: Number(this.maxBackupSize) maxBackupSize: Number(this.maxBackupSize)
} }
+220 -153
View File
@@ -1,150 +1,210 @@
<template> <template>
<div> <div>
<!-- <div class="h-0.5 bg-primary bg-opacity-50 w-full" /> --> <div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-2">
<div class="mb-2">
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
<div class="flex items-center mb-2">
<h1 class="text-xl">Settings</h1> <h1 class="text-xl">Settings</h1>
</div> </div>
<div class="flex items-center py-2"> <div class="lg:flex">
<ui-toggle-switch v-model="newServerSettings.storeCoverWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeCoverWithItem', val)" /> <div class="flex-1">
<ui-tooltip :text="tooltips.storeCoverWithItem"> <div class="pt-4">
<p class="pl-4 text-lg"> <h2 class="font-semibold">General</h2>
Store covers with item </div>
<span class="material-icons icon-text">info_outlined</span> <div class="flex items-end py-2">
</p> <ui-toggle-switch v-model="newServerSettings.storeCoverWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeCoverWithItem', val)" />
</ui-tooltip> <ui-tooltip :text="tooltips.storeCoverWithItem">
</div> <p class="pl-4">
Store covers with item
<span class="material-icons icon-text text-sm">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="flex items-center py-2"> <div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.storeMetadataWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeMetadataWithItem', val)" /> <ui-toggle-switch v-model="newServerSettings.storeMetadataWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeMetadataWithItem', val)" />
<ui-tooltip :text="tooltips.storeMetadataWithItem"> <ui-tooltip :text="tooltips.storeMetadataWithItem">
<p class="pl-4 text-lg"> <p class="pl-4">
Store metadata with item Store metadata with item
<span class="material-icons icon-text">info_outlined</span> <span class="material-icons icon-text text-sm">info_outlined</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> </div>
<div class="flex items-center py-2"> <div class="flex items-center py-2">
<ui-toggle-switch v-model="useSquareBookCovers" :disabled="updatingServerSettings" @input="updateBookCoverAspectRatio" /> <ui-toggle-switch v-model="newServerSettings.sortingIgnorePrefix" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('sortingIgnorePrefix', val)" />
<ui-tooltip :text="tooltips.coverAspectRatio"> <ui-tooltip :text="tooltips.sortingIgnorePrefix">
<p class="pl-4 text-lg"> <p class="pl-4">
Use square book covers Ignore prefixes when sorting
<span class="material-icons icon-text">info_outlined</span> <span class="material-icons icon-text text-sm">info_outlined</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> </div>
<div v-if="newServerSettings.sortingIgnorePrefix" class="w-72 ml-14 mb-2">
<ui-multi-select v-model="newServerSettings.sortingPrefixes" small :items="newServerSettings.sortingPrefixes" label="Prefixes to Ignore (case insensitive)" @input="updateSortingPrefixes" :disabled="updatingServerSettings" />
</div>
<div class="flex items-center py-2"> <div class="flex items-center py-2">
<ui-toggle-switch v-model="useAlternativeBookshelfView" :disabled="updatingServerSettings" @input="updateAlternativeBookshelfView" /> <ui-toggle-switch v-model="newServerSettings.chromecastEnabled" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('chromecastEnabled', val)" />
<ui-tooltip :text="tooltips.bookshelfView"> <p class="pl-4">Chromecast support</p>
<p class="pl-4 text-lg"> </div>
Use alternative bookshelf view
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="flex items-center py-2"> <div class="pt-4">
<ui-toggle-switch v-model="newServerSettings.sortingIgnorePrefix" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('sortingIgnorePrefix', val)" /> <h2 class="font-semibold">Display</h2>
<ui-tooltip :text="tooltips.sortingIgnorePrefix"> </div>
<p class="pl-4 text-lg">
Ignore prefixes when sorting title and series
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div v-if="newServerSettings.sortingIgnorePrefix" class="w-72 ml-14 mb-2">
<ui-multi-select v-model="newServerSettings.sortingPrefixes" small :items="newServerSettings.sortingPrefixes" label="Prefixes to Ignore (case insensitive)" @input="updateSortingPrefixes" :disabled="updatingServerSettings" />
</div>
<div class="flex items-center py-2"> <div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.chromecastEnabled" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('chromecastEnabled', val)" /> <ui-toggle-switch v-model="useSquareBookCovers" :disabled="updatingServerSettings" @input="updateBookCoverAspectRatio" />
<p class="pl-4 text-lg">Enable Chromecast</p> <ui-tooltip :text="tooltips.coverAspectRatio">
</div> <p class="pl-4">
Square book covers
<span class="material-icons icon-text text-sm">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="flex items-center mb-2 mt-8"> <div class="flex items-center py-2">
<h1 class="text-xl">Scanner Settings</h1> <ui-toggle-switch v-model="useAlternativeBookshelfView" :disabled="updatingServerSettings" @input="updateAlternativeBookshelfView" />
</div> <ui-tooltip :text="tooltips.bookshelfView">
<p class="pl-4">
Alternative bookshelf view
<span class="material-icons icon-text text-sm">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="flex items-center py-2"> <div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerParseSubtitle" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerParseSubtitle', val)" /> <p class="pr-4">Date Format</p>
<ui-tooltip :text="tooltips.scannerParseSubtitle"> <ui-dropdown v-model="newServerSettings.dateFormat" :items="dateFormats" small class="max-w-40" @input="(val) => updateSettingsKey('dateFormat', val)" />
<p class="pl-4 text-lg"> </div>
Scanner parse subtitles </div>
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="flex items-center py-2"> <div class="flex-1">
<ui-toggle-switch v-model="newServerSettings.scannerFindCovers" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerFindCovers', val)" /> <div class="pt-4">
<ui-tooltip :text="tooltips.scannerFindCovers"> <h2 class="font-semibold">Scanner</h2>
<p class="pl-4 text-lg"> </div>
Scanner find covers
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
<div class="flex-grow" />
</div>
<div v-if="newServerSettings.scannerFindCovers" class="w-44 ml-14 mb-2">
<ui-dropdown v-model="newServerSettings.scannerCoverProvider" small :items="providers" label="Cover Provider" @input="updateScannerCoverProvider" :disabled="updatingServerSettings" />
</div>
<div class="flex items-center py-2"> <div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerPreferAudioMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferAudioMetadata', val)" /> <ui-toggle-switch v-model="newServerSettings.scannerParseSubtitle" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerParseSubtitle', val)" />
<ui-tooltip :text="tooltips.scannerPreferAudioMetadata"> <ui-tooltip :text="tooltips.scannerParseSubtitle">
<p class="pl-4 text-lg"> <p class="pl-4">
Scanner prefer audio metadata Parse subtitles
<span class="material-icons icon-text">info_outlined</span> <span class="material-icons icon-text text-sm">info_outlined</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> </div>
<div class="flex items-center py-2"> <div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerPreferOpfMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferOpfMetadata', val)" /> <ui-toggle-switch v-model="newServerSettings.scannerFindCovers" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerFindCovers', val)" />
<ui-tooltip :text="tooltips.scannerPreferOpfMetadata"> <ui-tooltip :text="tooltips.scannerFindCovers">
<p class="pl-4 text-lg"> <p class="pl-4">
Scanner prefer OPF metadata Find covers
<span class="material-icons icon-text">info_outlined</span> <span class="material-icons icon-text text-sm">info_outlined</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> <div class="flex-grow" />
</div>
<div v-if="newServerSettings.scannerFindCovers" class="w-44 ml-14 mb-2">
<ui-dropdown v-model="newServerSettings.scannerCoverProvider" small :items="providers" label="Cover Provider" @input="updateScannerCoverProvider" :disabled="updatingServerSettings" />
</div>
<div class="flex items-center py-2"> <div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerPreferMatchedMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferMatchedMetadata', val)" /> <ui-toggle-switch v-model="newServerSettings.scannerPreferOverdriveMediaMarker" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferOverdriveMediaMarker', val)" />
<ui-tooltip :text="tooltips.scannerPreferMatchedMetadata"> <ui-tooltip :text="tooltips.scannerPreferOverdriveMediaMarker">
<p class="pl-4 text-lg"> <p class="pl-4">
Scanner prefer matched metadata Use Overdrive Media Markers for chapters
<span class="material-icons icon-text">info_outlined</span> <span class="material-icons icon-text text-sm">info_outlined</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> </div>
<div class="flex items-center py-2"> <div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerDisableWatcher" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerDisableWatcher', val)" /> <ui-toggle-switch v-model="newServerSettings.scannerPreferAudioMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferAudioMetadata', val)" />
<ui-tooltip :text="tooltips.scannerDisableWatcher"> <ui-tooltip :text="tooltips.scannerPreferAudioMetadata">
<p class="pl-4 text-lg"> <p class="pl-4">
Disable Watcher Prefer audio metadata
<span class="material-icons icon-text">info_outlined</span> <span class="material-icons icon-text text-sm">info_outlined</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> </div>
<div class="flex items-center mb-2 mt-8"> <div class="flex items-center py-2">
<h1 class="text-xl">Experimental Feature Settings</h1> <ui-toggle-switch v-model="newServerSettings.scannerPreferOpfMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferOpfMetadata', val)" />
</div> <ui-tooltip :text="tooltips.scannerPreferOpfMetadata">
<p class="pl-4">
Prefer OPF metadata
<span class="material-icons icon-text text-sm">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="flex items-center py-2"> <div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.enableEReader" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('enableEReader', val)" /> <ui-toggle-switch v-model="newServerSettings.scannerPreferMatchedMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferMatchedMetadata', val)" />
<ui-tooltip :text="tooltips.enableEReader"> <ui-tooltip :text="tooltips.scannerPreferMatchedMetadata">
<p class="pl-4 text-lg"> <p class="pl-4">
Enable e-reader for all users Prefer matched metadata
<span class="material-icons icon-text">info_outlined</span> <span class="material-icons icon-text text-sm">info_outlined</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerDisableWatcher" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerDisableWatcher', val)" />
<ui-tooltip :text="tooltips.scannerDisableWatcher">
<p class="pl-4">
Disable Watcher
<span class="material-icons icon-text text-sm">info_outlined</span>
</p>
</ui-tooltip>
</div>
<!-- <div class="flex items-center py-2">
<ui-text-input type="number" v-model="newServerSettings.scannerMaxThreads" no-spinner :disabled="updatingServerSettings" :padding-x="1" text-center class="w-10" @change="updateScannerMaxThreads" />
<ui-tooltip :text="tooltips.scannerMaxThreads">
<p class="pl-4">
Max # of threads to use
<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 class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerUseSingleThreadedProber" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerUseSingleThreadedProber', val)" />
<ui-tooltip :text="tooltips.scannerUseSingleThreadedProber">
<p class="pl-4">
Scanner use old single threaded audio prober
<span class="material-icons icon-text text-sm">info_outlined</span>
</p>
</ui-tooltip>
</div> -->
</div>
</div> </div>
</div> </div>
@@ -152,7 +212,7 @@
<div class="flex items-center py-4"> <div class="flex items-center py-4">
<ui-btn color="bg" small :padding-x="4" class="hidden lg:block mr-2" :loading="isPurgingCache" @click="purgeCache">Purge Cache</ui-btn> <ui-btn color="bg" small :padding-x="4" class="hidden lg:block mr-2" :loading="isPurgingCache" @click="purgeCache">Purge Cache</ui-btn>
<ui-btn color="bg" small :padding-x="4" class="hidden lg:block" :loading="isResettingLibraryItems" @click="resetLibraryItems">Remove All Library Items</ui-btn> <ui-btn color="bg" small :padding-x="4" class="hidden lg:block mr-2" :loading="isResettingLibraryItems" @click="resetLibraryItems">Remove All Library Items</ui-btn>
<div class="flex-grow" /> <div class="flex-grow" />
<p class="pr-2 text-sm font-book text-yellow-400"> <p class="pr-2 text-sm font-book text-yellow-400">
Report bugs, request features, and contribute on Report bugs, request features, and contribute on
@@ -188,30 +248,12 @@
<div class="h-0.5 bg-primary bg-opacity-30 w-full" /> <div class="h-0.5 bg-primary bg-opacity-30 w-full" />
<div class="py-12 mb-4 opacity-60 hover:opacity-100">
<div class="flex items-center">
<div>
<div class="flex items-center">
<ui-toggle-switch v-model="showExperimentalFeatures" />
<ui-tooltip :text="tooltips.experimentalFeatures">
<p class="pl-4 text-lg">
Experimental Features
<a href="https://github.com/advplyr/audiobookshelf/discussions/75" target="_blank">
<span class="material-icons icon-text">info_outlined</span>
</a>
</p>
</ui-tooltip>
</div>
</div>
</div>
</div>
<prompt-dialog v-model="showConfirmPurgeCache" :width="675"> <prompt-dialog v-model="showConfirmPurgeCache" :width="675">
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300"> <div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
<p class="text-error text-lg font-semibold">Important Notice!</p> <p class="text-error font-semibold">Important Notice!</p>
<p class="text-lg my-2 text-center">Purge cache will delete the entire directory at <span class="font-mono">/metadata/cache</span>.</p> <p class="my-2 text-center">Purge cache will delete the entire directory at <span class="font-mono">/metadata/cache</span>.</p>
<p class="text-lg text-center mb-8">Are you sure you want to remove the cache directory?</p> <p class="text-center mb-8">Are you sure you want to remove the cache directory?</p>
<div class="flex px-1 items-center"> <div class="flex px-1 items-center">
<ui-btn color="primary" @click="showConfirmPurgeCache = false">Nevermind</ui-btn> <ui-btn color="primary" @click="showConfirmPurgeCache = false">Nevermind</ui-btn>
<div class="flex-grow" /> <div class="flex-grow" />
@@ -245,7 +287,10 @@ export default {
storeCoverWithItem: 'By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named "cover" will be kept', storeCoverWithItem: 'By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named "cover" will be kept',
storeMetadataWithItem: 'By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders. Uses .abs file extension', storeMetadataWithItem: 'By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders. Uses .abs file extension',
coverAspectRatio: 'Prefer to use square covers over standard 1.6:1 book covers', coverAspectRatio: 'Prefer to use square covers over standard 1.6:1 book covers',
enableEReader: 'E-reader is still a work in progress, but use this setting to open it up to all your users (or use the "Experimental Features" toggle below just for you)' enableEReader: 'E-reader is still a work in progress, but use this setting to open it up to all your users (or use the "Experimental Features" toggle just for use by you)',
scannerPreferOverdriveMediaMarker: 'MP3 files from Overdrive come with chapter timings embedded as custom metadata. Enabling this will use these tags for chapter timings automatically',
scannerUseSingleThreadedProber: 'The old scanner used a single thread. Leaving it in to use as a comparison for now.',
scannerMaxThreads: 'Number of concurrent media files to scan at a time. Value of 1 will be a slower scan but less CPU usage. <br><br>Value of 0 defaults to # of CPU cores for this server times 2 (i.e. 4-core CPU will be 8)'
}, },
showConfirmPurgeCache: false showConfirmPurgeCache: false
} }
@@ -271,11 +316,31 @@ export default {
set(val) { set(val) {
this.$store.commit('setExperimentalFeatures', val) this.$store.commit('setExperimentalFeatures', val)
} }
},
dateFormats() {
return this.$store.state.globals.dateFormats
} }
}, },
methods: { methods: {
updateEnableChromecast(val) { updateScannerMaxThreads(val) {
this.updateServerSettings({ enableChromecast: val }) if (!val || isNaN(val)) {
this.$toast.error('Invalid max threads must be a number')
this.newServerSettings.scannerMaxThreads = 0
return
}
if (Number(val) < 0) {
this.$toast.error('Max threads must be >= 0')
this.newServerSettings.scannerMaxThreads = 0
return
}
if (Math.round(Number(val)) !== Number(val)) {
this.$toast.error('Max threads must be an integer')
this.newServerSettings.scannerMaxThreads = 0
return
}
this.updateServerSettings({
scannerMaxThreads: Number(val)
})
}, },
updateSortingPrefixes(val) { updateSortingPrefixes(val) {
if (!val || !val.length) { if (!val || !val.length) {
@@ -314,10 +379,12 @@ export default {
.then((success) => { .then((success) => {
console.log('Updated Server Settings', success) console.log('Updated Server Settings', success)
this.updatingServerSettings = false this.updatingServerSettings = false
this.$toast.success('Server settings updated')
}) })
.catch((error) => { .catch((error) => {
console.error('Failed to update server settings', error) console.error('Failed to update server settings', error)
this.updatingServerSettings = false this.updatingServerSettings = false
this.$toast.error('Failed to update server settings')
}) })
}, },
initServerSettings() { initServerSettings() {
+3 -4
View File
@@ -1,10 +1,9 @@
<template> <template>
<div> <div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
<p class="text-xl">Stats for library {{ currentLibraryName }}</p> <h1 class="text-xl">Stats for library {{ currentLibraryName }}</h1>
<stats-preview-icons v-if="totalItems" :library-stats="libraryStats" /> <stats-preview-icons v-if="totalItems" :library-stats="libraryStats" />
<div class="flex md:flex-row flex-wrap justify-between flex-col mt-12"> <div class="flex md:flex-row flex-wrap justify-between flex-col mt-8">
<div class="w-80 my-6 mx-auto"> <div class="w-80 my-6 mx-auto">
<h1 class="text-2xl mb-4 font-book">Top 5 Genres</h1> <h1 class="text-2xl mb-4 font-book">Top 5 Genres</h1>
<p v-if="!top5Genres.length">No Genres</p> <p v-if="!top5Genres.length">No Genres</p>
+12 -14
View File
@@ -1,24 +1,21 @@
<template> <template>
<div id="page-wrapper" class="page overflow-y-auto" :class="streamLibraryItem ? 'streaming' : ''"> <div class="w-full h-full">
<div class="w-full max-w-4xl mx-auto"> <div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
<div class="mb-4 flex flex-col sm:flex-row items-start sm:items-end"> <div class="flex items-center mb-2">
<p class="text-2xl mr-4 mb-2 sm:mb-0">Logger</p> <h1 class="text-xl">Logs</h1>
</div>
<div class="flex justify-between mb-2 place-items-end">
<ui-text-input ref="input" v-model="search" placeholder="Search filter.." @input="inputUpdate" clearable class="w-full sm:w-40 h-8 text-sm sm:mb-0" />
<ui-text-input ref="input" v-model="search" placeholder="Search filter.." @input="inputUpdate" clearable class="w-full sm:w-40 h-8 text-sm mb-2 sm:mb-0" /> <ui-dropdown v-model="newServerSettings.logLevel" label="Server Log Level" :items="logLevelItems" @input="logLevelUpdated" class="w-full sm:w-44" />
<div class="flex-grow" />
<div class="w-full sm:w-44">
<ui-dropdown v-model="newServerSettings.logLevel" label="Server Log Level" :items="logLevelItems" @input="logLevelUpdated" />
</div>
</div> </div>
<div class="relative"> <div class="relative">
<div ref="container" class="relative w-full h-full bg-primary border-bg overflow-x-hidden overflow-y-auto text-red shadow-inner rounded-md" style="max-height: 550px; min-height: 550px"> <div ref="container" class="relative w-full h-full bg-primary border-bg overflow-x-hidden overflow-y-auto text-red shadow-inner rounded-md" style="max-height: 800px; min-height: 550px">
<template v-for="(log, index) in logs"> <template v-for="(log, index) in logs">
<div :key="index" class="flex flex-nowrap px-2 py-1 items-start text-sm bg-opacity-10" :class="`bg-${logColors[log.level]}`"> <div :key="index" class="flex flex-nowrap px-2 py-1 items-start text-sm bg-opacity-10" :class="`bg-${logColors[log.level]}`">
<p class="text-gray-400 w-40 font-mono">{{ log.timestamp.split('.')[0].split('T').join(' ') }}</p> <p class="text-gray-400 w-36 font-mono text-xs">{{ log.timestamp }}</p>
<p class="font-semibold w-12 text-right" :class="`text-${logColors[log.level]}`">{{ log.levelName }}</p> <p class="font-semibold w-12 text-right text-sm" :class="`text-${logColors[log.level]}`">{{ log.levelName }}</p>
<p class="px-4 logmessage">{{ log.message }}</p> <p class="px-4 logmessage">{{ log.message }}</p>
</div> </div>
</template> </template>
@@ -164,6 +161,7 @@ export default {
}, },
beforeDestroy() { beforeDestroy() {
if (!this.$root.socket) return if (!this.$root.socket) return
this.$root.socket.emit('remove_log_listener')
this.$root.socket.off('daily_logs', this.dailyLogsLoaded) this.$root.socket.off('daily_logs', this.dailyLogsLoaded)
this.$root.socket.off('log', this.logEvtReceived) this.$root.socket.off('log', this.logEvtReceived)
} }
+57 -55
View File
@@ -1,60 +1,61 @@
<template> <template>
<div class="w-full h-full"> <div class="w-full h-full">
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-0 sm:p-4 mb-8"> <div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
<div class="py-2"> <div class="flex items-center mb-2">
<div class="flex items-center mb-1"> <h1 class="text-xl">Listening Sessions</h1>
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Listening Sessions</h1>
<div class="flex-grow" />
<ui-dropdown v-model="selectedUser" :items="userItems" label="Filter by User" small class="max-w-48" @input="updateUserFilter" />
</div>
<div v-if="listeningSessions.length">
<table class="userSessionsTable">
<tr class="bg-primary bg-opacity-40">
<th class="flex-grow text-left">Item</th>
<th class="w-20 text-left">User</th>
<th class="w-32 text-left hidden md:table-cell">Play Method</th>
<th class="w-40 text-left hidden sm:table-cell">Device Info</th>
<th class="w-20">Listened</th>
<th class="w-20">Last Time</th>
<th class="w-40 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">
<p class="text-sm text-gray-200">{{ session.displayTitle }}</p>
<p class="text-xs text-gray-400">{{ 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">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
</ui-tooltip>
</td>
</tr>
</table>
<div class="flex items-center justify-end py-1">
<ui-icon-btn icon="arrow_back_ios_new" :size="7" icon-font-size="1rem" class="mx-1" :disabled="currentPage === 0" @click="prevPage" />
<p class="text-sm mx-1">Page {{ currentPage + 1 }} of {{ numPages }}</p>
<ui-icon-btn icon="arrow_forward_ios" :size="7" icon-font-size="1rem" class="mx-1" :disabled="currentPage >= numPages - 1" @click="nextPage" />
</div>
</div>
<p v-else class="text-white text-opacity-50">No sessions yet...</p>
</div> </div>
<div class="flex justify-end mb-2">
<ui-dropdown v-model="selectedUser" :items="userItems" label="Filter by User" small class="max-w-48" @input="updateUserFilter" />
</div>
<div v-if="listeningSessions.length" class="block max-w-full">
<table class="userSessionsTable">
<tr class="bg-primary bg-opacity-40">
<th class="w-48 min-w-48 text-left">Item</th>
<th class="w-20 min-w-20 text-left hidden md:table-cell">User</th>
<th class="w-32 min-w-32 text-left hidden md:table-cell">Play Method</th>
<th class="w-32 min-w-32 text-left hidden sm:table-cell">Device Info</th>
<th class="w-32 min-w-32">Listened</th>
<th class="w-16 min-w-16">Last Time</th>
<th class="flex-grow hidden sm:table-cell">Last Update</th>
</tr>
<tr v-for="session in listeningSessions" :key="session.id" class="cursor-pointer" @click="showSession(session)">
<td class="py-1 max-w-48">
<p class="text-xs text-gray-200 truncate">{{ session.displayTitle }}</p>
<p class="text-xs text-gray-400 truncate">{{ session.displayAuthor }}</p>
</td>
<td class="hidden md:table-cell">
<p v-if="filteredUserUsername" class="text-xs">{{ filteredUserUsername }}</p>
<p v-else class="text-xs">{{ session.user ? session.user.username : 'N/A' }}</p>
</td>
<td class="hidden md:table-cell">
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p>
</td>
<td class="hidden sm:table-cell">
<p class="text-xs" v-html="getDeviceInfoString(session.deviceInfo)" />
</td>
<td class="text-center">
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
</td>
<td class="text-center">
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
</td>
<td class="text-center hidden sm:table-cell">
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDate(session.updatedAt, 'MMMM do, yyyy HH:mm')">
<p class="text-xs text-gray-200">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
</ui-tooltip>
</td>
</tr>
</table>
<div class="flex items-center justify-end my-2">
<ui-icon-btn icon="arrow_back_ios_new" :size="7" icon-font-size="1rem" class="mx-1" :disabled="currentPage === 0" @click="prevPage" />
<p class="text-sm mx-1">Page {{ currentPage + 1 }} of {{ numPages }}</p>
<ui-icon-btn icon="arrow_forward_ios" :size="7" icon-font-size="1rem" class="mx-1" :disabled="currentPage >= numPages - 1" @click="nextPage" />
</div>
</div>
<p v-else class="text-white text-opacity-50">No sessions yet...</p>
</div> </div>
<modals-listening-session-modal v-model="showSessionModal" :session="selectedSession" /> <modals-listening-session-modal v-model="showSessionModal" :session="selectedSession" />
@@ -166,10 +167,11 @@ export default {
} }
</script> </script>
<style> <style scoped>
.userSessionsTable { .userSessionsTable {
border-collapse: collapse; border-collapse: collapse;
width: 100%; width: 100%;
max-width: 100%;
border: 1px solid #474747; border: 1px solid #474747;
} }
.userSessionsTable tr:first-child { .userSessionsTable tr:first-child {
+7 -2
View File
@@ -1,5 +1,7 @@
<template> <template>
<div> <div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
<h1 class="text-xl">Stats for {{ username }}</h1>
<div class="flex justify-center"> <div class="flex justify-center">
<div class="flex p-2"> <div class="flex p-2">
<svg class="hidden sm:block h-14 w-14 lg:h-18 lg:w-18" viewBox="0 0 24 24"> <svg class="hidden sm:block h-14 w-14 lg:h-18 lg:w-18" viewBox="0 0 24 24">
@@ -46,7 +48,7 @@
<template v-for="(item, index) in mostRecentListeningSessions"> <template v-for="(item, index) in mostRecentListeningSessions">
<div :key="item.id" class="w-full py-0.5"> <div :key="item.id" class="w-full py-0.5">
<div class="flex items-center mb-1"> <div class="flex items-center mb-1">
<p class="text-sm font-book text-white text-opacity-70 w-6 truncate">{{ index + 1 }}.&nbsp;</p> <p class="text-sm font-book text-white text-opacity-70 w-8">{{ index + 1 }}.&nbsp;</p>
<div class="w-56"> <div class="w-56">
<p class="text-sm font-book text-white text-opacity-80 truncate">{{ item.mediaMetadata ? item.mediaMetadata.title : '' }}</p> <p class="text-sm font-book text-white text-opacity-80 truncate">{{ item.mediaMetadata ? item.mediaMetadata.title : '' }}</p>
<p class="text-xs text-white text-opacity-50">{{ $dateDistanceFromNow(item.updatedAt) }}</p> <p class="text-xs text-white text-opacity-50">{{ $dateDistanceFromNow(item.updatedAt) }}</p>
@@ -84,6 +86,9 @@ export default {
user() { user() {
return this.$store.state.user.user return this.$store.state.user.user
}, },
username() {
return this.user.username
},
currentLibraryId() { currentLibraryId() {
return this.$store.state.libraries.currentLibraryId return this.$store.state.libraries.currentLibraryId
}, },
+15 -11
View File
@@ -13,11 +13,12 @@
<widgets-online-indicator :value="!!userOnline" /> <widgets-online-indicator :value="!!userOnline" />
<h1 class="text-xl pl-2">{{ username }}</h1> <h1 class="text-xl pl-2">{{ username }}</h1>
</div> </div>
<div class="cursor-pointer text-gray-400 hover:text-white" @click="copyToClipboard(userToken)"> <div v-if="userToken" class="flex text-xs mt-4">
<p v-if="userToken" class="py-2 text-xs"> <ui-text-input-with-label label="API Token" :value="userToken" readonly />
<strong class="text-white">API Token: </strong><br /><span class="text-white">{{ userToken }}</span
><span class="material-icons pl-2 text-base">content_copy</span> <div class="px-1 mt-8 cursor-pointer" @click="copyToClipboard(userToken)">
</p> <span class="material-icons pl-2 text-base">content_copy</span>
</div>
</div> </div>
<div class="w-full h-px bg-white bg-opacity-10 my-2" /> <div class="w-full h-px bg-white bg-opacity-10 my-2" />
<div class="py-2"> <div class="py-2">
@@ -138,12 +139,15 @@ export default {
this.$copyToClipboard(str, this) this.$copyToClipboard(str, this)
}, },
async init() { async init() {
this.listeningSessions = await this.$axios.$get(`/api/users/${this.user.id}/listening-sessions?page=0&itemsPerPage=10`).then((data) => { this.listeningSessions = await this.$axios
return data.sessions || [] .$get(`/api/users/${this.user.id}/listening-sessions?page=0&itemsPerPage=10`)
}).catch((err) => { .then((data) => {
console.error('Failed to load listening sesions', err) return data.sessions || []
return [] })
}) .catch((err) => {
console.error('Failed to load listening sesions', err)
return []
})
this.listeningStats = await this.$axios.$get(`/api/users/${this.user.id}/listening-stats`).catch((err) => { this.listeningStats = await this.$axios.$get(`/api/users/${this.user.id}/listening-stats`).catch((err) => {
console.error('Failed to load listening sesions', err) console.error('Failed to load listening sesions', err)
return [] return []
+12 -11
View File
@@ -21,17 +21,17 @@
<div v-if="listeningSessions.length"> <div v-if="listeningSessions.length">
<table class="userSessionsTable"> <table class="userSessionsTable">
<tr class="bg-primary bg-opacity-40"> <tr class="bg-primary bg-opacity-40">
<th class="flex-grow text-left">Item</th> <th class="w-48 min-w-48 text-left">Item</th>
<th class="w-32 text-left hidden md:table-cell">Play Method</th> <th class="w-32 min-w-32 text-left hidden md:table-cell">Play Method</th>
<th class="w-40 text-left hidden sm:table-cell">Device Info</th> <th class="w-32 min-w-32 text-left hidden sm:table-cell">Device Info</th>
<th class="w-20">Listened</th> <th class="w-32 min-w-32">Listened</th>
<th class="w-20">Last Time</th> <th class="w-16 min-w-16">Last Time</th>
<th class="w-40 hidden sm:table-cell">Last Update</th> <th class="flex-grow hidden sm:table-cell">Last Update</th>
</tr> </tr>
<tr v-for="session in listeningSessions" :key="session.id" class="cursor-pointer" @click="showSession(session)"> <tr v-for="session in listeningSessions" :key="session.id" class="cursor-pointer" @click="showSession(session)">
<td class="py-1"> <td class="py-1 max-w-48">
<p class="text-sm text-gray-200">{{ session.displayTitle }}</p> <p class="text-xs text-gray-200 truncate">{{ session.displayTitle }}</p>
<p class="text-xs text-gray-400">{{ session.displayAuthor }}</p> <p class="text-xs text-gray-400 truncate">{{ session.displayAuthor }}</p>
</td> </td>
<td class="hidden md:table-cell"> <td class="hidden md:table-cell">
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p> <p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p>
@@ -47,7 +47,7 @@
</td> </td>
<td class="text-center hidden sm:table-cell"> <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')"> <ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDate(session.updatedAt, 'MMMM do, yyyy HH:mm')">
<p class="text-xs">{{ $dateDistanceFromNow(session.updatedAt) }}</p> <p class="text-xs text-gray-200">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
</ui-tooltip> </ui-tooltip>
</td> </td>
</tr> </tr>
@@ -149,10 +149,11 @@ export default {
} }
</script> </script>
<style> <style scoped>
.userSessionsTable { .userSessionsTable {
border-collapse: collapse; border-collapse: collapse;
width: 100%; width: 100%;
max-width: 100%;
border: 1px solid #474747; border: 1px solid #474747;
} }
.userSessionsTable tr:first-child { .userSessionsTable tr:first-child {
+7 -4
View File
@@ -118,9 +118,9 @@
<!-- Progress --> <!-- Progress -->
<div v-if="!isPodcast && progressPercent > 0" class="px-4 py-2 mt-4 bg-primary text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0" :class="resettingProgress ? 'opacity-25' : ''"> <div v-if="!isPodcast && progressPercent > 0" class="px-4 py-2 mt-4 bg-primary text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0" :class="resettingProgress ? 'opacity-25' : ''">
<p v-if="progressPercent < 1" class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p> <p v-if="progressPercent < 1" class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p>
<p v-else class="text-xs">Finished {{ $formatDate(userProgressFinishedAt, 'MM/dd/yyyy') }}</p> <p v-else class="text-xs">Finished {{ $formatDate(userProgressFinishedAt, dateFormat) }}</p>
<p v-if="progressPercent < 1" class="text-gray-200 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p> <p v-if="progressPercent < 1" class="text-gray-200 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p>
<p class="text-gray-400 text-xs pt-1">Started {{ $formatDate(userProgressStartedAt, 'MM/dd/yyyy') }}</p> <p class="text-gray-400 text-xs pt-1">Started {{ $formatDate(userProgressStartedAt, dateFormat) }}</p>
<div v-if="!resettingProgress" class="absolute -top-1.5 -right-1.5 p-1 w-5 h-5 rounded-full bg-bg hover:bg-error border border-primary flex items-center justify-center cursor-pointer" @click.stop="clearProgressClick"> <div v-if="!resettingProgress" class="absolute -top-1.5 -right-1.5 p-1 w-5 h-5 rounded-full bg-bg hover:bg-error border border-primary flex items-center justify-center cursor-pointer" @click.stop="clearProgressClick">
<span class="material-icons text-sm">close</span> <span class="material-icons text-sm">close</span>
@@ -226,6 +226,9 @@ export default {
} }
}, },
computed: { computed: {
dateFormat() {
return this.$store.state.serverSettings.dateFormat
},
showExperimentalFeatures() { showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures return this.$store.state.showExperimentalFeatures
}, },
@@ -534,13 +537,13 @@ export default {
} }
}, },
rssFeedOpen(data) { rssFeedOpen(data) {
if (data.libraryItemId === this.libraryItemId) { if (data.entityId === this.libraryItemId) {
console.log('RSS Feed Opened', data) console.log('RSS Feed Opened', data)
this.rssFeedUrl = data.feedUrl this.rssFeedUrl = data.feedUrl
} }
}, },
rssFeedClosed(data) { rssFeedClosed(data) {
if (data.libraryItemId === this.libraryItemId) { if (data.entityId === this.libraryItemId) {
console.log('RSS Feed Closed', data) console.log('RSS Feed Closed', data)
this.rssFeedUrl = null this.rssFeedUrl = null
} }
@@ -2,25 +2,26 @@
<div class="page" :class="streamLibraryItem ? 'streaming' : ''"> <div class="page" :class="streamLibraryItem ? 'streaming' : ''">
<app-book-shelf-toolbar page="podcast-search" /> <app-book-shelf-toolbar page="podcast-search" />
<div class="w-full h-full overflow-y-auto p-12 relative"> <div class="w-full h-full overflow-y-auto px-2 py-6 sm:px-4 md:p-12 relative">
<div class="w-full max-w-4xl mx-auto flex"> <div class="w-full max-w-4xl mx-auto flex">
<form @submit.prevent="submit" class="flex flex-grow"> <form @submit.prevent="submit" class="flex flex-grow">
<ui-text-input v-model="searchInput" :disabled="processing" placeholder="Enter search term or RSS feed URL" class="flex-grow mr-2" /> <ui-text-input v-model="searchInput" :disabled="processing" placeholder="Enter search term or RSS feed URL" class="flex-grow mr-2 text-sm md:text-base" />
<ui-btn type="submit" :disabled="processing">Submit</ui-btn> <ui-btn type="submit" :disabled="processing" class="hidden md:block">Submit</ui-btn>
<ui-btn type="submit" :disabled="processing" class="block md:hidden" small>Submit</ui-btn>
</form> </form>
<ui-file-input ref="fileInput" :accept="'.opml, .txt'" class="mx-2" @change="opmlFileUpload"> Upload OPML File </ui-file-input> <ui-file-input ref="fileInput" :accept="'.opml, .txt'" class="ml-2" @change="opmlFileUpload"> Upload OPML File </ui-file-input>
</div> </div>
<div class="w-full max-w-3xl mx-auto py-4"> <div class="w-full max-w-3xl mx-auto py-4">
<p v-if="termSearched && !results.length && !processing" class="text-center text-xl">No podcasts found</p> <p v-if="termSearched && !results.length && !processing" class="text-center text-xl">No podcasts found</p>
<template v-for="podcast in results"> <template v-for="podcast in results">
<div :key="podcast.id" class="flex p-1 hover:bg-primary hover:bg-opacity-25 cursor-pointer" @click="selectPodcast(podcast)"> <div :key="podcast.id" class="flex p-1 hover:bg-primary hover:bg-opacity-25 cursor-pointer" @click="selectPodcast(podcast)">
<div class="w-24 min-w-24 h-24 bg-primary"> <div class="w-20 min-w-20 h-20 md:w-24 md:min-w-24 md:h-24 bg-primary">
<img v-if="podcast.cover" :src="podcast.cover" class="h-full w-full" /> <img v-if="podcast.cover" :src="podcast.cover" class="h-full w-full" />
</div> </div>
<div class="flex-grow pl-4 max-w-2xl"> <div class="flex-grow pl-4 max-w-2xl">
<a :href="podcast.pageUrl" class="text-lg text-gray-200 hover:underline" target="_blank" @click.stop>{{ podcast.title }}</a> <a :href="podcast.pageUrl" class="text-base md:text-lg text-gray-200 hover:underline" target="_blank" @click.stop>{{ podcast.title }}</a>
<p class="text-base text-gray-300 whitespace-nowrap truncate">by {{ podcast.artistName }}</p> <p class="text-sm md:text-base text-gray-300 whitespace-nowrap truncate">by {{ podcast.artistName }}</p>
<p class="text-xs text-gray-400 leading-5">{{ podcast.genres.join(', ') }}</p> <p class="text-xs text-gray-400 leading-5">{{ podcast.genres.join(', ') }}</p>
<p class="text-xs text-gray-400 leading-5">{{ podcast.trackCount }} Episodes</p> <p class="text-xs text-gray-400 leading-5">{{ podcast.trackCount }} Episodes</p>
</div> </div>
+3 -1
View File
@@ -29,7 +29,9 @@
</div> </div>
</div> </div>
<div class="pt-8 text-center"> <div class="pt-8 text-center">
<p class="text-xs text-white text-opacity-50 font-mono"><strong>Supported File Types: </strong>{{ inputAccept.join(', ') }}</p> <p class="text-xs text-white text-opacity-50 font-mono mb-4"><strong>Supported File Types: </strong>{{ inputAccept.join(', ') }}</p>
<p class="text-sm text-white text-opacity-70">Folders with media files will be treated as separate library items. <span v-if="selectedLibraryMediaType === 'book'">If uploading only audio files then each audio file will be treated as a separate audiobook.</span></p>
</div> </div>
</div> </div>
<!-- Item list header --> <!-- Item list header -->
+2 -1
View File
@@ -71,7 +71,6 @@ export default class LocalAudioPlayer extends EventEmitter {
console.log(`[LocalPlayer] Track ended - loading next track ${this.currentTrackIndex + 1}`) console.log(`[LocalPlayer] Track ended - loading next track ${this.currentTrackIndex + 1}`)
// Has next track // Has next track
this.currentTrackIndex++ this.currentTrackIndex++
this.playWhenReady = !this.player.paused
this.startTime = this.currentTrack.startOffset this.startTime = this.currentTrack.startOffset
this.loadCurrentTrack() this.loadCurrentTrack()
} else { } else {
@@ -206,10 +205,12 @@ export default class LocalAudioPlayer extends EventEmitter {
} }
play() { play() {
this.playWhenReady = true
if (this.player) this.player.play() if (this.player) this.player.play()
} }
pause() { pause() {
this.playWhenReady = false
if (this.player) this.player.pause() if (this.player) this.player.pause()
} }
+2 -1
View File
@@ -177,12 +177,13 @@ export default class PlayerHandler {
} }
prepareOpenSession(session, playbackRate) { // Session opened on init socket prepareOpenSession(session, playbackRate) { // Session opened on init socket
if (!this.player) this.switchPlayer() // Must set player first for open sessions
this.libraryItem = session.libraryItem this.libraryItem = session.libraryItem
this.isVideo = session.libraryItem.mediaType === 'video' this.isVideo = session.libraryItem.mediaType === 'video'
this.playWhenReady = false this.playWhenReady = false
this.initialPlaybackRate = playbackRate this.initialPlaybackRate = playbackRate
if (!this.player) this.switchPlayer()
this.prepareSession(session) this.prepareSession(session)
} }
+1 -1
View File
@@ -23,7 +23,7 @@ export default (ctx) => {
var castContext = cast.framework.CastContext.getInstance() var castContext = cast.framework.CastContext.getInstance()
castContext.setOptions({ castContext.setOptions({
receiverApplicationId: process.env.chromecastReceiver, receiverApplicationId: process.env.chromecastReceiver,
autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED autoJoinPolicy: chrome.cast ? chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED : null
}); });
castContext.addEventListener( castContext.addEventListener(
+1 -1
View File
@@ -1,6 +1,6 @@
const SupportedFileTypes = { const SupportedFileTypes = {
image: ['png', 'jpg', 'jpeg', 'webp'], image: ['png', 'jpg', 'jpeg', 'webp'],
audio: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'mp4', 'aac', 'wma', 'aiff', 'wav'], audio: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav'],
ebook: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'], ebook: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'],
info: ['nfo'], info: ['nfo'],
text: ['txt'], text: ['txt'],
-1
View File
@@ -204,7 +204,6 @@ Vue.prototype.$copyToClipboard = (str, ctx) => {
}) })
} }
function xmlToJson(xml) { function xmlToJson(xml) {
const json = {}; const json = {};
for (const res of xml.matchAll(/(?:<(\w*)(?:\s[^>]*)*>)((?:(?!<\1).)*)(?:<\/\1>)|<(\w*)(?:\s*)*\/>/gm)) { for (const res of xml.matchAll(/(?:<(\w*)(?:\s[^>]*)*>)((?:(?!<\1).)*)(?:<\/\1>)|<(\w*)(?:\s*)*\/>/gm)) {
+7 -1
View File
@@ -47,8 +47,13 @@ export async function checkForUpdate() {
largestVer = verObj largestVer = verObj
} }
} }
if (verObj.version == currVerObj.version) {
currVerObj.changelog = release.body
}
}) })
} }
}) })
if (!largestVer) { if (!largestVer) {
console.error('No valid version tags to compare with') console.error('No valid version tags to compare with')
@@ -59,6 +64,7 @@ export async function checkForUpdate() {
hasUpdate: largestVer.total > currVerObj.total, hasUpdate: largestVer.total > currVerObj.total,
latestVersion: largestVer.version, latestVersion: largestVer.version,
githubTagUrl: `https://github.com/advplyr/audiobookshelf/releases/tag/v${largestVer.version}`, githubTagUrl: `https://github.com/advplyr/audiobookshelf/releases/tag/v${largestVer.version}`,
currentVersion: currVerObj.version currentVersion: currVerObj.version,
currentVersionChangelog: currVerObj.changelog
} }
} }
+44
View File
@@ -0,0 +1,44 @@
# License information
## Contribution License Agreement
If you contribute code to this project, you are implicitly allowing your code
to be distributed under the MIT license. You are also implicitly verifying that
all code is your original work. `</legalese>`
## Marked
Copyright (c) 2018+, MarkedJS (https://github.com/markedjs/)
Copyright (c) 2011-2018, Christopher Jeffrey (https://github.com/chjj/)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
## Markdown
Copyright © 2004, John Gruber
http://daringfireball.net/
All rights reserved.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
* Neither the name “Markdown” nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
This software is provided by the copyright holders and contributors “as is” and any express or implied warranties, including, but not limited to, the implied warranties of merchantability and fitness for a particular purpose are disclaimed. In no event shall the copyright owner or contributors be liable for any direct, indirect, incidental, special, exemplary, or consequential damages (including, but not limited to, procurement of substitute goods or services; loss of use, data, or profits; or business interruption) however caused and on any theory of liability, whether in contract, strict liability, or tort (including negligence or otherwise) arising in any way out of the use of this software, even if advised of the possibility of such damage.
File diff suppressed because one or more lines are too long
+15 -2
View File
@@ -1,4 +1,3 @@
export const state = () => ({ export const state = () => ({
isMobile: false, isMobile: false,
isMobileLandscape: false, isMobileLandscape: false,
@@ -12,7 +11,21 @@ export const state = () => ({
selectedCollection: null, selectedCollection: null,
selectedAuthor: null, selectedAuthor: null,
isCasting: false, // Actively casting isCasting: false, // Actively casting
isChromecastInitialized: false // Script loaded isChromecastInitialized: false, // Script loaded
dateFormats: [
{
text: 'MM/DD/YYYY',
value: 'MM/dd/yyyy'
},
{
text: 'DD/MM/YYYY',
value: 'dd/MM/yyyy'
},
{
text: 'YYYY-MM-DD',
value: 'yyyy-MM-dd'
}
]
}) })
export const getters = { export const getters = {
+4
View File
@@ -136,6 +136,10 @@ export const mutations = {
localStorage.removeItem('token') localStorage.removeItem('token')
} }
}, },
setUserToken(state, token) {
state.user.token = token
localStorage.setItem('token', user.token)
},
updateMediaProgress(state, { id, data }) { updateMediaProgress(state, { id, data }) {
if (!state.user) return if (!state.user) return
if (!data) { if (!data) {
+22 -16
View File
@@ -1,19 +1,22 @@
const defaultTheme = require('tailwindcss/defaultTheme')
module.exports = { module.exports = {
purge: { purge: {
options: { content: [
safelist: [ 'components/**/*.vue',
'bg-success', 'layouts/**/*.vue',
'bg-red-600', 'pages/**/*.vue',
'text-green-500', 'templates/**/*.vue',
'py-1.5', 'plugins/**/*.js',
'bg-info', 'nuxt.config.js'
'px-1.5' ],
] safelist: [
} 'bg-success',
'bg-red-600',
'text-green-500',
'py-1.5',
'bg-info',
'px-1.5'
],
}, },
darkMode: false,
theme: { theme: {
extend: { extend: {
height: { height: {
@@ -31,6 +34,7 @@ module.exports = {
'20': '5rem', '20': '5rem',
'24': '6rem', '24': '6rem',
'32': '8rem', '32': '8rem',
'40': '10rem',
'48': '12rem', '48': '12rem',
'64': '16rem', '64': '16rem',
'80': '20rem' '80': '20rem'
@@ -39,6 +43,7 @@ module.exports = {
'6': '1.5rem', '6': '1.5rem',
'12': '3rem', '12': '3rem',
'16': '4rem', '16': '4rem',
'20': '5rem',
'24': '6rem', '24': '6rem',
'32': '8rem', '32': '8rem',
'48': '12rem', '48': '12rem',
@@ -73,12 +78,13 @@ module.exports = {
none: 'none' none: 'none'
}, },
fontFamily: { fontFamily: {
sans: ['Source Sans Pro', ...defaultTheme.fontFamily.sans], sans: ['Source Sans Pro'],
mono: ['Ubuntu Mono', ...defaultTheme.fontFamily.mono], mono: ['Ubuntu Mono'],
book: ['Gentium Book Basic', 'serif'] book: ['Gentium Book Basic', 'serif']
}, },
fontSize: { fontSize: {
xxs: '0.625rem' xxs: '0.625rem',
'2.5xl': '1.6875rem'
}, },
zIndex: { zIndex: {
'50': 50 '50': 50
-1
View File
@@ -1,4 +1,3 @@
if (process.env.TOKEN_SECRET == null) process.env.TOKEN_SECRET = '09f26e402586e2faa8da4c98a35f1b20d6b033c6097befa8be3486a829587fe2f90a832bd3ff9d42710a4da095a2ce285b009f0c3730cd9b8e1af3eb84df6611'
const server = require('./server/Server') const server = require('./server/Server')
global.appRoot = __dirname global.appRoot = __dirname
+22 -2104
View File
File diff suppressed because it is too large Load Diff
+5 -20
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.0.19", "version": "2.1.0",
"description": "Self-hosted audiobook and podcast server", "description": "Self-hosted audiobook and podcast server",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
@@ -11,6 +11,9 @@
"build-win": "npm run client && pkg -t node16-win-x64 -o ./dist/win/audiobookshelf -C GZip .", "build-win": "npm run client && pkg -t node16-win-x64 -o ./dist/win/audiobookshelf -C GZip .",
"build-linux": "build/linuxpackager", "build-linux": "build/linuxpackager",
"docker": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 --push . -t advplyr/audiobookshelf", "docker": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 --push . -t advplyr/audiobookshelf",
"docker-amd64-local": "docker buildx build --platform linux/amd64 --load . -t advplyr/audiobookshelf-amd64-local",
"docker-arm64-local": "docker buildx build --platform linux/arm64 --load . -t advplyr/audiobookshelf-arm64-local",
"docker-armv7-local": "docker buildx build --platform linux/arm/v7 --load . -t advplyr/audiobookshelf-armv7-local",
"deploy": "node dist/autodeploy" "deploy": "node dist/autodeploy"
}, },
"bin": "prod.js", "bin": "prod.js",
@@ -27,28 +30,10 @@
"author": "advplyr", "author": "advplyr",
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"archiver": "^5.3.0",
"axios": "^0.26.1", "axios": "^0.26.1",
"bcryptjs": "^2.4.3",
"command-line-args": "^5.2.0",
"date-and-time": "^2.0.1",
"express": "^4.17.1", "express": "^4.17.1",
"express-fileupload": "^1.2.1", "graceful-fs": "^4.2.10",
"express-rate-limit": "^5.3.0",
"fast-sort": "^3.1.1",
"fluent-ffmpeg": "^2.1.2",
"fs-extra": "^10.0.0",
"htmlparser2": "^8.0.1", "htmlparser2": "^8.0.1",
"image-type": "^4.1.0",
"jsonwebtoken": "^8.5.1",
"libgen": "^2.1.0",
"node-cron": "^3.0.0",
"node-ffprobe": "^3.0.0",
"node-stream-zip": "^1.15.0",
"podcast": "^2.0.0",
"proper-lockfile": "^4.1.2",
"read-chunk": "^3.1.0",
"recursive-readdir-async": "^1.1.8",
"socket.io": "^4.4.1", "socket.io": "^4.4.1",
"xml2js": "^0.4.23" "xml2js": "^0.4.23"
} }
+1 -1
View File
@@ -6,7 +6,7 @@ const optionDefinitions = [
{ name: 'source', alias: 's', type: String } { name: 'source', alias: 's', type: String }
] ]
const commandLineArgs = require('command-line-args') const commandLineArgs = require('./server/libs/commandLineArgs')
const options = commandLineArgs(optionDefinitions) const options = commandLineArgs(optionDefinitions)
const Path = require('path') const Path = require('path')
+29 -7
View File
@@ -1,5 +1,5 @@
const bcrypt = require('bcryptjs') const bcrypt = require('./libs/bcryptjs')
const jwt = require('jsonwebtoken') const jwt = require('./libs/jsonwebtoken')
const Logger = require('./Logger') const Logger = require('./Logger')
class Auth { class Auth {
@@ -20,7 +20,9 @@ class Auth {
cors(req, res, next) { cors(req, res, next) {
res.header('Access-Control-Allow-Origin', '*') res.header('Access-Control-Allow-Origin', '*')
res.header("Access-Control-Allow-Methods", 'GET, POST, PATCH, PUT, DELETE, OPTIONS') res.header("Access-Control-Allow-Methods", 'GET, POST, PATCH, PUT, DELETE, OPTIONS')
res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Accept-Encoding, Range, Authorization") res.header('Access-Control-Allow-Headers', '*')
// TODO: Make sure allowing all headers is not a security concern. It is required for adding custom headers for SSO
// res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Accept-Encoding, Range, Authorization")
res.header('Access-Control-Allow-Credentials', true) res.header('Access-Control-Allow-Credentials', true)
if (req.method === 'OPTIONS') { if (req.method === 'OPTIONS') {
res.sendStatus(200) res.sendStatus(200)
@@ -29,6 +31,26 @@ class Auth {
} }
} }
async initTokenSecret() {
if (process.env.TOKEN_SECRET) { // User can supply their own token secret
Logger.debug(`[Auth] Setting token secret - using user passed in TOKEN_SECRET env var`)
this.db.serverSettings.tokenSecret = process.env.TOKEN_SECRET
} else {
Logger.debug(`[Auth] Setting token secret - using random bytes`)
this.db.serverSettings.tokenSecret = require('crypto').randomBytes(256).toString('base64')
}
await this.db.updateServerSettings()
// New token secret creation added in v2.1.0 so generate new API tokens for each user
if (this.db.users.length) {
for (const user of this.db.users) {
user.token = await this.generateAccessToken({ userId: user.id, username: user.username })
Logger.warn(`[Auth] User ${user.username} api token has been updated using new token secret`)
}
await this.db.updateEntities('user', this.db.users)
}
}
async authMiddleware(req, res, next) { async authMiddleware(req, res, next) {
var token = null var token = null
@@ -72,7 +94,7 @@ class Auth {
} }
generateAccessToken(payload) { generateAccessToken(payload) {
return jwt.sign(payload, process.env.TOKEN_SECRET); return jwt.sign(payload, global.ServerSettings.tokenSecret);
} }
authenticateUser(token) { authenticateUser(token) {
@@ -81,12 +103,12 @@ class Auth {
verifyToken(token) { verifyToken(token) {
return new Promise((resolve) => { return new Promise((resolve) => {
jwt.verify(token, process.env.TOKEN_SECRET, (err, payload) => { jwt.verify(token, global.ServerSettings.tokenSecret, (err, payload) => {
if (!payload || err) { if (!payload || err) {
Logger.error('JWT Verify Token Failed', err) Logger.error('JWT Verify Token Failed', err)
return resolve(null) return resolve(null)
} }
var user = this.users.find(u => u.id === payload.userId) var user = this.users.find(u => u.id === payload.userId && u.username === payload.username)
resolve(user || null) resolve(user || null)
}) })
}) })
@@ -96,7 +118,7 @@ class Auth {
return { return {
user: user.toJSONForBrowser(), user: user.toJSONForBrowser(),
userDefaultLibraryId: user.getDefaultLibraryId(this.db.libraries), userDefaultLibraryId: user.getDefaultLibraryId(this.db.libraries),
serverSettings: this.db.serverSettings.toJSON(), serverSettings: this.db.serverSettings.toJSONForBrowser(),
Source: global.Source Source: global.Source
} }
} }
+14 -17
View File
@@ -1,6 +1,5 @@
const Path = require('path') const Path = require('path')
const njodb = require('./njodb') const njodb = require('./libs/njodb')
const jwt = require('jsonwebtoken')
const Logger = require('./Logger') const Logger = require('./Logger')
const { version } = require('../package.json') const { version } = require('../package.json')
const LibraryItem = require('./objects/LibraryItem') const LibraryItem = require('./objects/LibraryItem')
@@ -22,6 +21,7 @@ class Db {
this.CollectionsPath = Path.join(global.ConfigPath, 'collections') this.CollectionsPath = Path.join(global.ConfigPath, 'collections')
this.AuthorsPath = Path.join(global.ConfigPath, 'authors') this.AuthorsPath = Path.join(global.ConfigPath, 'authors')
this.SeriesPath = Path.join(global.ConfigPath, 'series') this.SeriesPath = Path.join(global.ConfigPath, 'series')
this.FeedsPath = Path.join(global.ConfigPath, 'feeds')
this.libraryItemsDb = new njodb.Database(this.LibraryItemsPath) this.libraryItemsDb = new njodb.Database(this.LibraryItemsPath)
this.usersDb = new njodb.Database(this.UsersPath) this.usersDb = new njodb.Database(this.UsersPath)
@@ -31,6 +31,7 @@ class Db {
this.collectionsDb = new njodb.Database(this.CollectionsPath, { datastores: 2 }) this.collectionsDb = new njodb.Database(this.CollectionsPath, { datastores: 2 })
this.authorsDb = new njodb.Database(this.AuthorsPath) this.authorsDb = new njodb.Database(this.AuthorsPath)
this.seriesDb = new njodb.Database(this.SeriesPath, { datastores: 2 }) this.seriesDb = new njodb.Database(this.SeriesPath, { datastores: 2 })
this.feedsDb = new njodb.Database(this.FeedsPath, { datastores: 2 })
this.libraryItems = [] this.libraryItems = []
this.users = [] this.users = []
@@ -59,6 +60,7 @@ class Db {
else if (entityName === 'collection') return this.collectionsDb else if (entityName === 'collection') return this.collectionsDb
else if (entityName === 'author') return this.authorsDb else if (entityName === 'author') return this.authorsDb
else if (entityName === 'series') return this.seriesDb else if (entityName === 'series') return this.seriesDb
else if (entityName === 'feed') return this.feedsDb
return null return null
} }
@@ -71,6 +73,7 @@ class Db {
else if (entityName === 'collection') return 'collections' else if (entityName === 'collection') return 'collections'
else if (entityName === 'author') return 'authors' else if (entityName === 'author') return 'authors'
else if (entityName === 'series') return 'series' else if (entityName === 'series') return 'series'
else if (entityName === 'feed') return 'feeds'
return null return null
} }
@@ -83,6 +86,7 @@ class Db {
this.collectionsDb = new njodb.Database(this.CollectionsPath, { datastores: 2 }) this.collectionsDb = new njodb.Database(this.CollectionsPath, { datastores: 2 })
this.authorsDb = new njodb.Database(this.AuthorsPath) this.authorsDb = new njodb.Database(this.AuthorsPath)
this.seriesDb = new njodb.Database(this.SeriesPath, { datastores: 2 }) this.seriesDb = new njodb.Database(this.SeriesPath, { datastores: 2 })
this.feedsDb = new njodb.Database(this.FeedsPath, { datastores: 2 })
return this.init() return this.init()
} }
@@ -116,21 +120,6 @@ class Db {
async init() { async init() {
await this.load() await this.load()
// Insert Defaults
// var rootUser = this.users.find(u => u.type === 'root')
// if (!rootUser) {
// var token = await jwt.sign({ userId: 'root' }, process.env.TOKEN_SECRET)
// Logger.debug('Generated default token', token)
// Logger.info('[Db] Root user created')
// await this.insertEntity('user', this.getDefaultUser(token))
// } else {
// Logger.info(`[Db] Root user exists, pw: ${rootUser.hasPw}`)
// }
// if (!this.libraries.length) {
// await this.insertEntity('library', this.getDefaultLibrary())
// }
if (!this.serverSettings) { if (!this.serverSettings) {
this.serverSettings = new ServerSettings() this.serverSettings = new ServerSettings()
await this.insertEntity('settings', this.serverSettings) await this.insertEntity('settings', this.serverSettings)
@@ -278,6 +267,14 @@ class Db {
return this.updateEntity('settings', this.serverSettings) return this.updateEntity('settings', this.serverSettings)
} }
getAllEntities(entityName) {
var entityDb = this.getEntityDb(entityName)
return entityDb.select(() => true).then((results) => results.data).catch((error) => {
Logger.error(`[DB] Failed to get all ${entityName}`, error)
return null
})
}
insertEntities(entityName, entities) { insertEntities(entityName, entities) {
var entityDb = this.getEntityDb(entityName) var entityDb = this.getEntityDb(entityName)
return entityDb.insert(entities).then((results) => { return entityDb.insert(entities).then((results) => {
+2 -2
View File
@@ -1,16 +1,16 @@
const date = require('./libs/dateAndTime')
const { LogLevel } = require('./utils/constants') const { LogLevel } = require('./utils/constants')
class Logger { class Logger {
constructor() { constructor() {
this.logLevel = process.env.NODE_ENV === 'production' ? LogLevel.INFO : LogLevel.TRACE this.logLevel = process.env.NODE_ENV === 'production' ? LogLevel.INFO : LogLevel.TRACE
// this.logFileLevel = LogLevel.INFO
this.socketListeners = [] this.socketListeners = []
this.logManager = null this.logManager = null
} }
get timestamp() { get timestamp() {
return (new Date()).toISOString() return date.format(new Date(), 'YYYY-MM-DD HH:mm:ss')
} }
get levelString() { get levelString() {
+20 -18
View File
@@ -2,9 +2,9 @@ const Path = require('path')
const express = require('express') const express = require('express')
const http = require('http') const http = require('http')
const SocketIO = require('socket.io') const SocketIO = require('socket.io')
const fs = require('fs-extra') const fs = require('./libs/fsExtra')
const fileUpload = require('express-fileupload') const fileUpload = require('./libs/expressFileupload')
const rateLimit = require('express-rate-limit') const rateLimit = require('./libs/expressRateLimit')
const { version } = require('../package.json') const { version } = require('../package.json')
@@ -136,12 +136,19 @@ class Server {
await this.db.init() await this.db.init()
} }
// Create token secret if does not exist (Added v2.1.0)
if (!this.db.serverSettings.tokenSecret) {
await this.auth.initTokenSecret()
}
await this.checkUserMediaProgress() // Remove invalid user item progress await this.checkUserMediaProgress() // Remove invalid user item progress
await this.purgeMetadata() // Remove metadata folders without library item await this.purgeMetadata() // Remove metadata folders without library item
await this.cacheManager.ensureCachePaths() await this.cacheManager.ensureCachePaths()
await this.abMergeManager.ensureDownloadDirPath()
await this.backupManager.init() await this.backupManager.init()
await this.logManager.init() await this.logManager.init()
await this.rssFeedManager.init()
this.podcastManager.init() this.podcastManager.init()
if (this.db.serverSettings.scannerDisableWatcher) { if (this.db.serverSettings.scannerDisableWatcher) {
@@ -194,14 +201,14 @@ class Server {
// RSS Feed temp route // RSS Feed temp route
app.get('/feed/:id', (req, res) => { app.get('/feed/:id', (req, res) => {
Logger.info(`[Server] requesting rss feed ${req.params.id}`) Logger.info(`[Server] Requesting rss feed ${req.params.id}`)
this.rssFeedManager.getFeed(req, res) this.rssFeedManager.getFeed(req, res)
}) })
app.get('/feed/:id/cover', (req, res) => { app.get('/feed/:id/cover', (req, res) => {
this.rssFeedManager.getFeedCover(req, res) this.rssFeedManager.getFeedCover(req, res)
}) })
app.get('/feed/:id/item/*', (req, res) => { app.get('/feed/:id/item/:episodeId/*', (req, res) => {
Logger.info(`[Server] requesting rss feed ${req.params.id}`) Logger.debug(`[Server] Requesting rss feed episode ${req.params.id}/${req.params.episodeId}`)
this.rssFeedManager.getFeedItem(req, res) this.rssFeedManager.getFeedItem(req, res)
}) })
@@ -276,6 +283,7 @@ class Server {
// Logs // Logs
socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level)) socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level))
socket.on('remove_log_listener', () => Logger.removeSocketListener(socket.id))
socket.on('fetch_daily_logs', () => this.logManager.socketRequestDailyLogs(socket)) socket.on('fetch_daily_logs', () => this.logManager.socketRequestDailyLogs(socket))
socket.on('ping', () => { socket.on('ping', () => {
@@ -285,21 +293,21 @@ class Server {
socket.emit('pong') socket.emit('pong')
}) })
socket.on('disconnect', () => { socket.on('disconnect', (reason) => {
Logger.removeSocketListener(socket.id) Logger.removeSocketListener(socket.id)
var _client = this.clients[socket.id] var _client = this.clients[socket.id]
if (!_client) { if (!_client) {
Logger.warn('[Server] Socket disconnect, no client ' + socket.id) Logger.warn(`[Server] Socket ${socket.id} disconnect, no client (Reason: ${reason})`)
} else if (!_client.user) { } else if (!_client.user) {
Logger.info('[Server] Unauth socket disconnected ' + socket.id) Logger.info(`[Server] Unauth socket ${socket.id} disconnected (Reason: ${reason})`)
delete this.clients[socket.id] delete this.clients[socket.id]
} else { } else {
Logger.debug('[Server] User Offline ' + _client.user.username) Logger.debug('[Server] User Offline ' + _client.user.username)
this.io.emit('user_offline', _client.user.toJSONForPublic(this.playbackSessionManager.sessions, this.db.libraryItems)) this.io.emit('user_offline', _client.user.toJSONForPublic(this.playbackSessionManager.sessions, this.db.libraryItems))
const disconnectTime = Date.now() - _client.connected_at const disconnectTime = Date.now() - _client.connected_at
Logger.info(`[Server] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms`) Logger.info(`[Server] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms (Reason: ${reason})`)
delete this.clients[socket.id] delete this.clients[socket.id]
} }
}) })
@@ -311,7 +319,7 @@ class Server {
const newRoot = req.body.newRoot const newRoot = req.body.newRoot
let rootPash = newRoot.password ? await this.auth.hashPass(newRoot.password) : '' let rootPash = newRoot.password ? await this.auth.hashPass(newRoot.password) : ''
if (!rootPash) Logger.warn(`[Server] Creating root user with no password`) if (!rootPash) Logger.warn(`[Server] Creating root user with no password`)
let rootToken = await this.auth.generateAccessToken({ userId: 'root' }) let rootToken = await this.auth.generateAccessToken({ userId: 'root', username: newRoot.username })
await this.db.createRootUser(newRoot.username, rootPash, rootToken) await this.db.createRootUser(newRoot.username, rootPash, rootToken)
res.sendStatus(200) res.sendStatus(200)
@@ -449,14 +457,13 @@ class Server {
} else { } else {
Logger.debug(`[Server] User Online ${client.user.username}`) Logger.debug(`[Server] User Online ${client.user.username}`)
} }
this.io.emit('user_online', client.user.toJSONForPublic(this.playbackSessionManager.sessions, this.db.libraryItems)) this.io.emit('user_online', client.user.toJSONForPublic(this.playbackSessionManager.sessions, this.db.libraryItems))
user.lastSeen = Date.now() user.lastSeen = Date.now()
await this.db.updateEntity('user', user) await this.db.updateEntity('user', user)
const initialPayload = { const initialPayload = {
// TODO: this is sent with user auth now, update mobile app to use that then remove this
serverSettings: this.db.serverSettings.toJSON(),
metadataPath: global.MetadataPath, metadataPath: global.MetadataPath,
configPath: global.ConfigPath, configPath: global.ConfigPath,
user: client.user.toJSONForBrowser(), user: client.user.toJSONForBrowser(),
@@ -468,11 +475,6 @@ class Server {
initialPayload.usersOnline = this.usersOnline initialPayload.usersOnline = this.usersOnline
} }
client.socket.emit('init', initialPayload) client.socket.emit('init', initialPayload)
// Setup log listener for root user
if (user.type === 'root') {
Logger.addSocketListener(socket, this.db.serverSettings.logLevel || 0)
}
} }
async stop() { async stop() {
+13 -1
View File
@@ -1,6 +1,6 @@
const Logger = require('../Logger') const Logger = require('../Logger')
const { reqSupportsWebp } = require('../utils/index') const { reqSupportsWebp } = require('../utils/index')
const { createNewSortInstance } = require('fast-sort') const { createNewSortInstance } = require('../libs/fastSort')
const naturalSort = createNewSortInstance({ const naturalSort = createNewSortInstance({
comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
@@ -63,15 +63,27 @@ class AuthorController {
// If updating or removing cover image then clear cache // If updating or removing cover image then clear cache
if (payload.imagePath !== undefined && req.author.imagePath && payload.imagePath !== req.author.imagePath) { if (payload.imagePath !== undefined && req.author.imagePath && payload.imagePath !== req.author.imagePath) {
this.cacheManager.purgeImageCache(req.author.id) this.cacheManager.purgeImageCache(req.author.id)
if (!payload.imagePath) { // If removing image then remove file if (!payload.imagePath) { // If removing image then remove file
var currentImagePath = req.author.imagePath var currentImagePath = req.author.imagePath
await this.coverManager.removeFile(currentImagePath) await this.coverManager.removeFile(currentImagePath)
} else if (payload.imagePath.startsWith('http')) { // Check if image path is a url
var imageData = await this.authorFinder.saveAuthorImage(req.author.id, payload.imagePath)
if (imageData) {
req.author.imagePath = imageData.path
req.author.relImagePath = imageData.relPath
hasUpdated = hasUpdated || true;
} else {
req.author.imagePath = null
req.author.relImagePath = null
}
} }
} }
var authorNameUpdate = payload.name !== undefined && payload.name !== req.author.name var authorNameUpdate = payload.name !== undefined && payload.name !== req.author.name
var hasUpdated = req.author.update(payload) var hasUpdated = req.author.update(payload)
if (hasUpdated) { if (hasUpdated) {
if (authorNameUpdate) { // Update author name on all books if (authorNameUpdate) { // Update author name on all books
var itemsWithAuthor = this.db.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasAuthor(req.author.id)) var itemsWithAuthor = this.db.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasAuthor(req.author.id))
+14 -3
View File
@@ -1,10 +1,10 @@
const Path = require('path') const Path = require('path')
const fs = require('fs-extra') const fs = require('../libs/fsExtra')
const filePerms = require('../utils/filePerms') const filePerms = require('../utils/filePerms')
const Logger = require('../Logger') const Logger = require('../Logger')
const Library = require('../objects/Library') const Library = require('../objects/Library')
const libraryHelpers = require('../utils/libraryHelpers') const libraryHelpers = require('../utils/libraryHelpers')
const { sort, createNewSortInstance } = require('fast-sort') const { sort, createNewSortInstance } = require('../libs/fastSort')
const naturalSort = createNewSortInstance({ const naturalSort = createNewSortInstance({
comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
}) })
@@ -176,7 +176,8 @@ class LibraryController {
} }
// Handle server setting sortingIgnorePrefix // Handle server setting sortingIgnorePrefix
if (sortKey === 'media.metadata.title' && this.db.serverSettings.sortingIgnorePrefix) { const sortByTitle = sortKey === 'media.metadata.title'
if (sortByTitle && this.db.serverSettings.sortingIgnorePrefix) {
// BookMetadata.js has titleIgnorePrefix getter // BookMetadata.js has titleIgnorePrefix getter
sortKey += 'IgnorePrefix' sortKey += 'IgnorePrefix'
} }
@@ -186,6 +187,16 @@ class LibraryController {
var sortArray = [ var sortArray = [
{ {
[direction]: (li) => { [direction]: (li) => {
// When collapsing by series and sorting by title use the series name instead of the book title
if (payload.mediaType === 'book' && payload.collapseseries && li.media.metadata.seriesName) {
if (sortByTitle) {
return li.media.metadata.seriesName
} else {
// When not sorting by title always show the collapsed series at the end
return direction === 'desc' ? -1 : 'zzzz'
}
}
// Supports dot notation strings i.e. "media.metadata.title" // Supports dot notation strings i.e. "media.metadata.title"
return sortKey.split('.').reduce((a, b) => a[b], li) return sortKey.split('.').reduce((a, b) => a[b], li)
} }
+2 -2
View File
@@ -378,7 +378,7 @@ class LibraryItemController {
return res.sendStatus(500) return res.sendStatus(500)
} }
const feedData = this.rssFeedManager.openFeedForItem(req.user, req.libraryItem, req.body) const feedData = await this.rssFeedManager.openFeedForItem(req.user, req.libraryItem, req.body)
if (feedData.error) { if (feedData.error) {
return res.json({ return res.json({
success: false, success: false,
@@ -398,7 +398,7 @@ class LibraryItemController {
return res.sendStatus(500) return res.sendStatus(500)
} }
this.rssFeedManager.closeFeedForItem(req.params.id) await this.rssFeedManager.closeFeedForItem(req.params.id)
res.sendStatus(200) res.sendStatus(200)
} }
+5 -2
View File
@@ -33,7 +33,7 @@ class MeController {
// GET: api/me/progress/:id/:episodeId? // GET: api/me/progress/:id/:episodeId?
async getMediaProgress(req, res) { async getMediaProgress(req, res) {
const mediaProgress = req.user.getMediaProgress(req.id, req.episodeId || null) const mediaProgress = req.user.getMediaProgress(req.params.id, req.params.episodeId || null)
if (!mediaProgress) { if (!mediaProgress) {
return res.sendStatus(404) return res.sendStatus(404)
} }
@@ -57,6 +57,7 @@ class MeController {
if (!libraryItem) { if (!libraryItem) {
return res.status(404).send('Item not found') return res.status(404).send('Item not found')
} }
var wasUpdated = req.user.createUpdateMediaProgress(libraryItem, req.body) var wasUpdated = req.user.createUpdateMediaProgress(libraryItem, req.body)
if (wasUpdated) { if (wasUpdated) {
await this.db.updateEntity('user', req.user) await this.db.updateEntity('user', req.user)
@@ -189,6 +190,7 @@ class MeController {
const updatedLocalMediaProgress = [] const updatedLocalMediaProgress = []
var numServerProgressUpdates = 0 var numServerProgressUpdates = 0
var localMediaProgress = req.body.localMediaProgress || [] var localMediaProgress = req.body.localMediaProgress || []
localMediaProgress.forEach(localProgress => { localMediaProgress.forEach(localProgress => {
if (!localProgress.libraryItemId) { if (!localProgress.libraryItemId) {
Logger.error(`[MeController] syncLocalMediaProgress invalid local media progress object`, localProgress) Logger.error(`[MeController] syncLocalMediaProgress invalid local media progress object`, localProgress)
@@ -215,7 +217,8 @@ class MeController {
Logger.debug(`[MeController] syncLocalMediaProgress server progress is more recent by ${updateTimeDifference}ms - ${mediaProgress.id}`) Logger.debug(`[MeController] syncLocalMediaProgress server progress is more recent by ${updateTimeDifference}ms - ${mediaProgress.id}`)
for (const key in localProgress) { for (const key in localProgress) {
if (mediaProgress[key] != undefined && localProgress[key] !== mediaProgress[key]) { // Local media progress ID uses the local library item id and server media progress uses the library item id
if (key !== 'id' && mediaProgress[key] != undefined && localProgress[key] !== mediaProgress[key]) {
// Logger.debug(`[MeController] syncLocalMediaProgress key ${key} changed from ${localProgress[key]} to ${mediaProgress[key]} - ${mediaProgress.id}`) // Logger.debug(`[MeController] syncLocalMediaProgress key ${key} changed from ${localProgress[key]} to ${mediaProgress[key]} - ${mediaProgress.id}`)
localProgress[key] = mediaProgress[key] localProgress[key] = mediaProgress[key]
} }
+2 -2
View File
@@ -1,5 +1,5 @@
const Path = require('path') const Path = require('path')
const fs = require('fs-extra') const fs = require('../libs/fsExtra')
const Logger = require('../Logger') const Logger = require('../Logger')
const filePerms = require('../utils/filePerms') const filePerms = require('../utils/filePerms')
@@ -242,7 +242,7 @@ class MiscController {
const userResponse = { const userResponse = {
user: req.user, user: req.user,
userDefaultLibraryId: req.user.getDefaultLibraryId(this.db.libraries), userDefaultLibraryId: req.user.getDefaultLibraryId(this.db.libraries),
serverSettings: this.db.serverSettings.toJSON(), serverSettings: this.db.serverSettings.toJSONForBrowser(),
Source: global.Source Source: global.Source
} }
res.json(userResponse) res.json(userResponse)
+2 -2
View File
@@ -1,5 +1,5 @@
const axios = require('axios') const axios = require('axios')
const fs = require('fs-extra') const fs = require('../libs/fsExtra')
const Path = require('path') const Path = require('path')
const Logger = require('../Logger') const Logger = require('../Logger')
const { parsePodcastRssFeedXml } = require('../utils/podcastUtils') const { parsePodcastRssFeedXml } = require('../utils/podcastUtils')
@@ -103,7 +103,7 @@ class PodcastController {
Logger.error('Invalid podcast feed request response') Logger.error('Invalid podcast feed request response')
return res.status(500).send('Bad response from feed request') return res.status(500).send('Bad response from feed request')
} }
Logger.debug(`[PdocastController] Podcast feed size ${(data.data.length / 1024 / 1024).toFixed(2)}MB`) Logger.debug(`[PodcastController] Podcast feed size ${(data.data.length / 1024 / 1024).toFixed(2)}MB`)
var payload = await parsePodcastRssFeedXml(data.data, false, includeRaw) var payload = await parsePodcastRssFeedXml(data.data, false, includeRaw)
if (!payload) { if (!payload) {
return res.status(500).send('Invalid podcast RSS feed') return res.status(500).send('Invalid podcast RSS feed')
+7 -1
View File
@@ -43,7 +43,7 @@ class UserController {
account.id = getId('usr') account.id = getId('usr')
account.pash = await this.auth.hashPass(account.password) account.pash = await this.auth.hashPass(account.password)
delete account.password delete account.password
account.token = await this.auth.generateAccessToken({ userId: account.id }) account.token = await this.auth.generateAccessToken({ userId: account.id, username })
account.createdAt = Date.now() account.createdAt = Date.now()
var newUser = new User(account) var newUser = new User(account)
var success = await this.db.insertEntity('user', newUser) var success = await this.db.insertEntity('user', newUser)
@@ -74,12 +74,14 @@ class UserController {
} }
var account = req.body var account = req.body
var shouldUpdateToken = false
if (account.username !== undefined && account.username !== user.username) { if (account.username !== undefined && account.username !== user.username) {
var usernameExists = this.db.users.find(u => u.username.toLowerCase() === account.username.toLowerCase()) var usernameExists = this.db.users.find(u => u.username.toLowerCase() === account.username.toLowerCase())
if (usernameExists) { if (usernameExists) {
return res.status(500).send('Username already taken') return res.status(500).send('Username already taken')
} }
shouldUpdateToken = true
} }
// Updating password // Updating password
@@ -90,6 +92,10 @@ class UserController {
var hasUpdated = user.update(account) var hasUpdated = user.update(account)
if (hasUpdated) { if (hasUpdated) {
if (shouldUpdateToken) {
user.token = await this.auth.generateAccessToken({ userId: user.id, username: user.username })
Logger.info(`[UserController] User ${user.username} was generated a new api token`)
}
await this.db.updateEntity('user', user) await this.db.updateEntity('user', user)
} }
+7 -2
View File
@@ -1,9 +1,10 @@
const fs = require('fs-extra') const fs = require('../libs/fsExtra')
const Logger = require('../Logger') const Logger = require('../Logger')
const Path = require('path') const Path = require('path')
const Audnexus = require('../providers/Audnexus') const Audnexus = require('../providers/Audnexus')
const { downloadFile } = require('../utils/fileUtils') const { downloadFile } = require('../utils/fileUtils')
const filePerms = require('../utils/filePerms')
class AuthorFinder { class AuthorFinder {
constructor() { constructor() {
@@ -38,7 +39,11 @@ class AuthorFinder {
async saveAuthorImage(authorId, url) { async saveAuthorImage(authorId, url) {
var authorDir = this.AuthorPath var authorDir = this.AuthorPath
var relAuthorDir = Path.posix.join('/metadata', 'authors') var relAuthorDir = Path.posix.join('/metadata', 'authors')
await fs.ensureDir(authorDir)
if (!await fs.pathExists(authorDir)) {
await fs.ensureDir(authorDir)
await filePerms.setDefault(authorDir)
}
var imageExtension = url.toLowerCase().split('.').pop() var imageExtension = url.toLowerCase().split('.').pop()
var ext = imageExtension === 'png' ? 'png' : 'jpg' var ext = imageExtension === 'png' ? 'png' : 'jpg'
+16 -37
View File
@@ -1,5 +1,4 @@
const OpenLibrary = require('../providers/OpenLibrary') const OpenLibrary = require('../providers/OpenLibrary')
const LibGen = require('../providers/LibGen')
const GoogleBooks = require('../providers/GoogleBooks') const GoogleBooks = require('../providers/GoogleBooks')
const Audible = require('../providers/Audible') const Audible = require('../providers/Audible')
const iTunes = require('../providers/iTunes') const iTunes = require('../providers/iTunes')
@@ -10,7 +9,6 @@ const { levenshteinDistance } = require('../utils/index')
class BookFinder { class BookFinder {
constructor() { constructor() {
this.openLibrary = new OpenLibrary() this.openLibrary = new OpenLibrary()
this.libGen = new LibGen()
this.googleBooks = new GoogleBooks() this.googleBooks = new GoogleBooks()
this.audible = new Audible() this.audible = new Audible()
this.iTunesApi = new iTunes() this.iTunesApi = new iTunes()
@@ -123,20 +121,6 @@ class BookFinder {
}) })
} }
async getLibGenResults(title, author, maxTitleDistance, maxAuthorDistance) {
var books = await this.libGen.search(title)
if (this.verbose) Logger.debug(`LibGen Book Search Results: ${books.length || 0}`)
if (books.errorCode) {
Logger.error(`LibGen Search Error ${books.errorCode}`)
return []
}
var booksFiltered = this.filterSearchResults(books, title, author, maxTitleDistance, maxAuthorDistance)
if (!booksFiltered.length && books.length) {
if (this.verbose) Logger.debug(`Search has ${books.length} matches, but no close title matches`)
}
return booksFiltered
}
async getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance) { async getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance) {
var books = await this.openLibrary.searchTitle(title) var books = await this.openLibrary.searchTitle(title)
if (this.verbose) Logger.debug(`OpenLib Book Search Results: ${books.length || 0}`) if (this.verbose) Logger.debug(`OpenLib Book Search Results: ${books.length || 0}`)
@@ -180,34 +164,29 @@ class BookFinder {
Logger.debug(`Book Search: title: "${title}", author: "${author}", provider: ${provider}`) Logger.debug(`Book Search: title: "${title}", author: "${author}", provider: ${provider}`)
if (provider === 'google') { if (provider === 'google') {
return this.getGoogleBooksResults(title, author) books = await this.getGoogleBooksResults(title, author)
} else if (provider === 'audible') { } else if (provider === 'audible') {
return this.getAudibleResults(title, author, asin) books = await this.getAudibleResults(title, author, asin)
} else if (provider === 'itunes') { } else if (provider === 'itunes') {
return this.getiTunesAudiobooksResults(title, author) books = await this.getiTunesAudiobooksResults(title, author)
} else if (provider === 'libgen') {
books = await this.getLibGenResults(title, author, maxTitleDistance, maxAuthorDistance)
} else if (provider === 'openlibrary') { } else if (provider === 'openlibrary') {
books = await this.getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance) books = await this.getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance)
} else if (provider === 'all') {
var lbBooks = await this.getLibGenResults(title, author, maxTitleDistance, maxAuthorDistance)
var olBooks = await this.getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance)
books = books.concat(lbBooks, olBooks)
} else { } else {
books = await this.getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance) books = await this.getGoogleBooksResults(title, author)
var hasCloseMatch = books.find(b => (b.totalDistance < 2 && b.totalPossibleDistance > 6))
if (!hasCloseMatch) {
Logger.debug(`Book Search, openlib has no super close matches - get libgen results also`)
var lbBooks = await this.getLibGenResults(title, author, maxTitleDistance, maxAuthorDistance)
books = books.concat(lbBooks)
}
if (!books.length && author && options.fallbackTitleOnly) {
Logger.debug(`Book Search, no matches for title and author.. check title only`)
return this.search(provider, title, null, options)
}
} }
if (!books.length && !options.currentlyTryingCleaned) {
var cleanedTitle = this.cleanTitleForCompares(title)
var cleanedAuthor = this.cleanAuthorForCompares(author)
if (cleanedTitle == title && cleanedAuthor == author) return books
Logger.debug(`Book Search, no matches.. checking cleaned title and author`)
options.currentlyTryingCleaned = true
return this.search(provider, cleanedTitle, cleanedAuthor, isbn, asin, options)
}
if (["google", "audible", "itunes"].includes(provider)) return books
return books.sort((a, b) => { return books.sort((a, b) => {
return a.totalDistance - b.totalDistance return a.totalDistance - b.totalDistance
}) })
+22
View File
@@ -0,0 +1,22 @@
Copyright (c) 2012-2014 Chris Talkington, contributors.
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,21 @@
(MIT)
Copyright (c) 2013 Julian Gruber &lt;julian@juliangruber.com&gt;
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,81 @@
'use strict'
/**
* @param {string | RegExp} a
* @param {string | RegExp} b
* @param {string} str
*/
function balanced(a, b, str) {
if (a instanceof RegExp) a = maybeMatch(a, str)
if (b instanceof RegExp) b = maybeMatch(b, str)
const r = range(a, b, str)
return (
r && {
start: r[0],
end: r[1],
pre: str.slice(0, r[0]),
body: str.slice(r[0] + a.length, r[1]),
post: str.slice(r[1] + b.length)
}
)
}
/**
* @param {RegExp} reg
* @param {string} str
*/
function maybeMatch(reg, str) {
const m = str.match(reg)
return m ? m[0] : null
}
balanced.range = range
/**
* @param {string} a
* @param {string} b
* @param {string} str
*/
function range(a, b, str) {
let begs, beg, left, right, result
let ai = str.indexOf(a)
let bi = str.indexOf(b, ai + 1)
let i = ai
if (ai >= 0 && bi > 0) {
if (a === b) {
return [ai, bi]
}
begs = []
left = str.length
while (i >= 0 && !result) {
if (i === ai) {
begs.push(i)
ai = str.indexOf(a, i + 1)
} else if (begs.length === 1) {
result = [begs.pop(), bi]
} else {
beg = begs.pop()
if (beg < left) {
left = beg
right = bi
}
bi = str.indexOf(b, i + 1)
}
i = ai < bi && ai >= 0 ? ai : bi
}
if (begs.length) {
result = [left, right]
}
}
return result
}
module.exports = balanced
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2013 Julian Gruber <julian@juliangruber.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,235 @@
const balanced = require('../balancedMatch');
const escSlash = '\0SLASH' + Math.random() + '\0';
const escOpen = '\0OPEN' + Math.random() + '\0';
const escClose = '\0CLOSE' + Math.random() + '\0';
const escComma = '\0COMMA' + Math.random() + '\0';
const escPeriod = '\0PERIOD' + Math.random() + '\0';
/**
* @return {number}
*/
function numeric(str) {
return parseInt(str, 10) == str
? parseInt(str, 10)
: str.charCodeAt(0);
}
/**
* @param {string} str
*/
function escapeBraces(str) {
return str.split('\\\\').join(escSlash)
.split('\\{').join(escOpen)
.split('\\}').join(escClose)
.split('\\,').join(escComma)
.split('\\.').join(escPeriod);
}
/**
* @param {string} str
*/
function unescapeBraces(str) {
return str.split(escSlash).join('\\')
.split(escOpen).join('{')
.split(escClose).join('}')
.split(escComma).join(',')
.split(escPeriod).join('.');
}
/**
* Basically just str.split(","), but handling cases
* where we have nested braced sections, which should be
* treated as individual members, like {a,{b,c},d}
* @param {string} str
*/
function parseCommaParts(str) {
if (!str)
return [''];
const parts = [];
const m = balanced('{', '}', str);
if (!m)
return str.split(',');
const { pre, body, post } = m;
const p = pre.split(',');
p[p.length - 1] += '{' + body + '}';
const postParts = parseCommaParts(post);
if (post.length) {
p[p.length - 1] += postParts.shift();
p.push.apply(p, postParts);
}
parts.push.apply(parts, p);
return parts;
}
/**
* @param {string} str
*/
function expandTop(str) {
if (!str)
return [];
// I don't know why Bash 4.3 does this, but it does.
// Anything starting with {} will have the first two bytes preserved
// but *only* at the top level, so {},a}b will not expand to anything,
// but a{},b}c will be expanded to [a}c,abc].
// One could argue that this is a bug in Bash, but since the goal of
// this module is to match Bash's rules, we escape a leading {}
if (str.slice(0, 2) === '{}') {
str = '\\{\\}' + str.slice(2);
}
return expand(escapeBraces(str), true).map(unescapeBraces);
}
/**
* @param {string} str
*/
function embrace(str) {
return '{' + str + '}';
}
/**
* @param {string} el
*/
function isPadded(el) {
return /^-?0\d/.test(el);
}
/**
* @param {number} i
* @param {number} y
*/
function lte(i, y) {
return i <= y;
}
/**
* @param {number} i
* @param {number} y
*/
function gte(i, y) {
return i >= y;
}
/**
* @param {string} str
* @param {boolean} [isTop]
*/
function expand(str, isTop) {
/** @type {string[]} */
const expansions = [];
const m = balanced('{', '}', str);
if (!m) return [str];
// no need to expand pre, since it is guaranteed to be free of brace-sets
const pre = m.pre;
const post = m.post.length
? expand(m.post, false)
: [''];
if (/\$$/.test(m.pre)) {
for (let k = 0; k < post.length; k++) {
const expansion = pre + '{' + m.body + '}' + post[k];
expansions.push(expansion);
}
} else {
const isNumericSequence = /^-?\d+\.\.-?\d+(?:\.\.-?\d+)?$/.test(m.body);
const isAlphaSequence = /^[a-zA-Z]\.\.[a-zA-Z](?:\.\.-?\d+)?$/.test(m.body);
const isSequence = isNumericSequence || isAlphaSequence;
const isOptions = m.body.indexOf(',') >= 0;
if (!isSequence && !isOptions) {
// {a},b}
if (m.post.match(/,.*\}/)) {
str = m.pre + '{' + m.body + escClose + m.post;
return expand(str);
}
return [str];
}
let n;
if (isSequence) {
n = m.body.split(/\.\./);
} else {
n = parseCommaParts(m.body);
if (n.length === 1) {
// x{{a,b}}y ==> x{a}y x{b}y
n = expand(n[0], false).map(embrace);
if (n.length === 1) {
return post.map(function (p) {
return m.pre + n[0] + p;
});
}
}
}
// at this point, n is the parts, and we know it's not a comma set
// with a single entry.
let N;
if (isSequence) {
const x = numeric(n[0]);
const y = numeric(n[1]);
const width = Math.max(n[0].length, n[1].length)
let incr = n.length == 3
? Math.abs(numeric(n[2]))
: 1;
let test = lte;
const reverse = y < x;
if (reverse) {
incr *= -1;
test = gte;
}
const pad = n.some(isPadded);
N = [];
for (let i = x; test(i, y); i += incr) {
let c;
if (isAlphaSequence) {
c = String.fromCharCode(i);
if (c === '\\')
c = '';
} else {
c = String(i);
if (pad) {
const need = width - c.length;
if (need > 0) {
const z = new Array(need + 1).join('0');
if (i < 0)
c = '-' + z + c.slice(1);
else
c = z + c;
}
}
}
N.push(c);
}
} else {
N = [];
for (let j = 0; j < n.length; j++) {
N.push.apply(N, expand(n[j], false));
}
}
for (let j = 0; j < N.length; j++) {
for (let k = 0; k < post.length; k++) {
const expansion = pre + N[j] + post[k];
if (!isTop || isSequence || expansion)
expansions.push(expansion);
}
}
}
return expansions;
}
module.exports = expandTop;
+209
View File
@@ -0,0 +1,209 @@
/**
* archiver-utils
*
* Copyright (c) 2012-2014 Chris Talkington, contributors.
* Licensed under the MIT license.
* https://github.com/archiverjs/node-archiver/blob/master/LICENSE-MIT
*/
var fs = require('graceful-fs');
var path = require('path');
var flatten = require('./lodash.flatten')
var difference = require('./lodash.difference');
var union = require('./lodash.union');
var isPlainObject = require('./lodash.isplainobject');
var glob = require('./glob');
var file = module.exports = {};
var pathSeparatorRe = /[\/\\]/g;
// Process specified wildcard glob patterns or filenames against a
// callback, excluding and uniquing files in the result set.
var processPatterns = function (patterns, fn) {
// Filepaths to return.
var result = [];
// Iterate over flattened patterns array.
flatten(patterns).forEach(function (pattern) {
// If the first character is ! it should be omitted
var exclusion = pattern.indexOf('!') === 0;
// If the pattern is an exclusion, remove the !
if (exclusion) { pattern = pattern.slice(1); }
// Find all matching files for this pattern.
var matches = fn(pattern);
if (exclusion) {
// If an exclusion, remove matching files.
result = difference(result, matches);
} else {
// Otherwise add matching files.
result = union(result, matches);
}
});
return result;
};
// True if the file path exists.
file.exists = function () {
var filepath = path.join.apply(path, arguments);
return fs.existsSync(filepath);
};
// Return an array of all file paths that match the given wildcard patterns.
file.expand = function (...args) {
// If the first argument is an options object, save those options to pass
// into the File.prototype.glob.sync method.
var options = isPlainObject(args[0]) ? args.shift() : {};
// Use the first argument if it's an Array, otherwise convert the arguments
// object to an array and use that.
var patterns = Array.isArray(args[0]) ? args[0] : args;
// Return empty set if there are no patterns or filepaths.
if (patterns.length === 0) { return []; }
// Return all matching filepaths.
var matches = processPatterns(patterns, function (pattern) {
// Find all matching files for this pattern.
return glob.sync(pattern, options);
});
// Filter result set?
if (options.filter) {
matches = matches.filter(function (filepath) {
filepath = path.join(options.cwd || '', filepath);
try {
if (typeof options.filter === 'function') {
return options.filter(filepath);
} else {
// If the file is of the right type and exists, this should work.
return fs.statSync(filepath)[options.filter]();
}
} catch (e) {
// Otherwise, it's probably not the right type.
return false;
}
});
}
return matches;
};
// Build a multi task "files" object dynamically.
file.expandMapping = function (patterns, destBase, options) {
options = Object.assign({
rename: function (destBase, destPath) {
return path.join(destBase || '', destPath);
}
}, options);
var files = [];
var fileByDest = {};
// Find all files matching pattern, using passed-in options.
file.expand(options, patterns).forEach(function (src) {
var destPath = src;
// Flatten?
if (options.flatten) {
destPath = path.basename(destPath);
}
// Change the extension?
if (options.ext) {
destPath = destPath.replace(/(\.[^\/]*)?$/, options.ext);
}
// Generate destination filename.
var dest = options.rename(destBase, destPath, options);
// Prepend cwd to src path if necessary.
if (options.cwd) { src = path.join(options.cwd, src); }
// Normalize filepaths to be unix-style.
dest = dest.replace(pathSeparatorRe, '/');
src = src.replace(pathSeparatorRe, '/');
// Map correct src path to dest path.
if (fileByDest[dest]) {
// If dest already exists, push this src onto that dest's src array.
fileByDest[dest].src.push(src);
} else {
// Otherwise create a new src-dest file mapping object.
files.push({
src: [src],
dest: dest,
});
// And store a reference for later use.
fileByDest[dest] = files[files.length - 1];
}
});
return files;
};
// reusing bits of grunt's multi-task source normalization
file.normalizeFilesArray = function (data) {
var files = [];
data.forEach(function (obj) {
var prop;
if ('src' in obj || 'dest' in obj) {
files.push(obj);
}
});
if (files.length === 0) {
return [];
}
files = _(files).chain().forEach(function (obj) {
if (!('src' in obj) || !obj.src) { return; }
// Normalize .src properties to flattened array.
if (Array.isArray(obj.src)) {
obj.src = flatten(obj.src);
} else {
obj.src = [obj.src];
}
}).map(function (obj) {
// Build options object, removing unwanted properties.
var expandOptions = Object.assign({}, obj);
delete expandOptions.src;
delete expandOptions.dest;
// Expand file mappings.
if (obj.expand) {
return file.expandMapping(obj.src, obj.dest, expandOptions).map(function (mapObj) {
// Copy obj properties to result.
var result = Object.assign({}, obj);
// Make a clone of the orig obj available.
result.orig = Object.assign({}, obj);
// Set .src and .dest, processing both as templates.
result.src = mapObj.src;
result.dest = mapObj.dest;
// Remove unwanted properties.
['expand', 'cwd', 'flatten', 'rename', 'ext'].forEach(function (prop) {
delete result[prop];
});
return result;
});
}
// Copy obj properties to result, adding an .orig property.
var result = Object.assign({}, obj);
// Make a clone of the orig obj available.
result.orig = Object.assign({}, obj);
if ('src' in result) {
// Expose an expand-on-demand getter method as .src.
Object.defineProperty(result, 'src', {
enumerable: true,
get: function fn() {
var src;
if (!('result' in fn)) {
src = obj.src;
// If src is an array, flatten it. Otherwise, make it into an array.
src = Array.isArray(src) ? flatten(src) : [src];
// Expand src files, memoizing result.
fn.result = file.expand(expandOptions, src);
}
return fn.result;
}
});
}
if ('dest' in result) {
result.dest = obj.dest;
}
return result;
}).flatten().value();
return files;
};
@@ -0,0 +1,43 @@
The ISC License
Copyright (c) 2016-2022 Isaac Z. Schlueter and Contributors
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
----
This library bundles a version of the `fs.realpath` and `fs.realpathSync`
methods from Node.js v0.10 under the terms of the Node.js MIT license.
Node's license follows, also included at the header of `old.js` which contains
the licensed code:
Copyright (c) 2016-2022 Joyent, Inc. and other Node contributors.
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,54 @@
module.exports = realpath
realpath.realpath = realpath
realpath.sync = realpathSync
realpath.realpathSync = realpathSync
var fs = require('fs')
var origRealpath = fs.realpath
var origRealpathSync = fs.realpathSync
var version = process.version
var ok = /^v[0-5]\./.test(version)
var old = require('./old.js')
function newError(er) {
return er && er.syscall === 'realpath' && (
er.code === 'ELOOP' ||
er.code === 'ENOMEM' ||
er.code === 'ENAMETOOLONG'
)
}
function realpath(p, cache, cb) {
if (ok) {
return origRealpath(p, cache, cb)
}
if (typeof cache === 'function') {
cb = cache
cache = null
}
origRealpath(p, cache, function (er, result) {
if (newError(er)) {
old.realpath(p, cache, cb)
} else {
cb(er, result)
}
})
}
function realpathSync(p, cache) {
if (ok) {
return origRealpathSync(p, cache)
}
try {
return origRealpathSync(p, cache)
} catch (er) {
if (newError(er)) {
return old.realpathSync(p, cache)
} else {
throw er
}
}
}
@@ -0,0 +1,302 @@
/* istanbul ignore file - tested in node */
// Copyright Joyent, Inc. and other Node contributors.
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the
// "Software"), to deal in the Software without restriction, including
// without limitation the rights to use, copy, modify, merge, publish,
// distribute, sublicense, and/or sell copies of the Software, and to permit
// persons to whom the Software is furnished to do so, subject to the
// following conditions:
//
// The above copyright notice and this permission notice shall be included
// in all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
// USE OR OTHER DEALINGS IN THE SOFTWARE.
var pathModule = require('path');
var isWindows = process.platform === 'win32';
var fs = require('fs');
// JavaScript implementation of realpath, ported from node pre-v6
var DEBUG = process.env.NODE_DEBUG && /fs/.test(process.env.NODE_DEBUG);
function rethrow() {
// Only enable in debug mode. A backtrace uses ~1000 bytes of heap space and
// is fairly slow to generate.
var callback;
if (DEBUG) {
var backtrace = new Error;
callback = debugCallback;
} else
callback = missingCallback;
return callback;
function debugCallback(err) {
if (err) {
backtrace.message = err.message;
err = backtrace;
missingCallback(err);
}
}
function missingCallback(err) {
if (err) {
if (process.throwDeprecation)
throw err; // Forgot a callback but don't know where? Use NODE_DEBUG=fs
else if (!process.noDeprecation) {
var msg = 'fs: missing callback ' + (err.stack || err.message);
if (process.traceDeprecation)
console.trace(msg);
else
console.error(msg);
}
}
}
}
function maybeCallback(cb) {
return typeof cb === 'function' ? cb : rethrow();
}
// Regexp that finds the next partion of a (partial) path
// result is [base_with_slash, base], e.g. ['somedir/', 'somedir']
if (isWindows) {
var nextPartRe = /(.*?)(?:[\/\\]+|$)/g;
} else {
var nextPartRe = /(.*?)(?:[\/]+|$)/g;
}
// Regex to find the device root, including trailing slash. E.g. 'c:\\'.
if (isWindows) {
var splitRootRe = /^(?:[a-zA-Z]:|[\\\/]{2}[^\\\/]+[\\\/][^\\\/]+)?[\\\/]*/;
} else {
var splitRootRe = /^[\/]*/;
}
exports.realpathSync = function realpathSync(p, cache) {
// make p is absolute
p = pathModule.resolve(p);
if (cache && Object.prototype.hasOwnProperty.call(cache, p)) {
return cache[p];
}
var original = p,
seenLinks = {},
knownHard = {};
// current character position in p
var pos;
// the partial path so far, including a trailing slash if any
var current;
// the partial path without a trailing slash (except when pointing at a root)
var base;
// the partial path scanned in the previous round, with slash
var previous;
start();
function start() {
// Skip over roots
var m = splitRootRe.exec(p);
pos = m[0].length;
current = m[0];
base = m[0];
previous = '';
// On windows, check that the root exists. On unix there is no need.
if (isWindows && !knownHard[base]) {
fs.lstatSync(base);
knownHard[base] = true;
}
}
// walk down the path, swapping out linked pathparts for their real
// values
// NB: p.length changes.
while (pos < p.length) {
// find the next part
nextPartRe.lastIndex = pos;
var result = nextPartRe.exec(p);
previous = current;
current += result[0];
base = previous + result[1];
pos = nextPartRe.lastIndex;
// continue if not a symlink
if (knownHard[base] || (cache && cache[base] === base)) {
continue;
}
var resolvedLink;
if (cache && Object.prototype.hasOwnProperty.call(cache, base)) {
// some known symbolic link. no need to stat again.
resolvedLink = cache[base];
} else {
var stat = fs.lstatSync(base);
if (!stat.isSymbolicLink()) {
knownHard[base] = true;
if (cache) cache[base] = base;
continue;
}
// read the link if it wasn't read before
// dev/ino always return 0 on windows, so skip the check.
var linkTarget = null;
if (!isWindows) {
var id = stat.dev.toString(32) + ':' + stat.ino.toString(32);
if (seenLinks.hasOwnProperty(id)) {
linkTarget = seenLinks[id];
}
}
if (linkTarget === null) {
fs.statSync(base);
linkTarget = fs.readlinkSync(base);
}
resolvedLink = pathModule.resolve(previous, linkTarget);
// track this, if given a cache.
if (cache) cache[base] = resolvedLink;
if (!isWindows) seenLinks[id] = linkTarget;
}
// resolve the link, then start over
p = pathModule.resolve(resolvedLink, p.slice(pos));
start();
}
if (cache) cache[original] = p;
return p;
};
exports.realpath = function realpath(p, cache, cb) {
if (typeof cb !== 'function') {
cb = maybeCallback(cache);
cache = null;
}
// make p is absolute
p = pathModule.resolve(p);
if (cache && Object.prototype.hasOwnProperty.call(cache, p)) {
return process.nextTick(cb.bind(null, null, cache[p]));
}
var original = p,
seenLinks = {},
knownHard = {};
// current character position in p
var pos;
// the partial path so far, including a trailing slash if any
var current;
// the partial path without a trailing slash (except when pointing at a root)
var base;
// the partial path scanned in the previous round, with slash
var previous;
start();
function start() {
// Skip over roots
var m = splitRootRe.exec(p);
pos = m[0].length;
current = m[0];
base = m[0];
previous = '';
// On windows, check that the root exists. On unix there is no need.
if (isWindows && !knownHard[base]) {
fs.lstat(base, function (err) {
if (err) return cb(err);
knownHard[base] = true;
LOOP();
});
} else {
process.nextTick(LOOP);
}
}
// walk down the path, swapping out linked pathparts for their real
// values
function LOOP() {
// stop if scanned past end of path
if (pos >= p.length) {
if (cache) cache[original] = p;
return cb(null, p);
}
// find the next part
nextPartRe.lastIndex = pos;
var result = nextPartRe.exec(p);
previous = current;
current += result[0];
base = previous + result[1];
pos = nextPartRe.lastIndex;
// continue if not a symlink
if (knownHard[base] || (cache && cache[base] === base)) {
return process.nextTick(LOOP);
}
if (cache && Object.prototype.hasOwnProperty.call(cache, base)) {
// known symbolic link. no need to stat again.
return gotResolvedLink(cache[base]);
}
return fs.lstat(base, gotStat);
}
function gotStat(err, stat) {
if (err) return cb(err);
// if not a symlink, skip to the next path part
if (!stat.isSymbolicLink()) {
knownHard[base] = true;
if (cache) cache[base] = base;
return process.nextTick(LOOP);
}
// stat & read the link if not read before
// call gotTarget as soon as the link target is known
// dev/ino always return 0 on windows, so skip the check.
if (!isWindows) {
var id = stat.dev.toString(32) + ':' + stat.ino.toString(32);
if (seenLinks.hasOwnProperty(id)) {
return gotTarget(null, seenLinks[id], base);
}
}
fs.stat(base, function (err) {
if (err) return cb(err);
fs.readlink(base, function (err, target) {
if (!isWindows) seenLinks[id] = target;
gotTarget(err, target);
});
});
}
function gotTarget(err, target, base) {
if (err) return cb(err);
var resolvedLink = pathModule.resolve(previous, target);
if (cache) cache[base] = resolvedLink;
gotResolvedLink(resolvedLink);
}
function gotResolvedLink(resolvedLink) {
// resolve the link, then start over
p = pathModule.resolve(resolvedLink, p.slice(pos));
start();
}
};
@@ -0,0 +1,15 @@
The ISC License
Copyright (c) 2009-2022 Isaac Z. Schlueter and Contributors
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
@@ -0,0 +1,240 @@
exports.setopts = setopts
exports.ownProp = ownProp
exports.makeAbs = makeAbs
exports.finish = finish
exports.mark = mark
exports.isIgnored = isIgnored
exports.childrenIgnored = childrenIgnored
function ownProp(obj, field) {
return Object.prototype.hasOwnProperty.call(obj, field)
}
var fs = require("fs")
var path = require("path")
var minimatch = require("../minimatch")
var isAbsolute = require("path").isAbsolute
var Minimatch = minimatch.Minimatch
function alphasort(a, b) {
return a.localeCompare(b, 'en')
}
function setupIgnores(self, options) {
self.ignore = options.ignore || []
if (!Array.isArray(self.ignore))
self.ignore = [self.ignore]
if (self.ignore.length) {
self.ignore = self.ignore.map(ignoreMap)
}
}
// ignore patterns are always in dot:true mode.
function ignoreMap(pattern) {
var gmatcher = null
if (pattern.slice(-3) === '/**') {
var gpattern = pattern.replace(/(\/\*\*)+$/, '')
gmatcher = new Minimatch(gpattern, { dot: true })
}
return {
matcher: new Minimatch(pattern, { dot: true }),
gmatcher: gmatcher
}
}
function setopts(self, pattern, options) {
if (!options)
options = {}
// base-matching: just use globstar for that.
if (options.matchBase && -1 === pattern.indexOf("/")) {
if (options.noglobstar) {
throw new Error("base matching requires globstar")
}
pattern = "**/" + pattern
}
self.silent = !!options.silent
self.pattern = pattern
self.strict = options.strict !== false
self.realpath = !!options.realpath
self.realpathCache = options.realpathCache || Object.create(null)
self.follow = !!options.follow
self.dot = !!options.dot
self.mark = !!options.mark
self.nodir = !!options.nodir
if (self.nodir)
self.mark = true
self.sync = !!options.sync
self.nounique = !!options.nounique
self.nonull = !!options.nonull
self.nosort = !!options.nosort
self.nocase = !!options.nocase
self.stat = !!options.stat
self.noprocess = !!options.noprocess
self.absolute = !!options.absolute
self.fs = options.fs || fs
self.maxLength = options.maxLength || Infinity
self.cache = options.cache || Object.create(null)
self.statCache = options.statCache || Object.create(null)
self.symlinks = options.symlinks || Object.create(null)
setupIgnores(self, options)
self.changedCwd = false
var cwd = process.cwd()
if (!ownProp(options, "cwd"))
self.cwd = path.resolve(cwd)
else {
self.cwd = path.resolve(options.cwd)
self.changedCwd = self.cwd !== cwd
}
self.root = options.root || path.resolve(self.cwd, "/")
self.root = path.resolve(self.root)
// TODO: is an absolute `cwd` supposed to be resolved against `root`?
// e.g. { cwd: '/test', root: __dirname } === path.join(__dirname, '/test')
self.cwdAbs = isAbsolute(self.cwd) ? self.cwd : makeAbs(self, self.cwd)
self.nomount = !!options.nomount
if (process.platform === "win32") {
self.root = self.root.replace(/\\/g, "/")
self.cwd = self.cwd.replace(/\\/g, "/")
self.cwdAbs = self.cwdAbs.replace(/\\/g, "/")
}
// disable comments and negation in Minimatch.
// Note that they are not supported in Glob itself anyway.
options.nonegate = true
options.nocomment = true
// always treat \ in patterns as escapes, not path separators
options.allowWindowsEscape = true
self.minimatch = new Minimatch(pattern, options)
self.options = self.minimatch.options
}
function finish(self) {
var nou = self.nounique
var all = nou ? [] : Object.create(null)
for (var i = 0, l = self.matches.length; i < l; i++) {
var matches = self.matches[i]
if (!matches || Object.keys(matches).length === 0) {
if (self.nonull) {
// do like the shell, and spit out the literal glob
var literal = self.minimatch.globSet[i]
if (nou)
all.push(literal)
else
all[literal] = true
}
} else {
// had matches
var m = Object.keys(matches)
if (nou)
all.push.apply(all, m)
else
m.forEach(function (m) {
all[m] = true
})
}
}
if (!nou)
all = Object.keys(all)
if (!self.nosort)
all = all.sort(alphasort)
// at *some* point we statted all of these
if (self.mark) {
for (var i = 0; i < all.length; i++) {
all[i] = self._mark(all[i])
}
if (self.nodir) {
all = all.filter(function (e) {
var notDir = !(/\/$/.test(e))
var c = self.cache[e] || self.cache[makeAbs(self, e)]
if (notDir && c)
notDir = c !== 'DIR' && !Array.isArray(c)
return notDir
})
}
}
if (self.ignore.length)
all = all.filter(function (m) {
return !isIgnored(self, m)
})
self.found = all
}
function mark(self, p) {
var abs = makeAbs(self, p)
var c = self.cache[abs]
var m = p
if (c) {
var isDir = c === 'DIR' || Array.isArray(c)
var slash = p.slice(-1) === '/'
if (isDir && !slash)
m += '/'
else if (!isDir && slash)
m = m.slice(0, -1)
if (m !== p) {
var mabs = makeAbs(self, m)
self.statCache[mabs] = self.statCache[abs]
self.cache[mabs] = self.cache[abs]
}
}
return m
}
// lotta situps...
function makeAbs(self, f) {
var abs = f
if (f.charAt(0) === '/') {
abs = path.join(self.root, f)
} else if (isAbsolute(f) || f === '') {
abs = f
} else if (self.changedCwd) {
abs = path.resolve(self.cwd, f)
} else {
abs = path.resolve(f)
}
if (process.platform === 'win32')
abs = abs.replace(/\\/g, '/')
return abs
}
// Return true, if pattern ends with globstar '**', for the accompanying parent directory.
// Ex:- If node_modules/** is the pattern, add 'node_modules' to ignore list along with it's contents
function isIgnored(self, path) {
if (!self.ignore.length)
return false
return self.ignore.some(function (item) {
return item.matcher.match(path) || !!(item.gmatcher && item.gmatcher.match(path))
})
}
function childrenIgnored(self, path) {
if (!self.ignore.length)
return false
return self.ignore.some(function (item) {
return !!(item.gmatcher && item.gmatcher.match(path))
})
}
@@ -0,0 +1,789 @@
// Approach:
//
// 1. Get the minimatch set
// 2. For each pattern in the set, PROCESS(pattern, false)
// 3. Store matches per-set, then uniq them
//
// PROCESS(pattern, inGlobStar)
// Get the first [n] items from pattern that are all strings
// Join these together. This is PREFIX.
// If there is no more remaining, then stat(PREFIX) and
// add to matches if it succeeds. END.
//
// If inGlobStar and PREFIX is symlink and points to dir
// set ENTRIES = []
// else readdir(PREFIX) as ENTRIES
// If fail, END
//
// with ENTRIES
// If pattern[n] is GLOBSTAR
// // handle the case where the globstar match is empty
// // by pruning it out, and testing the resulting pattern
// PROCESS(pattern[0..n] + pattern[n+1 .. $], false)
// // handle other cases.
// for ENTRY in ENTRIES (not dotfiles)
// // attach globstar + tail onto the entry
// // Mark that this entry is a globstar match
// PROCESS(pattern[0..n] + ENTRY + pattern[n .. $], true)
//
// else // not globstar
// for ENTRY in ENTRIES (not dotfiles, unless pattern[n] is dot)
// Test ENTRY against pattern[n]
// If fails, continue
// If passes, PROCESS(pattern[0..n] + item + pattern[n+1 .. $])
//
// Caveat:
// Cache all stats and readdirs results to minimize syscall. Since all
// we ever care about is existence and directory-ness, we can just keep
// `true` for files, and [children,...] for directories, or `false` for
// things that don't exist.
module.exports = glob
var rp = require('../fsRealpath')
var minimatch = require('../minimatch')
// var inherits = require('inherits')
const util = require('util')
var EE = require('events').EventEmitter
var path = require('path')
var assert = require('assert')
var isAbsolute = require('path').isAbsolute
var globSync = require('./sync.js')
var common = require('./common.js')
var setopts = common.setopts
var ownProp = common.ownProp
var inflight = require('../inflight')
var childrenIgnored = common.childrenIgnored
var isIgnored = common.isIgnored
var once = require('../../../lodash.once')
function glob(pattern, options, cb) {
if (typeof options === 'function') cb = options, options = {}
if (!options) options = {}
if (options.sync) {
if (cb)
throw new TypeError('callback provided to sync glob')
return globSync(pattern, options)
}
return new Glob(pattern, options, cb)
}
glob.sync = globSync
var GlobSync = glob.GlobSync = globSync.GlobSync
// old api surface
glob.glob = glob
function extend(origin, add) {
if (add === null || typeof add !== 'object') {
return origin
}
var keys = Object.keys(add)
var i = keys.length
while (i--) {
origin[keys[i]] = add[keys[i]]
}
return origin
}
glob.hasMagic = function (pattern, options_) {
var options = extend({}, options_)
options.noprocess = true
var g = new Glob(pattern, options)
var set = g.minimatch.set
if (!pattern)
return false
if (set.length > 1)
return true
for (var j = 0; j < set[0].length; j++) {
if (typeof set[0][j] !== 'string')
return true
}
return false
}
glob.Glob = Glob
util.inherits(Glob, EE)
function Glob(pattern, options, cb) {
if (typeof options === 'function') {
cb = options
options = null
}
if (options && options.sync) {
if (cb)
throw new TypeError('callback provided to sync glob')
return new GlobSync(pattern, options)
}
if (!(this instanceof Glob))
return new Glob(pattern, options, cb)
setopts(this, pattern, options)
this._didRealPath = false
// process each pattern in the minimatch set
var n = this.minimatch.set.length
// The matches are stored as {<filename>: true,...} so that
// duplicates are automagically pruned.
// Later, we do an Object.keys() on these.
// Keep them as a list so we can fill in when nonull is set.
this.matches = new Array(n)
if (typeof cb === 'function') {
cb = once(cb)
this.on('error', cb)
this.on('end', function (matches) {
cb(null, matches)
})
}
var self = this
this._processing = 0
this._emitQueue = []
this._processQueue = []
this.paused = false
if (this.noprocess)
return this
if (n === 0)
return done()
var sync = true
for (var i = 0; i < n; i++) {
this._process(this.minimatch.set[i], i, false, done)
}
sync = false
function done() {
--self._processing
if (self._processing <= 0) {
if (sync) {
process.nextTick(function () {
self._finish()
})
} else {
self._finish()
}
}
}
}
Glob.prototype._finish = function () {
assert(this instanceof Glob)
if (this.aborted)
return
if (this.realpath && !this._didRealpath)
return this._realpath()
common.finish(this)
this.emit('end', this.found)
}
Glob.prototype._realpath = function () {
if (this._didRealpath)
return
this._didRealpath = true
var n = this.matches.length
if (n === 0)
return this._finish()
var self = this
for (var i = 0; i < this.matches.length; i++)
this._realpathSet(i, next)
function next() {
if (--n === 0)
self._finish()
}
}
Glob.prototype._realpathSet = function (index, cb) {
var matchset = this.matches[index]
if (!matchset)
return cb()
var found = Object.keys(matchset)
var self = this
var n = found.length
if (n === 0)
return cb()
var set = this.matches[index] = Object.create(null)
found.forEach(function (p, i) {
// If there's a problem with the stat, then it means that
// one or more of the links in the realpath couldn't be
// resolved. just return the abs value in that case.
p = self._makeAbs(p)
rp.realpath(p, self.realpathCache, function (er, real) {
if (!er)
set[real] = true
else if (er.syscall === 'stat')
set[p] = true
else
self.emit('error', er) // srsly wtf right here
if (--n === 0) {
self.matches[index] = set
cb()
}
})
})
}
Glob.prototype._mark = function (p) {
return common.mark(this, p)
}
Glob.prototype._makeAbs = function (f) {
return common.makeAbs(this, f)
}
Glob.prototype.abort = function () {
this.aborted = true
this.emit('abort')
}
Glob.prototype.pause = function () {
if (!this.paused) {
this.paused = true
this.emit('pause')
}
}
Glob.prototype.resume = function () {
if (this.paused) {
this.emit('resume')
this.paused = false
if (this._emitQueue.length) {
var eq = this._emitQueue.slice(0)
this._emitQueue.length = 0
for (var i = 0; i < eq.length; i++) {
var e = eq[i]
this._emitMatch(e[0], e[1])
}
}
if (this._processQueue.length) {
var pq = this._processQueue.slice(0)
this._processQueue.length = 0
for (var i = 0; i < pq.length; i++) {
var p = pq[i]
this._processing--
this._process(p[0], p[1], p[2], p[3])
}
}
}
}
Glob.prototype._process = function (pattern, index, inGlobStar, cb) {
assert(this instanceof Glob)
assert(typeof cb === 'function')
if (this.aborted)
return
this._processing++
if (this.paused) {
this._processQueue.push([pattern, index, inGlobStar, cb])
return
}
//console.error('PROCESS %d', this._processing, pattern)
// Get the first [n] parts of pattern that are all strings.
var n = 0
while (typeof pattern[n] === 'string') {
n++
}
// now n is the index of the first one that is *not* a string.
// see if there's anything else
var prefix
switch (n) {
// if not, then this is rather simple
case pattern.length:
this._processSimple(pattern.join('/'), index, cb)
return
case 0:
// pattern *starts* with some non-trivial item.
// going to readdir(cwd), but not include the prefix in matches.
prefix = null
break
default:
// pattern has some string bits in the front.
// whatever it starts with, whether that's 'absolute' like /foo/bar,
// or 'relative' like '../baz'
prefix = pattern.slice(0, n).join('/')
break
}
var remain = pattern.slice(n)
// get the list of entries.
var read
if (prefix === null)
read = '.'
else if (isAbsolute(prefix) ||
isAbsolute(pattern.map(function (p) {
return typeof p === 'string' ? p : '[*]'
}).join('/'))) {
if (!prefix || !isAbsolute(prefix))
prefix = '/' + prefix
read = prefix
} else
read = prefix
var abs = this._makeAbs(read)
//if ignored, skip _processing
if (childrenIgnored(this, read))
return cb()
var isGlobStar = remain[0] === minimatch.GLOBSTAR
if (isGlobStar)
this._processGlobStar(prefix, read, abs, remain, index, inGlobStar, cb)
else
this._processReaddir(prefix, read, abs, remain, index, inGlobStar, cb)
}
Glob.prototype._processReaddir = function (prefix, read, abs, remain, index, inGlobStar, cb) {
var self = this
this._readdir(abs, inGlobStar, function (er, entries) {
return self._processReaddir2(prefix, read, abs, remain, index, inGlobStar, entries, cb)
})
}
Glob.prototype._processReaddir2 = function (prefix, read, abs, remain, index, inGlobStar, entries, cb) {
// if the abs isn't a dir, then nothing can match!
if (!entries)
return cb()
// It will only match dot entries if it starts with a dot, or if
// dot is set. Stuff like @(.foo|.bar) isn't allowed.
var pn = remain[0]
var negate = !!this.minimatch.negate
var rawGlob = pn._glob
var dotOk = this.dot || rawGlob.charAt(0) === '.'
var matchedEntries = []
for (var i = 0; i < entries.length; i++) {
var e = entries[i]
if (e.charAt(0) !== '.' || dotOk) {
var m
if (negate && !prefix) {
m = !e.match(pn)
} else {
m = e.match(pn)
}
if (m)
matchedEntries.push(e)
}
}
//console.error('prd2', prefix, entries, remain[0]._glob, matchedEntries)
var len = matchedEntries.length
// If there are no matched entries, then nothing matches.
if (len === 0)
return cb()
// if this is the last remaining pattern bit, then no need for
// an additional stat *unless* the user has specified mark or
// stat explicitly. We know they exist, since readdir returned
// them.
if (remain.length === 1 && !this.mark && !this.stat) {
if (!this.matches[index])
this.matches[index] = Object.create(null)
for (var i = 0; i < len; i++) {
var e = matchedEntries[i]
if (prefix) {
if (prefix !== '/')
e = prefix + '/' + e
else
e = prefix + e
}
if (e.charAt(0) === '/' && !this.nomount) {
e = path.join(this.root, e)
}
this._emitMatch(index, e)
}
// This was the last one, and no stats were needed
return cb()
}
// now test all matched entries as stand-ins for that part
// of the pattern.
remain.shift()
for (var i = 0; i < len; i++) {
var e = matchedEntries[i]
var newPattern
if (prefix) {
if (prefix !== '/')
e = prefix + '/' + e
else
e = prefix + e
}
this._process([e].concat(remain), index, inGlobStar, cb)
}
cb()
}
Glob.prototype._emitMatch = function (index, e) {
if (this.aborted)
return
if (isIgnored(this, e))
return
if (this.paused) {
this._emitQueue.push([index, e])
return
}
var abs = isAbsolute(e) ? e : this._makeAbs(e)
if (this.mark)
e = this._mark(e)
if (this.absolute)
e = abs
if (this.matches[index][e])
return
if (this.nodir) {
var c = this.cache[abs]
if (c === 'DIR' || Array.isArray(c))
return
}
this.matches[index][e] = true
var st = this.statCache[abs]
if (st)
this.emit('stat', e, st)
this.emit('match', e)
}
Glob.prototype._readdirInGlobStar = function (abs, cb) {
if (this.aborted)
return
// follow all symlinked directories forever
// just proceed as if this is a non-globstar situation
if (this.follow)
return this._readdir(abs, false, cb)
var lstatkey = 'lstat\0' + abs
var self = this
var lstatcb = inflight(lstatkey, lstatcb_)
if (lstatcb)
self.fs.lstat(abs, lstatcb)
function lstatcb_(er, lstat) {
if (er && er.code === 'ENOENT')
return cb()
var isSym = lstat && lstat.isSymbolicLink()
self.symlinks[abs] = isSym
// If it's not a symlink or a dir, then it's definitely a regular file.
// don't bother doing a readdir in that case.
if (!isSym && lstat && !lstat.isDirectory()) {
self.cache[abs] = 'FILE'
cb()
} else
self._readdir(abs, false, cb)
}
}
Glob.prototype._readdir = function (abs, inGlobStar, cb) {
if (this.aborted)
return
cb = inflight('readdir\0' + abs + '\0' + inGlobStar, cb)
if (!cb)
return
//console.error('RD %j %j', +inGlobStar, abs)
if (inGlobStar && !ownProp(this.symlinks, abs))
return this._readdirInGlobStar(abs, cb)
if (ownProp(this.cache, abs)) {
var c = this.cache[abs]
if (!c || c === 'FILE')
return cb()
if (Array.isArray(c))
return cb(null, c)
}
var self = this
self.fs.readdir(abs, readdirCb(this, abs, cb))
}
function readdirCb(self, abs, cb) {
return function (er, entries) {
if (er)
self._readdirError(abs, er, cb)
else
self._readdirEntries(abs, entries, cb)
}
}
Glob.prototype._readdirEntries = function (abs, entries, cb) {
if (this.aborted)
return
// if we haven't asked to stat everything, then just
// assume that everything in there exists, so we can avoid
// having to stat it a second time.
if (!this.mark && !this.stat) {
for (var i = 0; i < entries.length; i++) {
var e = entries[i]
if (abs === '/')
e = abs + e
else
e = abs + '/' + e
this.cache[e] = true
}
}
this.cache[abs] = entries
return cb(null, entries)
}
Glob.prototype._readdirError = function (f, er, cb) {
if (this.aborted)
return
// handle errors, and cache the information
switch (er.code) {
case 'ENOTSUP': // https://github.com/isaacs/node-glob/issues/205
case 'ENOTDIR': // totally normal. means it *does* exist.
var abs = this._makeAbs(f)
this.cache[abs] = 'FILE'
if (abs === this.cwdAbs) {
var error = new Error(er.code + ' invalid cwd ' + this.cwd)
error.path = this.cwd
error.code = er.code
this.emit('error', error)
this.abort()
}
break
case 'ENOENT': // not terribly unusual
case 'ELOOP':
case 'ENAMETOOLONG':
case 'UNKNOWN':
this.cache[this._makeAbs(f)] = false
break
default: // some unusual error. Treat as failure.
this.cache[this._makeAbs(f)] = false
if (this.strict) {
this.emit('error', er)
// If the error is handled, then we abort
// if not, we threw out of here
this.abort()
}
if (!this.silent)
console.error('glob error', er)
break
}
return cb()
}
Glob.prototype._processGlobStar = function (prefix, read, abs, remain, index, inGlobStar, cb) {
var self = this
this._readdir(abs, inGlobStar, function (er, entries) {
self._processGlobStar2(prefix, read, abs, remain, index, inGlobStar, entries, cb)
})
}
Glob.prototype._processGlobStar2 = function (prefix, read, abs, remain, index, inGlobStar, entries, cb) {
//console.error('pgs2', prefix, remain[0], entries)
// no entries means not a dir, so it can never have matches
// foo.txt/** doesn't match foo.txt
if (!entries)
return cb()
// test without the globstar, and with every child both below
// and replacing the globstar.
var remainWithoutGlobStar = remain.slice(1)
var gspref = prefix ? [prefix] : []
var noGlobStar = gspref.concat(remainWithoutGlobStar)
// the noGlobStar pattern exits the inGlobStar state
this._process(noGlobStar, index, false, cb)
var isSym = this.symlinks[abs]
var len = entries.length
// If it's a symlink, and we're in a globstar, then stop
if (isSym && inGlobStar)
return cb()
for (var i = 0; i < len; i++) {
var e = entries[i]
if (e.charAt(0) === '.' && !this.dot)
continue
// these two cases enter the inGlobStar state
var instead = gspref.concat(entries[i], remainWithoutGlobStar)
this._process(instead, index, true, cb)
var below = gspref.concat(entries[i], remain)
this._process(below, index, true, cb)
}
cb()
}
Glob.prototype._processSimple = function (prefix, index, cb) {
// XXX review this. Shouldn't it be doing the mounting etc
// before doing stat? kinda weird?
var self = this
this._stat(prefix, function (er, exists) {
self._processSimple2(prefix, index, er, exists, cb)
})
}
Glob.prototype._processSimple2 = function (prefix, index, er, exists, cb) {
//console.error('ps2', prefix, exists)
if (!this.matches[index])
this.matches[index] = Object.create(null)
// If it doesn't exist, then just mark the lack of results
if (!exists)
return cb()
if (prefix && isAbsolute(prefix) && !this.nomount) {
var trail = /[\/\\]$/.test(prefix)
if (prefix.charAt(0) === '/') {
prefix = path.join(this.root, prefix)
} else {
prefix = path.resolve(this.root, prefix)
if (trail)
prefix += '/'
}
}
if (process.platform === 'win32')
prefix = prefix.replace(/\\/g, '/')
// Mark this as a match
this._emitMatch(index, prefix)
cb()
}
// Returns either 'DIR', 'FILE', or false
Glob.prototype._stat = function (f, cb) {
var abs = this._makeAbs(f)
var needDir = f.slice(-1) === '/'
if (f.length > this.maxLength)
return cb()
if (!this.stat && ownProp(this.cache, abs)) {
var c = this.cache[abs]
if (Array.isArray(c))
c = 'DIR'
// It exists, but maybe not how we need it
if (!needDir || c === 'DIR')
return cb(null, c)
if (needDir && c === 'FILE')
return cb()
// otherwise we have to stat, because maybe c=true
// if we know it exists, but not what it is.
}
var exists
var stat = this.statCache[abs]
if (stat !== undefined) {
if (stat === false)
return cb(null, stat)
else {
var type = stat.isDirectory() ? 'DIR' : 'FILE'
if (needDir && type === 'FILE')
return cb()
else
return cb(null, type, stat)
}
}
var self = this
var statcb = inflight('stat\0' + abs, lstatcb_)
if (statcb)
self.fs.lstat(abs, statcb)
function lstatcb_(er, lstat) {
if (lstat && lstat.isSymbolicLink()) {
// If it's a symlink, then treat it as the target, unless
// the target does not exist, then treat it as a file.
return self.fs.stat(abs, function (er, stat) {
if (er)
self._stat2(f, abs, null, lstat, cb)
else
self._stat2(f, abs, er, stat, cb)
})
} else {
self._stat2(f, abs, er, lstat, cb)
}
}
}
Glob.prototype._stat2 = function (f, abs, er, stat, cb) {
if (er && (er.code === 'ENOENT' || er.code === 'ENOTDIR')) {
this.statCache[abs] = false
return cb()
}
var needDir = f.slice(-1) === '/'
this.statCache[abs] = stat
if (abs.slice(-1) === '/' && stat && !stat.isDirectory())
return cb(null, false, stat)
var c = true
if (stat)
c = stat.isDirectory() ? 'DIR' : 'FILE'
this.cache[abs] = this.cache[abs] || c
if (needDir && c === 'FILE')
return cb()
return cb(null, c, stat)
}

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