Compare commits

...

101 Commits

Author SHA1 Message Date
advplyr
dd2d61f38e Version bump 2.1.2 2022-08-06 17:05:23 -05:00
advplyr
ca2c2f2702 Fix:Episode match encode title query string 2022-08-06 16:51:25 -05:00
advplyr
1fc929ab33 Update:Min width on libraries dropdown 2022-08-06 16:41:26 -05:00
advplyr
f5495d64a9 Update:Show resolution under covers in covers tab #885 2022-08-06 08:25:31 -05:00
advplyr
d6afb17bf2 Fix:Library stats page overflowing under side nav #896 2022-08-06 08:10:12 -05:00
advplyr
2cba9d8f4a Fix:Finished items showing yellow bar on item page 2022-08-06 08:00:28 -05:00
advplyr
e02169907d Add:Filter for RSS feeds open #893 2022-08-06 07:58:19 -05:00
advplyr
24a142e718 Add:RSS feed icon over library item covers when feed is open #893 2022-08-05 19:23:18 -05:00
advplyr
2cb4f972d7 Update:Collections page show total duration 2022-08-04 18:22:35 -05:00
advplyr
513d946faa Update:Updates to collections page for mobile screen sizes 2022-08-04 18:06:39 -05:00
advplyr
87d1f457ba Update:Editing series on book in edit modal sets focus to first available input #889 2022-08-03 18:38:08 -05:00
advplyr
8810f90226 Update:Cleanup edit modal match tab for mobile screens 2022-08-03 18:27:38 -05:00
advplyr
3d3571013f Add:Show audiobook duration and narrator in audible match results #886 2022-08-03 18:06:25 -05:00
advplyr
605a6d8b25 Update:Match tab searches for results when opening tab #882 2022-08-03 17:53:19 -05:00
advplyr
1bfa4b31f2 Fix:Audiobook RSS feed offset pubDate for each track to ensure correct order #888 2022-08-03 17:01:28 -05:00
advplyr
7a14b49aea Fix:Continue series shelf showing in reverse order 2022-08-02 16:41:52 -05:00
advplyr
95ac74d748 Fix:Overdrive chapter parser crash server on invalid meta data #880 2022-08-01 18:28:56 -05:00
advplyr
fddf850a41 Add:Cron validation api endpoint 2022-08-01 18:06:22 -05:00
advplyr
d93d4f3236 Update:Auto-download new podcast episode check max failed attempts to 24 2022-07-31 14:00:17 -05:00
advplyr
91f15d5a23 Update:Scanned in podcast episodes remove file ext from title 2022-07-31 13:52:34 -05:00
advplyr
516c5c3308 Add:Podcast episode match tab and find episode by title api route 2022-07-31 13:12:37 -05:00
advplyr
f702c02859 Add click to play session at timestamp in users playback sessions table 2022-07-30 18:20:15 -05:00
advplyr
ad88de0571 Version bump 2.1.1 2022-07-30 17:14:59 -05:00
advplyr
b64a651b27 Update:Series page support sort ignore prefix #866 2022-07-30 17:07:54 -05:00
advplyr
06b8d1194c Fix:Library collapsed series to respect ignore prefixes setting #866 2022-07-30 16:18:26 -05:00
advplyr
377ae7ab19 Update /matchall api endpoint to GET 2022-07-30 15:52:13 -05:00
advplyr
53cf6edd6a Update:Show prompt before marking item as finished that has progress #805 2022-07-30 12:40:43 -05:00
advplyr
92bedeac15 Update:Click chapter times in chapters table to jump to timestamp 2022-07-30 12:25:15 -05:00
advplyr
3cf8b9dca9 Update start playback from bookmark time confirm to new confirm prompt 2022-07-30 11:53:48 -05:00
advplyr
bcc2f847f9 Add:Click timestamp in listening sessions table to open playback at timestamp #798, add confirm prompt 2022-07-30 11:36:04 -05:00
advplyr
f1421f351b Merge pull request #871 from arabshapt/feature/use-svg-instead-of-png
feature: use svg instead of png where possible for better quality
2022-07-30 08:45:20 -05:00
advplyr
ed23feaf3f Fix typo Received Ping 2022-07-30 08:37:35 -05:00
arabshapt
668ebf8550 feature: use svg instead of png where possible for better quality 2022-07-30 13:58:20 +02:00
advplyr
a8c7905f6d Add:Bookmarks icon btn on library item page and ability to open player at specified time #796 2022-07-29 19:06:52 -05:00
advplyr
45cd39ac0c Update:Remain on same tab in item edit modal when navigating next/prev #867 2022-07-29 18:19:19 -05:00
advplyr
21e1f62c65 Fix:Merging chapters from multiple files skipping chapters #857 2022-07-29 18:15:41 -05:00
advplyr
8416f2d6be Fix:Remove invalid playback sessions on server start #868 2022-07-29 17:13:46 -05:00
advplyr
3b4ac3a230 Fix:Long library names overflow #858 2022-07-26 18:54:32 -05:00
advplyr
6244909332 Update:Timestamp input support over 99 hours and focus input #657 2022-07-25 19:32:04 -05:00
advplyr
5db949e4a7 Update:Save after editing chapters redirects to previous page #827 2022-07-25 18:57:00 -05:00
advplyr
c453d3e8c7 Update:Chapter editor to use timestamp input for chapter start time with toggle to show seconds #657 2022-07-25 18:40:11 -05:00
advplyr
9d7ffdfcd0 Update docker file healthcheck to use /healthcheck instead of /ping 2022-07-24 15:46:19 -05:00
advplyr
976427b0b3 Fix:Set correct mime type for m4b file static requests 2022-07-24 13:32:05 -05:00
advplyr
6cbfd8679b Update:Changing author name to match another authors name will merge the authors #487 2022-07-24 12:00:36 -05:00
advplyr
217bbb4a8e Merge pull request #842 from revilo951/master
Add restart and fix volumes
2022-07-20 17:49:43 -05:00
advplyr
9916a1e8f6 Fix:Watcher scanner to ignore non-media files that are not inside library item folders #834 2022-07-19 08:33:32 -05:00
advplyr
372101592c Version bump 2.1.0 2022-07-18 18:41:51 -05:00
advplyr
18123664ee Fix:RSS Feed cover, Update:Remove experimental scanner 2022-07-18 18:39:51 -05:00
advplyr
2e6e4f970c Remove comments from scanner 2022-07-18 18:17:50 -05:00
advplyr
1c9e56ce2e Fix:Libraries table to use draggable handle for items so they can be clicked and edited #839 2022-07-18 17:44:01 -05:00
advplyr
9e7b84f289 Update:JWT signing 2022-07-18 17:19:16 -05:00
revilo951
7b83ab8970 Add restart and fix volumes
Added `restart: unless-stopped`
Adjust volumes - probably shouldn't be scattering the volumes around the root dir?
https://docs.docker.com/compose/compose-file/compose-file-v3/#short-syntax-3
2022-07-18 10:12:02 +10:00
advplyr
86ee4dcff2 Update:Scanner adjustable number of parallel audio probes to use less CPU 2022-07-16 18:54:34 -05:00
advplyr
277a5fa37c Version bump 2.0.24 2022-07-14 19:51:33 -05:00
advplyr
51b87912f8 Update:Collapsed series shows series name instead of first book title and only sorts when sorting by title #629 2022-07-14 19:00:52 -05:00
advplyr
653019921e Add:Support for OGA file extension #804, Update:Mime type for m4b and m4a to audio/mp4 2022-07-14 18:32:00 -05:00
advplyr
ccc291067d Fix:Truetype fonts format 2022-07-14 18:06:37 -05:00
advplyr
af7e3a03f0 Fix:Audio player when using chapter track then playing an item without chapters 2022-07-13 19:38:34 -05:00
advplyr
7c40d26857 Fix:Sync local mobile app progress replacing local media progress id causing duplicate media progress in mobile 2022-07-13 19:18:49 -05:00
advplyr
6c507de501 Remove client marked dependency 2022-07-12 16:21:32 -05:00
advplyr
482a4340f5 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-07-12 16:19:54 -05:00
advplyr
21e704e12c Fix:Show toasts below appbar & toolbar #819 2022-07-12 16:11:23 -05:00
advplyr
2b91bff1af Fix:Ordering newly scanned in audio tracks properly #823 2022-07-12 15:02:08 -05:00
advplyr
d11f9608b4 Remove old audio file scanner 2022-07-12 14:35:43 -05:00
advplyr
2b0b691b69 Merge pull request #821 from jmt-gh/whats_new_modal
Add ability to view current version's changelog from within ABS
2022-07-09 17:32:33 -05:00
advplyr
5dfd5c4971 Remove marked dependency 2022-07-09 17:29:30 -05:00
advplyr
201f1bff3e Add marked package in static libs 2022-07-09 17:27:30 -05:00
jmt-gh
a22ebb257f add style comments 2022-07-08 20:45:09 -07:00
jmt-gh
bf6e87d4bc update formatting 2022-07-08 20:34:32 -07:00
jmt-gh
b823a93ae2 integrate modal to sidenavs 2022-07-08 20:29:18 -07:00
jmt-gh
05afd12682 add new changelog modal 2022-07-08 20:28:34 -07:00
jmt-gh
997e23150e extract current version changelog information 2022-07-08 20:26:30 -07:00
advplyr
3c5bf376b5 Remove archiver-utils dependency 2022-07-08 18:11:09 -05:00
advplyr
bca2cfda13 Update:Remove log listener for root user & only set when on logger config page 2022-07-07 17:25:52 -05:00
advplyr
916b41d587 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-07-06 20:12:22 -05:00
advplyr
ab08d83c04 Remove archiver dependency 2022-07-06 20:12:14 -05:00
advplyr
415e0a7b5a Remove dependency date-and-time 2022-07-06 19:18:27 -05:00
advplyr
d301c12acd Remove dependency express-rate-limit 2022-07-06 19:14:47 -05:00
advplyr
7aa7e662b2 Remove dependency express-fileupload 2022-07-06 19:10:25 -05:00
advplyr
1dbfb5637a Remove bcryptjs dependency 2022-07-06 19:01:27 -05:00
advplyr
4e1aacb44f Remove command-line-args dependency 2022-07-06 18:56:13 -05:00
advplyr
954cf3e14e Remove jsonwebtoken dependency 2022-07-06 18:45:43 -05:00
advplyr
b61ecefce4 Remove fluent-ffmpeg dependency 2022-07-06 17:38:19 -05:00
advplyr
8562b8d1b3 Remove node-ffprobe dependency 2022-07-06 17:07:08 -05:00
advplyr
06ec2159f5 Update bug.yaml 2022-07-06 07:18:03 -05:00
advplyr
68b565505e Update bug.yaml 2022-07-06 07:13:39 -05:00
advplyr
83ff2752dd Update bug.yaml 2022-07-06 07:10:27 -05:00
advplyr
d0af1c3c9a Remove fs-extra dependency 2022-07-05 19:53:01 -05:00
advplyr
1ad46d4fb8 Add missing dependency license files 2022-07-05 19:33:43 -05:00
advplyr
d3dd13eae5 Remove node-stream-zip dependency 2022-07-05 19:24:16 -05:00
advplyr
f27982d887 Update:Default backup schedule to 1:30 to avoid conflict with new episode checks #761 2022-07-05 17:38:17 -05:00
advplyr
624a44f572 Fix:Quick match split multiple comma separated authors #808 2022-07-05 17:26:14 -05:00
advplyr
e623bf7fde Remove rra dependency 2022-07-04 19:19:38 -05:00
advplyr
6fc70b8656 Remove LibGen provider and package 2022-07-04 19:14:52 -05:00
advplyr
354cefb9f4 Update:Update isFile flag on check scan data for library items 2022-07-03 15:04:41 -05:00
advplyr
a78aa88dbc Fix:Audiobooks incorrectly flagged as single file root audiobooks #714 2022-07-03 14:41:07 -05:00
advplyr
9ac2453676 Merge pull request #802 from jmt-gh/getMediaProgress_fix
fix getMediaProgress always returning 404
2022-07-03 12:20:24 -05:00
advplyr
bb70800b4e Merge pull request #801 from mcdinner/missing-cache-directory
Remove cachePathExists property (Issue #800)
2022-07-03 12:19:51 -05:00
jmt-gh
855272a558 fix getMediaProgress not returning properly 2022-07-03 10:15:40 -07:00
mcdinner
ebb2c5f791 Remove cachePathExists property (Issue #800)
Remove cachePathsExist property to ensure missing cache directories are recreated when EnsureCachePaths() called.
2022-07-03 16:35:12 +02:00
advplyr
2e466bb164 Update tailwindcss 2022-07-02 20:02:43 -05:00
374 changed files with 37407 additions and 2736 deletions

View File

@@ -9,6 +9,12 @@ body:
- type: markdown
attributes:
value: "### Mobile app issues report [here](https://github.com/advplyr/audiobookshelf-app/issues/new/choose)."
- type: markdown
attributes:
value: "### Join the [discord server](https://discord.gg/pJsjuNCKRq) for questions or if you are not sure about a bug."
- type: markdown
attributes:
value: "## Be as descriptive as you can. Include screenshots, error logs, browser, file types, everything you can think of that might be relevant."
- type: textarea
id: what-happened
attributes:
@@ -27,6 +33,7 @@ body:
id: version
attributes:
label: Audiobookshelf version
description: Do not put 'Latest version', please put the actual version here
placeholder: "e.g. v1.6.60"
validations:
required: true

View File

@@ -25,5 +25,5 @@ HEALTHCHECK \
--interval=30s \
--timeout=3s \
--start-period=10s \
CMD curl -f http://127.0.0.1/ping || exit 1
CMD curl -f http://127.0.0.1/healthcheck || exit 1
CMD ["npm", "start"]

View File

@@ -225,4 +225,18 @@ Bookshelf Label
-webkit-line-clamp: 2;
/* number of lines to show */
-webkit-box-orient: vertical;
}
/* Padding for toastification toasts in the top right to not cover appbar/toolbar */
.app-bar-and-toolbar .Vue-Toastification__container.top-right {
padding-top: 104px;
}
.app-bar .Vue-Toastification__container.top-right {
padding-top: 64px;
}
.no-bars .Vue-Toastification__container.top-right {
padding-top: 8px;
}

View File

@@ -1,34 +1,40 @@
.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;
}
.drag-handle {
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);
}
@@ -36,6 +42,7 @@
.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

@@ -74,7 +74,7 @@
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
@@ -84,7 +84,7 @@
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@@ -94,7 +94,7 @@
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
unicode-range: U+1F00-1FFF;
}
@@ -104,7 +104,7 @@
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
unicode-range: U+0370-03FF;
}
@@ -114,7 +114,7 @@
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
@@ -124,7 +124,7 @@
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@@ -134,7 +134,7 @@
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@@ -154,7 +154,7 @@
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@@ -164,7 +164,7 @@
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
unicode-range: U+1F00-1FFF;
}
@@ -174,7 +174,7 @@
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
unicode-range: U+0370-03FF;
}
@@ -184,7 +184,7 @@
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
@@ -194,7 +194,7 @@
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@@ -204,7 +204,7 @@
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@@ -214,7 +214,7 @@
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
@@ -224,7 +224,7 @@
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@@ -234,7 +234,7 @@
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
unicode-range: U+1F00-1FFF;
}
@@ -244,7 +244,7 @@
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
unicode-range: U+0370-03FF;
}
@@ -254,7 +254,7 @@
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
@@ -264,7 +264,7 @@
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@@ -274,7 +274,7 @@
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@@ -284,7 +284,7 @@
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
@@ -294,7 +294,7 @@
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@@ -304,7 +304,7 @@
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
unicode-range: U+1F00-1FFF;
}
@@ -314,7 +314,7 @@
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
unicode-range: U+0370-03FF;
}
@@ -324,7 +324,7 @@
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@@ -334,6 +334,6 @@
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

View File

@@ -3,14 +3,14 @@
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-50">
<div class="flex h-full items-center">
<nuxt-link to="/">
<img src="/icon48.png" class="w-8 h-8 mr-8 sm:w-12 sm:h-12 sm:mr-4" />
<img src="/icon.svg" class="w-10 min-w-10 h-10 mr-2 sm:w-12 sm:min-w-12 sm:h-12 sm:mr-4" />
</nuxt-link>
<nuxt-link to="/">
<h1 class="text-2xl font-book mr-6 hidden lg:block hover:underline">audiobookshelf</h1>
</nuxt-link>
<ui-libraries-dropdown />
<ui-libraries-dropdown class="mr-2" />
<controls-global-search v-if="currentLibrary" class="" />
<div class="flex-grow" />

View File

@@ -11,12 +11,14 @@
<div class="w-full h-12 px-4 border-t border-black border-opacity-20 absolute left-0 flex flex-col justify-center" :style="{ bottom: streamLibraryItem && isMobileLandscape ? '300px' : '65px' }">
<div class="flex justify-between">
<p class="font-mono text-sm">v{{ $config.version }}</p>
<p class="underline font-mono text-sm" @click="clickChangelog">v{{ $config.version }}</p>
<p class="font-mono text-xs text-gray-300 italic">{{ Source }}</p>
</div>
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xs">Latest: {{ latestVersion }}</a>
</div>
<modals-changelog-view-modal v-model="showChangelogModal" :changelog="currentVersionChangelog" :currentVersion="$config.version"/>
</div>
</template>
@@ -26,7 +28,9 @@ export default {
isOpen: Boolean
},
data() {
return {}
return {
showChangelogModal: false
}
},
computed: {
Source() {
@@ -129,18 +133,24 @@ export default {
githubTagUrl() {
return this.versionData.githubTagUrl
},
currentVersionChangelog() {
return this.versionData.currentVersionChangelog || 'No Changelog Available'
},
streamLibraryItem() {
return this.$store.state.streamLibraryItem
}
},
methods: {
clickChangelog(){
this.showChangelogModal = true
},
clickOutside() {
if (!this.isOpen) return
this.closeDrawer()
},
closeDrawer() {
this.$emit('update:isOpen', false)
}
},
}
}
</script>

View File

