Compare commits

...

221 Commits

Author SHA1 Message Date
advplyr
b6ae6d86fa Version update 2021-10-26 20:09:35 -05:00
advplyr
9f66054a72 Add: Experimental bookmarks edit and delete #115 2021-10-26 20:09:04 -05:00
advplyr
fffa02e7e8 Change: Book details modal re-scan and save metadata buttons shifted right #147 2021-10-26 18:35:37 -05:00
advplyr
6fbd9dc260 Fix: initializing backup settings #146 2021-10-26 18:29:04 -05:00
advplyr
c6614aba05 Fix: listen to changing series query string #140 2021-10-26 18:24:42 -05:00
advplyr
ee62385980 Change: audio player author and duration styles #122 2021-10-26 18:12:19 -05:00
advplyr
806017175d Fix: seconds to timestamp floor #144 2021-10-26 17:55:48 -05:00
advplyr
1a3a7c5823 Version update 2021-10-26 17:38:02 -05:00
advplyr
550873ff87 Merge pull request #150 from svdztn/seekback-support
Seekback support
2021-10-26 16:53:06 -05:00
advplyr
4dd9f779e2 Fix check user audiobook data exists when getting last update 2021-10-26 16:52:45 -05:00
svd
580b961c4a log modify 2021-10-26 11:36:23 +08:00
svdztn
28b1132171 Merge branch 'advplyr:master' into seekback-support 2021-10-26 10:53:01 +08:00
advplyr
8b31c6555a Fix: audiobook progress emitter emit to all user sockets #145, Fix: save user after progress update 2021-10-25 21:14:54 -05:00
svd
8ffb4f88c9 transcoding maxSeekBackTime more; json add lastUpdate 2021-10-26 09:38:09 +08:00
advplyr
c5eafdfa8a Change: audio player shows time remaining and percent #122, Add: experimental bookmarks start #115 2021-10-24 18:25:44 -05:00
advplyr
f9bf846b30 readme and version update 2021-10-24 16:05:58 -05:00
advplyr
335bbac81d Add: Filter and side rail button to show books with issues #132, Add: realtime updates for audiobook progress 2021-10-24 15:53:51 -05:00
advplyr
874c910e24 Fix: hotkeys prevent default browser behavior #135, Fix: mark audiobook as read when less than 10s remaining 2021-10-24 14:02:49 -05:00
advplyr
aca88f73ad Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2021-10-24 11:32:59 -05:00
advplyr
ed80e15b7d Fix: stream to use actual timestamp, update start time to tenth of second string #116 2021-10-24 11:32:52 -05:00
advplyr
3f13d35241 Merge pull request #136 from zv0n/master
Updated Readme to include Apache config.
2021-10-24 06:29:22 -05:00
zvon
05be496817 Updated Readme to include Apache config. 2021-10-24 10:36:34 +02:00
advplyr
1cca288031 Version update 2021-10-23 21:09:23 -05:00
advplyr
d05e9ebfdd Merge pull request #131 from jarrodCoombes/master
Updated Readme to include nginx config.
2021-10-23 20:32:07 -05:00
advplyr
92c2c53c09 Add: track manager sort by filename #128, Add: Support publish year wrapped in parenthesis, Change: Parse out CD Num from filename, Change: Show First Last author everywhere when Last, First is used in folder name, Fix: Re-Scan updates parsed track nums 2021-10-23 20:31:48 -05:00
JRC
80f8a784c8 Fixed minor formatting issue. 2021-10-23 17:13:29 -07:00
JRC
de9c0ef034 Updated the readme to include the nginx settings 2021-10-23 17:12:32 -07:00
advplyr
b32b99418a Add: map series and series-part meta tags #129 2021-10-23 17:32:42 -05:00
advplyr
98c1ee01fd Add: add hotkeys to modals, player, and ereader #121, Fix: audio player track not resizing on window resize #123, Add: sortable columns on manage tracks page #128 2021-10-23 16:49:34 -05:00
advplyr
97a065030e Change: ffmpeg stream avoid negative timestamp #116 2021-10-23 13:42:07 -05:00
advplyr
ee1dc92898 Fix: hls fmp4 init.mp4 filename to stream flac files 2021-10-23 10:57:01 -05:00
advplyr
74d2987310 Merge pull request #124 from zv0n/master
Change: Dockerfile install ffmpeg from source to support more architectures
2021-10-23 10:52:27 -05:00
advplyr
c20aaf3cb2 Fix: genre tag map check empty array and check metadata on rescan #114 2021-10-23 06:50:13 -05:00
zvon
994eb2862e Change: support more architectures in Dockerfile 2021-10-23 13:18:58 +02:00
advplyr
ff1eeda468 Change: config page to multiple pages, Add: user permissions for accessible libraries #120, Add: map genre metadata tag #114, Add: experimental audio player keyboard controls #121, Add: view user audiobook progress list 2021-10-22 20:08:02 -05:00
advplyr
7d9ed75a28 Change: audio player default volume to 100% #118, Change: username case insensitive #117, Fix: allowing multiple users of the same name, Added: experimental scan audio tracks show raw tags #114 2021-10-20 18:54:05 -05:00
advplyr
09aed354b3 Fix: icon font size 2021-10-18 19:53:33 -05:00
advplyr
e3425acd75 Fix icon size issue, clean up reader interface, extract metadata from comics #113 2021-10-17 18:05:43 -05:00
advplyr
03e39640be Fix series displayed on global search menu book 2021-10-17 12:18:26 -05:00
advplyr
48f0e039e5 New search page, updated search menu includes tags #112 2021-10-17 11:29:52 -05:00
advplyr
aa872948d5 Fonts saved locally #111, Fix select cover from search 2021-10-17 07:09:40 -05:00
advplyr
88e2bac3f5 Support cbr and cbz comics and comic reader #109 2021-10-16 20:33:49 -05:00
advplyr
18c1d8f1a3 Add: user settings for mobile sort & filter 2021-10-16 15:49:34 -05:00
advplyr
b9dee8704f add db init logs for checking root user #81, add track number check on edit page #108 2021-10-16 07:49:12 -05:00
advplyr
03963aa9a1 change color of book read icon #105, basic .pdf reader #107, fix: cover path updating properly #102, step forward/backward from book edit modal #100, add all files tab to edit modal #99, select input auto submit on blur #98 2021-10-15 20:31:00 -05:00
advplyr
315592efe5 Backup manager to only zip specific directories in config #89 2021-10-14 19:22:59 -05:00
advplyr
cd6e99b4c3 Update users table info #94, Reorder libraries in config #95, Use dropdown for library menu #96, update mobi reader 2021-10-12 20:07:42 -05:00
advplyr
9715c53332 Fix: root user password change #93, Change: link to series filter on ab page #90, Add: basic mobi and azw3 ereader support 2021-10-11 16:59:41 -05:00
advplyr
120c70622a Fix folder browser, fix track number parsed from filename too large, add mp4 audiobook support 2021-10-10 19:29:22 -05:00
advplyr
04f92c33c2 Abort backup if it is getting too large #89, support for ebook only book folders #75 2021-10-10 16:36:21 -05:00
advplyr
0c168b3da4 New screenshot 2021-10-09 14:54:45 -05:00
advplyr
2ed0468b33 Banner3 2021-10-09 13:17:07 -05:00
advplyr
d9c8aa287d Banner 2 2021-10-09 13:14:41 -05:00
advplyr
1d118d1364 Banner 2021-10-09 13:13:34 -05:00
advplyr
c94d9e620c Readme 2021-10-09 13:06:10 -05:00
advplyr
ff68440d26 Readme 2021-10-09 12:19:29 -05:00
advplyr
125a8a8e32 Readme 2021-10-09 12:18:57 -05:00
advplyr
2d507a455e Readme 2021-10-09 12:18:31 -05:00
advplyr
32bc9d5282 Update global search, fix toggling between automated backup, add open search cover in new tab #83 2021-10-09 11:09:06 -05:00
advplyr
59d12ef5de Remove Match Tab 2021-10-08 17:51:09 -05:00
advplyr
e80ec10e8a Fix ebook url #75, download other files #75, fix book icon disappearing #88, backups #87 2021-10-08 17:30:20 -05:00
advplyr
f752c19418 narrator filter, no series filter, full paths toggle, book landing page details, new sans font, update query string on filter/sort, persist experimental feature flag, batch edit redirect bug, upload file permissions and owner 2021-10-06 21:08:52 -05:00
advplyr
75aede914f Fix URL encoding, fix download m4b cover art 2021-10-06 13:00:12 -05:00
advplyr
19dcb6173e Fix download requests, v1.4.0 rollout 2021-10-06 07:23:32 -05:00
advplyr
d9d34e87e0 Support multi library 1.4.0 2021-10-05 21:10:49 -05:00
advplyr
a65f7e6fad Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2021-10-04 22:11:50 -05:00
advplyr
577f3bead9 Support for libraries and folder mapping, updating static cover path, detect reader.txt 2021-10-04 22:11:42 -05:00
advplyr
49072769a5 merge
Merge branch 'master' of https://github.com/advplyr/audiobookshelf
2021-10-03 19:41:53 +00:00
advplyr
a590e795e3 Fix ebook url, add opus to uploader, set chapters after track updates 2021-10-02 15:36:33 -05:00
advplyr
b20cbb020a Fix ebook url, add opus to uploader, set chapters after track updates 2021-10-02 15:36:33 -05:00
advplyr
1637b6c7f0 merge 2021-10-02 13:55:40 -05:00
advplyr
78449d918b merge 2021-10-02 13:55:40 -05:00
advplyr
bd336345ee Support for opus audio type, experimental features toggle, epub reader starting point 2021-10-02 13:50:39 -05:00
advplyr
326663d9d9 Support for opus audio type, experimental features toggle, epub reader starting point 2021-10-02 13:50:39 -05:00
advplyr
ef2b9a0415 Scan for covers now saves covers, server settings to save covers in audiobook folder 2021-10-02 11:27:13 +00:00
advplyr
509d9e5e03 Scan for covers now saves covers, server settings to save covers in audiobook folder 2021-10-02 11:27:13 +00:00
Mark Cooper
3dd8dc6dd4 Scan for covers now saves covers, server settings to save covers in audiobook folder 2021-10-01 20:29:00 -05:00
advplyr
a4bf3a759f Scan for covers now saves covers, server settings to save covers in audiobook folder 2021-10-01 20:29:00 -05:00
Mark Cooper
8d9d5a8d1b Always sync file inodes, save http url covers in cover directory 2021-10-01 18:42:48 -05:00
advplyr
28cbe0a95c Always sync file inodes, save http url covers in cover directory 2021-10-01 18:42:48 -05:00
Mark Cooper
0db34dcab5 Scanner update check for mismatched inode and update 2021-10-01 14:52:10 -05:00
advplyr
4e45ff83c6 Scanner update check for mismatched inode and update 2021-10-01 14:52:10 -05:00
Mark Cooper
47c6c1aaad Readme update 2021-09-30 19:57:13 -05:00
advplyr
d753683090 Readme update 2021-09-30 19:57:13 -05:00
Mark Cooper
d6cab8e591 logLevel as server setting, logger config page, re-scan audiobook option, fix embedded cover extraction, flac and mobi support, fix series bookshelf not wrapping 2021-09-30 18:52:32 -05:00
advplyr
de5baba930 logLevel as server setting, logger config page, re-scan audiobook option, fix embedded cover extraction, flac and mobi support, fix series bookshelf not wrapping 2021-09-30 18:52:32 -05:00
Mark Cooper
dc18eb408e Scanner v4, audio file metadata used in setting book details, embedded cover art extracted and used 2021-09-29 20:43:36 -05:00
advplyr
b26c1ba886 Scanner v4, audio file metadata used in setting book details, embedded cover art extracted and used 2021-09-29 20:43:36 -05:00
Mark Cooper
6f891208d0 Update readme 2021-09-29 11:04:35 -05:00
advplyr
b74b12301c Update readme 2021-09-29 11:04:35 -05:00
Mark Cooper
643040e635 Readme update 2021-09-29 10:52:59 -05:00
advplyr
bb7e580ee3 Readme update 2021-09-29 10:52:59 -05:00
advplyr
c82d888b5a Write metadata file option, rate limiting login attempts, generic failed login message 2021-09-29 10:16:38 -05:00
advplyr
07207b091d Readme update 2021-09-28 17:59:44 -05:00
advplyr
8dc0acb6b7 Fix config page not scrolling, add scroll arrows on home page, fix routing issue on back button, fix continue reading shelf 2021-09-28 17:36:41 -05:00
advplyr
8389a31775 Starting point for home page 2021-09-28 06:44:40 -05:00
advplyr
893032bcc0 Remove ino from file tables 2021-09-27 07:02:31 -05:00
advplyr
26b37def46 Fixing scanner inodes, select all fix, starting ebook reader 2021-09-27 06:52:21 -05:00
advplyr
29ba62357d Fix docs link url 2021-09-26 17:21:10 -05:00
advplyr
8ddfc6e7ac Readme update docs 2021-09-26 17:20:41 -05:00
advplyr
ef8ec3d7ab Hide volume number on selection mode 2021-09-26 15:44:25 -05:00
advplyr
68f534a97e Series order by volume number, show volume number, keyword filter, fix overflow bug 2021-09-26 15:34:08 -05:00
advplyr
0d556c3f76 Readme audiobookshelf.org 2021-09-26 12:08:48 -05:00
advplyr
8c91ef85c9 Readme update install instructions 2021-09-25 17:58:06 -05:00
advplyr
ac950f2173 linux installer use existing config 2021-09-25 17:37:21 -05:00
advplyr
bf1e6051be Fix installer 2021-09-25 17:14:06 -05:00
advplyr
0eb00c8820 Fix linuxpackager control file 2021-09-25 17:10:02 -05:00
advplyr
6067aecc4f Linuxpackager create debian control file 2021-09-25 17:07:48 -05:00
advplyr
f0b070ca1b Increment version, linuxpacakger executable permissions 2021-09-25 16:45:53 -05:00
advplyr
34f0815319 Linuxpackager to use /dist directory 2021-09-25 16:34:36 -05:00
advplyr
2ed2790550 Adding linux and ppa install to readme 2021-09-25 16:23:57 -05:00
advplyr
e2a6bf6c1a Preinstall prompt more readable 2021-09-25 14:14:22 -05:00
advplyr
cce860ff74 Debian package remove conf file, generate in preinst now 2021-09-25 14:05:27 -05:00
advplyr
73ef4cd4b5 Linux builder bash script 2021-09-25 14:02:50 -05:00
advplyr
d95689a078 Debian preinstall script for config and ffmpeg 2021-09-25 13:01:53 -05:00
advplyr
784ffbc568 Fix check old streams in metadata, download manager worker thread path 2021-09-25 10:35:33 -05:00
advplyr
77eee0c85a Update debian build 2021-09-24 20:18:02 -05:00
advplyr
0d33ed98d9 Debian package builder 2021-09-24 19:37:35 -05:00
advplyr
4de3ffac63 build script permissions workaround 2021-09-24 17:38:35 -05:00
advplyr
be6da8dad9 Fix build script 2021-09-24 17:26:10 -05:00
advplyr
ec23250273 Ignore dist dir 2021-09-24 17:23:40 -05:00
advplyr
8fdbe58da8 Build scripts 2021-09-24 17:21:47 -05:00
advplyr
e1fd74caaf Update chapters modal, search page, fix version check, ignore matching audio file paths on rescan 2021-09-24 16:14:33 -05:00
advplyr
37c38e69df Side rail, book group cards, fix dropdown select 2021-09-24 07:32:38 -05:00
advplyr
31e109d0f0 Pkg scripts win/linux 2021-09-22 20:40:35 -05:00
advplyr
b23f9362ef Book cover uploader, moving streams to /metadata/streams, adding jwt auth from query string, auth check static metadata 2021-09-21 20:57:33 -05:00
advplyr
ae1b94e991 Fix player track tooltip overflowing page 2021-09-21 17:28:43 -05:00
advplyr
03feaf6e5a Fix package.json script 2021-09-21 16:55:32 -05:00
advplyr
c821ef424f Player track chapter tickmarks, highlight current chapter, progress filters, links in stream container 2021-09-21 16:42:01 -05:00
advplyr
9e668bbf35 Remove production from prod script 2021-09-19 20:38:24 -05:00
advplyr
9e8ea43d5b Update readme for running on local. Add command line arg parser. 2021-09-19 19:52:08 -05:00
advplyr
c7b01165fd Add script & file for running production without docker 2021-09-19 19:22:35 -05:00
advplyr
a63d76985c Remove test stream, add prod script 2021-09-18 16:42:20 -05:00
advplyr
ea61400329 Update regex for volume scanner 2021-09-18 13:26:02 -05:00
advplyr
3ba3069894 Fix regex misplaced \b in volume parser 2021-09-18 13:09:30 -05:00
advplyr
6738c51f6e Readme update 2021-09-18 12:53:52 -05:00
advplyr
ef587a5f58 Readme update 2021-09-18 12:53:14 -05:00
advplyr
540f3e4196 Readme update 2021-09-18 12:52:38 -05:00
advplyr
043b353333 Readme Update 2021-09-18 12:50:22 -05:00
advplyr
737f8342f4 Readme update 2021-09-18 12:49:21 -05:00
advplyr
c884fd6880 Adding upload permission to users, directory structure readme update 2021-09-18 12:45:34 -05:00
advplyr
07994d38d5 Add volume number parsing to scanner 2021-09-18 11:13:05 -05:00
advplyr
80e2e35dcd Missing audiobooks flagged not deleted, fix close progress loop on stream errors, clickable download toast, consolidate duplicate track error log, improved scanner to ignore non-audio files 2021-09-17 18:40:30 -05:00
advplyr
bb7e79004d Fix sort by volume number, show batch read/not read update for users 2021-09-17 14:15:15 -05:00
advplyr
62d0a82dde Readme upcoming features update 2021-09-16 08:44:39 -05:00
advplyr
5f69339a27 Add batch read/not read update, Update tooltip positions 2021-09-16 08:37:09 -05:00
advplyr
bd0e8518be Add version checker 2021-09-15 17:59:38 -05:00
advplyr
a56b3a8096 Adding download zip file, fix local cover art for m4b download 2021-09-14 20:45:00 -05:00
advplyr
a7c538193c Add audiobook uploader 2021-09-13 20:18:58 -05:00
advplyr
5dd1542af0 Update user progress reset and delete logic 2021-09-12 18:22:52 -05:00
advplyr
9107620b3c Add socket event to remove download, fix clearInterval on stream loop 2021-09-12 16:10:12 -05:00
advplyr
c30955f909 Update merge for m4b files, add progress event for local audiobooks 2021-09-11 19:59:48 -05:00
advplyr
beaa1e14bb Update scanner v3, add isActive support for users 2021-09-10 19:55:02 -05:00
advplyr
394d312282 Merge tracks with codec copy' 2021-09-09 05:10:55 -05:00
advplyr
299fc95c78 Adding chapters and downloading m4b file 2021-09-08 09:15:54 -05:00
advplyr
07a2a0aefd Auto add/update/remove audiobooks, update screenshots 2021-09-06 20:14:04 -05:00
advplyr
2e82370408 Adding permissions per user, add volume number sort 2021-09-06 17:42:15 -05:00
advplyr
c8d857edb9 Fix details tab save 2021-09-06 16:11:37 -05:00
advplyr
11d7330c5d Update user audiobook progress model, add mark as read/not read, download individual tracks 2021-09-06 14:13:01 -05:00
advplyr
4f094df0b2 Editing accounts, change root account username, removed token expiration 2021-09-05 18:20:29 -05:00
advplyr
838d188504 Allow any utf-8 char in genre and tags, fix stream manager user undefined 2021-09-05 14:30:33 -05:00
advplyr
6f6a3f71b3 New filters using base64 strings, keyword filter 2021-09-05 13:21:02 -05:00
advplyr
a66a84bd2d Add Subtitle and Narrarator fields, add server settings object, scanner to parse out subtitles 2021-09-04 19:58:39 -05:00
advplyr
af0365c81f Improve track order detection, allow for excluding audio files from tracklist 2021-09-04 18:02:42 -05:00
advplyr
45ee42bddd Version 1.0.0, updating readme 2021-09-04 14:35:25 -05:00
advplyr
1aff255170 Adding download tab and download manager, ffmpeg in worker thread 2021-09-04 14:17:26 -05:00
advplyr
27956bd67a Fix server client.user undefined, update logo 2021-09-03 06:40:59 -05:00
advplyr
6a2d7af720 fix variable typo 2021-09-01 19:50:18 -05:00
advplyr
3c854ca32b Emit stream_closed even if stream is not found 2021-09-01 19:39:38 -05:00
advplyr
c5bc865e6c Update ver 2021-09-01 16:01:53 -05:00
advplyr
7a35342577 Logo update, fix book card shadow 2021-09-01 16:01:15 -05:00
advplyr
1b63d90cd7 Remove test fonts 2021-09-01 13:50:37 -05:00
advplyr
7ddb4f0539 Fix multi-select, add new book flag 2021-09-01 13:47:18 -05:00
advplyr
28966e191b Add m4a filetype 2021-08-27 14:35:16 -05:00
advplyr
35808abdf6 Adding and deleting users 2021-08-27 07:01:47 -05:00
advplyr
db0ecfb51b Batch updating and deleting, multi-select 2021-08-26 18:32:05 -05:00
advplyr
64cc7efa6a Fix set card size index on mount 2021-08-26 10:43:46 -05:00
advplyr
b4a62dbf4b Bookshelf cover size setting and widget 2021-08-26 09:47:51 -05:00
advplyr
091aa6ef82 Cover image aspect ratio solution 2021-08-26 08:04:52 -05:00
advplyr
c208e876d2 Fix update tracklist and invalid parts alert, update readme screenshots 2021-08-26 07:09:23 -05:00
advplyr
3141e74513 Sync tracks always 2021-08-25 19:32:17 -05:00
advplyr
1dc01615dd Fix listener for audiobook updates in edit modal, add remove cover button 2021-08-25 19:15:00 -05:00
advplyr
cb40e063da Adding inode to files and audiobooks to support renaming, setting up watcher and removing chokidar 2021-08-25 17:36:54 -05:00
advplyr
0c1a29adbf Parse and update author name on each update 2021-08-25 06:38:32 -05:00
advplyr
6e8270c5ea Emit update when cover is updated 2021-08-24 20:32:13 -05:00
advplyr
51357195e2 Clean and parse author name from directory, sort by author last name, scan for covers 2021-08-24 20:24:40 -05:00
advplyr
759be593b6 Fix ab undefined 2021-08-24 08:04:32 -05:00
advplyr
82241fd2f2 Fix incorrect audiobook file paths before a scan 2021-08-24 07:50:36 -05:00
advplyr
d63e582e56 package lock sync 2021-08-24 07:36:20 -05:00
advplyr
c59cc52667 Scanner update - remove and update audiobooks on scans 2021-08-24 07:15:56 -05:00
advplyr
db2f2d6660 Fix dynamic route requests, add auth middleware 2021-08-23 19:37:40 -05:00
advplyr
7ef977b783 Moving settings to be user specific, adding playbackRate setting, update playbackRate picker to go up to 3x 2021-08-23 18:31:04 -05:00
advplyr
40502aab1e Update readme 2021-08-23 14:20:33 -05:00
advplyr
33bbf1a49b Increment beta verison 2021-08-23 14:14:19 -05:00
advplyr
a93f409dcd Fix scan for audiobook directories in root dir 2021-08-23 14:08:54 -05:00
advplyr
5ecfaa88c2 Reset password and users table on settings page 2021-08-22 10:46:04 -05:00
advplyr
e7898377ed remove comment 2021-08-22 09:26:03 -05:00
advplyr
c3fd9045a8 Series as a dropdown and filter, fix genre list in details modal 2021-08-22 08:52:37 -05:00
advplyr
f70e1beca1 Add global search, add reset all audiobooks 2021-08-21 16:23:35 -05:00
advplyr
fb0a6f4ec2 Removing release-it 2021-08-21 14:02:10 -05:00
advplyr
696766945c Release 0.9.61-beta.0 2021-08-21 13:28:32 -05:00
advplyr
5e9d40b663 update release-update command 2021-08-21 13:27:46 -05:00
advplyr
1c453a3560 release-update command 2021-08-21 13:26:43 -05:00
advplyr
343657fe97 Release 0.9.61-beta 2021-08-21 13:15:45 -05:00
advplyr
e04b5d3152 Disable npm publish release-it 2021-08-21 13:13:33 -05:00
advplyr
30ce8fee76 Updating version to include -beta 2021-08-21 13:05:46 -05:00
advplyr
f30fa2fb0c Use local image as cover if found, adding release-it version control 2021-08-21 13:02:24 -05:00
advplyr
7d4e2e3d97 Update book finder and cover matching - includes LibGen provider 2021-08-21 09:15:44 -05:00
advplyr
744aacbb4b Adding audio playback speed control, updating volume control UI, fix stream play for small streams 2021-08-20 18:29:10 -05:00
advplyr
506f16c431 Fix readme 2021-08-19 19:21:04 -05:00
advplyr
08c3f84c06 Supporting more file structures for series and publish year 2021-08-19 19:14:24 -05:00
advplyr
7df0708f38 Update readme image urls 2021-08-19 17:32:40 -05:00
advplyr
d59aefd8c7 Cleaning up, adding readme and images, genre filter 2021-08-19 17:29:36 -05:00
advplyr
2c5b5dbeae removing .nuxt 2021-08-19 11:31:03 -05:00
advplyr
1c88c0a796 Sorting and filtering 2021-08-18 20:18:44 -05:00
advplyr
7e482352b1 Adding files tables, fixing loading when switching streams 2021-08-18 18:31:19 -05:00
advplyr
6e8fe32bf5 Sorting, fix user object bug, add settings module 2021-08-18 06:50:24 -05:00
advplyr
9a74825bad Add version to config 2021-08-17 17:51:07 -05:00
advplyr
dee7bcb2c2 Update cover url, audiobook player speed 2021-08-17 17:43:29 -05:00
advplyr
8fbdc1523d Update Logo, Add docker template 2021-08-17 17:19:57 -05:00
advplyr
6930e69b55 Init 2021-08-17 17:01:11 -05:00
160 changed files with 11398 additions and 1803 deletions

View File

@@ -6,6 +6,7 @@ npm-debug.log
/config
/audiobooks
/audiobooks2
/media/
/metadata
dev.js
test/

1
.gitignore vendored
View File

@@ -4,6 +4,7 @@ node_modules/
/config/
/audiobooks/
/audiobooks2/
/media/
/metadata/
test/
/client/.nuxt/

View File

@@ -1,19 +1,15 @@
### STAGE 0: FFMPEG ###
FROM jrottenberg/ffmpeg:4.1-alpine AS ffmpeg
### STAGE 1: Build client ###
### STAGE 0: Build client ###
FROM node:12-alpine AS build
WORKDIR /client
COPY /client /client
RUN npm install
RUN npm run generate
### STAGE 2: Build server ###
### STAGE 1: Build server ###
FROM node:12-alpine
RUN apk update && apk add --no-cache --update ffmpeg
ENV NODE_ENV=production
ENV LOG_LEVEL=INFO
COPY --from=build /client/dist /client/dist
COPY --from=ffmpeg / /
COPY index.js index.js
COPY package.json package.json
COPY server server

View File

@@ -1,4 +1,6 @@
@import url('./transitions.css');
@import './fonts.css';
@import './transitions.css';
@import './draggable.css';
.page {
width: 100%;
@@ -51,6 +53,19 @@
opacity: 0;
}
/* Chrome, Safari, Edge, Opera */
.no-spinner::-webkit-outer-spin-button,
.no-spinner::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
input[type=number] {
-moz-appearance: textfield;
}
.tracksTable {
border-collapse: collapse;
width: 100%;
@@ -66,10 +81,10 @@
background-color: #474747;
}
.tracksTable td {
padding: 4px;
padding: 4px 8px;
}
.tracksTable th {
padding: 4px;
padding: 4px 8px;
font-size: 0.75rem;
}

View File

@@ -0,0 +1,38 @@
.flip-list-move {
transition: transform 0.5s;
}
.no-move {
transition: transform 0s;
}
.ghost {
opacity: 0.5;
background-color: rgba(255, 255, 255, 0.25);
}
.list-group {
min-height: 30px;
}
#librariesTable .item {
cursor: n-resize;
}
.list-group-item:not(.exclude) {
cursor: n-resize;
}
.list-group-item.exclude {
cursor: not-allowed;
}
.list-group-item:not(.ghost):not(.exclude):hover {
background-color: rgba(0, 0, 0, 0.1);
}
.list-group-item:nth-child(even):not(.ghost):not(.exclude) {
background-color: rgba(0, 0, 0, 0.25);
}
.list-group-item:nth-child(even):not(.ghost):not(.exclude):hover {
background-color: rgba(0, 0, 0, 0.1);
}
.list-group-item.exclude:not(.ghost) {
background-color: rgba(255, 0, 0, 0.25);
}
.list-group-item.exclude:not(.ghost):hover {
background-color: rgba(223, 0, 0, 0.25);
}

View File

@@ -0,0 +1,506 @@
/*
Calibres stylesheet
*/
export default `
@charset "UTF-8";
/*
Calibre styles
*/
.arabic {
display: block;
list-style-type: decimal;
margin-bottom: 1em;
margin-right: 0;
margin-top: 1em;
text-align: justify
}
.attribution {
display: block;
font-size: 1em;
line-height: 1.2;
text-align: left;
margin: 0.3em 0
}
.big {
font-size: 1.375em;
line-height: 1.2
}
.big1 {
font-size: 1em
}
.block {
display: block;
text-align: justify;
margin: 1em 1em 2em
}
.block1 {
display: block;
text-align: justify;
margin: 1em 4em
}
.block2 {
display: block;
text-align: justify;
margin: 1em 1em 1em 2em
}
.bullet {
display: block;
list-style-type: disc;
margin-bottom: 1em;
margin-right: 0;
margin-top: 1em;
text-align: disc
}
.calibre {
background-color: #000007;
display: block;
font-family: Charis, "Times New Roman", Verdana, Arial;
font-size: 1.125em;
line-height: 1.2;
padding-left: 0;
padding-right: 0;
text-align: center;
margin: 0 5pt
}
.calibre1 {
display: block
}
.calibre2 {
height: auto;
width: auto
}
.calibre3:not(strong) {
display: block;
font-family: Charis, "Times New Roman", Verdana, Arial;
font-size: 1.125em;
line-height: 1.2;
padding-left: 0;
padding-right: 0;
margin: 0 5pt
}
.calibre4 {
font-weight: bold
}
.calibre5 {
font-style: italic
}
.calibre6 {
background-color: #FFF;
display: block;
font-family: Charis, "Times New Roman", Verdana, Arial;
font-size: 1.125em;
line-height: 1.2;
padding-left: 0;
padding-right: 0;
text-align: center;
margin: 0 5pt
}
.calibre7 {
display: list-item
}
.calibre8 {
font-size: 1em;
line-height: 1.2;
vertical-align: super
}
.calibre9 {
border-collapse: separate;
border-spacing: 2px;
display: table;
margin-bottom: 0;
margin-top: 0;
text-indent: 0
}
.calibre10 {
display: table-row;
vertical-align: middle
}
.calibre11 {
display: table-cell;
text-align: right;
vertical-align: inherit;
padding: 1px
}
.calibre12 {
display: table-cell;
text-align: left;
vertical-align: inherit;
padding: 1px
}
.calibre13 {
height: 1em;
width: auto
}
.calibre14 {
font-size: 0.88889em;
line-height: 1.2;
vertical-align: super
}
.calibre15 {
font-size: 1em;
line-height: 1.2;
vertical-align: sub
}
.calibre16 {
display: block;
list-style-type: decimal;
margin-bottom: 1em;
margin-right: 0;
margin-top: 1em
}
.calibre17 {
display: block;
font-size: 1.125em;
font-weight: bold;
line-height: 1.2;
margin: 0.83em 0
}
.center {
display: block;
text-align: center;
margin: 1em 0
}
.center1 {
display: block;
font-size: 1em;
font-weight: bold;
line-height: 1.2;
text-align: center;
margin: -2em 0 3em
}
.center2 {
display: block;
font-size: 1em;
font-weight: bold;
line-height: 1.2;
text-align: center;
margin: 2em 0 1em
}
.center3 {
display: block;
text-align: center;
margin: -1em 0 1em
}
.center4 {
display: block;
text-align: center;
text-indent: 3%;
margin: 1em 0
}
.chapter {
display: block;
font-size: 1.125em;
font-weight: bold;
line-height: 2em;
text-align: center;
margin: 2em 0 1em
}
.chapter1 {
display: block;
font-size: 0.88889em;
line-height: 1.2;
margin-left: 0.5em;
margin-right: 0.5em;
margin-top: 2em
}
.chapter2 {
display: block;
font-size: 1.125em;
font-weight: bold;
line-height: 2em;
text-align: center;
margin: 2em 0 3em
}
.copyright {
display: block;
font-size: 0.88889em;
line-height: 1.2;
margin-top: 4em;
text-align: center
}
.dedication {
display: block;
font-size: 0.88889em;
line-height: 1.2;
margin-top: 4em
}
.dropcaps {
float: left;
font-size: 3.4375rem;
line-height: 50px;
margin-right: 0.09em;
margin-top: -0.05em;
padding-top: 1px
}
.dropcaps1 {
float: left;
font-size: 3.4375rem;
line-height: 50px;
margin-right: 0.09em;
margin-top: 0.15em;
padding-top: 1px
}
.extract {
display: block;
text-align: justify;
margin: 2em 0 0.3em
}
.extract1 {
display: block;
text-align: justify;
text-indent: 3%;
margin: 2em 0 0.3em
}
.extract2 {
display: block;
text-align: justify;
margin: 1em 0 0.3em
}
.footnote {
border-bottom-style: solid;
border-bottom-width: 0;
border-left-style: solid;
border-left-width: 0;
border-right-style: solid;
border-right-width: 0;
border-top-style: solid;
border-top-width: 1px;
display: block;
font-size: 1em;
line-height: 1.2;
margin-top: 2 em
}
.footnote1 {
display: block;
text-align: justify;
margin: 0.3em 0 0.3em 2
}
.footnote2 {
border-bottom-style: solid;
border-bottom-width: 0;
border-left-style: solid;
border-left-width: 0;
border-right-style: solid;
border-right-width: 0;
border-top-style: solid;
border-top-width: 1px;
display: block;
font-size: 0.88889em;
line-height: 1.2;
margin-top: 2 em
}
.hanging {
display: block;
font-size: 0.88889em;
line-height: 1.2;
text-align: left;
text-indent: -1em;
margin: 0.5em 0 0.3em 1em
}
.hanging1 {
display: block;
font-size: 0.88889em;
line-height: 1.2;
text-align: left;
text-indent: -1em;
margin: 0.5em 0 0.3em 1.5em
}
.hanging2 {
display: block;
font-size: 1em;
line-height: 1.2;
text-indent: -1em;
margin: 0.5em 0 0.3em 1em
}
.hanging3 {
display: block;
font-size: 1em;
line-height: 1.2;
text-align: left;
text-indent: 1em;
margin: 0.1em 0 0.3em 1em
}
.hanging4 {
display: block;
font-size: 1em;
line-height: 1.2;
text-align: left;
text-indent: 0.1em;
margin: 0.1em 0 0.3em 1em
}
a.hlink {
text-decoration: none
}
.indent {
display: block;
text-align: justify;
text-indent: 1em;
margin: 0.3em 0
}
.line {
border-top: currentColor solid 1px;
border-bottom: currentColor solid 1px
}
.loweralpha {
display: block;
list-style-type: lower-alpha;
margin-bottom: 1em;
margin-right: 0;
margin-top: 1em;
text-align: justify
}
.none {
display: block;
list-style-type: none;
margin-bottom: 1em;
margin-right: 0;
margin-top: 1em;
text-align: justify
}
.none1 {
display: block;
list-style-type: none;
margin-bottom: 0;
margin-right: 0;
margin-top: 0;
text-align: justify
}
.nonindent {
display: block;
text-align: justify;
margin: 0.3em 0
}
.nonindent1 {
display: block;
font-size: 1.125em;
line-height: 1.2;
text-indent: -1em;
margin: 0.5em 0 0.3em 0.1em
}
.nonindent2 {
display: block;
font-size: 1.125em;
line-height: 1.2;
text-indent: -1em;
margin: 0.5em 0 0.3em -0.5em
}
.nonindent3 {
display: block;
text-align: justify;
text-indent: 3%;
margin: 0.3em 0
}
.part {
display: block;
font-size: 1em;
font-weight: bold;
line-height: 2em;
text-align: center;
margin: 4em 0 1em
}
.preface {
display: block;
font-size: 0.88889em;
line-height: 1.2;
margin-left: 2em;
margin-right: 2em;
text-align: justify
}
.pubhlink {
color: green;
text-decoration: none
}
.right {
display: block;
text-align: right;
margin: 0.3em 0
}
.section {
display: block;
font-size: 1.125em;
font-weight: bold;
line-height: 1.2;
text-align: center;
margin: 2em 0 0.5em
}
.section1 {
display: block;
font-size: 1.125em;
font-weight: bold;
line-height: 1.2;
text-align: left;
margin: 2em 0 0.3em
}
.section2 {
display: block;
font-size: 1em;
font-weight: bold;
line-height: 1.2;
text-align: left;
margin: 2em 0 0.3em 1em
}
.small {
font-size: 0.66667em
}
.small1 {
font-size: 0.75em
}
.subchapter {
display: block;
font-size: 1.125em;
font-weight: bold;
line-height: 1.2;
margin: 1em 0
}
.textbox {
background-color: #E4E4E4;
display: block;
line-height: 1.5em;
margin-bottom: 2em;
margin-top: 2em;
text-align: justify;
border-top: currentColor double 2px;
border-bottom: currentColor double 2px
}
.textbox1 {
display: block;
text-align: justify;
margin: 0.3em 0.5em 0.3em 0.8em
}
.textbox2 {
display: block;
text-align: justify;
text-indent: 1em;
margin: 0.3em 0.5em
}
.textbox3 {
display: block;
text-align: justify;
text-indent: 3%;
margin: 0.3em 0.5em 0.3em 0.8em
}
.titlepage {
display: block;
margin-left: -0.4em;
margin-top: 1.2em
}
.toc {
display: block;
font-size: 1em;
line-height: 1.2;
text-align: center
}
.toc1 {
display: block;
font-size: 1em;
font-weight: bold;
line-height: 1.2;
text-align: center;
margin: 0.67em 0 3em
}
.underline {
text-decoration: underline
}
`

