Compare commits

...

179 Commits

Author SHA1 Message Date
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
Mark Cooper
4c07f9ec25 Write metadata file option, rate limiting login attempts, generic failed login message 2021-09-29 10:16:38 -05:00
advplyr
c82d888b5a Write metadata file option, rate limiting login attempts, generic failed login message 2021-09-29 10:16:38 -05:00
Mark Cooper
0ba38d45bc Readme update 2021-09-28 17:59:44 -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
134 changed files with 8889 additions and 1436 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

@@ -11,7 +11,6 @@ RUN npm run generate
### STAGE 2: Build server ###
FROM node:12-alpine
ENV NODE_ENV=production
ENV LOG_LEVEL=INFO
COPY --from=build /client/dist /client/dist
COPY --from=ffmpeg / /
COPY index.js index.js

View File

@@ -1,4 +1,5 @@
@import url('./transitions.css');
@import url('./draggable.css');
.page {
width: 100%;
@@ -51,6 +52,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 +80,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;

View File

@@ -440,7 +440,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')
}

View File

@@ -7,16 +7,21 @@
<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 class="bg-black bg-opacity-20 rounded-md py-1.5 px-3 flex items-center text-white text-opacity-70 cursor-pointer hover:bg-opacity-10 hover:text-opacity-90" @click="clickLibrary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" 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>
<p class="text-sm leading-3 font-sans pl-2">{{ libraryName }}</p>
</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 +69,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 +87,6 @@ export default {
isRootUser() {
return this.$store.getters['user/getIsRoot']
},
username() {
return this.user ? this.user.username : 'err'
},
@@ -117,9 +127,15 @@ export default {
},
processingBatch() {
return this.$store.state.processingBatch
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
}
},
methods: {
clickLibrary() {
this.$store.commit('libraries/setShowModal', true)
},
async back() {
var popped = await this.$store.dispatch('popRoute')
var backTo = popped || '/'

View File

@@ -23,7 +23,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" />
@@ -53,7 +53,7 @@ export default {
data() {
return {
shelves: [],
currFilterOrderKey: null,
currSearchParams: null,
availableSizes: [60, 80, 100, 120, 140, 160, 180, 200, 220],
selectedSizeIndex: 3,
rowPaddingX: 40,
@@ -77,6 +77,11 @@ export default {
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 })
}
}
},
computed: {
@@ -89,9 +94,6 @@ export default {
audiobooks() {
return this.$store.state.audiobooks.audiobooks
},
filterOrderKey() {
return this.$store.getters['user/getFilterOrderKey']
},
bookCoverWidth() {
return this.availableSizes[this.selectedSizeIndex]
},
@@ -102,7 +104,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,6 +113,12 @@ 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
},
@@ -129,6 +138,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 +175,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 +201,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 +221,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 +265,7 @@ export default {
}
},
scan() {
this.$root.socket.emit('scan')
this.$root.socket.emit('scan', this.$store.state.libraries.currentLibraryId)
}
},
updated() {

View File

@@ -94,13 +94,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 +143,7 @@ export default {
}
},
scan() {
this.$root.socket.emit('scan')
this.$root.socket.emit('scan', this.$store.state.libraries.currentLibraryId)
}
},
mounted() {

View File

@@ -4,7 +4,7 @@
<div class="w-full h-full" :style="{ marginTop: sizeMultiplier + 'rem' }">
<div 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>
@@ -53,6 +53,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(() => {

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>
@@ -81,14 +84,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,74 @@
<template>
<div class="w-full pt-20">
<div :style="{ height: pdfHeight + 'px' }" class="overflow-hidden m-auto">
<div class="flex items-center justify-center">
<div class="px-12">
<span class="material-icons text-5xl text-black" :class="!canGoPrev ? 'text-opacity-10' : 'cursor-pointer text-opacity-30 hover:text-opacity-90'" @click.stop.prevent="goPrevPage" @mousedown.prevent>arrow_back_ios</span>
</div>
<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="src" :page="page" :rotate="rotate" @progress="loadedRatio = $event" @error="error" @num-pages="numPagesLoaded" @link-clicked="page = $event"></pdf>
</div>
<div class="px-12">
<span class="material-icons text-5xl text-black" :class="!canGoNext ? 'text-opacity-10' : 'cursor-pointer text-opacity-30 hover:text-opacity-90'" @click.stop.prevent="goNextPage" @mousedown.prevent>arrow_forward_ios</span>
</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: {
src: 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
},
goPrevPage() {
if (this.page <= 1) return
this.page--
},
goNextPage() {
if (this.page >= this.numPages) return
this.page++
},
error(err) {
console.error(err)
}
},
mounted() {}
}
</script>

View File

@@ -0,0 +1,347 @@
<template>
<div v-if="show" class="w-screen h-screen fixed top-0 left-0 z-50 bg-white text-black">
<div class="absolute top-4 right-4 z-10">
<span class="material-icons cursor-pointer text-4xl" @click="show = false">close</span>
</div>
<!-- <div v-if="chapters.length" class="absolute top-0 left-0 w-52">
<select v-model="selectedChapter" class="w-52" @change="changedChapter">
<option v-for="chapter in chapters" :key="chapter.href" :value="chapter.href">{{ chapter.label }}</option>
</select>
</div> -->
<div class="absolute top-4 left-4 font-book">
<h1 class="text-2xl mb-1">{{ title || abTitle }}</h1>
<p v-if="author || abAuthor">by {{ author || abAuthor }}</p>
</div>
<!-- EPUB -->
<div v-if="ebookType === 'epub'" class="h-full flex items-center">
<div style="width: 100px; max-width: 100px" class="h-full flex items-center overflow-x-hidden">
<span v-show="hasPrev" class="material-icons text-black text-opacity-30 hover:text-opacity-80 cursor-pointer text-8xl" @mousedown.prevent @click="pageLeft">chevron_left</span>
</div>
<div id="frame" class="w-full" style="height: 650px">
<div id="viewer" class="spreads"></div>
<div class="px-16 flex justify-center" style="height: 50px">
<p class="px-4">{{ progress }}%</p>
</div>
</div>
<div style="width: 100px; max-width: 100px" class="h-full flex items-center overflow-x-hidden">
<span v-show="hasNext" class="material-icons text-black text-opacity-30 hover:text-opacity-80 cursor-pointer text-8xl" @mousedown.prevent @click="pageRight">chevron_right</span>
</div>
</div>
<!-- MOBI/AZW3 -->
<div v-else-if="ebookType === 'mobi'" 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">
<iframe title="html-viewer" width="100%"> Loading </iframe>
</div>
</div>
<!-- PDF -->
<div v-else-if="ebookType === 'pdf'" class="h-full flex items-center">
<app-pdf-reader :src="ebookUrl" />
</div>
<div class="absolute bottom-2 left-2">{{ ebookType }}</div>
</div>
</template>
<script>
import ePub from 'epubjs'
import MobiParser from '@/assets/ebooks/mobi.js'
import HtmlParser from '@/assets/ebooks/htmlParser.js'
import defaultCss from '@/assets/ebooks/basic.js'
export default {
data() {
return {
scale: 1,
book: null,
rendition: null,
chapters: [],
title: '',
author: '',
progress: 0,
hasNext: true,
hasPrev: false,
ebookType: '',
ebookUrl: ''
}
},
watch: {
show(newVal) {
if (newVal) {
this.init()
} else {
this.close()
}
}
},
computed: {
show: {
get() {
return this.$store.state.showEReader
},
set(val) {
this.$store.commit('setShowEReader', val)
}
},
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')
},
userToken() {
return this.$store.getters['user/getToken']
},
selectedAudiobookFile() {
return this.$store.state.selectedAudiobookFile
}
},
methods: {
getEbookUrl(path) {
return `/ebook/${this.libraryId}/${this.folderId}/${path}`
},
changedChapter() {
if (this.rendition) {
this.rendition.display(this.selectedChapter)
}
},
pageLeft() {
if (this.rendition) {
this.rendition.prev()
}
},
pageRight() {
if (this.rendition) {
this.rendition.next()
}
},
keyUp(e) {
if (!this.rendition) {
console.error('No rendition')
return
}
if ((e.keyCode || e.which) == 37) {
this.rendition.prev()
} else if ((e.keyCode || e.which) == 39) {
this.rendition.next()
} else if ((e.keyCode || e.which) == 27) {
this.show = false
}
},
registerListeners() {
document.addEventListener('keyup', this.keyUp)
},
unregisterListeners() {
document.removeEventListener('keyup', this.keyUp)
},
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'
this.initMobi()
} else if (this.selectedAudiobookFile.ext === '.epub') {
this.ebookType = 'epub'
this.initEpub()
}
} else if (this.epubEbook) {
this.ebookType = 'epub'
this.ebookUrl = this.getEbookUrl(this.epubEbook.path)
this.initEpub()
} else if (this.mobiEbook) {
this.ebookType = 'mobi'
this.ebookUrl = this.getEbookUrl(this.mobiEbook.path)
this.initMobi()
} else if (this.pdfEbook) {
this.ebookType = 'pdf'
this.ebookUrl = this.getEbookUrl(this.pdfEbook.path)
}
},
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.ebookUrl, {
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)
},
initEpub() {
// var book = ePub(this.url, {
// requestHeaders: {
// Authorization: `Bearer ${this.userToken}`
// }
// })
var book = ePub(this.ebookUrl)
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)
var percentage = Math.floor(percent * 100)
this.progress = percentage
this.hasNext = !location.atEnd
this.hasPrev = !location.atStart
})
},
close() {
this.unregisterListeners()
}
},
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

@@ -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,7 +11,7 @@
<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="paramId === '' && !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="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>
@@ -21,7 +21,7 @@
<div v-show="paramId === '' && !homePage" 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>
@@ -72,11 +72,14 @@ 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'
}
},
methods: {},

View File

@@ -63,12 +63,15 @@ export default {
},
playlistUrl() {
return this.stream ? this.stream.clientPlaylistUri : null
},
libraryId() {
return this.streamAudiobook ? this.streamAudiobook.libraryId : null
}
},
methods: {
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,6 +1,6 @@
<template>
<div class="flex h-full px-1 overflow-hidden">
<cards-book-cover :audiobook="audiobook" :width="40" />
<cards-book-cover :audiobook="audiobook" :width="50" />
<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>
@@ -38,7 +38,7 @@ export default {
<style>
.searchCardContent {
width: calc(100% - 80px);
height: calc(40px * 1.5);
height: calc(50px * 1.5);
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="https://rpgplanner.com/wp-content/uploads/2020/06/no-photo-available.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>
.searchCardContent {
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,23 @@ 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
},
showReadButton() {
return !this.isSelectionMode && this.showExperimentalFeatures && !this.showPlayButton && this.hasEbook
},
showPlayButton() {
return !this.isSelectionMode && !this.isMissing && !this.isIncomplete && this.hasTracks
},
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 +187,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 +228,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 +237,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="`/library/${currentLibraryId}/bookshelf/series?${groupType}=${groupEncode}`" 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,6 +48,9 @@ export default {
}
},
computed: {
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
_group() {
return this.group || {}
},

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

@@ -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,6 +92,11 @@ export default {
value: 'authors',
sublist: true
},
{
text: 'Narrator',
value: 'narrators',
sublist: true
},
{
text: 'Progress',
value: 'progress',
@@ -137,6 +147,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,42 @@
<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" />
</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" @click="clickedOption(item.series)">
<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>
</template>
@@ -42,7 +61,9 @@ export default {
isTyping: false,
isFetching: false,
search: null,
items: [],
audiobookResults: [],
authorResults: [],
seriesResults: [],
searchTimeout: null,
lastSearch: null
}
@@ -50,15 +71,23 @@ 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
}
},
methods: {
submitSearch() {
if (!this.search) return
this.$router.push(`/library/search?query=${this.search}`)
this.$router.push(`/library/${this.currentLibraryId}/bookshelf/search?query=${this.search}`)
this.search = null
this.items = []
this.audiobookResults = []
this.authorResults = []
this.seriesResults = []
this.showMenu = false
this.$nextTick(() => {
if (this.$refs.input) {
@@ -83,22 +112,19 @@ 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 []
})
this.audiobookResults = searchResults.audiobooks || []
this.authorResults = searchResults.authors || []
this.seriesResults = searchResults.series || []
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)
@@ -111,21 +137,25 @@ export default {
this.searchTimeout = setTimeout(() => {
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.lastSearch = null
this.audiobookResults = []
this.authorResults = []
this.seriesResults = []
this.showMenu = false
}
}
},
mounted() {}
}
</script>
</script>
<style>
.globalSearchMenu {
max-height: 80vh;
}
</style>

View File

@@ -0,0 +1,45 @@
<template>
<modals-modal v-model="show" :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

@@ -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]
}
@@ -137,9 +151,44 @@ 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) 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,9 +204,12 @@ 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
}
}