@@ -88,6 +88,7 @@ export default {
if (this.page === 'collections') return "You haven't made any collections yet"
if (this.hasFilter) {
if (this.filterName === 'Issues') return 'No Issues'
else if (this.filterName === 'Feed-open') return 'No RSS feeds are open'
return `No Results for filter "${this.filterName}: ${this.filterValue}"`
}
return 'No results'

View File

@@ -75,17 +75,21 @@
</nuxt-link>
<div class="w-full h-12 px-1 py-2 border-t border-black border-opacity-20 absolute left-0" :style="{ bottom: streamLibraryItem ? '240px' : '65px' }">
<p class="font-mono text-xs text-center text-gray-300 leading-3 mb-1">v{{ $config.version }}</p>
<p class="underline font-mono text-xs text-center text-gray-300 leading-3 mb-1" @click="clickChangelog">v{{ $config.version }}</p>
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xxs text-center block leading-3">Update</a>
<p v-else class="text-xxs text-gray-400 leading-3 text-center italic">{{ Source }}</p>
</div>
<modals-changelog-view-modal v-model="showChangelogModal" :changelog="currentVersionChangelog" :currentVersion="$config.version"/>
</div>
</template>
<script>
export default {
data() {
return {}
return {
showChangelogModal: false
}
},
computed: {
Source() {
@@ -150,17 +154,21 @@ export default {
hasUpdate() {
return !!this.versionData.hasUpdate
},
latestVersion() {
return this.versionData.latestVersion
},
githubTagUrl() {
return this.versionData.githubTagUrl
},
currentVersionChangelog() {
return this.versionData.currentVersionChangelog || 'No Changelog Available'
},
streamLibraryItem() {
return this.$store.state.streamLibraryItem
}
},
methods: {},
methods: {
clickChangelog(){
this.showChangelogModal = true
}
},
mounted() {}
}
</script>

View File

@@ -364,7 +364,11 @@ export default {
var episodeId = payload.episodeId || null
if (this.playerHandler.libraryItemId == libraryItemId && this.playerHandler.episodeId == episodeId) {
this.playerHandler.play()
if (payload.startTime !== null && !isNaN(payload.startTime)) {
this.seek(payload.startTime)
} else {
this.playerHandler.play()
}
return
}
@@ -377,7 +381,11 @@ export default {
libraryItem,
episodeId
})
this.playerHandler.load(libraryItem, episodeId, true, this.initialPlaybackRate)
this.$nextTick(() => {
if (this.$refs.audioPlayer) this.$refs.audioPlayer.checkUpdateChapterTrack()
})
this.playerHandler.load(libraryItem, episodeId, true, this.initialPlaybackRate, payload.startTime)
},
pauseItem() {
this.playerHandler.pause()
@@ -389,11 +397,13 @@ export default {
},
mounted() {
this.$eventBus.$on('cast-session-active', this.castSessionActive)
this.$eventBus.$on('playback-seek', this.seek)
this.$eventBus.$on('play-item', this.playLibraryItem)
this.$eventBus.$on('pause-item', this.pauseItem)
},
beforeDestroy() {
this.$eventBus.$off('cast-session-active', this.castSessionActive)
this.$eventBus.$off('playback-seek', this.seek)
this.$eventBus.$off('play-item', this.playLibraryItem)
this.$eventBus.$off('pause-item', this.pauseItem)
}

View File

@@ -1,16 +1,20 @@
<template>
<div class="w-full border-b border-gray-700 pb-2">
<div v-if="book" class="w-full border-b border-gray-700 pb-2">
<div class="flex py-1 hover:bg-gray-300 hover:bg-opacity-10 cursor-pointer" @click="selectMatch">
<div class="h-24 bg-primary" :style="{ minWidth: 96 / bookCoverAspectRatio + 'px' }">
<img v-if="selectedCover" :src="selectedCover" class="h-full w-full object-contain" />
</div>
<div v-if="!isPodcast" class="px-4 flex-grow">
<div class="flex items-center">
<h1 class="text-base">{{ book.title }}</h1>
<div class="flex-grow" />
<p>{{ book.publishedYear }}</p>
<div class="min-w-12 max-w-12 md:min-w-20 md:max-w-20">
<div class="w-full bg-primary">
<img v-if="selectedCover" :src="selectedCover" class="h-full w-full object-contain" />
</div>
<p class="text-gray-300 text-sm">{{ book.author }}</p>
</div>
<div v-if="!isPodcast" class="px-2 md:px-4 flex-grow">
<div class="flex items-center">
<h1 class="text-sm md:text-base">{{ book.title }}</h1>
<div class="flex-grow" />
<p class="text-sm md:text-base">{{ book.publishedYear }}</p>
</div>
<p class="text-gray-300 text-xs md:text-sm">by {{ book.author }}</p>
<p v-if="book.narrator" class="text-gray-400 text-xs">Narrated by {{ book.narrator }}</p>
<p v-if="book.duration" class="text-gray-400 text-xs">Runtime: {{ $elapsedPrettyExtended(book.duration) }}</p>
<div v-if="book.series && book.series.length" class="flex py-1 -mx-1">
<div v-for="(series, index) in book.series" :key="index" class="bg-white bg-opacity-10 rounded-full px-1 py-0.5 mx-1">
<p class="leading-3 text-xs text-gray-400">
@@ -25,7 +29,7 @@
<div v-else class="px-4 flex-grow">
<h1>{{ book.title }}</h1>
<p class="text-base text-gray-300 whitespace-nowrap truncate">by {{ book.author }}</p>
<p class="text-xs text-gray-400 leading-5">{{ book.genres.join(', ') }}</p>
<p v-if="book.genres" class="text-xs text-gray-400 leading-5">{{ book.genres.join(', ') }}</p>
<p class="text-xs text-gray-400 leading-5">{{ book.trackCount }} Episodes</p>
</div>
</div>
@@ -63,7 +67,7 @@ export default {
selectMatch() {
var book = { ...this.book }
book.cover = this.selectedCover
this.$emit('select', this.book)
this.$emit('select', book)
},
clickCover(cover) {
this.selectedCover = cover

View File

@@ -78,6 +78,10 @@
</div>
</ui-tooltip>
<div v-if="rssFeed && !isSelectionMode && !isHovering" class="absolute text-success top-0 left-0 z-10" :style="{ padding: 0.375 * sizeMultiplier + 'rem' }">
<span class="material-icons" :style="{ fontSize: sizeMultiplier * 1.5 + 'rem' }">rss_feed</span>
</div>
<!-- Series sequence -->
<div v-if="seriesSequence && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 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' }">#{{ seriesSequence }}</p>
@@ -249,14 +253,14 @@ export default {
},
displayTitle() {
if (this.recentEpisode) return this.recentEpisode.title
if (this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix) {
return this.mediaMetadata.titleIgnorePrefix
}
return this.title
const ignorePrefix = this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix
if (this.collapsedSeries) return ignorePrefix ? this.collapsedSeries.nameIgnorePrefix : this.collapsedSeries.name
return ignorePrefix ? this.mediaMetadata.titleIgnorePrefix : this.title
},
displayLineTwo() {
if (this.recentEpisode) return this.title
if (this.isPodcast) return this.author
if (this.collapsedSeries) return ''
if (this.isAuthorBookshelfView) {
return this.mediaMetadata.publishedYear || ''
}
@@ -264,6 +268,7 @@ export default {
return this.author
},
displaySortLine() {
if (this.collapsedSeries) return null
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs, this.dateFormat)
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs, this.dateFormat)
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt, this.dateFormat)
@@ -443,6 +448,10 @@ export default {
if (!this.isAlternativeBookshelfView && !this.isAuthorBookshelfView) return 0
else if (!this.displaySortLine) return 3 * this.sizeMultiplier
return 4.25 * this.sizeMultiplier
},
rssFeed() {
if (this.booksInSeries) return null
return this.store.getters['feeds/getFeedForItem'](this.libraryItemId)
}
},
methods: {
@@ -499,7 +508,21 @@ export default {
}
this.$emit('edit', this.libraryItem)
},
toggleFinished() {
toggleFinished(confirmed = false) {
if (!this.itemIsFinished && this.userProgressPercent > 0 && !confirmed) {
const payload = {
message: `Are you sure you want to mark "${this.displayTitle}" as finished?`,
callback: (confirmed) => {
if (confirmed) {
this.toggleFinished(true)
}
},
type: 'yesNo'
}
this.store.commit('globals/setConfirmPrompt', payload)
return
}
var updatePayload = {
isFinished: !this.itemIsFinished
}

View File

@@ -2,7 +2,7 @@
<div ref="card" :id="`series-card-${index}`" :style="{ width: width + 'px', height: height + 'px' }" class="rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
<div class="w-full h-full bg-primary relative rounded overflow-hidden z-0">
<covers-group-cover v-if="series" ref="cover" :id="seriesId" :name="title" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" :group-to="seriesBooksRoute" />
<covers-group-cover v-if="series" ref="cover" :id="seriesId" :name="displayTitle" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" :group-to="seriesBooksRoute" />
</div>
<div class="absolute z-10 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ books.length }}</div>
@@ -10,16 +10,16 @@
<div v-if="isSeriesFinished" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b bg-success w-full" />
<div v-if="hasValidCovers" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: `${sizeMultiplier}rem` }">
<p class="font-book" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ title }}</p>
<p class="font-book" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ displayTitle }}</p>
</div>
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ displayTitle }}</p>
</div>
</div>
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ displayTitle }}</p>
</div>
</div>
</template>
@@ -39,7 +39,8 @@ export default {
seriesMount: {
type: Object,
default: () => null
}
},
sortingIgnorePrefix: Boolean
},
data() {
return {
@@ -65,6 +66,13 @@ export default {
title() {
return this.series ? this.series.name : ''
},
nameIgnorePrefix() {
return this.series ? this.series.nameIgnorePrefix : ''
},
displayTitle() {
if (this.sortingIgnorePrefix) return this.nameIgnorePrefix || this.title
return this.title
},
books() {
return this.series ? this.series.books || [] : []
},

View File

@@ -116,6 +116,11 @@ export default {
text: 'Issues',
value: 'issues',
sublist: false
},
{
text: 'RSS Feed Open',
value: 'feed-open',
sublist: false
}
],
podcastItems: [

View File

@@ -1,5 +1,5 @@
<template>
<div class="sm:w-80 w-full sm:ml-6 relative">
<div class="sm:w-80 w-full 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>

View File

@@ -125,6 +125,9 @@ export default {
},
userToken() {
return this.$store.getters['user/getToken']
},
resolution() {
return `${this.naturalWidth}x${this.naturalHeight}px`
}
},
methods: {

View File

@@ -1,6 +1,6 @@
<template>
<div class="relative rounded-sm overflow-hidden" :style="{ height: width * bookCoverAspectRatio + 'px', width: width + 'px', maxWidth: width + 'px', minWidth: width + 'px' }" @mouseover="isHovering = true" @mouseleave="isHovering = false">
<div class="w-full h-full relative">
<div class="relative rounded-sm" :style="{ height: width * bookCoverAspectRatio + 'px', width: width + 'px', maxWidth: width + 'px', minWidth: width + 'px' }" @mouseover="isHovering = true" @mouseleave="isHovering = false">
<div class="w-full h-full relative overflow-hidden">
<div v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary">
<div class="absolute cover-bg" ref="coverBg" />
</div>
@@ -17,6 +17,8 @@
<p class="text-center font-book text-error" :style="{ fontSize: sizeMultiplier + 'rem' }">Invalid Cover</p>
</div>
</div>
<p v-if="!imageFailed" class="absolute -bottom-5 left-0 right-0 mx-auto text-xs text-gray-300 text-center">{{ resolution }}</p>
</div>
</template>
@@ -54,6 +56,9 @@ export default {
},
placeholderCoverPadding() {
return 0.8 * this.sizeMultiplier
},
resolution() {
return `${this.naturalWidth}x${this.naturalHeight}px`
}
},
methods: {

View File

@@ -146,7 +146,6 @@ export default {
watch: {
show: {
handler(newVal) {
console.log('accoutn modal show change', newVal)
if (newVal) {
this.init()
}
@@ -162,6 +161,9 @@ export default {
this.$emit('input', val)
}
},
user() {
return this.$store.state.user.user
},
title() {
return this.isNew ? 'Add New Account' : `Update ${(this.account || {}).username}`
},
@@ -250,6 +252,12 @@ export default {
this.$toast.error(`Failed to update account: ${data.error}`)
} else {
console.log('Account updated', data.user)
if (data.user.id === this.user.id && data.user.token !== this.user.token) {
console.log('Current user token was updated')
this.$store.commit('user/setUserToken', data.user.token)
}
this.$toast.success('Account updated')
this.show = false
}
@@ -305,7 +313,6 @@ export default {
this.isNew = !this.account
if (this.account) {
console.log(this.account)
this.newUser = {
username: this.account.username,
password: this.account.password,

View File

@@ -1,6 +1,11 @@
<template>
<modals-modal v-model="show" name="bookmarks" :width="500" :height="'unset'">
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
<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">Your Bookmarks</p>
</div>
</template>
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
<div v-if="show" class="w-full h-full">
<template v-for="bookmark in bookmarks">
<modals-bookmarks-bookmark-item :key="bookmark.id" :highlight="currentTime === bookmark.time" :bookmark="bookmark" @click="clickBookmark" @update="submitUpdateBookmark" @delete="deleteBookmark" />
@@ -8,8 +13,8 @@
<div v-if="!bookmarks.length" class="flex h-32 items-center justify-center">
<p class="text-xl">No Bookmarks</p>
</div>
<div class="w-full h-px bg-white bg-opacity-10" />
<form @submit.prevent="submitCreateBookmark">
<div v-if="!hideCreate" class="w-full h-px bg-white bg-opacity-10" />
<form v-if="!hideCreate" @submit.prevent="submitCreateBookmark">
<div v-show="canCreateBookmark" class="flex px-4 py-2 items-center text-center border-b border-white border-opacity-10 text-white text-opacity-80">
<div class="w-16 max-w-16 text-center">
<p class="text-sm font-mono text-gray-400">
@@ -39,7 +44,8 @@ export default {
type: Number,
default: 0
},
libraryItemId: String
libraryItemId: String,
hideCreate: Boolean
},
data() {
return {

View File

@@ -8,10 +8,10 @@
<div class="bg-bg rounded-lg px-2 py-6 sm:p-6 md:p-8" @click.stop>
<div class="flex">
<div class="flex-grow p-1 min-w-48 sm:min-w-64 md:min-w-80">
<ui-input-dropdown ref="newSeriesSelect" v-model="selectedSeries.name" :items="existingSeriesNames" :disabled="!selectedSeries.id.startsWith('new')" label="Series Name" />
<ui-input-dropdown ref="newSeriesSelect" v-model="selectedSeries.name" :items="existingSeriesNames" :disabled="!isNewSeries" label="Series Name" />
</div>
<div class="w-24 sm:w-28 md:w-40 p-1">
<ui-text-input-with-label v-model="selectedSeries.sequence" label="Sequence" />
<ui-text-input-with-label ref="sequenceInput" v-model="selectedSeries.sequence" label="Sequence" />
</div>
</div>
<div class="flex justify-end mt-2 p-1">
@@ -59,9 +59,26 @@ export default {
set(val) {
this.$emit('input', val)
}
},
isNewSeries() {
if (!this.selectedSeries || !this.selectedSeries.id) return false
return this.selectedSeries.id.startsWith('new')
}
},
methods: {
setInputFocus() {
if (this.isNewSeries) {
// Focus on series input if new series
if (this.$refs.newSeriesSelect) {
this.$refs.newSeriesSelect.setFocus()
}
} else {
// Focus on sequence input if existing series
if (this.$refs.sequenceInput) {
this.$refs.sequenceInput.setFocus()
}
}
},
submitSeriesForm() {
if (this.$refs.newSeriesSelect) {
this.$refs.newSeriesSelect.blur()
@@ -89,15 +106,15 @@ export default {
setTimeout(() => {
this.content.style.transform = 'scale(1)'
}, 10)
document.documentElement.classList.add('modal-open')
this.$store.commit('setInnerModalOpen', true)
this.$eventBus.$on('modal-hotkey', this.hotkey)
this.setInputFocus()
},
setHide() {
if (this.content) this.content.style.transform = 'scale(0)'
if (this.el) this.el.remove()
document.documentElement.classList.remove('modal-open')
this.$store.commit('setInnerModalOpen', false)
this.$eventBus.$off('modal-hotkey', this.hotkey)

View File

@@ -50,7 +50,8 @@ export default {
return {
el: null,
content: null,
preventClickoutside: false
preventClickoutside: false,
isShowingPrompt: false
}
},
watch: {
@@ -93,7 +94,7 @@ export default {
this.show = false
},
clickBg(ev) {
if (!this.show) return
if (!this.show || this.isShowingPrompt) return
if (this.preventClickoutside) {
this.preventClickoutside = false
return
@@ -147,8 +148,16 @@ export default {
} else {
console.warn('Invalid modal init', this.name)
}
},
showingPrompt(isShowing) {
this.isShowingPrompt = isShowing
}
},
mounted() {}
mounted() {
this.$eventBus.$on('showing-prompt', this.showingPrompt)
},
beforeDestroy() {
this.$eventBus.$off('showing-prompt', this.showingPrompt)
}
}
</script>

View File

@@ -116,6 +116,9 @@ export default {
if (result.updated) {
this.$toast.success('Author updated')
this.show = false
} else if (result.merged) {
this.$toast.success('Author merged')
this.show = false
} else this.$toast.info('No updates were needed')
}
this.processing = false

View File

@@ -1,6 +1,5 @@
<template>
<div class="flex items-center px-4 py-4 justify-start relative hover:bg-bg" :class="wrapperClass" @click="click" @mouseover="mouseover" @mouseleave="mouseleave">
<!-- <span class="material-icons" :class="highlight ? 'text-success' : 'text-white text-opacity-80'">{{ highlight ? 'bookmark' : 'bookmark_border' }}</span> -->
<div class="flex items-center px-4 py-4 justify-start relative bg-primary hover:bg-opacity-25" :class="wrapperClass" @click.stop="click" @mouseover="mouseover" @mouseleave="mouseleave">
<div class="w-16 max-w-16 text-center">
<p class="text-sm font-mono text-gray-400">
{{ this.$secondsToTimestamp(bookmark.time) }}

View File

@@ -0,0 +1,73 @@
<template>
<modals-modal v-model="show" name="changelog" :width="800" :height="'unset'">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">Changelog</p>
</div>
</template>
<div class="px-8 py-6 w-full rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-y-scroll" style="max-height: 80vh">
<p class="text-xl font-bold pb-4">Changelog v{{ currentVersionNumber }}</p>
<div class="custom-text" v-html="compiledMarkedown" />
</div>
</modals-modal>
</template>
<script>
import { marked } from '@/static/libs/marked/index.js'
export default {
props: {
value: Boolean,
changelog: String,
currentVersion: String
},
watch: {
show: {
immediate: true,
handler(newVal) {
if (newVal) {
this.init()
}
}
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
compiledMarkedown() {
return marked.parse(this.changelog, { gfm: true, breaks: true })
},
currentVersionNumber() {
return this.currentVersion
}
},
methods: {
init() {}
},
mounted() {}
}
</script>
<style scoped>
/*
1. we need to manually define styles to apply to the parsed markdown elements,
since we don't have access to the actual elements in this component
2. v-deep allows these to take effect on the content passed in to the v-html in the div above
*/
.custom-text ::v-deep > h2 {
@apply text-lg font-bold;
}
.custom-text ::v-deep > h3 {
@apply text-lg font-bold;
}
.custom-text ::v-deep > ul {
@apply list-disc list-inside pb-4;
}
</style>

View File

@@ -190,7 +190,6 @@ export default {
if (prevBook) {
this.unregisterListeners()
this.libraryItem = prevBook
this.selectedTab = 'details'
this.$store.commit('setSelectedLibraryItem', prevBook)
this.$nextTick(this.registerListeners)
} else {
@@ -210,7 +209,6 @@ export default {
if (nextBook) {
this.unregisterListeners()
this.libraryItem = nextBook
this.selectedTab = 'details'
this.$store.commit('setSelectedLibraryItem', nextBook)
this.$nextTick(this.registerListeners)
} else {

View File

@@ -31,9 +31,9 @@
<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.metadata.path === coverPath ? 'border-yellow-300' : ''" @click="setCover(cover)">
<div :key="cover.path" class="m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover.metadata.path === coverPath ? 'border-yellow-300' : ''" @click="setCover(cover)">
<div class="h-24 bg-primary" :style="{ width: 96 / bookCoverAspectRatio + 'px' }">
<img :src="`${cover.localPath}?token=${userToken}`" class="h-full w-full object-contain" />
<covers-preview-cover :src="`${cover.localPath}?token=${userToken}`" :width="96 / bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div>
</div>
</template>
@@ -58,7 +58,7 @@
<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="updateCover(cover)">
<div :key="cover" class="m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover === imageUrl ? 'border-yellow-300' : ''" @click="updateCover(cover)">
<covers-preview-cover :src="cover" :width="80" show-open-new-tab :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div>
</template>

View File

@@ -1,14 +1,14 @@
<template>
<div id="match-wrapper" class="w-full h-full overflow-hidden px-4 py-6 relative">
<div id="match-wrapper" class="w-full h-full overflow-hidden px-2 md:px-4 py-4 md:py-6 relative">
<form @submit.prevent="submitSearch">
<div class="flex items-center justify-start -mx-1 h-20">
<div class="w-40 px-1">
<div class="flex flex-wrap md:flex-nowrap items-center justify-start -mx-1">
<div class="w-36 px-1">
<ui-dropdown v-model="provider" :items="providers" label="Provider" small />
</div>
<div class="w-72 px-1">
<div class="flex-grow md:w-72 px-1">
<ui-text-input-with-label v-model="searchTitle" :label="searchTitleLabel" placeholder="Search" />
</div>
<div v-show="provider != 'itunes'" class="w-72 px-1">
<div v-show="provider != 'itunes'" class="w-60 md:w-72 px-1">
<ui-text-input-with-label v-model="searchAuthor" label="Author" />
</div>
<ui-btn class="mt-5 ml-1" type="submit">Search</ui-btn>
@@ -20,7 +20,7 @@
<div v-show="!processing && !searchResults.length && hasSearched" class="flex h-full items-center justify-center">
<p>No Results</p>
</div>
<div v-show="!processing" class="w-full max-h-full overflow-y-auto overflow-x-hidden matchListWrapper">
<div v-show="!processing" class="w-full max-h-full overflow-y-auto overflow-x-hidden matchListWrapper mt-4">
<template v-for="(res, index) in searchResults">
<cards-book-match-card :key="index" :book="res" :is-podcast="isPodcast" :book-cover-aspect-ratio="bookCoverAspectRatio" @select="selectMatch" />
</template>
@@ -299,7 +299,7 @@ export default {
this.isProcessing = true
this.lastSearch = searchQuery
var searchEntity = this.isPodcast ? 'podcast' : 'books'
var results = await this.$axios.$get(`/api/search/${searchEntity}?${searchQuery}`).catch((error) => {
var results = await this.$axios.$get(`/api/search/${searchEntity}?${searchQuery}`, { timeout: 10000 }).catch((error) => {
console.error('Failed', error)
return []
})
@@ -363,6 +363,10 @@ export default {
this.searchAuthor = this.libraryItem.media.metadata.authorName || ''
if (this.isPodcast) this.provider = 'itunes'
else this.provider = localStorage.getItem('book-provider') || 'google'
if (this.searchTitle) {
this.submitSearch()
}
},
selectMatch(match) {
if (match) {
@@ -497,6 +501,11 @@ export default {
<style>
.matchListWrapper {
height: calc(100% - 80px);
height: calc(100% - 124px);
}
@media (min-width: 768px) {
.matchListWrapper {
height: calc(100% - 80px);
}
}
</style>

View File

@@ -5,33 +5,14 @@
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<div ref="wrapper" class="p-4 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-y-auto" style="max-height: 80vh">
<div class="flex flex-wrap">
<div class="w-1/5 p-1">
<ui-text-input-with-label v-model="newEpisode.season" label="Season" />
</div>
<div class="w-1/5 p-1">
<ui-text-input-with-label v-model="newEpisode.episode" label="Episode" />
</div>
<div class="w-1/5 p-1">
<ui-text-input-with-label v-model="newEpisode.episodeType" label="Episode Type" />
</div>
<div class="w-2/5 p-1">
<ui-text-input-with-label v-model="pubDateInput" @input="updatePubDate" type="datetime-local" label="Pub Date" />
</div>
<div class="w-full p-1">
<ui-text-input-with-label v-model="newEpisode.title" label="Title" />
</div>
<div class="w-full p-1">
<ui-textarea-with-label v-model="newEpisode.subtitle" label="Subtitle" :rows="3" />
</div>
<div class="w-full p-1 default-style">
<ui-rich-text-editor v-if="show" label="Description" v-model="newEpisode.description" />
</div>
</div>
<div class="flex justify-end pt-4">
<ui-btn @click="submit">Submit</ui-btn>
</div>
<div class="absolute -top-10 left-0 z-10 w-full flex">
<template v-for="tab in tabs">
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-0.5 sm:mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
</template>
</div>
<div ref="wrapper" class="p-4 w-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative overflow-y-auto" style="max-height: 80vh">
<component v-if="libraryItem && show" :is="tabComponentName" :library-item="libraryItem" :episode="episode" :processing.sync="processing" @close="show = false" @selectTab="selectTab" />
</div>
</modals-modal>
</template>
@@ -41,25 +22,19 @@ export default {
data() {
return {
processing: false,
newEpisode: {
season: null,
episode: null,
episodeType: null,
title: null,
subtitle: null,
description: null,
pubDate: null,
publishedAt: null
},
pubDateInput: null
}
},
watch: {
episode: {
immediate: true,
handler(newVal) {
if (newVal) this.init()
}
selectedTab: 'details',
tabs: [
{
id: 'details',
title: 'Details',
component: 'modals-podcast-tabs-episode-details'
},
{
id: 'match',
title: 'Match',
component: 'modals-podcast-tabs-episode-match'
}
]
}
},
computed: {
@@ -77,67 +52,29 @@ export default {
episode() {
return this.$store.state.globals.selectedEpisode
},
episodeId() {
return this.episode ? this.episode.id : null
},
title() {
if (!this.libraryItem) return ''
return this.libraryItem.media.metadata.title || 'Unknown'
},
tabComponentName() {
var _tab = this.tabs.find((t) => t.id === this.selectedTab)
return _tab ? _tab.component : ''
}
},
methods: {
updatePubDate(val) {
if (val) {
this.newEpisode.pubDate = this.$formatJsDate(new Date(val), 'E, d MMM yyyy HH:mm:ssxx')
this.newEpisode.publishedAt = new Date(val).valueOf()
} else {
this.newEpisode.pubDate = null
this.newEpisode.publishedAt = null
}
},
init() {
this.newEpisode.season = this.episode.season || ''
this.newEpisode.episode = this.episode.episode || ''
this.newEpisode.episodeType = this.episode.episodeType || ''
this.newEpisode.title = this.episode.title || ''
this.newEpisode.subtitle = this.episode.subtitle || ''
this.newEpisode.description = this.episode.description || ''
this.newEpisode.pubDate = this.episode.pubDate || ''
this.newEpisode.publishedAt = this.episode.publishedAt
this.pubDateInput = this.episode.pubDate ? this.$formatJsDate(new Date(this.episode.pubDate), "yyyy-MM-dd'T'HH:mm") : null
},
getUpdatePayload() {
var updatePayload = {}
for (const key in this.newEpisode) {
if (this.newEpisode[key] != this.episode[key]) {
updatePayload[key] = this.newEpisode[key]
}
}
return updatePayload
},
submit() {
const payload = this.getUpdatePayload()
if (!Object.keys(payload).length) {
return this.$toast.info('No updates were made')
}
this.processing = true
this.$axios
.$patch(`/api/podcasts/${this.libraryItem.id}/episode/${this.episodeId}`, payload)
.then(() => {
this.processing = false
this.$toast.success('Podcast episode updated')
this.show = false
})
.catch((error) => {
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed update episode'
console.error('Failed update episode', error)
this.processing = false
this.$toast.error(errorMsg)
})
selectTab(tab) {
this.selectedTab = tab
}
},
mounted() {}
}
</script>
<style scoped>
.tab {
height: 40px;
}
.tab.tab-selected {
height: 41px;
}
</style>

View File

@@ -0,0 +1,136 @@
<template>
<div>
<div class="flex flex-wrap">
<div class="w-1/5 p-1">
<ui-text-input-with-label v-model="newEpisode.season" label="Season" />
</div>
<div class="w-1/5 p-1">
<ui-text-input-with-label v-model="newEpisode.episode" label="Episode" />
</div>
<div class="w-1/5 p-1">
<ui-text-input-with-label v-model="newEpisode.episodeType" label="Episode Type" />
</div>
<div class="w-2/5 p-1">
<ui-text-input-with-label v-model="pubDateInput" @input="updatePubDate" type="datetime-local" label="Pub Date" />
</div>
<div class="w-full p-1">
<ui-text-input-with-label v-model="newEpisode.title" label="Title" />
</div>
<div class="w-full p-1">
<ui-textarea-with-label v-model="newEpisode.subtitle" label="Subtitle" :rows="3" />
</div>
<div class="w-full p-1 default-style">
<ui-rich-text-editor label="Description" v-model="newEpisode.description" />
</div>
</div>
<div class="flex justify-end pt-4">
<ui-btn @click="submit">Submit</ui-btn>
</div>
</div>
</template>
<script>
export default {
props: {
processing: Boolean,
libraryItem: {
type: Object,
default: () => {}
},
episode: {
type: Object,
default: () => {}
}
},
data() {
return {
newEpisode: {
season: null,
episode: null,
episodeType: null,
title: null,
subtitle: null,
description: null,
pubDate: null,
publishedAt: null
},
pubDateInput: null
}
},
watch: {
episode: {
immediate: true,
handler(newVal) {
if (newVal) this.init()
}
}
},
computed: {
isProcessing: {
get() {
return this.processing
},
set(val) {
this.$emit('update:processing', val)
}
},
episodeId() {
return this.episode ? this.episode.id : null
}
},
methods: {
updatePubDate(val) {
if (val) {
this.newEpisode.pubDate = this.$formatJsDate(new Date(val), 'E, d MMM yyyy HH:mm:ssxx')
this.newEpisode.publishedAt = new Date(val).valueOf()
} else {
this.newEpisode.pubDate = null
this.newEpisode.publishedAt = null
}
},
init() {
this.newEpisode.season = this.episode.season || ''
this.newEpisode.episode = this.episode.episode || ''
this.newEpisode.episodeType = this.episode.episodeType || ''
this.newEpisode.title = this.episode.title || ''
this.newEpisode.subtitle = this.episode.subtitle || ''
this.newEpisode.description = this.episode.description || ''
this.newEpisode.pubDate = this.episode.pubDate || ''
this.newEpisode.publishedAt = this.episode.publishedAt
this.pubDateInput = this.episode.pubDate ? this.$formatJsDate(new Date(this.episode.pubDate), "yyyy-MM-dd'T'HH:mm") : null
},
getUpdatePayload() {
var updatePayload = {}
for (const key in this.newEpisode) {
if (this.newEpisode[key] != this.episode[key]) {
updatePayload[key] = this.newEpisode[key]
}
}
return updatePayload
},
submit() {
const payload = this.getUpdatePayload()
if (!Object.keys(payload).length) {
return this.$toast.info('No updates were made')
}
this.isProcessing = true
this.$axios
.$patch(`/api/podcasts/${this.libraryItem.id}/episode/${this.episodeId}`, payload)
.then(() => {
this.isProcessing = false
this.$toast.success('Podcast episode updated')
this.$emit('close')
})
.catch((error) => {
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to update episode'
console.error('Failed update episode', error)
this.isProcessing = false
this.$toast.error(errorMsg)
})
}
},
mounted() {}
}
</script>

View File

@@ -0,0 +1,156 @@
<template>
<div style="min-height: 200px">
<template v-if="!podcastFeedUrl">
<div class="py-8">
<widgets-alert type="error">Podcast has no RSS feed url to use for matching</widgets-alert>
</div>
</template>
<template v-else>
<form @submit.prevent="submitForm">
<div class="flex mb-2">
<ui-text-input-with-label v-model="episodeTitle" :disabled="isProcessing" label="Episode Title" class="pr-1" />
<ui-btn class="mt-5 ml-1" :loading="isProcessing" type="submit">Search</ui-btn>
</div>
</form>
<div v-if="!isProcessing && searchedTitle && !episodesFound.length" class="w-full py-8">
<p class="text-center text-lg">No episode matches found</p>
</div>
<div v-for="(episode, index) in episodesFound" :key="index" class="w-full py-4 border-b border-white border-opacity-5 hover:bg-gray-300 hover:bg-opacity-10 cursor-pointer px-2" @click.stop="selectEpisode(episode)">
<p v-if="episode.episode" class="font-semibold text-gray-200">#{{ episode.episode }}</p>
<p class="break-words mb-1">{{ episode.title }}</p>
<p v-if="episode.subtitle" class="break-words mb-1 text-sm text-gray-300 episode-subtitle">{{ episode.subtitle }}</p>
<p class="text-xs text-gray-400">Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}</p>
</div>
</template>
</div>
</template>
<script>
export default {
props: {
processing: Boolean,
libraryItem: {
type: Object,
default: () => {}
},
episode: {
type: Object,
default: () => {}
}
},
data() {
return {
episodeTitle: '',
searchedTitle: '',
episodesFound: []
}
},
watch: {
episode: {
immediate: true,
handler(newVal) {
if (newVal) this.init()
}
}
},
computed: {
isProcessing: {
get() {
return this.processing
},
set(val) {
this.$emit('update:processing', val)
}
},
episodeId() {
return this.episode ? this.episode.id : null
},
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
},
mediaMetadata() {
return this.media.metadata || {}
},
podcastFeedUrl() {
return this.mediaMetadata.feedUrl
}
},
methods: {
getUpdatePayload(episodeData) {
var updatePayload = {}
for (const key in episodeData) {
if (key === 'enclosure') {
if (!this.episode.enclosure || JSON.stringify(this.episode.enclosure) !== JSON.stringify(episodeData.enclosure)) {
updatePayload[key] = {
...episodeData.enclosure
}
}
} else if (episodeData[key] != this.episode[key]) {
updatePayload[key] = episodeData[key]
}
}
return updatePayload
},
selectEpisode(episode) {
const episodeData = {
title: episode.title || '',
subtitle: episode.subtitle || '',
description: episode.description || '',
enclosure: episode.enclosure || null,
episode: episode.episode || '',
episodeType: episode.episodeType || '',
season: episode.season || '',
pubDate: episode.pubDate || '',
publishedAt: episode.publishedAt
}
const updatePayload = this.getUpdatePayload(episodeData)
if (!Object.keys(updatePayload).length) {
return this.$toast.info('No updates are necessary')
}
console.log('Episode update payload', updatePayload)
this.isProcessing = true
this.$axios
.$patch(`/api/podcasts/${this.libraryItem.id}/episode/${this.episodeId}`, updatePayload)
.then(() => {
this.isProcessing = false
this.$toast.success('Podcast episode updated')
this.$emit('selectTab', 'details')
})
.catch((error) => {
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to update episode'
console.error('Failed update episode', error)
this.isProcessing = false
this.$toast.error(errorMsg)
})
},
submitForm() {
if (!this.episodeTitle || !this.episodeTitle.length) {
this.$toast.error('Must enter an episode title')
return
}
this.searchedTitle = this.episodeTitle
this.isProcessing = true
this.$axios
.$get(`/api/podcasts/${this.libraryItem.id}/search-episode?title=${this.$encodeUriPath(this.episodeTitle)}`)
.then((results) => {
this.episodesFound = results.episodes.map((ep) => ep.episode)
console.log('Episodes found', this.episodesFound)
this.isProcessing = false
})
.catch((error) => {
console.error('Failed to search for episode', error)
var errMsg = error.response ? error.response.data || '' : ''
this.$toast.error(errMsg || 'Failed to search for episode')
this.isProcessing = false
})
},
init() {
this.searchedTitle = null
this.episodesFound = []
this.episodeTitle = this.episode ? this.episode.title || '' : ''
}
},
mounted() {}
}
</script>

View File

@@ -226,6 +226,13 @@ export default {
})
this.updateTimestamp()
},
checkUpdateChapterTrack() {
// Changing media in player may not have chapters
if (!this.chapters.length && this.useChapterTrack) {
this.useChapterTrack = false
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack)
}
},
seek(time) {
this.$emit('seek', time)
},
@@ -286,7 +293,10 @@ export default {
},
init() {
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
this.useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack') || false
var _useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack') || false
this.useChapterTrack = this.chapters.length ? _useChapterTrack : false
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack)
this.$emit('setPlaybackRate', this.playbackRate)
},

View File

@@ -0,0 +1,117 @@
<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-60 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" class="relative text-white" :style="{ height: modalHeight, width: modalWidth }" v-click-outside="clickedOutside">
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
<p class="text-base mb-8 mt-2 px-1">{{ message }}</p>
<div class="flex px-1 items-center">
<ui-btn v-if="isYesNo" color="primary" @click="nevermind">Cancel</ui-btn>
<div class="flex-grow" />
<ui-btn v-if="isYesNo" color="success" @click="confirm">Yes</ui-btn>
<ui-btn v-else color="primary" @click="confirm">Ok</ui-btn>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
props: {},
data() {
return {
el: null,
content: null
}
},
watch: {
show(newVal) {
if (newVal) {
this.setShow()
} else {
this.setHide()
}
}
},
computed: {
show: {
get() {
return this.$store.state.globals.showConfirmPrompt
},
set(val) {
this.$store.commit('globals/setShowConfirmPrompt', val)
}
},
confirmPromptOptions() {
return this.$store.state.globals.confirmPromptOptions || {}
},
message() {
return this.confirmPromptOptions.message || ''
},
callback() {
return this.confirmPromptOptions.callback
},
type() {
return this.confirmPromptOptions.type || 'ok'
},
persistent() {
return !!this.confirmPromptOptions.persistent
},
isYesNo() {
return this.type === 'yesNo'
},
modalHeight() {
return 'unset'
},
modalWidth() {
return '500px'
}
},
methods: {
clickedOutside(evt) {
if (!this.show) return
if (evt) {
evt.stopPropagation()
evt.preventDefault()
}
if (this.persistent) return
if (this.callback) this.callback(false)
this.show = false
},
nevermind() {
if (this.callback) this.callback(false)
this.show = false
},
confirm() {
if (this.callback) this.callback(true)
this.show = false
},
setShow() {
this.$eventBus.$emit('showing-prompt', true)
document.body.appendChild(this.el)
setTimeout(() => {
this.content.style.transform = 'scale(1)'
}, 10)
},
setHide() {
this.$eventBus.$emit('showing-prompt', false)
this.content.style.transform = 'scale(0)'
this.el.remove()
}
},
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()
},
beforeDestroy() {
if (this.show) {
this.$eventBus.$emit('showing-prompt', false)
}
}
}
</script>

View File

@@ -65,12 +65,10 @@ export default {
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() {

View File

@@ -24,10 +24,10 @@
<td class="font-book">
{{ chapter.title }}
</td>
<td class="font-mono text-center">
<td class="font-mono text-center hover:underline cursor-pointer" @click.stop="goToTimestamp(chapter.start)">
{{ $secondsToTimestamp(chapter.start) }}
</td>
<td class="font-mono text-center">
<td class="font-mono text-center hover:underline cursor-pointer" @click.stop="goToTimestamp(chapter.start)">
{{ $secondsToTimestamp(chapter.end) }}
</td>
</tr>
@@ -57,6 +57,9 @@ export default {
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
},
metadata() {
return this.media.metadata || {}
},
chapters() {
return this.media.chapters || []
},
@@ -67,6 +70,30 @@ export default {
methods: {
clickBar() {
this.expanded = !this.expanded
},
goToTimestamp(time) {
if (this.$store.getters['getIsMediaStreaming'](this.libraryItemId)) {
this.$eventBus.$emit('play-item', {
libraryItemId: this.libraryItemId,
episodeId: null,
startTime: time
})
} else {
const payload = {
message: `Start playback for "${this.metadata.title}" at ${this.$secondsToTimestamp(time)}?`,
callback: (confirmed) => {
if (confirmed) {
this.$eventBus.$emit('play-item', {
libraryItemId: this.libraryItemId,
episodeId: null,
startTime: time
})
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
}
}
},
mounted() {}

View File

@@ -1,12 +1,13 @@
<template>
<div class="w-full bg-primary bg-opacity-40">
<div class="w-full h-14 flex items-center px-4 bg-primary">
<p>Collection List</p>
<div class="w-6 h-6 bg-white bg-opacity-10 flex items-center justify-center rounded-full ml-2">
<p class="font-mono text-sm">{{ books.length }}</p>
<div class="w-full h-14 flex items-center px-4 md:px-6 py-2 bg-primary">
<p class="pr-4">Collection List</p>
<div class="w-6 h-6 md:w-7 md:h-7 bg-white bg-opacity-10 rounded-full flex items-center justify-center">
<span class="text-xs md:text-sm font-mono leading-none">{{ books.length }}</span>
</div>
<div class="flex-grow" />
<!-- <p v-if="totalDuration">{{ totalDurationPretty }}</p> -->
<p v-if="totalDuration" class="text-sm text-gray-200">{{ totalDurationPretty }}</p>
</div>
<draggable v-model="booksCopy" v-bind="dragOptions" class="list-group" handle=".drag-handle" draggable=".item" tag="div" @start="drag = true" @end="drag = false" @update="draggableUpdate">
<transition-group type="transition" :name="!drag ? 'collection-book' : null">
@@ -56,6 +57,16 @@ export default {
},
bookCoverAspectRatio() {
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6
},
totalDuration() {
var _total = 0
this.books.forEach((book) => {
_total += book.media.duration
})
return _total
},
totalDurationPretty() {
return this.$elapsedPrettyExtended(this.totalDuration)
}
},
methods: {

View File

@@ -1,12 +1,12 @@
<template>
<div class="w-full px-2 py-2 overflow-hidden relative" @mouseover="mouseover" @mouseleave="mouseleave" :class="isHovering ? 'bg-white bg-opacity-5' : ''">
<div v-if="book" class="flex h-20">
<div class="w-16 max-w-16 h-full">
<div class="w-full px-1 md:px-2 py-2 overflow-hidden relative" @mouseover="mouseover" @mouseleave="mouseleave" :class="isHovering ? 'bg-white bg-opacity-5' : ''">
<div v-if="book" class="flex h-16 md:h-20">
<div class="w-10 min-w-10 md:w-16 md:max-w-16 h-full">
<div class="flex h-full items-center justify-center">
<span class="material-icons drag-handle text-xl">menu</span>
<span class="material-icons drag-handle text-lg md:text-xl">menu</span>
</div>
</div>
<div class="h-full relative" :style="{ width: coverWidth + 'px' }">
<div class="h-full relative" :style="{ width: coverWidth + 'px', minWidth: coverWidth + 'px', maxWidth: coverWidth + 'px' }">
<covers-book-cover :library-item="book" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<div class="absolute top-0 left-0 bg-black bg-opacity-50 flex items-center justify-center h-full w-full z-10" v-show="isHovering && showPlayBtn">
<div class="w-8 h-8 bg-white bg-opacity-20 rounded-full flex items-center justify-center hover:bg-opacity-40 cursor-pointer" @click="playClick">
@@ -107,9 +107,12 @@ export default {
userIsFinished() {
return this.itemProgress ? !!this.itemProgress.isFinished : false
},
coverSize() {
return this.$store.state.globals.isMobile ? 40 : 50
},
coverWidth() {
if (this.bookCoverAspectRatio === 1) return 50 * 1.6
return 50
if (this.bookCoverAspectRatio === 1) return this.coverSize * 1.6
return this.coverSize
}
},
methods: {

View File

@@ -6,7 +6,7 @@
<span class="material-icons" style="font-size: 1.4rem">add</span>
</div>
</div>
<draggable v-if="libraryCopies.length" :list="libraryCopies" v-bind="dragOptions" class="list-group" draggable=".item" tag="div" @start="startDrag" @end="endDrag">
<draggable v-if="libraryCopies.length" :list="libraryCopies" v-bind="dragOptions" class="list-group" handle=".drag-handle" draggable=".item" tag="div" @start="startDrag" @end="endDrag">
<template v-for="library in libraryCopies">
<div :key="library.id" class="item">
<tables-library-item :library="library" :selected="currentLibraryId === library.id" :dragging="drag" @edit="editLibrary" @click="setLibrary" />

View File

@@ -23,7 +23,7 @@
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
</svg>
</div>
<span class="material-icons text-xl text-gray-400 hover:text-gray-50 ml-4">reorder</span>
<span class="material-icons drag-handle text-xl text-gray-400 hover:text-gray-50 ml-4">reorder</span>
<!-- For mobile -->
<modals-dialog v-model="showMobileMenu" :title="menuTitle" :items="mobileMenuItems" @action="mobileMenuAction" />
@@ -105,7 +105,7 @@ export default {
},
matchAll() {
this.$axios
.$post(`/api/libraries/${this.library.id}/matchall`)
.$get(`/api/libraries/${this.library.id}/matchall`)
.then(() => {
console.log('Starting scan for matches')
})

View File

@@ -78,7 +78,7 @@ export default {
return this.$secondsToTimestamp(this.episode.duration)
},
isStreaming() {
return this.$store.getters['getIsEpisodeStreaming'](this.libraryItemId, this.episode.id)
return this.$store.getters['getIsMediaStreaming'](this.libraryItemId, this.episode.id)
},
streamIsPlaying() {
return this.$store.state.streamIsPlaying && this.isStreaming
@@ -124,7 +124,21 @@ export default {
})
}
},
toggleFinished() {
toggleFinished(confirmed = false) {
if (!this.userIsFinished && this.itemProgressPercent > 0 && !confirmed) {
const payload = {
message: `Are you sure you want to mark "${this.title}" as finished?`,
callback: (confirmed) => {
if (confirmed) {
this.toggleFinished(true)
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
return
}
var updatePayload = {
isFinished: !this.userIsFinished
}

View File

@@ -89,6 +89,9 @@ export default {
// this.currentSearch = this.textInput
}, 100)
},
setFocus() {
if (this.$refs.input && this.editable) this.$refs.input.focus()
},
inputFocus() {
this.isFocused = true
},

View File

@@ -1,18 +1,18 @@
<template>
<div v-if="currentLibrary" class="relative sm:w-36 h-8 px-1.5" v-click-outside="clickOutsideObj">
<button type="button" :disabled="disabled" class="w-10 sm:w-36 relative h-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm px-2 text-left text-sm focus:outline-none cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
<span class="flex items-center justify-center sm:justify-start">
<widgets-library-icon :icon="currentLibraryIcon" class="sm:mr-2" />
<span class="hidden sm:block">{{ currentLibrary.name }}</span>
</span>
<div v-if="currentLibrary" class="relative h-8 max-w-52 min-w-32" v-click-outside="clickOutsideObj">
<button type="button" :disabled="disabled" class="w-10 sm:w-full relative h-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm px-2 text-left text-sm focus:outline-none cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
<div class="flex items-center justify-center sm:justify-start">
<widgets-library-icon :icon="currentLibraryIcon" class="sm:mr-1.5" />
<span class="hidden sm:block truncate">{{ currentLibrary.name }}</span>
</div>
</button>
<transition name="menu">
<ul v-show="showMenu" class="absolute z-10 -mt-px w-36 bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox">
<ul v-show="showMenu" class="absolute z-10 -mt-px min-w-48 w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox">
<template v-for="library in librariesFiltered">
<li :key="library.id" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="selectLibrary(library)">
<div class="flex items-center px-3">
<widgets-library-icon :icon="library.icon" class="mr-2" />
<div class="flex items-center px-2">
<widgets-library-icon :icon="library.icon" class="mr-1.5 text-gray-400" />
<span class="font-normal block truncate font-sans text-sm">{{ library.name }}</span>
</div>
</li>

View File

@@ -80,6 +80,9 @@ export default {
blur() {
if (this.$refs.input) this.$refs.input.blur()
},
setFocus() {
if (this.$refs.input) this.$refs.input.focus()
},
mouseover() {
this.isHovering = true
},

View File

@@ -34,6 +34,11 @@ export default {
}
},
methods: {
setFocus() {
if (this.$refs.input && this.$refs.input.setFocus) {
this.$refs.input.setFocus()
}
},
blur() {
if (this.$refs.input && this.$refs.input.blur) {
this.$refs.input.blur()

View File

@@ -0,0 +1,212 @@
<template>
<div class="relative">
<div class="rounded text-gray-200 border w-full px-3 py-2" :class="focusedDigit ? 'bg-primary bg-opacity-50 border-gray-300' : 'bg-primary border-gray-600'" @click="clickInput" v-click-outside="clickOutsideObj">
<div class="flex items-center">
<template v-for="(digit, index) in digitDisplay">
<div v-if="digit == ':'" :key="index" class="px-px" @click.stop="clickMedian(index)">:</div>
<div v-else :key="index" class="px-px" :class="{ 'digit-focused': focusedDigit == digit }" @click.stop="focusDigit(digit)">{{ digits[digit] }}</div>
</template>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
value: [String, Number],
showThreeDigitHour: Boolean
},
data() {
return {
clickOutsideObj: {
handler: this.clickOutside,
events: ['mousedown'],
isActive: true
},
digitDisplay: ['hour1', 'hour0', ':', 'minute1', 'minute0', ':', 'second1', 'second0'],
focusedDigit: null,
digits: {
hour2: 0,
hour1: 0,
hour0: 0,
minute1: 0,
minute0: 0,
second1: 0,
second0: 0
},
isOver99Hours: false
}
},
watch: {
value: {
immediate: true,
handler() {
this.initDigits()
}
}
},
computed: {},
methods: {
initDigits() {
var totalSeconds = !this.value || isNaN(this.value) ? 0 : Number(this.value)
totalSeconds = Math.round(totalSeconds)
var minutes = Math.floor(totalSeconds / 60)
var seconds = totalSeconds - minutes * 60
var hours = Math.floor(minutes / 60)
minutes -= hours * 60
this.digits.second1 = seconds <= 9 ? 0 : Number(String(seconds)[0])
this.digits.second0 = seconds <= 9 ? seconds : Number(String(seconds)[1])
this.digits.minute1 = minutes <= 9 ? 0 : Number(String(minutes)[0])
this.digits.minute0 = minutes <= 9 ? minutes : Number(String(minutes)[1])
if (hours > 99) {
this.digits.hour2 = Number(String(hours)[0])
this.digits.hour1 = Number(String(hours)[1])
this.digits.hour0 = Number(String(hours)[2])
this.isOver99Hours = true
} else {
this.digits.hour1 = hours <= 9 ? 0 : Number(String(hours)[0])
this.digits.hour0 = hours <= 9 ? hours : Number(String(hours)[1])
this.isOver99Hours = this.showThreeDigitHour
}
if (this.isOver99Hours) {
this.digitDisplay = ['hour2', 'hour1', 'hour0', ':', 'minute1', 'minute0', ':', 'second1', 'second0']
} else {
this.digitDisplay = ['hour1', 'hour0', ':', 'minute1', 'minute0', ':', 'second1', 'second0']
}
},
updateSeconds() {
var seconds = this.digits.second0 + this.digits.second1 * 10
seconds += this.digits.minute0 * 60 + this.digits.minute1 * 600
seconds += this.digits.hour0 * 3600 + this.digits.hour1 * 36000
if (this.isOver99Hours) seconds += this.digits.hour2 * 360000
if (Number(this.value) !== seconds) {
this.$emit('input', seconds)
this.$emit('change', seconds)
}
},
clickMedian(index) {
// Click colon select digit to right
if (index >= 5) {
this.focusedDigit = 'second1'
} else {
this.focusedDigit = 'minute1'
}
},
clickOutside() {
this.removeFocus()
},
removeFocus() {
this.focusedDigit = null
this.removeListeners()
},
focusDigit(digit) {
if (this.focusedDigit == null || isNaN(this.focusedDigit)) this.initListeners()
this.focusedDigit = digit
},
clickInput() {
if (this.focusedDigit) return
this.focusDigit('second0')
},
shiftFocusLeft() {
if (!this.focusedDigit) return
if (this.focusedDigit.endsWith('2')) return
const isDigit1 = this.focusedDigit.endsWith('1')
if (!isDigit1) {
const digit1Key = this.focusedDigit.replace('0', '1')
this.focusedDigit = digit1Key
} else if (this.focusedDigit.startsWith('second')) {
this.focusedDigit = 'minute0'
} else if (this.focusedDigit.startsWith('minute')) {
this.focusedDigit = 'hour0'
} else if (this.isOver99Hours && this.focusedDigit.startsWith('hour')) {
this.focusedDigit = 'hour2'
}
},
shiftFocusRight() {
if (!this.focusedDigit) return
if (this.focusedDigit.endsWith('2')) {
// Must be hour2
this.focusedDigit = 'hour1'
return
}
const isDigit1 = this.focusedDigit.endsWith('1')
if (isDigit1) {
const digit0Key = this.focusedDigit.replace('1', '0')
this.focusedDigit = digit0Key
} else if (this.focusedDigit.startsWith('hour')) {
this.focusedDigit = 'minute1'
} else if (this.focusedDigit.startsWith('minute')) {
this.focusedDigit = 'second1'
}
},
increaseFocused() {
if (!this.focusedDigit) return
const isDigit1 = this.focusedDigit.endsWith('1')
const digit = Number(this.digits[this.focusedDigit])
if (isDigit1 && !this.focusedDigit.startsWith('hour')) this.digits[this.focusedDigit] = (digit + 1) % 6
else this.digits[this.focusedDigit] = (digit + 1) % 10
this.updateSeconds()
},
decreaseFocused() {
if (!this.focusedDigit) return
const isDigit1 = this.focusedDigit.endsWith('1')
const digit = Number(this.digits[this.focusedDigit])
if (isDigit1 && !this.focusedDigit.startsWith('hour')) this.digits[this.focusedDigit] = digit - 1 < 0 ? 5 : digit - 1
else this.digits[this.focusedDigit] = digit - 1 < 0 ? 9 : digit - 1
this.updateSeconds()
},
keydown(evt) {
if (!this.focusedDigit || !evt.key) return
if (evt.key === 'ArrowLeft') {
return this.shiftFocusLeft()
} else if (evt.key === 'ArrowRight') {
return this.shiftFocusRight()
} else if (evt.key === 'ArrowUp') {
return this.increaseFocused()
} else if (evt.key === 'ArrowDown') {
return this.decreaseFocused()
} else if (evt.key === 'Enter' || evt.key === 'Escape') {
return this.removeFocus()
}
if (isNaN(evt.key)) return
var digit = Number(evt.key)
const isDigit1 = this.focusedDigit.endsWith('1')
if (isDigit1 && !this.focusedDigit.startsWith('hour') && digit >= 6) {
digit = 5
}
this.digits[this.focusedDigit] = digit
this.updateSeconds()
this.shiftFocusRight()
},
initListeners() {
window.addEventListener('keydown', this.keydown)
},
removeListeners() {
window.removeEventListener('keydown', this.keydown)
}
},
mounted() {},
beforeDestroy() {
this.removeListeners()
}
}
</script>
<style scoped>
.digit-focused {
background-color: #555;
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<div :class="`h-${size} w-${size}`">
<div :class="`h-${size} w-${size} min-w-${size}`">
<component :is="iconComponentName" />
</div>
</template>

View File

@@ -1,7 +1,7 @@
<template>
<div class="absolute w-36 bg-bg rounded-md border border-black-200 shadow-lg z-50" v-click-outside="clickOutsideObj" style="top: 0; left: 0">
<template v-for="(item, index) in items">
<div :key="index" class="flex h-7 items-center px-2 hover:bg-white hover:bg-opacity-5 text-white text-xs cursor-pointer" @click="clickAction(item.func)">
<div :key="index" class="flex h-7 items-center px-2 hover:bg-white hover:bg-opacity-5 text-white text-xs cursor-pointer" @click.stop="clickAction(item.func)">
<p>{{ item.text }}</p>
</div>
</template>

View File

@@ -5,5 +5,10 @@
</template>
<script>
export default {}
export default {
mounted() {
document.body.classList.remove('app-bar', 'app-bar-and-toolbar')
document.body.classList.add('no-bars')
}
}
</script>

View File

@@ -15,6 +15,7 @@
<modals-podcast-edit-episode />
<modals-podcast-view-episode />
<modals-authors-edit-modal />
<prompt-confirm />
<readers-reader />
</div>
</template>
@@ -40,6 +41,7 @@ export default {
if (this.$store.state.selectedLibraryItems) {
this.$store.commit('setSelectedLibraryItems', [])
}
this.updateBodyClass()
}
},
computed: {
@@ -53,11 +55,23 @@ export default {
if (!this.$route.name) return false
return !this.$route.name.startsWith('config') && this.$store.state.libraries.currentLibraryId
},
isShowingToolbar() {
return this.isShowingSideRail && this.$route.name !== 'upload' && this.$route.name !== 'account'
},
appContentMarginLeft() {
return this.isShowingSideRail ? 80 : 0
}
},
methods: {
updateBodyClass() {
if (this.isShowingToolbar) {
document.body.classList.remove('no-bars', 'app-bar')
document.body.classList.add('app-bar-and-toolbar')
} else {
document.body.classList.remove('no-bars', 'app-bar-and-toolbar')
document.body.classList.add('app-bar')
}
},
updateSocketConnectionToast(content, type, timeout) {
if (this.socketConnectionToastId !== null && this.socketConnectionToastId !== undefined) {
this.$toast.update(this.socketConnectionToastId, { content: content, options: { timeout: timeout, type: type, closeButton: false, position: 'bottom-center', onClose: () => null, closeOnClick: timeout !== null } }, false)
@@ -197,6 +211,12 @@ export default {
libraryItemUpdated(libraryItem) {
if (this.$store.state.selectedLibraryItem && this.$store.state.selectedLibraryItem.id === libraryItem.id) {
this.$store.commit('setSelectedLibraryItem', libraryItem)
if (this.$store.state.globals.selectedEpisode && libraryItem.mediaType === 'podcast') {
const episode = libraryItem.media.episodes.find((ep) => ep.id === this.$store.state.globals.selectedEpisode.id)
if (episode) {
this.$store.commit('globals/setSelectedEpisode', episode)
}
}
}
this.$eventBus.$emit(`${libraryItem.id}_updated`, libraryItem)
this.$store.commit('libraries/updateFilterDataWithItem', libraryItem)
@@ -341,11 +361,11 @@ export default {
download.status = this.$constants.DownloadStatus.EXPIRED
this.$store.commit('downloads/addUpdateDownload', download)
},
showErrorToast(message) {
this.$toast.error(message)
rssFeedOpen(data) {
this.$store.commit('feeds/addFeed', data)
},
showSuccessToast(message) {
this.$toast.success(message)
rssFeedClosed(data) {
this.$store.commit('feeds/removeFeed', data)
},
backupApplied() {
// Force refresh
@@ -417,9 +437,9 @@ export default {
this.socket.on('abmerge_killed', this.abmergeKilled)
this.socket.on('abmerge_expired', this.abmergeExpired)
// Toast Listeners
this.socket.on('show_error_toast', this.showErrorToast)
this.socket.on('show_success_toast', this.showSuccessToast)
// Feed Listeners
this.socket.on('rss_feed_open', this.rssFeedOpen)
this.socket.on('rss_feed_closed', this.rssFeedClosed)
this.socket.on('backup_applied', this.backupApplied)
},
@@ -521,6 +541,7 @@ export default {
this.initializeSocket()
},
mounted() {
this.updateBodyClass()
this.resize()
window.addEventListener('resize', this.resize)
window.addEventListener('keydown', this.keyDown)

View File

@@ -52,13 +52,13 @@ export default {
width: this.entityWidth,
height: this.entityHeight,
bookCoverAspectRatio: this.bookCoverAspectRatio,
bookshelfView: this.bookshelfView
bookshelfView: this.bookshelfView,
sortingIgnorePrefix: !!this.sortingIgnorePrefix
}
if (this.entityName === 'books') {
props.filterBy = this.filterBy
props.orderBy = this.orderBy
props.sortingIgnorePrefix = !!this.sortingIgnorePrefix
}
var _this = this

View File

@@ -108,15 +108,15 @@ module.exports = {
background_color: '#373838',
icons: [
{
src: '/icon64.png',
src: '/icon.svg',
sizes: "64x64"
},
{
src: '/icon192.png',
src: '/icon.svg',
sizes: "192x192"
},
{
src: '/Logo.png',
src: '/icon.svg',
sizes: "512x512"
}
]

View File

@@ -1,12 +1,12 @@
{
"name": "audiobookshelf-client",
"version": "2.0.23",
"version": "2.1.2",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf-client",
"version": "2.0.22",
"version": "2.1.2",
"license": "ISC",
"dependencies": {
"@nuxtjs/axios": "^5.13.6",
@@ -29,7 +29,8 @@
"@nuxtjs/pwa": "^3.3.5",
"@nuxtjs/tailwindcss": "^4.2.1",
"autoprefixer": "^10.4.7",
"postcss": "^8.3.6"
"postcss": "^8.3.6",
"tailwindcss": "^3.1.4"
}
},
"node_modules/@ampproject/remapping": {
@@ -1851,7 +1852,7 @@
"version": "2.6.12",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz",
"integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==",
"deprecated": "core-js@<3.4 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Please, upgrade your dependencies to the actual version of core-js.",
"deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.",
"hasInstallScript": true
},
"node_modules/@nuxt/builder": {
@@ -5261,6 +5262,7 @@
"version": "3.22.5",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.22.5.tgz",
"integrity": "sha512-VP/xYuvJ0MJWRAobcmQ8F2H6Bsn+s7zqAAjFaHGBMc5AQm7zaelhD1LGduFn2EehEcQcU+br6t+fwbpQ5d1ZWA==",
"deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.",
"hasInstallScript": true,
"funding": {
"type": "opencollective",
@@ -9854,7 +9856,6 @@
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
"dev": true,
"peer": true,
"engines": {
"node": ">= 6"
}
@@ -11447,7 +11448,6 @@
"resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.0.tgz",
"integrity": "sha512-77QESFBwgX4irogGVPgQ5s07vLvFqWr228qZY+w6lW599cRlK/HmnlivnnVUxkjHnCu4J16PDMHcH+e+2HbvTQ==",
"dev": true,
"peer": true,
"dependencies": {
"camelcase-css": "^2.0.1"
},
@@ -14492,7 +14492,8 @@
"node_modules/stable": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz",
"integrity": "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w=="
"integrity": "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==",
"deprecated": "Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility"
},
"node_modules/stack-trace": {
"version": "0.0.10",
@@ -14933,7 +14934,6 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.1.4.tgz",
"integrity": "sha512-NrxbFV4tYsga/hpWbRyUfIaBrNMXDxx5BsHgBS4v5tlyjf+sDsgBg5m9OxjrXIqAS/uR9kicxLKP+bEHI7BSeQ==",
"dev": true,
"peer": true,
"dependencies": {
"arg": "^5.0.2",
"chokidar": "^3.5.3",
@@ -14974,7 +14974,6 @@
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"dev": true,
"peer": true,
"dependencies": {
"is-glob": "^4.0.3"
},
@@ -14987,7 +14986,6 @@
"resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-14.1.0.tgz",
"integrity": "sha512-flwI+Vgm4SElObFVPpTIT7SU7R3qk2L7PyduMcokiaVKuWv9d/U+Gm/QAd8NDLuykTWTkcrjOeD2Pp1rMeBTGw==",
"dev": true,
"peer": true,
"dependencies": {
"postcss-value-parser": "^4.0.0",
"read-cache": "^1.0.0",
@@ -25025,8 +25023,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
"dev": true,
"peer": true
"dev": true
},
"object-inspect": {
"version": "1.12.0",
@@ -26229,7 +26226,6 @@
"resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.0.tgz",
"integrity": "sha512-77QESFBwgX4irogGVPgQ5s07vLvFqWr228qZY+w6lW599cRlK/HmnlivnnVUxkjHnCu4J16PDMHcH+e+2HbvTQ==",
"dev": true,
"peer": true,
"requires": {
"camelcase-css": "^2.0.1"
}
@@ -28918,7 +28914,6 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.1.4.tgz",
"integrity": "sha512-NrxbFV4tYsga/hpWbRyUfIaBrNMXDxx5BsHgBS4v5tlyjf+sDsgBg5m9OxjrXIqAS/uR9kicxLKP+bEHI7BSeQ==",
"dev": true,
"peer": true,
"requires": {
"arg": "^5.0.2",
"chokidar": "^3.5.3",
@@ -28949,7 +28944,6 @@
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"dev": true,
"peer": true,
"requires": {
"is-glob": "^4.0.3"
}
@@ -28959,7 +28953,6 @@
"resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-14.1.0.tgz",
"integrity": "sha512-flwI+Vgm4SElObFVPpTIT7SU7R3qk2L7PyduMcokiaVKuWv9d/U+Gm/QAd8NDLuykTWTkcrjOeD2Pp1rMeBTGw==",
"dev": true,
"peer": true,
"requires": {
"postcss-value-parser": "^4.0.0",
"read-cache": "^1.0.0",

View File

@@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
"version": "2.0.23",
"version": "2.1.2",
"description": "Self-hosted audiobook and podcast client",
"main": "index.js",
"scripts": {
@@ -33,6 +33,7 @@
"@nuxtjs/pwa": "^3.3.5",
"@nuxtjs/tailwindcss": "^4.2.1",
"autoprefixer": "^10.4.7",
"postcss": "^8.3.6"
"postcss": "^8.3.6",
"tailwindcss": "^3.1.4"
}
}

View File

@@ -17,6 +17,7 @@
<div class="flex items-center">
<p class="text-lg mb-4 font-semibold">Audiobook Chapters</p>
<div class="flex-grow" />
<ui-checkbox v-model="showSecondInputs" checkbox-bg="primary" small label-class="text-sm text-gray-200 pl-1" label="Show seconds" class="mx-2" />
<ui-btn color="primary" small class="mx-2" @click="showFindChaptersModal = true">Lookup</ui-btn>
<ui-btn color="success" small @click="saveChapters">Save</ui-btn>
<div class="w-40" />
@@ -32,7 +33,8 @@
<div :key="chapter.id" class="flex py-1">
<div class="w-12">#{{ chapter.id + 1 }}</div>
<div class="w-32 px-1">
<ui-text-input v-model="chapter.start" type="number" class="text-xs" @change="checkChapters" />
<ui-text-input v-if="showSecondInputs" v-model="chapter.start" type="number" class="text-xs" @change="checkChapters" />
<ui-time-picker v-else class="text-xs" v-model="chapter.start" :show-three-digit-hour="mediaDuration >= 360000" @change="checkChapters" />
</div>
<div class="flex-grow px-1">
<ui-text-input v-model="chapter.title" class="text-xs" />
@@ -136,7 +138,7 @@
<script>
export default {
async asyncData({ store, params, app, redirect, route }) {
async asyncData({ store, params, app, redirect, from }) {
if (!store.getters['user/getUserCanUpdate']) {
return redirect('/?error=unauthorized')
}
@@ -152,8 +154,12 @@ export default {
console.error('Invalid media type')
return redirect('/')
}
var previousRoute = from ? from.fullPath : null
if (from && from.path === '/login') previousRoute = null
return {
libraryItem
libraryItem,
previousRoute
}
},
data() {
@@ -168,7 +174,8 @@ export default {
asinInput: null,
findingChapters: false,
showFindChaptersModal: false,
chapterData: null
chapterData: null,
showSecondInputs: false
}
},
computed: {
@@ -339,7 +346,6 @@ export default {
this.saving = true
console.log('udpated chapters', this.newChapters)
const payload = {
chapters: this.newChapters
}
@@ -349,7 +355,11 @@ export default {
this.saving = false
if (data.updated) {
this.$toast.success('Chapters updated')
this.$router.push(`/item/${this.libraryItem.id}`)
if (this.previousRoute) {
this.$router.push(this.previousRoute)
} else {
this.$router.push(`/item/${this.libraryItem.id}`)
}
} else {
this.$toast.info('No changes needed updating')
}

View File

@@ -8,8 +8,8 @@
</div>
</div>
<div class="flex-grow px-2 py-6 md:py-0 md:px-10">
<div class="flex sm:items-end flex-col sm:flex-row">
<h1 class="text-2xl md:text-3xl font-sans">
<div class="flex items-end flex-row flex-wrap md:flex-nowrap">
<h1 class="text-2xl md:text-3xl font-sans w-full md:w-fit mb-4 md:mb-0">
{{ collectionName }}
</h1>
<div class="flex-grow" />

View File

@@ -71,6 +71,11 @@ export default {
.configContent.page-library-stats {
width: 1200px;
}
@media (max-width: 1550px) {
.configContent.page-library-stats {
margin-left: 176px;
}
}
@media (max-width: 1240px) {
.configContent {
margin-left: 176px;
@@ -82,5 +87,8 @@ export default {
width: 100%;
max-width: 100%;
}
.configContent.page-library-stats {
margin-left: 0px;
}
}
</style>

View File

@@ -14,6 +14,12 @@
</ui-tooltip>
</div>
<!-- <div class="flex items-center py-2">
<ui-text-input v-model="cronExpression" :disabled="updatingServerSettings" class="w-32" @change="changedCronExpression" />
<p class="pl-4 text-lg">Cron expression</p>
</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" />
@@ -41,6 +47,7 @@ export default {
dailyBackups: true,
backupsToKeep: 2,
maxBackupSize: 1,
// cronExpression: '',
newServerSettings: {}
}
},
@@ -54,7 +61,7 @@ export default {
},
computed: {
dailyBackupsTooltip() {
return 'Runs at 1am every day (your server time). Saved in /metadata/backups.'
return 'Runs at 1:30am every day (your server time). Saved in /metadata/backups.'
},
maxBackupSizeTooltip() {
return 'As a safeguard against misconfiguration, backups will fail if they exceed the configured size.'
@@ -64,6 +71,18 @@ export default {
}
},
methods: {
// changedCronExpression() {
// this.$axios
// .$post('/api/validate-cron', { expression: this.cronExpression })
// .then(() => {
// console.log('Cron is valid')
// })
// .catch((error) => {
// console.error('Cron validation failed', error)
// const msg = (error.response ? error.response.data : null) || 'Unknown cron validation error'
// this.$toast.error(msg)
// })
// },
updateBackupsSettings() {
if (isNaN(this.maxBackupSize) || this.maxBackupSize <= 0) {
this.$toast.error('Invalid maximum backup size')
@@ -74,7 +93,7 @@ export default {
return
}
var updatePayload = {
backupSchedule: this.dailyBackups ? '0 1 * * *' : false,
backupSchedule: this.dailyBackups ? '30 1 * * *' : false,
backupsToKeep: Number(this.backupsToKeep),
maxBackupSize: Number(this.maxBackupSize)
}
@@ -99,6 +118,7 @@ export default {
this.backupsToKeep = this.newServerSettings.backupsToKeep || 2
this.dailyBackups = !!this.newServerSettings.backupSchedule
this.maxBackupSize = this.newServerSettings.maxBackupSize || 1
// this.cronExpression = '30 1 * * *'
}
},
mounted() {

View File

@@ -157,6 +157,16 @@
</ui-tooltip>
</div>
<!-- <div class="flex items-center py-2">
<ui-text-input type="number" v-model="newServerSettings.scannerMaxThreads" no-spinner :disabled="updatingServerSettings" :padding-x="1" text-center class="w-10" @change="updateScannerMaxThreads" />
<ui-tooltip :text="tooltips.scannerMaxThreads">
<p class="pl-4">
Max # of threads to use
<span class="material-icons icon-text text-sm">info_outlined</span>
</p>
</ui-tooltip>
</div> -->
<div class="pt-4">
<h2 class="font-semibold">Experimental Features</h2>
</div>
@@ -184,6 +194,16 @@
</p>
</ui-tooltip>
</div>
<!-- <div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerUseSingleThreadedProber" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerUseSingleThreadedProber', val)" />
<ui-tooltip :text="tooltips.scannerUseSingleThreadedProber">
<p class="pl-4">
Scanner use old single threaded audio prober
<span class="material-icons icon-text text-sm">info_outlined</span>
</p>
</ui-tooltip>
</div> -->
</div>
</div>
</div>
@@ -268,7 +288,9 @@ export default {
storeMetadataWithItem: 'By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders. Uses .abs file extension',
coverAspectRatio: 'Prefer to use square covers over standard 1.6:1 book covers',
enableEReader: 'E-reader is still a work in progress, but use this setting to open it up to all your users (or use the "Experimental Features" toggle just for use by you)',
scannerPreferOverdriveMediaMarker: 'MP3 files from Overdrive come with chapter timings embedded as custom metadata. Enabling this will use these tags for chapter timings automatically'
scannerPreferOverdriveMediaMarker: 'MP3 files from Overdrive come with chapter timings embedded as custom metadata. Enabling this will use these tags for chapter timings automatically',
scannerUseSingleThreadedProber: 'The old scanner used a single thread. Leaving it in to use as a comparison for now.',
scannerMaxThreads: 'Number of concurrent media files to scan at a time. Value of 1 will be a slower scan but less CPU usage. <br><br>Value of 0 defaults to # of CPU cores for this server times 2 (i.e. 4-core CPU will be 8)'
},
showConfirmPurgeCache: false
}
@@ -300,6 +322,26 @@ export default {
}
},
methods: {
updateScannerMaxThreads(val) {
if (!val || isNaN(val)) {
this.$toast.error('Invalid max threads must be a number')
this.newServerSettings.scannerMaxThreads = 0
return
}
if (Number(val) < 0) {
this.$toast.error('Max threads must be >= 0')
this.newServerSettings.scannerMaxThreads = 0
return
}
if (Math.round(Number(val)) !== Number(val)) {
this.$toast.error('Max threads must be an integer')
this.newServerSettings.scannerMaxThreads = 0
return
}
this.updateServerSettings({
scannerMaxThreads: Number(val)
})
},
updateSortingPrefixes(val) {
if (!val || !val.length) {
this.$toast.error('Must have at least 1 prefix')

View File

@@ -3,7 +3,7 @@
<h1 class="text-xl">Stats for library {{ currentLibraryName }}</h1>
<stats-preview-icons v-if="totalItems" :library-stats="libraryStats" />
<div class="flex md:flex-row flex-wrap justify-between flex-col mt-8">
<div class="flex lg:flex-row flex-wrap justify-between flex-col mt-8">
<div class="w-80 my-6 mx-auto">
<h1 class="text-2xl mb-4 font-book">Top 5 Genres</h1>
<p v-if="!top5Genres.length">No Genres</p>

View File

@@ -161,6 +161,7 @@ export default {
},
beforeDestroy() {
if (!this.$root.socket) return
this.$root.socket.emit('remove_log_listener')
this.$root.socket.off('daily_logs', this.dailyLogsLoaded)
this.$root.socket.off('log', this.logEvtReceived)
}

View File

@@ -39,7 +39,7 @@
<td class="text-center">
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
</td>
<td class="text-center">
<td class="text-center hover:underline" @click.stop="clickCurrentTime(session)">
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
</td>
<td class="text-center hidden sm:table-cell">
@@ -57,7 +57,7 @@
</div>
<p v-else class="text-white text-opacity-50">No sessions yet...</p>
</div>
<modals-listening-session-modal v-model="showSessionModal" :session="selectedSession" />
</div>
</template>
@@ -89,7 +89,8 @@ export default {
total: 0,
currentPage: 0,
userFilter: null,
selectedUser: ''
selectedUser: '',
processingGoToTimestamp: false
}
},
computed: {
@@ -110,6 +111,41 @@ export default {
}
},
methods: {
async clickCurrentTime(session) {
if (this.processingGoToTimestamp) return
this.processingGoToTimestamp = true
const libraryItem = await this.$axios.$get(`/api/items/${session.libraryItemId}`).catch((error) => {
console.error('Failed to get library item', error)
return null
})
if (!libraryItem) {
this.$toast.error('Failed to get library item')
this.processingGoToTimestamp = false
return
}
if (session.episodeId && !libraryItem.media.episodes.find((ep) => ep.id === session.episodeId)) {
this.$toast.error('Failed to get podcast episode')
this.processingGoToTimestamp = false
return
}
const payload = {
message: `Start playback for "${session.displayTitle}" at ${this.$secondsToTimestamp(session.currentTime)}?`,
callback: (confirmed) => {
if (confirmed) {
this.$eventBus.$emit('play-item', {
libraryItemId: libraryItem.id,
episodeId: session.episodeId || null,
startTime: session.currentTime
})
}
this.processingGoToTimestamp = false
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
updateUserFilter() {
this.loadSessions(0)
},

View File

@@ -13,11 +13,12 @@
<widgets-online-indicator :value="!!userOnline" />
<h1 class="text-xl pl-2">{{ username }}</h1>
</div>
<div class="cursor-pointer text-gray-400 hover:text-white" @click="copyToClipboard(userToken)">
<p v-if="userToken" class="py-2 text-xs">
<strong class="text-white">API Token: </strong><br /><span class="text-white">{{ userToken }}</span
><span class="material-icons pl-2 text-base">content_copy</span>
</p>
<div v-if="userToken" class="flex text-xs mt-4">
<ui-text-input-with-label label="API Token" :value="userToken" readonly />
<div class="px-1 mt-8 cursor-pointer" @click="copyToClipboard(userToken)">
<span class="material-icons pl-2 text-base">content_copy</span>
</div>
</div>
<div class="w-full h-px bg-white bg-opacity-10 my-2" />
<div class="py-2">
@@ -138,12 +139,15 @@ export default {
this.$copyToClipboard(str, this)
},
async init() {
this.listeningSessions = await this.$axios.$get(`/api/users/${this.user.id}/listening-sessions?page=0&itemsPerPage=10`).then((data) => {
return data.sessions || []
}).catch((err) => {
console.error('Failed to load listening sesions', err)
return []
})
this.listeningSessions = await this.$axios
.$get(`/api/users/${this.user.id}/listening-sessions?page=0&itemsPerPage=10`)
.then((data) => {
return data.sessions || []
})
.catch((err) => {
console.error('Failed to load listening sesions', err)
return []
})
this.listeningStats = await this.$axios.$get(`/api/users/${this.user.id}/listening-stats`).catch((err) => {
console.error('Failed to load listening sesions', err)
return []

View File

@@ -42,7 +42,7 @@
<td class="text-center">
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
</td>
<td class="text-center">
<td class="text-center hover:underline" @click.stop="clickCurrentTime(session)">
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
</td>
<td class="text-center hidden sm:table-cell">
@@ -85,7 +85,8 @@ export default {
listeningSessions: [],
numPages: 0,
total: 0,
currentPage: 0
currentPage: 0,
processingGoToTimestamp: false
}
},
computed: {
@@ -97,6 +98,41 @@ export default {
}
},
methods: {
async clickCurrentTime(session) {
if (this.processingGoToTimestamp) return
this.processingGoToTimestamp = true
const libraryItem = await this.$axios.$get(`/api/items/${session.libraryItemId}`).catch((error) => {
console.error('Failed to get library item', error)
return null
})
if (!libraryItem) {
this.$toast.error('Failed to get library item')
this.processingGoToTimestamp = false
return
}
if (session.episodeId && !libraryItem.media.episodes.find((ep) => ep.id === session.episodeId)) {
this.$toast.error('Failed to get podcast episode')
this.processingGoToTimestamp = false
return
}
const payload = {
message: `Start playback for "${session.displayTitle}" at ${this.$secondsToTimestamp(session.currentTime)}?`,
callback: (confirmed) => {
if (confirmed) {
this.$eventBus.$emit('play-item', {
libraryItemId: libraryItem.id,
episodeId: session.episodeId || null,
startTime: session.currentTime
})
}
this.processingGoToTimestamp = false
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
prevPage() {
this.loadSessions(this.currentPage - 1)
},

View File

@@ -7,11 +7,11 @@
<covers-book-cover :library-item="libraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<!-- Item Progress Bar -->
<div v-if="!isPodcast" class="absolute bottom-0 left-0 h-1.5 bg-yellow-400 shadow-sm z-10" :class="userIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: 208 * progressPercent + 'px' }"></div>
<div v-if="!isPodcast" class="absolute bottom-0 left-0 h-1.5 shadow-sm z-10" :class="userIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: 208 * progressPercent + 'px' }"></div>
<!-- Item Cover Overlay -->
<div class="absolute top-0 left-0 w-full h-full z-10 bg-black bg-opacity-30 opacity-0 hover:opacity-100 transition-opacity" @mousedown.prevent @mouseup.prevent>
<div v-show="showPlayButton && !streaming" class="h-full flex items-center justify-center pointer-events-none">
<div v-show="showPlayButton && !isStreaming" class="h-full flex items-center justify-center pointer-events-none">
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto cursor-pointer" @click.stop.prevent="startStream">
<span class="material-icons text-4xl">play_circle_filled</span>
</div>
@@ -129,12 +129,12 @@
<!-- Icon buttons -->
<div class="flex items-center justify-center md:justify-start pt-4">
<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 ? 'Playing' : 'Play' }}
<ui-btn v-if="showPlayButton" :disabled="isStreaming" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="startStream">
<span v-show="!isStreaming" class="material-icons -ml-2 pr-1 text-white">play_arrow</span>
{{ isStreaming ? 'Playing' : 'Play' }}
</ui-btn>
<ui-btn v-else-if="isMissing || isInvalid" 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>
<span v-show="!isStreaming" class="material-icons -ml-2 pr-1 text-white">error</span>
{{ isMissing ? 'Missing' : 'Incomplete' }}
</ui-btn>
@@ -160,7 +160,11 @@
<ui-icon-btn icon="search" class="mx-0.5" :loading="fetchingRSSFeed" outlined @click="findEpisodesClick" />
</ui-tooltip>
<!-- Experimental RSS feed open -->
<ui-tooltip v-if="bookmarks.length" text="Your Bookmarks" direction="top">
<ui-icon-btn :icon="bookmarks.length ? 'bookmarks' : 'bookmark_border'" class="mx-0.5" @click="clickBookmarksBtn" />
</ui-tooltip>
<!-- RSS feed -->
<ui-tooltip v-if="showRssFeedBtn" text="Open RSS Feed" direction="top">
<ui-icon-btn icon="rss_feed" class="mx-0.5" :bg-color="rssFeedUrl ? 'success' : 'primary'" outlined @click="clickRSSFeed" />
</ui-tooltip>
@@ -189,6 +193,7 @@
<modals-podcast-episode-feed v-model="showPodcastEpisodeFeed" :library-item="libraryItem" :episodes="podcastFeedEpisodes" />
<modals-rssfeed-view-modal v-model="showRssFeedModal" :library-item="libraryItem" :feed-url="rssFeedUrl" />
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :library-item-id="libraryItemId" hide-create @select="selectBookmark" />
</div>
</template>
@@ -222,7 +227,8 @@ export default {
podcastFeedEpisodes: [],
episodesDownloading: [],
episodeDownloadsQueued: [],
showRssFeedModal: false
showRssFeedModal: false,
showBookmarksModal: false
}
},
computed: {
@@ -296,6 +302,10 @@ export default {
chapters() {
return this.media.chapters || []
},
bookmarks() {
if (this.isPodcast) return []
return this.$store.getters['user/getUserBookmarksForItem'](this.libraryItemId)
},
tracks() {
return this.media.tracks || []
},
@@ -389,7 +399,7 @@ export default {
streamLibraryItem() {
return this.$store.state.streamLibraryItem
},
streaming() {
isStreaming() {
return this.streamLibraryItem && this.streamLibraryItem.id === this.libraryItemId
},
userCanUpdate() {
@@ -409,6 +419,31 @@ export default {
}
},
methods: {
clickBookmarksBtn() {
this.showBookmarksModal = true
},
selectBookmark(bookmark) {
if (!bookmark) return
if (this.isStreaming) {
this.$eventBus.$emit('playback-seek', bookmark.time)
} else if (this.streamLibraryItem) {
this.showBookmarksModal = false
console.log('Already streaming library item so ask about it')
const payload = {
message: `Start playback for "${this.title}" at ${this.$secondsToTimestamp(bookmark.time)}?`,
callback: (confirmed) => {
if (confirmed) {
this.startStream(bookmark.time)
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
} else {
this.startStream(bookmark.time)
}
this.showBookmarksModal = false
},
clearDownloadQueue() {
if (confirm('Are you sure you want to clear episode download queue?')) {
this.$axios
@@ -453,7 +488,21 @@ export default {
openEbook() {
this.$store.commit('showEReader', this.libraryItem)
},
toggleFinished() {
toggleFinished(confirmed = false) {
if (!this.userIsFinished && this.progressPercent > 0 && !confirmed) {
const payload = {
message: `Are you sure you want to mark "${this.title}" as finished?`,
callback: (confirmed) => {
if (confirmed) {
this.toggleFinished(true)
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
return
}
var updatePayload = {
isFinished: !this.userIsFinished
}
@@ -470,7 +519,7 @@ export default {
this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
})
},
startStream() {
startStream(startTime = null) {
var episodeId = null
if (this.isPodcast) {
var episode = this.podcastEpisodes.find((ep) => {
@@ -483,7 +532,8 @@ export default {
this.$eventBus.$emit('play-item', {
libraryItemId: this.libraryItem.id,
episodeId
episodeId,
startTime
})
},
editClick() {

View File

@@ -124,9 +124,10 @@ export default {
location.reload()
},
setUser({ user, userDefaultLibraryId, serverSettings, Source }) {
setUser({ user, userDefaultLibraryId, serverSettings, Source, feeds }) {
this.$store.commit('setServerSettings', serverSettings)
this.$store.commit('setSource', Source)
this.$store.commit('feeds/setFeeds', feeds)
if (serverSettings.chromecastEnabled) {
console.log('Chromecast enabled import script')

View File

@@ -18,6 +18,7 @@ export default class PlayerHandler {
this.isHlsTranscode = false
this.isVideo = false
this.currentSessionId = null
this.startTimeOverride = undefined // Used for starting playback at a specific time (i.e. clicking bookmark from library item page)
this.startTime = 0
this.failedProgressSyncs = 0
@@ -51,12 +52,13 @@ export default class PlayerHandler {
return this.libraryItem.media.episodes.find(ep => ep.id === this.episodeId)
}
load(libraryItem, episodeId, playWhenReady, playbackRate) {
load(libraryItem, episodeId, playWhenReady, playbackRate, startTimeOverride = undefined) {
this.libraryItem = libraryItem
this.episodeId = episodeId
this.playWhenReady = playWhenReady
this.initialPlaybackRate = playbackRate
this.isVideo = libraryItem.mediaType === 'video'
this.startTimeOverride = (startTimeOverride == null || isNaN(startTimeOverride)) ? undefined : Number(startTimeOverride)
if (!this.player) this.switchPlayer(playWhenReady)
else this.prepare()
@@ -142,11 +144,13 @@ export default class PlayerHandler {
} else {
this.stopPlayInterval()
}
if (this.playerState === 'LOADED' || this.playerState === 'PLAYING') {
this.ctx.setDuration(this.getDuration())
}
if (this.playerState !== 'LOADING') {
this.ctx.setCurrentTime(this.player.getCurrentTime())
if (this.player) {
if (this.playerState === 'LOADED' || this.playerState === 'PLAYING') {
this.ctx.setDuration(this.getDuration())
}
if (this.playerState !== 'LOADING') {
this.ctx.setCurrentTime(this.player.getCurrentTime())
}
}
this.ctx.setPlaying(this.playerState === 'PLAYING')
@@ -183,13 +187,14 @@ export default class PlayerHandler {
this.isVideo = session.libraryItem.mediaType === 'video'
this.playWhenReady = false
this.initialPlaybackRate = playbackRate
this.startTimeOverride = undefined
this.prepareSession(session)
}
prepareSession(session) {
this.failedProgressSyncs = 0
this.startTime = session.currentTime
this.startTime = this.startTimeOverride !== undefined ? this.startTimeOverride : session.currentTime
this.currentSessionId = session.id
this.displayTitle = session.displayTitle
this.displayAuthor = session.displayAuthor

View File

@@ -1,6 +1,6 @@
const SupportedFileTypes = {
image: ['png', 'jpg', 'jpeg', 'webp'],
audio: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'mp4', 'aac', 'wma', 'aiff', 'wav'],
audio: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav'],
ebook: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'],
info: ['nfo'],
text: ['txt'],

View File

@@ -47,8 +47,13 @@ export async function checkForUpdate() {
largestVer = verObj
}
}
if (verObj.version == currVerObj.version) {
currVerObj.changelog = release.body
}
})
}
})
if (!largestVer) {
console.error('No valid version tags to compare with')
@@ -59,6 +64,7 @@ export async function checkForUpdate() {
hasUpdate: largestVer.total > currVerObj.total,
latestVersion: largestVer.version,
githubTagUrl: `https://github.com/advplyr/audiobookshelf/releases/tag/v${largestVer.version}`,
currentVersion: currVerObj.version
currentVersion: currVerObj.version,
currentVersionChangelog: currVerObj.changelog
}
}

41
client/static/icon.svg Normal file
View File

@@ -0,0 +1,41 @@
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 1235.7 1235.4" style="enable-background:new 0 0 6702.7 1277.4;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
.st1{fill:url(#SVGID_1_);}
.st2{fill:#C9C9C9;}
.st3{font-family:'GentiumBookBasic';}
.st4{font-size:800px;}
.st5{fill:#474747;}
</style>
<title>bgAsset 6</title>
<g id="Layer_2_1_">
<g id="Layer_2-2">
<g id="Layer_4">
<g id="Layer_5">
<circle class="st0" cx="618.6" cy="618.6" r="618.6"/>
</g>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="617.37" y1="1257.3" x2="617.37" y2="61.4399" gradientTransform="matrix(1 0 0 -1 0 1278)">
<stop offset="0.32" style="stop-color:#CD9D49"/>
<stop offset="0.99" style="stop-color:#875D27"/>
</linearGradient>
<circle class="st1" cx="617.4" cy="618.6" r="597.9"/>
</g>
<path class="st0" d="M1005.6,574.1c-4.8-4-12.4-10-22.6-17v-79.2c0-201.9-163.7-365.6-365.6-365.6l0,0
c-201.9,0-365.6,163.7-365.6,365.6v79.2c-10.2,7-17.7,13-22.6,17c-4.1,3.4-6.5,8.5-6.5,13.9v94.9c0,5.4,2.4,10.5,6.5,14
c11.3,9.4,37.2,29.1,77.5,49.3v9.2c0,24.9,16,45,35.8,45l0,0c19.8,0,35.8-20.2,35.8-45V527.8c0-24.9-16-45-35.8-45l0,0
c-19,0-34.5,18.5-35.8,41.9h-0.1v-46.9c0-171.6,139.1-310.7,310.7-310.7l0,0C789,167.2,928,306.3,928,477.9v46.9H928
c-1.3-23.4-16.8-41.9-35.8-41.9l0,0c-19.8,0-35.8,20.2-35.8,45v227.6c0,24.9,16,45,35.8,45l0,0c19.8,0,35.8-20.2,35.8-45v-9.2
c40.3-20.2,66.2-39.9,77.5-49.3c4.2-3.5,6.5-8.6,6.5-14V588C1012.1,582.6,1009.7,577.5,1005.6,574.1z"/>
<path class="st0" d="M489.9,969.7c23.9,0,43.3-19.4,43.3-43.3V441.6c0-23.9-19.4-43.3-43.3-43.3h-44.7
c-23.9,0-43.3,19.4-43.3,43.3v484.8c0,23.9,19.4,43.3,43.3,43.3L489.9,969.7z M418.2,514.6h98.7v10.3h-98.7V514.6z"/>
<path class="st0" d="M639.7,969.7c23.9,0,43.3-19.4,43.3-43.3V441.6c0-23.9-19.4-43.3-43.3-43.3H595c-23.9,0-43.3,19.4-43.3,43.3
v484.8c0,23.9,19.4,43.3,43.3,43.3H639.7z M568,514.6h98.7v10.3H568V514.6z"/>
<path class="st0" d="M789.6,969.7c23.9,0,43.3-19.4,43.3-43.3V441.6c0-23.9-19.4-43.3-43.3-43.3h-44.7
c-23.9,0-43.3,19.4-43.3,43.3v484.8c0,23.9,19.4,43.3,43.3,43.3L789.6,969.7z M717.9,514.6h98.7v10.3h-98.7V514.6z"/>
<path class="st0" d="M327.1,984.7h580.5c18,0,32.6,14.6,32.6,32.6v0c0,18-14.6,32.6-32.6,32.6H327.1c-18,0-32.6-14.6-32.6-32.6v0
C294.5,999.3,309.1,984.7,327.1,984.7z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -0,0 +1,44 @@
# License information
## Contribution License Agreement
If you contribute code to this project, you are implicitly allowing your code
to be distributed under the MIT license. You are also implicitly verifying that
all code is your original work. `</legalese>`
## Marked
Copyright (c) 2018+, MarkedJS (https://github.com/markedjs/)
Copyright (c) 2011-2018, Christopher Jeffrey (https://github.com/chjj/)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
## Markdown
Copyright © 2004, John Gruber
http://daringfireball.net/
All rights reserved.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
* Neither the name “Markdown” nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
This software is provided by the copyright holders and contributors “as is” and any express or implied warranties, including, but not limited to, the implied warranties of merchantability and fitness for a particular purpose are disclaimed. In no event shall the copyright owner or contributors be liable for any direct, indirect, incidental, special, exemplary, or consequential damages (including, but not limited to, procurement of substitute goods or services; loss of use, data, or profits; or business interruption) however caused and on any theory of liability, whether in contract, strict liability, or tort (including negligence or otherwise) arising in any way out of the use of this software, even if advised of the possibility of such damage.

View File

File diff suppressed because one or more lines are too long

28
client/store/feeds.js Normal file
View File

@@ -0,0 +1,28 @@
export const state = () => ({
feeds: []
})
export const getters = {
getFeedForItem: state => id => {
return state.feeds.find(feed => feed.id === id)
}
}
export const actions = {
}
export const mutations = {
addFeed(state, feed) {
var index = state.feeds.findIndex(f => f.id === feed.id)
if (index >= 0) state.feeds.splice(index, 1, feed)
else state.feeds.push(feed)
},
removeFeed(state, feed) {
state.feeds = state.feeds.filter(f => f.id !== feed.id)
},
setFeeds(state, feeds) {
state.feeds = feeds || []
}
}

View File

@@ -6,6 +6,8 @@ export const state = () => ({
showEditCollectionModal: false,
showEditPodcastEpisode: false,
showViewPodcastEpisodeModal: false,
showConfirmPrompt: false,
confirmPromptOptions: null,
showEditAuthorModal: false,
selectedEpisode: null,
selectedCollection: null,
@@ -69,6 +71,13 @@ export const mutations = {
setShowViewPodcastEpisodeModal(state, val) {
state.showViewPodcastEpisodeModal = val
},
setShowConfirmPrompt(state, val) {
state.showConfirmPrompt = val
},
setConfirmPrompt(state, options) {
state.confirmPromptOptions = options
state.showConfirmPrompt = true
},
setEditCollection(state, collection) {
state.selectedCollection = collection
state.showEditCollectionModal = true

View File

@@ -41,8 +41,9 @@ export const getters = {
getLibraryItemIdStreaming: state => {
return state.streamLibraryItem ? state.streamLibraryItem.id : null
},
getIsEpisodeStreaming: state => (libraryItemId, episodeId) => {
getIsMediaStreaming: state => (libraryItemId, episodeId) => {
if (!state.streamLibraryItem) return null
if (!episodeId) return state.streamLibraryItem.id == libraryItemId
return state.streamLibraryItem.id == libraryItemId && state.streamEpisodeId == episodeId
}
}

View File

@@ -136,6 +136,10 @@ export const mutations = {
localStorage.removeItem('token')
}
},
setUserToken(state, token) {
state.user.token = token
localStorage.setItem('token', user.token)
},
updateMediaProgress(state, { id, data }) {
if (!state.user) return
if (!data) {

View File

@@ -1,5 +1,3 @@
const defaultTheme = require('tailwindcss/defaultTheme')
module.exports = {
purge: {
content: [
@@ -16,7 +14,8 @@ module.exports = {
'text-green-500',
'py-1.5',
'bg-info',
'px-1.5'
'px-1.5',
'min-w-5'
],
},
theme: {
@@ -38,11 +37,14 @@ module.exports = {
'32': '8rem',
'40': '10rem',
'48': '12rem',
'52': '13rem',
'64': '16rem',
'80': '20rem'
},
minWidth: {
'5': '1.25rem',
'6': '1.5rem',
'10': '2.5rem',
'12': '3rem',
'16': '4rem',
'20': '5rem',
@@ -80,8 +82,8 @@ module.exports = {
none: 'none'
},
fontFamily: {
sans: ['Source Sans Pro', ...defaultTheme.fontFamily.sans],
mono: ['Ubuntu Mono', ...defaultTheme.fontFamily.mono],
sans: ['Source Sans Pro'],
mono: ['Ubuntu Mono'],
book: ['Gentium Book Basic', 'serif']
},
fontSize: {
@@ -89,7 +91,8 @@ module.exports = {
'2.5xl': '1.6875rem'
},
zIndex: {
'50': 50
'50': 50,
'60': 60
}
}
},

View File

@@ -7,6 +7,7 @@ services:
ports:
- 13378:80
volumes:
- /audiobooks:/audiobooks
- /metadata:/metadata
- /config:/config
- ./audiobooks:/audiobooks
- ./metadata:/metadata
- ./config:/config
restart: unless-stopped

View File

@@ -1,4 +1,3 @@
if (process.env.TOKEN_SECRET == null) process.env.TOKEN_SECRET = '09f26e402586e2faa8da4c98a35f1b20d6b033c6097befa8be3486a829587fe2f90a832bd3ff9d42710a4da095a2ce285b009f0c3730cd9b8e1af3eb84df6611'
const server = require('./server/Server')
global.appRoot = __dirname

1787
package-lock.json generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
"version": "2.0.23",
"version": "2.1.2",
"description": "Self-hosted audiobook and podcast server",
"main": "index.js",
"scripts": {
@@ -30,22 +30,10 @@
"author": "advplyr",
"license": "GPL-3.0",
"dependencies": {
"archiver": "^5.3.0",
"axios": "^0.26.1",
"bcryptjs": "^2.4.3",
"command-line-args": "^5.2.0",
"date-and-time": "^2.3.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",
"graceful-fs": "^4.2.10",
"htmlparser2": "^8.0.1",
"jsonwebtoken": "^8.5.1",
"libgen": "^2.1.0",
"node-ffprobe": "^3.0.0",
"node-stream-zip": "^1.15.0",
"recursive-readdir-async": "^1.1.8",
"socket.io": "^4.4.1",
"xml2js": "^0.4.23"
}

View File

@@ -6,7 +6,7 @@ const optionDefinitions = [
{ name: 'source', alias: 's', type: String }
]
const commandLineArgs = require('command-line-args')
const commandLineArgs = require('./server/libs/commandLineArgs')
const options = commandLineArgs(optionDefinitions)
const Path = require('path')

View File

@@ -1,5 +1,5 @@
const bcrypt = require('bcryptjs')
const jwt = require('jsonwebtoken')
const bcrypt = require('./libs/bcryptjs')
const jwt = require('./libs/jsonwebtoken')
const Logger = require('./Logger')
class Auth {
@@ -31,6 +31,26 @@ class Auth {
}
}
async initTokenSecret() {
if (process.env.TOKEN_SECRET) { // User can supply their own token secret
Logger.debug(`[Auth] Setting token secret - using user passed in TOKEN_SECRET env var`)
this.db.serverSettings.tokenSecret = process.env.TOKEN_SECRET
} else {
Logger.debug(`[Auth] Setting token secret - using random bytes`)
this.db.serverSettings.tokenSecret = require('crypto').randomBytes(256).toString('base64')
}
await this.db.updateServerSettings()
// New token secret creation added in v2.1.0 so generate new API tokens for each user
if (this.db.users.length) {
for (const user of this.db.users) {
user.token = await this.generateAccessToken({ userId: user.id, username: user.username })
Logger.warn(`[Auth] User ${user.username} api token has been updated using new token secret`)
}
await this.db.updateEntities('user', this.db.users)
}
}
async authMiddleware(req, res, next) {
var token = null
@@ -74,7 +94,7 @@ class Auth {
}
generateAccessToken(payload) {
return jwt.sign(payload, process.env.TOKEN_SECRET);
return jwt.sign(payload, global.ServerSettings.tokenSecret);
}
authenticateUser(token) {
@@ -83,27 +103,28 @@ class Auth {
verifyToken(token) {
return new Promise((resolve) => {
jwt.verify(token, process.env.TOKEN_SECRET, (err, payload) => {
jwt.verify(token, global.ServerSettings.tokenSecret, (err, payload) => {
if (!payload || err) {
Logger.error('JWT Verify Token Failed', err)
return resolve(null)
}
var user = this.users.find(u => u.id === payload.userId)
var user = this.users.find(u => u.id === payload.userId && u.username === payload.username)
resolve(user || null)
})
})
}
getUserLoginResponsePayload(user) {
getUserLoginResponsePayload(user, feeds) {
return {
user: user.toJSONForBrowser(),
userDefaultLibraryId: user.getDefaultLibraryId(this.db.libraries),
serverSettings: this.db.serverSettings.toJSON(),
serverSettings: this.db.serverSettings.toJSONForBrowser(),
feeds,
Source: global.Source
}
}
async login(req, res) {
async login(req, res, feeds) {
var username = (req.body.username || '').toLowerCase()
var password = req.body.password || ''
@@ -122,14 +143,14 @@ class Auth {
if (password) {
return res.status(401).send('Invalid root password (hint: there is none)')
} else {
return res.json(this.getUserLoginResponsePayload(user))
return res.json(this.getUserLoginResponsePayload(user, feeds))
}
}
// Check password match
var compare = await bcrypt.compare(password, user.pash)
if (compare) {
res.json(this.getUserLoginResponsePayload(user))
res.json(this.getUserLoginResponsePayload(user, feeds))
} else {
Logger.debug(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit}`)
if (req.rateLimit.remaining <= 2) {

View File

@@ -414,6 +414,23 @@ class Db {
})
}
removeEntities(entityName, selectFunc) {
var entityDb = this.getEntityDb(entityName)
return entityDb.delete(selectFunc).then((results) => {
Logger.debug(`[DB] Deleted entities ${entityName}: ${results.deleted}`)
var arrayKey = this.getEntityArrayKey(entityName)
if (this[arrayKey]) {
this[arrayKey] = this[arrayKey].filter(e => {
return !selectFunc(e)
})
}
return results.deleted
}).catch((error) => {
Logger.error(`[DB] Remove entities ${entityName} Failed: ${error}`)
return 0
})
}
recreateLibraryItemsDb() {
return this.libraryItemsDb.drop().then((results) => {
Logger.info(`[DB] Dropped library items db`, results)
@@ -426,8 +443,8 @@ class Db {
})
}
getAllSessions() {
return this.sessionsDb.select(() => true).then((results) => {
getAllSessions(selectFunc = () => true) {
return this.sessionsDb.select(selectFunc).then((results) => {
return results.data || []
}).catch((error) => {
Logger.error('[Db] Failed to select sessions', error)

View File

@@ -1,4 +1,4 @@
const date = require('date-and-time')
const date = require('./libs/dateAndTime')
const { LogLevel } = require('./utils/constants')
class Logger {

View File

@@ -2,9 +2,9 @@ const Path = require('path')
const express = require('express')
const http = require('http')
const SocketIO = require('socket.io')
const fs = require('fs-extra')
const fileUpload = require('express-fileupload')
const rateLimit = require('express-rate-limit')
const fs = require('./libs/fsExtra')
const fileUpload = require('./libs/expressFileupload')
const rateLimit = require('./libs/expressRateLimit')
const { version } = require('../package.json')
@@ -136,8 +136,14 @@ class Server {
await this.db.init()
}
// Create token secret if does not exist (Added v2.1.0)
if (!this.db.serverSettings.tokenSecret) {
await this.auth.initTokenSecret()
}
await this.checkUserMediaProgress() // Remove invalid user item progress
await this.purgeMetadata() // Remove metadata folders without library item
await this.playbackSessionManager.removeInvalidSessions()
await this.cacheManager.ensureCachePaths()
await this.abMergeManager.ensureDownloadDirPath()
@@ -171,7 +177,6 @@ class Server {
const distPath = Path.join(global.appRoot, '/client/dist')
app.use(express.static(distPath))
// Metadata folder static path
app.use('/metadata', this.authMiddleware.bind(this), express.static(global.MetadataPath))
@@ -225,7 +230,7 @@ class Server {
]
dyanimicRoutes.forEach((route) => app.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html'))))
app.post('/login', this.getLoginRateLimiter(), (req, res) => this.auth.login(req, res))
app.post('/login', this.getLoginRateLimiter(), (req, res) => this.auth.login(req, res, this.rssFeedManager.feedsArray))
app.post('/logout', this.authMiddleware.bind(this), this.logout.bind(this))
app.post('/init', (req, res) => {
if (this.db.hasRootUser) {
@@ -247,9 +252,10 @@ class Server {
res.json(payload)
})
app.get('/ping', (req, res) => {
Logger.info('Recieved ping')
Logger.info('Received ping')
res.json({ success: true })
})
app.get('/healthcheck', (req, res) => res.sendStatus(200))
this.server.listen(this.Port, this.Host, () => {
Logger.info(`Listening on http://${this.Host}:${this.Port}`)
@@ -278,6 +284,7 @@ class Server {
// Logs
socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level))
socket.on('remove_log_listener', () => Logger.removeSocketListener(socket.id))
socket.on('fetch_daily_logs', () => this.logManager.socketRequestDailyLogs(socket))
socket.on('ping', () => {
@@ -287,21 +294,21 @@ class Server {
socket.emit('pong')
})
socket.on('disconnect', () => {
socket.on('disconnect', (reason) => {
Logger.removeSocketListener(socket.id)
var _client = this.clients[socket.id]
if (!_client) {
Logger.warn('[Server] Socket disconnect, no client ' + socket.id)
Logger.warn(`[Server] Socket ${socket.id} disconnect, no client (Reason: ${reason})`)
} else if (!_client.user) {
Logger.info('[Server] Unauth socket disconnected ' + socket.id)
Logger.info(`[Server] Unauth socket ${socket.id} disconnected (Reason: ${reason})`)
delete this.clients[socket.id]
} else {
Logger.debug('[Server] User Offline ' + _client.user.username)
this.io.emit('user_offline', _client.user.toJSONForPublic(this.playbackSessionManager.sessions, this.db.libraryItems))
const disconnectTime = Date.now() - _client.connected_at
Logger.info(`[Server] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms`)
Logger.info(`[Server] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms (Reason: ${reason})`)
delete this.clients[socket.id]
}
})
@@ -313,7 +320,7 @@ class Server {
const newRoot = req.body.newRoot
let rootPash = newRoot.password ? await this.auth.hashPass(newRoot.password) : ''
if (!rootPash) Logger.warn(`[Server] Creating root user with no password`)
let rootToken = await this.auth.generateAccessToken({ userId: 'root' })
let rootToken = await this.auth.generateAccessToken({ userId: 'root', username: newRoot.username })
await this.db.createRootUser(newRoot.username, rootPash, rootToken)
res.sendStatus(200)
@@ -458,8 +465,6 @@ class Server {
await this.db.updateEntity('user', user)
const initialPayload = {
// TODO: this is sent with user auth now, update mobile app to use that then remove this
serverSettings: this.db.serverSettings.toJSON(),
metadataPath: global.MetadataPath,
configPath: global.ConfigPath,
user: client.user.toJSONForBrowser(),
@@ -471,11 +476,6 @@ class Server {
initialPayload.usersOnline = this.usersOnline
}
client.socket.emit('init', initialPayload)
// Setup log listener for root user
if (user.type === 'root') {
Logger.addSocketListener(socket, this.db.serverSettings.logLevel || 0)
}
}
async stop() {

View File

@@ -82,31 +82,59 @@ class AuthorController {
var authorNameUpdate = payload.name !== undefined && payload.name !== req.author.name
var hasUpdated = req.author.update(payload)
if (hasUpdated) {
if (authorNameUpdate) { // Update author name on all books
var itemsWithAuthor = this.db.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasAuthor(req.author.id))
itemsWithAuthor.forEach(libraryItem => {
libraryItem.media.metadata.updateAuthor(req.author)
})
if (itemsWithAuthor.length) {
await this.db.updateLibraryItems(itemsWithAuthor)
this.emitter('items_updated', itemsWithAuthor.map(li => li.toJSONExpanded()))
}
// Check if author name matches another author and merge the authors
var existingAuthor = authorNameUpdate ? this.db.authors.find(au => au.id !== req.author.id && payload.name === au.name) : false
if (existingAuthor) {
var itemsWithAuthor = this.db.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasAuthor(req.author.id))
itemsWithAuthor.forEach(libraryItem => { // Replace old author with merging author for each book
libraryItem.media.metadata.replaceAuthor(req.author, existingAuthor)
})
if (itemsWithAuthor.length) {
await this.db.updateLibraryItems(itemsWithAuthor)
this.emitter('items_updated', itemsWithAuthor.map(li => li.toJSONExpanded()))
}
await this.db.updateEntity('author', req.author)
var numBooks = this.db.libraryItems.filter(li => {
return li.media.metadata.hasAuthor && li.media.metadata.hasAuthor(req.author.id)
}).length
this.emitter('author_updated', req.author.toJSONExpanded(numBooks))
}
// Remove old author
await this.db.removeEntity('author', req.author.id)
this.emitter('author_removed', req.author.toJSON())
res.json({
author: req.author.toJSON(),
updated: hasUpdated
})
// Send updated num books for merged author
var numBooks = this.db.libraryItems.filter(li => {
return li.media.metadata.hasAuthor && li.media.metadata.hasAuthor(existingAuthor.id)
}).length
this.emitter('author_updated', existingAuthor.toJSONExpanded(numBooks))
res.json({
author: existingAuthor.toJSON(),
merged: true
})
} else { // Regular author update
var hasUpdated = req.author.update(payload)
if (hasUpdated) {
if (authorNameUpdate) { // Update author name on all books
var itemsWithAuthor = this.db.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasAuthor(req.author.id))
itemsWithAuthor.forEach(libraryItem => {
libraryItem.media.metadata.updateAuthor(req.author)
})
if (itemsWithAuthor.length) {
await this.db.updateLibraryItems(itemsWithAuthor)
this.emitter('items_updated', itemsWithAuthor.map(li => li.toJSONExpanded()))
}
}
await this.db.updateEntity('author', req.author)
var numBooks = this.db.libraryItems.filter(li => {
return li.media.metadata.hasAuthor && li.media.metadata.hasAuthor(req.author.id)
}).length
this.emitter('author_updated', req.author.toJSONExpanded(numBooks))
}
res.json({
author: req.author.toJSON(),
updated: hasUpdated
})
}
}
async search(req, res) {

View File

@@ -1,5 +1,5 @@
const Path = require('path')
const fs = require('fs-extra')
const fs = require('../libs/fsExtra')
const filePerms = require('../utils/filePerms')
const Logger = require('../Logger')
const Library = require('../objects/Library')
@@ -163,7 +163,7 @@ class LibraryController {
// If filtering by series, will include seriesName and seriesSequence on media metadata
filterSeries = (payload.mediaType == 'book' && payload.filterBy.startsWith('series.')) ? libraryHelpers.decode(payload.filterBy.replace('series.', '')) : null
libraryItems = libraryHelpers.getFilteredLibraryItems(libraryItems, payload.filterBy, req.user)
libraryItems = libraryHelpers.getFilteredLibraryItems(libraryItems, payload.filterBy, req.user, this.rssFeedManager.feedsArray)
payload.total = libraryItems.length
}
@@ -176,7 +176,8 @@ class LibraryController {
}
// Handle server setting sortingIgnorePrefix
if (sortKey === 'media.metadata.title' && this.db.serverSettings.sortingIgnorePrefix) {
const sortByTitle = sortKey === 'media.metadata.title'
if (sortByTitle && this.db.serverSettings.sortingIgnorePrefix) {
// BookMetadata.js has titleIgnorePrefix getter
sortKey += 'IgnorePrefix'
}
@@ -186,6 +187,16 @@ class LibraryController {
var sortArray = [
{
[direction]: (li) => {
// When collapsing by series and sorting by title use the series name instead of the book title
if (payload.mediaType === 'book' && payload.collapseseries && li.media.metadata.seriesName) {
if (sortByTitle) {
return this.db.serverSettings.sortingIgnorePrefix ? li.media.metadata.seriesNameIgnorePrefix : li.media.metadata.seriesName
} else {
// When not sorting by title always show the collapsed series at the end
return direction === 'desc' ? -1 : 'zzzz'
}
}
// Supports dot notation strings i.e. "media.metadata.title"
return sortKey.split('.').reduce((a, b) => a[b], li)
}
@@ -262,7 +273,7 @@ class LibraryController {
var series = libraryHelpers.getSeriesFromBooks(libraryItems, payload.minified)
series = sort(series).asc(s => {
return s.name
return this.db.serverSettings.sortingIgnorePrefix ? s.nameIgnorePrefix : s.name
})
payload.total = series.length

View File

@@ -33,7 +33,7 @@ class MeController {
// GET: api/me/progress/:id/:episodeId?
async getMediaProgress(req, res) {
const mediaProgress = req.user.getMediaProgress(req.id, req.episodeId || null)
const mediaProgress = req.user.getMediaProgress(req.params.id, req.params.episodeId || null)
if (!mediaProgress) {
return res.sendStatus(404)
}
@@ -190,6 +190,7 @@ class MeController {
const updatedLocalMediaProgress = []
var numServerProgressUpdates = 0
var localMediaProgress = req.body.localMediaProgress || []
localMediaProgress.forEach(localProgress => {
if (!localProgress.libraryItemId) {
Logger.error(`[MeController] syncLocalMediaProgress invalid local media progress object`, localProgress)
@@ -216,7 +217,8 @@ class MeController {
Logger.debug(`[MeController] syncLocalMediaProgress server progress is more recent by ${updateTimeDifference}ms - ${mediaProgress.id}`)
for (const key in localProgress) {
if (mediaProgress[key] != undefined && localProgress[key] !== mediaProgress[key]) {
// Local media progress ID uses the local library item id and server media progress uses the library item id
if (key !== 'id' && mediaProgress[key] != undefined && localProgress[key] !== mediaProgress[key]) {
// Logger.debug(`[MeController] syncLocalMediaProgress key ${key} changed from ${localProgress[key]} to ${mediaProgress[key]} - ${mediaProgress.id}`)
localProgress[key] = mediaProgress[key]
}

View File

@@ -1,8 +1,8 @@
const Path = require('path')
const fs = require('fs-extra')
const fs = require('../libs/fsExtra')
const Logger = require('../Logger')
const filePerms = require('../utils/filePerms')
const patternValidation = require('../libs/nodeCron/pattern-validation')
const { isObject } = require('../utils/index')
//
@@ -239,12 +239,7 @@ class MiscController {
Logger.error('Invalid user in authorize')
return res.sendStatus(401)
}
const userResponse = {
user: req.user,
userDefaultLibraryId: req.user.getDefaultLibraryId(this.db.libraries),
serverSettings: this.db.serverSettings.toJSON(),
Source: global.Source
}
const userResponse = this.auth.getUserLoginResponsePayload(req.user, this.rssFeedManager.feedsArray)
res.json(userResponse)
}
@@ -263,5 +258,20 @@ class MiscController {
})
res.json(tags)
}
validateCronExpression(req, res) {
const expression = req.body.expression
if (!expression) {
return res.sendStatus(400)
}
try {
patternValidation(expression)
res.sendStatus(200)
} catch (error) {
Logger.warn(`[MiscController] Invalid cron expression ${expression}`, error.message)
res.status(400).send(error.message)
}
}
}
module.exports = new MiscController()

View File

@@ -1,5 +1,5 @@
const axios = require('axios')
const fs = require('fs-extra')
const fs = require('../libs/fsExtra')
const Path = require('path')
const Logger = require('../Logger')
const { parsePodcastRssFeedXml } = require('../utils/podcastUtils')
@@ -164,6 +164,25 @@ class PodcastController {
})
}
async findEpisode(req, res) {
const rssFeedUrl = req.libraryItem.media.metadata.feedUrl
if (!rssFeedUrl) {
Logger.error(`[PodcastController] findEpisode: Podcast has no feed url`)
return res.status(500).send('Podcast does not have an RSS feed URL')
}
var searchTitle = req.query.title
if (!searchTitle) {
return res.sendStatus(500)
}
searchTitle = searchTitle.toLowerCase().trim()
const episodes = await this.podcastManager.findEpisode(rssFeedUrl, searchTitle)
res.json({
episodes: episodes || []
})
}
async downloadEpisodes(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[PodcastController] Non-admin user attempted to download episodes`, req.user)
@@ -185,7 +204,7 @@ class PodcastController {
var episodeId = req.params.episodeId
if (!libraryItem.media.checkHasEpisode(episodeId)) {
return res.status(500).send('Episode not found')
return res.status(404).send('Episode not found')
}
var wasUpdated = libraryItem.media.updateEpisode(episodeId, req.body)

View File

@@ -43,7 +43,7 @@ class UserController {
account.id = getId('usr')
account.pash = await this.auth.hashPass(account.password)
delete account.password
account.token = await this.auth.generateAccessToken({ userId: account.id })
account.token = await this.auth.generateAccessToken({ userId: account.id, username })
account.createdAt = Date.now()
var newUser = new User(account)
var success = await this.db.insertEntity('user', newUser)
@@ -74,12 +74,14 @@ class UserController {
}
var account = req.body
var shouldUpdateToken = false
if (account.username !== undefined && account.username !== user.username) {
var usernameExists = this.db.users.find(u => u.username.toLowerCase() === account.username.toLowerCase())
if (usernameExists) {
return res.status(500).send('Username already taken')
}
shouldUpdateToken = true
}
// Updating password
@@ -90,6 +92,10 @@ class UserController {
var hasUpdated = user.update(account)
if (hasUpdated) {
if (shouldUpdateToken) {
user.token = await this.auth.generateAccessToken({ userId: user.id, username: user.username })
Logger.info(`[UserController] User ${user.username} was generated a new api token`)
}
await this.db.updateEntity('user', user)
}

View File

@@ -1,4 +1,4 @@
const fs = require('fs-extra')
const fs = require('../libs/fsExtra')
const Logger = require('../Logger')
const Path = require('path')
const Audnexus = require('../providers/Audnexus')

View File

@@ -1,5 +1,4 @@
const OpenLibrary = require('../providers/OpenLibrary')
const LibGen = require('../providers/LibGen')
const GoogleBooks = require('../providers/GoogleBooks')
const Audible = require('../providers/Audible')
const iTunes = require('../providers/iTunes')
@@ -10,7 +9,6 @@ const { levenshteinDistance } = require('../utils/index')
class BookFinder {
constructor() {
this.openLibrary = new OpenLibrary()
this.libGen = new LibGen()
this.googleBooks = new GoogleBooks()
this.audible = new Audible()
this.iTunesApi = new iTunes()
@@ -123,20 +121,6 @@ class BookFinder {
})
}
async getLibGenResults(title, author, maxTitleDistance, maxAuthorDistance) {
var books = await this.libGen.search(title)
if (this.verbose) Logger.debug(`LibGen Book Search Results: ${books.length || 0}`)
if (books.errorCode) {
Logger.error(`LibGen Search Error ${books.errorCode}`)
return []
}
var booksFiltered = this.filterSearchResults(books, title, author, maxTitleDistance, maxAuthorDistance)
if (!booksFiltered.length && books.length) {
if (this.verbose) Logger.debug(`Search has ${books.length} matches, but no close title matches`)
}
return booksFiltered
}
async getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance) {
var books = await this.openLibrary.searchTitle(title)
if (this.verbose) Logger.debug(`OpenLib Book Search Results: ${books.length || 0}`)
@@ -185,27 +169,10 @@ class BookFinder {
books = await this.getAudibleResults(title, author, asin)
} else if (provider === 'itunes') {
books = await this.getiTunesAudiobooksResults(title, author)
} else if (provider === 'libgen') {
books = await this.getLibGenResults(title, author, maxTitleDistance, maxAuthorDistance)
} else if (provider === 'openlibrary') {
books = await this.getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance)
} else if (provider === 'all') {
var lbBooks = await this.getLibGenResults(title, author, maxTitleDistance, maxAuthorDistance)
var olBooks = await this.getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance)
books = books.concat(lbBooks, olBooks)
} else {
books = await this.getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance)
var hasCloseMatch = books.find(b => (b.totalDistance < 2 && b.totalPossibleDistance > 6))
if (!hasCloseMatch) {
Logger.debug(`Book Search, openlib has no super close matches - get libgen results also`)
var lbBooks = await this.getLibGenResults(title, author, maxTitleDistance, maxAuthorDistance)
books = books.concat(lbBooks)
}
if (!books.length && author && options.fallbackTitleOnly) {
Logger.debug(`Book Search, no matches for title and author.. check title only`)
return this.search(provider, title, null, options)
}
books = await this.getGoogleBooksResults(title, author)
}
if (!books.length && !options.currentlyTryingCleaned) {

View File

@@ -0,0 +1,22 @@
Copyright (c) 2012-2014 Chris Talkington, contributors.
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -0,0 +1,21 @@
(MIT)
Copyright (c) 2013 Julian Gruber &lt;julian@juliangruber.com&gt;
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,81 @@
'use strict'
/**
* @param {string | RegExp} a
* @param {string | RegExp} b
* @param {string} str
*/
function balanced(a, b, str) {
if (a instanceof RegExp) a = maybeMatch(a, str)
if (b instanceof RegExp) b = maybeMatch(b, str)
const r = range(a, b, str)
return (
r && {
start: r[0],
end: r[1],
pre: str.slice(0, r[0]),
body: str.slice(r[0] + a.length, r[1]),
post: str.slice(r[1] + b.length)
}
)
}
/**
* @param {RegExp} reg
* @param {string} str
*/
function maybeMatch(reg, str) {
const m = str.match(reg)
return m ? m[0] : null
}
balanced.range = range
/**
* @param {string} a
* @param {string} b
* @param {string} str
*/
function range(a, b, str) {
let begs, beg, left, right, result
let ai = str.indexOf(a)
let bi = str.indexOf(b, ai + 1)
let i = ai
if (ai >= 0 && bi > 0) {
if (a === b) {
return [ai, bi]
}
begs = []
left = str.length
while (i >= 0 && !result) {
if (i === ai) {
begs.push(i)
ai = str.indexOf(a, i + 1)
} else if (begs.length === 1) {
result = [begs.pop(), bi]
} else {
beg = begs.pop()
if (beg < left) {
left = beg
right = bi
}
bi = str.indexOf(b, i + 1)
}
i = ai < bi && ai >= 0 ? ai : bi
}
if (begs.length) {
result = [left, right]
}
}
return result
}
module.exports = balanced

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2013 Julian Gruber <julian@juliangruber.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,235 @@
const balanced = require('../balancedMatch');
const escSlash = '\0SLASH' + Math.random() + '\0';
const escOpen = '\0OPEN' + Math.random() + '\0';
const escClose = '\0CLOSE' + Math.random() + '\0';
const escComma = '\0COMMA' + Math.random() + '\0';
const escPeriod = '\0PERIOD' + Math.random() + '\0';
/**
* @return {number}
*/
function numeric(str) {
return parseInt(str, 10) == str
? parseInt(str, 10)
: str.charCodeAt(0);
}
/**
* @param {string} str
*/
function escapeBraces(str) {
return str.split('\\\\').join(escSlash)
.split('\\{').join(escOpen)
.split('\\}').join(escClose)
.split('\\,').join(escComma)
.split('\\.').join(escPeriod);
}
/**
* @param {string} str
*/
function unescapeBraces(str) {
return str.split(escSlash).join('\\')
.split(escOpen).join('{')
.split(escClose).join('}')
.split(escComma).join(',')
.split(escPeriod).join('.');
}
/**
* Basically just str.split(","), but handling cases
* where we have nested braced sections, which should be
* treated as individual members, like {a,{b,c},d}
* @param {string} str
*/
function parseCommaParts(str) {
if (!str)
return [''];
const parts = [];
const m = balanced('{', '}', str);
if (!m)
return str.split(',');
const { pre, body, post } = m;
const p = pre.split(',');
p[p.length - 1] += '{' + body + '}';
const postParts = parseCommaParts(post);
if (post.length) {
p[p.length - 1] += postParts.shift();
p.push.apply(p, postParts);
}
parts.push.apply(parts, p);
return parts;
}
/**
* @param {string} str
*/
function expandTop(str) {
if (!str)
return [];
// I don't know why Bash 4.3 does this, but it does.
// Anything starting with {} will have the first two bytes preserved
// but *only* at the top level, so {},a}b will not expand to anything,
// but a{},b}c will be expanded to [a}c,abc].
// One could argue that this is a bug in Bash, but since the goal of
// this module is to match Bash's rules, we escape a leading {}
if (str.slice(0, 2) === '{}') {
str = '\\{\\}' + str.slice(2);
}
return expand(escapeBraces(str), true).map(unescapeBraces);
}
/**
* @param {string} str
*/
function embrace(str) {
return '{' + str + '}';
}
/**
* @param {string} el
*/
function isPadded(el) {
return /^-?0\d/.test(el);
}
/**
* @param {number} i
* @param {number} y
*/
function lte(i, y) {
return i <= y;
}
/**
* @param {number} i
* @param {number} y
*/
function gte(i, y) {
return i >= y;
}
/**
* @param {string} str
* @param {boolean} [isTop]
*/
function expand(str, isTop) {
/** @type {string[]} */
const expansions = [];
const m = balanced('{', '}', str);
if (!m) return [str];
// no need to expand pre, since it is guaranteed to be free of brace-sets
const pre = m.pre;
const post = m.post.length
? expand(m.post, false)
: [''];
if (/\$$/.test(m.pre)) {
for (let k = 0; k < post.length; k++) {
const expansion = pre + '{' + m.body + '}' + post[k];
expansions.push(expansion);
}
} else {
const isNumericSequence = /^-?\d+\.\.-?\d+(?:\.\.-?\d+)?$/.test(m.body);
const isAlphaSequence = /^[a-zA-Z]\.\.[a-zA-Z](?:\.\.-?\d+)?$/.test(m.body);
const isSequence = isNumericSequence || isAlphaSequence;
const isOptions = m.body.indexOf(',') >= 0;
if (!isSequence && !isOptions) {
// {a},b}
if (m.post.match(/,.*\}/)) {
str = m.pre + '{' + m.body + escClose + m.post;
return expand(str);
}
return [str];
}
let n;
if (isSequence) {
n = m.body.split(/\.\./);
} else {
n = parseCommaParts(m.body);
if (n.length === 1) {
// x{{a,b}}y ==> x{a}y x{b}y
n = expand(n[0], false).map(embrace);
if (n.length === 1) {
return post.map(function (p) {
return m.pre + n[0] + p;
});
}
}
}
// at this point, n is the parts, and we know it's not a comma set
// with a single entry.
let N;
if (isSequence) {
const x = numeric(n[0]);
const y = numeric(n[1]);
const width = Math.max(n[0].length, n[1].length)
let incr = n.length == 3
? Math.abs(numeric(n[2]))
: 1;
let test = lte;
const reverse = y < x;
if (reverse) {
incr *= -1;
test = gte;
}
const pad = n.some(isPadded);
N = [];
for (let i = x; test(i, y); i += incr) {
let c;
if (isAlphaSequence) {
c = String.fromCharCode(i);
if (c === '\\')
c = '';
} else {
c = String(i);
if (pad) {
const need = width - c.length;
if (need > 0) {
const z = new Array(need + 1).join('0');
if (i < 0)
c = '-' + z + c.slice(1);
else
c = z + c;
}
}
}
N.push(c);
}
} else {
N = [];
for (let j = 0; j < n.length; j++) {
N.push.apply(N, expand(n[j], false));
}
}
for (let j = 0; j < N.length; j++) {
for (let k = 0; k < post.length; k++) {
const expansion = pre + N[j] + post[k];
if (!isTop || isSequence || expansion)
expansions.push(expansion);
}
}
}
return expansions;
}
module.exports = expandTop;

View File

@@ -0,0 +1,209 @@
/**
* archiver-utils
*
* Copyright (c) 2012-2014 Chris Talkington, contributors.
* Licensed under the MIT license.
* https://github.com/archiverjs/node-archiver/blob/master/LICENSE-MIT
*/
var fs = require('graceful-fs');
var path = require('path');
var flatten = require('./lodash.flatten')
var difference = require('./lodash.difference');
var union = require('./lodash.union');
var isPlainObject = require('./lodash.isplainobject');
var glob = require('./glob');
var file = module.exports = {};
var pathSeparatorRe = /[\/\\]/g;
// Process specified wildcard glob patterns or filenames against a
// callback, excluding and uniquing files in the result set.
var processPatterns = function (patterns, fn) {
// Filepaths to return.
var result = [];
// Iterate over flattened patterns array.
flatten(patterns).forEach(function (pattern) {
// If the first character is ! it should be omitted
var exclusion = pattern.indexOf('!') === 0;
// If the pattern is an exclusion, remove the !
if (exclusion) { pattern = pattern.slice(1); }
// Find all matching files for this pattern.
var matches = fn(pattern);
if (exclusion) {
// If an exclusion, remove matching files.
result = difference(result, matches);
} else {
// Otherwise add matching files.
result = union(result, matches);
}
});
return result;
};
// True if the file path exists.
file.exists = function () {
var filepath = path.join.apply(path, arguments);
return fs.existsSync(filepath);
};
// Return an array of all file paths that match the given wildcard patterns.
file.expand = function (...args) {
// If the first argument is an options object, save those options to pass
// into the File.prototype.glob.sync method.
var options = isPlainObject(args[0]) ? args.shift() : {};
// Use the first argument if it's an Array, otherwise convert the arguments
// object to an array and use that.
var patterns = Array.isArray(args[0]) ? args[0] : args;
// Return empty set if there are no patterns or filepaths.
if (patterns.length === 0) { return []; }
// Return all matching filepaths.
var matches = processPatterns(patterns, function (pattern) {
// Find all matching files for this pattern.
return glob.sync(pattern, options);
});
// Filter result set?
if (options.filter) {
matches = matches.filter(function (filepath) {
filepath = path.join(options.cwd || '', filepath);
try {
if (typeof options.filter === 'function') {
return options.filter(filepath);
} else {
// If the file is of the right type and exists, this should work.
return fs.statSync(filepath)[options.filter]();
}
} catch (e) {
// Otherwise, it's probably not the right type.
return false;
}
});
}
return matches;
};
// Build a multi task "files" object dynamically.
file.expandMapping = function (patterns, destBase, options) {
options = Object.assign({
rename: function (destBase, destPath) {
return path.join(destBase || '', destPath);
}
}, options);
var files = [];
var fileByDest = {};
// Find all files matching pattern, using passed-in options.
file.expand(options, patterns).forEach(function (src) {
var destPath = src;
// Flatten?
if (options.flatten) {
destPath = path.basename(destPath);
}
// Change the extension?
if (options.ext) {
destPath = destPath.replace(/(\.[^\/]*)?$/, options.ext);
}
// Generate destination filename.
var dest = options.rename(destBase, destPath, options);
// Prepend cwd to src path if necessary.
if (options.cwd) { src = path.join(options.cwd, src); }
// Normalize filepaths to be unix-style.
dest = dest.replace(pathSeparatorRe, '/');
src = src.replace(pathSeparatorRe, '/');
// Map correct src path to dest path.
if (fileByDest[dest]) {
// If dest already exists, push this src onto that dest's src array.
fileByDest[dest].src.push(src);
} else {
// Otherwise create a new src-dest file mapping object.
files.push({
src: [src],
dest: dest,
});
// And store a reference for later use.
fileByDest[dest] = files[files.length - 1];
}
});
return files;
};
// reusing bits of grunt's multi-task source normalization
file.normalizeFilesArray = function (data) {
var files = [];
data.forEach(function (obj) {
var prop;
if ('src' in obj || 'dest' in obj) {
files.push(obj);
}
});
if (files.length === 0) {
return [];
}
files = _(files).chain().forEach(function (obj) {
if (!('src' in obj) || !obj.src) { return; }
// Normalize .src properties to flattened array.
if (Array.isArray(obj.src)) {
obj.src = flatten(obj.src);
} else {
obj.src = [obj.src];
}
}).map(function (obj) {
// Build options object, removing unwanted properties.
var expandOptions = Object.assign({}, obj);
delete expandOptions.src;
delete expandOptions.dest;
// Expand file mappings.
if (obj.expand) {
return file.expandMapping(obj.src, obj.dest, expandOptions).map(function (mapObj) {
// Copy obj properties to result.
var result = Object.assign({}, obj);
// Make a clone of the orig obj available.
result.orig = Object.assign({}, obj);
// Set .src and .dest, processing both as templates.
result.src = mapObj.src;
result.dest = mapObj.dest;
// Remove unwanted properties.
['expand', 'cwd', 'flatten', 'rename', 'ext'].forEach(function (prop) {
delete result[prop];
});
return result;
});
}
// Copy obj properties to result, adding an .orig property.
var result = Object.assign({}, obj);
// Make a clone of the orig obj available.
result.orig = Object.assign({}, obj);
if ('src' in result) {
// Expose an expand-on-demand getter method as .src.
Object.defineProperty(result, 'src', {
enumerable: true,
get: function fn() {
var src;
if (!('result' in fn)) {
src = obj.src;
// If src is an array, flatten it. Otherwise, make it into an array.
src = Array.isArray(src) ? flatten(src) : [src];
// Expand src files, memoizing result.
fn.result = file.expand(expandOptions, src);
}
return fn.result;
}
});
}
if ('dest' in result) {
result.dest = obj.dest;
}
return result;
}).flatten().value();
return files;
};

View File

@@ -0,0 +1,43 @@
The ISC License
Copyright (c) 2016-2022 Isaac Z. Schlueter and Contributors
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
----
This library bundles a version of the `fs.realpath` and `fs.realpathSync`
methods from Node.js v0.10 under the terms of the Node.js MIT license.
Node's license follows, also included at the header of `old.js` which contains
the licensed code:
Copyright (c) 2016-2022 Joyent, Inc. and other Node contributors.
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.

View File

@@ -0,0 +1,54 @@
module.exports = realpath
realpath.realpath = realpath
realpath.sync = realpathSync
realpath.realpathSync = realpathSync
var fs = require('fs')
var origRealpath = fs.realpath
var origRealpathSync = fs.realpathSync
var version = process.version
var ok = /^v[0-5]\./.test(version)
var old = require('./old.js')
function newError(er) {
return er && er.syscall === 'realpath' && (
er.code === 'ELOOP' ||
er.code === 'ENOMEM' ||
er.code === 'ENAMETOOLONG'
)
}
function realpath(p, cache, cb) {
if (ok) {
return origRealpath(p, cache, cb)
}
if (typeof cache === 'function') {
cb = cache
cache = null
}
origRealpath(p, cache, function (er, result) {
if (newError(er)) {
old.realpath(p, cache, cb)
} else {
cb(er, result)
}
})
}
function realpathSync(p, cache) {
if (ok) {
return origRealpathSync(p, cache)
}
try {
return origRealpathSync(p, cache)
} catch (er) {
if (newError(er)) {
return old.realpathSync(p, cache)
} else {
throw er
}
}
}

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