View File

@@ -0,0 +1,248 @@
/*
This is borrowed from koodo-reader https://github.com/troyeguo/koodo-reader/tree/master/src
*/
export const isTitle = (
line,
isContainDI = false,
isContainChapter = false,
isContainCHAPTER = false
) => {
return (
line.length < 30 &&
line.indexOf("[") === -1 &&
line.indexOf("(") === -1 &&
(line.startsWith("CHAPTER") ||
line.startsWith("Chapter") ||
line.startsWith("序章") ||
line.startsWith("前言") ||
line.startsWith("声明") ||
line.startsWith("聲明") ||
line.startsWith("写在前面的话") ||
line.startsWith("后记") ||
line.startsWith("楔子") ||
line.startsWith("后序") ||
line.startsWith("寫在前面的話") ||
line.startsWith("後記") ||
line.startsWith("後序") ||
/(?=[MDCLXVI])M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$/.test(
line
) ||
(line.startsWith("第") && startWithDI(line)) ||
(line.startsWith("卷") && startWithJUAN(line)) ||
startWithRomanNum(line) ||
(!isContainDI &&
!isContainChapter &&
!isContainCHAPTER &&
line.indexOf("第") > -1 &&
(line[line.indexOf("第") - 1] === " " ||
line[line.indexOf("第") - 1] === " " ||
line[line.indexOf("第") - 1] === "、" ||
line[line.indexOf("第") - 1] === "" ||
line[line.indexOf("第") - 1] === ":") &&
startWithDI(line.substr(line.indexOf("第")))) ||
(!isContainDI &&
!isContainChapter &&
!isContainCHAPTER &&
line.indexOf(" ") &&
startWithNumAndSpace(line)) ||
(!isContainDI &&
!isContainChapter &&
!isContainCHAPTER &&
line.indexOf(" ") &&
startWithNumAndSpace(line)) ||
(!isContainDI &&
!isContainChapter &&
!isContainCHAPTER &&
line.indexOf("、") &&
startWithNumAndPause(line)) ||
(!isContainDI &&
!isContainChapter &&
!isContainCHAPTER &&
line.indexOf("") &&
startWithNumAndColon(line)) ||
(!isContainDI &&
!isContainChapter &&
!isContainCHAPTER &&
line.indexOf(":") &&
startWithNumAndColon(line)))
);
};
const startWithDI = (line) => {
let keywords = [
"章",
"节",
"回",
"節",
"卷",
"部",
"輯",
"辑",
"話",
"集",
"话",
"篇",
];
let flag = false;
for (let i = 0; i < keywords.length; i++) {
if (
(line.indexOf(keywords[i]) > -1 &&
(line[line.indexOf(keywords[i]) + 1] === " " ||
line[line.indexOf(keywords[i]) + 1] === " " ||
line[line.indexOf(keywords[i]) + 1] === "、" ||
line[line.indexOf(keywords[i]) + 1] === "" ||
line[line.indexOf(keywords[i]) + 1] === ":")) ||
!line[line.indexOf(keywords[i]) + 1]
) {
if (
/^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test(
line.substring(1, line.indexOf(keywords[i])).trim()
) ||
/^\d+$/.test(line.substring(1, line.indexOf(keywords[i])).trim())
) {
flag = true;
}
if (flag) break;
}
}
return flag;
};
const startWithJUAN = (line) => {
if (
/^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test(
line.substring(1, line.indexOf(" "))
) ||
/^\d+$/.test(line.substring(1, line.indexOf(" ")))
)
return true;
if (
/^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test(
line.substring(1, line.indexOf(" "))
) ||
/^\d+$/.test(line.substring(1, line.indexOf(" ")))
)
return true;
if (
/^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test(
line.substring(1)
) ||
/^\d+$/.test(line.substring(1))
)
return true;
return false;
};
const startWithRomanNum = (line) => {
if (
/(?=[MDCLXVI])M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$/.test(
line.substring(0, line.indexOf(" "))
)
)
return true;
if (
/(?=[MDCLXVI])M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$/.test(
line.substring(0, line.indexOf("."))
)
)
return true;
if (
/(?=[MDCLXVI])M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$/.test(
line.trim()
)
)
return true;
return false;
};
const startWithNumAndSpace = (line) => {
if (
/^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test(
line.substring(0, line.indexOf(" "))
)
)
return true;
if (
/^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test(
line.substring(0, line.indexOf(" "))
)
)
return true;
if (/^\d+$/.test(line.substring(0, line.indexOf(" ")))) return true;
if (/^\d+$/.test(line.substring(0, line.indexOf(" ")))) return true;
return false;
};
const startWithNumAndColon = (line) => {
if (
/^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test(
line.substring(0, line.indexOf(":"))
)
)
return true;
if (
/^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test(
line.substring(0, line.indexOf(""))
)
)
return true;
if (/^\d+$/.test(line.substring(0, line.indexOf(":")))) return true;
if (/^\d+$/.test(line.substring(0, line.indexOf("")))) return true;
return false;
};
const startWithNumAndPause = (line) => {
if (
/^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test(
line.substring(0, line.indexOf("、"))
)
)
return true;
if (/^\d+$/.test(line.substring(0, line.indexOf("、")))) return true;
return false;
};
class HtmlParser {
bookDoc;
contentList;
contentTitleList;
constructor(bookDoc) {
this.bookDoc = bookDoc;
this.contentList = [];
this.contentTitleList = [];
this.getContent(bookDoc);
}
getContent(bookDoc) {
this.contentList = Array.from(
bookDoc.querySelectorAll("h1,h2,h3,h4,h5,b,font")
).filter((item, index) => {
return isTitle(item.innerText.trim());
});
for (let i = 0; i < this.contentList.length; i++) {
let random = Math.floor(Math.random() * 900000) + 100000;
this.contentTitleList.push({
label: this.contentList[i].innerText,
id: "title" + random,
href: "#title" + random,
subitems: [],
});
}
for (let i = 0; i < this.contentList.length; i++) {
this.contentList[i].id = this.contentTitleList[i].id;
}
}
getAnchoredDoc() {
return this.bookDoc;
}
getContentList() {
return this.contentTitleList.filter((item, index) => {
if (index > 0) {
return item.label !== this.contentTitleList[index - 1].label;
} else {
return true;
}
});
}
}
export default HtmlParser;

View File

@@ -0,0 +1,450 @@
/*
This is borrowed from koodo-reader https://github.com/troyeguo/koodo-reader/tree/master/src
*/
function ab2str(buf) {
if (buf instanceof ArrayBuffer) {
buf = new Uint8Array(buf);
}
return new TextDecoder("utf-8").decode(buf);
}
var domParser = new DOMParser();
class Buffer {
capacity;
fragment_list;
imageArray;
cur_fragment;
constructor(capacity) {
this.capacity = capacity;
this.fragment_list = [];
this.imageArray = [];
this.cur_fragment = new Fragment(capacity);
this.fragment_list.push(this.cur_fragment);
}
write(byte) {
var result = this.cur_fragment.write(byte);
if (!result) {
this.cur_fragment = new Fragment(this.capacity);
this.fragment_list.push(this.cur_fragment);
this.cur_fragment.write(byte);
}
}
get(idx) {
var fi = 0;
while (fi < this.fragment_list.length) {
var frag = this.fragment_list[fi];
if (idx < frag.size) {
return frag.get(idx);
}
idx -= frag.size;
fi += 1;
}
return null;
}
size() {
var s = 0;
for (var i = 0; i < this.fragment_list.length; i++) {
s += this.fragment_list[i].size;
}
return s;
}
shrink() {
var total_buffer = new Uint8Array(this.size());
var offset = 0;
for (var i = 0; i < this.fragment_list.length; i++) {
var frag = this.fragment_list[i];
if (frag.full()) {
total_buffer.set(frag.buffer, offset);
} else {
total_buffer.set(frag.buffer.slice(0, frag.size), offset);
}
offset += frag.size;
}
return total_buffer;
}
}
var copagesne_uint8array = function (buffers) {
var total_size = 0;
for (let i = 0; i < buffers.length; i++) {
var buffer = buffers[i];
total_size += buffer.length;
}
var total_buffer = new Uint8Array(total_size);
var offset = 0;
for (let i = 0; i < buffers.length; i++) {
buffer = buffers[i];
total_buffer.set(buffer, offset);
offset += buffer.length;
}
return total_buffer;
};
class Fragment {
buffer;
capacity;
size;
constructor(capacity) {
this.buffer = new Uint8Array(capacity);
this.capacity = capacity;
this.size = 0;
}
write(byte) {
if (this.size >= this.capacity) {
return false;
}
this.buffer[this.size] = byte;
this.size += 1;
return true;
}
full() {
return this.size === this.capacity;
}
get(idx) {
return this.buffer[idx];
}
}
var uncompression_lz77 = function (data) {
var length = data.length;
var offset = 0; // Current offset into data
var buffer = new Buffer(data.length);
while (offset < length) {
var char = data[offset];
offset += 1;
if (char === 0) {
buffer.write(char);
} else if (char <= 8) {
for (var i = offset; i < offset + char; i++) {
buffer.write(data[i]);
}
offset += char;
} else if (char <= 0x7f) {
buffer.write(char);
} else if (char <= 0xbf) {
var next = data[offset];
offset += 1;
var distance = (((char << 8) | next) >> 3) & 0x7ff;
var lz_length = (next & 0x7) + 3;
var buffer_size = buffer.size();
for (let i = 0; i < lz_length; i++) {
buffer.write(buffer.get(buffer_size - distance));
buffer_size += 1;
}
} else {
buffer.write(32);
buffer.write(char ^ 0x80);
}
}
return buffer;
};
class MobiFile {
view;
buffer;
offset;
header;
palm_header;
mobi_header;
reclist;
constructor(data) {
this.view = new DataView(data);
this.buffer = this.view.buffer;
this.offset = 0;
this.header = null;
}
parse() { }
getUint8() {
var v = this.view.getUint8(this.offset);
this.offset += 1;
return v;
}
getUint16() {
var v = this.view.getUint16(this.offset);
this.offset += 2;
return v;
}
getUint32() {
var v = this.view.getUint32(this.offset);
this.offset += 4;
return v;
}
getStr(size) {
var v = ab2str(this.buffer.slice(this.offset, this.offset + size));
this.offset += size;
return v;
}
skip(size) {
this.offset += size;
}
setoffset(_of) {
this.offset = _of;
}
get_record_extrasize(data, flags) {
var pos = data.length - 1;
var extra = 0;
for (var i = 15; i > 0; i--) {
if (flags & (1 << i)) {
var res = this.buffer_get_varlen(data, pos);
var size = res[0];
var l = res[1];
pos = res[2];
pos -= size - l;
extra += size;
}
}
if (flags & 1) {
var a = data[pos];
extra += (a & 0x3) + 1;
}
return extra;
}
// data should be uint8array
buffer_get_varlen(data, pos) {
var l = 0;
var size = 0;
var byte_count = 0;
var mask = 0x7f;
var stop_flag = 0x80;
var shift = 0;
for (var i = 0; ; i++) {
var byte = data[pos];
size |= (byte & mask) << shift;
shift += 7;
l += 1;
byte_count += 1;
pos -= 1;
var to_stop = byte & stop_flag;
if (byte_count >= 4 || to_stop > 0) {
break;
}
}
return [size, l, pos];
}
// 读出文本内容
read_text() {
var text_end = this.palm_header.record_count;
var buffers = [];
for (var i = 1; i <= text_end; i++) {
buffers.push(this.read_text_record(i));
}
var all = copagesne_uint8array(buffers);
return ab2str(all);
}
read_text_record(i) {
var flags = this.mobi_header.extra_flags;
var begin = this.reclist[i].offset;
var end = this.reclist[i + 1].offset;
var data = new Uint8Array(this.buffer.slice(begin, end));
var ex = this.get_record_extrasize(data, flags);
data = new Uint8Array(this.buffer.slice(begin, end - ex));
if (this.palm_header.compression === 2) {
var buffer = uncompression_lz77(data);
return buffer.shrink();
} else {
return data;
}
}
// 从buffer中读出image
read_image(idx) {
var first_image_idx = this.mobi_header.first_image_idx;
var begin = this.reclist[first_image_idx + idx].offset;
var end = this.reclist[first_image_idx + idx + 1].offset;
var data = new Uint8Array(this.buffer.slice(begin, end));
return new Blob([data.buffer]);
}
load() {
this.header = this.load_pdbheader();
this.reclist = this.load_reclist();
this.load_record0();
}
load_pdbheader() {
var header = {};
header.name = this.getStr(32);
header.attr = this.getUint16();
header.version = this.getUint16();
header.ctime = this.getUint32();
header.mtime = this.getUint32();
header.btime = this.getUint32();
header.mod_num = this.getUint32();
header.appinfo_offset = this.getUint32();
header.sortinfo_offset = this.getUint32();
header.type = this.getStr(4);
header.creator = this.getStr(4);
header.uid = this.getUint32();
header.next_rec = this.getUint32();
header.record_num = this.getUint16();
return header;
}
load_reclist() {
var reclist = [];
for (var i = 0; i < this.header.record_num; i++) {
var record = {};
record.offset = this.getUint32();
// TODO(zz) change
record.attr = this.getUint32();
reclist.push(record);
}
return reclist;
}
load_record0() {
this.palm_header = this.load_record0_header();
this.mobi_header = this.load_mobi_header();
}
load_record0_header() {
var p_header = {};
var first_record = this.reclist[0];
this.setoffset(first_record.offset);
p_header.compression = this.getUint16();
this.skip(2);
p_header.text_length = this.getUint32();
p_header.record_count = this.getUint16();
p_header.record_size = this.getUint16();
p_header.encryption_type = this.getUint16();
this.skip(2);
return p_header;
}
load_mobi_header() {
var mobi_header = {};
var start_offset = this.offset;
mobi_header.identifier = this.getUint32();
mobi_header.header_length = this.getUint32();
mobi_header.mobi_type = this.getUint32();
mobi_header.text_encoding = this.getUint32();
mobi_header.uid = this.getUint32();
mobi_header.generator_version = this.getUint32();
this.skip(40);
mobi_header.first_nonbook_index = this.getUint32();
mobi_header.full_name_offset = this.getUint32();
mobi_header.full_name_length = this.getUint32();
mobi_header.language = this.getUint32();
mobi_header.input_language = this.getUint32();
mobi_header.output_language = this.getUint32();
mobi_header.min_version = this.getUint32();
mobi_header.first_image_idx = this.getUint32();
mobi_header.huff_rec_index = this.getUint32();
mobi_header.huff_rec_count = this.getUint32();
mobi_header.datp_rec_index = this.getUint32();
mobi_header.datp_rec_count = this.getUint32();
mobi_header.exth_flags = this.getUint32();
this.skip(36);
mobi_header.drm_offset = this.getUint32();
mobi_header.drm_count = this.getUint32();
mobi_header.drm_size = this.getUint32();
mobi_header.drm_flags = this.getUint32();
this.skip(8);
// TODO (zz) fdst_index
this.skip(4);
this.skip(46);
mobi_header.extra_flags = this.getUint16();
this.setoffset(start_offset + mobi_header.header_length);
return mobi_header;
}
load_exth_header() {
// TODO
return {};
}
extractContent(s) {
var span = document.createElement("span");
span.innerHTML = s;
return span.textContent || span.innerText;
}
render(isElectron = false) {
return new Promise((resolve, reject) => {
this.load();
var content = this.read_text();
var bookDoc = domParser.parseFromString(content, "text/html")
.documentElement;
let lines = Array.from(
bookDoc.querySelectorAll("p,b,font,h3,h2,h1")
);
let parseContent = [];
for (let i = 0, len = lines.length; i < len - 1; i++) {
lines[i].innerText &&
lines[i].innerText !== parseContent[parseContent.length - 1] &&
parseContent.push(lines[i].innerText);
let imgDoms = lines[i].getElementsByTagName("img");
if (imgDoms.length > 0) {
for (let i = 0; i < imgDoms.length; i++) {
parseContent.push("#image");
}
}
}
const handleImage = async () => {
var imgDoms = bookDoc.getElementsByTagName("img");
parseContent.push("~image");
for (let i = 0; i < imgDoms.length; i++) {
const src = await this.render_image(imgDoms, i);
parseContent.push(
src + " " + imgDoms[i].width + " " + imgDoms[i].height
);
}
if (imgDoms.length > 200 || !isElectron) {
resolve(bookDoc);
} else {
resolve(parseContent.join("\n \n"));
}
};
handleImage();
});
}
render_image = (imgDoms, i) => {
return new Promise((resolve, reject) => {
var imgDom = imgDoms[i];
var idx = +imgDom.getAttribute("recindex");
var blob = this.read_image(idx - 1);
var imgReader = new FileReader();
imgReader.onload = (e) => {
imgDom.src = e.target?.result;
resolve(e.target?.result);
};
imgReader.onerror = function (err) {
reject(err);
};
imgReader.readAsDataURL(blob);
});
};
}
export default MobiFile;

44
client/assets/fonts.css Normal file
View File

@@ -0,0 +1,44 @@
/* fallback */
@font-face {
font-family: 'Material Icons';
font-style: normal;
font-weight: 400;
src: url(/fonts/material-icons.woff2) format('woff2');
}
.material-icons {
font-family: 'Material Icons';
font-weight: normal;
font-style: normal;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-feature-settings: 'liga';
-webkit-font-smoothing: antialiased;
}
.material-icons:not(.text-xs):not(.text-sm):not(.text-md):not(.text-base):not(.text-lg):not(.text-xl):not(.text-2xl):not(.text-3xl):not(.text-4xl):not(.text-5xl):not(.text-6xl) {
font-size: 1.5rem;
}
@font-face {
font-family: 'Gentium Book Basic';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/GentiumBookBasic.woff2) format('woff2');
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;
}
/* latin */
@font-face {
font-family: 'Gentium Book Basic';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/GentiumBookBasic.woff2) format('woff2');
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;
}

View File