View File

@@ -0,0 +1,99 @@
<template>
<modals-modal v-model="show" :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 v-if="show" 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: 200px; max-height: 80vh">
<div v-if="!showAddLibrary" class="w-full h-full flex flex-col justify-center px-4">
<div class="flex items-center mb-4">
<p>{{ libraries.length }} Libraries</p>
<!-- <div class="flex-grow" />
<ui-btn @click="addLibraryClick">Add Library</ui-btn> -->
</div>
<template v-for="library in libraries">
<modals-libraries-library-item :key="library.id" :library="library" :selected="currentLibraryId === library.id" :show-edit="false" @edit="editLibrary" @delete="deleteLibrary" @click="clickLibrary" />
</template>
</div>
<modals-libraries-edit-library v-else :library="selectedLibrary" :show="showAddLibrary" :processing.sync="processing" @back="showAddLibrary = false" @close="showAddLibrary = false" />
</div>
</modals-modal>
</template>
<script>
export default {
data() {
return {
selectedLibrary: null,
processing: false,
showAddLibrary: false
}
},
computed: {
show: {
get() {
return this.$store.state.libraries.showModal
},
set(val) {
this.$store.commit('libraries/setShowModal', val)
}
},
title() {
return 'Libraries'
},
currentLibrary() {
return this.$store.getters['libraries/getCurrentLibrary']
},
currentLibraryId() {
return this.currentLibrary ? this.currentLibrary.id : null
},
libraries() {
return this.$store.state.libraries.libraries
}
},
watch: {
show(newVal) {
if (newVal) this.showAddLibrary = false
}
},
methods: {
async clickLibrary(library) {
await this.$store.dispatch('libraries/fetch', library.id)
this.$router.push(`/library/${library.id}`)
this.show = false
},
editLibrary(library) {
this.selectedLibrary = library
this.showAddLibrary = true
},
addLibraryClick() {
this.selectedLibrary = null
this.showAddLibrary = true
},
deleteLibrary(library) {
if (confirm(`Are you sure you want to delete library "${library.name}"?\n(no files will be deleted but book data will be lost)`)) {
console.log('Delete library', library)
this.processing = true
this.$axios
.$delete(`/api/library/${library.id}`)
.then(() => {
console.log('Library delete success')
this.$toast.success(`Library "${library.name}" deleted`)
this.processing = false
})
.catch((error) => {
console.error('Failed to delete library', error)
var errMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
this.$toast.error(errMsg)
this.processing = false
})
}
}
},
mounted() {},
beforeDestroy() {}
}
</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" /> -->
<cards-preview-cover :src="cover" :width="80" show-open-new-tab />
</div>
</template>
</div>
@@ -124,21 +121,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 +169,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
})
},
@@ -204,20 +215,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 +265,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,7 +47,7 @@
<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>
@@ -55,6 +55,15 @@
<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>
<ui-tooltip text="(Root User Only) Save a NFO metadata file in your audiobooks directory" direction="bottom" class="ml-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>
<ui-tooltip :disabled="libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="ml-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>
<div class="flex-grow" />
<ui-btn type="submit">Submit</ui-btn>
</div>
@@ -79,7 +88,7 @@ export default {
subtitle: null,
description: null,
author: null,
narrarator: null,
narrator: null,
series: null,
volumeNumber: null,
publishYear: null,
@@ -87,7 +96,9 @@ export default {
},
newTags: [],
resettingProgress: false,
isScrollable: false
isScrollable: false,
savingMetadata: false,
rescanning: false
}
},
watch: {
@@ -107,6 +118,9 @@ export default {
this.$emit('update:processing', val)
}
},
isRootUser() {
return this.$store.getters['user/getIsRoot']
},
audiobookId() {
return this.audiobook ? this.audiobook.id : null
},
@@ -124,9 +138,51 @@ 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) {
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.audiobookTitle}"`)
}
}
},
saveMetadata() {
this.savingMetadata = true
this.$root.socket.once('save_metadata_complete', this.saveMetadataComplete)
this.$root.socket.emit('save_metadata', this.audiobookId)
},
async submitForm() {
if (this.isProcessing) {
return
@@ -152,7 +208,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,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,112 @@
<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 }
})
}
},
mounted() {
this.init()
},
beforeDestroy() {}
}
</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,220 @@
<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="user.isActive ? '' : 'bg-error bg-opacity-20'">
<td>
<div class="flex items-center">
<span v-if="usersOnline[user.id]" class="w-3 h-3 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
></span>
<svg v-else class="w-3 h-3 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>
{{ user.username }} <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>
<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>
<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 _users = this.$store.state.users.users
var currUserStream = null
if (this.userStream) {
currUserStream = {
audiobook: this.userStream
}
}
var usermap = {
[this.currentUserId]: {
online: true,
stream: currUserStream
}
}
_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)
return abs[0] && 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
})
.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;
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>

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,71 @@
<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-10 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 libraries">
<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']()
},
libraryItems() {
return this.libraries.map((lib) => ({ value: lib.id, text: lib.name }))
}
},
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

@@ -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

@@ -5,12 +5,16 @@
<Nuxt />
<app-stream-container ref="streamContainer" />
<modals-libraries-modal />
<modals-edit-modal />
<widgets-scan-alert />
<app-reader />
<!-- <widgets-scan-alert /> -->
</div>
</template>
<script>
import CloseButton from '@/components/widgets/CloseButton'
export default {
middleware: 'authenticated',
data() {
@@ -64,6 +68,14 @@ 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)
}
},
streamOpen(stream) {
if (this.$refs.streamContainer) this.$refs.streamContainer.streamOpen(stream)
@@ -89,43 +101,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)
libraryRemoved(library) {
this.$store.commit('libraries/remove', library)
},
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 {
this.$store.commit('setScanProgress', progress)
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) {
@@ -133,10 +163,20 @@ 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)
},
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)
@@ -190,6 +230,13 @@ export default {
download.status = this.$constants.DownloadStatus.EXPIRED
this.$store.commit('downloads/addUpdateDownload', download)
},
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',
@@ -223,8 +270,16 @@ 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)
// Scan Listeners
this.socket.on('scan_start', this.scanStart)
@@ -237,6 +292,10 @@ export default {
this.socket.on('download_failed', this.downloadFailed)
this.socket.on('download_killed', this.downloadKilled)
this.socket.on('download_expired', this.downloadExpired)
this.socket.on('log', this.logEvtReceived)
this.socket.on('backup_applied', this.backupApplied)
},
showUpdateToast(versionData) {
var ignoreVersion = localStorage.getItem('ignoreVersion')
@@ -265,6 +324,14 @@ export default {
},
mounted() {
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) => {

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 !== 'config' && from.name !== 'config-log' && 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

@@ -35,7 +35,7 @@ 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/css2?family=Fira+Mono&family=Ubuntu+Mono&family=Gentium+Book+Basic&&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' : '/' }
},

244
client/package-lock.json generated
View File

@@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
"version": "1.2.4",
"version": "1.4.8",
"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,14 @@
"launch-editor": "^2.2.1"
}
},
"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 +7430,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 +7550,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 +7856,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 +8482,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 +8499,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 +11249,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 +11897,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 +13008,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 +13355,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 +14240,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 +14382,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.7",
"version": "1.4.9",
"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,12 @@
"@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",
"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>

View File

@@ -224,41 +224,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,82 @@
</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">
<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="author" :to="`/library/${libraryId}/bookshelf?filter=authors.${$encode(author)}`" class="hover:underline">{{ author }}</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 class="w-min">
<ui-tooltip :text="authorTooltipText" direction="bottom">
<span class="text-sm text-gray-100 leading-7 whitespace-nowrap">by {{ author }}</span>
<span class="text-base text-gray-100 leading-8 whitespace-nowrap"><span class="text-white text-opacity-60">By:</span> {{ author }}</span>
</ui-tooltip>
</div> -->
<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-show="showEpubAlert" class="bg-error p-4 rounded-xl flex items-center mt-2">
<span class="material-icons text-2xl">warning_amber</span>
<p class="ml-4">Book has valid ebook files, but the experimental e-reader currently only supports epub files.</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 +95,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 && (epubEbook || mobiEbook)" 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 +122,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,16 +144,16 @@
</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>
<!-- <app-reader v-if="showExperimentalFeatures" v-model="showReader" :url="epubUrl" /> -->
</div>
</template>
@@ -111,6 +171,7 @@ export default {
console.error('No audiobook...', params.id)
return redirect('/')
}
store.commit('audiobooks/addUpdate', audiobook)
return {
audiobook
}
@@ -134,6 +195,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 +232,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 +321,21 @@ export default {
ebooks() {
return this.audiobook.ebooks
},
showEpubAlert() {
return this.ebooks.length && !this.epubEbook && !this.tracks.length
},
showExperimentalReadAlert() {
return !this.tracks.length && this.ebooks.length && !this.showExperimentalFeatures
},
epubEbook() {
return this.ebooks.find((eb) => eb.ext === '.epub')
},
mobiEbook() {
return this.ebooks.find((eb) => eb.ext === '.mobi' || eb.ext === '.azw3')
},
userToken() {
return this.$store.getters['user/getToken']
},
description() {
return this.book.description || ''
},
@@ -272,16 +375,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,6 +411,7 @@ export default {
this.$root.socket.emit('open_stream', this.audiobook.id)
},
editClick() {
this.$store.commit('setBookshelfBookIds', [])
this.$store.commit('showEditModal', this.audiobook)
},
lookupMetadata(index) {
@@ -334,6 +429,7 @@ export default {
this.$axios
.$get(`/api/audiobook/${this.audiobookId}`)
.then((audiobook) => {
console.log('Updated audiobook', audiobook)
this.audiobook = audiobook
})
.catch((error) => {
@@ -362,6 +458,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')
}

View File

@@ -1,68 +1,69 @@
<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="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>
<tables-users-table />
<!-- <div class="h-0.5 bg-primary bg-opacity-50 w-full" /> -->
<div class="w-full">
<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>
</div>
<tables-libraries-table />
<!-- <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-xl">Settings</h1>
</div>
<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="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-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="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-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 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-grow" />
<ui-btn to="/config/log">View Logger</ui-btn>
</div>
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
@@ -79,10 +80,28 @@
</svg>
</a>
</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="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 class="flex-grow" /> -->
<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>
<div class="fixed bottom-0 left-0 w-10 h-10" @dblclick="setDeveloperMode"></div>
</div>
</template>
@@ -95,18 +114,19 @@ export default {
},
data() {
return {
storeCoversInAudiobookDir: false,
isResettingAudiobooks: false,
users: [],
selectedAccount: null,
showAccountModal: false,
isDeletingUser: false,
newServerSettings: {}
newServerSettings: {},
updatingServerSettings: false,
dailyBackups: true,
backupsToKeep: 2
}
},
watch: {
serverSettings(newVal, oldVal) {
if (newVal && !oldVal) {
this.newServerSettings = { ...this.serverSettings }
this.initServerSettings()
}
}
},
@@ -114,6 +134,24 @@ export default {
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"'
},
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.'
},
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'
},
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.'
},
dailyBackupsTooltip() {
return 'Runs at 1am every day (your server time). Saved in /metadata/backups.'
},
backupsToKeepTooltip() {
return ''
},
serverSettings() {
return this.$store.state.serverSettings
},
@@ -125,20 +163,55 @@ export default {
},
isScanningCovers() {
return this.$store.state.isScanningCovers
},
showExperimentalFeatures: {
get() {
return this.$store.state.showExperimentalFeatures
},
set(val) {
this.$store.commit('setExperimentalFeatures', val)
}
}
},
methods: {
updateScannerParseSubtitle(val) {
var payload = {
scannerParseSubtitle: val
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)
},
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) {
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() {
@@ -147,20 +220,17 @@ export default {
this.$toast.info(`Developer Mode ${value ? 'Enabled' : 'Disabled'}`)
},
scan() {
this.$root.socket.emit('scan')
this.$root.socket.emit('scan', this.$store.state.libraries.currentLibraryId)
},
scanCovers() {
this.$root.socket.emit('scan_covers')
saveMetadataComplete(result) {
this.savingMetadata = false
if (!result) return
this.$toast.success(`Metadata saved for ${result.success} audiobooks`)
},
loadUsers() {
this.$axios
.$get('/api/users')
.then((users) => {
this.users = users
})
.catch((error) => {
console.error('Failed', error)
})
saveMetadataFiles() {
this.savingMetadata = true
this.$root.socket.once('save_metadata_complete', this.saveMetadataComplete)
this.$root.socket.emit('save_metadata')
},
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?')) {
@@ -170,112 +240,28 @@ 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)
init() {
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
this.initServerSettings()
},
initServerSettings() {
this.storeCoversInAudiobookDir = this.newServerSettings.coverDestination === this.$constants.CoverDestination.AUDIOBOOK
this.storeCoversInAudiobookDir = this.newServerSettings.coverDestination === this.$constants.CoverDestination.AUDIOBOOK
this.backupsToKeep = this.newServerSettings.backupsToKeep || 2
this.dailyBackups = !!this.newServerSettings.backupSchedule
}
},
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)
}
}
}
</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>

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