@@ -1,25 +1,29 @@
<template>
<div class="w-full">
<div class="w-full relative mb-4">
<div class="absolute left-2 top-0 bottom-0 h-full flex items-center">
<p ref="currentTimestamp" class="font-mono text-sm">00:00:00</p>
</div>
<div class="absolute right-2 top-0 bottom-0 h-full flex items-center">
<p class="font-mono text-sm">{{ totalDurationPretty }}</p>
<div class="w-full -mt-4">
<div class="w-full relative mb-2">
<div class="absolute top-0 left-0 w-full h-full bg-red flex items-end pointer-events-none">
<p ref="currentTimestamp" class="font-mono text-sm text-gray-100 pointer-events-auto">00:00:00</p>
<p class="font-mono text-sm text-gray-100 pointer-events-auto">&nbsp;/&nbsp;{{ progressPercent }}%</p>
<div class="flex-grow" />
<p class="font-mono text-sm text-gray-100 pointer-events-auto">{{ timeRemainingPretty }}</p>
</div>
<div class="absolute right-20 top-0 bottom-0">
<div v-if="chapters.length" class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="showChapters">
<div v-if="chapters.length" class="absolute right-20 top-0 bottom-0 h-full flex items-end">
<div class="cursor-pointer text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="showChapters">
<span class="material-icons text-3xl">format_list_bulleted</span>
</div>
</div>
<div class="absolute right-32 top-0 bottom-0">
<controls-volume-control v-model="volume" @input="updateVolume" />
<div v-if="showExperimentalFeatures" class="absolute top-0 bottom-0 h-full flex items-end" :class="chapters.length ? ' right-32' : 'right-20'">
<div class="cursor-pointer text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="showBookmarks">
<span class="material-icons" style="font-size: 1.7rem">{{ bookmarks.length ? 'bookmarks' : 'bookmark_border' }}</span>
</div>
</div>
<div class="absolute top-0 bottom-0 h-full flex items-end" :class="!showExperimentalFeatures ? (chapters.length ? ' right-32' : 'right-20') : chapters.length ? ' right-44' : 'right-32'">
<controls-volume-control ref="volumeControl" v-model="volume" @input="updateVolume" />
</div>
<div class="flex my-2">
<div class="flex-grow" />
<div class="flex pb-2">
<div class="flex-grow" />
<template v-if="!loading">
<div class="cursor-pointer flex items-center justify-center text-gray-300 mr-8" @mousedown.prevent @mouseup.prevent @click.stop="restart">
<span class="material-icons text-3xl">first_page</span>
@@ -85,13 +89,17 @@ export default {
chapters: {
type: Array,
default: () => []
},
bookmarks: {
type: Array,
default: () => []
}
},
data() {
return {
hlsInstance: null,
staleHlsInstance: null,
volume: 0.5,
volume: 1,
playbackRate: 1,
trackWidth: 0,
isPaused: true,
@@ -116,6 +124,17 @@ export default {
totalDurationPretty() {
return this.$secondsToTimestamp(this.totalDuration)
},
timeRemaining() {
if (!this.audioEl) return 0
return this.totalDuration - this.currentTime
},
timeRemainingPretty() {
return '-' + this.$secondsToTimestamp(this.timeRemaining)
},
progressPercent() {
if (!this.totalDuration) return 0
return Math.round((100 * this.currentTime) / this.totalDuration)
},
chapterTicks() {
return this.chapters.map((chap) => {
var perc = chap.start / this.totalDuration
@@ -127,6 +146,9 @@ export default {
},
currentChapter() {
return this.chapters.find((chapter) => chapter.start <= this.currentTime && this.currentTime < chapter.end)
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
}
},
methods: {
@@ -134,6 +156,11 @@ export default {
this.seek(chapter.start)
this.showChaptersModal = false
},
selectBookmark(bookmark) {
if (bookmark) {
this.seek(bookmark.time)
}
},
seek(time) {
if (this.loading) {
return
@@ -232,6 +259,7 @@ export default {
},
restart() {
this.seek(0)
this.$nextTick(this.sendStreamUpdate)
},
backward10() {
var newTime = this.audioEl.currentTime - 10
@@ -346,7 +374,7 @@ export default {
return
}
var lastbuff = this.getLastBufferedTime()
this.sendStreamUpdate()
var bufferlen = (lastbuff / this.audioEl.duration) * this.trackWidth
bufferlen = Math.round(bufferlen)
if (this.bufferTrackWidth === bufferlen || !this.$refs.bufferTrack) return
@@ -373,6 +401,13 @@ export default {
this.updateTimestamp()
// Send update to server when currentTime > 0
// this prevents errors when seeking to position not yet transcoded
// seeking to position not yet transcoded will cause audio element to set currentTime to 0
if (this.audioEl.currentTime) {
this.sendStreamUpdate()
}
this.currentTime = this.audioEl.currentTime
var perc = this.audioEl.currentTime / this.audioEl.duration
@@ -399,6 +434,7 @@ export default {
},
audioLoadedData() {
this.totalDuration = this.audioEl.duration
this.$emit('loaded', this.totalDuration)
},
set(url, currentTime, playOnLoad = false) {
if (this.hlsInstance) {
@@ -440,7 +476,7 @@ export default {
})
this.hlsInstance.on(Hls.Events.ERROR, (e, data) => {
console.error('[HLS] Error', data.type, data.details)
console.error('[HLS] Error', data.type, data.details, data)
if (data.details === Hls.ErrorDetails.BUFFER_STALLED_ERROR) {
console.error('[HLS] BUFFER STALLED ERROR')
}
@@ -451,7 +487,11 @@ export default {
})
},
showChapters() {
this.showChaptersModal = true
if (!this.chapters.length) return
this.showChaptersModal = !this.showChaptersModal
},
showBookmarks() {
this.$emit('showBookmarks', this.currentTime)
},
play() {
if (!this.$refs.audio) {
@@ -486,6 +526,9 @@ export default {
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
this.audioEl = this.$refs.audio
this.setTrackWidth()
},
setTrackWidth() {
if (this.$refs.track) {
this.trackWidth = this.$refs.track.clientWidth
} else {
@@ -496,14 +539,66 @@ export default {
if (settings.playbackRate && this.playbackRate !== settings.playbackRate) {
this.updatePlaybackRate(settings.playbackRate)
}
},
volumeUp() {
if (this.volume >= 1) return
this.volume = Math.min(1, this.volume + 0.1)
this.updateVolume(this.volume)
},
volumeDown() {
if (this.volume <= 0) return
this.volume = Math.max(0, this.volume - 0.1)
this.updateVolume(this.volume)
},
toggleMute() {
if (this.$refs.volumeControl && this.$refs.volumeControl.toggleMute) {
this.$refs.volumeControl.toggleMute()
}
},
increasePlaybackRate() {
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
if (currentRateIndex >= rates.length - 1) return
this.playbackRate = rates[currentRateIndex + 1] || 1
this.playbackRateChanged(this.playbackRate)
},
decreasePlaybackRate() {
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
if (currentRateIndex <= 0) return
this.playbackRate = rates[currentRateIndex - 1] || 1
this.playbackRateChanged(this.playbackRate)
},
closePlayer() {
if (this.loading) return
this.$emit('close')
},
hotkey(action) {
if (action === this.$hotkeys.AudioPlayer.PLAY_PAUSE) this.playPauseClick()
else if (action === this.$hotkeys.AudioPlayer.JUMP_FORWARD) this.forward10()
else if (action === this.$hotkeys.AudioPlayer.JUMP_BACKWARD) this.backward10()
else if (action === this.$hotkeys.AudioPlayer.VOLUME_UP) this.volumeUp()
else if (action === this.$hotkeys.AudioPlayer.VOLUME_DOWN) this.volumeDown()
else if (action === this.$hotkeys.AudioPlayer.MUTE_UNMUTE) this.toggleMute()
else if (action === this.$hotkeys.AudioPlayer.SHOW_CHAPTERS) this.showChapters()
else if (action === this.$hotkeys.AudioPlayer.INCREASE_PLAYBACK_RATE) this.increasePlaybackRate()
else if (action === this.$hotkeys.AudioPlayer.DECREASE_PLAYBACK_RATE) this.decreasePlaybackRate()
else if (action === this.$hotkeys.AudioPlayer.CLOSE) this.closePlayer()
},
windowResize() {
this.setTrackWidth()
}
},
mounted() {
window.addEventListener('resize', this.windowResize)
this.$store.commit('user/addSettingsListener', { id: 'audioplayer', meth: this.settingsUpdated })
this.init()
this.$eventBus.$on('player-hotkey', this.hotkey)
},
beforeDestroy() {
window.removeEventListener('resize', this.windowResize)
this.$store.commit('user/removeSettingsListener', 'audioplayer')
this.$eventBus.$off('player-hotkey', this.hotkey)
}
}
</script>

View File

@@ -7,16 +7,14 @@
<span class="material-icons text-4xl text-white">arrow_back</span>
</a>
<h1 class="text-2xl font-book mr-6">AudioBookshelf</h1>
<!-- <div class="-mb-2">
<h1 class="text-lg font-book leading-3 mr-6 px-1">AudioBookshelf</h1>
<div class="bg-black bg-opacity-20 rounded-sm py-1.5 px-2 mt-1.5 flex items-center justify-between border border-bg">
<p class="text-sm text-gray-400 leading-3">My Library</p>
<span class="material-icons text-sm leading-3 text-gray-400">expand_more</span>
</div>
</div> -->
<ui-libraries-dropdown />
<controls-global-search />
<div class="flex-grow" />
<span v-if="showExperimentalFeatures" class="material-icons text-4xl text-warning pr-4">logo_dev</span>
<nuxt-link v-if="userCanUpload" to="/upload" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center">
<span class="material-icons">upload</span>
</nuxt-link>
@@ -64,11 +62,17 @@ export default {
}
},
computed: {
currentLibrary() {
return this.$store.getters['libraries/getCurrentLibrary']
},
libraryName() {
return this.currentLibrary ? this.currentLibrary.name : 'unknown'
},
isHome() {
return this.$route.name === 'index'
return this.$route.name === 'library-library'
},
showBack() {
return this.$route.name !== 'library-id' && !this.isHome
return this.$route.name !== 'library-library-bookshelf-id' && !this.isHome
},
user() {
return this.$store.state.user.user
@@ -76,7 +80,6 @@ export default {
isRootUser() {
return this.$store.getters['user/getIsRoot']
},
username() {
return this.user ? this.user.username : 'err'
},
@@ -117,6 +120,9 @@ export default {
},
processingBatch() {
return this.$store.state.processingBatch
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
}
},
methods: {

View File

@@ -1,7 +1,7 @@
<template>
<div id="bookshelf" ref="wrapper" class="w-full h-full overflow-y-scroll relative">
<!-- Cover size widget -->
<div v-show="!isSelectionMode" class="fixed bottom-2 right-4 z-20">
<div v-show="!isSelectionMode" class="fixed bottom-2 right-4 z-30">
<div class="rounded-full py-1 bg-primary px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent>
<span class="material-icons" :class="selectedSizeIndex === 0 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="decreaseSize">remove</span>
<p class="px-2 font-mono">{{ bookCoverWidth }}</p>
@@ -16,6 +16,14 @@
<ui-btn color="success" class="w-52" @click="scan">Scan Audiobooks</ui-btn>
</div>
</div>
<div v-else-if="page === 'search'" id="bookshelf-categorized" class="w-full flex flex-col items-center">
<template v-for="(shelf, index) in categorizedShelves">
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" />
</template>
<div v-show="!categorizedShelves.length" class="w-full py-16 text-center text-xl">
<div class="py-4 mb-6"><p class="text-2xl">No Results</p></div>
</div>
</div>
<div v-else id="bookshelf" class="w-full flex flex-col items-center">
<template v-for="(shelf, index) in shelves">
<div :key="index" class="w-full bookshelfRow relative">
@@ -23,7 +31,7 @@
<template v-for="entity in shelf">
<cards-group-card v-if="showGroups" :key="entity.id" :width="bookCoverWidth" :group="entity" @click="clickGroup" />
<!-- <cards-book-3d :key="entity.id" v-else :width="100" :src="$store.getters['audiobooks/getBookCoverSrc'](entity.book)" /> -->
<cards-book-card v-else :key="entity.id" :show-volume-number="!!selectedSeries" :width="bookCoverWidth" :user-progress="userAudiobooks[entity.id]" :audiobook="entity" />
<cards-book-card v-else :key="entity.id" :show-volume-number="!!selectedSeries" :width="bookCoverWidth" :user-progress="userAudiobooks[entity.id]" :audiobook="entity" @edit="editBook" />
</template>
</div>
<div class="bookshelfDivider h-4 w-full absolute bottom-0 left-0 right-0 z-10" />
@@ -45,15 +53,15 @@ export default {
page: String,
selectedSeries: String,
searchResults: {
type: Array,
default: () => []
type: Object,
default: () => {}
},
searchQuery: String
},
data() {
return {
shelves: [],
currFilterOrderKey: null,
currSearchParams: null,
availableSizes: [60, 80, 100, 120, 140, 160, 180, 200, 220],
selectedSizeIndex: 3,
rowPaddingX: 40,
@@ -74,9 +82,16 @@ export default {
},
searchResults() {
this.$nextTick(() => {
this.$store.commit('audiobooks/setSearchResults', this.searchResults)
// this.$store.commit('audiobooks/setSearchResults', this.searchResults)
this.setBookshelfEntities()
})
},
'$route.query.filter'() {
if (this.$route.query.filter && this.$route.query.filter !== this.filterBy) {
this.$store.dispatch('user/updateUserSettings', { filterBy: this.$route.query.filter })
} else if (!this.$route.query.filter && this.filterBy) {
this.$store.dispatch('user/updateUserSettings', { filterBy: 'all' })
}
}
},
computed: {
@@ -89,8 +104,8 @@ export default {
audiobooks() {
return this.$store.state.audiobooks.audiobooks
},
filterOrderKey() {
return this.$store.getters['user/getFilterOrderKey']
sizeMultiplier() {
return this.bookCoverWidth / 120
},
bookCoverWidth() {
return this.availableSizes[this.selectedSizeIndex]
@@ -102,7 +117,8 @@ export default {
return 16 * this.sizeMultiplier
},
bookWidth() {
return this.bookCoverWidth + this.paddingX * 2
var _width = this.bookCoverWidth + this.paddingX * 2
return this.showGroups ? _width * 1.6 : _width
},
isSelectionMode() {
return this.$store.getters['getNumAudiobooksSelected']
@@ -110,14 +126,63 @@ export default {
filterBy() {
return this.$store.getters['user/getUserSetting']('filterBy')
},
orderBy() {
return this.$store.getters['user/getUserSetting']('orderBy')
},
orderDesc() {
return this.$store.getters['user/getUserSetting']('orderDesc')
},
showGroups() {
return this.page !== '' && this.page !== 'search' && !this.selectedSeries
},
categorizedShelves() {
if (this.page !== 'search') return []
var audiobookSearchResults = this.searchResults ? this.searchResults.audiobooks || [] : []
const shelves = []
if (audiobookSearchResults.length) {
shelves.push({
label: 'Books',
books: audiobookSearchResults.map((absr) => absr.audiobook)
})
}
if (this.searchResults.series && this.searchResults.series.length) {
var seriesGroups = this.searchResults.series.map((seriesResult) => {
return {
type: 'series',
name: seriesResult.series || '',
books: seriesResult.audiobooks || []
}
})
shelves.push({
label: 'Series',
series: seriesGroups
})
}
if (this.searchResults.tags && this.searchResults.tags.length) {
var tagGroups = this.searchResults.tags.map((tagResult) => {
return {
type: 'tags',
name: tagResult.tag || '',
books: tagResult.audiobooks || []
}
})
shelves.push({
label: 'Tags',
series: tagGroups
})
}
return shelves
},
entities() {
if (this.page === '') {
return this.$store.getters['audiobooks/getFilteredAndSorted']()
} else if (this.page === 'search') {
return this.searchResults || []
var audiobookSearchResults = this.searchResults ? this.searchResults.audiobooks || [] : []
return audiobookSearchResults.map((absr) => absr.audiobook)
} else {
var seriesGroups = this.$store.getters['audiobooks/getSeriesGroups']()
if (this.selectedSeries) {
@@ -129,6 +194,11 @@ export default {
}
},
methods: {
editBook(audiobook) {
var bookIds = this.entities.map((e) => e.id)
this.$store.commit('setBookshelfBookIds', bookIds)
this.$store.commit('showEditModal', audiobook)
},
clickGroup(group) {
this.$emit('update:selectedSeries', group.name)
},
@@ -161,9 +231,13 @@ export default {
setBookshelfEntities() {
this.wrapperClientWidth = this.$refs.wrapper.clientWidth
var width = Math.max(0, this.wrapperClientWidth - this.rowPaddingX * 2)
var booksPerRow = Math.floor(width / this.bookWidth)
this.currSearchParams = this.buildSearchParams()
var entities = this.entities
var groups = []
var currentRow = 0
var currentGroup = []
@@ -183,6 +257,8 @@ export default {
this.shelves = groups
},
async init() {
this.checkUpdateSearchParams()
this.wrapperClientWidth = this.$refs.wrapper ? this.$refs.wrapper.clientWidth : 0
var bookshelfCoverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
@@ -201,10 +277,41 @@ export default {
console.log('[AudioBookshelf] Audiobooks Updated')
this.setBookshelfEntities()
},
settingsUpdated(settings) {
if (this.currFilterOrderKey !== this.filterOrderKey) {
this.setBookshelfEntities()
buildSearchParams() {
if (this.page === 'search' || this.page === 'series') {
return ''
}
let searchParams = new URLSearchParams()
if (this.filterBy && this.filterBy !== 'all') {
searchParams.set('filter', this.filterBy)
}
if (this.orderBy) {
searchParams.set('order', this.orderBy)
searchParams.set('orderdesc', this.orderDesc ? 1 : 0)
}
return searchParams.toString()
},
checkUpdateSearchParams() {
var newSearchParams = this.buildSearchParams()
var currentQueryString = window.location.search
if (newSearchParams === '') {
return false
}
if (newSearchParams !== this.currSearchParams || newSearchParams !== currentQueryString) {
let newurl = window.location.protocol + '//' + window.location.host + window.location.pathname + '?' + newSearchParams
window.history.replaceState({ path: newurl }, '', newurl)
return true
}
return false
},
settingsUpdated(settings) {
var wasUpdated = this.checkUpdateSearchParams()
if (wasUpdated) this.setBookshelfEntities()
if (settings.bookshelfCoverSize !== this.bookCoverWidth && settings.bookshelfCoverSize !== undefined) {
var index = this.availableSizes.indexOf(settings.bookshelfCoverSize)
if (index >= 0) {
@@ -214,7 +321,7 @@ export default {
}
},
scan() {
this.$root.socket.emit('scan')
this.$root.socket.emit('scan', this.$store.state.libraries.currentLibraryId)
}
},
updated() {

View File

@@ -51,9 +51,6 @@ export default {
sizeMultiplier() {
return this.bookCoverWidth / 120
},
signSizeMultiplier() {
return (1 - this.sizeMultiplier) / 2 + this.sizeMultiplier
},
paddingX() {
return 16 * this.sizeMultiplier
},
@@ -94,13 +91,17 @@ export default {
return audiobooks.slice(0, 10)
},
shelves() {
var shelves = [
{ books: this.mostRecentPlayed, label: 'Continue Reading' },
{ books: this.mostRecentAdded, label: 'Recently Added' }
]
var shelves = []
if (this.mostRecentPlayed.length) {
shelves.push({ books: this.mostRecentPlayed, label: 'Continue Reading' })
}
shelves.push({ books: this.mostRecentAdded, label: 'Recently Added' })
if (this.recentlyUpdatedSeries) {
shelves.push({ books: this.recentlyUpdatedSeries, label: 'Newest Series' })
}
if (this.booksRecentlyRead.length) {
shelves.push({ books: this.booksRecentlyRead, label: 'Read Again' })
}
@@ -139,7 +140,7 @@ export default {
}
},
scan() {
this.$root.socket.emit('scan')
this.$root.socket.emit('scan', this.$store.state.libraries.currentLibraryId)
}
},
mounted() {

View File

@@ -2,9 +2,21 @@
<div class="relative">
<div ref="shelf" class="w-full max-w-full bookshelfRowCategorized relative overflow-x-scroll overflow-y-hidden z-10" :style="{ paddingLeft: 2.5 * sizeMultiplier + 'rem' }" @scroll="scrolled">
<div class="w-full h-full" :style="{ marginTop: sizeMultiplier + 'rem' }">
<div class="flex items-center -mb-2">
<div v-if="shelf.books" class="flex items-center -mb-2">
<template v-for="entity in shelf.books">
<cards-book-card :key="entity.id" :width="bookCoverWidth" :user-progress="userAudiobooks[entity.id]" :audiobook="entity" @hook:updated="updatedBookCard" />
<cards-book-card :key="entity.id" :width="bookCoverWidth" :user-progress="userAudiobooks[entity.id]" :audiobook="entity" @hook:updated="updatedBookCard" @edit="editBook" />
</template>
</div>
<div v-else-if="shelf.series" class="flex items-center -mb-2">
<template v-for="entity in shelf.series">
<cards-group-card :key="entity.name" :width="bookCoverWidth" :group="entity" @click="$emit('clickSeries', entity)" />
</template>
</div>
<div v-else-if="shelf.tags" class="flex items-center -mb-2">
<template v-for="entity in shelf.tags">
<nuxt-link :key="entity.name" :to="`/library/${currentLibraryId}/bookshelf?filter=tags.${$encode(entity.name)}`">
<cards-group-card :width="bookCoverWidth" :group="entity" />
</nuxt-link>
</template>
</div>
</div>
@@ -53,6 +65,11 @@ export default {
}
},
methods: {
editBook(audiobook) {
var bookIds = this.shelf.books.map((e) => e.id)
this.$store.commit('setBookshelfBookIds', bookIds)
this.$store.commit('showEditModal', audiobook)
},
scrolled() {
clearTimeout(this.scrollTimer)
this.scrollTimer = setTimeout(() => {
@@ -62,7 +79,6 @@ export default {
},
scrollLeft() {
if (!this.$refs.shelf) {
console.error('No Shelf', this.index)
return
}
this.isScrolling = true
@@ -70,7 +86,6 @@ export default {
},
scrollRight() {
if (!this.$refs.shelf) {
console.error('No Shelf', this.index)
return
}
this.isScrolling = true
@@ -84,7 +99,6 @@ export default {
},
checkCanScroll() {
if (!this.$refs.shelf) {
console.error('No Shelf', this.index)
return
}
var clientWidth = this.$refs.shelf.clientWidth

View File

@@ -1,20 +1,23 @@
<template>
<div class="w-full h-10 relative">
<div id="toolbar" class="absolute top-0 left-0 w-full h-full z-40 flex items-center px-8">
<div id="toolbar" class="absolute top-0 left-0 w-full h-full z-20 flex items-center px-8">
<template v-if="page !== 'search' && !isHome">
<p v-if="!selectedSeries" class="font-book">{{ numShowing }} {{ entityName }}</p>
<div v-else class="flex items-center">
<div @click="seriesBackArrow" class="rounded-full h-10 w-10 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
<span class="material-icons text-3xl text-white">west</span>
<div @click="seriesBackArrow" class="rounded-full h-9 w-9 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
<span class="material-icons text-2xl text-white">west</span>
</div>
<!-- <span class="material-icons text-2xl cursor-pointer" @click="seriesBackArrow">west</span> -->
<p class="pl-4 font-book text-lg">
{{ selectedSeries }} <span class="ml-3 font-mono text-lg bg-black bg-opacity-30 rounded-lg px-1 py-0.5">{{ numShowing }}</span>
{{ selectedSeries }}
</p>
<div class="w-6 h-6 rounded-full bg-black bg-opacity-30 flex items-center justify-center ml-3">
<span class="font-mono">{{ numShowing }}</span>
</div>
</div>
<div class="flex-grow" />
<ui-text-input v-show="!selectedSeries" v-model="_keywordFilter" placeholder="Keyword Filter" :padding-y="1.5" class="text-xs w-40" />
<ui-text-input v-show="!selectedSeries" v-model="_keywordFilter" placeholder="Keyword Filter" :padding-y="1.5" clearable class="text-xs w-40" />
<controls-filter-select v-show="showSortFilters" v-model="settings.filterBy" class="w-48 h-7.5 ml-4" @change="updateFilter" />
<controls-order-select v-show="showSortFilters" v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-48 h-7.5 ml-4" @change="updateOrder" />
</template>
@@ -38,8 +41,8 @@ export default {
isHome: Boolean,
selectedSeries: String,
searchResults: {
type: Array,
default: () => []
type: Object,
default: () => {}
},
searchQuery: String
},
@@ -57,7 +60,8 @@ export default {
if (this.page === '') {
return this.$store.getters['audiobooks/getFiltered']().length
} else if (this.page === 'search') {
return (this.searchResults || []).length
var audiobookSearchResults = this.searchResults ? this.searchResults.audiobooks || [] : []
return audiobookSearchResults.length
} else {
var groups = this.$store.getters['audiobooks/getSeriesGroups']()
if (this.selectedSeries) {
@@ -81,14 +85,17 @@ export default {
set(val) {
this.$store.commit('audiobooks/setKeywordFilter', val)
}
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
}
},
methods: {
searchBackArrow() {
this.$router.replace('/library')
this.$router.replace(`/library/${this.currentLibraryId}/bookshelf`)
},
seriesBackArrow() {
this.$router.replace('/library/series')
this.$router.replace(`/library/${this.currentLibraryId}/bookshelf/series`)
this.$emit('update:selectedSeries', null)
},
updateOrder() {

View File

@@ -0,0 +1,56 @@
<template>
<div class="w-44 fixed left-0 top-16 z-40 h-full bg-bg bg-opacity-70 shadow-lg border-r border-white border-opacity-5 py-4">
<nuxt-link to="/config" class="w-full px-4 h-12 border-b border-opacity-0 flex items-center cursor-pointer relative" :class="routeName === 'config' ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
<p>Settings</p>
<div v-show="routeName === 'config'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link to="/config/libraries" class="w-full px-4 h-12 border-b border-opacity-0 flex items-center cursor-pointer relative" :class="routeName === 'config-libraries' ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
<p>Libraries</p>
<div v-show="routeName === 'config-libraries'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link to="/config/users" class="w-full px-4 h-12 border-b border-opacity-0 flex items-center cursor-pointer relative" :class="routeName === 'config-users' ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
<p>Users</p>
<div v-show="routeName === 'config-users'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link to="/config/backups" class="w-full px-4 h-12 border-b border-opacity-0 flex items-center cursor-pointer relative" :class="routeName === 'config-backups' ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
<p>Backups</p>
<div v-show="routeName === 'config-backups'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link to="/config/log" class="w-full px-4 h-12 border-b border-opacity-0 flex items-center cursor-pointer relative" :class="routeName === 'config-log' ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
<p>Log</p>
<div v-show="routeName === 'config-log'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<div class="w-full h-12 px-4 border-t border-black border-opacity-20 absolute bottom-20 left-0 flex flex-col justify-center">
<p class="font-mono text-sm">v{{ $config.version }}</p>
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-sm">Update available: {{ latestVersion }}</a>
</div>
</div>
</template>
<script>
export default {
data() {
return {}
},
computed: {
routeName() {
return this.$route.name
},
versionData() {
return this.$store.state.versionData || {}
},
hasUpdate() {
return !!this.versionData.hasUpdate
},
latestVersion() {
return this.versionData.latestVersion
},
githubTagUrl() {
return this.versionData.githubTagUrl
}
},
methods: {},
mounted() {}
}
</script>

View File

@@ -1,7 +1,7 @@
<template>
<div class="w-20 bg-bg h-full relative box-shadow-side z-30" style="min-width: 80px">
<div class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" />
<nuxt-link to="/" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<nuxt-link :to="`/library/${currentLibraryId}`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
@@ -11,17 +11,17 @@
<div v-show="homePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link to="/library" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === '' && !homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="showLibrary ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
<p class="font-book pt-1.5" style="font-size: 1rem">Library</p>
<div v-show="paramId === '' && !homePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
<div v-show="showLibrary" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link to="/library/series" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'series' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf/series`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'series' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
</svg>
@@ -31,6 +31,16 @@
<div v-show="paramId === 'series'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="numIssues" :to="`/library/${currentLibraryId}/bookshelf?filter=issues`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-opacity-40 cursor-pointer relative" :class="showingIssues ? 'bg-error bg-opacity-40' : ' bg-error bg-opacity-20'">
<span class="material-icons text-2xl">warning</span>
<p class="font-book pt-1.5" style="font-size: 1rem">Issues</p>
<div v-show="showingIssues" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
<div class="absolute top-1 right-1 w-4 h-4 rounded-full bg-white bg-opacity-30 flex items-center justify-center">
<p class="text-xs font-mono pb-0.5">{{ numIssues }}</p>
</div>
</nuxt-link>
<!-- <nuxt-link to="/library/collections" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'collections' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
@@ -72,11 +82,27 @@ export default {
paramId() {
return this.$route.params ? this.$route.params.id || '' : ''
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
selectedClassName() {
return ''
},
homePage() {
return this.$route.name === 'index'
return this.$route.name === 'library-library'
},
libraryBookshelfPage() {
return this.$route.name === 'library-library-bookshelf-id'
},
showLibrary() {
return this.libraryBookshelfPage && this.paramId === '' && !this.showingIssues
},
showingIssues() {
if (!this.$route.query) return false
return this.libraryBookshelfPage && this.$route.query.filter === 'issues'
},
numIssues() {
return this.$store.getters['audiobooks/getAudiobooksWithIssues'].length
}
},
methods: {},

View File

@@ -1,20 +1,30 @@
<template>
<div v-if="streamAudiobook" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-40 z-40 bg-primary p-4">
<div v-if="streamAudiobook" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-40 z-40 bg-primary px-4 pb-4 pt-2">
<nuxt-link :to="`/audiobook/${streamAudiobook.id}`" class="absolute -top-16 left-4 cursor-pointer">
<cards-book-cover :audiobook="streamAudiobook" :width="88" />
</nuxt-link>
<div class="flex items-center pl-24">
<div>
<nuxt-link :to="`/audiobook/${streamAudiobook.id}`" class="hover:underline cursor-pointer">
<nuxt-link :to="`/audiobook/${streamAudiobook.id}`" class="hover:underline cursor-pointer text-lg">
{{ title }} <span v-if="stream && $isDev" class="text-xs text-gray-400">({{ stream.id }})</span>
</nuxt-link>
<p class="text-gray-400 text-sm hover:underline cursor-pointer" @click="filterByAuthor">by {{ author }}</p>
<div class="text-gray-400 flex items-center">
<span class="material-icons text-sm">person</span>
<p class="text-base hover:underline cursor-pointer pl-2" @click="filterByAuthor">{{ author }}</p>
</div>
<div class="text-gray-400 flex items-center">
<span class="material-icons text-xs">schedule</span>
<p class="font-mono text-sm pl-2 pb-px">{{ totalDurationPretty }}</p>
</div>
</div>
<div class="flex-grow" />
<span v-if="stream" class="material-icons px-4 cursor-pointer" @click="cancelStream">close</span>
</div>
<audio-player ref="audioPlayer" :chapters="chapters" :loading="isLoading" @updateTime="updateTime" @hook:mounted="audioPlayerMounted" />
<audio-player ref="audioPlayer" :chapters="chapters" :loading="isLoading" :bookmarks="bookmarks" @close="cancelStream" @updateTime="updateTime" @loaded="(d) => (totalDuration = d)" @showBookmarks="showBookmarks" @hook:mounted="audioPlayerMounted" />
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :audiobook-id="bookmarkAudiobookId" :current-time="bookmarkCurrentTime" @select="selectBookmark" @create="createBookmark" @update="updateBookmark" @delete="deleteBookmark" />
</div>
</template>
@@ -24,7 +34,11 @@ export default {
return {
audioPlayerReady: false,
lastServerUpdateSentSeconds: 0,
stream: null
stream: null,
totalDuration: 0,
showBookmarksModal: false,
bookmarkCurrentTime: 0,
bookmarkAudiobookId: null
}
},
computed: {
@@ -35,6 +49,14 @@ export default {
user() {
return this.$store.state.user.user
},
userAudiobook() {
if (!this.audiobookId) return
return this.$store.getters['user/getUserAudiobook'](this.audiobookId)
},
bookmarks() {
if (!this.userAudiobook) return []
return this.userAudiobook.bookmarks || []
},
isLoading() {
if (!this.streamAudiobook) return false
if (this.stream) {
@@ -46,6 +68,9 @@ export default {
streamAudiobook() {
return this.$store.state.streamAudiobook
},
audiobookId() {
return this.streamAudiobook ? this.streamAudiobook.id : null
},
book() {
return this.streamAudiobook ? this.streamAudiobook.book || {} : {}
},
@@ -63,12 +88,55 @@ export default {
},
playlistUrl() {
return this.stream ? this.stream.clientPlaylistUri : null
},
libraryId() {
return this.streamAudiobook ? this.streamAudiobook.libraryId : null
},
totalDurationPretty() {
return this.$secondsToTimestamp(this.totalDuration)
}
},
methods: {
showBookmarks(currentTime) {
this.bookmarkAudiobookId = this.audiobookId
this.bookmarkCurrentTime = currentTime
this.showBookmarksModal = true
},
// bookmarkCreated(time) {
// if (time === this.bookmarkTimeProcessing) {
// this.bookmarkTimeProcessing = 0
// this.$toast.success(`${this.$secondsToTimestamp(time)} Bookmarked`)
// }
// },
createBookmark(bookmark) {
// this.bookmarkTimeProcessing = bookmark.time
this.$root.socket.emit('create_bookmark', bookmark)
this.showBookmarksModal = false
},
// bookmarkUpdated(time) {
// if (time === this.bookmarkTimeProcessing) {
// this.bookmarkTimeProcessing = 0
// this.$toast.success(`Bookmark @${this.$secondsToTimestamp(time)} Updated`)
// }
// },
updateBookmark(bookmark) {
// this.bookmarkTimeProcessing = bookmark.time
this.$root.socket.emit('update_bookmark', bookmark)
this.showBookmarksModal = false
},
selectBookmark(bookmark) {
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.selectBookmark(bookmark)
}
this.showBookmarksModal = false
},
deleteBookmark(bookmark) {
this.$root.socket.emit('delete_bookmark', bookmark)
this.showBookmarksModal = false
},
filterByAuthor() {
if (this.$route.name !== 'index') {
this.$router.push('/library')
this.$router.push(`/library/${this.libraryId || this.$store.state.libraries.currentLibraryId}/bookshelf`)
}
var settingsUpdate = {
filterBy: `authors.${this.$encode(this.author)}`

View File

@@ -1,9 +1,16 @@
<template>
<div class="flex h-full px-1 overflow-hidden">
<cards-book-cover :audiobook="audiobook" :width="40" />
<div class="flex-grow px-2 searchCardContent h-full">
<p class="truncate text-sm">{{ title }}</p>
<p class="text-xs text-gray-200 truncate">by {{ author }}</p>
<cards-book-cover :audiobook="audiobook" :width="50" />
<div class="flex-grow px-2 audiobookSearchCardContent">
<p v-if="matchKey !== 'title'" class="truncate text-sm">{{ title }}</p>
<p v-else class="truncate text-sm" v-html="matchHtml" />
<p v-if="matchKey === 'subtitle'" class="truncate text-xs text-gray-300">{{ matchHtml }}</p>
<p v-if="matchKey !== 'authorFL'" class="text-xs text-gray-200 truncate">by {{ authorFL }}</p>
<p v-else class="truncate text-xs text-gray-200" v-html="matchHtml" />
<div v-if="matchKey === 'series' || matchKey === 'tags'" class="m-0 p-0 truncate" v-html="matchHtml" />
</div>
</div>
</template>
@@ -14,7 +21,10 @@ export default {
audiobook: {
type: Object,
default: () => {}
}
},
search: String,
matchKey: String,
matchText: String
},
data() {
return {}
@@ -26,8 +36,35 @@ export default {
title() {
return this.book ? this.book.title : 'No Title'
},
author() {
return this.book ? this.book.author : 'Unknown'
subtitle() {
return this.book ? this.book.subtitle : ''
},
authorFL() {
return this.book ? this.book.authorFL : 'Unknown'
},
matchHtml() {
if (!this.matchText || !this.search) return ''
if (this.matchKey === 'subtitle') return ''
var matchSplit = this.matchText.toLowerCase().split(this.search.toLowerCase().trim())
if (matchSplit.length < 2) return ''
var html = ''
var totalLenSoFar = 0
for (let i = 0; i < matchSplit.length - 1; i++) {
var indexOf = matchSplit[i].length
var firstPart = this.matchText.substr(totalLenSoFar, indexOf)
var actualWasThere = this.matchText.substr(totalLenSoFar + indexOf, this.search.length)
totalLenSoFar += indexOf + this.search.length
html += `${firstPart}<strong class="text-warning">${actualWasThere}</strong>`
}
var lastPart = this.matchText.substr(totalLenSoFar)
html += lastPart
if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>`
if (this.matchKey === 'authorFL') return `by ${html}`
if (this.matchKey === 'series') return `<p class="truncate">Series: ${html}</p>`
return `${html}`
}
},
methods: {},
@@ -36,9 +73,9 @@ export default {
</script>
<style>
.searchCardContent {
.audiobookSearchCardContent {
width: calc(100% - 80px);
height: calc(40px * 1.5);
height: 75px;
display: flex;
flex-direction: column;
justify-content: center;

View File

@@ -0,0 +1,32 @@
<template>
<div class="flex h-full px-1 overflow-hidden">
<img src="/icons/NoUserPhoto.png" class="w-40 h-40 max-h-40 object-contain" style="max-height: 40px; max-width: 40px" />
<div class="flex-grow px-2 authorSearchCardContent h-full">
<p class="truncate text-sm">{{ author }}</p>
</div>
</div>
</template>
<script>
export default {
props: {
author: String
},
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>
<style>
.authorSearchCardContent {
width: calc(100% - 80px);
height: 40px;
display: flex;
flex-direction: column;
justify-content: center;
}
</style>

View File

@@ -14,11 +14,16 @@
<cards-book-cover :audiobook="audiobook" :author-override="authorFormat" :width="width" />
<div v-show="isHovering || isSelectionMode" class="absolute top-0 left-0 w-full h-full bg-black rounded" :class="overlayWrapperClasslist">
<div v-show="!isSelectionMode && !isMissing" class="h-full flex items-center justify-center">
<div v-show="showPlayButton" class="h-full flex items-center justify-center">
<div class="hover:text-gray-200 hover:scale-110 transform duration-200" @click.stop.prevent="play">
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">play_circle_filled</span>
</div>
</div>
<div v-show="showReadButton" class="h-full flex items-center justify-center">
<div class="hover:text-gray-200 hover:scale-110 transform duration-200" @click.stop.prevent="clickReadEBook">
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">auto_stories</span>
</div>
</div>
<div v-if="userCanUpdate" v-show="!isSelectionMode" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-50" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="editClick">
<span class="material-icons" :style="{ fontSize: sizeMultiplier + 'rem' }">edit</span>
@@ -33,9 +38,16 @@
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ volumeNumber }}</p>
</div>
<!-- <div v-if="true && hasEbook" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">EBook</p>
</div> -->
<!-- EBook Icon -->
<div
v-if="showSmallEBookIcon"
class="absolute rounded-full bg-blue-500 flex items-center justify-center bg-opacity-90 hover:scale-125 transform duration-200"
:style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem`, width: 1.5 * sizeMultiplier + 'rem', height: 1.5 * sizeMultiplier + 'rem' }"
@click.stop.prevent="clickReadEBook"
>
<!-- <p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">EBook</p> -->
<span class="material-icons text-white" :style="{ fontSize: sizeMultiplier * 1 + 'rem' }">auto_stories</span>
</div>
<div v-show="!isSelectionMode" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full" :class="userIsRead ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
@@ -73,6 +85,9 @@ export default {
}
},
computed: {
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
isNew() {
return this.tags.includes('New')
},
@@ -85,8 +100,10 @@ export default {
hasEbook() {
return this.audiobook.numEbooks
},
hasTracks() {
return this.audiobook.numTracks
},
isSelectionMode() {
// return this.$store.getters['getNumAudiobooksSelected']
return !!this.selectedAudiobooks.length
},
selectedAudiobooks() {
@@ -145,11 +162,26 @@ export default {
return this.userProgress ? !!this.userProgress.isRead : false
},
showError() {
return this.hasMissingParts || this.hasInvalidParts || this.isMissing
return this.hasMissingParts || this.hasInvalidParts || this.isMissing || this.isIncomplete
},
isStreaming() {
return this.$store.getters['getAudiobookIdStreaming'] === this.audiobookId
},
showReadButton() {
return !this.isSelectionMode && this.showExperimentalFeatures && !this.showPlayButton && this.hasEbook
},
showPlayButton() {
return !this.isSelectionMode && !this.isMissing && !this.isIncomplete && this.hasTracks && !this.isStreaming
},
showSmallEBookIcon() {
return !this.isSelectionMode && this.showExperimentalFeatures && this.hasEbook
},
isMissing() {
return this.audiobook.isMissing
},
isIncomplete() {
return this.audiobook.isIncomplete
},
hasMissingParts() {
return this.audiobook.hasMissingParts
},
@@ -158,6 +190,7 @@ export default {
},
errorText() {
if (this.isMissing) return 'Audiobook directory is missing!'
else if (this.isIncomplete) return 'Audiobook has no audio tracks & ebook'
var txt = ''
if (this.hasMissingParts) {
txt = `${this.hasMissingParts} missing parts.`
@@ -198,7 +231,8 @@ export default {
this.$root.socket.emit('open_stream', this.audiobookId)
},
editClick() {
this.$store.commit('showEditModal', this.audiobook)
// this.$store.commit('showEditModal', this.audiobook)
this.$emit('edit', this.audiobook)
},
clickCard(e) {
if (this.isSelectionMode) {
@@ -206,6 +240,9 @@ export default {
e.preventDefault()
this.selectBtnClick()
}
},
clickReadEBook() {
this.$store.commit('showEReader', this.audiobook)
}
}
}

View File

@@ -53,9 +53,6 @@ export default {
book() {
return this.audiobook.book || {}
},
bookLastUpdate() {
return this.book.lastUpdate || Date.now()
},
title() {
return this.book.title || 'No Title'
},
@@ -79,7 +76,7 @@ export default {
return '/book_placeholder.jpg'
},
fullCoverUrl() {
return this.$store.getters['audiobooks/getBookCoverSrc'](this.book, this.placeholderUrl)
return this.$store.getters['audiobooks/getBookCoverSrc'](this.audiobook, this.placeholderUrl)
},
cover() {
return this.book.cover || this.placeholderUrl

View File

@@ -1,7 +1,7 @@
<template>
<div class="relative">
<div class="rounded-sm h-full overflow-hidden relative" :style="{ padding: `16px ${paddingX}px` }" @mouseover="isHovering = true" @mouseleave="isHovering = false" @click="clickCard">
<nuxt-link :to="`/library/series?${groupType}=${groupEncode}`" class="cursor-pointer">
<nuxt-link :to="groupTo" class="cursor-pointer">
<div class="w-full relative" :class="isHovering ? 'bg-black-400' : 'bg-primary'" :style="{ height: height + 'px', width: height + 'px' }">
<cards-group-cover ref="groupcover" :name="groupName" :book-items="bookItems" :width="height" :height="height" />
@@ -48,9 +48,22 @@ export default {
}
},
computed: {
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
_group() {
return this.group || {}
},
groupType() {
return this._group.type
},
groupTo() {
if (this.groupType === 'series') {
return `/library/${this.currentLibraryId}/bookshelf/series?series=${this.groupEncode}`
} else {
return `/library/${this.currentLibraryId}/bookshelf?filter=tags.${this.groupEncode}`
}
},
height() {
return this.width * 1.6
},

View File

@@ -105,7 +105,7 @@ export default {
this.coverDiv.remove()
this.coverDiv = null
}
var validCovers = this.bookItems.map((bookItem) => this.getCoverUrl(bookItem.book)).filter((b) => b !== '')
var validCovers = this.bookItems.map((bookItem) => this.getCoverUrl(bookItem)).filter((b) => b !== '')
if (!validCovers.length) {
this.noValidCovers = true
return

View File

@@ -1,10 +1,14 @@
<template>
<div class="relative rounded-sm overflow-hidden" :style="{ height: width * 1.6 + 'px', width: width + 'px', maxWidth: width + 'px', minWidth: width + 'px' }">
<div class="relative rounded-sm overflow-hidden" :style="{ height: width * 1.6 + 'px', width: width + 'px', maxWidth: width + 'px', minWidth: width + 'px' }" @mouseover="isHovering = true" @mouseleave="isHovering = false">
<div class="w-full h-full relative">
<div v-if="showCoverBg" class="bg-primary absolute top-0 left-0 w-full h-full">
<div class="w-full h-full z-0" ref="coverBg" />
</div>
<img ref="cover" :src="cover" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0" :class="showCoverBg ? 'object-contain' : 'object-cover'" />
<a v-if="!imageFailed && showOpenNewTab && isHovering" :href="cover" @click.stop target="_blank" class="absolute bg-primary flex items-center justify-center shadow-sm rounded-full hover:scale-110 transform duration-100" :style="{ top: sizeMultiplier * 0.5 + 'rem', right: sizeMultiplier * 0.5 + 'rem', width: 2.5 * sizeMultiplier + 'rem', height: 2.5 * sizeMultiplier + 'rem' }">
<span class="material-icons" :style="{ fontSize: sizeMultiplier * 1.75 + 'rem' }">open_in_new</span>
</a>
</div>
<div v-if="imageFailed" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-red-100" :style="{ padding: placeholderCoverPadding + 'rem' }">
@@ -23,12 +27,16 @@ export default {
width: {
type: Number,
default: 120
}
},
showOpenNewTab: Boolean
},
data() {
return {
imageFailed: false,
showCoverBg: false
showCoverBg: false,
isHovering: false,
naturalHeight: 0,
naturalWidth: 0
}
},
watch: {
@@ -60,6 +68,9 @@ export default {
imageLoaded() {
if (this.$refs.cover) {
var { naturalWidth, naturalHeight } = this.$refs.cover
this.naturalHeight = naturalHeight
this.naturalWidth = naturalWidth
var aspectRatio = naturalHeight / naturalWidth
var arDiff = Math.abs(aspectRatio - 1.6)

View File

@@ -0,0 +1,36 @@
<template>
<div class="flex h-full px-1 overflow-hidden">
<cards-group-cover :name="series" :book-items="bookItems" :width="60" :height="60" />
<div class="flex-grow px-2 seriesSearchCardContent h-full">
<p class="truncate text-sm">{{ series }}</p>
</div>
</div>
</template>
<script>
export default {
props: {
series: String,
bookItems: {
type: Array,
default: () => []
}
},
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>
<style>
.seriesSearchCardContent {
width: calc(100% - 80px);
height: 60px;
display: flex;
flex-direction: column;
justify-content: center;
}
</style>

View File

@@ -0,0 +1,34 @@
<template>
<div class="flex h-full px-1 overflow-hidden">
<div class="w-10 h-10 flex items-center justify-center">
<span class="material-icons text-2xl text-gray-200">local_offer</span>
</div>
<div class="flex-grow px-2 tagSearchCardContent h-full">
<p class="truncate text-sm">{{ tag }}</p>
</div>
</div>
</template>
<script>
export default {
props: {
tag: String
},
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>
<style>
.tagSearchCardContent {
width: calc(100% - 40px);
height: 40px;
display: flex;
flex-direction: column;
justify-content: center;
}
</style>

View File

@@ -41,6 +41,11 @@
<span class="font-normal block truncate py-2">No {{ sublist }}</span>
</div>
</li>
<li v-else-if="sublist === 'series'" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" role="option" @click="clickedSublistOption($encode('No Series'))">
<div class="flex items-center">
<span class="font-normal block truncate py-2 text-xs text-white text-opacity-80">No Series</span>
</div>
</li>
<template v-for="item in sublistItems">
<li :key="item.value" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" :class="`${sublist}.${item.value}` === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedSublistOption(item.value)">
<div class="flex items-center">
@@ -87,10 +92,20 @@ export default {
value: 'authors',
sublist: true
},
{
text: 'Narrator',
value: 'narrators',
sublist: true
},
{
text: 'Progress',
value: 'progress',
sublist: true
},
{
text: 'Issues',
value: 'issues',
sublist: false
}
]
}
@@ -137,6 +152,9 @@ export default {
authors() {
return this.$store.getters['audiobooks/getUniqueAuthors']
},
narrators() {
return this.$store.getters['audiobooks/getUniqueNarrators']
},
progress() {
return ['Read', 'Unread', 'In Progress']
},

View File

@@ -1,5 +1,5 @@
<template>
<div class="w-64 ml-8 relative">
<div class="w-80 ml-6 relative">
<form @submit.prevent="submitSearch">
<ui-text-input ref="input" v-model="search" placeholder="Search.." @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
</form>
@@ -7,23 +7,51 @@
<span v-if="!search" class="material-icons" style="font-size: 1.2rem">search</span>
<span v-else class="material-icons" style="font-size: 1.2rem">close</span>
</div>
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-10 -mt-px w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
<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">
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<li v-if="isTyping" class="py-2 px-2">
<p>Typing...</p>
<p>Thinking...</p>
</li>
<li v-else-if="isFetching" class="py-2 px-2">
<p>Fetching...</p>
</li>
<li v-else-if="!items.length" class="py-2 px-2">
<li v-else-if="!totalResults" class="py-2 px-2">
<p>No Results</p>
</li>
<template v-else>
<template v-for="item in items">
<li :key="item.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickedOption(item)">
<template v-if="item.type === 'audiobook'">
<cards-audiobook-search-card :audiobook="item.data" />
</template>
<p class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">Books</p>
<template v-for="item in audiobookResults">
<li :key="item.audiobook.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
<nuxt-link :to="`/audiobook/${item.audiobook.id}`">
<cards-audiobook-search-card :audiobook="item.audiobook" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
</nuxt-link>
</li>
</template>
<p v-if="authorResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Authors</p>
<template v-for="item in authorResults">
<li :key="item.author" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(item.author)}`">
<cards-author-search-card :author="item.author" />
</nuxt-link>
</li>
</template>
<p v-if="seriesResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Series</p>
<template v-for="item in seriesResults">
<li :key="item.series" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf/series?series=${$encode(item.series)}`">
<cards-series-search-card :series="item.series" :book-items="item.audiobooks" />
</nuxt-link>
</li>
</template>
<p v-if="tagResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Tags</p>
<template v-for="item in tagResults">
<li :key="item.tag" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=tags.${$encode(item.tag)}`">
<cards-tag-search-card :tag="item.tag" />
</nuxt-link>
</li>
</template>
</template>
@@ -42,7 +70,10 @@ export default {
isTyping: false,
isFetching: false,
search: null,
items: [],
audiobookResults: [],
authorResults: [],
seriesResults: [],
tagResults: [],
searchTimeout: null,
lastSearch: null
}
@@ -50,16 +81,32 @@ export default {
computed: {
audiobooks() {
return this.$store.state.audiobooks.audiobooks
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
totalResults() {
return this.audiobookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length
}
},
methods: {
submitSearch() {
if (!this.search) return
this.$router.push(`/library/search?query=${this.search}`)
var search = this.search
this.clearResults()
this.$router.push(`/library/${this.currentLibraryId}/bookshelf/search?query=${search}`)
},
clearResults() {
this.search = null
this.items = []
this.lastSearch = null
this.audiobookResults = []
this.authorResults = []
this.seriesResults = []
this.tagResults = []
this.showMenu = false
this.isFetching = false
this.isTyping = false
clearTimeout(this.searchTimeout)
this.$nextTick(() => {
if (this.$refs.input) {
this.$refs.input.blur()
@@ -83,22 +130,24 @@ export default {
return
}
this.isFetching = true
var results = await this.$axios.$get(`/api/audiobooks?q=${value}`).catch((error) => {
var searchResults = await this.$axios.$get(`/api/library/${this.currentLibraryId}/search?q=${value}`).catch((error) => {
console.error('Search error', error)
return []
})
// Search was canceled
if (!this.isFetching) return
this.audiobookResults = searchResults.audiobooks || []
this.authorResults = searchResults.authors || []
this.seriesResults = searchResults.series || []
this.tagResults = searchResults.tags || []
this.isFetching = false
if (!this.showMenu) {
return
}
this.items = results.map((res) => {
return {
id: res.id,
data: res,
type: 'audiobook'
}
})
},
inputUpdate(val) {
clearTimeout(this.searchTimeout)
@@ -109,23 +158,23 @@ export default {
}
this.isTyping = true
this.searchTimeout = setTimeout(() => {
// Canceled search
if (!this.isTyping) return
this.isTyping = false
this.runSearch(val)
}, 1000)
},
clickedOption(option) {
if (option.type === 'audiobook') {
this.$router.push(`/audiobook/${option.data.id}`)
}
}, 750)
},
clickClear() {
if (this.search) {
this.search = null
this.items = []
this.showMenu = false
}
this.clearResults()
}
},
mounted() {}
}
</script>
</script>
<style>
.globalSearchMenu {
max-height: 80vh;
}
</style>

View File

@@ -114,6 +114,9 @@ export default {
this.volume = this.lastValue || 0.5
}
},
toggleMute() {
this.clickVolumeIcon()
},
clickVolumeTrack(e) {
var vol = e.offsetX / this.trackWidth
vol = Math.min(Math.max(vol, 0), 1)

View File

@@ -1,5 +1,5 @@
<template>
<modals-modal v-model="show" :width="800" :height="'unset'" :processing="processing">
<modals-modal v-model="show" name="account" :width="800" :height="'unset'" :processing="processing">
<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">{{ title }}</p>
@@ -64,6 +64,19 @@
<ui-toggle-switch v-model="newUser.permissions.upload" />
</div>
</div>
<div class="flex items-center my-2 max-w-md">
<div class="w-1/2">
<p>Can Access All Libraries</p>
</div>
<div class="w-1/2">
<ui-toggle-switch v-model="newUser.permissions.accessAllLibraries" @input="accessAllLibrariesToggled" />
</div>
</div>
<div v-if="!newUser.permissions.accessAllLibraries" class="my-4">
<ui-multi-select-dropdown v-model="newUser.librariesAccessible" :items="libraryItems" label="Libraries Accessible to User" />
</div>
</div>
<div class="flex pt-4">
@@ -116,14 +129,31 @@ export default {
},
isEditingRoot() {
return this.account && this.account.type === 'root'
},
libraries() {
return this.$store.state.libraries.libraries
},
libraryItems() {
return this.libraries.map((lib) => ({ text: lib.name, value: lib.id }))
}
},
methods: {
accessAllLibrariesToggled(val) {
if (!val && !this.newUser.librariesAccessible.length) {
this.newUser.librariesAccessible = this.libraries.map((l) => l.id)
} else if (val && this.newUser.librariesAccessible.length) {
this.newUser.librariesAccessible = []
}
},
submitForm() {
if (!this.newUser.username) {
this.$toast.error('Enter a username')
return
}
if (!this.newUser.permissions.accessAllLibraries && !this.newUser.librariesAccessible.length) {
this.$toast.error('Must select at least one library')
return
}
if (this.isNew) {
this.submitCreateAccount()
@@ -139,6 +169,7 @@ export default {
if (account.type === 'root' && !account.isActive) return
this.processing = true
console.log('Calling update', account)
this.$axios
.$patch(`/api/user/${this.account.id}`, account)
.then((data) => {
@@ -146,14 +177,16 @@ export default {
if (data.error) {
this.$toast.error(`Failed to update account: ${data.error}`)
} else {
console.log('Account updated', data.user)
this.$toast.success('Account updated')
this.show = false
}
})
.catch((error) => {
console.error('Failed to update account', error)
this.processing = false
this.$toast.error('Failed to update account')
console.error('Failed to update account', error)
var errMsg = error.response ? error.response.data || '' : ''
this.$toast.error(errMsg || 'Failed to update account')
})
},
submitCreateAccount() {
@@ -176,9 +209,10 @@ export default {
}
})
.catch((error) => {
console.error('Failed to create account', error)
this.processing = false
this.$toast.error('Failed to create account')
console.error('Failed to create account', error)
var errMsg = error.response ? error.response.data || '' : ''
this.$toast.error(errMsg || 'Failed to create account')
})
},
toggleActive() {
@@ -195,12 +229,14 @@ export default {
init() {
this.isNew = !this.account
if (this.account) {
var librariesAccessible = this.account.librariesAccessible || []
this.newUser = {
username: this.account.username,
password: this.account.password,
type: this.account.type,
isActive: this.account.isActive,
permissions: { ...this.account.permissions }
permissions: { ...this.account.permissions },
librariesAccessible: [...librariesAccessible]
}
} else {
this.newUser = {
@@ -212,8 +248,10 @@ export default {
download: true,
update: false,
delete: false,
upload: false
}
upload: false,
accessAllLibraries: true
},
librariesAccessible: []
}
}
}

View File

@@ -0,0 +1,122 @@
<template>
<modals-modal v-model="show" name="bookmarks" :width="500" :height="'unset'">
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
<div class="w-full h-full px-6 py-6" v-show="showBookmarkTitleInput">
<div class="flex mb-4 items-center">
<div class="w-9 h-9 flex items-center justify-center rounded-full hover:bg-white hover:bg-opacity-10 cursor-pointer" @click="showBookmarkTitleInput = false">
<span class="material-icons text-3xl">arrow_back</span>
</div>
<p class="text-xl pl-2">{{ selectedBookmark ? 'Edit Bookmark' : 'New Bookmark' }}</p>
<div class="flex-grow" />
<p class="text-xl font-mono">
{{ this.$secondsToTimestamp(currentTime) }}
</p>
</div>
<form @submit.prevent="submitBookmark">
<ui-text-input-with-label v-model="newBookmarkTitle" label="Note" />
<div class="flex justify-end mt-6">
<ui-btn color="success" class="w-1/2" type="submit">{{ selectedBookmark ? 'Update' : 'Create' }} Bookmark</ui-btn>
</div>
</form>
</div>
<div class="w-full h-full" v-show="!showBookmarkTitleInput">
<template v-for="bookmark in bookmarks">
<modals-bookmarks-bookmark-item :key="bookmark.id" :highlight="currentTime === bookmark.time" :bookmark="bookmark" @click="clickBookmark" @edit="editBookmark" @delete="deleteBookmark" />
</template>
<div v-if="!bookmarks.length" class="flex h-32 items-center justify-center">
<p class="text-xl">No Bookmarks</p>
</div>
<div v-show="canCreateBookmark" class="flex px-4 py-2 items-center text-center justify-between border-b border-white border-opacity-10 bg-blue-500 bg-opacity-20 cursor-pointer text-white text-opacity-80 hover:bg-opacity-40 hover:text-opacity-100" @click="createBookmark">
<span class="material-icons">add</span>
<p class="text-base pl-2">Create Bookmark</p>
<p class="text-sm font-mono">
{{ this.$secondsToTimestamp(currentTime) }}
</p>
</div>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
bookmarks: {
type: Array,
default: () => []
},
currentTime: {
type: Number,
default: 0
},
audiobookId: String
},
data() {
return {
selectedBookmark: null,
showBookmarkTitleInput: false,
newBookmarkTitle: ''
}
},
watch: {
show(newVal) {
if (newVal) {
this.showBookmarkTitleInput = false
this.newBookmarkTitle = ''
}
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
canCreateBookmark() {
return !this.bookmarks.find((bm) => bm.time === this.currentTime)
}
},
methods: {
editBookmark(bm) {
this.selectedBookmark = bm
this.newBookmarkTitle = bm.title
this.showBookmarkTitleInput = true
},
deleteBookmark(bm) {
var bookmark = { ...bm, audiobookId: this.audiobookId }
this.$emit('delete', bookmark)
},
clickBookmark(bm) {
this.$emit('select', bm)
},
createBookmark() {
this.selectedBookmark = null
this.newBookmarkTitle = this.$formatDate(Date.now(), 'MMM dd, yyyy HH:mm')
this.showBookmarkTitleInput = true
},
submitBookmark() {
if (this.selectedBookmark) {
if (this.selectedBookmark.title !== this.newBookmarkTitle) {
var bookmark = { ...this.selectedBookmark }
bookmark.audiobookId = this.audiobookId
bookmark.title = this.newBookmarkTitle
this.$emit('update', bookmark)
}
} else {
var bookmark = {
audiobookId: this.audiobookId,
title: this.newBookmarkTitle,
time: this.currentTime
}
this.$emit('create', bookmark)
}
this.newBookmarkTitle = ''
this.showBookmarkTitleInput = false
}
}
}
</script>

View File

@@ -1,5 +1,5 @@
<template>
<modals-modal v-model="show" :width="500" :height="'unset'">
<modals-modal v-model="show" name="chapters" :width="500" :height="'unset'">
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
<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-bg bg-opacity-80' : 'bg-opacity-20'" @click="clickChapter(chap)">

View File

@@ -0,0 +1,45 @@
<template>
<modals-modal v-model="show" name="edit-library" :width="700" :height="'unset'" :processing="processing">
<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">{{ title }}</p>
</div>
</template>
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
<modals-libraries-edit-library v-if="show" :library="library" :processing.sync="processing" @close="show = false" />
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
library: {
type: Object,
default: () => {}
}
},
data() {
return {
processing: false
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
title() {
return this.library ? 'Update Library' : 'New Library'
}
},
methods: {},
mounted() {},
beforeDestroy() {}
}
</script>

View File

@@ -1,5 +1,5 @@
<template>
<modals-modal v-model="show" :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="75">
<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">{{ title }}</p>
@@ -10,6 +10,14 @@
<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" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
</template>
</div>
<div v-show="canGoPrev" class="absolute -left-24 top-0 bottom-0 h-full pointer-events-none flex items-center px-6">
<div class="material-icons text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goPrevBook" @mousedown.prevent>arrow_back_ios</div>
</div>
<div v-show="canGoNext" class="absolute -right-24 top-0 bottom-0 h-full pointer-events-none flex items-center px-6">
<div class="material-icons text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goNextBook" @mousedown.prevent>arrow_forward_ios</div>
</div>
<div class="w-full h-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300">
<keep-alive>
<component v-if="audiobook" :is="tabName" :audiobook="audiobook" :processing.sync="processing" @close="show = false" />
@@ -36,11 +44,11 @@ export default {
title: 'Cover',
component: 'modals-edit-tabs-cover'
},
{
id: 'match',
title: 'Match',
component: 'modals-edit-tabs-match'
},
// {
// id: 'match',
// title: 'Match',
// component: 'modals-edit-tabs-match'
// },
{
id: 'tracks',
title: 'Tracks',
@@ -51,6 +59,11 @@ export default {
title: 'Chapters',
component: 'modals-edit-tabs-chapters'
},
{
id: 'files',
title: 'Files',
component: 'modals-edit-tabs-files'
},
{
id: 'download',
title: 'Download',
@@ -68,6 +81,7 @@ export default {
this.show = false
return
}
if (!availableTabIds.includes(this.selectedTab)) {
this.selectedTab = availableTabIds[0]
}
@@ -79,6 +93,9 @@ export default {
this.fetchOnShow = false
this.audiobook = null
this.init()
this.registerListeners()
} else {
this.unregisterListeners()
}
}
}
@@ -137,9 +154,45 @@ export default {
},
title() {
return this.book.title || 'No Title'
},
bookshelfBookIds() {
return this.$store.state.bookshelfBookIds || []
},
currentBookshelfIndex() {
if (!this.bookshelfBookIds.length) return 0
return this.bookshelfBookIds.findIndex((bid) => bid === this.selectedAudiobookId)
},
canGoPrev() {
return this.bookshelfBookIds.length && this.currentBookshelfIndex > 0
},
canGoNext() {
return this.bookshelfBookIds.length && this.currentBookshelfIndex < this.bookshelfBookIds.length - 1
}
},
methods: {
goPrevBook() {
if (this.currentBookshelfIndex - 1 < 0) return
var prevBookId = this.bookshelfBookIds[this.currentBookshelfIndex - 1]
var prevBook = this.$store.getters['audiobooks/getAudiobook'](prevBookId)
if (prevBook) {
this.$store.commit('showEditModalOnTab', { audiobook: prevBook, tab: this.selectedTab })
this.$nextTick(this.init)
} else {
console.error('Book not found', prevBookId)
}
},
goNextBook() {
if (this.currentBookshelfIndex >= this.bookshelfBookIds.length - 1) return
var nextBookId = this.bookshelfBookIds[this.currentBookshelfIndex + 1]
var nextBook = this.$store.getters['audiobooks/getAudiobook'](nextBookId)
if (nextBook) {
this.$store.commit('showEditModalOnTab', { audiobook: nextBook, tab: this.selectedTab })
this.$nextTick(this.init)
} else {
console.error('Book not found', nextBookId)
}
},
selectTab(tab) {
this.selectedTab = tab
},
@@ -155,14 +208,33 @@ export default {
},
async fetchFull() {
try {
this.processing = true
this.audiobook = await this.$axios.$get(`/api/audiobook/${this.selectedAudiobookId}`)
this.processing = false
} catch (error) {
console.error('Failed to fetch audiobook', this.selectedAudiobookId, error)
this.processing = false
this.show = false
}
},
hotkey(action) {
if (action === this.$hotkeys.Modal.NEXT_PAGE) {
this.goNextBook()
} else if (action === this.$hotkeys.Modal.PREV_PAGE) {
this.goPrevBook()
}
},
registerListeners() {
this.$eventBus.$on('modal-hotkey', this.hotkey)
},
unregisterListeners() {
this.$eventBus.$off('modal-hotkey', this.hotkey)
}
},
mounted() {}
mounted() {},
beforeDestroy() {
this.unregisterListeners()
}
}
</script>

View File

@@ -2,7 +2,7 @@
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary bg-opacity-75 flex items-center justify-center z-40 opacity-0">
<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="show = false">
<div class="absolute top-5 right-5 h-12 w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="clickClose">
<span class="material-icons text-4xl">close</span>
</div>
<slot name="outer" />
@@ -18,6 +18,7 @@
<script>
export default {
props: {
name: String,
value: Boolean,
processing: Boolean,
persistent: {
@@ -73,23 +74,37 @@ export default {
}
},
methods: {
clickClose() {
this.show = false
},
clickBg(vm, ev) {
if (this.processing && this.persistent) return
if (vm.srcElement.classList.contains('modal-bg')) {
this.show = false
}
},
hotkey(action) {
if (action === this.$hotkeys.Modal.CLOSE) {
this.show = false
}
},
setShow() {
document.body.appendChild(this.el)
setTimeout(() => {
this.content.style.transform = 'scale(1)'
}, 10)
document.documentElement.classList.add('modal-open')
this.$eventBus.$on('modal-hotkey', this.hotkey)
this.$store.commit('setOpenModal', this.name)
},
setHide() {
this.content.style.transform = 'scale(0)'
this.el.remove()
document.documentElement.classList.remove('modal-open')
this.$eventBus.$off('modal-hotkey', this.hotkey)
this.$store.commit('setOpenModal', null)
}
},
mounted() {

View File

@@ -0,0 +1,45 @@
<template>
<div :key="bookmark.id" :id="`bookmark-row-${bookmark.id}`" class="flex items-center px-4 py-4 justify-start cursor-pointer hover:bg-bg relative" :class="highlight ? 'bg-bg bg-opacity-60' : ' bg-opacity-20'" @click="click" @mouseover="isHovering = true" @mouseleave="isHovering = false">
<span class="material-icons" :class="highlight ? 'text-success' : 'text-white text-opacity-60'">{{ highlight ? 'bookmark' : 'bookmark_border' }}</span>
<div class="flex-grow overflow-hidden">
<p class="pl-2 pr-2 truncate">{{ bookmark.title }}</p>
</div>
<div class="h-full flex items-center w-16 justify-end">
<span class="font-mono text-sm text-gray-300">{{ $secondsToTimestamp(bookmark.time) }}</span>
</div>
<div class="h-full flex items-center justify-end transform" :class="isHovering ? 'transition-transform translate-0 w-16' : 'translate-x-40 w-0'">
<span class="material-icons text-lg mr-2 text-gray-200 hover:text-yellow-400" @click.stop="editClick">edit</span>
<span class="material-icons text-lg text-gray-200 hover:text-error cursor-pointer" @click.stop="deleteClick">delete</span>
</div>
</div>
</template>
<script>
export default {
props: {
bookmark: {
type: Object,
default: () => {}
},
highlight: Boolean
},
data() {
return {
isHovering: false
}
},
computed: {},
methods: {
click() {
this.$emit('click', this.bookmark)
},
deleteClick() {
this.$emit('delete', this.bookmark)
},
editClick() {
this.$emit('edit', this.bookmark)
}
},
mounted() {}
}
</script>

View File

@@ -13,8 +13,8 @@
</div>
<div class="flex-grow pl-6 pr-2">
<div class="flex items-center">
<div v-if="userCanUpload" class="w-40 pr-2" style="min-width: 160px">
<ui-file-input ref="fileInput" @change="fileUploadSelected" />
<div v-if="userCanUpload" class="w-40 pr-2 pt-4" style="min-width: 160px">
<ui-file-input ref="fileInput" @change="fileUploadSelected">Upload Cover</ui-file-input>
</div>
<form @submit.prevent="submitForm" class="flex flex-grow">
<ui-text-input-with-label v-model="imageUrl" label="Cover Image URL" />
@@ -24,16 +24,16 @@
<div v-if="localCovers.length" class="mb-4 mt-6 border-t border-b border-primary">
<div class="flex items-center justify-center py-2">
<p>{{ localCovers.length }} local image(s)</p>
<p>{{ localCovers.length }} local image{{ localCovers.length !== 1 ? 's' : '' }}</p>
<div class="flex-grow" />
<ui-btn small @click="showLocalCovers = !showLocalCovers">{{ showLocalCovers ? 'Hide' : 'Show' }}</ui-btn>
</div>
<div v-if="showLocalCovers" class="flex items-center justify-center">
<template v-for="cover in localCovers">
<div :key="cover.path" class="m-0.5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover.localPath === imageUrl ? 'border-yellow-300' : ''" @click="setCover(cover.localPath)">
<div :key="cover.path" class="m-0.5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover.localPath === imageUrl ? 'border-yellow-300' : ''" @click="setCover(cover)">
<div class="h-24 bg-primary" style="width: 60px">
<img :src="cover.localPath" class="h-full w-full object-contain" />
<img :src="`${cover.localPath}?token=${userToken}`" class="h-full w-full object-contain" />
</div>
</div>
</template>
@@ -53,14 +53,11 @@
</div>
</div>
</form>
<div v-if="hasSearched" class="flex items-center flex-wrap justify-center max-h-60 overflow-y-scroll mt-2 max-w-full">
<div v-if="hasSearched" class="flex items-center flex-wrap justify-center max-h-80 overflow-y-scroll mt-2 max-w-full">
<p v-if="!coversFound.length">No Covers Found</p>
<template v-for="cover in coversFound">
<div :key="cover" class="m-0.5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover === imageUrl ? 'border-yellow-300' : ''" @click="setCover(cover)">
<div class="h-24 bg-primary" style="width: 60px">
<img :src="cover" class="h-full w-full object-contain" />
</div>
<!-- <img :src="cover" class="h-24 object-cover" style="width: 60px" /> -->
<div :key="cover" class="m-0.5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover === imageUrl ? 'border-yellow-300' : ''" @click="updateCover(cover)">
<cards-preview-cover :src="cover" :width="80" show-open-new-tab />
</div>
</template>
</div>
@@ -82,8 +79,6 @@
</template>
<script>
import Path from 'path'
export default {
props: {
processing: Boolean,
@@ -124,21 +119,31 @@ export default {
this.$emit('update:processing', val)
}
},
audiobookId() {
return this.audiobook ? this.audiobook.id : null
},
book() {
return this.audiobook ? this.audiobook.book || {} : {}
},
audiobookPath() {
return this.audiobook ? this.audiobook.path : null
},
otherFiles() {
return this.audiobook ? this.audiobook.otherFiles || [] : []
},
userCanUpload() {
return this.$store.getters['user/getUserCanUpload']
},
userToken() {
return this.$store.getters['user/getToken']
},
localCovers() {
return this.otherFiles
.filter((f) => f.filetype === 'image')
.map((file) => {
var _file = { ...file }
_file.localPath = Path.join('local', _file.path)
var imgRelPath = _file.path.replace(this.audiobookPath, '')
_file.localPath = `/s/book/${this.audiobookId}${imgRelPath}`
return _file
})
}
@@ -162,7 +167,11 @@ export default {
})
.catch((error) => {
console.error('Failed', error)
this.$toast.error('Oops, something went wrong...')
if (error.response && error.response.data) {
this.$toast.error(error.response.data)
} else {
this.$toast.error('Oops, something went wrong...')
}
this.processingUpload = false
})
},
@@ -179,13 +188,13 @@ export default {
},
init() {
this.showLocalCovers = false
if (this.coversFound.length && (this.searchTitle !== this.book.title || this.searchAuthor !== this.book.author)) {
if (this.coversFound.length && (this.searchTitle !== this.book.title || this.searchAuthor !== this.book.authorFL)) {
this.coversFound = []
this.hasSearched = false
}
this.imageUrl = this.book.cover || ''
this.searchTitle = this.book.title || ''
this.searchAuthor = this.book.author || ''
this.searchAuthor = this.book.authorFL || ''
},
removeCover() {
if (!this.book.cover) {
@@ -204,20 +213,39 @@ export default {
}
this.isProcessing = true
const updatePayload = {
book: {
cover: cover
var success = false
// Download cover from url and use
if (cover.startsWith('http:') || cover.startsWith('https:')) {
success = await this.$axios.$post(`/api/audiobook/${this.audiobook.id}/cover`, { url: cover }).catch((error) => {
console.error('Failed to download cover from url', error)
if (error.response && error.response.data) {
this.$toast.error(error.response.data)
}
return false
})
} else {
// Update local cover url
const updatePayload = {
book: {
cover: cover
}
}
success = await this.$axios.$patch(`/api/audiobook/${this.audiobook.id}`, updatePayload).catch((error) => {
console.error('Failed to update', error)
if (error.response && error.response.data) {
this.$toast.error(error.response.data)
}
return false
})
}
var updatedAudiobook = await this.$axios.$patch(`/api/audiobook/${this.audiobook.id}`, updatePayload).catch((error) => {
console.error('Failed to update', error)
return false
})
this.isProcessing = false
if (updatedAudiobook) {
if (success) {
this.$toast.success('Update Successful')
this.$emit('close')
} else {
this.imageUrl = this.book.cover || ''
}
this.isProcessing = false
},
getSearchQuery() {
var searchQuery = `provider=openlibrary&title=${this.searchTitle}`
@@ -235,8 +263,24 @@ export default {
this.isProcessing = false
this.hasSearched = true
},
setCover(cover) {
this.updateCover(cover)
setCover(coverFile) {
this.isProcessing = true
this.$axios
.$patch(`/api/audiobook/${this.audiobook.id}/coverfile`, coverFile)
.then((data) => {
console.log('response data', data)
if (data && typeof data === 'string') {
this.$toast.success(data)
}
this.isProcessing = false
})
.catch((error) => {
console.error('Failed to update', error)
if (error.response && error.response.data) {
this.$toast.error(error.response.data)
}
this.isProcessing = false
})
}
}
}

View File

@@ -47,20 +47,25 @@
<div class="flex mt-2 -mx-1">
<div class="w-1/2 px-1">
<ui-text-input-with-label v-model="details.narrarator" label="Narrarator" />
<ui-text-input-with-label v-model="details.narrator" label="Narrator" />
</div>
</div>
</div>
<div class="absolute bottom-0 left-0 w-full py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'box-shadow-sm-up border-t border-primary border-opacity-50'">
<div class="flex px-4">
<ui-btn v-if="userCanDelete" color="error" type="button" small @click.stop.prevent="deleteAudiobook">Remove</ui-btn>
<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="deleteAudiobook">Remove</ui-btn>
<ui-tooltip text="(Root User Only) Save a NFO metadata file in your audiobooks directory" class="mx-4">
<div class="flex-grow" />
<ui-tooltip v-if="!isMissing" text="(Root User Only) Save a NFO metadata file in your audiobooks directory" direction="bottom" class="mr-4">
<ui-btn v-if="isRootUser" :loading="savingMetadata" color="bg" type="button" class="h-full" small @click.stop.prevent="saveMetadata">Save Metadata</ui-btn>
</ui-tooltip>
<div class="flex-grow" />
<ui-tooltip :disabled="!!libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="mr-4">
<ui-btn v-if="isRootUser" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn>
</ui-tooltip>
<ui-btn type="submit">Submit</ui-btn>
</div>
</div>
@@ -84,7 +89,7 @@ export default {
subtitle: null,
description: null,
author: null,
narrarator: null,
narrator: null,
series: null,
volumeNumber: null,
publishYear: null,
@@ -93,7 +98,8 @@ export default {
newTags: [],
resettingProgress: false,
isScrollable: false,
savingMetadata: false
savingMetadata: false,
rescanning: false
}
},
watch: {
@@ -116,6 +122,9 @@ export default {
isRootUser() {
return this.$store.getters['user/getIsRoot']
},
isMissing() {
return !!this.audiobook && !!this.audiobook.isMissing
},
audiobookId() {
return this.audiobook ? this.audiobook.id : null
},
@@ -133,9 +142,33 @@ export default {
},
series() {
return this.$store.state.audiobooks.series
},
libraryId() {
return this.audiobook ? this.audiobook.libraryId : null
},
libraryScan() {
if (!this.libraryId) return null
return this.$store.getters['scanners/getLibraryScan'](this.libraryId)
}
},
methods: {
audiobookScanComplete(result) {
this.rescanning = false
if (!result) {
this.$toast.error(`Re-Scan Failed for "${this.title}"`)
} else if (result === 'UPDATED') {
this.$toast.success(`Re-Scan complete audiobook was updated`)
} else if (result === 'UPTODATE') {
this.$toast.success(`Re-Scan complete audiobook was up to date`)
} else if (result === 'REMOVED') {
this.$toast.error(`Re-Scan complete audiobook was removed`)
}
},
rescan() {
this.rescanning = true
this.$root.socket.once('audiobook_scan_complete', this.audiobookScanComplete)
this.$root.socket.emit('scan_audiobook', this.audiobookId)
},
saveMetadataComplete(result) {
this.savingMetadata = false
if (result.error) {
@@ -179,7 +212,7 @@ export default {
this.details.subtitle = this.book.subtitle
this.details.description = this.book.description
this.details.author = this.book.author
this.details.narrarator = this.book.narrarator
this.details.narrator = this.book.narrator
this.details.genres = this.book.genres || []
this.details.series = this.book.series
this.details.volumeNumber = this.book.volumeNumber

View File

@@ -1,10 +1,9 @@
<template>
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
<p class="text-center text-lg mb-4 py-8">Preparing downloads 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.<br />Download will timeout after 15 minutes.</p>
<div class="w-full border border-black-200 p-4 my-4">
<div v-if="showM4bDownload" class="w-full border border-black-200 p-4 my-4">
<div class="flex items-center">
<div>
<!-- <p class="text-lg">{{ isSingleTrack ? 'Single Track' : 'M4B Audiobook File' }}</p> -->
<p class="text-lg">M4B Audiobook File <span class="text-error">*</span></p>
<p class="max-w-xs text-sm pt-2 text-gray-300">Generate a .M4B audiobook file with embedded cover image and chapters.</p>
</div>
@@ -14,7 +13,6 @@
<p v-if="singleDownloadStatus === $constants.DownloadStatus.READY" class="text-success mb-2">Download Ready!</p>
<p v-if="singleDownloadStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
<!-- <a v-if="isSingleTrack" :href="`/local/${singleTrackPath}`" class="btn outline-none rounded-md shadow-md relative border border-gray-600 px-4 py-2 bg-primary">Download Track</a> -->
<ui-btn v-if="singleDownloadStatus !== $constants.DownloadStatus.READY" :loading="singleDownloadStatus === $constants.DownloadStatus.PENDING" :disabled="tempDisable" @click="startSingleAudioDownload">Start Download</ui-btn>
<div v-else>
<ui-btn @click="downloadWithProgress(singleAudioDownload)">Download</ui-btn>
@@ -46,7 +44,7 @@
</div>
</div>
<div class="w-full flex items-center justify-center absolute bottom-4 left-0 right-0 text-center">
<div v-if="showM4bDownload" class="w-full flex items-center justify-center absolute bottom-4 left-0 right-0 text-center">
<p class="text-error text-lg">* <strong>Experimental:</strong> Merging multiple .m4b files may have issues. <a href="https://github.com/advplyr/audiobookshelf/issues" class="underline text-blue-600" target="_blank">Report issues here.</a></p>
</div>
@@ -91,6 +89,9 @@ export default {
audiobookId() {
return this.audiobook ? this.audiobook.id : null
},
_audiobook() {
return this.audiobook || {}
},
downloads() {
return this.$store.getters['downloads/getDownloads'](this.audiobookId)
},
@@ -122,6 +123,9 @@ export default {
},
totalFiles() {
return this.audioFiles.length + this.otherFiles.length
},
showM4bDownload() {
return !this._audiobook.isMissing && !this._audiobook.isIncomplete && this._audiobook.tracks.length
}
},
methods: {

View File

@@ -0,0 +1,21 @@
<template>
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
<tables-all-files-table :audiobook="audiobook" />
</div>
</template>
<script>
export default {
props: {
audiobook: {
type: Object,
default: () => {}
}
},
data() {
return {}
},
computed: {},
methods: {}
}
</script>

View File

@@ -1,38 +1,44 @@
<template>
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
<div class="flex mb-4">
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobook.id}/edit`">
<ui-btn color="primary">Edit Track Order</ui-btn>
</nuxt-link>
</div>
<table class="text-sm tracksTable">
<tr class="font-book">
<th>#</th>
<th class="text-left">Filename</th>
<th class="text-left">Size</th>
<th class="text-left">Duration</th>
<th v-if="showDownload" class="text-center">Download</th>
</tr>
<template v-for="track in tracks">
<tr :key="track.index">
<td class="text-center">
<p>{{ track.index }}</p>
</td>
<td class="font-book">
{{ track.filename }}
</td>
<td class="font-mono">
{{ $bytesPretty(track.size) }}
</td>
<td class="font-mono">
{{ $secondsToTimestamp(track.duration) }}
</td>
<td v-if="showDownload" class="font-mono text-center">
<a :href="`/local/${track.path}`" download><span class="material-icons icon-text">download</span></a>
</td>
<template v-if="hasTracks">
<div class="w-full bg-primary px-4 py-2 flex items-center">
<div class="h-7 w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
<span class="text-sm font-mono">{{ tracks.length }}</span>
</div>
<div class="flex-grow" />
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobook.id}/edit`" class="mr-4">
<ui-btn small color="primary">Manage Tracks</ui-btn>
</nuxt-link>
</div>
<table class="text-sm tracksTable">
<tr class="font-book">
<th>#</th>
<th class="text-left">Filename</th>
<th class="text-left">Size</th>
<th class="text-left">Duration</th>
<th v-if="showDownload" class="text-center">Download</th>
</tr>
</template>
</table>
<template v-for="track in tracksCleaned">
<tr :key="track.index">
<td class="text-center">
<p>{{ track.index }}</p>
</td>
<td class="font-sans">{{ showFullPath ? track.fullPath : track.filename }}</td>
<td class="font-mono">
{{ $bytesPretty(track.size) }}
</td>
<td class="font-mono">
{{ $secondsToTimestamp(track.duration) }}
</td>
<td v-if="showDownload" class="font-mono text-center">
<a :href="`/s/book/${audiobook.id}/${track.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
</td>
</tr>
</template>
</table>
</template>
<div v-else class="flex my-4 text-center justify-center text-xl">No Audio Tracks</div>
</div>
</template>
@@ -47,7 +53,7 @@ export default {
data() {
return {
tracks: null,
audioFiles: null
showFullPath: false
}
},
watch: {
@@ -59,6 +65,26 @@ export default {
}
},
computed: {
audiobookPath() {
return this.audiobook.path
},
tracksCleaned() {
return this.tracks.map((track) => {
var trackPath = track.path.replace(/\\/g, '/')
var audiobookPath = this.audiobookPath.replace(/\\/g, '/')
return {
...track,
relativePath: trackPath
.replace(audiobookPath + '/', '')
.replace(/%/g, '%25')
.replace(/#/g, '%23')
}
})
},
userToken() {
return this.$store.getters['user/getToken']
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
@@ -70,11 +96,13 @@ export default {
},
showDownload() {
return this.userCanDownload && !this.isMissing
},
hasTracks() {
return this.audiobook.tracks.length
}
},
methods: {
init() {
this.audioFiles = this.audiobook.audioFiles
this.tracks = this.audiobook.tracks
}
}

View File

@@ -0,0 +1,165 @@
<template>
<div class="w-full h-full px-4 py-2 mb-12">
<div class="flex items-center py-1 mb-2">
<span v-show="showDirectoryPicker" class="material-icons text-3xl cursor-pointer hover:text-gray-300" @click="backArrowPress">arrow_back</span>
<p class="px-4 text-xl">{{ title }}</p>
</div>
<div v-if="!showDirectoryPicker" class="w-full h-full py-4">
<ui-text-input-with-label v-model="name" label="Library Name" />
<div class="w-full py-4">
<p class="px-1 text-sm font-semibold">Folders</p>
<div v-for="(folder, index) in folders" :key="index" class="w-full flex items-center py-1 px-2">
<!-- <ui-text-input :value="folder.fullPath" type="text" class="w-full" /> -->
<span class="material-icons bg-opacity-50 mr-2 text-yellow-200" style="font-size: 1.2rem">folder</span>
<ui-editable-text v-model="folder.fullPath" type="text" class="w-full" />
<span v-show="folders.length > 1" class="material-icons ml-2 cursor-pointer hover:text-error" @click="removeFolder(folder)">close</span>
</div>
<p v-if="!folders.length" class="text-sm text-gray-300 px-1 py-2">No folders</p>
<ui-btn class="w-full mt-2" color="primary" @click="showDirectoryPicker = true">Browse for Folder</ui-btn>
</div>
<div class="absolute bottom-0 left-0 w-full py-4 px-4">
<div class="flex items-center">
<div class="flex-grow" />
<ui-btn v-show="!disableSubmit" color="success" :disabled="disableSubmit" @click="submit">{{ library ? 'Update Library' : 'Create Library' }}</ui-btn>
</div>
</div>
</div>
<modals-libraries-folder-chooser v-else :paths="folderPaths" @select="selectFolder" />
</div>
</template>
<script>
export default {
props: {
library: {
type: Object,
default: () => null
},
processing: Boolean
},
data() {
return {
name: '',
folders: [],
showDirectoryPicker: false
}
},
computed: {
title() {
if (this.showDirectoryPicker) return 'Choose a Folder'
return ''
},
folderPaths() {
return this.folders.map((f) => f.fullPath)
},
disableSubmit() {
if (!this.library) {
return false
}
var newfolderpaths = this.folderPaths.join(',')
var origfolderpaths = this.library.folders.map((f) => f.fullPath).join(',')
console.log(newfolderpaths)
console.log(origfolderpaths)
console.log(newfolderpaths === origfolderpaths)
return newfolderpaths === origfolderpaths && this.name === this.library.name
}
},
methods: {
removeFolder(folder) {
this.folders = this.folders.filter((f) => f.fullPath !== folder.fullPath)
},
backArrowPress() {
if (this.showDirectoryPicker) {
this.showDirectoryPicker = false
}
},
init() {
this.name = this.library ? this.library.name : ''
this.folders = this.library ? this.library.folders.map((p) => ({ ...p })) : []
this.showDirectoryPicker = false
},
selectFolder(fullPath) {
this.folders.push({ fullPath })
this.showDirectoryPicker = false
},
submit() {
if (this.library) {
this.updateLibrary()
} else {
this.createLibrary()
}
},
updateLibrary() {
if (!this.name) {
this.$toast.error('Library must have a name')
return
}
if (!this.folders.length) {
this.$toast.error('Library must have at least 1 path')
return
}
var newLibraryPayload = {
name: this.name,
folders: this.folders
}
this.$emit('update:processing', true)
this.$axios
.$patch(`/api/library/${this.library.id}`, newLibraryPayload)
.then((res) => {
this.$emit('update:processing', false)
this.$emit('close')
this.$toast.success(`Library "${res.name}" updated successfully`)
})
.catch((error) => {
console.error(error)
if (error.response && error.response.data) {
this.$toast.error(error.response.data)
} else {
this.$toast.error('Failed to update library')
}
this.$emit('update:processing', false)
})
},
createLibrary() {
if (!this.name) {
this.$toast.error('Library must have a name')
return
}
if (!this.folders.length) {
this.$toast.error('Library must have at least 1 path')
return
}
var newLibraryPayload = {
name: this.name,
folders: this.folders
}
this.$emit('update:processing', true)
this.$axios
.$post('/api/library', newLibraryPayload)
.then((res) => {
this.$emit('update:processing', false)
this.$emit('close')
this.$toast.success(`Library "${res.name}" created successfully`)
})
.catch((error) => {
console.error(error)
if (error.response && error.response.data) {
this.$toast.error(error.response.data)
} else {
this.$toast.error('Failed to create library')
}
this.$emit('update:processing', false)
})
}
},
mounted() {
console.log('Mounted edit library')
this.init()
}
}
</script>

View File

@@ -0,0 +1,166 @@
<template>
<div class="w-full h-full">
<div v-if="allFolders.length" class="w-full bg-primary bg-opacity-70 py-1 px-4 mb-2">
<p class="font-mono truncate">{{ selectedPath || '\\' }}</p>
</div>
<div v-if="allFolders.length" class="flex bg-primary bg-opacity-50 p-4">
<div class="w-1/2 border-r border-bg">
<div v-if="level > 0" class="w-full p-1 cursor-pointer flex items-center" @click="goBack">
<span class="material-icons bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span>
<p class="text-base font-mono px-2">..</p>
</div>
<div v-for="dir in _directories" :key="dir.path" class="dir-item w-full p-1 cursor-pointer flex items-center hover:text-white text-gray-200" :class="dir.className" @click="selectDir(dir)">
<span class="material-icons bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span>
<p class="text-base font-mono px-2 truncate">{{ dir.dirname }}</p>
<span v-if="dir.dirs && dir.dirs.length && dir.path === selectedPath" class="material-icons" style="font-size: 1.1rem">arrow_right</span>
</div>
</div>
<div class="w-1/2">
<div v-for="dir in _subdirs" :key="dir.path" :class="dir.className" class="dir-item w-full p-1 cursor-pointer flex items-center hover:text-white text-gray-200" @click="selectSubDir(dir)">
<span class="material-icons bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span>
<p class="text-base font-mono px-2 truncate">{{ dir.dirname }}</p>
</div>
</div>
</div>
<div v-else-if="loadingFolders" class="py-12 text-center">
<p>Loading folders...</p>
</div>
<div v-else class="py-12 text-center">
<p class="text-lg mb-2">No Folders Available</p>
<p class="text-gray-300">Note: folders already mapped will not be shown</p>
</div>
<div class="absolute bottom-0 left-0 w-full py-4 px-8">
<ui-btn :disabled="!selectedPath" color="primary" class="w-full mt-2" @click="selectFolder">Select Folder Path</ui-btn>
<!-- <div class="flex items-center">
<div class="flex-grow" />
<ui-btn color="success" @click="selectFolder">Select</ui-btn>
</div> -->
</div>
</div>
</template>
<script>
export default {
props: {
paths: {
type: Array,
default: () => []
}
},
data() {
return {
loadingFolders: false,
allFolders: [],
directories: [],
selectedPath: '',
selectedFullPath: '',
subdirs: [],
level: 0,
currentDir: null,
previousDir: null
}
},
computed: {
_directories() {
return this.directories.map((d) => {
console.log('Directories', d)
var isUsed = !!this.paths.find((path) => path.endsWith(d.path))
var isSelected = d.path === this.selectedPath
var classes = []
if (isSelected) classes.push('dir-selected')
if (isUsed) classes.push('dir-used')
return {
isUsed,
isSelected,
className: classes.join(' '),
...d
}
})
},
_subdirs() {
return this.subdirs.map((d) => {
var isUsed = !!this.paths.find((path) => path.endsWith(d.path))
var classes = []
if (isUsed) classes.push('dir-used')
return {
isUsed,
className: classes.join(' '),
...d
}
})
}
},
methods: {
goBack() {
var splitPaths = this.selectedPath.split('\\').slice(1)
var prev = splitPaths.slice(0, -1).join('\\')
var currDirs = this.allFolders
for (let i = 0; i < splitPaths.length; i++) {
var _dir = currDirs.find((dir) => dir.dirname === splitPaths[i])
if (_dir && _dir.path.slice(1) === prev) {
this.directories = currDirs
this.selectDir(_dir)
return
} else if (_dir) {
currDirs = _dir.dirs
}
}
},
selectDir(dir) {
if (dir.isUsed) return
this.selectedPath = dir.path
this.selectedFullPath = dir.fullPath
this.level = dir.level
this.subdirs = dir.dirs
},
selectSubDir(dir) {
if (dir.isUsed) return
this.selectedPath = dir.path
this.selectedFullPath = dir.fullPath
this.level = dir.level
this.directories = this.subdirs
this.subdirs = dir.dirs
},
selectFolder() {
if (!this.selectedPath) {
console.error('No Selected path')
return
}
if (this.paths.find((p) => p.startsWith(this.selectedFullPath))) {
this.$toast.error(`Oops, you cannot add a parent directory of a folder already added`)
return
}
this.$emit('select', this.selectedFullPath)
this.selectedPath = ''
this.selectedFullPath = ''
},
async init() {
this.loadingFolders = true
this.allFolders = await this.$store.dispatch('libraries/loadFolders')
this.loadingFolders = false
this.directories = this.allFolders
this.subdirs = []
this.selectedPath = ''
this.selectedFullPath = ''
}
},
mounted() {
console.log('folder chooser mounted')
this.init()
}
}
</script>
<style>
.dir-item.dir-selected {
background-color: rgba(255, 255, 255, 0.1);
}
.dir-item.dir-used {
background-color: rgba(255, 25, 0, 0.1);
}
</style>

View File

@@ -0,0 +1,94 @@
<template>
<div class="w-full px-4 h-12 border border-white border-opacity-10 flex items-center relative -mt-px" :class="selected ? 'bg-primary bg-opacity-50' : 'hover:bg-primary hover:bg-opacity-25'" @mouseover="mouseover = true" @mouseleave="mouseover = false">
<div v-show="selected" class="absolute top-0 left-0 h-full w-0.5 bg-warning z-10" />
<svg v-if="!libraryScan" xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-white" :class="isHovering ? 'text-opacity-90' : 'text-opacity-50'" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
</svg>
<svg v-else viewBox="0 0 24 24" class="h-6 w-6 text-white text-opacity-50 animate-spin">
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
</svg>
<p class="text-xl font-book pl-4 hover:underline cursor-pointer" @click.stop="$emit('click', library)">{{ library.name }}</p>
<div class="flex-grow" />
<ui-btn v-show="isHovering && !libraryScan && canScan" small color="bg" @click.stop="scan">Scan</ui-btn>
<span v-show="isHovering && !libraryScan && showEdit && canEdit" class="material-icons text-xl text-gray-300 hover:text-gray-50 ml-4 cursor-pointer" @click.stop="editClick">edit</span>
<span v-show="!libraryScan && isHovering && showEdit && canDelete && !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>
<div v-show="isDeleting" class="text-xl text-gray-300 ml-3 animate-spin">
<svg viewBox="0 0 24 24" class="w-6 h-6">
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
</svg>
</div>
</div>
</template>
<script>
export default {
props: {
library: {
type: Object,
default: () => {}
},
selected: Boolean,
showEdit: Boolean,
dragging: Boolean
},
data() {
return {
mouseover: false,
isDeleting: false
}
},
computed: {
isHovering() {
return this.mouseover && !this.dragging
},
isMain() {
return this.library.id === 'main'
},
libraryScan() {
return this.$store.getters['scanners/getLibraryScan'](this.library.id)
},
canEdit() {
return this.$store.getters['user/getIsRoot']
},
canDelete() {
return this.$store.getters['user/getIsRoot']
},
canScan() {
return this.$store.getters['user/getIsRoot']
}
},
methods: {
itemClicked() {
// this.$emit('click', this.library)
},
editClick() {
this.$emit('edit', this.library)
},
scan() {
this.$root.socket.emit('scan', this.library.id)
},
deleteClick() {
if (this.isMain) return
if (confirm(`Are you sure you want to permanently delete library "${this.library.name}"?`)) {
this.isDeleting = true
this.$axios
.$delete(`/api/library/${this.library.id}`)
.then((data) => {
this.isDeleting = false
if (data.error) {
this.$toast.error(data.error)
} else {
this.$toast.success('Library deleted')
}
})
.catch((error) => {
console.error('Failed to delete library', error)
this.$toast.error('Failed to delete library')
this.isDeleting = false
})
}
}
},
mounted() {}
}
</script>

View File

@@ -0,0 +1,85 @@
<template>
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary bg-opacity-75 flex items-center justify-center z-40 opacity-0">
<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 ref="content" style="min-width: 400px; min-height: 200px" class="relative text-white" :style="{ height: modalHeight, width: modalWidth }">
<slot />
</div>
</div>
</template>
<script>
export default {
props: {
value: Boolean,
persistent: {
type: Boolean,
default: true
},
width: {
type: [String, Number],
default: 500
},
height: {
type: [String, Number],
default: 'unset'
}
},
data() {
return {
el: null,
content: null
}
},
watch: {
show(newVal) {
if (newVal) {
this.setShow()
} else {
this.setHide()
}
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
modalHeight() {
if (typeof this.height === 'string') {
return this.height
} else {
return this.height + 'px'
}
},
modalWidth() {
return typeof this.width === 'string' ? this.width : this.width + 'px'
}
},
methods: {
setShow() {
document.body.appendChild(this.el)
setTimeout(() => {
this.content.style.transform = 'scale(1)'
}, 10)
document.documentElement.classList.add('modal-open')
},
setHide() {
this.content.style.transform = 'scale(0)'
this.el.remove()
document.documentElement.classList.remove('modal-open')
}
},
mounted() {
this.el = this.$refs.wrapper
this.content = this.$refs.content
this.content.style.transform = 'scale(0)'
this.content.style.transition = 'transform 0.25s cubic-bezier(0.16, 1, 0.3, 1)'
this.el.style.opacity = 1
this.el.remove()
}
}
</script>

View File

@@ -0,0 +1,238 @@
<template>
<div class="w-full h-full">
<div v-show="showPageMenu" v-click-outside="clickOutside" class="pagemenu absolute top-9 right-20 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-52">
<div v-for="(file, index) in pages" :key="file" class="w-full cursor-pointer hover:bg-black-200 px-2 py-1" :class="page === index ? 'bg-black-200' : ''" @click="setPage(index)">
<p class="text-sm truncate">{{ file }}</p>
</div>
</div>
<div v-show="showInfoMenu" v-click-outside="clickOutside" class="pagemenu absolute top-9 right-10 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-96">
<div v-for="key in comicMetadataKeys" :key="key" class="w-full px-2 py-1">
<p class="text-xs">
<strong>{{ key }}</strong
>: {{ comicMetadata[key] }}
</p>
</div>
</div>
<div v-if="comicMetadata" class="absolute top-0 right-52 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="showInfoMenu = !showInfoMenu">
<span class="material-icons text-xl">more</span>
</div>
<div class="absolute top-0 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" style="right: 156px" @mousedown.prevent @click.stop.prevent="showPageMenu = !showPageMenu">
<span class="material-icons text-xl">menu</span>
</div>
<div class="absolute top-0 right-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center z-20">
<p class="font-mono">{{ page + 1 }} / {{ numPages }}</p>
</div>
<div class="overflow-hidden m-auto comicwrapper relative">
<div v-show="canGoPrev" class="absolute top-0 left-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="prev" @mousedown.prevent>
<div class="flex items-center justify-center h-full w-1/2">
<span v-show="loadedFirstPage" class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_back_ios</span>
</div>
</div>
<div v-show="canGoNext" class="absolute top-0 right-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="next" @mousedown.prevent>
<div class="flex items-center justify-center h-full w-1/2 ml-auto">
<span v-show="loadedFirstPage" class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_forward_ios</span>
</div>
</div>
<div class="h-full flex justify-center">
<img v-if="mainImg" :src="mainImg" class="object-contain comicimg" />
</div>
<div v-show="loading" class="w-full h-full absolute top-0 left-0 flex items-center justify-center z-10">
<ui-loading-indicator />
</div>
</div>
<!-- <div v-show="loading" class="w-screen h-screen absolute top-0 left-0 bg-black bg-opacity-20 flex items-center justify-center">
<ui-loading-indicator />
</div> -->
</div>
</template>
<script>
import Path from 'path'
import { Archive } from 'libarchive.js/main.js'
Archive.init({
workerUrl: '/libarchive/worker-bundle.js'
})
export default {
props: {
url: String
},
data() {
return {
loading: false,
pages: null,
filesObject: null,
mainImg: null,
page: 0,
numPages: 0,
showPageMenu: false,
showInfoMenu: false,
loadTimeout: null,
loadedFirstPage: false,
comicMetadata: null
}
},
watch: {
url: {
immediate: true,
handler() {
this.extract()
}
}
},
computed: {
comicMetadataKeys() {
return this.comicMetadata ? Object.keys(this.comicMetadata) : []
},
canGoNext() {
return this.page < this.numPages - 1
},
canGoPrev() {
return this.page > 0
}
},
methods: {
clickOutside() {
if (this.showPageMenu) this.showPageMenu = false
if (this.showInfoMenu) this.showInfoMenu = false
},
next() {
if (!this.canGoNext) return
this.setPage(this.page + 1)
},
prev() {
if (!this.canGoPrev) return
this.setPage(this.page - 1)
},
setPage(index) {
if (index < 0 || index > this.numPages - 1) {
return
}
var filename = this.pages[index]
this.page = index
return this.extractFile(filename)
},
setLoadTimeout() {
this.loadTimeout = setTimeout(() => {
this.loading = true
}, 150)
},
extractFile(filename) {
return new Promise(async (resolve) => {
this.setLoadTimeout()
var file = await this.filesObject[filename].extract()
var reader = new FileReader()
reader.onload = (e) => {
this.mainImg = e.target.result
this.loading = false
resolve()
}
reader.onerror = (e) => {
console.error(e)
this.$toast.error('Read page file failed')
this.loading = false
resolve()
}
reader.readAsDataURL(file)
clearTimeout(this.loadTimeout)
})
},
async extract() {
this.loading = true
console.log('Extracting', this.url)
var buff = await this.$axios.$get(this.url, {
responseType: 'blob'
})
const archive = await Archive.open(buff)
this.filesObject = await archive.getFilesObject()
var filenames = Object.keys(this.filesObject)
this.parseFilenames(filenames)
var xmlFile = filenames.find((f) => (Path.extname(f) || '').toLowerCase() === '.xml')
if (xmlFile) await this.extractXmlFile(xmlFile)
this.numPages = this.pages.length
if (this.pages.length) {
this.loading = false
await this.setPage(0)
this.loadedFirstPage = true
} else {
this.$toast.error('Unable to extract pages')
this.loading = false
}
},
async extractXmlFile(filename) {
console.log('extracting xml filename', filename)
try {
var file = await this.filesObject[filename].extract()
var reader = new FileReader()
reader.onload = (e) => {
this.comicMetadata = this.$xmlToJson(e.target.result)
console.log('Metadata', this.comicMetadata)
}
reader.onerror = (e) => {
console.error(e)
}
reader.readAsText(file)
} catch (error) {
console.error(error)
}
},
parseImageFilename(filename) {
var basename = Path.basename(filename, Path.extname(filename))
var numbersinpath = basename.match(/\d{1,4}/g)
if (!numbersinpath || !numbersinpath.length) {
return {
index: -1,
filename
}
} else {
return {
index: Number(numbersinpath[numbersinpath.length - 1]),
filename
}
}
},
parseFilenames(filenames) {
const acceptableImages = ['.jpeg', '.jpg', '.png']
var imageFiles = filenames.filter((f) => {
return acceptableImages.includes((Path.extname(f) || '').toLowerCase())
})
var imageFileObjs = imageFiles.map((img) => {
return this.parseImageFilename(img)
})
var imagesWithNum = imageFileObjs.filter((i) => i.index >= 0)
var orderedImages = imagesWithNum.sort((a, b) => a.index - b.index).map((i) => i.filename)
var noNumImages = imageFileObjs.filter((i) => i.index < 0)
orderedImages = orderedImages.concat(noNumImages.map((i) => i.filename))
this.pages = orderedImages
}
},
mounted() {},
beforeDestroy() {}
}
</script>
<style scoped>
.pagemenu {
max-height: calc(100vh - 60px);
}
.comicimg {
height: calc(100vh - 40px);
margin: auto;
}
.comicwrapper {
width: 100vw;
height: calc(100vh - 40px);
margin-top: 20px;
}
</style>

View File

@@ -0,0 +1,129 @@
<template>
<div class="h-full w-full">
<div class="h-full flex items-center">
<div style="width: 100px; max-width: 100px" class="h-full flex items-center overflow-x-hidden justify-center">
<span v-show="hasPrev" class="material-icons text-white text-opacity-50 hover:text-opacity-80 cursor-pointer text-6xl" @mousedown.prevent @click="prev">chevron_left</span>
</div>
<div id="frame" class="w-full" style="height: 650px">
<div id="viewer" class="border border-gray-100 bg-white shadow-md"></div>
<div class="py-4 flex justify-center" style="height: 50px">
<p>{{ progress }}%</p>
</div>
</div>
<div style="width: 100px; max-width: 100px" class="h-full flex items-center justify-center overflow-x-hidden">
<span v-show="hasNext" class="material-icons text-white text-opacity-50 hover:text-opacity-80 cursor-pointer text-6xl" @mousedown.prevent @click="next">chevron_right</span>
</div>
</div>
</div>
</template>
<script>
import ePub from 'epubjs'
export default {
props: {
url: String
},
data() {
return {
book: null,
rendition: null,
chapters: [],
title: '',
author: '',
progress: 0,
hasNext: true,
hasPrev: false
}
},
computed: {},
methods: {
changedChapter() {
if (this.rendition) {
this.rendition.display(this.selectedChapter)
}
},
prev() {
if (this.rendition) {
this.rendition.prev()
}
},
next() {
if (this.rendition) {
this.rendition.next()
}
},
keyUp() {
if ((e.keyCode || e.which) == 37) {
this.prev()
} else if ((e.keyCode || e.which) == 39) {
this.next()
}
},
initEpub() {
// var book = ePub(this.url, {
// requestHeaders: {
// Authorization: `Bearer ${this.userToken}`
// }
// })
var book = ePub(this.url)
this.book = book
this.rendition = book.renderTo('viewer', {
width: window.innerWidth - 200,
height: 600,
ignoreClass: 'annotator-hl',
manager: 'continuous',
spread: 'always'
})
var displayed = this.rendition.display()
book.ready
.then(() => {
console.log('Book ready')
return book.locations.generate(1600)
})
.then((locations) => {
// console.log('Loaded locations', locations)
// Wait for book to be rendered to get current page
displayed.then(() => {
// Get the current CFI
var currentLocation = this.rendition.currentLocation()
if (!currentLocation.start) {
console.error('No Start', currentLocation)
} else {
var currentPage = book.locations.percentageFromCfi(currentLocation.start.cfi)
// console.log('current page', currentPage)
}
})
})
book.loaded.navigation.then((toc) => {
var _chapters = []
toc.forEach((chapter) => {
_chapters.push(chapter)
})
this.chapters = _chapters
})
book.loaded.metadata.then((metadata) => {
this.author = metadata.creator
this.title = metadata.title
})
this.rendition.on('keyup', this.keyUp)
this.rendition.on('relocated', (location) => {
var percent = book.locations.percentageFromCfi(location.start.cfi)
this.progress = Math.floor(percent * 100)
this.hasNext = !location.atEnd
this.hasPrev = !location.atStart
})
}
},
mounted() {
this.initEpub()
}
}
</script>

View File

@@ -0,0 +1,118 @@
<template>
<div class="w-full h-full">
<div class="h-full max-h-full w-full">
<div class="ebook-viewer absolute overflow-y-scroll left-0 right-0 top-12 w-full max-w-4xl m-auto z-10 border border-black border-opacity-20 shadow-md bg-white">
<iframe title="html-viewer" width="100%"> Loading </iframe>
</div>
</div>
</div>
</template>
<script>
import MobiParser from '@/assets/ebooks/mobi.js'
import HtmlParser from '@/assets/ebooks/htmlParser.js'
import defaultCss from '@/assets/ebooks/basic.js'
export default {
props: {
url: String
},
data() {
return {}
},
computed: {},
methods: {
addHtmlCss() {
let iframe = document.getElementsByTagName('iframe')[0]
if (!iframe) return
let doc = iframe.contentDocument
if (!doc) return
let style = doc.createElement('style')
style.id = 'default-style'
style.textContent = defaultCss
doc.head.appendChild(style)
},
handleIFrameHeight(iFrame) {
const isElement = (obj) => !!(obj && obj.nodeType === 1)
var body = iFrame.contentWindow.document.body,
html = iFrame.contentWindow.document.documentElement
iFrame.height = Math.max(body.scrollHeight, body.offsetHeight, html.clientHeight, html.scrollHeight, html.offsetHeight) * 2
setTimeout(() => {
let lastchild = body.lastElementChild
let lastEle = body.lastChild
let itemAs = body.querySelectorAll('a')
let itemPs = body.querySelectorAll('p')
let lastItemA = itemAs[itemAs.length - 1]
let lastItemP = itemPs[itemPs.length - 1]
let lastItem
if (isElement(lastItemA) && isElement(lastItemP)) {
if (lastItemA.clientHeight + lastItemA.offsetTop > lastItemP.clientHeight + lastItemP.offsetTop) {
lastItem = lastItemA
} else {
lastItem = lastItemP
}
}
if (!lastchild && !lastItem && !lastEle) return
if (lastEle.nodeType === 3 && !lastchild && !lastItem) return
let nodeHeight = 0
if (lastEle.nodeType === 3 && document.createRange) {
let range = document.createRange()
range.selectNodeContents(lastEle)
if (range.getBoundingClientRect) {
let rect = range.getBoundingClientRect()
if (rect) {
nodeHeight = rect.bottom - rect.top
}
}
}
var lastChildHeight = isElement(lastchild) ? lastchild.clientHeight + lastchild.offsetTop : 0
var lastEleHeight = isElement(lastEle) ? lastEle.clientHeight + lastEle.offsetTop : 0
var lastItemHeight = isElement(lastItem) ? lastItem.clientHeight + lastItem.offsetTop : 0
iFrame.height = Math.max(lastChildHeight, lastEleHeight, lastItemHeight) + 100 + nodeHeight
}, 500)
},
async initMobi() {
// Fetch mobi file as blob
var buff = await this.$axios.$get(this.url, {
responseType: 'blob'
})
var reader = new FileReader()
reader.onload = async (event) => {
var file_content = event.target.result
let mobiFile = new MobiParser(file_content)
let content = await mobiFile.render()
let htmlParser = new HtmlParser(new DOMParser().parseFromString(content.outerHTML, 'text/html'))
var anchoredDoc = htmlParser.getAnchoredDoc()
let iFrame = document.getElementsByTagName('iframe')[0]
iFrame.contentDocument.body.innerHTML = anchoredDoc.documentElement.outerHTML
// Add css
let style = iFrame.contentDocument.createElement('style')
style.id = 'default-style'
style.textContent = defaultCss
iFrame.contentDocument.head.appendChild(style)
this.handleIFrameHeight(iFrame)
}
reader.readAsArrayBuffer(buff)
}
},
mounted() {
this.initMobi()
}
}
</script>
<style>
.ebook-viewer {
height: calc(100% - 96px);
}
</style>

View File

@@ -0,0 +1,82 @@
<template>
<div class="w-full h-full pt-20 relative">
<div v-show="canGoPrev" class="absolute top-0 left-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="prev" @mousedown.prevent>
<div class="flex items-center justify-center h-full w-1/2">
<span class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_back_ios</span>
</div>
</div>
<div v-show="canGoNext" class="absolute top-0 right-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="next" @mousedown.prevent>
<div class="flex items-center justify-center h-full w-1/2 ml-auto">
<span class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_forward_ios</span>
</div>
</div>
<div class="absolute top-0 right-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center">
<p class="font-mono">{{ page }} / {{ numPages }}</p>
</div>
<div :style="{ height: pdfHeight + 'px' }" class="overflow-hidden m-auto">
<div class="flex items-center justify-center">
<div :style="{ width: pdfWidth + 'px', height: pdfHeight + 'px' }" class="w-full h-full overflow-auto">
<div v-if="loadedRatio > 0 && loadedRatio < 1" style="background-color: green; color: white; text-align: center" :style="{ width: loadedRatio * 100 + '%' }">{{ Math.floor(loadedRatio * 100) }}%</div>
<pdf ref="pdf" class="m-auto z-10 border border-black border-opacity-20 shadow-md" :src="url" :page="page" :rotate="rotate" @progress="loadedRatio = $event" @error="error" @num-pages="numPagesLoaded" @link-clicked="page = $event"></pdf>
</div>
</div>
</div>
<!-- <div class="text-center py-2 text-lg">
<p>{{ page }} / {{ numPages }}</p>
</div> -->
</div>
</template>
<script>
import pdf from 'vue-pdf'
export default {
components: {
pdf
},
props: {
url: String
},
data() {
return {
rotate: 0,
loadedRatio: 0,
page: 1,
numPages: 0
}
},
computed: {
pdfWidth() {
return this.pdfHeight * 0.6667
},
pdfHeight() {
return window.innerHeight - 120
},
canGoNext() {
return this.page < this.numPages
},
canGoPrev() {
return this.page > 1
}
},
methods: {
numPagesLoaded(e) {
this.numPages = e
},
prev() {
if (this.page <= 1) return
this.page--
},
next() {
if (this.page >= this.numPages) return
this.page++
},
error(err) {
console.error(err)
}
},
mounted() {}
}
</script>

View File

@@ -0,0 +1,155 @@
<template>
<div v-if="show" class="w-screen h-screen fixed top-0 left-0 z-50 bg-primary text-white">
<div class="absolute top-4 right-4 z-20">
<span class="material-icons cursor-pointer text-4xl" @click="close">close</span>
</div>
<div class="absolute top-4 left-4 font-book">
<h1 class="text-2xl mb-1">{{ abTitle }}</h1>
<p v-if="abAuthor">by {{ abAuthor }}</p>
</div>
<component v-if="componentName" ref="readerComponent" :is="componentName" :url="ebookUrl" />
<div class="absolute bottom-2 left-2">{{ ebookType }}</div>
</div>
</template>
<script>
export default {
data() {
return {
ebookType: '',
ebookUrl: ''
}
},
watch: {
show(newVal) {
if (newVal) {
this.init()
}
}
},
computed: {
show: {
get() {
return this.$store.state.showEReader
},
set(val) {
this.$store.commit('setShowEReader', val)
}
},
componentName() {
if (this.ebookType === 'epub') return 'readers-epub-reader'
else if (this.ebookType === 'mobi') return 'readers-mobi-reader'
else if (this.ebookType === 'pdf') return 'readers-pdf-reader'
else if (this.ebookType === 'comic') return 'readers-comic-reader'
return null
},
abTitle() {
return this.selectedAudiobook.book.title
},
abAuthor() {
return this.selectedAudiobook.book.author
},
selectedAudiobook() {
return this.$store.state.selectedAudiobook
},
libraryId() {
return this.selectedAudiobook.libraryId
},
folderId() {
return this.selectedAudiobook.folderId
},
ebooks() {
return this.selectedAudiobook.ebooks || []
},
epubEbook() {
return this.ebooks.find((eb) => eb.ext === '.epub')
},
mobiEbook() {
return this.ebooks.find((eb) => eb.ext === '.mobi' || eb.ext === '.azw3')
},
pdfEbook() {
return this.ebooks.find((eb) => eb.ext === '.pdf')
},
comicEbook() {
return this.ebooks.find((eb) => eb.ext === '.cbz' || eb.ext === '.cbr')
},
userToken() {
return this.$store.getters['user/getToken']
},
selectedAudiobookFile() {
return this.$store.state.selectedAudiobookFile
}
},
methods: {
getEbookUrl(path) {
return `/ebook/${this.libraryId}/${this.folderId}/${path}`
},
hotkey(action) {
console.log('Reader hotkey', action)
if (!this.$refs.readerComponent) return
if (action === this.$hotkeys.EReader.NEXT_PAGE) {
if (this.$refs.readerComponent.next) this.$refs.readerComponent.next()
} else if (action === this.$hotkeys.EReader.PREV_PAGE) {
if (this.$refs.readerComponent.prev) this.$refs.readerComponent.prev()
} else if (action === this.$hotkeys.EReader.CLOSE) {
this.close()
}
},
registerListeners() {
this.$eventBus.$on('reader-hotkey', this.hotkey)
},
unregisterListeners() {
this.$eventBus.$off('reader-hotkey', this.hotkey)
},
init() {
this.registerListeners()
if (this.selectedAudiobookFile) {
this.ebookUrl = this.getEbookUrl(this.selectedAudiobookFile.path)
if (this.selectedAudiobookFile.ext === '.pdf') {
this.ebookType = 'pdf'
} else if (this.selectedAudiobookFile.ext === '.mobi' || this.selectedAudiobookFile.ext === '.azw3') {
this.ebookType = 'mobi'
} else if (this.selectedAudiobookFile.ext === '.epub') {
this.ebookType = 'epub'
} else if (this.selectedAudiobookFile.ext === '.cbr' || this.selectedAudiobookFile.ext === '.cbz') {
this.ebookType = 'comic'
}
} else if (this.epubEbook) {
this.ebookType = 'epub'
this.ebookUrl = this.getEbookUrl(this.epubEbook.path)
} else if (this.mobiEbook) {
this.ebookType = 'mobi'
this.ebookUrl = this.getEbookUrl(this.mobiEbook.path)
} else if (this.pdfEbook) {
this.ebookType = 'pdf'
this.ebookUrl = this.getEbookUrl(this.pdfEbook.path)
} else if (this.comicEbook) {
this.ebookType = 'comic'
this.ebookUrl = this.getEbookUrl(this.comicEbook.path)
}
},
close() {
this.unregisterListeners()
this.show = false
}
},
mounted() {
if (this.show) this.init()
},
beforeDestroy() {
this.unregisterListeners()
}
}
</script>
<style>
/* @import url(@/assets/calibre/basic.css); */
.ebook-viewer {
height: calc(100% - 96px);
}
</style>

View File

@@ -0,0 +1,109 @@
<template>
<div class="w-full my-2">
<div class="w-full bg-primary px-6 py-2 flex items-center cursor-pointer">
<p class="pr-4">Files</p>
<span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ allFiles.length }}</span>
<div class="flex-grow" />
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
</div>
<div class="w-full">
<table class="text-sm tracksTable">
<tr class="font-book">
<th class="text-left px-4">Path</th>
<th class="text-left px-4 w-24">Filetype</th>
<th v-if="userCanDownload" class="text-center w-20">Download</th>
</tr>
<template v-for="file in allFiles">
<tr :key="file.path">
<td class="font-book pl-2">
{{ showFullPath ? file.fullPath : file.path }}
</td>
<td class="text-xs">
<p>{{ file.filetype }}</p>
</td>
<td v-if="userCanDownload" class="text-center">
<a :href="`/s/book/${audiobookId}/${file.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
</td>
</tr>
</template>
</table>
</div>
</div>
</template>
<script>
export default {
props: {
audiobook: {
type: Object,
default: () => {}
}
},
data() {
return {
showFullPath: false
}
},
computed: {
audiobookId() {
return this.audiobook.id
},
audiobookPath() {
return this.audiobook.path
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
userToken() {
return this.$store.getters['user/getToken']
},
isMissing() {
return this.audiobook.isMissing
},
showDownload() {
return this.userCanDownload && !this.isMissing
},
otherFiles() {
return this.audiobook.otherFiles || []
},
audioFiles() {
return this.audiobook.audioFiles || []
},
audioFilesCleaned() {
return this.audioFiles.map((af) => {
return {
path: af.path,
fullPath: af.fullPath,
relativePath: this.getRelativePath(af.path),
filetype: 'audio'
}
})
},
otherFilesCleaned() {
return this.otherFiles.map((af) => {
return {
path: af.path,
fullPath: af.fullPath,
relativePath: this.getRelativePath(af.path),
filetype: af.filetype
}
})
},
allFiles() {
return this.audioFilesCleaned.concat(this.otherFilesCleaned)
}
},
methods: {
getRelativePath(path) {
var filePath = path.replace(/\\/g, '/')
var audiobookPath = this.audiobookPath.replace(/\\/g, '/')
return filePath
.replace(audiobookPath + '/', '')
.replace(/%/g, '%25')
.replace(/#/g, '%23')
}
},
mounted() {}
}
</script>

View File

@@ -0,0 +1,190 @@
<template>
<div class="text-center mt-4">
<div class="flex py-4">
<ui-file-input ref="fileInput" class="mr-2" accept=".audiobookshelf" @change="backupUploaded">Upload Backup</ui-file-input>
<div class="flex-grow" />
<ui-btn :loading="isBackingUp" @click="clickCreateBackup">Create Backup</ui-btn>
</div>
<div class="relative">
<table id="backups">
<tr>
<th>File</th>
<th class="w-56">Datetime</th>
<th class="w-28">Size</th>
<th class="w-36"></th>
</tr>
<tr v-for="backup in backups" :key="backup.id">
<td>
<p class="truncate">/{{ backup.path.replace(/\\/g, '/') }}</p>
</td>
<td class="font-sans">{{ backup.datePretty }}</td>
<td class="font-mono">{{ $bytesPretty(backup.fileSize) }}</td>
<td>
<div class="w-full flex items-center justify-center">
<ui-btn small color="primary" @click="applyBackup(backup)">Apply</ui-btn>
<a :href="`/metadata/${backup.path.replace(/%/g, '%25').replace(/#/g, '%23')}?token=${userToken}`" class="mx-1 pt-0.5 hover:text-opacity-100 text-opacity-70 text-white" download><span class="material-icons text-xl">download</span></a>
<!-- <span class="material-icons text-xl hover:text-opacity-100 text-opacity-70 text-white cursor-pointer mx-1" @click="downloadBackup">download</span> -->
<span class="material-icons text-xl hover:text-error hover:text-opacity-100 text-opacity-70 text-white cursor-pointer mx-1" @click="deleteBackupClick(backup)">delete</span>
</div>
</td>
</tr>
<tr v-if="!backups.length" class="staticrow">
<td colspan="4" class="text-lg">No Backups</td>
</tr>
</table>
<div v-show="processing" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-25 flex items-center justify-center">
<ui-loading-indicator />
</div>
</div>
<prompt-dialog v-model="showConfirmApply" :width="675">
<div v-if="selectedBackup" 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-base py-1">Applying a backup will overwrite users, user progress, book details, settings, and covers stored in metadata with the backed up data.</p>
<p class="text-base py-1">Backups <strong>do not</strong> modify any files in your library folders, only data in the audiobookshelf created <span class="font-mono">/config</span> and <span class="font-mono">/metadata</span> directories.</p>
<p class="text-base py-1">All clients using your server will be automatically refreshed.</p>
<p class="text-lg text-center my-8">Are you sure you want to apply the backup created on {{ selectedBackup.datePretty }}?</p>
<div class="flex px-1 items-center">
<ui-btn color="primary" @click="showConfirmApply = false">Nevermind</ui-btn>
<div class="flex-grow" />
<ui-btn color="success" @click="confirm">Apply Backup</ui-btn>
</div>
</div>
</prompt-dialog>
</div>
</template>
<script>
export default {
data() {
return {
showConfirmApply: false,
selectedBackup: null,
isBackingUp: false,
processing: false
}
},
computed: {
backups() {
return this.$store.state.backups || []
},
userToken() {
return this.$store.getters['user/getToken']
}
},
methods: {
confirm() {
this.showConfirmApply = false
this.$root.socket.once('apply_backup_complete', this.applyBackupComplete)
this.$root.socket.emit('apply_backup', this.selectedBackup.id)
},
deleteBackupClick(backup) {
if (confirm(`Are you sure you want to delete backup for ${backup.datePretty}?`)) {
this.processing = true
this.$axios
.$delete(`/api/backup/${backup.id}`)
.then((backups) => {
console.log('Backup deleted', backups)
this.$store.commit('setBackups', backups)
this.$toast.success(`Backup deleted`)
this.processing = false
})
.catch((error) => {
console.error(error)
this.$toast.error('Failed to delete backup')
this.processing = false
})
}
},
applyBackupComplete(success) {
if (success) {
// this.$toast.success('Backup Applied, refresh the page')
location.replace('/config?backup=1')
} else {
this.$toast.error('Failed to apply backup')
}
},
applyBackup(backup) {
this.selectedBackup = backup
this.showConfirmApply = true
},
backupComplete(backups) {
this.isBackingUp = false
if (backups) {
this.$toast.success('Backup Successful')
this.$store.commit('setBackups', backups)
} else this.$toast.error('Backup Failed')
},
clickCreateBackup() {
this.isBackingUp = true
this.$root.socket.once('backup_complete', this.backupComplete)
this.$root.socket.emit('create_backup')
},
backupUploaded(file) {
var form = new FormData()
form.set('file', file)
this.processing = true
this.$axios
.$post('/api/backup/upload', form)
.then((result) => {
console.log('Upload backup result', result)
this.$store.commit('setBackups', result)
this.$toast.success('Backup upload success')
this.processing = false
})
.catch((error) => {
console.error(error)
var errorMessage = error.response && error.response.data ? error.response.data : 'Failed to upload backup'
this.$toast.error(errorMessage)
this.processing = false
})
}
},
mounted() {
if (this.$route.query.backup) {
this.$toast.success('Backup applied successfully')
this.$router.replace('/config')
}
}
}
</script>
<style>
#backups {
table-layout: fixed;
border-collapse: collapse;
width: 100%;
}
#backups td,
#backups th {
border: 1px solid #2e2e2e;
padding: 8px 8px;
text-align: left;
}
#backups tr.staticrow td {
text-align: center;
}
#backups tr:nth-child(even) {
background-color: #3a3a3a;
}
#backups tr:not(.staticrow):hover {
background-color: #444;
}
#backups th {
font-size: 0.8rem;
font-weight: 600;
padding-top: 5px;
padding-bottom: 5px;
background-color: #333;
}
</style>

View File

@@ -0,0 +1,118 @@
<template>
<div id="librariesTable" 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">Libraries</h1>
<div class="mx-2 w-7 h-7 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center" @click="clickAddLibrary">
<span class="material-icons" style="font-size: 1.4rem">add</span>
</div>
</div>
<draggable v-model="libraryCopies" v-bind="dragOptions" class="list-group" draggable=".item" tag="div" @start="startDrag" @end="endDrag">
<!-- <transition-group type="transition" :name="!drag ? 'flip-list' : null"> -->
<template v-for="library in libraryCopies">
<modals-libraries-library-item :key="library.id" :library="library" :selected="currentLibraryId === library.id" :show-edit="true" :dragging="drag" @edit="editLibrary" @click="setLibrary" class="item" />
</template>
<!-- </transition-group> -->
</draggable>
<modals-edit-library-modal v-model="showLibraryModal" :library="selectedLibrary" />
</div>
</template>
<script>
import draggable from 'vuedraggable'
export default {
components: {
draggable
},
data() {
return {
libraryCopies: [],
currentOrder: [],
showLibraryModal: false,
selectedLibrary: null,
drag: false,
dragOptions: {
animation: 200,
group: 'description',
ghostClass: 'ghost'
},
orderTimeout: null
}
},
computed: {
currentLibrary() {
return this.$store.getters['libraries/getCurrentLibrary']
},
currentLibraryId() {
return this.currentLibrary ? this.currentLibrary.id : null
},
libraries() {
return this.$store.getters['libraries/getSortedLibraries']()
}
},
methods: {
startDrag() {
this.drag = true
clearTimeout(this.orderTimeout)
},
endDrag() {
this.drag = false
this.checkOrder()
console.log('DRAG END')
},
checkOrder() {
clearTimeout(this.orderTimeout)
this.orderTimeout = setTimeout(() => {
this.saveOrder()
}, 500)
},
saveOrder() {
var _newOrder = 1
var currOrder = this.libraries.map((lib) => lib.id).join(',')
var libraryOrderData = this.libraryCopies.map((library) => {
return {
newOrder: _newOrder++,
oldOrder: library.displayOrder,
id: library.id
}
})
var newOrder = libraryOrderData.map((lib) => lib.id).join(',')
if (currOrder !== newOrder) {
this.$axios.$patch('/api/libraries/order', libraryOrderData).then((libraries) => {
if (libraries && libraries.length) {
this.$toast.success('Library order saved', { timeout: 1500 })
this.$store.commit('libraries/set', libraries)
}
})
}
},
async setLibrary(library) {
await this.$store.dispatch('libraries/fetch', library.id)
this.$router.push(`/library/${library.id}`)
},
clickAddLibrary() {
this.selectedLibrary = null
this.showLibraryModal = true
},
editLibrary(library) {
this.selectedLibrary = library
this.showLibraryModal = true
},
init() {
this.libraryCopies = this.libraries.map((lib) => {
return { ...lib }
})
},
librariesUpdated() {
this.init()
}
},
mounted() {
this.$store.commit('libraries/addListener', { id: 'libraries-table', meth: this.librariesUpdated })
this.init()
},
beforeDestroy() {
this.$store.commit('libraries/removeListener', 'libraries-table')
}
}
</script>

View File

@@ -2,11 +2,14 @@
<div class="w-full my-2">
<div class="w-full bg-primary px-6 py-2 flex items-center cursor-pointer" @click.stop="clickBar">
<p class="pr-4">Other Files</p>
<span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ files.length }}</span>
<div class="h-7 w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
<span class="text-sm font-mono">{{ files.length }}</span>
</div>
<div class="flex-grow" />
<!-- <nuxt-link :to="`/audiobook/${audiobookId}/edit`" class="mr-4">
<ui-btn small color="primary">Manage Tracks</ui-btn>
</nuxt-link> -->
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showFiles ? 'transform rotate-180' : ''">
<span class="material-icons text-4xl">expand_more</span>
</div>
@@ -15,16 +18,23 @@
<div class="w-full" v-show="showFiles">
<table class="text-sm tracksTable">
<tr class="font-book">
<th class="text-left">Path</th>
<th class="text-left">Filetype</th>
<th class="text-left px-4">Path</th>
<th class="text-left px-4 w-24">Filetype</th>
<th v-if="userCanDownload && !isMissing" class="text-center w-20">Download</th>
</tr>
<template v-for="file in files">
<template v-for="file in otherFilesCleaned">
<tr :key="file.path">
<td class="font-book pl-2">
{{ file.path }}
{{ showFullPath ? file.fullPath : file.path }}
</td>
<td class="text-xs">
<p>{{ file.filetype }}</p>
<div class="flex items-center">
<span v-if="file.filetype === 'ebook'" class="material-icons text-base mr-1 cursor-pointer text-white text-opacity-60 hover:text-opacity-100" @click="readEbookClick(file)">auto_stories </span>
<p>{{ file.filetype }}</p>
</div>
</td>
<td v-if="userCanDownload && !isMissing" class="text-center">
<a :href="`/s/book/${audiobookId}/${file.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
</td>
</tr>
</template>
@@ -41,15 +51,52 @@ export default {
type: Array,
default: () => []
},
audiobookId: String
audiobook: {
type: Object,
default: () => null
}
},
data() {
return {
showFiles: false
showFiles: false,
showFullPath: false
}
},
computed: {
audiobookId() {
return this.audiobook.id
},
audiobookPath() {
return this.audiobook.path
},
otherFilesCleaned() {
return this.files.map((file) => {
var filePath = file.path.replace(/\\/g, '/')
var audiobookPath = this.audiobookPath.replace(/\\/g, '/')
return {
...file,
relativePath: filePath
.replace(audiobookPath + '/', '')
.replace(/%/g, '%25')
.replace(/#/g, '%23')
}
})
},
userToken() {
return this.$store.getters['user/getToken']
},
isMissing() {
return this.audiobook.isMissing
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
}
},
computed: {},
methods: {
readEbookClick(file) {
this.$store.commit('showEReaderForFile', { audiobook: this.audiobook, file })
},
clickBar() {
this.showFiles = !this.showFiles
}

View File

@@ -2,8 +2,12 @@
<div class="w-full my-2">
<div class="w-full bg-primary px-6 py-2 flex items-center cursor-pointer" @click.stop="clickBar">
<p class="pr-4">Audio Tracks</p>
<span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ tracks.length }}</span>
<div class="h-7 w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
<span class="text-sm font-mono">{{ tracks.length }}</span>
</div>
<!-- <span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ tracks.length }}</span> -->
<div class="flex-grow" />
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobookId}/edit`" class="mr-4">
<ui-btn small color="primary">Manage Tracks</ui-btn>
</nuxt-link>
@@ -15,20 +19,18 @@
<div class="w-full" v-show="showTracks">
<table class="text-sm tracksTable">
<tr class="font-book">
<th>#</th>
<th class="w-10">#</th>
<th class="text-left">Filename</th>
<th class="text-left">Size</th>
<th class="text-left">Duration</th>
<th v-if="userCanDownload" class="text-center">Download</th>
<th class="text-left w-20">Size</th>
<th class="text-left w-20">Duration</th>
<th v-if="userCanDownload" class="text-center w-20">Download</th>
</tr>
<template v-for="track in tracks">
<template v-for="track in tracksCleaned">
<tr :key="track.index">
<td class="text-center">
<p>{{ track.index }}</p>
</td>
<td class="font-book">
{{ track.filename }}
</td>
<td class="font-sans">{{ showFullPath ? track.fullPath : track.filename }}</td>
<td class="font-mono">
{{ $bytesPretty(track.size) }}
</td>
@@ -36,7 +38,7 @@
{{ $secondsToTimestamp(track.duration) }}
</td>
<td v-if="userCanDownload" class="text-center">
<a :href="`/local/${track.path}`" download><span class="material-icons icon-text">download</span></a>
<a :href="`/s/book/${audiobook.id}/${track.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
</td>
</tr>
</template>
@@ -53,14 +55,41 @@ export default {
type: Array,
default: () => []
},
audiobookId: String
audiobook: {
type: Object,
default: () => null
}
},
data() {
return {
showTracks: false
showTracks: false,
showFullPath: false
}
},
computed: {
audiobookId() {
return this.audiobook.id
},
audiobookPath() {
return this.audiobook.path
},
tracksCleaned() {
return this.tracks.map((track) => {
var trackPath = track.path.replace(/\\/g, '/')
var audiobookPath = this.audiobookPath.replace(/\\/g, '/')
return {
...track,
relativePath: trackPath
.replace(audiobookPath + '/', '')
.replace(/%/g, '%25')
.replace(/#/g, '%23')
}
})
},
userToken() {
return this.$store.getters['user/getToken']
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},

View File

@@ -0,0 +1,215 @@
<template>
<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">Users</h1>
<div class="mx-2 w-7 h-7 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center" @click="clickAddUser">
<span class="material-icons" style="font-size: 1.4rem">add</span>
</div>
</div>
<!-- <div class="h-0.5 bg-primary bg-opacity-50 w-full" /> -->
<div class="text-center">
<table id="accounts">
<tr>
<th>Username</th>
<th class="w-20">Type</th>
<th>Activity</th>
<th class="w-32">Last Seen</th>
<th class="w-32">Created</th>
<th class="w-32"></th>
</tr>
<tr v-for="user in users" :key="user.id" class="cursor-pointer" :class="user.isActive ? '' : 'bg-error bg-opacity-20'" @click="$router.push(`/config/users/${user.id}`)">
<td>
<div class="flex items-center">
<widgets-online-indicator :value="!!usersOnline[user.id]" />
<span class="pl-2">{{ user.username }}</span> <span v-show="$isDev" class="text-xs text-gray-400 italic pl-4">({{ user.id }})</span>
</div>
</td>
<td class="text-sm">{{ user.type }}</td>
<td>
<div v-if="usersOnline[user.id] && usersOnline[user.id].stream && usersOnline[user.id].stream.audiobook && usersOnline[user.id].stream.audiobook.book">
<p class="truncate text-xs">Reading: {{ usersOnline[user.id].stream.audiobook.book.title || '' }}</p>
</div>
<div v-else-if="user.audiobooks && getLastRead(user.audiobooks)">
<p class="truncate text-xs">Last: {{ getLastRead(user.audiobooks) }}</p>
</div>
</td>
<td class="text-xs font-mono">
<ui-tooltip v-if="user.lastSeen" direction="top" :text="$formatDate(user.lastSeen, 'MMMM do, yyyy HH:mm')">
{{ $dateDistanceFromNow(user.lastSeen) }}
</ui-tooltip>
</td>
<td class="text-xs font-mono">
<ui-tooltip direction="top" :text="$formatDate(user.createdAt, 'MMMM do, yyyy HH:mm')">
{{ $formatDate(user.createdAt, 'MMM d, yyyy') }}
</ui-tooltip>
</td>
<td class="py-0">
<div class="w-full flex justify-center">
<!-- <span class="material-icons hover:text-gray-400 cursor-pointer text-base pr-2" @click.stop="editUser(user)">edit</span> -->
<div class="h-8 w-8 flex items-center justify-center text-white text-opacity-50 hover:text-opacity-100 cursor-pointer" @click.stop="editUser(user)">
<span class="material-icons text-base">edit</span>
</div>
<div v-show="user.type !== 'root'" class="h-8 w-8 flex items-center justify-center text-white text-opacity-50 hover:text-error cursor-pointer" @click.stop="deleteUserClick(user)">
<span class="material-icons text-base">delete</span>
</div>
</div>
</td>
</tr>
</table>
</div>
<modals-account-modal v-model="showAccountModal" :account="selectedAccount" />
</div>
</template>
<script>
export default {
data() {
return {
users: [],
selectedAccount: null,
showAccountModal: false,
isDeletingUser: false
}
},
computed: {
currentUserId() {
return this.$store.state.user.user.id
},
userStream() {
return this.$store.state.streamAudiobook
},
usersOnline() {
var usermap = {}
this.$store.state.users.users.forEach((u) => (usermap[u.id] = { online: true, stream: u.stream }))
return usermap
}
},
methods: {
getLastRead(audiobooks) {
var abs = Object.values(audiobooks)
if (abs.length) {
abs = abs.sort((a, b) => a.lastUpdate - b.lastUpdate)
// Book object is attached on request
if (abs[0].book) return abs[0].book.title
return abs[0].audiobookTitle ? abs[0].audiobookTitle : null
}
return null
},
deleteUserClick(user) {
if (this.isDeletingUser) return
if (confirm(`Are you sure you want to permanently delete user "${user.username}"?`)) {
this.isDeletingUser = true
this.$axios
.$delete(`/api/user/${user.id}`)
.then((data) => {
this.isDeletingUser = false
if (data.error) {
this.$toast.error(data.error)
} else {
this.$toast.success('User deleted')
}
})
.catch((error) => {
console.error('Failed to delete user', error)
this.$toast.error('Failed to delete user')
this.isDeletingUser = false
})
}
},
clickAddUser() {
this.selectedAccount = null
this.showAccountModal = true
},
editUser(user) {
this.selectedAccount = user
this.showAccountModal = true
},
loadUsers() {
this.$axios
.$get('/api/users')
.then((users) => {
this.users = users.sort((a, b) => {
return a.createdAt - b.createdAt
})
})
.catch((error) => {
console.error('Failed', error)
})
},
addUpdateUser(user) {
if (!this.users) return
var index = this.users.findIndex((u) => u.id === user.id)
if (index >= 0) {
this.users.splice(index, 1, user)
} else {
this.users.push(user)
}
},
userRemoved(user) {
this.users = this.users.filter((u) => u.id !== user.id)
},
init(attempts = 0) {
if (!this.$root.socket) {
if (attempts > 10) {
return console.error('Failed to setup socket listeners')
}
setTimeout(() => {
this.init(++attempts)
}, 250)
return
}
this.$root.socket.on('user_added', this.addUpdateUser)
this.$root.socket.on('user_updated', this.addUpdateUser)
this.$root.socket.on('user_removed', this.userRemoved)
}
},
mounted() {
this.loadUsers()
this.init()
},
beforeDestroy() {
if (this.$root.socket) {
this.$root.socket.off('user_added', this.newUserAdded)
this.$root.socket.off('user_updated', this.userUpdated)
this.$root.socket.off('user_removed', this.userRemoved)
}
}
}
</script>
<style>
#accounts {
table-layout: fixed;
border-collapse: collapse;
border: 1px solid #474747;
width: 100%;
}
#accounts td,
#accounts th {
/* border: 1px solid #2e2e2e; */
padding: 8px 8px;
text-align: left;
}
#accounts td.py-0 {
padding: 0px 8px;
}
#accounts tr:nth-child(even) {
background-color: #3a3a3a;
}
#accounts tr:hover {
background-color: #444;
}
#accounts th {
font-size: 0.8rem;
font-weight: 600;
padding-top: 5px;
padding-bottom: 5px;
background-color: #333;
}
</style>

View File

@@ -56,6 +56,9 @@ export default {
if (this.paddingX !== undefined) {
list.push(`px-${this.paddingX}`)
}
if (this.disabled) {
list.push('cursor-not-allowed')
}
return list
}
},

View File

@@ -0,0 +1,77 @@
<template>
<div class="relative w-full" v-click-outside="clickOutside">
<p class="text-sm text-opacity-75 mb-1">{{ label }}</p>
<button type="button" :disabled="disabled" class="relative h-10 w-full border border-gray-500 rounded shadow-sm pl-3 pr-10 py-2 text-left focus:outline-none sm:text-sm cursor-pointer bg-primary" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
<span class="flex items-center">
<span class="block truncate">{{ selectedText }}</span>
</span>
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<span class="material-icons text-gray-100">expand_more</span>
</span>
</button>
<transition name="menu">
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox">
<template v-for="item in items">
<li :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
<div class="flex items-center">
<span class="font-normal ml-3 block truncate font-sans text-sm">{{ item.text }}</span>
</div>
</li>
</template>
</ul>
</transition>
</div>
</template>
<script>
export default {
props: {
value: [String, Number],
label: {
type: String,
default: ''
},
items: {
type: Array,
default: () => []
},
disabled: Boolean
},
data() {
return {
showMenu: false
}
},
computed: {
selected: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
selectedItem() {
return this.items.find((i) => i.value === this.selected)
},
selectedText() {
return this.selectedItem ? this.selectedItem.text : ''
}
},
methods: {
clickShowMenu() {
if (this.disabled) return
this.showMenu = !this.showMenu
},
clickOutside() {
this.showMenu = false
},
clickedOption(itemValue) {
this.selected = itemValue
this.showMenu = false
}
},
mounted() {}
}
</script>

View File

@@ -0,0 +1,58 @@
<template>
<input ref="input" v-model="inputValue" :type="type" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="py-2 px-1 bg-transparent border-b border-opacity-0 border-gray-400 focus:border-opacity-100 focus:outline-none" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
</template>
<script>
export default {
props: {
value: [String, Number],
placeholder: String,
readonly: Boolean,
type: {
type: String,
default: 'text'
},
disabled: Boolean
},
data() {
return {}
},
computed: {
inputValue: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
}
},
methods: {
focused() {
this.$emit('focus')
},
blurred() {
this.$emit('blur')
},
change(e) {
this.$emit('change', e.target.value)
},
keyup(e) {
this.$emit('keyup', e)
},
blur() {
if (this.$refs.input) this.$refs.input.blur()
}
},
mounted() {}
}
</script>
<style scoped>
input {
border-style: inherit !important;
}
input:read-only {
background-color: #444;
}
</style>

View File

@@ -1,17 +1,21 @@
<template>
<div>
<input ref="fileInput" id="hidden-input" type="file" :accept="inputAccept" class="hidden" @change="inputChanged" />
<ui-btn @click="clickUpload" color="primary" type="text">Upload Cover</ui-btn>
<input ref="fileInput" id="hidden-input" type="file" :accept="accept" class="hidden" @change="inputChanged" />
<ui-btn @click="clickUpload" color="primary" type="text"><slot /></ui-btn>
</div>
</template>
<script>
export default {
data() {
return {
inputAccept: 'image/*'
props: {
accept: {
type: String,
default: '.png, .jpg, .jpeg, .webp'
}
},
data() {
return {}
},
computed: {},
methods: {
reset() {

View File

@@ -116,9 +116,6 @@ export default {
this.textInput = null
this.currentSearch = null
this.input = item
// this.input = this.textInput ? this.textInput.trim() : null
console.log('Clicked option', item)
if (this.$refs.input) this.$refs.input.blur()
}
},

View File

@@ -0,0 +1,80 @@
<template>
<div v-if="currentLibrary" class="relative w-36 h-8" v-click-outside="clickOutside">
<button type="button" :disabled="disabled" class="relative h-full w-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
<span class="flex items-center">
<widgets-library-icon :icon="currentLibraryIcon" class="mr-2" />
<span class="block truncate text-sm">{{ currentLibrary.name }}</span>
</span>
</button>
<transition name="menu">
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox">
<template v-for="library in librariesFiltered">
<li :key="library.id" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="selectLibrary(library)">
<div class="flex items-center px-3">
<widgets-library-icon :icon="currentLibraryIcon" class="mr-2" />
<span class="font-normal block truncate font-sans text-sm">{{ library.name }}</span>
</div>
</li>
</template>
</ul>
</transition>
</div>
</template>
<script>
export default {
data() {
return {
showMenu: false,
disabled: false
}
},
computed: {
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
currentLibrary() {
return this.libraries.find((lib) => lib.id === this.currentLibraryId)
},
currentLibraryIcon() {
return this.currentLibrary ? this.currentLibrary.icon || 'database' : 'database'
},
libraries() {
return this.$store.getters['libraries/getSortedLibraries']()
},
canUserAccessAllLibraries() {
return this.$store.getters['user/getUserCanAccessAllLibraries']
},
userLibrariesAccessible() {
return this.$store.getters['user/getLibrariesAccessible']
},
librariesFiltered() {
if (this.canUserAccessAllLibraries) return this.libraries
return this.libraries.filter((lib) => {
return this.userLibrariesAccessible.includes(lib.id)
})
}
},
methods: {
clickShowMenu() {
if (this.disabled) return
this.showMenu = !this.showMenu
},
clickOutside() {
this.showMenu = false
},
selectLibrary(library) {
this.updateLibrary(library)
this.showMenu = false
},
async updateLibrary(library) {
this.disabled = true
await this.$store.dispatch('libraries/fetch', library.id)
this.$router.push(`/library/${library.id}`)
this.disabled = false
}
},
mounted() {}
}
</script>

View File

@@ -127,6 +127,7 @@ export default {
return
}
this.isFocused = false
if (this.textInput) this.submitForm()
}, 50)
},
focus() {
@@ -145,6 +146,7 @@ export default {
var newSelected = null
if (this.selected.includes(itemValue)) {
newSelected = this.selected.filter((s) => s !== itemValue)
this.$emit('removedItem', itemValue)
} else {
newSelected = this.selected.concat([itemValue])
}
@@ -164,6 +166,7 @@ export default {
removeItem(item) {
var remaining = this.selected.filter((i) => i !== item)
this.$emit('input', remaining)
this.$emit('removedItem', item)
this.$nextTick(() => {
this.recalcMenuPos()
})
@@ -171,6 +174,7 @@ export default {
insertNewItem(item) {
this.selected.push(item)
this.$emit('input', this.selected)
this.$emit('newItem', item)
this.textInput = null
this.currentSearch = null
this.$nextTick(() => {

View File

@@ -0,0 +1,120 @@
<template>
<div class="w-full" v-click-outside="closeMenu">
<p class="px-1 text-sm font-semibold">{{ label }}</p>
<div ref="wrapper" class="relative">
<div ref="inputWrapper" style="min-height: 40px" class="flex-wrap relative w-full shadow-sm flex items-center bg-primary border border-gray-600 rounded-md px-2 py-1 cursor-pointer" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
<div v-for="item in selectedItems" :key="item.value" class="rounded-full px-2 py-1 ma-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center relative">
<div class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg bg-opacity-75 flex items-center justify-end cursor-pointer">
<span class="material-icons text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item.value)">close</span>
</div>
{{ item.text }}
</div>
</div>
<ul ref="menu" v-show="showMenu" class="absolute z-50 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in items">
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
<div class="flex items-center">
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
</div>
<span v-if="selected.includes(item.value)" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
<span class="material-icons text-xl">checkmark</span>
</span>
</li>
</template>
<li v-if="!items.length" class="text-gray-50 select-none relative py-2 pr-9" role="option">
<div class="flex items-center justify-center">
<span class="font-normal">No items</span>
</div>
</li>
</ul>
</div>
</div>
</template>
<script>
export default {
props: {
value: {
type: Array,
default: () => []
},
items: {
type: Array,
default: () => []
},
label: String
},
data() {
return {
showMenu: false,
menu: null
}
},
computed: {
selected: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
selectedItems() {
return (this.value || []).map((v) => {
return this.items.find((i) => i.value === v) || {}
})
}
},
methods: {
recalcMenuPos() {
if (!this.menu) return
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
this.menu.style.top = boundingBox.y + boundingBox.height - 4 + 'px'
this.menu.style.left = boundingBox.x + 'px'
this.menu.style.width = boundingBox.width + 'px'
},
unmountMountMenu() {
if (!this.$refs.menu) return
this.menu = this.$refs.menu
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
this.menu.remove()
document.body.appendChild(this.menu)
this.menu.style.top = boundingBox.y + boundingBox.height - 4 + 'px'
this.menu.style.left = boundingBox.x + 'px'
this.menu.style.width = boundingBox.width + 'px'
},
clickedOption(e, item) {
if (e) {
e.stopPropagation()
e.preventDefault()
}
var newSelected = null
if (this.selected.includes(item.value)) {
newSelected = this.selected.filter((s) => s !== item.value)
} else {
newSelected = this.selected.concat([item.value])
}
this.$emit('input', newSelected)
this.$nextTick(() => {
this.recalcMenuPos()
})
},
closeMenu() {
this.showMenu = false
},
clickWrapper() {
this.showMenu = !this.showMenu
},
removeItem(itemValue) {
var remaining = this.selected.filter((i) => i !== itemValue)
this.$emit('input', remaining)
this.$nextTick(() => {
this.recalcMenuPos()
})
}
},
mounted() {}
}
</script>

View File

@@ -1,7 +1,7 @@
<template>
<button class="icon-btn rounded-md bg-primary border border-gray-600 flex items-center justify-center h-9 w-9 relative" @click="clickBtn">
<div class="w-5 h-5 text-white relative">
<svg v-if="isRead" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<svg v-if="isRead" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="rgb(63, 181, 68)">
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">

View File

@@ -1,5 +1,10 @@
<template>
<input ref="input" v-model="inputValue" :type="type" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="rounded bg-primary text-gray-200 focus:border-gray-500 focus:outline-none border border-gray-600" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
<div class="relative">
<input ref="input" v-model="inputValue" :type="type" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="rounded bg-primary text-gray-200 focus:border-gray-300 focus:bg-bg focus:outline-none border border-gray-600 h-full w-full" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
<div v-if="clearable && inputValue" class="absolute top-0 right-0 h-full px-2 flex items-center justify-center">
<span class="material-icons text-gray-300 cursor-pointer" style="font-size: 1.1rem" @click.stop.prevent="clear">close</span>
</div>
</div>
</template>
<script>
@@ -20,7 +25,10 @@ export default {
paddingX: {
type: Number,
default: 3
}
},
noSpinner: Boolean,
textCenter: Boolean,
clearable: Boolean
},
data() {
return {}
@@ -38,10 +46,15 @@ export default {
var _list = []
_list.push(`px-${this.paddingX}`)
_list.push(`py-${this.paddingY}`)
if (this.noSpinner) _list.push('no-spinner')
if (this.textCenter) _list.push('text-center')
return _list.join(' ')
}
},
methods: {
clear() {
this.inputValue = ''
},
focused() {
this.$emit('focus')
},

View File

@@ -1,7 +1,7 @@
<template>
<div>
<div class="border rounded-full border-black-100 flex items-center cursor-pointer w-12 justify-start" :class="className" @click="clickToggle">
<span class="rounded-full border w-6 h-6 border-black-50 shadow transform transition-transform duration-100" :class="switchClassName"></span>
<div class="border rounded-full border-black-100 flex items-center cursor-pointer w-10 justify-start" :class="className" @click="clickToggle">
<span class="rounded-full border w-5 h-5 border-black-50 shadow transform transition-transform duration-100" :class="switchClassName"></span>
</div>
</div>
</template>
@@ -30,12 +30,12 @@ export default {
}
},
className() {
if (this.disabled) return 'bg-bg cursor-not-allowed'
if (this.disabled) return this.toggleValue ? `bg-${this.onColor} cursor-not-allowed` : `bg-${this.offColor} cursor-not-allowed`
return this.toggleValue ? `bg-${this.onColor}` : `bg-${this.offColor}`
},
switchClassName() {
var bgColor = this.disabled ? 'bg-gray-300' : 'bg-white'
return this.toggleValue ? 'translate-x-6 ' + bgColor : bgColor
return this.toggleValue ? 'translate-x-5 ' + bgColor : bgColor
}
},
methods: {

View File

@@ -20,6 +20,7 @@ export default {
data() {
return {
tooltip: null,
tooltipId: null,
isShowing: false
}
},
@@ -45,13 +46,14 @@ export default {
'font-size': '0.75rem'
}
var size = this.$calculateTextSize(this.text, styles)
console.log('Text Size', size.width, size.height)
return size.width
},
createTooltip() {
if (!this.$refs.box) return
var tooltip = document.createElement('div')
tooltip.className = 'absolute px-2 py-1 text-white pointer-events-none text-xs rounded shadow-lg max-w-xs'
this.tooltipId = String(Math.floor(Math.random() * 10000))
tooltip.id = this.tooltipId
tooltip.className = 'tooltip-wrapper absolute px-2 py-1 text-white pointer-events-none text-xs rounded shadow-lg max-w-xs'
tooltip.style.zIndex = 100
tooltip.style.backgroundColor = 'rgba(0,0,0,0.85)'
tooltip.innerHTML = this.text
@@ -91,8 +93,15 @@ export default {
if (this.disabled) return
if (!this.tooltip) {
this.createTooltip()
if (!this.tooltip) return
}
if (!this.$refs.box) return // Ensure element is not destroyed
try {
document.body.appendChild(this.tooltip)
this.setTooltipPosition(this.tooltip)
} catch (error) {
console.error(error)
}
document.body.appendChild(this.tooltip)
this.isShowing = true
},
hideTooltip() {

View File

@@ -0,0 +1,33 @@
<template>
<button class="bg-error text-white px-2 py-1 shadow-md" @click="$emit('click', $event)">Cancel</button>
</template>
<script>
export default {
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>
<style>
.Vue-Toastification__close-button.cancel-scan-btn {
background-color: rgb(255, 82, 82);
color: white;
font-size: 0.9rem;
opacity: 1;
padding: 0px 10px;
border-radius: 6px;
font-weight: normal;
font-family: 'Open Sans';
margin-left: 10px;
opacity: 0.3;
}
.Vue-Toastification__close-button.cancel-scan-btn:hover {
background-color: rgb(235, 65, 65);
opacity: 1;
}
</style>

View File

@@ -0,0 +1,21 @@
<template>
<div class="h-4 w-4">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
</svg>
</div>
</template>
<script>
export default {
props: {
icon: String
},
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>

View File

@@ -0,0 +1,26 @@
<template>
<div class="w-3 h-3">
<div v-if="value" class="w-full h-full text-sm mr-2 text-success animate-pulse">
<svg viewBox="0 0 24 24">
<path fill="currentColor" d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" />
</svg>
</div>
<svg v-else class="w-full h-full mr-2 text-white text-opacity-20" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" />
</svg>
</div>
</template>
<script>
export default {
props: {
value: Boolean
},
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>

View File

@@ -1,64 +0,0 @@
<template>
<div v-show="isScanning" class="fixed bottom-4 left-0 right-0 mx-auto z-20 max-w-lg">
<div class="w-full my-1 rounded-lg drop-shadow-lg px-4 py-2 flex items-center justify-center text-center transition-all border border-white border-opacity-40 shadow-md bg-warning">
<p class="text-lg font-sans" v-html="text" />
</div>
<div v-show="!hasCanceled" class="absolute right-0 top-3 bottom-0 px-2">
<ui-btn color="red-600" small :padding-x="1" @click="cancelScan">Cancel</ui-btn>
</div>
</div>
</template>
<script>
export default {
data() {
return {
hasCanceled: false
}
},
watch: {
isScanning(newVal) {
if (newVal) {
this.hasCanceled = false
}
}
},
computed: {
text() {
var scanText = this.isScanningFiles ? 'Scanning...' : 'Scanning Covers...'
return `${scanText} <span class="font-mono">${this.scanNum}</span> of <span class="font-mono">${this.scanTotal}</span> <strong class='font-mono px-2'>${this.scanPercent}</strong>`
},
isScanning() {
return this.isScanningFiles || this.isScanningCovers
},
isScanningFiles() {
return this.$store.state.isScanning
},
isScanningCovers() {
return this.$store.state.isScanningCovers
},
scanProgressKey() {
return this.isScanningFiles ? 'scanProgress' : 'coverScanProgress'
},
scanProgress() {
return this.$store.state[this.scanProgressKey]
},
scanPercent() {
return this.scanProgress ? this.scanProgress.progress + '%' : '0%'
},
scanNum() {
return this.scanProgress ? this.scanProgress.done : 0
},
scanTotal() {
return this.scanProgress ? this.scanProgress.total : 0
}
},
methods: {
cancelScan() {
this.hasCanceled = true
this.$root.socket.emit('cancel_scan')
}
},
mounted() {}
}
</script>

View File

@@ -5,12 +5,15 @@
<Nuxt />
<app-stream-container ref="streamContainer" />
<modals-edit-modal />
<widgets-scan-alert />
<readers-reader />
</div>
</template>
<script>
import CloseButton from '@/components/widgets/CloseButton'
export default {
middleware: 'authenticated',
data() {
@@ -64,6 +67,20 @@ export default {
if (payload.serverSettings) {
this.$store.commit('setServerSettings', payload.serverSettings)
}
if (payload.librariesScanning) {
payload.librariesScanning.forEach((libraryScan) => {
this.scanStart(libraryScan)
})
}
if (payload.backups && payload.backups.length) {
this.$store.commit('setBackups', payload.backups)
}
if (payload.usersOnline) {
this.$store.commit('users/resetUsers')
payload.usersOnline.forEach((user) => {
this.$store.commit('users/updateUser', user)
})
}
},
streamOpen(stream) {
if (this.$refs.streamContainer) this.$refs.streamContainer.streamOpen(stream)
@@ -89,58 +106,61 @@ export default {
audiobookRemoved(audiobook) {
if (this.$route.name.startsWith('audiobook')) {
if (this.$route.params.id === audiobook.id) {
this.$router.replace('/library')
this.$router.replace(`/library/${this.$store.state.libraries.currentLibraryId}`)
}
}
this.$store.commit('audiobooks/remove', audiobook)
},
scanComplete({ scanType, results }) {
if (scanType === 'covers') {
this.$store.commit('setIsScanningCovers', false)
if (results) {
this.$toast.success(`Scan Finished\nUpdated ${results.found} covers`)
}
} else {
this.$store.commit('setIsScanning', false)
if (results) {
var scanResultMsgs = []
if (results.added) scanResultMsgs.push(`${results.added} added`)
if (results.updated) scanResultMsgs.push(`${results.updated} updated`)
if (results.removed) scanResultMsgs.push(`${results.removed} removed`)
if (results.missing) scanResultMsgs.push(`${results.missing} missing`)
if (!scanResultMsgs.length) this.$toast.success('Scan Finished\nEverything was up to date')
else this.$toast.success('Scan Finished\n' + scanResultMsgs.join('\n'))
}
}
libraryAdded(library) {
this.$store.commit('libraries/addUpdate', library)
},
scanStart(scanType) {
if (scanType === 'covers') {
this.$store.commit('setIsScanningCovers', true)
} else {
this.$store.commit('setIsScanning', true)
}
libraryUpdated(library) {
this.$store.commit('libraries/addUpdate', library)
},
scanProgress({ scanType, progress }) {
if (scanType === 'covers') {
this.$store.commit('setCoverScanProgress', progress)
} else {
this.$store.commit('setScanProgress', progress)
}
libraryRemoved(library) {
this.$store.commit('libraries/remove', library)
},
saveMetadataComplete(result) {
if (result.error) {
this.$toast.error(result.error)
} else if (result.audiobookId) {
var { savedPath } = result
if (!savedPath) {
this.$toast.error(`Failed to save metadata file (${result.audiobookId})`)
} else {
this.$toast.success(`Metadata file saved (${result.audiobookId})`)
}
scanComplete(data) {
var message = `Scan "${data.name}" complete!`
if (data.results) {
var scanResultMsgs = []
var results = data.results
if (results.added) scanResultMsgs.push(`${results.added} added`)
if (results.updated) scanResultMsgs.push(`${results.updated} updated`)
if (results.removed) scanResultMsgs.push(`${results.removed} removed`)
if (results.missing) scanResultMsgs.push(`${results.missing} missing`)
if (!scanResultMsgs.length) message += '\nEverything was up to date'
else message += '\n' + scanResultMsgs.join('\n')
} else {
var { success, failed } = result
this.$toast.success(`Metadata save complete\n${success} Succeeded\n${failed} Failed`)
message = `Scan "${data.name}" was canceled`
}
var existingScan = this.$store.getters['scanners/getLibraryScan'](data.id)
if (existingScan && !isNaN(existingScan.toastId)) {
this.$toast.update(existingScan.toastId, { content: message, options: { timeout: 5000, type: 'success', closeButton: false, position: 'bottom-center', onClose: () => null } }, true)
} else {
this.$toast.success(message, { timeout: 5000, position: 'bottom-center' })
}
this.$store.commit('scanners/remove', data)
},
onScanToastCancel(id) {
this.$root.socket.emit('cancel_scan', id)
},
scanStart(data) {
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) })
this.$store.commit('scanners/addUpdate', data)
},
scanProgress(data) {
var existingScan = this.$store.getters['scanners/getLibraryScan'](data.id)
if (existingScan && !isNaN(existingScan.toastId)) {
data.toastId = existingScan.toastId
this.$toast.update(existingScan.toastId, { content: `Scanning "${existingScan.name}"... ${data.progress.progress || 0}%`, options: { timeout: false } }, true)
} else {
data.toastId = this.$toast(`Scanning "${data.name}"...`, { timeout: false, type: 'info', draggable: false, closeOnClick: false, closeButton: CloseButton, closeButtonClassName: 'cancel-scan-btn', showCloseButtonOnHover: false, position: 'bottom-center', onClose: () => this.onScanToastCancel(data.id) })
}
this.$store.commit('scanners/addUpdate', data)
},
userUpdated(user) {
if (this.$store.state.user.user.id === user.id) {
@@ -148,10 +168,24 @@ export default {
this.$store.commit('user/setSettings', user.settings)
}
},
userOnline(user) {
this.$store.commit('users/updateUser', user)
},
userOffline(user) {
this.$store.commit('users/removeUser', user)
},
userStreamUpdate(user) {
this.$store.commit('users/updateUser', user)
},
currentUserAudiobookUpdate(payload) {
// console.log('Received user audiobook update', payload)
this.$store.commit('user/updateUserAudiobook', payload)
},
downloadToastClick(download) {
if (!download || !download.audiobookId) {
return console.error('Invalid download object', download)
}
var audiobook = this.$store.getters['audiobooks/getAudiobook'](download.audiobookId)
if (!audiobook) {
return console.error('Audiobook not found for download', download)
@@ -205,6 +239,19 @@ export default {
download.status = this.$constants.DownloadStatus.EXPIRED
this.$store.commit('downloads/addUpdateDownload', download)
},
showErrorToast(message) {
this.$toast.error(message)
},
showSuccessToast(message) {
this.$toast.success(message)
},
logEvtReceived(payload) {
this.$store.commit('logs/logEvt', payload)
},
backupApplied() {
// Force refresh
location.reload()
},
initializeSocket() {
this.socket = this.$nuxtSocket({
name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod',
@@ -238,14 +285,22 @@ export default {
this.socket.on('audiobook_added', this.audiobookAdded)
this.socket.on('audiobook_removed', this.audiobookRemoved)
// Library Listeners
this.socket.on('library_updated', this.libraryUpdated)
this.socket.on('library_added', this.libraryAdded)
this.socket.on('library_removed', this.libraryRemoved)
// User Listeners
this.socket.on('user_updated', this.userUpdated)
this.socket.on('user_online', this.userOnline)
this.socket.on('user_offline', this.userOffline)
this.socket.on('user_stream_update', this.userStreamUpdate)
this.socket.on('current_user_audiobook_update', this.currentUserAudiobookUpdate)
// Scan Listeners
this.socket.on('scan_start', this.scanStart)
this.socket.on('scan_complete', this.scanComplete)
this.socket.on('scan_progress', this.scanProgress)
// this.socket.on('save_metadata_complete', this.saveMetadataComplete)
// Download Listeners
this.socket.on('download_started', this.downloadStarted)
@@ -253,6 +308,14 @@ export default {
this.socket.on('download_failed', this.downloadFailed)
this.socket.on('download_killed', this.downloadKilled)
this.socket.on('download_expired', this.downloadExpired)
// Toast Listeners
this.socket.on('show_error_toast', this.showErrorToast)
this.socket.on('show_success_toast', this.showSuccessToast)
this.socket.on('log', this.logEvtReceived)
this.socket.on('backup_applied', this.backupApplied)
},
showUpdateToast(versionData) {
var ignoreVersion = localStorage.getItem('ignoreVersion')
@@ -277,10 +340,77 @@ export default {
} else {
console.warn(`Update is available but user chose to dismiss it! v${versionData.latestVersion}`)
}
},
checkActiveElementIsInput() {
var activeElement = document.activeElement
var inputs = ['input', 'select', 'button', 'textarea']
return activeElement && inputs.indexOf(activeElement.tagName.toLowerCase()) !== -1
},
getHotkeyName(e) {
var keyCode = e.keyCode || e.which
if (!this.$keynames[keyCode]) {
// Unused hotkey
return null
}
var keyName = this.$keynames[keyCode]
var name = keyName
if (e.shiftKey) name = 'Shift-' + keyName
if (process.env.NODE_ENV !== 'production') {
console.log('Hotkey command', name)
}
return name
},
keyDown(e) {
var name = this.getHotkeyName(e)
if (!name) return
// Input is focused then ignore key press
if (this.checkActiveElementIsInput()) {
return
}
// Modal is open
if (this.$store.state.openModal && Object.values(this.$hotkeys.Modal).includes(name)) {
this.$eventBus.$emit('modal-hotkey', name)
e.preventDefault()
return
}
// EReader is open
if (this.$store.state.showEReader && Object.values(this.$hotkeys.EReader).includes(name)) {
this.$eventBus.$emit('reader-hotkey', name)
e.preventDefault()
return
}
// Batch selecting
if (this.$store.getters['getNumAudiobooksSelected'] && name === 'Escape') {
// ESCAPE key cancels batch selection
this.$store.commit('setSelectedAudiobooks', [])
e.preventDefault()
return
}
// Playing audiobook
if (this.$store.state.streamAudiobook && Object.values(this.$hotkeys.AudioPlayer).includes(name)) {
this.$eventBus.$emit('player-hotkey', name)
e.preventDefault()
}
}
},
mounted() {
window.addEventListener('keydown', this.keyDown)
this.initializeSocket()
this.$store.dispatch('libraries/load')
// If experimental features set in local storage
var experimentalFeaturesSaved = localStorage.getItem('experimental')
if (experimentalFeaturesSaved === '1') {
this.$store.commit('setExperimentalFeatures', true)
}
this.$store
.dispatch('checkForUpdate')
.then((res) => {
@@ -292,6 +422,9 @@ export default {
this.$toast.error(this.$route.query.error)
this.$router.replace(this.$route.path)
}
},
beforeDestroy() {
window.removeEventListener('keydown', this.keyDown)
}
}
</script>

View File

@@ -6,8 +6,13 @@ export default function (context) {
if (route.name === 'login' || from.name === 'login') return
if (route.name === 'config' || route.name === 'upload' || route.name === 'account' || route.name.startsWith('audiobook-id')) {
if (from.name !== route.name && from.name !== 'audiobook-id-edit' && from.name !== 'config' && from.name !== 'upload' && from.name !== 'account') {
if (!route.name) {
console.warn('No Route name', route)
return
}
if (route.name.startsWith('config') || route.name === 'upload' || route.name === 'account' || route.name.startsWith('audiobook-id')) {
if (from.name !== route.name && from.name !== 'audiobook-id-edit' && !from.name.startsWith('config') && from.name !== 'upload' && from.name !== 'account') {
var _history = [...store.state.routeHistory]
if (!_history.length || _history[_history.length - 1] !== from.fullPath) {
_history.push(from.fullPath)

View File

@@ -19,7 +19,7 @@ module.exports = {
// Global page headers: https://go.nuxtjs.dev/config-head
head: {
title: 'AudioBookshelf',
title: 'Audiobookshelf',
htmlAttrs: {
lang: 'en'
},
@@ -35,8 +35,8 @@ module.exports = {
],
link: [
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
{ rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Fira+Mono&family=Ubuntu+Mono&family=Open+Sans:wght@400;600&family=Gentium+Book+Basic' },
{ rel: 'stylesheet', href: 'https://fonts.googleapis.com/icon?family=Material+Icons' }
{ rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Ubuntu+Mono&family=Source+Sans+Pro:wght@300;400;600' },
// { rel: 'stylesheet', href: 'https://fonts.googleapis.com/icon?family=Material+Icons' }
]
},
@@ -75,7 +75,10 @@ module.exports = {
proxy: {
'/dev/': { target: 'http://localhost:3333', pathRewrite: { '^/dev/': '' } },
'/local/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/', pathRewrite: { '^/local/': '' } },
'/local/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' },
'/lib/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' },
'/ebook/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' },
'/s/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' },
'/metadata/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' }
},
@@ -95,8 +98,7 @@ module.exports = {
},
// Build Configuration: https://go.nuxtjs.dev/config-build
build: {
},
build: {},
watchers: {
webpack: {
aggregateTimeout: 300,

249
client/package-lock.json generated
View File

@@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
"version": "1.2.4",
"version": "1.4.10",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@@ -2532,6 +2532,14 @@
"resolved": "https://registry.npmjs.org/@types/less/-/less-3.0.2.tgz",
"integrity": "sha512-62vfe65cMSzYaWmpmhqCMMNl0khen89w57mByPi1OseGfcV/LV03fO8YVrNj7rFQsRWNJo650WWyh6m7p8vZmA=="
},
"@types/localforage": {
"version": "0.0.34",
"resolved": "https://registry.npmjs.org/@types/localforage/-/localforage-0.0.34.tgz",
"integrity": "sha1-XjHDLdh5HsS5/z70fJy1Wy0NlDg=",
"requires": {
"localforage": "*"
}
},
"@types/mime": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz",
@@ -3407,6 +3415,11 @@
"@babel/helper-define-polyfill-provider": "^0.2.2"
}
},
"babel-plugin-syntax-dynamic-import": {
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-dynamic-import/-/babel-plugin-syntax-dynamic-import-6.18.0.tgz",
"integrity": "sha1-jWomIpyDdFqZgqRBBRVyyqF5sdo="
},
"backo2": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz",
@@ -4978,6 +4991,20 @@
"resolved": "https://registry.npmjs.org/cyclist/-/cyclist-1.0.1.tgz",
"integrity": "sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk="
},
"d": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz",
"integrity": "sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==",
"requires": {
"es5-ext": "^0.10.50",
"type": "^1.0.1"
}
},
"date-fns": {
"version": "2.25.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.25.0.tgz",
"integrity": "sha512-ovYRFnTrbGPD4nqaEqescPEv1mNwvt+UTqI3Ay9SzNtey9NZnYu6E2qCcBBgJ6/2VF1zGGygpyTDITqpQQ5e+w=="
},
"de-indent": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
@@ -5373,6 +5400,22 @@
"resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz",
"integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="
},
"epubjs": {
"version": "0.3.88",
"resolved": "https://registry.npmjs.org/epubjs/-/epubjs-0.3.88.tgz",
"integrity": "sha512-VRumULpUELYmYwzypyfbDwoSIqDp2LXOXCtY3o55o3YDW5Zm32UjtZuX/xaWFGqyZORNNMWWQ8VlMaY1djnDYg==",
"requires": {
"@types/localforage": "0.0.34",
"core-js": "^3.6.5",
"event-emitter": "^0.3.5",
"jszip": "^3.4.0",
"localforage": "^1.7.3",
"lodash": "^4.17.15",
"marks-pane": "^1.0.9",
"path-webpack": "0.0.3",
"xmldom": "^0.3.0"
}
},
"errno": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz",
@@ -5431,6 +5474,35 @@
"is-symbol": "^1.0.2"
}
},
"es5-ext": {
"version": "0.10.53",
"resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.53.tgz",
"integrity": "sha512-Xs2Stw6NiNHWypzRTY1MtaG/uJlwCk8kH81920ma8mvN8Xq1gsfhZvpkImLQArw8AHnv8MT2I45J3c0R8slE+Q==",
"requires": {
"es6-iterator": "~2.0.3",
"es6-symbol": "~3.1.3",
"next-tick": "~1.0.0"
}
},
"es6-iterator": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz",
"integrity": "sha1-p96IkUGgWpSwhUQDstCg+/qY87c=",
"requires": {
"d": "1",
"es5-ext": "^0.10.35",
"es6-symbol": "^3.1.1"
}
},
"es6-symbol": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.3.tgz",
"integrity": "sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==",
"requires": {
"d": "^1.0.1",
"ext": "^1.1.2"
}
},
"escalade": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
@@ -5490,6 +5562,15 @@
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc="
},
"event-emitter": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz",
"integrity": "sha1-34xp7vFkeSPHFXuc6DhAYQsCzDk=",
"requires": {
"d": "1",
"es5-ext": "~0.10.14"
}
},
"eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
@@ -5580,6 +5661,21 @@
}
}
},
"ext": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/ext/-/ext-1.6.0.tgz",
"integrity": "sha512-sdBImtzkq2HpkdRLtlLWDa6w4DX22ijZLKx8BMPUuKe1c5lbN6xwQDQCxSfxBQnHZ13ls/FH0MQZx/q/gr6FQg==",
"requires": {
"type": "^2.5.0"
},
"dependencies": {
"type": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/type/-/type-2.5.0.tgz",
"integrity": "sha512-180WMDQaIMm3+7hGXWf12GtdniDEy7nYcyFMKJn/eZz/6tSLXrUN9V0wKSbMjej0I1WHWbpREDEKHtqPQa9NNw=="
}
}
},
"extend-shallow": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz",
@@ -6583,6 +6679,11 @@
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.8.tgz",
"integrity": "sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw=="
},
"immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha1-nbHb0Pr43m++D13V5Wu2BigN5ps="
},
"import-cwd": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/import-cwd/-/import-cwd-2.1.0.tgz",
@@ -7105,6 +7206,27 @@
"universalify": "^2.0.0"
}
},
"jszip": {
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.7.1.tgz",
"integrity": "sha512-ghL0tz1XG9ZEmRMcEN2vt7xabrDdqHHeykgARpmZ0BiIctWxM47Vt63ZO2dnp4QYt/xJVLLy5Zv1l/xRdh2byg==",
"requires": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"set-immediate-shim": "~1.0.1"
},
"dependencies": {
"lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"requires": {
"immediate": "~3.0.5"
}
}
}
},
"keygrip": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz",
@@ -7263,6 +7385,19 @@
"launch-editor": "^2.2.1"
}
},
"libarchive.js": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/libarchive.js/-/libarchive.js-1.3.0.tgz",
"integrity": "sha512-EkQfRXt9DhWwj6BnEA2TNpOf4jTnzSTUPGgE+iFxcdNqjktY8GitbDeHnx8qZA0/IukNyyBUR3oQKRdYkO+HFg=="
},
"lie": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz",
"integrity": "sha1-mkNrLMd0bKWd56QfpGmz77dr2H4=",
"requires": {
"immediate": "~3.0.5"
}
},
"lilconfig": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.0.3.tgz",
@@ -7300,6 +7435,14 @@
}
}
},
"localforage": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz",
"integrity": "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==",
"requires": {
"lie": "3.1.1"
}
},
"locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
@@ -7412,6 +7555,11 @@
"object-visit": "^1.0.0"
}
},
"marks-pane": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/marks-pane/-/marks-pane-1.0.9.tgz",
"integrity": "sha512-Ahs4oeG90tbdPWwAJkAAoHg2lRR8lAs9mZXETNPO9hYg3AkjUJBKi1NQ4aaIQZVGrig7c/3NUV1jANl8rFTeMg=="
},
"md5.js": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz",
@@ -7713,6 +7861,11 @@
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="
},
"next-tick": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.0.0.tgz",
"integrity": "sha1-yobR/ogoFpsBICCOPchCS524NCw="
},
"no-case": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz",
@@ -8334,6 +8487,11 @@
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="
},
"path-webpack": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/path-webpack/-/path-webpack-0.0.3.tgz",
"integrity": "sha1-/23sdJ7sWpRgXATV9j/FVgegOhY="
},
"pbkdf2": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.2.tgz",
@@ -8346,6 +8504,11 @@
"sha.js": "^2.4.8"
}
},
"pdfjs-dist": {
"version": "2.6.347",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-2.6.347.tgz",
"integrity": "sha512-QC+h7hG2su9v/nU1wEI3SnpPIrqJODL7GTDFvR74ANKGq1AFJW16PH8VWnhpiTi9YcLSFV9xLeWSgq+ckHLdVQ=="
},
"picomatch": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz",
@@ -11091,6 +11254,37 @@
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="
},
"raw-loader": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/raw-loader/-/raw-loader-4.0.2.tgz",
"integrity": "sha512-ZnScIV3ag9A4wPX/ZayxL/jZH+euYb6FcUinPcgiQW0+UBtEv0O6Q3lGd3cqJ+GHH+rksEv3Pj99oxJ3u3VIKA==",
"requires": {
"loader-utils": "^2.0.0",
"schema-utils": "^3.0.0"
},
"dependencies": {
"loader-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.0.tgz",
"integrity": "sha512-rP4F0h2RaWSvPEkD7BLDFQnvSf+nK+wr3ESUjNTyAGobqrijmW92zc+SO6d4p4B1wh7+B/Jg1mkQe5NYUEHtHQ==",
"requires": {
"big.js": "^5.2.2",
"emojis-list": "^3.0.0",
"json5": "^2.1.2"
}
},
"schema-utils": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
"integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
"requires": {
"@types/json-schema": "^7.0.8",
"ajv": "^6.12.5",
"ajv-keywords": "^3.5.2"
}
}
}
},
"rc9": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/rc9/-/rc9-1.2.0.tgz",
@@ -11708,6 +11902,11 @@
"resolved": "https://registry.npmjs.org/server-destroy/-/server-destroy-1.0.1.tgz",
"integrity": "sha1-8Tv5KOQrnD55OD5hzDmYtdFObN0="
},
"set-immediate-shim": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz",
"integrity": "sha1-SysbJ+uAip+NzEgaWOXlb1mfP2E="
},
"set-value": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz",
@@ -12814,6 +13013,11 @@
"resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.0.tgz",
"integrity": "sha1-oVe6QC2iTpv5V/mqadUk7tQpAaY="
},
"type": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/type/-/type-1.2.0.tgz",
"integrity": "sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg=="
},
"type-fest": {
"version": "0.21.3",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz",
@@ -13156,6 +13360,24 @@
"resolved": "https://registry.npmjs.org/vue-no-ssr/-/vue-no-ssr-1.1.1.tgz",
"integrity": "sha512-ZMjqRpWabMPqPc7gIrG0Nw6vRf1+itwf0Itft7LbMXs2g3Zs/NFmevjZGN1x7K3Q95GmIjWbQZTVerxiBxI+0g=="
},
"vue-pdf": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/vue-pdf/-/vue-pdf-4.3.0.tgz",
"integrity": "sha512-zd3lJj6CbtrawgaaDDciTDjkJMUKiLWtbEmBg5CvFn9Noe9oAO/GNy/fc5c59qGuFCJ14ibIV1baw4S07e5bSQ==",
"requires": {
"babel-plugin-syntax-dynamic-import": "^6.18.0",
"loader-utils": "^1.4.0",
"pdfjs-dist": "2.6.347",
"raw-loader": "^4.0.2",
"vue-resize-sensor": "^2.0.0",
"worker-loader": "^2.0.0"
}
},
"vue-resize-sensor": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/vue-resize-sensor/-/vue-resize-sensor-2.0.0.tgz",
"integrity": "sha512-W+y2EAI/BxS4Vlcca9scQv8ifeBFck56DRtSwWJ2H4Cw1GLNUYxiZxUHHkuzuI5JPW/cYtL1bPO5xPyEXx4LmQ=="
},
"vue-router": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-3.5.2.tgz",
@@ -14023,6 +14245,26 @@
"errno": "~0.1.7"
}
},
"worker-loader": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/worker-loader/-/worker-loader-2.0.0.tgz",
"integrity": "sha512-tnvNp4K3KQOpfRnD20m8xltE3eWh89Ye+5oj7wXEEHKac1P4oZ6p9oTj8/8ExqoSBnk9nu5Pr4nKfQ1hn2APJw==",
"requires": {
"loader-utils": "^1.0.0",
"schema-utils": "^0.4.0"
},
"dependencies": {
"schema-utils": {
"version": "0.4.7",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-0.4.7.tgz",
"integrity": "sha512-v/iwU6wvwGK8HbU9yi3/nhGzP0yGSuhQMzL6ySiec1FSrZZDkhm4noOSWzrNFo/jEc+SJY6jRTwuwbSXJPDUnQ==",
"requires": {
"ajv": "^6.1.0",
"ajv-keywords": "^3.1.0"
}
}
}
},
"wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
@@ -14145,6 +14387,11 @@
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.3.tgz",
"integrity": "sha512-kQ/dHIzuLrS6Je9+uv81ueZomEwH0qVYstcAQ4/Z93K8zeko9gtAbttJWzoC5ukqXY1PpoouV3+VSOqEAFt5wg=="
},
"xmldom": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.3.0.tgz",
"integrity": "sha512-z9s6k3wxE+aZHgXYxSTpGDo7BYOUfJsIRyoZiX6HTjwpwfS2wpQBQKa2fD+ShLyPkqDYo5ud7KitmLZ2Cd6r0g=="
},
"xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View File

@@ -1,10 +1,11 @@
{
"name": "audiobookshelf-client",
"version": "1.2.8",
"version": "1.5.5",
"description": "Audiobook manager and player",
"main": "index.js",
"scripts": {
"dev": "nuxt",
"dev2": "nuxt --hostname localhost --port 1337",
"build": "nuxt build",
"start": "nuxt start",
"generate": "nuxt generate"
@@ -15,9 +16,13 @@
"@nuxtjs/axios": "^5.13.6",
"@nuxtjs/proxy": "^2.1.0",
"core-js": "^3.16.0",
"date-fns": "^2.25.0",
"epubjs": "^0.3.88",
"hls.js": "^1.0.7",
"libarchive.js": "^1.3.0",
"nuxt": "^2.15.7",
"nuxt-socket-io": "^1.1.18",
"vue-pdf": "^4.3.0",
"vue-toastification": "^1.7.11",
"vuedraggable": "^2.24.3"
},

View File

@@ -23,7 +23,7 @@
<div class="flex items-center py-2">
<p v-if="isRoot" class="text-error py-2 text-xs">* Root user is the only user that can have an empty password</p>
<div class="flex-grow" />
<ui-btn type="submit" :loading="changingPassword" color="success">Submit</ui-btn>
<ui-btn v-show="(password && newPassword && confirmPassword) || isRoot" type="submit" :loading="changingPassword" color="success">Submit</ui-btn>
</div>
</form>
</div>
@@ -61,7 +61,11 @@ export default {
},
methods: {
logout() {
this.$axios.$post('/logout').catch((error) => {
var rootSocket = this.$root.socket || {}
const logoutPayload = {
socketId: rootSocket.id
}
this.$axios.$post('/logout', logoutPayload).catch((error) => {
console.error(error)
})
if (localStorage.getItem('token')) {

View File

@@ -10,10 +10,24 @@
</div>
<div class="w-full flex items-center text-sm py-4 bg-primary border-l border-r border-t border-gray-600">
<div class="font-book text-center px-4 w-12">New</div>
<div class="font-book text-center px-4 w-12">Old</div>
<div class="font-book text-center px-4 w-32">Track Parsed from Filename</div>
<div class="font-book text-center px-4 w-32">Track From Metadata</div>
<div class="font-book truncate px-4 flex-grow">Filename</div>
<div class="font-book text-center px-4 w-24 flex items-center cursor-pointer text-white text-opacity-40 hover:text-opacity-100" @click="sortByCurrent" @mousedown.prevent>
<span class="text-white">Current</span>
<span class="material-icons ml-1" :class="currentSort === 'current' ? 'text-white text-opacity-100 text-lg' : 'text-sm'">{{ currentSort === 'current' ? 'expand_more' : 'unfold_more' }}</span>
</div>
<div class="font-book text-center px-4 w-32 flex items-center cursor-pointer text-white text-opacity-40 hover:text-opacity-100" @click="sortByFilenameTrack" @mousedown.prevent>
<span class="text-white">Track From Filename</span>
<span class="material-icons ml-1" :class="currentSort === 'track-filename' ? 'text-white text-opacity-100 text-lg' : 'text-sm'">{{ currentSort === 'track-filename' ? 'expand_more' : 'unfold_more' }}</span>
</div>
<div class="font-book text-center px-4 w-32 flex items-center cursor-pointer text-white text-opacity-40 hover:text-opacity-100" @click="sortByMetadataTrack" @mousedown.prevent>
<span class="text-white">Track From Metadata</span>
<span class="material-icons ml-1" :class="currentSort === 'metadata' ? 'text-white text-opacity-100 text-lg' : 'text-sm'">{{ currentSort === 'metadata' ? 'expand_more' : 'unfold_more' }}</span>
</div>
<div class="font-mono w-20 text-center">CD From Filename</div>
<div class="font-book text-center px-4 flex-grow flex items-center cursor-pointer text-white text-opacity-40 hover:text-opacity-100" @click="sortByFilename" @mousedown.prevent>
<span class="text-white">Filename</span>
<span class="material-icons ml-1" :class="currentSort === 'filename' ? 'text-white text-opacity-100 text-lg' : 'text-sm'">{{ currentSort === 'filename' ? 'expand_more' : 'unfold_more' }}</span>
</div>
<!-- <div class="font-book truncate px-4 flex-grow">Filename</div> -->
<div class="font-mono w-20 text-center">Size</div>
<div class="font-mono w-20 text-center">Duration</div>
@@ -21,19 +35,22 @@
<div class="font-mono w-56">Notes</div>
<div class="font-book w-40">Include in Tracklist</div>
</div>
<draggable v-model="files" v-bind="dragOptions" class="list-group border border-gray-600" draggable=".item" tag="ul" @start="drag = true" @end="drag = false">
<draggable v-model="files" v-bind="dragOptions" class="list-group border border-gray-600" draggable=".item" tag="ul" @start="drag = true" @end="drag = false" @update="draggableUpdate">
<transition-group type="transition" :name="!drag ? 'flip-list' : null">
<li v-for="(audio, index) in files" :key="audio.path" :class="audio.include ? 'item' : 'exclude'" class="w-full list-group-item flex items-center">
<div class="font-book text-center px-4 py-1 w-12">
{{ audio.include ? index - numExcluded + 1 : -1 }}
</div>
<div class="font-book text-center px-4 w-12">{{ audio.index }}</div>
<div class="font-book text-center px-4 w-24">{{ audio.index }}</div>
<div class="font-book text-center px-2 w-32">
{{ audio.trackNumFromFilename }}
</div>
<div class="font-book text-center w-32">
{{ audio.trackNumFromMeta }}
</div>
<div class="font-book truncate px-4 w-20">
{{ audio.cdNumFromFilename }}
</div>
<div class="font-book truncate px-4 flex-grow">
{{ audio.filename }}
</div>
@@ -56,6 +73,30 @@
</li>
</transition-group>
</draggable>
<div v-if="showExperimentalFeatures" class="p-4">
<ui-btn :loading="checkingTrackNumbers" small @click="checkTrackNumbers">Check Track Numbers</ui-btn>
<div v-if="trackNumData && trackNumData.length" class="w-full max-w-4xl py-2">
<table class="tracksTable">
<tr>
<th class="text-left">Filename</th>
<th class="w-32">Index</th>
<th class="w-32"># From Metadata</th>
<th class="w-32"># From Filename</th>
<th class="w-32"># From Probe</th>
<th class="w-32">Raw Tags</th>
</tr>
<tr v-for="trackData in trackNumData" :key="trackData.filename">
<td class="text-xs">{{ trackData.filename }}</td>
<td class="text-center">{{ trackData.currentTrackNum }}</td>
<td class="text-center">{{ trackData.trackNumFromMeta }}</td>
<td class="text-center">{{ trackData.trackNumFromFilename }}</td>
<td class="text-center">{{ trackData.scanDataTrackNum }}</td>
<td class="text-left text-xs">{{ JSON.stringify(trackData.rawTags || '') }}</td>
</tr>
</table>
</div>
</div>
</div>
</div>
</template>
@@ -95,7 +136,10 @@ export default {
group: 'description',
ghostClass: 'ghost'
},
saving: false
saving: false,
checkingTrackNumbers: false,
trackNumData: [],
currentSort: 'current'
}
},
computed: {
@@ -172,9 +216,55 @@ export default {
},
streamAudiobook() {
return this.$store.state.streamAudiobook
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
}
},
methods: {
draggableUpdate(e) {
this.currentSort = ''
},
sortByCurrent() {
this.files.sort((a, b) => {
if (a.index === null) return 1
return a.index - b.index
})
this.currentSort = 'current'
},
sortByMetadataTrack() {
this.files.sort((a, b) => {
if (a.trackNumFromMeta === null) return 1
return a.trackNumFromMeta - b.trackNumFromMeta
})
this.currentSort = 'metadata'
},
sortByFilenameTrack() {
this.files.sort((a, b) => {
if (a.trackNumFromFilename === null) return 1
return a.trackNumFromFilename - b.trackNumFromFilename
})
this.currentSort = 'track-filename'
},
sortByFilename() {
this.files.sort((a, b) => {
return (a.filename || '').toLowerCase().localeCompare((b.filename || '').toLowerCase())
})
this.currentSort = 'filename'
},
checkTrackNumbers() {
this.checkingTrackNumbers = true
this.$axios
.$get(`/api/scantracks/${this.audiobookId}`)
.then((res) => {
this.trackNumData = res
this.checkingTrackNumbers = false
})
.catch((error) => {
console.error('Failed', error)
this.checkingTrackNumbers = false
})
},
includeToggled(audio) {
var new_index = 0
if (audio.include) {
@@ -224,41 +314,3 @@ export default {
mounted() {}
}
</script>
<style>
.flip-list-move {
transition: transform 0.5s;
}
.no-move {
transition: transform 0s;
}
.ghost {
opacity: 0.5;
background-color: rgba(255, 255, 255, 0.25);
}
.list-group {
min-height: 30px;
}
.list-group-item:not(.exclude) {
cursor: n-resize;
}
.list-group-item.exclude {
cursor: not-allowed;
}
.list-group-item:not(.ghost):not(.exclude):hover {
background-color: rgba(0, 0, 0, 0.1);
}
.list-group-item:nth-child(even):not(.ghost):not(.exclude) {
background-color: rgba(0, 0, 0, 0.25);
}
.list-group-item:nth-child(even):not(.ghost):not(.exclude):hover {
background-color: rgba(0, 0, 0, 0.1);
}
.list-group-item.exclude:not(.ghost) {
background-color: rgba(255, 0, 0, 0.25);
}
.list-group-item.exclude:not(.ghost):hover {
background-color: rgba(223, 0, 0, 0.25);
}
</style>

View File

@@ -10,20 +10,73 @@
</div>
<div class="flex-grow px-10">
<div class="flex">
<div class="mb-2">
<h1 class="text-2xl font-book leading-7">{{ title }}</h1>
<h3 v-if="series" class="font-book text-gray-300 text-lg leading-7">{{ seriesText }}</h3>
<div class="w-min">
<ui-tooltip :text="authorTooltipText" direction="bottom">
<span class="text-sm text-gray-100 leading-7 whitespace-nowrap">by {{ author }}</span>
</ui-tooltip>
<div class="mb-4">
<div class="flex items-end">
<h1 class="text-3xl font-sans">
{{ title }}<span v-if="isDeveloperMode"> ({{ audiobook.ino }})</span>
</h1>
<p v-if="subtitle" class="ml-4 text-gray-400 text-2xl">{{ subtitle }}</p>
</div>
<p class="mb-2 mt-0.5 text-gray-100 text-xl">
by <nuxt-link v-if="authorFL" :to="`/library/${libraryId}/bookshelf?filter=authors.${$encode(authorFL)}`" class="hover:underline">{{ authorFL }}</nuxt-link
><span v-else>Unknown</span>
</p>
<nuxt-link v-if="series" :to="`/library/${libraryId}/bookshelf/series?series=${$encode(series)}`" class="hover:underline font-sans text-gray-300 text-lg leading-7 mb-4"> {{ seriesText }}</nuxt-link>
<div v-if="narrator" class="flex py-0.5">
<div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Narrated By</span>
</div>
<div>
<nuxt-link :to="`/library/${libraryId}/bookshelf?filter=narrators.${$encode(narrator)}`" class="hover:underline">{{ narrator }}</nuxt-link>
</div>
</div>
<div v-if="publishYear" class="flex py-0.5">
<div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Publish Year</span>
</div>
<div>
{{ publishYear }}
</div>
</div>
<div class="flex py-0.5" v-if="genres.length">
<div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Genres</span>
</div>
<div>
<template v-for="(genre, index) in genres">
<nuxt-link :key="genre" :to="`/library/${libraryId}/bookshelf?filter=genres.${$encode(genre)}`" class="hover:underline">{{ genre }}</nuxt-link
><span :key="index" v-if="index < genres.length - 1">,&nbsp;</span>
</template>
</div>
</div>
<div v-if="tracks.length" class="flex py-0.5">
<div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Duration</span>
</div>
<div>
{{ durationPretty }}
</div>
</div>
<div v-if="tracks.length" class="flex py-0.5">
<div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Size</span>
</div>
<div>
{{ sizePretty }}
</div>
</div>
</div>
<div class="flex-grow" />
</div>
<p class="text-gray-300 text-sm my-1">
{{ durationPretty }}<span class="px-4">{{ sizePretty }}</span>
</p>
<!-- Alerts -->
<div v-show="showExperimentalReadAlert" class="bg-error p-4 rounded-xl flex items-center">
<span class="material-icons text-2xl">warning_amber</span>
<p class="ml-4">Book has no audio tracks but has valid ebook files. The e-reader is experimental and can be turned on in config.</p>
</div>
<div v-if="progressPercent > 0 && progressPercent < 1" class="px-4 py-2 mt-4 bg-primary text-sm font-semibold rounded-md text-gray-200 relative max-w-max" :class="resettingProgress ? 'opacity-25' : ''">
<p class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p>
<p class="text-gray-400 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p>
@@ -33,19 +86,19 @@
</div>
<div class="flex items-center pt-4">
<ui-btn v-if="!isMissing" :disabled="streaming" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="startStream">
<ui-btn v-if="showPlayButton" :disabled="streaming" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="startStream">
<span v-show="!streaming" class="material-icons -ml-2 pr-1 text-white">play_arrow</span>
{{ streaming ? 'Streaming' : 'Play' }}
</ui-btn>
<ui-btn v-else color="error" :padding-x="4" small class="flex items-center h-9 mr-2">
<ui-btn v-else-if="isMissing || isIncomplete" color="error" :padding-x="4" small class="flex items-center h-9 mr-2">
<span v-show="!streaming" class="material-icons -ml-2 pr-1 text-white">error</span>
Missing
{{ isMissing ? 'Missing' : 'Incomplete' }}
</ui-btn>
<!-- <ui-btn v-if="ebooks.length" color="info" :padding-x="4" small class="flex items-center h-9 mr-2" @click="openEbook">
<ui-btn v-if="showExperimentalFeatures && numEbooks" color="info" :padding-x="4" small class="flex items-center h-9 mr-2" @click="openEbook">
<span class="material-icons -ml-2 pr-2 text-white">auto_stories</span>
Read
</ui-btn> -->
</ui-btn>
<ui-tooltip v-if="userCanUpdate" text="Edit" direction="top">
<ui-icon-btn icon="edit" class="mx-0.5" @click="editClick" />
@@ -60,12 +113,10 @@
</ui-tooltip>
<ui-btn v-if="isDeveloperMode" class="mx-2" @click="openRssFeed">Open RSS Feed</ui-btn>
<div class="flex-grow" />
</div>
<div class="my-4">
<p class="text-sm text-gray-100">{{ description }}</p>
<div class="my-4 max-w-2xl">
<p class="text-base text-gray-100">{{ description }}</p>
</div>
<div v-if="missingParts.length" class="bg-error border-red-800 shadow-md p-4">
@@ -84,15 +135,13 @@
</div>
</div>
<tables-tracks-table :tracks="tracks" :audiobook-id="audiobook.id" class="mt-6" />
<tables-tracks-table v-if="tracks.length" :tracks="tracks" :audiobook="audiobook" class="mt-6" />
<tables-audio-files-table v-if="otherAudioFiles.length" :audiobook-id="audiobook.id" :files="otherAudioFiles" class="mt-6" />
<tables-other-files-table v-if="otherFiles.length" :audiobook-id="audiobook.id" :files="otherFiles" class="mt-6" />
<tables-other-files-table v-if="otherFiles.length" :audiobook="audiobook" :files="otherFiles" class="mt-6" />
</div>
</div>
<div id="area"></div>
</div>
</div>
</template>
@@ -111,6 +160,7 @@ export default {
console.error('No audiobook...', params.id)
return redirect('/')
}
store.commit('audiobooks/addUpdate', audiobook)
return {
audiobook
}
@@ -134,6 +184,9 @@ export default {
isDeveloperMode() {
return this.$store.state.developerMode
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
missingPartChunks() {
if (this.missingParts === 1) return this.missingParts[0]
var chunks = []
@@ -168,18 +221,42 @@ export default {
isMissing() {
return this.audiobook.isMissing
},
isIncomplete() {
return this.audiobook.isIncomplete
},
showPlayButton() {
return !this.isMissing && !this.isIncomplete && this.tracks.length
},
missingParts() {
return this.audiobook.missingParts || []
},
invalidParts() {
return this.audiobook.invalidParts || []
},
libraryId() {
return this.audiobook.libraryId
},
folderId() {
return this.audiobook.folderId
},
audiobookId() {
return this.audiobook.id
},
title() {
return this.book.title || 'No Title'
},
publishYear() {
return this.book.publishYear
},
narrator() {
return this.book.narrator
},
subtitle() {
return this.book.subtitle
},
genres() {
return this.book.genres || []
},
author() {
return this.book.author || 'Unknown'
},
@@ -233,6 +310,15 @@ export default {
ebooks() {
return this.audiobook.ebooks
},
showExperimentalReadAlert() {
return !this.tracks.length && this.ebooks.length && !this.showExperimentalFeatures
},
numEbooks() {
return this.audiobook.numEbooks
},
userToken() {
return this.$store.getters['user/getToken']
},
description() {
return this.book.description || ''
},
@@ -272,16 +358,7 @@ export default {
},
methods: {
openEbook() {
var ebook = this.ebooks[0]
console.log('Ebook', ebook)
this.$axios
.$get(`/ebook/open/${this.audiobookId}/${ebook.ino}`)
.then(() => {
console.log('opened')
})
.catch((error) => {
console.error('failed', error)
})
this.$store.commit('showEReader', this.audiobook)
},
toggleRead() {
var updatePayload = {
@@ -317,23 +394,15 @@ export default {
this.$root.socket.emit('open_stream', this.audiobook.id)
},
editClick() {
this.$store.commit('setBookshelfBookIds', [])
this.$store.commit('showEditModal', this.audiobook)
},
lookupMetadata(index) {
this.$axios
.$get(`/api/metadata/${this.audiobookId}/${index}`)
.then((metadata) => {
console.log('Metadata for ' + index, metadata)
})
.catch((error) => {
console.error(error)
})
},
audiobookUpdated() {
console.log('Audiobook Updated - Fetch full audiobook')
this.$axios
.$get(`/api/audiobook/${this.audiobookId}`)
.then((audiobook) => {
console.log('Updated audiobook', audiobook)
this.audiobook = audiobook
})
.catch((error) => {
@@ -362,6 +431,11 @@ export default {
},
mounted() {
this.$store.commit('audiobooks/addListener', { id: 'audiobook', audiobookId: this.audiobookId, meth: this.audiobookUpdated })
// If a library has not yet been loaded, use this audiobooks library id as the current
if (!this.$store.state.audiobooks.loadedLibraryId && this.libraryId) {
this.$store.commit('libraries/setCurrentLibrary', this.libraryId)
}
},
beforeDestroy() {
this.$store.commit('audiobooks/removeListener', 'audiobook')

View File

@@ -33,16 +33,16 @@
<div class="flex mt-2 -mx-1">
<div class="w-1/2 px-1">
<ui-multi-select v-model="audiobook.book.genres" label="Genres" :items="genres" />
<ui-multi-select v-model="audiobook.book.genres" label="Genres" :items="genreItems" @newItem="newGenreItem" @removedItem="removedGenreItem" />
</div>
<div class="flex-grow px-1">
<ui-multi-select v-model="audiobook.tags" label="Tags" :items="tags" />
<ui-multi-select v-model="audiobook.tags" label="Tags" :items="tagItems" @newItem="newTagItem" @removedItem="removedTagItem" />
</div>
</div>
<div class="flex mt-2 -mx-1">
<div class="w-1/2 px-1">
<ui-text-input-with-label v-model="audiobook.book.narrarator" label="Narrarator" />
<ui-text-input-with-label v-model="audiobook.book.narrator" label="Narrator" />
</div>
</div>
</div>
@@ -76,7 +76,9 @@ export default {
isProcessing: false,
audiobookCopies: [],
isScrollable: false,
newSeriesItems: []
newSeriesItems: [],
newTagItems: [],
newGenreItems: []
}
},
computed: {
@@ -86,20 +88,62 @@ export default {
genres() {
return this.$store.state.audiobooks.genres
},
genreItems() {
return this.genres.concat(this.newGenreItems)
},
tags() {
return this.$store.state.audiobooks.tags
},
tagItems() {
return this.tags.concat(this.newTagItems)
},
series() {
return this.$store.state.audiobooks.series
},
seriesItems() {
return [...this.series, ...this.newSeriesItems]
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
}
},
methods: {
newTagItem(item) {
if (item && !this.newTagItems.includes(item)) {
this.newTagItems.push(item)
}
},
removedTagItem(item) {
// If newly added, remove if not used on any other audiobooks
if (item && this.newTagItems.includes(item)) {
var usedByOtherAb = this.audiobookCopies.find((ab) => {
return ab.tags && ab.tags.includes(item)
})
if (!usedByOtherAb) {
this.newTagItems = this.newTagItems.filter((t) => t !== item)
}
}
},
newGenreItem(item) {
if (item && !this.newGenreItems.includes(item)) {
this.newGenreItems.push(item)
}
},
removedGenreItem(item) {
// If newly added, remove if not used on any other audiobooks
if (item && this.newGenreItems.includes(item)) {
var usedByOtherAb = this.audiobookCopies.find((ab) => {
return ab.book.genres && ab.book.genres.includes(item)
})
if (!usedByOtherAb) {
this.newGenreItems = this.newGenreItems.filter((t) => t !== item)
}
}
},
newSeriesItem(item) {
if (!item) return
this.newSeriesItems.push(item)
if (item && !this.newSeriesItems.includes(item)) {
this.newSeriesItems.push(item)
}
},
seriesChanged() {
this.newSeriesItems = this.newSeriesItems.filter((item) => {
@@ -130,7 +174,7 @@ export default {
this.isProcessing = false
if (data.updates) {
this.$toast.success(`Successfully updated ${data.updates} audiobooks`)
this.$router.replace('/library')
this.$router.replace(`/library/${this.currentLibraryId}`)
} else {
this.$toast.warning('No updates were necessary')
}

45
client/pages/config.vue Normal file
View File

@@ -0,0 +1,45 @@
<template>
<div id="page-wrapper" class="page p-6 overflow-y-auto relative" :class="streamAudiobook ? 'streaming' : ''">
<app-config-side-nav />
<div class="w-full max-w-4xl mx-auto">
<nuxt-child />
</div>
<div class="fixed bottom-0 right-0 w-10 h-10" @dblclick="setDeveloperMode"></div>
</div>
</template>
<script>
export default {
asyncData({ store, redirect }) {
if (!store.getters['user/getIsRoot']) {
redirect('/?error=unauthorized')
}
},
data() {
return {}
},
computed: {
streamAudiobook() {
return this.$store.state.streamAudiobook
}
},
methods: {
setDeveloperMode() {
var value = !this.$store.state.developerMode
this.$store.commit('setDeveloperMode', value)
this.$toast.info(`Developer Mode ${value ? 'Enabled' : 'Disabled'}`)
}
// saveMetadataComplete(result) {
// this.savingMetadata = false
// if (!result) return
// this.$toast.success(`Metadata saved for ${result.success} audiobooks`)
// },
// saveMetadataFiles() {
// this.savingMetadata = true
// this.$root.socket.once('save_metadata_complete', this.saveMetadataComplete)
// this.$root.socket.emit('save_metadata')
// }
},
mounted() {}
}
</script>

View File

@@ -0,0 +1,90 @@
<template>
<div class="w-full h-full">
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
<div class="flex items-center mb-2">
<h1 class="text-xl">Backups</h1>
</div>
<p class="text-base mb-4 text-gray-300">Backups include users, user progress, book details, server settings and covers stored in <span class="font-mono text-gray-100">/metadata/books</span>. <br />Backups <strong>do not</strong> include any files stored in your library folders.</p>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="dailyBackups" small :disabled="updatingServerSettings" @input="updateBackupsSettings" />
<ui-tooltip :text="dailyBackupsTooltip">
<p class="pl-4 text-lg">Run daily backups <span class="material-icons icon-text">info_outlined</span></p>
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-text-input type="number" v-model="backupsToKeep" no-spinner :disabled="updatingServerSettings" :padding-x="1" text-center class="w-10" @change="updateBackupsSettings" />
<p class="pl-4 text-lg">Number of backups to keep</p>
</div>
<tables-backups-table />
</div>
</div>
</template>
<script>
export default {
data() {
return {
updatingServerSettings: false,
dailyBackups: true,
backupsToKeep: 2,
newServerSettings: {}
}
},
watch: {
serverSettings(newVal, oldVal) {
if (newVal && !oldVal) {
this.newServerSettings = { ...this.serverSettings }
this.initServerSettings()
}
}
},
computed: {
dailyBackupsTooltip() {
return 'Runs at 1am every day (your server time). Saved in /metadata/backups.'
},
serverSettings() {
return this.$store.state.serverSettings
}
},
methods: {
updateBackupsSettings() {
if (isNaN(this.backupsToKeep) || this.backupsToKeep <= 0 || this.backupsToKeep > 99) {
this.$toast.error('Invalid number of backups to keep')
return
}
var updatePayload = {
backupSchedule: this.dailyBackups ? '0 1 * * *' : false,
backupsToKeep: Number(this.backupsToKeep)
}
this.updateServerSettings(updatePayload)
},
updateServerSettings(payload) {
this.updatingServerSettings = true
this.$store
.dispatch('updateServerSettings', payload)
.then((success) => {
console.log('Updated Server Settings', success)
this.updatingServerSettings = false
})
.catch((error) => {
console.error('Failed to update server settings', error)
this.updatingServerSettings = false
})
},
initServerSettings() {
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
this.backupsToKeep = this.newServerSettings.backupsToKeep || 2
this.dailyBackups = !!this.newServerSettings.backupSchedule
}
},
mounted() {
this.initServerSettings()
}
}
</script>

View File

@@ -1,107 +1,76 @@
<template>
<div id="page-wrapper" class="page p-6 overflow-y-auto" :class="streamAudiobook ? 'streaming' : ''">
<div class="w-full max-w-4xl mx-auto">
<div>
<!-- <div class="h-0.5 bg-primary bg-opacity-50 w-full" /> -->
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
<div class="flex items-center mb-2">
<h1 class="text-2xl">Users</h1>
<div class="mx-2 w-7 h-7 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center" @click="clickAddUser">
<span class="material-icons" style="font-size: 1.4rem">add</span>
</div>
<!-- <ui-btn small :padding-x="4" class="h-8">Create User</ui-btn> -->
</div>
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
<div class="p-4 text-center">
<table id="accounts" class="mb-8">
<tr>
<th>Username</th>
<th>Account Type</th>
<th style="width: 200px">Created At</th>
<th style="width: 100px"></th>
</tr>
<tr v-for="user in users" :key="user.id" :class="user.isActive ? '' : 'bg-error bg-opacity-20'">
<td>
{{ user.username }} <span class="text-xs text-gray-400 italic pl-4">({{ user.id }})</span>
</td>
<td>{{ user.type }}</td>
<td class="text-sm font-mono">
{{ new Date(user.createdAt).toISOString() }}
</td>
<td>
<div class="w-full flex justify-center">
<span class="material-icons hover:text-gray-400 cursor-pointer text-base pr-2" @click="editUser(user)">edit</span>
<span v-show="user.type !== 'root'" class="material-icons text-base hover:text-error cursor-pointer" @click="deleteUserClick(user)">delete</span>
</div>
</td>
</tr>
</table>
</div>
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
<div class="py-4 mb-8">
<p class="text-2xl">Scanner</p>
<div class="flex items-start py-2">
<div class="py-2">
<div class="flex items-center">
<ui-toggle-switch v-model="newServerSettings.scannerParseSubtitle" @input="updateScannerParseSubtitle" />
<ui-tooltip :text="parseSubtitleTooltip">
<p class="pl-4 text-lg">Parse Subtitles <span class="material-icons icon-text">info_outlined</span></p>
</ui-tooltip>
</div>
</div>
<div class="flex-grow" />
<div class="w-40 flex flex-col">
<ui-btn color="success" class="mb-4" :loading="isScanning" :disabled="isScanningCovers" @click="scan">Scan</ui-btn>
<div class="w-full mb-4">
<ui-tooltip direction="bottom" text="Only scans audiobooks without a cover. Covers will be applied if a close match is found." class="w-full">
<ui-btn color="primary" class="w-full" small :padding-x="2" :loading="isScanningCovers" :disabled="isScanning" @click="scanCovers">Scan for Covers</ui-btn>
</ui-tooltip>
</div>
<!-- <ui-btn color="primary" small @click="saveMetadataFiles">Save Metadata</ui-btn> -->
</div>
</div>
<h1 class="text-xl">Settings</h1>
</div>
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
<div class="flex items-center py-4">
<ui-btn color="bg" small :padding-x="4" :loading="isResettingAudiobooks" @click="resetAudiobooks">Reset All Audiobooks</ui-btn>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerParseSubtitle" small :disabled="updatingServerSettings" @input="updateScannerParseSubtitle" />
<ui-tooltip :text="parseSubtitleTooltip">
<p class="pl-4 text-lg">Scanner parse subtitles <span class="material-icons icon-text">info_outlined</span></p>
</ui-tooltip>
</div>
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerFindCovers" :disabled="updatingServerSettings" @input="updateScannerFindCovers" />
<ui-tooltip :text="scannerFindCoversTooltip">
<p class="pl-4 text-lg">Scanner find covers <span class="material-icons icon-text">info_outlined</span></p>
</ui-tooltip>
</div>
<div class="flex items-center py-4">
<p class="font-mono">v{{ $config.version }}</p>
<div class="flex-grow" />
<p class="pr-2 text-sm font-book text-yellow-400">Report bugs, request features, provide feedback, and contribute on <a class="underline" href="https://github.com/advplyr/audiobookshelf" target="_blank">github</a>.</p>
<a href="https://github.com/advplyr/audiobookshelf" target="_blank" class="text-white hover:text-gray-200 hover:scale-150 hover:rotate-6 transform duration-500">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" width="24" height="24" viewBox="0 0 24 24">
<path
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
/>
</svg>
</a>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="storeCoversInAudiobookDir" :disabled="updatingServerSettings" @input="updateCoverStorageDestination" />
<ui-tooltip :text="coverDestinationTooltip">
<p class="pl-4 text-lg">Store covers with audiobook <span class="material-icons icon-text">info_outlined</span></p>
</ui-tooltip>
</div>
</div>
<div class="fixed bottom-0 left-0 w-10 h-10" @dblclick="setDeveloperMode"></div>
<modals-account-modal v-model="showAccountModal" :account="selectedAccount" />
<div class="h-0.5 bg-primary bg-opacity-30 w-full" />
<div class="flex items-center py-4">
<ui-btn color="bg" small :padding-x="4" :loading="isResettingAudiobooks" @click="resetAudiobooks">Reset All Audiobooks</ui-btn>
<div class="flex-grow" />
<p class="pr-2 text-sm font-book text-yellow-400">Report bugs, request features, provide feedback, and contribute on <a class="underline" href="https://github.com/advplyr/audiobookshelf" target="_blank">github</a>.</p>
<a href="https://github.com/advplyr/audiobookshelf" target="_blank" class="text-white hover:text-gray-200 hover:scale-150 hover:rotate-6 transform duration-500">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" width="24" height="24" viewBox="0 0 24 24">
<path
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
/>
</svg>
</a>
</div>
<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="experimentalFeaturesTooltip">
<p class="pl-4 text-lg">Experimental Features <span class="material-icons icon-text">info_outlined</span></p>
</ui-tooltip>
</div>
</div>
<div>
<a href="https://github.com/advplyr/audiobookshelf/discussions/75#discussion-3604812" target="_blank" class="text-blue-500 hover:text-blue-300 underline">Join the discussion</a>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
asyncData({ store, redirect }) {
if (!store.getters['user/getIsRoot']) {
redirect('/?error=unauthorized')
}
},
data() {
return {
isResettingAudiobooks: false,
users: [],
selectedAccount: null,
showAccountModal: false,
isDeletingUser: false,
storeCoversInAudiobookDir: false,
updatingServerSettings: false,
newServerSettings: {}
}
},
@@ -109,63 +78,73 @@ export default {
serverSettings(newVal, oldVal) {
if (newVal && !oldVal) {
this.newServerSettings = { ...this.serverSettings }
this.initServerSettings()
}
}
},
computed: {
parseSubtitleTooltip() {
return 'Extract subtitles from audiobook directory names.<br>Subtitle must be seperated by " - "<br>i.e. "Book Title - A Subtitle Here" has the subtitle "A Subtitle Here"'
saveMetadataTooltip() {
return 'This will write a "metadata.nfo" file in all of your audiobook directories.'
},
experimentalFeaturesTooltip() {
return 'Features in development that could use your feedback and help testing.'
},
serverSettings() {
return this.$store.state.serverSettings
},
streamAudiobook() {
return this.$store.state.streamAudiobook
parseSubtitleTooltip() {
return 'Extract subtitles from audiobook directory names.<br>Subtitle must be seperated by " - "<br>i.e. "Book Title - A Subtitle Here" has the subtitle "A Subtitle Here"'
},
isScanning() {
return this.$store.state.isScanning
coverDestinationTooltip() {
return 'By default covers are stored in /metadata/books, enabling this setting will store covers inside your audiobooks directory. Only one file named "cover" will be kept.'
},
isScanningCovers() {
return this.$store.state.isScanningCovers
scannerFindCoversTooltip() {
return 'If your audiobook does not have an embedded cover or a cover image inside the folder, the scanner will attempt to find a cover.<br>Note: This will extend scan time'
},
showExperimentalFeatures: {
get() {
return this.$store.state.showExperimentalFeatures
},
set(val) {
this.$store.commit('setExperimentalFeatures', val)
}
}
},
methods: {
updateScannerFindCovers(val) {
this.updateServerSettings({
scannerFindCovers: !!val
})
},
updateCoverStorageDestination(val) {
this.newServerSettings.coverDestination = val ? this.$constants.CoverDestination.AUDIOBOOK : this.$constants.CoverDestination.METADATA
this.updateServerSettings({
coverDestination: this.newServerSettings.coverDestination
})
},
updateScannerParseSubtitle(val) {
var payload = {
scannerParseSubtitle: val
}
this.updateServerSettings({
scannerParseSubtitle: !!val
})
},
updateServerSettings(payload) {
this.updatingServerSettings = true
this.$store
.dispatch('updateServerSettings', payload)
.then((success) => {
console.log('Updated Server Settings', success)
this.updatingServerSettings = false
})
.catch((error) => {
console.error('Failed to update server settings', error)
this.updatingServerSettings = false
})
},
setDeveloperMode() {
var value = !this.$store.state.developerMode
this.$store.commit('setDeveloperMode', value)
this.$toast.info(`Developer Mode ${value ? 'Enabled' : 'Disabled'}`)
},
scan() {
this.$root.socket.emit('scan')
},
scanCovers() {
this.$root.socket.emit('scan_covers')
},
saveMetadataFiles() {
this.$root.socket.emit('save_metadata')
},
loadUsers() {
this.$axios
.$get('/api/users')
.then((users) => {
this.users = users
})
.catch((error) => {
console.error('Failed', error)
})
initServerSettings() {
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
this.storeCoversInAudiobookDir = this.newServerSettings.coverDestination === this.$constants.CoverDestination.AUDIOBOOK
this.storeCoversInAudiobookDir = this.newServerSettings.coverDestination === this.$constants.CoverDestination.AUDIOBOOK
},
resetAudiobooks() {
if (confirm('WARNING! This action will remove all audiobooks from the database including any updates or matches you have made. This does not do anything to your actual files. Shall we continue?')) {
@@ -175,112 +154,18 @@ export default {
.then(() => {
this.isResettingAudiobooks = false
this.$toast.success('Successfully reset audiobooks')
location.reload()
})
.catch((error) => {
console.error('failed to reset audiobooks', error)
this.isResettingAudiobooks = false
this.$toast.error('Failed to reset audiobooks - stop docker and manually remove appdata')
this.$toast.error('Failed to reset audiobooks - manually remove the /config/audiobooks folder')
})
}
},
clickAddUser() {
this.selectedAccount = null
this.showAccountModal = true
},
editUser(user) {
this.selectedAccount = user
this.showAccountModal = true
},
deleteUserClick(user) {
if (this.isDeletingUser) return
if (confirm(`Are you sure you want to permanently delete user "${user.username}"?`)) {
this.isDeletingUser = true
this.$axios
.$delete(`/api/user/${user.id}`)
.then((data) => {
this.isDeletingUser = false
if (data.error) {
this.$toast.error(data.error)
} else {
this.$toast.success('User deleted')
}
})
.catch((error) => {
console.error('Failed to delete user', error)
this.$toast.error('Failed to delete user')
this.isDeletingUser = false
})
}
},
addUpdateUser(user) {
if (!this.users) return
var index = this.users.findIndex((u) => u.id === user.id)
if (index >= 0) {
this.users.splice(index, 1, user)
} else {
this.users.push(user)
}
},
userRemoved(user) {
this.users = this.users.filter((u) => u.id !== user.id)
},
init(attempts = 0) {
if (!this.$root.socket) {
if (attempts > 10) {
return console.error('Failed to setup socket listeners')
}
setTimeout(() => {
this.init(++attempts)
}, 250)
return
}
this.$root.socket.on('user_added', this.addUpdateUser)
this.$root.socket.on('user_updated', this.addUpdateUser)
this.$root.socket.on('user_removed', this.userRemoved)
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
}
},
mounted() {
this.loadUsers()
this.init()
},
beforeDestroy() {
if (this.$root.socket) {
this.$root.socket.off('user_added', this.newUserAdded)
this.$root.socket.off('user_updated', this.userUpdated)
}
this.initServerSettings()
}
}
</script>
<style>
#accounts {
table-layout: fixed;
border-collapse: collapse;
width: 100%;
}
#accounts td,
#accounts th {
border: 1px solid #2e2e2e;
padding: 8px 8px;
text-align: left;
}
#accounts tr:nth-child(even) {
background-color: #3a3a3a;
}
#accounts tr:hover {
background-color: #444;
}
#accounts th {
font-size: 0.8rem;
font-weight: 600;
padding-top: 5px;
padding-bottom: 5px;
background-color: #333;
}
</style>
</script>

View File

@@ -0,0 +1,16 @@
<template>
<div>
<tables-libraries-table />
</div>
</template>
<script>
export default {
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>

138
client/pages/config/log.vue Normal file
View File

@@ -0,0 +1,138 @@
<template>
<div id="page-wrapper" class="page p-6 overflow-y-auto" :class="streamAudiobook ? 'streaming' : ''">
<div class="w-full max-w-4xl mx-auto">
<div class="mb-4 flex items-center justify-between">
<p class="text-2xl">Logger</p>
<div class="w-44">
<ui-dropdown v-model="newServerSettings.logLevel" label="Server Log Level" :items="logLevelItems" @input="logLevelUpdated" />
</div>
</div>
<div class="relative">
<div ref="container" class="relative w-full h-full bg-primary border-bg overflow-x-hidden overflow-y-auto text-red shadow-inner rounded-md" style="max-height: 550px; min-height: 550px">
<template v-for="(log, index) in logs">
<div :key="index" class="flex flex-nowrap px-2 py-1 items-start text-sm bg-opacity-10" :class="`bg-${logColors[log.level]}`">
<p class="text-gray-400 w-40 font-mono">{{ log.timestamp.split('.')[0].split('T').join(' ') }}</p>
<p class="font-semibold w-12 text-right" :class="`text-${logColors[log.level]}`">{{ log.levelName }}</p>
<p class="px-4 logmessage">{{ log.message }}</p>
</div>
</template>
</div>
<div v-if="!logs.length" class="absolute top-0 left-0 w-full h-full flex flex-col items-center justify-center text-center">
<p class="text-xl text-gray-200 mb-2">No Logs</p>
<p class="text-base text-gray-400">Log listening starts when you login</p>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
asyncData({ store, redirect }) {
if (!store.getters['user/getIsRoot']) {
redirect('/?error=unauthorized')
}
},
data() {
return {
newServerSettings: {},
logColors: ['yellow-200', 'gray-400', 'info', 'warning', 'error', 'red-800', 'blue-400'],
logLevels: [
{
value: 1,
text: 'Debug'
},
{
value: 2,
text: 'Info'
},
{
value: 3,
text: 'Warn'
}
]
}
},
watch: {
serverSettings(newVal, oldVal) {
if (newVal && !oldVal) {
this.newServerSettings = { ...this.serverSettings }
}
},
logs() {
this.updateScroll()
}
},
computed: {
logLevelItems() {
if (process.env.NODE_ENV === 'production') return this.logLevels
this.logLevels.unshift({ text: 'Trace', value: 0 })
return this.logLevels
},
logs() {
return this.$store.state.logs.logs.filter((log) => {
return log.level >= this.newServerSettings.logLevel
})
},
serverSettings() {
return this.$store.state.serverSettings
},
streamAudiobook() {
return this.$store.state.streamAudiobook
}
},
methods: {
updateScroll() {
if (this.$refs.container) {
this.$refs.container.scrollTop = this.$refs.container.scrollHeight - this.$refs.container.clientHeight
}
},
logLevelUpdated(val) {
var payload = {
logLevel: Number(val)
}
this.updateServerSettings(payload)
this.$store.dispatch('logs/setLogListener', this.newServerSettings.logLevel)
this.$nextTick(this.updateScroll)
},
updateServerSettings(payload) {
this.$store
.dispatch('updateServerSettings', payload)
.then((success) => {
console.log('Updated Server Settings', success)
})
.catch((error) => {
console.error('Failed to update server settings', error)
})
},
init(attempts = 0) {
if (!this.$root.socket) {
if (attempts > 10) {
return console.error('Failed to setup socket listeners')
}
setTimeout(() => {
this.init(++attempts)
}, 250)
return
}
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
}
},
updated() {
this.$nextTick(this.updateScroll)
},
mounted() {
this.init()
}
}
</script>
<style scoped>
.logmessage {
width: calc(100% - 208px);
}
</style>

View File

@@ -0,0 +1,110 @@
<template>
<div class="w-full h-full">
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
<nuxt-link to="/config/users" class="text-white text-opacity-70 hover:text-opacity-100 hover:bg-white hover:bg-opacity-5 cursor-pointer rounded-full">
<div class="flex items-center">
<div class="h-10 w-10 flex items-center justify-center">
<span class="material-icons text-2xl">arrow_back</span>
</div>
<p class="pl-1">All Users</p>
</div>
</nuxt-link>
<div class="flex items-center mb-2 mt-4">
<widgets-online-indicator :value="!!userOnline" />
<h1 class="text-xl pl-2">{{ username }}</h1>
</div>
<div class="w-full h-px bg-white bg-opacity-10 my-2" />
<div class="py-2">
<h1 class="text-lg mb-2 text-white text-opacity-90">Reading Progress</h1>
<table v-if="userAudiobooks.length" class="userAudiobooksTable">
<tr class="bg-primary bg-opacity-40">
<th class="w-16 text-left">Book</th>
<th class="text-left"></th>
<th class="w-32">Progress</th>
<th class="w-40">Started At</th>
<th class="w-40">Last Update</th>
</tr>
<tr v-for="ab in userAudiobooks" :key="ab.audiobookId" :class="!ab.isRead ? '' : 'isRead'">
<td>
<cards-book-cover :width="50" :audiobook="ab" />
</td>
<td class="font-book">
<p>{{ ab.book ? ab.book.title : ab.audiobookTitle || 'Unknown' }}</p>
<p v-if="ab.book && ab.book.author" class="text-white text-opacity-50 text-sm font-sans">by {{ ab.book.author }}</p>
</td>
<td class="text-center">{{ Math.floor(ab.progress * 100) }}%</td>
<td class="text-center">
<ui-tooltip v-if="ab.startedAt" direction="top" :text="$formatDate(ab.startedAt, 'MMMM do, yyyy HH:mm')">
<p class="text-sm">{{ $dateDistanceFromNow(ab.startedAt) }}</p>
</ui-tooltip>
</td>
<td class="text-center">
<ui-tooltip v-if="ab.lastUpdate" direction="top" :text="$formatDate(ab.lastUpdate, 'MMMM do, yyyy HH:mm')">
<p class="text-sm">{{ $dateDistanceFromNow(ab.lastUpdate) }}</p>
</ui-tooltip>
</td>
</tr>
</table>
<p v-else class="text-white text-opacity-50">Nothing read yet...</p>
</div>
</div>
</div>
</template>
<script>
export default {
async asyncData({ params, redirect, app }) {
var user = await app.$axios.$get(`/api/user/${params.id}`).catch((error) => {
console.error('Failed to get user', error)
return null
})
if (!user) return redirect('/config/users')
return {
user
}
},
data() {
return {}
},
computed: {
username() {
return this.user.username
},
userOnline() {
return this.$store.getters['users/getIsUserOnline'](this.user.id)
},
userAudiobooks() {
return Object.values(this.user.audiobooks || {}).sort((a, b) => b.lastUpdate - a.lastUpdate)
}
},
methods: {},
mounted() {}
}
</script>
<style>
.userAudiobooksTable {
border-collapse: collapse;
width: 100%;
border: 1px solid #474747;
}
.userAudiobooksTable tr:nth-child(even) {
background-color: #2e2e2e;
}
.userAudiobooksTable tr:not(:first-child) {
background-color: #373838;
}
.userAudiobooksTable tr:hover:not(:first-child) {
background-color: #474747;
}
.userAudiobooksTable tr.isRead {
background-color: rgba(76, 175, 80, 0.1);
}
.userAudiobooksTable td {
padding: 4px 8px;
}
.userAudiobooksTable th {
padding: 4px 8px;
font-size: 0.75rem;
}
</style>

View File

@@ -0,0 +1,16 @@
<template>
<div>
<tables-users-table />
</div>
</template>
<script>
export default {
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>

View File

@@ -20,9 +20,9 @@
<script>
export default {
// asyncData({ redirect }) {
// redirect('/library')
// },
asyncData({ redirect, store }) {
redirect(`/library/${store.state.libraries.currentLibraryId}`)
},
data() {
return {}
},

View File

@@ -12,27 +12,44 @@
<script>
export default {
async asyncData({ params, query, store, app }) {
async asyncData({ params, query, store, app, redirect }) {
var libraryId = params.library
var library = await store.dispatch('libraries/fetch', libraryId)
if (!library) {
return redirect('/oops?message=Library not found')
}
// Set filter by
if (query.filter) {
store.dispatch('user/updateUserSettings', { filterBy: query.filter })
}
var searchResults = []
// Search page
var searchResults = {}
var audiobookSearchResults = []
var searchQuery = null
if (params.id === 'search' && query.query) {
searchQuery = query.query
searchResults = await app.$axios.$get(`/api/audiobooks?q=${query.query}`).catch((error) => {
searchResults = await app.$axios.$get(`/api/library/${libraryId}/search?q=${searchQuery}`).catch((error) => {
console.error('Search error', error)
return []
return {}
})
audiobookSearchResults = searchResults.audiobooks || []
store.commit('audiobooks/setSearchResults', searchResults)
if (audiobookSearchResults.length) audiobookSearchResults.forEach((ab) => store.commit('audiobooks/addUpdate', ab.audiobook))
}
// Series page
var selectedSeries = query.series ? app.$decode(query.series) : null
store.commit('audiobooks/setSelectedSeries', selectedSeries)
var libraryPage = params.id || ''
store.commit('audiobooks/setLibraryPage', libraryPage)
return {
id: libraryPage,
libraryId,
searchQuery,
searchResults,
selectedSeries
@@ -47,6 +64,17 @@ export default {
if (this.$route.query.query !== this.searchQuery) {
this.newQuery()
}
} else if (this.id === 'series') {
if (this.selectedSeries && this.$route.query.series && this.$route.query.series !== this.$encode(this.selectedSeries)) {
// Series changed
this.selectedSeries = this.$decode(this.$route.query.series)
} else if (!this.selectedSeries && this.$route.query.series) {
// Series selected
this.selectedSeries = this.$decode(this.$route.query.series)
} else if (this.selectedSeries && !this.$route.query.series) {
// Series unselected
this.selectedSeries = null
}
}
}
},
@@ -58,9 +86,9 @@ export default {
methods: {
async newQuery() {
var query = this.$route.query.query
this.searchResults = await this.$axios.$get(`/api/audiobooks?q=${query}`).catch((error) => {
this.searchResults = await this.$axios.$get(`/api/library/${this.libraryId}/search?q=${query}`).catch((error) => {
console.error('Search error', error)
return []
return {}
})
this.searchQuery = query
}

View File

@@ -0,0 +1,37 @@
<template>
<div class="page" :class="streamAudiobook ? 'streaming' : ''">
<div class="flex h-full">
<app-side-rail />
<div class="flex-grow">
<app-book-shelf-toolbar is-home />
<app-book-shelf-categorized />
</div>
</div>
</div>
</template>
<script>
export default {
async asyncData({ store, params, redirect }) {
var libraryId = params.library
var library = await store.dispatch('libraries/fetch', libraryId)
if (!library) {
return redirect(`/oops?message=Library "${libraryId}" not found`)
}
return {
library
}
},
data() {
return {}
},
computed: {
streamAudiobook() {
return this.$store.state.streamAudiobook
}
},
methods: {},
mounted() {},
beforeDestroy() {}
}
</script>

View File

@@ -37,7 +37,7 @@ export default {
if (this.$route.query.redirect) {
this.$router.replace(this.$route.query.redirect)
} else {
this.$router.replace('/')
this.$router.replace(`/library/${this.$store.state.libraries.currentLibraryId}`)
}
}
}
@@ -48,6 +48,24 @@ export default {
}
},
methods: {
setUser(user) {
// If user is not able to access main library, then set current library
// var userLibrariesAccessible = this.$store.getters['user/getLibrariesAccessible']
var userCanAccessAll = user.permissions ? !!user.permissions.accessAllLibraries : false
if (!userCanAccessAll) {
var accessibleLibraries = user.librariesAccessible || []
console.log('Setting user without all library access', accessibleLibraries)
if (accessibleLibraries.length && !accessibleLibraries.includes('main')) {
console.log('Setting current library', accessibleLibraries[0])
this.$store.commit('libraries/setCurrentLibrary', accessibleLibraries[0])
}
}
// if (userLibrariesAccessible.length && !userLibrariesAccessible.includes('main')) {
// this.$store.commit('libraries/setCurrentLibrary', userLibrariesAccessible[0])
// }
this.$store.commit('user/setUser', user)
},
async submitForm() {
this.error = null
this.processing = true
@@ -65,7 +83,7 @@ export default {
if (authRes && authRes.error) {
this.error = authRes.error
} else if (authRes) {
this.$store.commit('user/setUser', authRes.user)
this.setUser(authRes.user)
}
this.processing = false
},
@@ -83,7 +101,7 @@ export default {
}
})
.then((res) => {
this.$store.commit('user/setUser', res.user)
this.setUser(res.user)
this.processing = false
})
.catch((error) => {

23
client/pages/oops.vue Normal file
View File

@@ -0,0 +1,23 @@
<template>
<div class="w-screen h-screen overflow-hidden page">
<div class="flex h-1/3 items-center justify-center">
<h1 class="text-2xl">Oops... {{ message }}</h1>
</div>
</div>
</template>
<script>
export default {
asyncData({ query }) {
return {
message: query.message || ''
}
},
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>

View File

@@ -4,6 +4,15 @@
<article class="max-h-full overflow-y-auto relative flex flex-col rounded-md" @drop="drop" @dragover="dragover" @dragleave="dragleave" @dragenter="dragenter">
<h1 class="text-xl font-book px-8 pt-4 pb-2">Audiobook Uploader</h1>
<div class="flex my-2 px-6">
<div class="w-1/3 px-2">
<!-- <ui-text-input-with-label v-model="title" label="Title" /> -->
<ui-dropdown v-model="selectedLibraryId" :items="libraryItems" label="Library" @input="libraryChanged" />
</div>
<div class="w-2/3 px-2">
<ui-dropdown v-model="selectedFolderId" :items="folderItems" :disabled="!selectedLibraryId" label="Folder" />
</div>
</div>
<div class="flex my-2 px-6">
<div class="w-1/2 px-2">
<ui-text-input-with-label v-model="title" label="Title" />
@@ -31,7 +40,7 @@
<input ref="fileInput" id="hidden-input" type="file" multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
<ui-btn @click="clickSelectAudioFiles">Select files</ui-btn>
<p class="text-xs text-gray-300 absolute bottom-3 right-3">{{ inputAccept.join(', ') }}</p>
<p class="text-xs text-gray-300 absolute bottom-3 right-3">{{ inputAccept }}</p>
</header>
</section>
<section v-else class="h-full overflow-auto px-8 pb-8 w-full flex flex-col">
@@ -120,14 +129,16 @@ export default {
title: null,
author: null,
series: null,
acceptedAudioFormats: ['.mp3', '.m4b', '.m4a'],
acceptedImageFormats: ['image/*'],
inputAccept: ['image/*, .mp3, .m4b, .m4a'],
acceptedAudioFormats: ['.mp3', '.m4b', '.m4a', '.flac', '.opus', '.mp4'],
acceptedImageFormats: ['.png', '.jpg', '.jpeg', '.webp'],
inputAccept: '.png, .jpg, .jpeg, .webp, .mp3, .m4b, .m4a, .flac, .opus, .mp4',
isDragOver: false,
showUploader: true,
validAudioFiles: [],
validImageFiles: [],
invalidFiles: []
invalidFiles: [],
selectedLibraryId: null,
selectedFolderId: null
}
},
computed: {
@@ -140,13 +151,55 @@ export default {
directory() {
if (!this.author || !this.title) return ''
if (this.series) {
return Path.join('/audiobooks', this.author, this.series, this.title)
return Path.join(this.author, this.series, this.title)
} else {
return Path.join('/audiobooks', this.author, this.title)
return Path.join(this.author, this.title)
}
},
libraries() {
return this.$store.state.libraries.libraries
},
libraryItems() {
return this.libraries.map((lib) => {
return {
value: lib.id,
text: lib.name
}
})
},
selectedLibrary() {
return this.libraries.find((lib) => lib.id === this.selectedLibraryId)
},
selectedFolder() {
if (!this.selectedLibrary) return null
return this.selectedLibrary.folders.find((fold) => fold.id === this.selectedFolderId)
},
folderItems() {
if (!this.selectedLibrary) return []
return this.selectedLibrary.folders.map((fold) => {
return {
value: fold.id,
text: fold.fullPath
}
})
}
},
methods: {
libraryChanged() {
if (!this.selectedLibrary && this.selectedFolderId) {
this.selectedFolderId = null
} else if (this.selectedFolderId) {
if (!this.selectedLibrary.folders.find((fold) => fold.id === this.selectedFolderId)) {
this.selectedFolderId = null
}
}
this.setDefaultFolder()
},
setDefaultFolder() {
if (!this.selectedFolderId && this.selectedLibrary && this.selectedLibrary.folders.length) {
this.selectedFolderId = this.selectedLibrary.folders[0].id
}
},
reset() {
this.title = ''
this.author = ''
@@ -218,12 +271,18 @@ export default {
this.$toast.error('Must enter a title and author')
return
}
if (!this.selectedLibraryId || !this.selectedFolderId) {
this.$toast.error('Must select a library and folder')
return
}
this.processing = true
var form = new FormData()
form.set('title', this.title)
form.set('author', this.author)
form.set('series', this.series)
form.set('library', this.selectedLibraryId)
form.set('folder', this.selectedFolderId)
var index = 0
var files = this.validAudioFiles.concat(this.validImageFiles)
@@ -234,21 +293,21 @@ export default {
this.$axios
.$post('/upload', form)
.then((data) => {
if (data.error) {
this.$toast.error(data.error)
} else {
this.$toast.success('Audiobook Uploaded Successfully')
this.reset()
}
this.$toast.success('Audiobook Uploaded Successfully')
this.reset()
this.processing = false
})
.catch((error) => {
console.error('Failed', error)
this.$toast.error('Oops, something went wrong...')
var errorMessage = error.response && error.response.data ? error.response.data : 'Oops, something went wrong...'
this.$toast.error(errorMessage)
this.processing = false
})
}
},
mounted() {}
mounted() {
this.selectedLibraryId = this.$store.state.libraries.currentLibraryId
this.setDefaultFolder()
}
}
</script>

View File

@@ -1,5 +1,9 @@
export default function ({ $axios, store }) {
$axios.onRequest(config => {
if (!config.url) {
console.error('Axios request invalid config', config)
return
}
if (config.url.startsWith('http:') || config.url.startsWith('https:')) {
return
}

View File

@@ -5,10 +5,53 @@ const DownloadStatus = {
FAILED: 3
}
const CoverDestination = {
METADATA: 0,
AUDIOBOOK: 1
}
const Constants = {
DownloadStatus
DownloadStatus,
CoverDestination
}
const KeyNames = {
27: 'Escape',
32: 'Space',
37: 'ArrowLeft',
38: 'ArrowUp',
39: 'ArrowRight',
40: 'ArrowDown',
76: 'KeyL',
77: 'KeyM'
}
const Hotkeys = {
AudioPlayer: {
PLAY_PAUSE: 'Space',
JUMP_FORWARD: 'ArrowRight',
JUMP_BACKWARD: 'ArrowLeft',
VOLUME_UP: 'ArrowUp',
VOLUME_DOWN: 'ArrowDown',
MUTE_UNMUTE: 'KeyM',
SHOW_CHAPTERS: 'KeyL',
INCREASE_PLAYBACK_RATE: 'Shift-ArrowUp',
DECREASE_PLAYBACK_RATE: 'Shift-ArrowDown',
CLOSE: 'Escape'
},
EReader: {
NEXT_PAGE: 'ArrowRight',
PREV_PAGE: 'ArrowLeft',
CLOSE: 'Escape'
},
Modal: {
NEXT_PAGE: 'ArrowRight',
PREV_PAGE: 'ArrowLeft',
CLOSE: 'Escape'
}
}
export default ({ app }, inject) => {
inject('constants', Constants)
inject('keynames', KeyNames)
inject('hotkeys', Hotkeys)
}

View File

@@ -1,6 +1,19 @@
import Vue from 'vue'
import { formatDistance, format } from 'date-fns'
Vue.prototype.$eventBus = new Vue()
Vue.prototype.$isDev = process.env.NODE_ENV !== 'production'
Vue.prototype.$dateDistanceFromNow = (unixms) => {
if (!unixms) return ''
return formatDistance(unixms, Date.now(), { addSuffix: true })
}
Vue.prototype.$formatDate = (unixms, fnsFormat = 'MM/dd/yyyy HH:mm') => {
if (!unixms) return ''
return format(unixms, fnsFormat)
}
Vue.prototype.$bytesPretty = (bytes, decimals = 2) => {
if (bytes === 0) {
return '0 Bytes'
@@ -31,7 +44,7 @@ Vue.prototype.$secondsToTimestamp = (seconds) => {
_seconds -= _minutes * 60
var _hours = Math.floor(_minutes / 60)
_minutes -= _hours * 60
_seconds = Math.round(_seconds)
_seconds = Math.floor(_seconds)
if (!_hours) {
return `${_minutes}:${_seconds.toString().padStart(2, '0')}`
}
@@ -116,6 +129,19 @@ Vue.prototype.$sanitizeFilename = (input, replacement = '') => {
return sanitized
}
function xmlToJson(xml) {
const json = {};
for (const res of xml.matchAll(/(?:<(\w*)(?:\s[^>]*)*>)((?:(?!<\1).)*)(?:<\/\1>)|<(\w*)(?:\s*)*\/>/gm)) {
const key = res[1] || res[3];
const value = res[2] && xmlToJson(res[2]);
json[key] = ((value && Object.keys(value).length) ? value : res[2]) || null;
}
return json;
}
Vue.prototype.$xmlToJson = xmlToJson
const encode = (text) => encodeURIComponent(Buffer.from(text).toString('base64'))
Vue.prototype.$encode = encode
const decode = (text) => Buffer.from(decodeURIComponent(text), 'base64').toString()

View File

Binary file not shown.

View File

Binary file not shown.

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