@@ -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,7 +12,13 @@
<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')
}
if (query.filter) {
store.dispatch('user/updateUserSettings', { filterBy: query.filter })
}
@@ -20,11 +26,12 @@ export default {
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}/audiobooks?q=${query.query}`).catch((error) => {
console.error('Search error', error)
return []
})
store.commit('audiobooks/setSearchResults', searchResults)
if (searchResults.length) searchResults.forEach((ab) => store.commit('audiobooks/addUpdate', ab))
}
var selectedSeries = query.series ? app.$decode(query.series) : null
store.commit('audiobooks/setSelectedSeries', selectedSeries)

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}`)
}
}
}
@@ -57,15 +57,14 @@ export default {
password: this.password || ''
}
var authRes = await this.$axios.$post('/login', payload).catch((error) => {
console.error('Failed', error)
console.error('Failed', error.response)
if (error.response) this.error = error.response.data
else this.error = 'Unknown Error'
return false
})
console.log('Auth res', authRes)
if (!authRes) {
this.error = 'Unknown Failure'
} else if (authRes.error) {
if (authRes && authRes.error) {
this.error = authRes.error
} else {
} else if (authRes) {
this.$store.commit('user/setUser', authRes.user)
}
this.processing = false

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

@@ -16,6 +16,7 @@ export default function ({ $axios, store }) {
$axios.onError(error => {
const code = parseInt(error.response && error.response.status)
console.error('Axios error code', code)
const message = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
console.error('Axios error', code, message)
})
}

View File

@@ -5,8 +5,14 @@ const DownloadStatus = {
FAILED: 3
}
const CoverDestination = {
METADATA: 0,
AUDIOBOOK: 1
}
const Constants = {
DownloadStatus
DownloadStatus,
CoverDestination
}
export default ({ app }, inject) => {

View File

@@ -1,6 +1,17 @@
import Vue from 'vue'
import { formatDistance, format } from 'date-fns'
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'

View File

@@ -5,6 +5,7 @@ const STANDARD_GENRES = ['Adventure', 'Autobiography', 'Biography', 'Childrens',
export const state = () => ({
audiobooks: [],
loadedLibraryId: '',
lastLoad: 0,
listeners: [],
genres: [...STANDARD_GENRES],
@@ -41,14 +42,19 @@ export const getters = {
var settings = rootState.user.settings || {}
var filterBy = settings.filterBy || ''
var searchGroups = ['genres', 'tags', 'series', 'authors', 'progress']
var searchGroups = ['genres', 'tags', 'series', 'authors', 'progress', 'narrators']
var group = searchGroups.find(_group => filterBy.startsWith(_group + '.'))
if (group) {
var filter = decode(filterBy.replace(`${group}.`, ''))
var filterVal = filterBy.replace(`${group}.`, '')
var filter = decode(filterVal)
if (group === 'genres') filtered = filtered.filter(ab => ab.book && ab.book.genres.includes(filter))
else if (group === 'tags') filtered = filtered.filter(ab => ab.tags.includes(filter))
else if (group === 'series') filtered = filtered.filter(ab => ab.book && ab.book.series === filter)
else if (group === 'series') {
if (filter === 'No Series') filtered = filtered.filter(ab => ab.book && !ab.book.series)
else filtered = filtered.filter(ab => ab.book && ab.book.series === filter)
}
else if (group === 'authors') filtered = filtered.filter(ab => ab.book && ab.book.author === filter)
else if (group === 'narrators') filtered = filtered.filter(ab => ab.book && ab.book.narrator === filter)
else if (group === 'progress') {
filtered = filtered.filter(ab => {
var userAudiobook = rootGetters['user/getUserAudiobook'](ab.id)
@@ -61,7 +67,7 @@ export const getters = {
}
}
if (state.keywordFilter) {
const keywordFilterKeys = ['title', 'subtitle', 'author', 'series', 'narrarator']
const keywordFilterKeys = ['title', 'subtitle', 'author', 'series', 'narrator']
const keyworkFilter = state.keywordFilter.toLowerCase()
return filtered.filter(ab => {
if (!ab.book) return false
@@ -75,6 +81,7 @@ export const getters = {
var direction = settings.orderDesc ? 'desc' : 'asc'
var filtered = getters.getFiltered()
var orderByNumber = settings.orderBy === 'book.volumeNumber'
return sort(filtered)[direction]((ab) => {
// Supports dot notation strings i.e. "book.title"
@@ -117,16 +124,21 @@ export const getters = {
var _authors = state.audiobooks.filter(ab => !!(ab.book && ab.book.author)).map(ab => ab.book.author)
return [...new Set(_authors)].sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
},
getUniqueNarrators: (state) => {
var _narrators = state.audiobooks.filter(ab => !!(ab.book && ab.book.narrator)).map(ab => ab.book.narrator)
return [...new Set(_narrators)].sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
},
getGenresUsed: (state) => {
var _genres = []
state.audiobooks.filter(ab => !!(ab.book && ab.book.genres)).forEach(ab => _genres = _genres.concat(ab.book.genres))
return [...new Set(_genres)].sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
},
getBookCoverSrc: (state, getters, rootState, rootGetters) => (book, placeholder = '/book_placeholder.jpg') => {
getBookCoverSrc: (state, getters, rootState, rootGetters) => (bookItem, placeholder = '/book_placeholder.jpg') => {
var book = bookItem.book
if (!book || !book.cover || book.cover === placeholder) return placeholder
var cover = book.cover
// Absolute URL covers
// Absolute URL covers (should no longer be used)
if (cover.startsWith('http:') || cover.startsWith('https:')) return cover
// Server hosted covers
@@ -135,7 +147,20 @@ export const getters = {
var bookLastUpdate = book.lastUpdate || Date.now()
var userToken = rootGetters['user/getToken']
var url = new URL(cover, document.baseURI)
cover = cover.replace(/\\/g, '/')
// Map old covers to new format /s/book/{bookid}/*
if (cover.startsWith('/local')) {
cover = cover.replace('local', `s/book/${bookItem.id}`)
if (cover.includes(bookItem.path + '/')) { // Remove book path
cover = cover.replace(bookItem.path + '/', '')
}
}
// Easier to replace these special characters then to encodeUriComponent of the filename
var encodedCover = cover.replace(/%/g, '%25').replace(/#/g, '%23')
var url = new URL(encodedCover, document.baseURI)
return url.href + `?token=${userToken}&ts=${bookLastUpdate}`
} catch (err) {
console.error(err)
@@ -152,29 +177,37 @@ export const actions = {
return false
}
// Don't load again if already loaded in the last 5 minutes
var lastLoadDiff = Date.now() - state.lastLoad
if (lastLoadDiff < 5 * 60 * 1000) {
// Already up to date
return false
var currentLibraryId = rootState.libraries.currentLibraryId
if (currentLibraryId === state.loadedLibraryId) {
// Don't load again if already loaded in the last 5 minutes
var lastLoadDiff = Date.now() - state.lastLoad
if (lastLoadDiff < 5 * 60 * 1000) {
// Already up to date
return false
}
}
commit('setLoadedLibrary', currentLibraryId)
this.$axios
.$get(`/api/audiobooks`)
.$get(`/api/library/${currentLibraryId}/audiobooks`)
.then((data) => {
commit('set', data)
commit('setLastLoad')
})
.catch((error) => {
console.error('Failed', error)
commit('set', [])
})
return true
},
}
}
export const mutations = {
setLoadedLibrary(state, val) {
state.loadedLibraryId = val
},
setLastLoad(state) {
state.lastLoad = Date.now()
},
@@ -223,6 +256,11 @@ export const mutations = {
})
},
addUpdate(state, audiobook) {
if (state.loadedLibraryId && audiobook.libraryId !== state.loadedLibraryId) {
console.warn('Invalid library', audiobook, 'loaded library', state.loadedLibraryId, '"')
return
}
var index = state.audiobooks.findIndex(a => a.id === audiobook.id)
var origAudiobook = null
if (index >= 0) {

View File

@@ -7,17 +7,18 @@ export const state = () => ({
streamAudiobook: null,
editModalTab: 'details',
showEditModal: false,
showEReader: false,
selectedAudiobook: null,
selectedAudiobookFile: null,
playOnLoad: false,
isScanning: false,
isScanningCovers: false,
scanProgress: null,
coverScanProgress: null,
developerMode: false,
selectedAudiobooks: [],
processingBatch: false,
previousPath: '/',
routeHistory: []
routeHistory: [],
showExperimentalFeatures: false,
backups: [],
bookshelfBookIds: []
})
export const getters = {
@@ -34,7 +35,7 @@ export const actions = {
}
return this.$axios.$patch('/api/serverSettings', updatePayload).then((result) => {
if (result.success) {
commit('setServerSettings', result.settings)
commit('setServerSettings', result.serverSettings)
return true
} else {
return false
@@ -67,6 +68,9 @@ export const actions = {
}
export const mutations = {
setBookshelfBookIds(state, val) {
state.bookshelfBookIds = val || []
},
setRouteHistory(state, val) {
state.routeHistory = val
},
@@ -77,6 +81,7 @@ export const mutations = {
state.versionData = versionData
},
setServerSettings(state, settings) {
if (!settings) return
state.serverSettings = settings
},
setStreamAudiobook(state, audiobook) {
@@ -112,39 +117,43 @@ export const mutations = {
setShowEditModal(state, val) {
state.showEditModal = val
},
setIsScanning(state, isScanning) {
state.isScanning = isScanning
showEReader(state, audiobook) {
state.selectedAudiobookFile = null
state.selectedAudiobook = audiobook
state.showEReader = true
},
setScanProgress(state, scanProgress) {
if (scanProgress && scanProgress.progress > 0) state.isScanning = true
state.scanProgress = scanProgress
showEReaderForFile(state, { audiobook, file }) {
state.selectedAudiobookFile = file
state.selectedAudiobook = audiobook
state.showEReader = true
},
setIsScanningCovers(state, isScanningCovers) {
state.isScanningCovers = isScanningCovers
},
setCoverScanProgress(state, coverScanProgress) {
if (coverScanProgress && coverScanProgress.progress > 0) state.isScanningCovers = true
state.coverScanProgress = coverScanProgress
setShowEReader(state, val) {
state.showEReader = val
},
setDeveloperMode(state, val) {
state.developerMode = val
},
setSelectedAudiobooks(state, audiobooks) {
Vue.set(state, 'selectedAudiobooks', audiobooks)
// state.selectedAudiobooks = audiobooks
},
toggleAudiobookSelected(state, audiobookId) {
if (state.selectedAudiobooks.includes(audiobookId)) {
state.selectedAudiobooks = state.selectedAudiobooks.filter(a => a !== audiobookId)
} else {
var newSel = state.selectedAudiobooks.concat([audiobookId])
// state.selectedAudiobooks = newSel
console.log('Setting toggle on sel', newSel)
Vue.set(state, 'selectedAudiobooks', newSel)
// state.selectedAudiobooks.push(audiobookId)
}
},
setProcessingBatch(state, val) {
state.processingBatch = val
},
setExperimentalFeatures(state, val) {
state.showExperimentalFeatures = val
localStorage.setItem('experimental', val ? 1 : 0)
},
setBackups(state, val) {
state.backups = val.sort((a, b) => b.createdAt - a.createdAt)
}
}

147
client/store/libraries.js Normal file
View File

@@ -0,0 +1,147 @@
export const state = () => ({
libraries: [],
lastLoad: 0,
listeners: [],
currentLibraryId: 'main',
showModal: false,
folders: [],
folderLastUpdate: 0
})
export const getters = {
getCurrentLibrary: state => {
return state.libraries.find(lib => lib.id === state.currentLibraryId)
},
getSortedLibraries: state => () => {
return state.libraries.map(lib => ({ ...lib })).sort((a, b) => a.displayOrder - b.displayOrder)
}
}
export const actions = {
loadFolders({ state, commit }) {
if (state.folders.length) {
var lastCheck = Date.now() - state.folderLastUpdate
if (lastCheck < 1000 * 60 * 10) { // 10 minutes
// Folders up to date
return state.folders
}
}
console.log('Loading folders')
commit('setFoldersLastUpdate')
return this.$axios
.$get('/api/filesystem')
.then((res) => {
console.log('Settings folders', res)
commit('setFolders', res)
return res
})
.catch((error) => {
console.error('Failed to load dirs', error)
commit('setFolders', [])
return []
})
},
fetch({ state, commit, rootState }, libraryId) {
if (!rootState.user || !rootState.user.user) {
console.error('libraries/fetch - User not set')
return false
}
var library = state.libraries.find(lib => lib.id === libraryId)
if (library) {
commit('setCurrentLibrary', libraryId)
return library
}
return this.$axios
.$get(`/api/library/${libraryId}`)
.then((data) => {
commit('addUpdate', data)
commit('setCurrentLibrary', libraryId)
return data
})
.catch((error) => {
console.error('Failed', error)
return false
})
},
// Return true if calling load
load({ state, commit, rootState }) {
if (!rootState.user || !rootState.user.user) {
console.error('libraries/load - User not set')
return false
}
// Don't load again if already loaded in the last 5 minutes
var lastLoadDiff = Date.now() - state.lastLoad
if (lastLoadDiff < 5 * 60 * 1000) {
// Already up to date
return false
}
this.$axios
.$get(`/api/libraries`)
.then((data) => {
commit('set', data)
commit('setLastLoad')
})
.catch((error) => {
console.error('Failed', error)
commit('set', [])
})
return true
},
}
export const mutations = {
setFolders(state, folders) {
state.folders = folders
},
setFoldersLastUpdate(state) {
state.folderLastUpdate = Date.now()
},
setShowModal(state, val) {
state.showModal = val
},
setLastLoad(state) {
state.lastLoad = Date.now()
},
setCurrentLibrary(state, val) {
state.currentLibraryId = val
},
set(state, libraries) {
state.libraries = libraries
state.listeners.forEach((listener) => {
listener.meth()
})
},
addUpdate(state, library) {
var index = state.libraries.findIndex(a => a.id === library.id)
if (index >= 0) {
state.libraries.splice(index, 1, library)
} else {
state.libraries.push(library)
}
state.listeners.forEach((listener) => {
listener.meth()
})
},
remove(state, library) {
state.libraries = state.libraries.filter(a => a.id !== library.id)
state.listeners.forEach((listener) => {
listener.meth()
})
},
addListener(state, listener) {
var index = state.listeners.findIndex(l => l.id === listener.id)
if (index >= 0) state.listeners.splice(index, 1, listener)
else state.listeners.push(listener)
},
removeListener(state, listenerId) {
state.listeners = state.listeners.filter(l => l.id !== listenerId)
}
}

31
client/store/logs.js Normal file
View File

@@ -0,0 +1,31 @@
export const state = () => ({
isListening: false,
logs: []
})
export const getters = {
}
export const actions = {
setLogListener({ state, commit, dispatch }) {
dispatch('$nuxtSocket/emit', {
label: 'main',
evt: 'set_log_listener',
msg: 0
}, { root: true })
commit('setIsListening', true)
}
}
export const mutations = {
setIsListening(state, val) {
state.isListening = val
},
logEvt(state, payload) {
state.logs.push(payload)
if (state.logs.length > 500) {
state.logs = state.logs.slice(50)
}
}
}

27
client/store/scanners.js Normal file
View File

@@ -0,0 +1,27 @@
export const state = () => ({
libraryScans: []
})
export const getters = {
getLibraryScan: state => id => {
return state.libraryScans.find(ls => ls.id === id)
}
}
export const actions = {
}
export const mutations = {
addUpdate(state, data) {
var index = state.libraryScans.findIndex(lib => lib.id === data.id)
if (index >= 0) {
state.libraryScans.splice(index, 1, data)
} else {
state.libraryScans.push(data)
}
},
remove(state, data) {
state.libraryScans = state.libraryScans.filter(scan => scan.id !== data.id)
}
}

View File

@@ -22,9 +22,6 @@ export const getters = {
getUserSetting: (state) => (key) => {
return state.settings ? state.settings[key] || null : null
},
getFilterOrderKey: (state) => {
return Object.values(state.settings).join('-')
},
getUserCanUpdate: (state) => {
return state.user && state.user.permissions ? !!state.user.permissions.update : false
},

26
client/store/users.js Normal file
View File

@@ -0,0 +1,26 @@
export const state = () => ({
users: []
})
export const getters = {
}
export const actions = {
}
export const mutations = {
updateUser(state, user) {
var index = state.users.findIndex(u => u.id === user.id)
if (index >= 0) {
state.users.splice(index, 1, user)
} else {
state.users.push(user)
}
},
removeUser(state, user) {
state.users = state.users.filter(u => u.id !== user.id)
}
}

View File

@@ -6,7 +6,8 @@ module.exports = {
safelist: [
'bg-success',
'bg-red-600',
'py-1.5'
'py-1.5',
'bg-info'
]
}
},
@@ -44,7 +45,7 @@ module.exports = {
none: 'none'
},
fontFamily: {
sans: ['Open Sans', ...defaultTheme.fontFamily.sans],
sans: ['Source Sans Pro', ...defaultTheme.fontFamily.sans],
mono: ['Ubuntu Mono', ...defaultTheme.fontFamily.mono],
book: ['Gentium Book Basic', 'serif']
}

View File

@@ -9,7 +9,7 @@
<Privileged>false</Privileged>
<Support>https://forums.unraid.net/topic/112698-support-audiobookshelf/</Support>
<Project>https://github.com/advplyr/audiobookshelf</Project>
<Overview>**(Android app in beta, try it out)** Audiobook manager and player. Saves your progress, supports multiple accounts, stream all audio formats on the fly. No more switching between dozens of audio files for a single audiobook, Audiobookshelf shows you one audio track with skipping, seeking and adjustable playback speed. Free &amp; open source mobile apps under construction, consider contributing by posting feedback, suggestions, feature requests on github or the forums.</Overview>
<Overview>**(Android app is live)** Audiobook manager and player. Saves your progress, supports multiple accounts, stream all audio formats on the fly. No more switching between dozens of audio files for a single audiobook, Audiobookshelf shows you one audio track with skipping, seeking and adjustable playback speed. Free &amp; open source mobile apps under construction, consider contributing by posting feedback, suggestions, feature requests on github or the forums.</Overview>
<Category>MediaApp:Books MediaServer:Books</Category>
<WebUI>http://[IP]:[PORT:80]</WebUI>
<TemplateURL>https://raw.githubusercontent.com/advplyr/docker-templates/master/audiobookshelf.xml</TemplateURL>
@@ -48,7 +48,18 @@
<Mode>rw</Mode>
</Volume>
</Data>
<Environment/>
<Environment>
<Variable>
<Value>99</Value>
<Name>AUDIOBOOKSHELF_UID</Name>
<Mode/>
</Variable>
<Variable>
<Value>100</Value>
<Name>AUDIOBOOKSHELF_GID</Name>
<Mode/>
</Variable>
</Environment>
<Labels/>
<Config Name="Audiobooks" Target="/audiobooks" Default="" Mode="rw" Description="Container Path: /audiobooks" Type="Path" Display="always" Required="true" Mask="false" />
<Config Name="Config" Target="/config" Default="/mnt/user/appdata/audiobookshelf/config/" Mode="rw" Description="Container Path: /config" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/audiobookshelf/config/</Config>

BIN
images/LibraryStream.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 936 KiB

1
images/banner.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 6702.73 1277.37"><defs><style>.cls-1{fill:#fff;}.cls-2{fill:url(#linear-gradient);}.cls-3{font-size:800px;fill:#c9c9c9;font-family:GentiumBookBasic, Gentium Book Basic;}.cls-4{font-size:420px;fill:#474747;font-family:GentiumBasic, Gentium Basic;}</style><linearGradient id="linear-gradient" x1="617.37" y1="20.7" x2="617.37" y2="1216.56" gradientUnits="userSpaceOnUse"><stop offset="0.32" stop-color="#cd9d49"/><stop offset="0.99" stop-color="#875d27"/></linearGradient></defs><title>bgAsset 6</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_2-2" data-name="Layer 2"><g id="Layer_4" data-name="Layer 4"><g id="Layer_5" data-name="Layer 5"><circle class="cls-1" cx="618.63" cy="618.63" r="618.63"/></g><circle class="cls-2" cx="617.37" cy="618.63" r="597.93"/></g><path class="cls-1" d="M1005.57,574.08c-4.84-4-12.37-10-22.58-17v-79.2c0-201.93-163.69-365.63-365.62-365.63h0c-201.93,0-365.63,163.7-365.63,365.63v79.2c-10.21,7-17.74,13-22.58,17A18.15,18.15,0,0,0,222.63,588v94.89a18.15,18.15,0,0,0,6.53,14c11.29,9.4,37.19,29.1,77.52,49.31v9.22c0,24.88,16,45,35.84,45h0c19.79,0,35.84-20.16,35.84-45V527.83c0-24.87-16.05-45-35.84-45h0c-19,0-34.48,18.51-35.75,41.94l-.09,0v-46.9c0-171.59,139.1-310.69,310.69-310.69h0c171.58,0,310.68,139.1,310.68,310.69v46.9l-.08,0c-1.27-23.43-16.79-41.94-35.76-41.94h0c-19.79,0-35.83,20.17-35.83,45V755.4c0,24.88,16,45,35.83,45h0c19.8,0,35.84-20.16,35.84-45v-9.22c40.33-20.21,66.24-39.91,77.52-49.31a18.15,18.15,0,0,0,6.53-14V588A18.15,18.15,0,0,0,1005.57,574.08Z"/><path class="cls-1" d="M489.87,969.71a43.31,43.31,0,0,0,43.3-43.3V441.64a43.3,43.3,0,0,0-43.3-43.29H445.15a43.3,43.3,0,0,0-43.3,43.29V926.41a43.31,43.31,0,0,0,43.3,43.3Zm-71.69-455.1h98.67v10.31H418.18Z"/><path class="cls-1" d="M639.73,969.71A43.3,43.3,0,0,0,683,926.41V441.64a43.29,43.29,0,0,0-43.29-43.29H595a43.29,43.29,0,0,0-43.29,43.29V926.41A43.3,43.3,0,0,0,595,969.71ZM568,514.61H666.7v10.31H568Z"/><path class="cls-1" d="M789.59,969.71a43.3,43.3,0,0,0,43.29-43.3V441.64a43.29,43.29,0,0,0-43.29-43.29H744.86a43.3,43.3,0,0,0-43.3,43.29V926.41a43.31,43.31,0,0,0,43.3,43.3Zm-71.7-455.1h98.67v10.31H717.89Z"/><rect class="cls-1" x="294.5" y="984.69" width="645.74" height="65.25" rx="32.63"/></g><g id="Layer_6" data-name="Layer 6"><text class="cls-3" transform="translate(1492.27 670.42)">audiobookshelf</text><text class="cls-4" transform="translate(1492.27 1128.69)">self-hosted audiobook server</text></g></g></svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

View File

@@ -18,8 +18,10 @@ const PORT = process.env.PORT || 80
const CONFIG_PATH = process.env.CONFIG_PATH || '/config'
const AUDIOBOOK_PATH = process.env.AUDIOBOOK_PATH || '/audiobooks'
const METADATA_PATH = process.env.METADATA_PATH || '/metadata'
const UID = process.env.AUDIOBOOKSHELF_UID || 99
const GID = process.env.AUDIOBOOKSHELF_GID || 100
console.log('Config', CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH)
const Server = new server(PORT, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH)
const Server = new server(PORT, UID, GID, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH)
Server.start()

106
package-lock.json generated
View File

@@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
"version": "1.2.4",
"version": "1.4.3",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@@ -411,15 +411,6 @@
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz",
"integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg=="
},
"cookie-parser": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.5.tgz",
"integrity": "sha512-f13bPUj/gG/5mDr+xLmSxxDsB9DQiTIfhJS/sqjrmfAWiAN+x2O4i/XguTL9yDZ+/IFDanJ+5x7hC4CXT9Tdzw==",
"requires": {
"cookie": "0.4.0",
"cookie-signature": "1.0.6"
}
},
"cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
@@ -457,6 +448,11 @@
"readable-stream": "^3.4.0"
}
},
"date-and-time": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/date-and-time/-/date-and-time-2.0.1.tgz",
"integrity": "sha512-O7Xe5dLaqvY/aF/MFWArsAM1J4j7w1CSZlPCX9uHgmb+6SbkPd8Q4YOvfvH/cZGvFlJFfHOZKxQtmMUOoZhc/w=="
},
"debounce": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz",
@@ -594,6 +590,16 @@
"base64-arraybuffer": "0.1.4"
}
},
"epub": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/epub/-/epub-1.2.1.tgz",
"integrity": "sha512-2GDDr2qcH3dvwX1lgwCQ3gki0CwwrxELLI005SauhT2TacJUiDqZrQuGuOSWEYIHX6ox5kXHpn1ZjsHqkNCb+g==",
"requires": {
"adm-zip": "^0.4.11",
"xml2js": "^0.4.23",
"zipfile": "^0.5.11"
}
},
"escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@@ -654,6 +660,16 @@
"busboy": "^0.3.1"
}
},
"express-rate-limit": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-5.3.0.tgz",
"integrity": "sha512-qJhfEgCnmteSeZAeuOKQ2WEIFTX5ajrzE0xS6gCOBCoRQcU+xEzQmgYQQTpzCcqUAAzTEtu4YEih4pnLfvNtew=="
},
"file-type": {
"version": "10.11.0",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-10.11.0.tgz",
"integrity": "sha512-uzk64HRpUZyTGZtVuvrjP0FYxzQrBf4rojot6J65YMEbwBLB0CWm0CLojVpwpmFmxcE/lkvYICgfcGozbBq6rw=="
},
"finalhandler": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz",
@@ -844,6 +860,14 @@
"minimatch": "^3.0.4"
}
},
"image-type": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/image-type/-/image-type-4.1.0.tgz",
"integrity": "sha512-CFJMJ8QK8lJvRlTCEgarL4ro6hfDQKif2HjSvYCdQZESaIPV4v9imrf7BQHK+sQeTeNeMpWciR9hyC/g8ybXEg==",
"requires": {
"file-type": "^10.10.0"
}
},
"inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@@ -1140,6 +1164,19 @@
"minimist": "^1.2.5"
}
},
"moment": {
"version": "2.29.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz",
"integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ=="
},
"moment-timezone": {
"version": "0.5.33",
"resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.33.tgz",
"integrity": "sha512-PTc2vcT8K9J5/9rDEPe5czSIKgLoGsH8UNpA4qZTVw0Vd/Uz19geE9abbIOQKaAQFcnQ3v5YEXrbSc5BpshH+w==",
"requires": {
"moment": ">= 2.9.0"
}
},
"ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@@ -1192,6 +1229,14 @@
"proper-lockfile": "^4.1.2"
}
},
"node-cron": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.0.tgz",
"integrity": "sha512-DDwIvvuCwrNiaU7HEivFDULcaQualDv7KoNlB/UU1wPW0n1tDEmBJKhEIE6DlF2FuoOHcNbLJ8ITL2Iv/3AWmA==",
"requires": {
"moment-timezone": "^0.5.31"
}
},
"node-dir": {
"version": "0.1.17",
"resolved": "https://registry.npmjs.org/node-dir/-/node-dir-0.1.17.tgz",
@@ -1218,6 +1263,11 @@
"tar": "^4"
}
},
"node-stream-zip": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/node-stream-zip/-/node-stream-zip-1.15.0.tgz",
"integrity": "sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw=="
},
"nopt": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-4.0.3.tgz",
@@ -1330,6 +1380,16 @@
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
"integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="
},
"p-finally": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
"integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4="
},
"p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="
},
"parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -1345,6 +1405,11 @@
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
"integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w="
},
"pify": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz",
"integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g=="
},
"podcast": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/podcast/-/podcast-1.3.0.tgz",
@@ -1434,6 +1499,15 @@
"strip-json-comments": "~2.0.1"
}
},
"read-chunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/read-chunk/-/read-chunk-3.1.0.tgz",
"integrity": "sha512-ZdiZJXXoZYE08SzZvTipHhI+ZW0FpzxmFtLI3vIeMuRN9ySbIZ+SZawKogqJ7dxW9fJ/W73BNtxu4Zu/bZp+Ng==",
"requires": {
"pify": "^4.0.1",
"with-open-file": "^0.1.5"
}
},
"readable-stream": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
@@ -1812,6 +1886,16 @@
"string-width": "^1.0.2 || 2"
}
},
"with-open-file": {
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/with-open-file/-/with-open-file-0.1.7.tgz",
"integrity": "sha512-ecJS2/oHtESJ1t3ZfMI3B7KIDKyfN0O16miWxdn30zdh66Yd3LsRFebXZXq6GU4xfxLf6nVxp9kIqElb5fqczA==",
"requires": {
"p-finally": "^1.0.0",
"p-try": "^2.1.0",
"pify": "^4.0.1"
}
},
"wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
@@ -1868,4 +1952,4 @@
}
}
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
"version": "1.2.7",
"version": "1.4.9",
"description": "Self-hosted audiobook server for managing and playing audiobooks",
"main": "index.js",
"scripts": {
@@ -8,7 +8,7 @@
"start": "node index.js",
"client": "cd client && npm install && npm run generate",
"prod": "npm run client && npm install && node prod.js",
"build-win": "npm run build-prep && pkg -t node12-win-x64 -o ./dist/win/audiobookshelf .",
"build-win": "pkg -t node12-win-x64 -o ./dist/win/audiobookshelf .",
"build-linux": "build/linuxpackager"
},
"bin": "prod.js",
@@ -26,17 +26,23 @@
"axios": "^0.21.1",
"bcryptjs": "^2.4.3",
"command-line-args": "^5.2.0",
"cookie-parser": "^1.4.5",
"date-and-time": "^2.0.1",
"epub": "^1.2.1",
"express": "^4.17.1",
"express-fileupload": "^1.2.1",
"express-rate-limit": "^5.3.0",
"fluent-ffmpeg": "^2.1.2",
"fs-extra": "^10.0.0",
"image-type": "^4.1.0",
"ip": "^1.1.5",
"jsonwebtoken": "^8.5.1",
"libgen": "^2.1.0",
"njodb": "^0.4.20",
"node-cron": "^3.0.0",
"node-dir": "^0.1.17",
"node-stream-zip": "^1.15.0",
"podcast": "^1.3.0",
"read-chunk": "^3.1.0",
"socket.io": "^4.1.3",
"watcher": "^1.2.0"
},

View File

@@ -24,8 +24,10 @@ const PORT = options.port || process.env.PORT || 3333
const CONFIG_PATH = inputConfig || process.env.CONFIG_PATH || Path.resolve('config')
const AUDIOBOOK_PATH = inputAudiobook || process.env.AUDIOBOOK_PATH || Path.resolve('audiobooks')
const METADATA_PATH = inputMetadata || process.env.METADATA_PATH || Path.resolve('metadata')
const UID = 99
const GID = 100
console.log(process.env.NODE_ENV, 'Config', CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH)
const Server = new server(PORT, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH)
const Server = new server(PORT, UID, GID, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH)
Server.start()

103
readme.md
View File

@@ -1,75 +1,50 @@
# AudioBookshelf
<br />
<div align="center">
<img alt="Audiobookshelf Banner" src="https://github.com/advplyr/audiobookshelf/raw/master/images/banner.svg" width="600">
AudioBookshelf is a self-hosted audiobook server for managing and playing your audiobooks.
<p align="center">
<br />
<a href="https://audiobookshelf.org/docs">Documentation</a>
·
<a href="https://audiobookshelf.org/install">Install Guides</a>
·
<a href="https://audiobookshelf.org/showcase">Showcase</a>
</p>
</div>
See [Install guides](https://audiobookshelf.org/install) and [documentation](https://audiobookshelf.org/docs)
## About
Audiobookshelf is a self-hosted audiobook server for managing and playing your audiobooks.
### Features
* Fully **open-source**, including the [android app](https://github.com/advplyr/audiobookshelf-app) *(in beta)*
* Stream all audiobook formats on the fly
* Multi-user support w/ custom permissions
* Keeps progress per user and syncs across devices
* Auto-detects library updates, no need to re-scan
* Upload full audiobooks and covers
* Backup your metadata + automated daily backups
Is there a feature you are looking for? [Suggest it](https://github.com/advplyr/audiobookshelf/issues/new)
Android app is in beta, try it out on the [Google Play Store](https://play.google.com/store/apps/details?id=com.audiobookshelf.app)
**Free & open source Android/iOS app is in development**
<img alt="Library Screenshot" src="https://github.com/advplyr/audiobookshelf/raw/master/images/LibraryStream.png" />
<img alt="Screenshot1" src="https://github.com/advplyr/audiobookshelf/raw/master/images/ss_streaming.png" />
## Organizing your audiobooks
#### Directory structure and folder names are critical to AudioBookshelf!
See [documentation](https://audiobookshelf.org/docs) for supported directory structure, folder naming conventions, and audio file metadata usage.
## Directory Structure
See [documentation](https://audiobookshelf.org/docs) for directory structure and naming.
Author, Series, Volume Number, Title and Publish Year can all be parsed from your folder structure.
**Note**: Files in the root directory `/audiobooks` will be ignored, all audiobooks should be in a directory
**1 Folder:** `/Title/...`\
**2 Folders:** `/Author/Title/...`\
**3 Folders:** `/Author/Series/Title/...`
### Parsing publish year
`/1984 - Hackers/...`\
Will save the publish year as `1984` and the title as `Hackers`
### Parsing volume number (only for series)
`/Book 3 - Hackers/...`\
Will save the volume number as `3` and the title as `Hackers`
`Book` `Volume` `Vol` `Vol.` are all supported case insensitive
These combinations will also work:\
`/Hackers - Vol. 3/...`\
`/1984 - Volume 3 - Hackers/...`\
`/1984 - Hackers Book 3/...`
### Parsing subtitles (optional in settings)
Title Folder: `/Hackers - Heroes of the Computer Revolution/...`
Will save the title as `Hackers` and the subtitle as `Heroes of the Computer Revolution`
### Full example
`/Steven Levy/The Hacker Series/1984 - Hackers - Heroes of the Computer Revolution - Vol. 1/...`
**Becomes:**
| Key | Value |
|---------------|-----------------------------------|
| Author | Steven Levy |
| Series | The Hacker Series |
| Publish Year | 1984 |
| Title | Hackers |
| Subtitle | Heroes of the Computer Revolution |
| Volume Number | 1 |
## Features coming soon
* Support different views to see more details of each audiobook
* iOS App (Android is in beta [here](https://play.google.com/store/apps/details?id=com.audiobookshelf.app))
## Installation
** Default username is "root" with no password
### Docker Install
Available in Unraid Community Apps
@@ -115,14 +90,10 @@ curl -s --compressed "https://advplyr.github.io/audiobookshelf-ppa/KEY.gpg" | su
Get the `deb` file from the [github repo](https://github.com/advplyr/audiobookshelf-ppa).
```bash
wget https://advplyr.github.io/audiobookshelf-ppa/audiobookshelf_1.2.3_amd64.deb
sudo apt install ./audiobookshelf_1.2.3_amd64.deb
```
See [instructions](https://www.audiobookshelf.org/install#debian)
#### File locations
#### Linux file locations
Project directory: `/usr/share/audiobookshelf/`

View File

@@ -3,17 +3,20 @@ const Path = require('path')
const fs = require('fs-extra')
const Logger = require('./Logger')
const User = require('./objects/User')
const { isObject, isAcceptableCoverMimeType } = require('./utils/index')
const { CoverDestination } = require('./utils/constants')
const { isObject } = require('./utils/index')
const Library = require('./objects/Library')
class ApiController {
constructor(MetadataPath, db, scanner, auth, streamManager, rssFeeds, downloadManager, emitter, clientEmitter) {
constructor(MetadataPath, db, scanner, auth, streamManager, rssFeeds, downloadManager, coverController, backupManager, watcher, emitter, clientEmitter) {
this.db = db
this.scanner = scanner
this.auth = auth
this.streamManager = streamManager
this.rssFeeds = rssFeeds
this.downloadManager = downloadManager
this.coverController = coverController
this.backupManager = backupManager
this.watcher = watcher
this.emitter = emitter
this.clientEmitter = clientEmitter
this.MetadataPath = MetadataPath
@@ -26,7 +29,16 @@ class ApiController {
this.router.get('/find/covers', this.findCovers.bind(this))
this.router.get('/find/:method', this.find.bind(this))
this.router.get('/audiobooks', this.getAudiobooks.bind(this))
this.router.get('/libraries', this.getLibraries.bind(this))
this.router.patch('/libraries/order', this.reorderLibraries.bind(this))
this.router.get('/library/:id/search', this.searchLibrary.bind(this))
this.router.get('/library/:id', this.getLibrary.bind(this))
this.router.delete('/library/:id', this.deleteLibrary.bind(this))
this.router.patch('/library/:id', this.updateLibrary.bind(this))
this.router.get('/library/:id/audiobooks', this.getLibraryAudiobooks.bind(this))
this.router.post('/library', this.createNewLibrary.bind(this))
this.router.get('/audiobooks', this.getAudiobooks.bind(this)) // Old route should pass library id
this.router.delete('/audiobooks', this.deleteAllAudiobooks.bind(this))
this.router.post('/audiobooks/delete', this.batchDeleteAudiobooks.bind(this))
this.router.post('/audiobooks/update', this.batchUpdateAudiobooks.bind(this))
@@ -35,9 +47,9 @@ class ApiController {
this.router.delete('/audiobook/:id', this.deleteAudiobook.bind(this))
this.router.patch('/audiobook/:id/tracks', this.updateAudiobookTracks.bind(this))
this.router.post('/audiobook/:id/cover', this.uploadAudiobookCover.bind(this))
this.router.patch('/audiobook/:id/coverfile', this.updateAudiobookCoverFromFile.bind(this))
this.router.patch('/audiobook/:id', this.updateAudiobook.bind(this))
this.router.get('/metadata/:id/:trackIndex', this.getMetadata.bind(this))
this.router.patch('/match/:id', this.match.bind(this))
this.router.delete('/user/audiobook/:id', this.resetUserAudiobookProgress.bind(this))
@@ -53,6 +65,9 @@ class ApiController {
this.router.patch('/serverSettings', this.updateServerSettings.bind(this))
this.router.delete('/backup/:id', this.deleteBackup.bind(this))
this.router.post('/backup/upload', this.uploadBackup.bind(this))
this.router.post('/authorize', this.authorize.bind(this))
this.router.get('/genres', this.getGenres.bind(this))
@@ -60,6 +75,8 @@ class ApiController {
this.router.post('/feed', this.openRssFeed.bind(this))
this.router.get('/download/:id', this.download.bind(this))
this.router.get('/filesystem', this.getFileSystemPaths.bind(this))
}
find(req, res) {
@@ -70,11 +87,6 @@ class ApiController {
this.scanner.findCovers(req, res)
}
async getMetadata(req, res) {
var metadata = await this.scanner.fetchMetadata(req.params.id, req.params.trackIndex)
res.json(metadata)
}
authorize(req, res) {
if (!req.user) {
Logger.error('Invalid user in authorize')
@@ -83,6 +95,180 @@ class ApiController {
res.json({ user: req.user })
}
getLibraries(req, res) {
var libraries = this.db.libraries.map(lib => lib.toJSON())
res.json(libraries)
}
async reorderLibraries(req, res) {
if (!req.user || !req.user.isRoot) {
Logger.error('[ApiController] ReorderLibraries invalid user', req.user)
return res.sendStatus(401)
}
var orderdata = req.body
var hasUpdates = false
for (let i = 0; i < orderdata.length; i++) {
var library = this.db.libraries.find(lib => lib.id === orderdata[i].id)
if (!library) {
Logger.error(`[ApiController] Invalid library not found in reorder ${orderdata[i].id}`)
return res.sendStatus(500)
}
if (library.update({ displayOrder: orderdata[i].newOrder })) {
hasUpdates = true
await this.db.updateEntity('library', library)
}
}
if (hasUpdates) {
Logger.info(`[ApiController] Updated library display orders`)
} else {
Logger.info(`[ApiController] Library orders were up to date`)
}
var libraries = this.db.libraries.map(lib => lib.toJSON())
res.json(libraries)
}
searchLibrary(req, res) {
var library = this.db.libraries.find(lib => lib.id === req.params.id)
if (!library) {
return res.status(404).send('Library not found')
}
if (!req.query.q) {
return res.status(400).send('No query string')
}
var maxResults = req.query.max || 3
var bookMatches = []
var authorMatches = {}
var seriesMatches = {}
var audiobooksInLibrary = this.db.audiobooks.filter(ab => ab.libraryId === library.id)
audiobooksInLibrary.forEach((ab) => {
var queryResult = ab.searchQuery(req.query.q)
if (queryResult.book) {
bookMatches.push({
audiobook: ab,
matchKey: queryResult.book
})
}
if (queryResult.author && !authorMatches[queryResult.author]) {
authorMatches[queryResult.author] = {
author: queryResult.author
}
}
if (queryResult.series) {
if (!seriesMatches[queryResult.series]) {
seriesMatches[queryResult.series] = {
series: queryResult.series,
audiobooks: [ab]
}
} else {
seriesMatches[queryResult.series].audiobooks.push(ab)
}
}
})
res.json({
audiobooks: bookMatches.slice(0, maxResults),
authors: Object.values(authorMatches).slice(0, maxResults),
series: Object.values(seriesMatches).slice(0, maxResults)
})
}
getLibrary(req, res) {
var library = this.db.libraries.find(lib => lib.id === req.params.id)
if (!library) {
return res.status(404).send('Library not found')
}
return res.json(library.toJSON())
}
async deleteLibrary(req, res) {
var library = this.db.libraries.find(lib => lib.id === req.params.id)
if (!library) {
return res.status(404).send('Library not found')
}
// Remove library watcher
this.watcher.removeLibrary(library)
// Remove audiobooks in this library
var audiobooks = this.db.audiobooks.filter(ab => ab.libraryId === library.id)
Logger.info(`[Server] deleting library "${library.name}" with ${audiobooks.length} audiobooks"`)
for (let i = 0; i < audiobooks.length; i++) {
await this.handleDeleteAudiobook(audiobooks[i])
}
var libraryJson = library.toJSON()
await this.db.removeEntity('library', library.id)
this.emitter('library_removed', libraryJson)
return res.json(libraryJson)
}
async updateLibrary(req, res) {
var library = this.db.libraries.find(lib => lib.id === req.params.id)
if (!library) {
return res.status(404).send('Library not found')
}
var hasUpdates = library.update(req.body)
if (hasUpdates) {
// Update watcher
this.watcher.updateLibrary(library)
// Remove audiobooks no longer in library
var audiobooksToRemove = this.db.audiobooks.filter(ab => !library.checkFullPathInLibrary(ab.fullPath))
if (audiobooksToRemove.length) {
Logger.info(`[Scanner] Updating library, removing ${audiobooksToRemove.length} audiobooks`)
for (let i = 0; i < audiobooksToRemove.length; i++) {
await this.handleDeleteAudiobook(audiobooksToRemove[i])
}
}
await this.db.updateEntity('library', library)
this.emitter('library_updated', library.toJSON())
}
return res.json(library.toJSON())
}
getLibraryAudiobooks(req, res) {
var libraryId = req.params.id
var library = this.db.libraries.find(lib => lib.id === libraryId)
if (!library) {
return res.status(400).send('Library does not exist')
}
var audiobooks = []
if (req.query.q) {
audiobooks = this.db.audiobooks.filter(ab => {
return ab.libraryId === libraryId && ab.isSearchMatch(req.query.q)
}).map(ab => ab.toJSONMinified())
} else {
audiobooks = this.db.audiobooks.filter(ab => ab.libraryId === libraryId).map(ab => ab.toJSONMinified())
}
res.json(audiobooks)
}
async createNewLibrary(req, res) {
var newLibraryPayload = {
...req.body
}
if (!newLibraryPayload.name || !newLibraryPayload.folders || !newLibraryPayload.folders.length) {
return res.status(500).send('Invalid request')
}
var library = new Library()
newLibraryPayload.displayOrder = this.db.libraries.length + 1
library.setData(newLibraryPayload)
await this.db.insertEntity('library', library)
this.emitter('library_added', library.toJSON())
// Add library watcher
this.watcher.addLibrary(library)
res.json(library)
}
getAudiobooks(req, res) {
var audiobooks = []
if (req.query.q) {
@@ -227,80 +413,59 @@ class ApiController {
Logger.warn('User attempted to upload a cover without permission', req.user)
return res.sendStatus(403)
}
if (!req.files || !req.files.cover) {
return res.status(400).send('No files were uploaded')
}
var audiobookId = req.params.id
var audiobook = this.db.audiobooks.find(ab => ab.id === audiobookId)
if (!audiobook) {
return res.status(404).send('Audiobook not found')
}
var coverFile = req.files.cover
var mimeType = coverFile.mimetype
var extname = Path.extname(coverFile.name.toLowerCase()) || '.jpg'
if (!isAcceptableCoverMimeType(mimeType)) {
return res.status(400).send('Invalid image file type: ' + mimeType)
}
var coverDestination = this.db.serverSettings ? this.db.serverSettings.coverDestination : CoverDestination.METADATA
Logger.info(`[ApiController] Cover Upload destination ${coverDestination}`)
var coverDirpath = audiobook.fullPath
var coverRelDirpath = Path.join('/local', audiobook.path)
if (coverDestination === CoverDestination.METADATA) {
coverDirpath = Path.join(this.MetadataPath, 'books', audiobookId)
coverRelDirpath = Path.join('/metadata', 'books', audiobookId)
Logger.debug(`[ApiController] storing in metadata | ${coverDirpath}`)
await fs.ensureDir(coverDirpath)
var result = null
if (req.body && req.body.url) {
Logger.debug(`[ApiController] Requesting download cover from url "${req.body.url}"`)
result = await this.coverController.downloadCoverFromUrl(audiobook, req.body.url)
} else if (req.files && req.files.cover) {
Logger.debug(`[ApiController] Handling uploaded cover`)
var coverFile = req.files.cover
result = await this.coverController.uploadCover(audiobook, coverFile)
} else {
Logger.debug(`[ApiController] storing in audiobook | ${coverRelDirpath}`)
return res.status(400).send('Invalid request no file or url')
}
var coverFilename = `cover${extname}`
var coverFullPath = Path.join(coverDirpath, coverFilename)
var coverPath = Path.join(coverRelDirpath, coverFilename)
// If current cover is a metadata cover and does not match replacement, then remove it
var currentBookCover = audiobook.book.cover
if (currentBookCover && currentBookCover.startsWith(Path.sep + 'metadata')) {
Logger.debug(`Current Book Cover is metadata ${currentBookCover}`)
if (currentBookCover !== coverPath) {
Logger.info(`[ApiController] removing old metadata cover "${currentBookCover}"`)
var oldFullBookCoverPath = Path.join(this.MetadataPath, currentBookCover.replace(Path.sep + 'metadata', ''))
// Metadata path may have changed, check if exists first
var exists = await fs.pathExists(oldFullBookCoverPath)
if (exists) {
try {
await fs.remove(oldFullBookCoverPath)
} catch (error) {
Logger.error(`[ApiController] Failed to remove old metadata book cover ${oldFullBookCoverPath}`)
}
}
}
if (result && result.error) {
return res.status(400).send(result.error)
} else if (!result || !result.cover) {
return res.status(500).send('Unknown error occurred')
}
var success = await coverFile.mv(coverFullPath).then(() => true).catch((error) => {
Logger.error('Failed to move cover file', path, error)
return false
})
if (!success) {
return res.status(500).send('Failed to move cover into destination')
}
Logger.info(`[ApiController] Uploaded audiobook cover "${coverPath}" for "${audiobook.title}"`)
audiobook.updateBookCover(coverPath)
await this.db.updateAudiobook(audiobook)
this.emitter('audiobook_updated', audiobook.toJSONMinified())
res.json({
success: true,
cover: coverPath
cover: result.cover
})
}
async updateAudiobookCoverFromFile(req, res) {
if (!req.user.canUpdate) {
Logger.warn('User attempted to update without permission', req.user)
return res.sendStatus(403)
}
var audiobook = this.db.audiobooks.find(a => a.id === req.params.id)
if (!audiobook) return res.sendStatus(404)
var coverFile = req.body
var updated = await audiobook.setCoverFromFile(coverFile)
if (updated) {
await this.db.updateAudiobook(audiobook)
this.emitter('audiobook_updated', audiobook.toJSONMinified())
}
if (updated) res.status(200).send('Cover updated successfully')
else res.status(200).send('No update was made to cover')
}
async updateAudiobook(req, res) {
if (!req.user.canUpdate) {
Logger.warn('User attempted to update without permission', req.user)
@@ -417,7 +582,7 @@ class ApiController {
account.token = await this.auth.generateAccessToken({ userId: account.id })
account.createdAt = Date.now()
var newUser = new User(account)
var success = await this.db.insertUser(newUser)
var success = await this.db.insertEntity('user', newUser)
if (success) {
this.clientEmitter(req.user.id, 'user_added', newUser)
res.json({
@@ -497,10 +662,16 @@ class ApiController {
}
var settingsUpdate = req.body
if (!settingsUpdate || !isObject(settingsUpdate)) {
return res.sendStatus(500)
return res.status(500).send('Invalid settings update object')
}
var madeUpdates = this.db.serverSettings.update(settingsUpdate)
if (madeUpdates) {
// If backup schedule is updated - update backup manager
if (settingsUpdate.backupSchedule !== undefined) {
this.backupManager.updateCronSchedule()
}
await this.db.updateEntity('settings', this.db.serverSettings)
}
return res.json({
@@ -509,6 +680,31 @@ class ApiController {
})
}
async deleteBackup(req, res) {
if (!req.user.isRoot) {
Logger.error(`[ApiController] Non-Root user attempting to delete backup`, req.user)
return res.sendStatus(403)
}
var backup = this.backupManager.backups.find(b => b.id === req.params.id)
if (!backup) {
return res.sendStatus(404)
}
await this.backupManager.removeBackup(backup)
res.json(this.backupManager.backups.map(b => b.toJSON()))
}
async uploadBackup(req, res) {
if (!req.user.isRoot) {
Logger.error(`[ApiController] Non-Root user attempting to upload backup`, req.user)
return res.sendStatus(403)
}
if (!req.files.file) {
Logger.error('[ApiController] Upload backup invalid')
return res.sendStatus(500)
}
this.backupManager.uploadBackup(req, res)
}
async download(req, res) {
if (!req.user.canDownload) {
Logger.error('User attempting to download without permission', req.user)
@@ -539,5 +735,53 @@ class ApiController {
genres: this.db.getGenres()
})
}
async getDirectories(dir, relpath, excludedDirs, level = 0) {
try {
var paths = await fs.readdir(dir)
var dirs = await Promise.all(paths.map(async dirname => {
var fullPath = Path.join(dir, dirname)
var path = Path.join(relpath, dirname)
var isDir = (await fs.lstat(fullPath)).isDirectory()
if (isDir && !excludedDirs.includes(path) && dirname !== 'node_modules') {
return {
path,
dirname,
fullPath,
level,
dirs: level < 4 ? (await this.getDirectories(fullPath, path, excludedDirs, level + 1)) : []
}
} else {
return false
}
}))
dirs = dirs.filter(d => d)
return dirs
} catch (error) {
Logger.error('Failed to readdir', dir, error)
return []
}
}
async getFileSystemPaths(req, res) {
var excludedDirs = ['node_modules', 'client', 'server', '.git', 'static', 'build', 'dist', 'metadata', 'config', 'sys', 'proc'].map(dirname => {
return Path.sep + dirname
})
// Do not include existing mapped library paths in response
this.db.libraries.forEach(lib => {
lib.folders.forEach((folder) => {
var dir = folder.fullPath
if (dir.includes(global.appRoot)) dir = dir.replace(global.appRoot, '')
excludedDirs.push(dir)
})
})
Logger.debug(`[Server] get file system paths, excluded: ${excludedDirs.join(', ')}`)
var dirs = await this.getDirectories(global.appRoot, '/', excludedDirs)
res.json(dirs)
}
}
module.exports = ApiController

View File

@@ -103,18 +103,18 @@ class Auth {
var user = this.users.find(u => u.username === username)
if (!user) {
return res.json({ error: 'User not found' })
}
if (!user.isActive) {
return res.json({ error: 'User unavailable' })
if (!user || !user.isActive) {
Logger.debug(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit}`)
if (req.rateLimit.remaining <= 2) {
return res.status(401).send(`Invalid user or password (${req.rateLimit.remaining === 0 ? '1 attempt remaining' : `${req.rateLimit.remaining + 1} attempts remaining`})`)
}
return res.status(401).send('Invalid user or password')
}
// Check passwordless root user
if (user.id === 'root' && (!user.pash || user.pash === '')) {
if (password) {
return res.json({ error: 'Invalid root password (hint: there is none)' })
return res.status(401).send('Invalid root password (hint: there is none)')
} else {
return res.json({ user: user.toJSONForBrowser() })
}
@@ -127,12 +127,24 @@ class Auth {
user: user.toJSONForBrowser()
})
} else {
res.json({
error: 'Invalid Password'
})
Logger.debug(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit}`)
if (req.rateLimit.remaining <= 2) {
Logger.error(`[Auth] Failed login attempt for user ${user.username}. Attempts: ${req.rateLimit.current}`)
return res.status(401).send(`Invalid user or password (${req.rateLimit.remaining === 0 ? '1 attempt remaining' : `${req.rateLimit.remaining + 1} attempts remaining`})`)
}
return res.status(401).send('Invalid user or password')
}
}
// Not in use now
lockUser(user) {
user.isLocked = true
return this.db.updateEntity('user', user).catch((error) => {
Logger.error('[Auth] Failed to lock user', user.username, error)
return false
})
}
comparePassword(password, user) {
if (user.type === 'root' && !password && !user.pash) return true
if (!password || !user.pash) return false

330
server/BackupManager.js Normal file
View File

@@ -0,0 +1,330 @@
const Path = require('path')
const cron = require('node-cron')
const fs = require('fs-extra')
const archiver = require('archiver')
const StreamZip = require('node-stream-zip')
// Utils
const { getFileSize } = require('./utils/fileUtils')
const filePerms = require('./utils/filePerms')
const Logger = require('./Logger')
const Backup = require('./objects/Backup')
class BackupManager {
constructor(MetadataPath, Uid, Gid, db) {
this.MetadataPath = MetadataPath
this.BackupPath = Path.join(this.MetadataPath, 'backups')
this.Uid = Uid
this.Gid = Gid
this.db = db
this.scheduleTask = null
this.backups = []
// If backup exceeds this value it will be aborted
this.MaxBytesBeforeAbort = 1000000000 // ~ 1GB
}
get serverSettings() {
return this.db.serverSettings || {}
}
async init() {
var backupsDirExists = await fs.pathExists(this.BackupPath)
if (!backupsDirExists) {
await fs.ensureDir(this.BackupPath)
await filePerms(this.BackupPath, 0o774, this.Uid, this.Gid)
}
await this.loadBackups()
this.scheduleCron()
}
scheduleCron() {
if (!this.serverSettings.backupSchedule) {
Logger.info(`[BackupManager] Auto Backups are disabled`)
return
}
try {
var cronSchedule = this.serverSettings.backupSchedule
this.scheduleTask = cron.schedule(cronSchedule, this.runBackup.bind(this))
} catch (error) {
Logger.error(`[BackupManager] Failed to schedule backup cron ${this.serverSettings.backupSchedule}`, error)
}
}
updateCronSchedule() {
if (this.scheduleTask && !this.serverSettings.backupSchedule) {
Logger.info(`[BackupManager] Disabling backup schedule`)
if (this.scheduleTask.destroy) this.scheduleTask.destroy()
this.scheduleTask = null
} else if (!this.scheduleTask && this.serverSettings.backupSchedule) {
Logger.info(`[BackupManager] Starting backup schedule ${this.serverSettings.backupSchedule}`)
this.scheduleCron()
} else if (this.serverSettings.backupSchedule) {
Logger.info(`[BackupManager] Restarting backup schedule ${this.serverSettings.backupSchedule}`)
if (this.scheduleTask.destroy) this.scheduleTask.destroy()
this.scheduleCron()
}
}
async uploadBackup(req, res) {
var backupFile = req.files.file
if (Path.extname(backupFile.name) !== '.audiobookshelf') {
Logger.error(`[BackupManager] Invalid backup file uploaded "${backupFile.name}"`)
return res.status(500).send('Invalid backup file')
}
var tempPath = Path.join(this.BackupPath, backupFile.name)
var success = await backupFile.mv(tempPath).then(() => true).catch((error) => {
Logger.error('[BackupManager] Failed to move backup file', path, error)
return false
})
if (!success) {
return res.status(500).send('Failed to move backup file into backups directory')
}
const zip = new StreamZip.async({ file: tempPath })
const data = await zip.entryData('details')
var details = data.toString('utf8').split('\n')
var backup = new Backup({ details, fullPath: tempPath })
backup.fileSize = await getFileSize(backup.fullPath)
var existingBackupIndex = this.backups.findIndex(b => b.id === backup.id)
if (existingBackupIndex >= 0) {
Logger.warn(`[BackupManager] Backup already exists with id ${backup.id} - overwriting`)
this.backups.splice(existingBackupIndex, 1, backup)
} else {
this.backups.push(backup)
}
return res.json(this.backups.map(b => b.toJSON()))
}
async requestCreateBackup(socket) {
// Only Root User allowed
var client = socket.sheepClient
if (!client || !client.user) {
Logger.error(`[BackupManager] Invalid user attempting to create backup`)
socket.emit('backup_complete', false)
return
} else if (!client.user.isRoot) {
Logger.error(`[BackupManager] Non-Root user attempting to create backup`)
socket.emit('backup_complete', false)
return
}
var backupSuccess = await this.runBackup()
socket.emit('backup_complete', backupSuccess ? this.backups.map(b => b.toJSON()) : false)
}
async requestApplyBackup(socket, id) {
// Only Root User allowed
var client = socket.sheepClient
if (!client || !client.user) {
Logger.error(`[BackupManager] Invalid user attempting to create backup`)
socket.emit('apply_backup_complete', false)
return
} else if (!client.user.isRoot) {
Logger.error(`[BackupManager] Non-Root user attempting to create backup`)
socket.emit('apply_backup_complete', false)
return
}
var backup = this.backups.find(b => b.id === id)
if (!backup) {
socket.emit('apply_backup_complete', false)
return
}
const zip = new StreamZip.async({ file: backup.fullPath })
await zip.extract('config/', this.db.ConfigPath)
if (backup.backupMetadataCovers) {
var metadataBooksPath = Path.join(this.MetadataPath, 'books')
await zip.extract('metadata-books/', metadataBooksPath)
}
await this.db.reinit()
socket.emit('apply_backup_complete', true)
socket.broadcast.emit('backup_applied')
}
async setLastBackup() {
this.backups.sort((a, b) => b.createdAt - a.createdAt)
var lastBackup = this.backups.shift()
const zip = new StreamZip.async({ file: lastBackup.fullPath })
await zip.extract('config/', this.db.ConfigPath)
console.log('Set Last Backup')
await this.db.reinit()
}
async loadBackups() {
try {
var filesInDir = await fs.readdir(this.BackupPath)
for (let i = 0; i < filesInDir.length; i++) {
var filename = filesInDir[i]
if (filename.endsWith('.audiobookshelf')) {
var fullFilePath = Path.join(this.BackupPath, filename)
const zip = new StreamZip.async({ file: fullFilePath })
const data = await zip.entryData('details')
var details = data.toString('utf8').split('\n')
var backup = new Backup({ details, fullPath: fullFilePath })
backup.fileSize = await getFileSize(backup.fullPath)
var existingBackupWithId = this.backups.find(b => b.id === backup.id)
if (existingBackupWithId) {
Logger.warn(`[BackupManager] Backup already loaded with id ${backup.id} - ignoring`)
} else {
this.backups.push(backup)
}
Logger.debug(`[BackupManager] Backup found "${backup.id}"`)
zip.close()
}
}
Logger.info(`[BackupManager] ${this.backups.length} Backups Found`)
} catch (error) {
Logger.error('[BackupManager] Failed to load backups', error)
}
}
async runBackup() {
// Check if Metadata Path is inside Config Path (otherwise there will be an infinite loop as the archiver tries to zip itself)
Logger.info(`[BackupManager] Running Backup`)
var metadataBooksPath = this.serverSettings.backupMetadataCovers ? Path.join(this.MetadataPath, 'books') : null
var newBackup = new Backup()
const newBackData = {
backupMetadataCovers: this.serverSettings.backupMetadataCovers,
backupDirPath: this.BackupPath
}
newBackup.setData(newBackData)
var zipResult = await this.zipBackup(this.db.ConfigPath, metadataBooksPath, newBackup).then(() => true).catch((error) => {
Logger.error(`[BackupManager] Backup Failed ${error}`)
return false
})
if (zipResult) {
Logger.info(`[BackupManager] Backup successful ${newBackup.id}`)
await filePerms(newBackup.fullPath, 0o774, this.Uid, this.Gid)
newBackup.fileSize = await getFileSize(newBackup.fullPath)
var existingIndex = this.backups.findIndex(b => b.id === newBackup.id)
if (existingIndex >= 0) {
this.backups.splice(existingIndex, 1, newBackup)
} else {
this.backups.push(newBackup)
}
// Check remove oldest backup
if (this.backups.length > this.serverSettings.backupsToKeep) {
this.backups.sort((a, b) => a.createdAt - b.createdAt)
var oldBackup = this.backups.shift()
Logger.debug(`[BackupManager] Removing old backup ${oldBackup.id}`)
this.removeBackup(oldBackup)
}
return true
} else {
return false
}
}
async removeBackup(backup) {
try {
Logger.debug(`[BackupManager] Removing Backup "${backup.fullPath}"`)
await fs.remove(backup.fullPath)
this.backups = this.backups.filter(b => b.id !== backup.id)
Logger.info(`[BackupManager] Backup "${backup.id}" Removed`)
} catch (error) {
Logger.error(`[BackupManager] Failed to remove backup`, error)
}
}
zipBackup(configPath, metadataBooksPath, backup) {
return new Promise((resolve, reject) => {
// create a file to stream archive data to
const output = fs.createWriteStream(backup.fullPath)
const archive = archiver('zip', {
zlib: { level: 9 } // Sets the compression level.
})
// listen for all archive data to be written
// 'close' event is fired only when a file descriptor is involved
output.on('close', () => {
Logger.info('[BackupManager]', archive.pointer() + ' total bytes')
resolve()
})
// This event is fired when the data source is drained no matter what was the data source.
// It is not part of this library but rather from the NodeJS Stream API.
// @see: https://nodejs.org/api/stream.html#stream_event_end
output.on('end', () => {
Logger.debug('Data has been drained')
})
output.on('finish', () => {
Logger.debug('Write Stream Finished')
})
output.on('error', (err) => {
Logger.debug('Write Stream Error', err)
reject(err)
})
// good practice to catch warnings (ie stat failures and other non-blocking errors)
archive.on('warning', function (err) {
if (err.code === 'ENOENT') {
// log warning
Logger.warn(`[BackupManager] Archiver warning: ${err.message}`)
} else {
// throw error
Logger.error(`[BackupManager] Archiver error: ${err.message}`)
// throw err
reject(err)
}
})
archive.on('error', function (err) {
Logger.error(`[BackupManager] Archiver error: ${err.message}`)
reject(err)
})
archive.on('progress', ({ fs: fsobj }) => {
if (fsobj.processedBytes > this.MaxBytesBeforeAbort) {
Logger.error(`[BackupManager] Archiver is too large - aborting to prevent endless loop, Bytes Processed: ${fsobj.processedBytes}`)
archive.abort()
setTimeout(() => {
this.removeBackup(backup)
output.destroy('Backup too large') // Promise is reject in write stream error evt
}, 500)
}
})
// pipe archive data to the file
archive.pipe(output)
var audiobooksDbDir = Path.join(configPath, 'audiobooks')
var librariesDbDir = Path.join(configPath, 'libraries')
var settingsDbDir = Path.join(configPath, 'settings')
var usersDbDir = Path.join(configPath, 'users')
archive.directory(audiobooksDbDir, 'config/audiobooks')
archive.directory(librariesDbDir, 'config/libraries')
archive.directory(settingsDbDir, 'config/settings')
archive.directory(usersDbDir, 'config/users')
if (metadataBooksPath) {
Logger.debug(`[BackupManager] Backing up Metadata Books "${metadataBooksPath}"`)
archive.directory(metadataBooksPath, 'metadata-books')
}
archive.append(backup.detailsString, { name: 'details' })
archive.finalize()
})
}
}
module.exports = BackupManager

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