Compare commits

...

40 Commits

Author SHA1 Message Date
advplyr
1bd657f07d Fix:Mark as finished once media has ended #635 2022-06-02 16:31:52 -05:00
advplyr
c3b33ea37a Fix:Sanitize filename to remove line breaks and check filename length is not too long #663 2022-06-01 20:14:10 -05:00
advplyr
36bd6e649a Fix:Remove podcast episode to also remove library file #636 2022-06-01 17:45:52 -05:00
advplyr
4621c78573 Update:Show version number in bottom of siderail #660 and save previous version data to continue showing if update is available 2022-06-01 17:15:13 -05:00
advplyr
c88bbf1ce4 Fix:Authors landing page available on refresh #659 2022-06-01 16:29:29 -05:00
advplyr
d37b25a6f6 Update audio player to player ui and separate out components 2022-05-31 20:13:46 -05:00
advplyr
792268f5ee Merge branch 'master' into video 2022-05-31 18:53:30 -05:00
advplyr
5f2d6f4d5e Add:Support for wav #652 2022-05-31 18:45:40 -05:00
advplyr
acf22ca4fa Testing video media type 2022-05-30 19:26:53 -05:00
advplyr
705aac40d7 Remove experimental set bookshelf texture 2022-05-30 09:58:02 -05:00
advplyr
7456052620 Fix:Match update cover image #648 2022-05-30 09:52:42 -05:00
advplyr
6cd4ec7fce Version bump v2.0.18 2022-05-29 13:18:31 -05:00
advplyr
93b8e11378 Fix:Mark media as finished if less than 5 seconds remain on a sync and call progress sync again when last track ends #635 2022-05-29 12:55:14 -05:00
advplyr
6161daeef0 Fix:OPML file upload reset 2022-05-29 12:22:16 -05:00
advplyr
cfcd351570 Add:Match All Authors button #642 2022-05-29 12:15:39 -05:00
advplyr
514893646a Add:OPML Upload for bulk adding podcasts #588 2022-05-29 11:46:45 -05:00
advplyr
e5469cc0f8 Update:Podcast library items do not show incomplete error when it doesnt have audio files #636 2022-05-29 07:25:30 -05:00
advplyr
ec6e70725c Fix:Include Watcher as lib with no dependencies and fix tiny-readdir bug #610 2022-05-28 20:01:20 -05:00
advplyr
160dac109d Add:User permission restrict explicit content #637 2022-05-28 16:53:03 -05:00
advplyr
6be741045f Merge pull request #622 from kaldigo/master
Updated matching
2022-05-28 15:56:27 -05:00
advplyr
f41d6d5c77 Update multi-series edit for match and make into separate component with inner modal 2022-05-28 15:54:04 -05:00
advplyr
a5dacd7821 Merge master 2022-05-28 13:58:52 -05:00
advplyr
8b12508b0c Add:Rich text editor for podcast episode description 2022-05-28 13:36:58 -05:00
advplyr
a394f38fe9 Add:Full podcast episode description parsed and viewable in modal #492 2022-05-28 11:38:51 -05:00
advplyr
c4bfa266b0 Add:HTML sanitizer lib to support html in podcasts and replace strip html lib 2022-05-27 19:41:40 -05:00
advplyr
96232676cb Fix:Save RSS feed url passed in by user instead of using the RSS feed returned from the request #634 2022-05-27 17:50:56 -05:00
advplyr
b2aab06e01 Add:Listening session modal with all details 2022-05-27 17:39:24 -05:00
advplyr
f002532c1e Add:User listening sessions page, Update:Listening sessions to save media times and device info 2022-05-26 19:09:46 -05:00
advplyr
54663f0f01 Fix:Listening stats on users page and user listening-sessions api endpoint 2022-05-26 15:10:12 -05:00
advplyr
d8df9a9dff Update dockerfile for generating client 2022-05-25 10:26:21 -05:00
advplyr
68efd30a54 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-05-24 21:44:15 -05:00
advplyr
27407d49dd Version bump 2.0.17 2022-05-24 21:44:11 -05:00
advplyr
97d4330cda Merge pull request #632 from cassieesposito/getBookDataFromDir-refactor
URGENT: Fix to serious bug
2022-05-24 21:33:40 -05:00
Cassie Esposito
3153bdc5bb Fixed bug that caused scanner to fail to get title when subtitle parsing is off, refactored possibly confusing variable declarations. 2022-05-24 19:04:51 -07:00
Cassie Esposito
31fd75a895 Merge branch 'getBookDataFromDir-refactor' of github.com:cassieesposito/audiobookshelf into getBookDataFromDir-refactor 2022-05-24 18:43:20 -07:00
Cassie Esposito
b22173a631 Undoing changes caused by linter run amok 2022-05-24 17:30:16 -07:00
Kaldigo
aeb87c81a1 Fix missed preferMatchedMetadata rename 2022-05-24 01:29:43 +01:00
Kaldigo
ce88ebb55b Removed response groups in Audible query and limited to 10 results 2022-05-23 22:48:11 +01:00
Kaldigo
c7e3f08d39 Merge branch 'advplyr:master' into master 2022-05-23 22:46:17 +01:00
Kaldigo
d15264832d Updated matching with latest changes, Added override toggle for quickmatch, added asin and isbn to quickmatch query, updated audible provider to use audnexus 2022-05-23 03:56:51 +01:00
142 changed files with 7451 additions and 1215 deletions

View File

@@ -2,7 +2,7 @@
FROM node:16-alpine AS build
WORKDIR /client
COPY /client /client
RUN npm install
RUN npm ci && npm cache clean --force
RUN npm run generate
### STAGE 1: Build server ###

View File

@@ -1,6 +1,7 @@
@import './fonts.css';
@import './transitions.css';
@import './draggable.css';
@import './defaultStyles.css';
:root {
--bookshelf-texture-img: url(/textures/wood_default.jpg);

View File

@@ -0,0 +1,55 @@
/*
This is for setting regular html styles for places where embedding HTML will be
like podcast episode descriptions. Otherwise TailwindCSS will have stripped all default markup.
*/
.default-style p {
display: block;
margin-block-start: 1em;
margin-block-end: 1em;
margin-inline-start: 0px;
margin-inline-end: 0px;
}
.default-style a {
text-decoration: none;
color: #5985ff;
}
.default-style ul {
display: block;
list-style: circle;
list-style-type: disc;
margin-block-start: 1em;
margin-block-end: 1em;
margin-inline-start: 0px;
margin-inline-end: 0px;
padding-inline-start: 40px;
}
.default-style ol {
display: block;
list-style: decimal;
list-style-type: decimal;
margin-block-start: 1em;
margin-block-end: 1em;
margin-inline-start: 0px;
margin-inline-end: 0px;
padding-inline-start: 40px;
}
.default-style li {
display: list-item;
text-align: -webkit-match-parent;
}
.default-style li::marker {
unicode-bidi: isolate;
font-variant-numeric: tabular-nums;
text-transform: none;
text-indent: 0px !important;
text-align: start !important;
text-align-last: start !important;
}

563
client/assets/trix.css Normal file
View File

@@ -0,0 +1,563 @@
@charset "UTF-8";
/*
Trix 1.3.1
Copyright © 2020 Basecamp, LLC
http://trix-editor.org/*/
trix-editor {
border: 1px solid rgb(75, 85, 99);
border-radius: 3px;
background: rgb(35, 35, 35);
margin: 0;
padding: 0.4em 0.6em;
min-height: 5em;
outline: none;
}
trix-toolbar * {
box-sizing: border-box;
}
trix-toolbar .trix-button-row {
display: flex;
flex-wrap: nowrap;
justify-content: space-between;
overflow-x: auto;
}
trix-toolbar .trix-button-group {
display: flex;
margin-bottom: 10px;
border: 1px solid rgb(75, 85, 99);
border-top-color: rgb(75, 85, 99);
border-bottom-color: rgb(75, 85, 99);
border-radius: 3px;
}
trix-toolbar .trix-button-group:not(:first-child) {
margin-left: 1.5vw;
}
@media (max-device-width: 768px) {
trix-toolbar .trix-button-group:not(:first-child) {
margin-left: 0;
}
}
trix-toolbar .trix-button-group-spacer {
flex-grow: 1;
}
@media (max-device-width: 768px) {
trix-toolbar .trix-button-group-spacer {
display: none;
}
}
trix-toolbar .trix-button {
position: relative;
float: left;
color: rgba(0, 0, 0, 0.6);
font-size: 0.75em;
font-weight: 600;
white-space: nowrap;
padding: 0 0.5em;
margin: 0;
outline: none;
border: none;
border-radius: 0;
background: transparent;
}
trix-toolbar .trix-button:not(:first-child) {
border-left: 1px solid rgb(75, 85, 99);
}
trix-toolbar .trix-button.trix-active {
background: #bbb;
color: black;
}
trix-toolbar .trix-button:not(:disabled) {
cursor: pointer;
background: rgb(35, 35, 35);
}
trix-toolbar .trix-button:disabled {
color: rgba(0, 0, 0, 0.25);
}
@media (max-device-width: 768px) {
trix-toolbar .trix-button {
letter-spacing: -0.01em;
padding: 0 0.3em;
}
}
trix-toolbar .trix-button--icon {
font-size: inherit;
width: 2.6em;
height: 1.6em;
max-width: calc(0.8em + 4vw);
text-indent: -9999px;
}
@media (max-device-width: 768px) {
trix-toolbar .trix-button--icon {
height: 2em;
max-width: calc(0.8em + 3.5vw);
}
}
trix-toolbar .trix-button--icon::before {
display: inline-block;
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
opacity: 0.6;
content: "";
background-position: center;
background-repeat: no-repeat;
background-size: contain;
filter: invert(100%);
}
@media (max-device-width: 768px) {
trix-toolbar .trix-button--icon::before {
right: 6%;
left: 6%;
}
}
trix-toolbar .trix-button--icon.trix-active::before {
opacity: 1;
}
trix-toolbar .trix-button--icon:disabled::before {
opacity: 0.125;
}
trix-toolbar .trix-button--icon-attach::before {
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M16.5%206v11.5a4%204%200%201%201-8%200V5a2.5%202.5%200%200%201%205%200v10.5a1%201%200%201%201-2%200V6H10v9.5a2.5%202.5%200%200%200%205%200V5a4%204%200%201%200-8%200v12.5a5.5%205.5%200%200%200%2011%200V6h-1.5z%22%2F%3E%3C%2Fsvg%3E);
top: 8%;
bottom: 4%;
}
trix-toolbar .trix-button--icon-bold::before {
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M15.6%2011.8c1-.7%201.6-1.8%201.6-2.8a4%204%200%200%200-4-4H7v14h7c2.1%200%203.7-1.7%203.7-3.8%200-1.5-.8-2.8-2.1-3.4zM10%207.5h3a1.5%201.5%200%201%201%200%203h-3v-3zm3.5%209H10v-3h3.5a1.5%201.5%200%201%201%200%203z%22%2F%3E%3C%2Fsvg%3E);
}
trix-toolbar .trix-button--icon-italic::before {
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M10%205v3h2.2l-3.4%208H6v3h8v-3h-2.2l3.4-8H18V5h-8z%22%2F%3E%3C%2Fsvg%3E);
}
trix-toolbar .trix-button--icon-link::before {
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M9.88%2013.7a4.3%204.3%200%200%201%200-6.07l3.37-3.37a4.26%204.26%200%200%201%206.07%200%204.3%204.3%200%200%201%200%206.06l-1.96%201.72a.91.91%200%201%201-1.3-1.3l1.97-1.71a2.46%202.46%200%200%200-3.48-3.48l-3.38%203.37a2.46%202.46%200%200%200%200%203.48.91.91%200%201%201-1.3%201.3z%22%2F%3E%3Cpath%20d%3D%22M4.25%2019.46a4.3%204.3%200%200%201%200-6.07l1.93-1.9a.91.91%200%201%201%201.3%201.3l-1.93%201.9a2.46%202.46%200%200%200%203.48%203.48l3.37-3.38c.96-.96.96-2.52%200-3.48a.91.91%200%201%201%201.3-1.3%204.3%204.3%200%200%201%200%206.07l-3.38%203.38a4.26%204.26%200%200%201-6.07%200z%22%2F%3E%3C%2Fsvg%3E);
}
trix-toolbar .trix-button--icon-strike::before {
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M12.73%2014l.28.14c.26.15.45.3.57.44.12.14.18.3.18.5%200%20.3-.15.56-.44.75-.3.2-.76.3-1.39.3A13.52%2013.52%200%200%201%207%2014.95v3.37a10.64%2010.64%200%200%200%204.84.88c1.26%200%202.35-.19%203.28-.56.93-.37%201.64-.9%202.14-1.57s.74-1.45.74-2.32c0-.26-.02-.51-.06-.75h-5.21zm-5.5-4c-.08-.34-.12-.7-.12-1.1%200-1.29.52-2.3%201.58-3.02%201.05-.72%202.5-1.08%204.34-1.08%201.62%200%203.28.34%204.97%201l-1.3%202.93c-1.47-.6-2.73-.9-3.8-.9-.55%200-.96.08-1.2.26-.26.17-.38.38-.38.64%200%20.27.16.52.48.74.17.12.53.3%201.05.53H7.23zM3%2013h18v-2H3v2z%22%2F%3E%3C%2Fsvg%3E);
}
trix-toolbar .trix-button--icon-quote::before {
background-image: url(data:image/svg+xml,%3Csvg%20version%3D%221%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M6%2017h3l2-4V7H5v6h3zm8%200h3l2-4V7h-6v6h3z%22%2F%3E%3C%2Fsvg%3E);
}
trix-toolbar .trix-button--icon-heading-1::before {
background-image: url(data:image/svg+xml,%3Csvg%20version%3D%221%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M12%209v3H9v7H6v-7H3V9h9zM8%204h14v3h-6v12h-3V7H8V4z%22%2F%3E%3C%2Fsvg%3E);
}
trix-toolbar .trix-button--icon-code::before {
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M18.2%2012L15%2015.2l1.4%201.4L21%2012l-4.6-4.6L15%208.8l3.2%203.2zM5.8%2012L9%208.8%207.6%207.4%203%2012l4.6%204.6L9%2015.2%205.8%2012z%22%2F%3E%3C%2Fsvg%3E);
}
trix-toolbar .trix-button--icon-bullet-list::before {
background-image: url(data:image/svg+xml,%3Csvg%20version%3D%221%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M4%204a2%202%200%201%200%200%204%202%202%200%200%200%200-4zm0%206a2%202%200%201%200%200%204%202%202%200%200%200%200-4zm0%206a2%202%200%201%200%200%204%202%202%200%200%200%200-4zm4%203h14v-2H8v2zm0-6h14v-2H8v2zm0-8v2h14V5H8z%22%2F%3E%3C%2Fsvg%3E);
}
trix-toolbar .trix-button--icon-number-list::before {
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M2%2017h2v.5H3v1h1v.5H2v1h3v-4H2v1zm1-9h1V4H2v1h1v3zm-1%203h1.8L2%2013.1v.9h3v-1H3.2L5%2010.9V10H2v1zm5-6v2h14V5H7zm0%2014h14v-2H7v2zm0-6h14v-2H7v2z%22%2F%3E%3C%2Fsvg%3E);
}
trix-toolbar .trix-button--icon-undo::before {
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M12.5%208c-2.6%200-5%201-6.9%202.6L2%207v9h9l-3.6-3.6A8%208%200%200%201%2020%2016l2.4-.8a10.5%2010.5%200%200%200-10-7.2z%22%2F%3E%3C%2Fsvg%3E);
}
trix-toolbar .trix-button--icon-redo::before {
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M18.4%2010.6a10.5%2010.5%200%200%200-16.9%204.6L4%2016a8%208%200%200%201%2012.7-3.6L13%2016h9V7l-3.6%203.6z%22%2F%3E%3C%2Fsvg%3E);
}
trix-toolbar .trix-button--icon-decrease-nesting-level::before {
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M3%2019h19v-2H3v2zm7-6h12v-2H10v2zm-8.3-.3l2.8%202.9L6%2014.2%204%2012l2-2-1.4-1.5L1%2012l.7.7zM3%205v2h19V5H3z%22%2F%3E%3C%2Fsvg%3E);
}
trix-toolbar .trix-button--icon-increase-nesting-level::before {
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M3%2019h19v-2H3v2zm7-6h12v-2H10v2zm-6.9-1L1%2014.2l1.4%201.4L6%2012l-.7-.7-2.8-2.8L1%209.9%203.1%2012zM3%205v2h19V5H3z%22%2F%3E%3C%2Fsvg%3E);
}
trix-toolbar .trix-dialogs {
position: relative;
}
trix-toolbar .trix-dialog {
position: absolute;
top: 0;
left: 0;
right: 0;
font-size: 0.75em;
padding: 15px 10px;
background: rgb(48, 48, 48);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
border: 1px solid rgb(112, 112, 112);
border-radius: 5px;
z-index: 5;
}
trix-toolbar .trix-input--dialog {
font-size: inherit;
font-weight: normal;
padding: 0.5em 0.8em;
margin: 0 10px 0 0;
border-radius: 3px;
border: 1px solid #bbb;
background-color: rgb(95, 95, 95);
box-shadow: none;
outline: none;
-webkit-appearance: none;
-moz-appearance: none;
}
trix-toolbar .trix-input--dialog.validate:invalid {
box-shadow: #F00 0px 0px 1.5px 1px;
}
trix-toolbar .trix-button--dialog {
font-size: inherit;
padding: 0.5em;
border-bottom: none;
color: #eee;
}
trix-toolbar .trix-dialog--link {
max-width: 600px;
}
trix-toolbar .trix-dialog__link-fields {
display: flex;
align-items: baseline;
}
trix-toolbar .trix-dialog__link-fields .trix-input {
flex: 1;
}
trix-toolbar .trix-dialog__link-fields .trix-button-group {
flex: 0 0 content;
margin: 0;
}
trix-editor [data-trix-mutable]:not(.attachment__caption-editor) {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
trix-editor [data-trix-mutable]::-moz-selection,
trix-editor [data-trix-cursor-target]::-moz-selection,
trix-editor [data-trix-mutable] ::-moz-selection {
background: none;
}
trix-editor [data-trix-mutable]::selection,
trix-editor [data-trix-cursor-target]::selection,
trix-editor [data-trix-mutable] ::selection {
background: none;
}
trix-editor [data-trix-mutable].attachment__caption-editor:focus::-moz-selection {
background: highlight;
}
trix-editor [data-trix-mutable].attachment__caption-editor:focus::selection {
background: highlight;
}
trix-editor [data-trix-mutable].attachment.attachment--file {
box-shadow: 0 0 0 2px highlight;
border-color: transparent;
}
trix-editor [data-trix-mutable].attachment img {
box-shadow: 0 0 0 2px highlight;
}
trix-editor .attachment {
position: relative;
}
trix-editor .attachment:hover {
cursor: default;
}
trix-editor .attachment--preview .attachment__caption:hover {
cursor: text;
}
trix-editor .attachment__progress {
position: absolute;
z-index: 1;
height: 20px;
top: calc(50% - 10px);
left: 5%;
width: 90%;
opacity: 0.9;
transition: opacity 200ms ease-in;
}
trix-editor .attachment__progress[value="100"] {
opacity: 0;
}
trix-editor .attachment__caption-editor {
display: inline-block;
width: 100%;
margin: 0;
padding: 0;
font-size: inherit;
font-family: inherit;
line-height: inherit;
color: inherit;
text-align: center;
vertical-align: top;
border: none;
outline: none;
-webkit-appearance: none;
-moz-appearance: none;
}
trix-editor .attachment__toolbar {
position: absolute;
z-index: 1;
top: -0.9em;
left: 0;
width: 100%;
text-align: center;
}
trix-editor .trix-button-group {
display: inline-flex;
}
trix-editor .trix-button {
position: relative;
float: left;
color: #666;
white-space: nowrap;
font-size: 80%;
padding: 0 0.8em;
margin: 0;
outline: none;
border: none;
border-radius: 0;
background: transparent;
}
trix-editor .trix-button:not(:first-child) {
border-left: 1px solid #ccc;
}
trix-editor .trix-button.trix-active {
background: #cbeefa;
}
trix-editor .trix-button:not(:disabled) {
cursor: pointer;
}
trix-editor .trix-button--remove {
text-indent: -9999px;
display: inline-block;
padding: 0;
outline: none;
width: 1.8em;
height: 1.8em;
line-height: 1.8em;
border-radius: 50%;
background-color: #fff;
border: 2px solid highlight;
box-shadow: 1px 1px 6px rgba(0, 0, 0, 0.25);
}
trix-editor .trix-button--remove::before {
display: inline-block;
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
opacity: 0.7;
content: "";
background-image: url(data:image/svg+xml,%3Csvg%20height%3D%2224%22%20width%3D%2224%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M19%206.4L17.6%205%2012%2010.6%206.4%205%205%206.4l5.6%205.6L5%2017.6%206.4%2019l5.6-5.6%205.6%205.6%201.4-1.4-5.6-5.6z%22%2F%3E%3Cpath%20d%3D%22M0%200h24v24H0z%22%20fill%3D%22none%22%2F%3E%3C%2Fsvg%3E);
background-position: center;
background-repeat: no-repeat;
background-size: 90%;
}
trix-editor .trix-button--remove:hover {
border-color: #333;
}
trix-editor .trix-button--remove:hover::before {
opacity: 1;
}
trix-editor .attachment__metadata-container {
position: relative;
}
trix-editor .attachment__metadata {
position: absolute;
left: 50%;
top: 2em;
transform: translate(-50%, 0);
max-width: 90%;
padding: 0.1em 0.6em;
font-size: 0.8em;
color: #fff;
background-color: rgba(0, 0, 0, 0.7);
border-radius: 3px;
}
trix-editor .attachment__metadata .attachment__name {
display: inline-block;
max-width: 100%;
vertical-align: bottom;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
trix-editor .attachment__metadata .attachment__size {
margin-left: 0.2em;
white-space: nowrap;
}
.trix-content {
line-height: 1.5;
}
.trix-content * {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.trix-content h1 {
font-size: 1.2em;
line-height: 1.2;
}
.trix-content blockquote {
border: 0 solid #ccc;
border-left-width: 0.3em;
margin-left: 0.3em;
padding-left: 0.6em;
}
.trix-content [dir=rtl] blockquote,
.trix-content blockquote[dir=rtl] {
border-width: 0;
border-right-width: 0.3em;
margin-right: 0.3em;
padding-right: 0.6em;
}
.trix-content li {
margin-left: 1em;
}
.trix-content [dir=rtl] li {
margin-right: 1em;
}
.trix-content pre {
display: inline-block;
width: 100%;
vertical-align: top;
font-family: monospace;
font-size: 0.9em;
padding: 0.5em;
white-space: pre;
background-color: #eee;
overflow-x: auto;
}
.trix-content img {
max-width: 100%;
height: auto;
}
.trix-content .attachment {
display: inline-block;
position: relative;
max-width: 100%;
}
.trix-content .attachment a {
color: inherit;
text-decoration: none;
}
.trix-content .attachment a:hover,
.trix-content .attachment a:visited:hover {
color: inherit;
}
.trix-content .attachment__caption {
text-align: center;
}
.trix-content .attachment__caption .attachment__name+.attachment__size::before {
content: ' · ';
}
.trix-content .attachment--preview {
width: 100%;
text-align: center;
}
.trix-content .attachment--preview .attachment__caption {
color: #666;
font-size: 0.9em;
line-height: 1.2;
}
.trix-content .attachment--file {
color: #333;
line-height: 1;
margin: 0 2px 2px 2px;
padding: 0.4em 1em;
border: 1px solid #bbb;
border-radius: 5px;
}
.trix-content .attachment-gallery {
display: flex;
flex-wrap: wrap;
position: relative;
}
.trix-content .attachment-gallery .attachment {
flex: 1 0 33%;
padding: 0 0.5em;
max-width: 33%;
}
.trix-content .attachment-gallery.attachment-gallery--2 .attachment,
.trix-content .attachment-gallery.attachment-gallery--4 .attachment {
flex-basis: 50%;
max-width: 50%;
}

View File

@@ -147,9 +147,6 @@ export default {
}
},
methods: {
toggleBookshelfTexture() {
this.$store.dispatch('setBookshelfTexture', 'wood2.png')
},
cancelSelectionMode() {
if (this.processingBatchDelete) return
this.$store.commit('setSelectedLibraryItems', [])

View File

@@ -2,10 +2,6 @@
<div id="bookshelf" ref="wrapper" class="w-full max-w-full h-full overflow-y-scroll relative">
<!-- Cover size widget -->
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
<!-- Experimental Bookshelf Texture -->
<div v-show="showExperimentalFeatures && !isAlternativeBookshelfView" class="fixed bottom-4 right-28 z-40">
<div class="rounded-full py-1 bg-primary hover:bg-bg cursor-pointer px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent @click="showBookshelfTextureModal"><p class="text-sm py-0.5">Texture</p></div>
</div>
<div v-if="loaded && !shelves.length && !search" class="w-full flex flex-col items-center justify-center py-12">
<p class="text-center text-2xl font-book mb-4 py-4">{{ libraryName }} Library is empty!</p>
@@ -100,9 +96,6 @@ export default {
}
},
methods: {
showBookshelfTextureModal() {
this.$store.commit('globals/setShowBookshelfTextureModal', true)
},
async init() {
this.wrapperClientWidth = this.$refs.wrapper ? this.$refs.wrapper.clientWidth : 0

View File

@@ -61,6 +61,10 @@
<p>Search results for "{{ searchQuery }}"</p>
<div class="flex-grow" />
</template>
<template v-else-if="page === 'authors'">
<div class="flex-grow" />
<ui-btn v-if="userCanUpdate && authors && authors.length" :loading="processingAuthors" color="primary" small @click="matchAllAuthors">Match All Authors</ui-btn>
</template>
</div>
</div>
</template>
@@ -75,7 +79,11 @@ export default {
default: () => null
},
searchQuery: String,
viewMode: String
viewMode: String,
authors: {
type: Array,
default: () => []
}
},
data() {
return {
@@ -85,13 +93,17 @@ export default {
keywordFilter: null,
keywordTimeout: null,
processingSeries: false,
processingIssues: false
processingIssues: false,
processingAuthors: false
}
},
computed: {
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
isPodcast() {
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast'
},
@@ -147,6 +159,35 @@ export default {
}
},
methods: {
async matchAllAuthors() {
this.processingAuthors = true
for (const author of this.authors) {
const payload = {}
if (author.asin) payload.asin = author.asin
else payload.q = author.name
console.log('Payload', payload, 'author', author)
this.$eventBus.$emit(`searching-author-${author.id}`, true)
var response = await this.$axios.$post(`/api/authors/${author.id}/match`, payload).catch((error) => {
console.error('Failed', error)
return null
})
if (!response) {
console.error(`Author ${author.name} not found`)
this.$toast.error(`Author ${author.name} not found`)
} else if (response.updated) {
if (response.author.imagePath) console.log(`Author ${response.author.name} was updated`)
else console.log(`Author ${response.author.name} was updated (no image found)`)
} else {
console.log(`No updates were made for Author ${response.author.name}`)
}
this.$eventBus.$emit(`searching-author-${author.id}`, false)
}
this.processingAuthors = false
},
removeAllIssues() {
if (confirm(`Are you sure you want to remove all library items with issues?\n\nNote: This will not delete any files`)) {
this.processingIssues = true

View File

@@ -22,13 +22,6 @@
</div>
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
<!-- Experimental Bookshelf Texture -->
<div v-show="showExperimentalFeatures && !isAlternativeBookshelfView" class="fixed bottom-4 right-28 z-40">
<div class="rounded-full py-1 bg-primary hover:bg-bg cursor-pointer px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent @click="showBookshelfTextureModal">
<p class="text-sm py-0.5">Texture</p>
</div>
</div>
</div>
</template>
@@ -206,9 +199,6 @@ export default {
}
},
methods: {
showBookshelfTextureModal() {
this.$store.commit('globals/setShowBookshelfTextureModal', true)
},
clearFilter() {
this.$store.dispatch('user/updateUserSettings', { filterBy: 'all' })
},

View File

@@ -73,6 +73,12 @@
<p class="text-xs font-mono pb-0.5">{{ numIssues }}</p>
</div>
</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>
<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>
</div>
</template>
@@ -82,6 +88,12 @@ export default {
return {}
},
computed: {
Source() {
return this.$store.state.Source
},
isMobileLandscape() {
return this.$store.state.globals.isMobileLandscape
},
isShowingBookshelfToolbar() {
if (!this.$route.name) return false
return this.$route.name.startsWith('library')
@@ -131,6 +143,21 @@ export default {
},
numIssues() {
return this.$store.state.libraries.issues || 0
},
versionData() {
return this.$store.state.versionData || {}
},
hasUpdate() {
return !!this.versionData.hasUpdate
},
latestVersion() {
return this.versionData.latestVersion
},
githubTagUrl() {
return this.versionData.githubTagUrl
},
streamLibraryItem() {
return this.$store.state.streamLibraryItem
}
},
methods: {},

View File

@@ -1,14 +1,15 @@
<template>
<div v-if="streamLibraryItem" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 sm:h-44 md:h-40 z-40 bg-primary px-4 pb-1 md:pb-4 pt-2">
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="absolute left-4 cursor-pointer" :style="{ top: bookCoverPosTop + 'px' }">
<div id="videoDock" />
<nuxt-link v-if="!playerHandler.isVideo" :to="`/item/${streamLibraryItem.id}`" class="absolute left-4 cursor-pointer" :style="{ top: bookCoverPosTop + 'px' }">
<covers-book-cover :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</nuxt-link>
<div class="flex items-start pl-24 mb-6 md:mb-0">
<div class="flex items-start mb-6 md:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : 'pl-24'">
<div>
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-base sm:text-lg">
{{ title }}
</nuxt-link>
<div class="text-gray-400 flex items-center">
<div v-if="!playerHandler.isVideo" class="text-gray-400 flex items-center">
<span class="material-icons text-sm">person</span>
<p v-if="podcastAuthor">{{ podcastAuthor }}</p>
<p v-else-if="authors.length" class="pl-1.5 text-sm sm:text-base">
@@ -25,7 +26,7 @@
<div class="flex-grow" />
<span class="material-icons px-2 py-1 md:p-4 cursor-pointer" @click="closePlayer">close</span>
</div>
<audio-player
<player-ui
ref="audioPlayer"
:chapters="chapters"
:paused="!isPlaying"

View File

@@ -101,8 +101,16 @@ export default {
this.$toast.info('No updates were made for Author')
}
this.searching = false
},
setSearching(isSearching) {
this.searching = isSearching
}
},
mounted() {}
mounted() {
this.$eventBus.$on(`searching-author-${this.authorId}`, this.setSearching)
},
beforeDestroy() {
this.$eventBus.$off(`searching-author-${this.authorId}`, this.setSearching)
}
}
</script>

View File

@@ -0,0 +1,71 @@
<template>
<div ref="wrapper" class="w-full p-2 border border-white border-opacity-10 rounded">
<div class="flex">
<div class="w-16 min-w-16">
<div class="w-full h-16 bg-primary">
<img v-if="image" :src="image" class="w-full h-full object-cover" />
</div>
<p class="text-gray-400 text-xxs pt-1 text-center">{{ numEpisodes }} Episodes</p>
</div>
<div class="flex-grow pl-2" :style="{ maxWidth: detailsWidth + 'px' }">
<p class="mb-1">{{ title }}</p>
<p class="text-xs mb-1 text-gray-300">{{ author }}</p>
<p class="text-xs mb-2 text-gray-200">{{ description }}</p>
<p class="text-xs truncate text-blue-200">
Folder: <span class="font-mono">{{ folderPath }}</span>
</p>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
feed: {
type: Object,
default: () => {}
},
libraryFolderPath: String
},
data() {
return {
width: 900
}
},
computed: {
title() {
return this.metadata.title || 'No Title'
},
image() {
return this.metadata.imageUrl
},
description() {
return this.metadata.description || ''
},
author() {
return this.metadata.author || ''
},
metadata() {
return this.feed || {}
},
numEpisodes() {
return this.feed.numEpisodes || 0
},
folderPath() {
if (!this.libraryFolderPath) return ''
return `${this.libraryFolderPath}\\${this.$sanitizeFilename(this.title)}`
},
detailsWidth() {
return this.width - 85
}
},
methods: {},
updated() {
this.width = this.$refs.wrapper.clientWidth
},
mounted() {
this.width = this.$refs.wrapper.clientWidth
}
}
</script>

View File

@@ -65,6 +65,15 @@
</div>
</div>
<div class="flex items-center my-2 max-w-md">
<div class="w-1/2">
<p>Can Access Explicit Content</p>
</div>
<div class="w-1/2">
<ui-toggle-switch v-model="newUser.permissions.accessExplicitContent" />
</div>
</div>
<div class="flex items-center my-2 max-w-md">
<div class="w-1/2">
<p>Can Access All Libraries</p>

View File

@@ -1,68 +0,0 @@
<template>
<modals-modal v-model="show" name="textures" :width="'40vw'" :height="'unset'" :bg-opacity="10" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">Bookshelf Texture</p>
</div>
</template>
<div class="px-4 w-full max-w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300" @mousedown.prevent @mouseup.prevent @mousemove.prevent>
<h1 class="text-2xl mb-2">Select a bookshelf texture (For testing only)</h1>
<div class="overflow-y-hidden overflow-x-auto">
<div class="flex -mx-1">
<template v-for="texture in textures">
<div :key="texture" class="relative mx-1" style="height: 180px; width: 180px; min-width: 180px" @mousedown.prevent @mouseup.prevent>
<img :src="texture" class="h-full object-cover cursor-pointer" @click="setTexture(texture)" />
<div v-if="texture === selectedBookshelfTexture" class="absolute top-0 left-0 flex items-center justify-center w-full h-full bg-black bg-opacity-10">
<span class="material-icons text-4xl text-success">check</span>
</div>
</div>
</template>
</div>
</div>
<!-- <div class="flex pt-4">
<div class="flex-grow" />
<ui-btn color="success" type="submit">Submit</ui-btn>
</div> -->
</div>
</modals-modal>
</template>
<script>
export default {
data() {
return {
textures: ['/textures/wood_default.jpg', '/textures/wood1.png', '/textures/wood2.png', '/textures/wood3.png', '/textures/wood4.png', '/textures/leather1.jpg'],
processing: false
}
},
watch: {
show: {
handler(newVal) {
if (newVal) {
this.init()
}
}
}
},
computed: {
show: {
get() {
return this.$store.state.globals.showBookshelfTextureModal
},
set(val) {
this.$store.commit('globals/setShowBookshelfTextureModal', val)
}
},
selectedBookshelfTexture() {
return this.$store.state.selectedBookshelfTexture
}
},
methods: {
init() {},
setTexture(img) {
this.$store.dispatch('setBookshelfTexture', img)
}
},
mounted() {}
}
</script>

View File

@@ -0,0 +1,120 @@
<template>
<div ref="wrapper" class="hidden absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 rounded-lg items-center justify-center" style="z-index: 51" @click="clickClose">
<div class="absolute top-5 right-5 h-12 w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300">
<span class="material-icons text-4xl">close</span>
</div>
<div ref="content" class="text-white">
<form v-if="selectedSeries" @submit.prevent="submitSeriesForm">
<div class="bg-bg rounded-lg p-8" @click.stop>
<div class="flex">
<div class="flex-grow p-1 min-w-80">
<ui-input-dropdown ref="newSeriesSelect" v-model="selectedSeries.name" :items="existingSeriesNames" :disabled="!selectedSeries.id.startsWith('new')" label="Series Name" />
</div>
<div class="w-40 p-1">
<ui-text-input-with-label v-model="selectedSeries.sequence" label="Sequence" />
</div>
</div>
<div class="flex justify-end mt-2 p-1">
<ui-btn type="submit">Save</ui-btn>
</div>
</div>
</form>
</div>
</div>
</template>
<script>
export default {
props: {
value: Boolean,
selectedSeries: {
type: Object,
default: () => {}
},
existingSeriesNames: {
type: Array,
default: () => []
}
},
data() {
return {
el: null,
content: null
}
},
watch: {
show(newVal) {
if (newVal) {
this.$nextTick(this.setShow)
} else {
this.setHide()
}
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
}
},
methods: {
submitSeriesForm() {
if (this.$refs.newSeriesSelect) {
this.$refs.newSeriesSelect.blur()
}
this.$emit('submit')
},
clickClose() {
this.show = false
},
hotkey(action) {
if (action === this.$hotkeys.Modal.CLOSE) {
this.show = false
}
},
setShow() {
if (!this.el || !this.content) {
this.init()
}
if (!this.el || !this.content) {
return
}
document.body.appendChild(this.el)
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)
},
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)
},
init() {
this.el = this.$refs.wrapper
this.content = this.$refs.content
if (this.content && this.el) {
this.el.classList.remove('hidden')
this.el.classList.add('flex')
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()
}
}
},
mounted() {}
}
</script>

View File

@@ -0,0 +1,150 @@
<template>
<modals-modal v-model="show" name="listening-session-modal" :width="700" :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">Session {{ _session.id }}</p>
</div>
</template>
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden p-6" style="max-height: 80vh">
<div class="flex items-center">
<p class="text-base text-gray-200">{{ _session.displayTitle }}</p>
<p v-if="_session.displayAuthor" class="text-xs text-gray-400 px-4">by {{ _session.displayAuthor }}</p>
</div>
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
<div class="flex flex-wrap mb-4">
<div class="w-full md:w-2/3">
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mb-2">Details</p>
<div class="flex items-center -mx-1 mb-1">
<div class="w-40 px-1 text-gray-200">Started At</div>
<div class="px-1">
{{ $formatDate(_session.startedAt, 'MMMM do, yyyy HH:mm') }}
</div>
</div>
<div class="flex items-center -mx-1 mb-1">
<div class="w-40 px-1 text-gray-200">Updated At</div>
<div class="px-1">
{{ $formatDate(_session.updatedAt, 'MMMM do, yyyy HH:mm') }}
</div>
</div>
<div class="flex items-center -mx-1 mb-1">
<div class="w-40 px-1 text-gray-200">Listened for</div>
<div class="px-1">
{{ $elapsedPrettyExtended(_session.timeListening) }}
</div>
</div>
<div class="flex items-center -mx-1 mb-1">
<div class="w-40 px-1 text-gray-200">Start Time</div>
<div class="px-1">
{{ $secondsToTimestamp(_session.startTime) }}
</div>
</div>
<div class="flex items-center -mx-1 mb-1">
<div class="w-40 px-1 text-gray-200">Last Time</div>
<div class="px-1">
{{ $secondsToTimestamp(_session.currentTime) }}
</div>
</div>
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">Item</p>
<div v-if="_session.libraryId" class="flex items-center -mx-1 mb-1">
<div class="w-40 px-1 text-gray-200">Library Id</div>
<div class="px-1">
{{ _session.libraryId }}
</div>
</div>
<div class="flex items-center -mx-1 mb-1">
<div class="w-40 px-1 text-gray-200">Library Item Id</div>
<div class="px-1">
{{ _session.libraryItemId }}
</div>
</div>
<div v-if="_session.episodeId" class="flex items-center -mx-1 mb-1">
<div class="w-40 px-1 text-gray-200">Episode Id</div>
<div class="px-1">
{{ _session.episodeId }}
</div>
</div>
<div class="flex items-center -mx-1 mb-1">
<div class="w-40 px-1 text-gray-200">Media Type</div>
<div class="px-1">
{{ _session.mediaType }}
</div>
</div>
<div class="flex items-center -mx-1 mb-1">
<div class="w-40 px-1 text-gray-200">Duration</div>
<div class="px-1">
{{ $elapsedPretty(_session.duration) }}
</div>
</div>
</div>
<div class="w-full md:w-1/3">
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mb-2 mt-6 md:mt-0">User</p>
<p class="mb-1">{{ _session.userId }}</p>
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">Media Player</p>
<p class="mb-1">{{ playMethodName }}</p>
<p class="mb-1">{{ _session.mediaPlayer }}</p>
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">Device</p>
<p v-if="deviceInfo.ipAddress" class="mb-1">{{ deviceInfo.ipAddress }}</p>
<p v-if="osDisplayName" class="mb-1">{{ osDisplayName }}</p>
<p v-if="deviceInfo.browserName" class="mb-1">{{ deviceInfo.browserName }}</p>
<p v-if="clientDisplayName" class="mb-1">{{ clientDisplayName }}</p>
<p v-if="deviceInfo.sdkVersion" class="mb-1">SDK Version: {{ deviceInfo.sdkVersion }}</p>
<p v-if="deviceInfo.deviceType" class="mb-1">Type: {{ deviceInfo.deviceType }}</p>
</div>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
session: {
type: Object,
default: () => {}
}
},
data() {
return {}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
_session() {
return this.session || {}
},
deviceInfo() {
return this._session.deviceInfo || {}
},
osDisplayName() {
if (!this.deviceInfo.osName) return null
return `${this.deviceInfo.osName} ${this.deviceInfo.osVersion}`
},
clientDisplayName() {
if (!this.deviceInfo.manufacturer || !this.deviceInfo.model) return null
return `${this.deviceInfo.manufacturer} ${this.deviceInfo.model}`
},
playMethodName() {
const playMethod = this._session.playMethod
if (playMethod === this.$constants.PlayMethod.DIRECTPLAY) return 'Direct Play'
else if (playMethod === this.$constants.PlayMethod.TRANSCODE) return 'Transcode'
else if (playMethod === this.$constants.PlayMethod.DIRECTSTREAM) return 'Direct Stream'
else if (playMethod === this.$constants.PlayMethod.LOCAL) return 'Local'
return 'Unknown'
}
},
methods: {},
mounted() {}
}
</script>

View File

@@ -104,6 +104,7 @@ export default {
}
},
hotkey(action) {
if (this.$store.state.innerModalOpen) return
if (action === this.$hotkeys.Modal.CLOSE) {
this.show = false
}

View File

@@ -1,5 +1,5 @@
<template>
<div class="w-full h-full overflow-hidden px-4 py-6 relative">
<div id="match-wrapper" class="w-full h-full overflow-hidden px-4 py-6 relative">
<form @submit.prevent="submitSearch">
<div class="flex items-center justify-start -mx-1 h-20">
<div class="w-40 px-1">
@@ -87,7 +87,7 @@
<div v-if="selectedMatch.series" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.series" />
<div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.series" :disabled="!selectedMatchUsage.series" label="Series" />
<widgets-series-input-widget v-model="selectedMatch.series" />
<p v-if="mediaMetadata.seriesName" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.seriesName || '' }}</p>
</div>
</div>
@@ -95,6 +95,27 @@
<ui-checkbox v-model="selectedMatchUsage.volumeNumber" />
<ui-text-input-with-label v-model="selectedMatch.volumeNumber" :disabled="!selectedMatchUsage.volumeNumber" label="Volume Number" class="flex-grow ml-4" />
</div>
<div v-if="selectedMatch.genres" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.genres" />
<div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.genres" :disabled="!selectedMatchUsage.genres" label="Genres" />
<p v-if="mediaMetadata.genresList" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.genresList || '' }}</p>
</div>
</div>
<div v-if="selectedMatch.tags" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.tags" />
<div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.tags" :disabled="!selectedMatchUsage.tags" label="Tags" />
<p v-if="mediaMetadata.tagsList" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.tagsList || '' }}</p>
</div>
</div>
<div v-if="selectedMatch.language" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.language" />
<div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.language" :disabled="!selectedMatchUsage.language" label="Language" />
<p v-if="mediaMetadata.language" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.language || '' }}</p>
</div>
</div>
<div v-if="selectedMatch.isbn" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.isbn" />
<div class="flex-grow ml-4">
@@ -177,6 +198,10 @@ export default {
publishedYear: true,
series: true,
volumeNumber: true,
genres: true,
tags: true,
language: true,
explicit: true,
asin: true,
isbn: true,
// Podcast specific
@@ -204,6 +229,22 @@ export default {
this.$emit('update:processing', val)
}
},
seriesItems: {
get() {
return this.selectedMatch.series.map((se) => {
return {
id: `new-${Math.floor(Math.random() * 10000)}`,
displayName: se.volumeNumber ? `${se.series} #${se.volumeNumber}` : se.series,
name: se.series,
sequence: se.volumeNumber || ''
}
})
},
set(val) {
console.log('set series items', val)
this.selectedMatch.series = val
}
},
bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio']
},
@@ -294,6 +335,10 @@ export default {
publishedYear: true,
series: true,
volumeNumber: true,
genres: true,
tags: true,
language: true,
explicit: true,
asin: true,
isbn: true,
// Podcast specific
@@ -320,36 +365,69 @@ export default {
else this.provider = localStorage.getItem('book-provider') || 'google'
},
selectMatch(match) {
if (match && match.series) {
match.series = match.series.map((se) => {
return {
id: `new-${Math.floor(Math.random() * 10000)}`,
displayName: se.volumeNumber ? `${se.series} #${se.volumeNumber}` : se.series,
name: se.series,
sequence: se.volumeNumber || ''
}
})
}
this.selectedMatch = match
},
buildMatchUpdatePayload() {
var updatePayload = {}
updatePayload.metadata = {}
var volumeNumber = this.selectedMatchUsage.volumeNumber ? this.selectedMatch.volumeNumber || null : null
for (const key in this.selectedMatchUsage) {
if (this.selectedMatchUsage[key] && this.selectedMatch[key]) {
if (key === 'series') {
var seriesItem = {
id: `new-${Math.floor(Math.random() * 10000)}`,
name: this.selectedMatch[key],
sequence: volumeNumber
var seriesPayload = []
if (!Array.isArray(this.selectedMatch[key])) {
seriesPayload.push({
id: `new-${Math.floor(Math.random() * 10000)}`,
name: this.selectedMatch[key],
sequence: volumeNumber
})
} else {
this.selectedMatch[key].forEach((seriesItem) =>
seriesPayload.push({
id: seriesItem.id,
name: seriesItem.name,
sequence: seriesItem.sequence
})
)
}
updatePayload.series = [seriesItem]
updatePayload.metadata.series = seriesPayload
} else if (key === 'author' && !this.isPodcast) {
var authorItem = {
id: `new-${Math.floor(Math.random() * 10000)}`,
name: this.selectedMatch[key]
}
updatePayload.authors = [authorItem]
if (!Array.isArray(this.selectedMatch[key])) this.selectedMatch[key] = [this.selectedMatch[key]]
var authorPayload = []
this.selectedMatch[key].forEach((authorName) =>
authorPayload.push({
id: `new-${Math.floor(Math.random() * 10000)}`,
name: authorName
})
)
updatePayload.metadata.authors = authorPayload
} else if (key === 'narrator') {
updatePayload.narrators = [this.selectedMatch[key]]
updatePayload.metadata.narrators = [this.selectedMatch[key]]
} else if (key === 'genres') {
updatePayload.metadata.genres = this.selectedMatch[key].split(',')
} else if (key === 'tags') {
updatePayload.tags = this.selectedMatch[key].split(',')
} else if (key === 'itunesId') {
updatePayload.itunesId = Number(this.selectedMatch[key])
} else if (key !== 'volumeNumber') {
updatePayload[key] = this.selectedMatch[key]
updatePayload.metadata.itunesId = Number(this.selectedMatch[key])
} else {
updatePayload.metadata[key] = this.selectedMatch[key]
}
}
}
return updatePayload
},
async submitMatchUpdate() {
@@ -359,9 +437,9 @@ export default {
}
this.isProcessing = true
if (updatePayload.cover) {
if (updatePayload.metadata.cover) {
var coverPayload = {
url: updatePayload.cover
url: updatePayload.metadata.cover
}
var success = await this.$axios.$post(`/api/items/${this.libraryItemId}/cover`, coverPayload).catch((error) => {
console.error('Failed to update', error)
@@ -373,13 +451,11 @@ export default {
this.$toast.error('Item Cover Failed to Update')
}
console.log('Updated cover')
delete updatePayload.cover
delete updatePayload.metadata.cover
}
if (Object.keys(updatePayload).length) {
var mediaUpdatePayload = {
metadata: updatePayload
}
var mediaUpdatePayload = updatePayload
var updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, mediaUpdatePayload).catch((error) => {
console.error('Failed to update', error)
return false

View File

@@ -5,7 +5,7 @@
<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-hidden">
<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" />
@@ -25,8 +25,8 @@
<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">
<ui-textarea-with-label v-model="newEpisode.description" label="Description" :rows="8" />
<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">

View File

@@ -0,0 +1,168 @@
<template>
<modals-modal v-model="show" name="opml-feeds-modal" :width="1000" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<div ref="wrapper" class="p-4 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
<div class="w-full p-4">
<div class="flex items-center -mx-2 mb-2">
<div class="w-full md:w-2/3 p-2">
<ui-dropdown v-model="selectedFolderId" :items="folderItems" :disabled="processing" label="Folder" />
</div>
<div class="w-full md:w-1/3 p-2 pt-6">
<ui-checkbox v-model="autoDownloadEpisodes" label="Auto Download New Episodes" checkbox-bg="primary" border-color="gray-600" label-class="text-sm font-semibold pl-2" />
</div>
</div>
<p class="text-lg font-semibold mb-2">Podcasts to Add</p>
<div class="w-full overflow-y-auto" style="max-height: 50vh">
<template v-for="(feed, index) in feedMetadata">
<cards-podcast-feed-summary-card :key="index" :feed="feed" :library-folder-path="selectedFolderPath" class="my-1" />
</template>
</div>
</div>
<div class="flex items-center py-4">
<div class="flex-grow" />
<ui-btn color="success" @click="submit">Add Podcasts</ui-btn>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
feeds: {
type: Array,
default: () => []
}
},
data() {
return {
processing: false,
selectedFolderId: null,
fullPath: null,
autoDownloadEpisodes: false,
feedMetadata: []
}
},
watch: {
show: {
immediate: true,
handler(newVal) {
if (newVal) {
this.init()
}
}
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
title() {
return 'OPML Feeds'
},
currentLibrary() {
return this.$store.getters['libraries/getCurrentLibrary']
},
folders() {
if (!this.currentLibrary) return []
return this.currentLibrary.folders || []
},
folderItems() {
return this.folders.map((fold) => {
return {
value: fold.id,
text: fold.fullPath
}
})
},
selectedFolder() {
return this.folders.find((f) => f.id === this.selectedFolderId)
},
selectedFolderPath() {
if (!this.selectedFolder) return ''
return this.selectedFolder.fullPath
}
},
methods: {
toFeedMetadata(feed) {
var metadata = feed.metadata
return {
title: metadata.title,
author: metadata.author,
description: metadata.description,
releaseDate: '',
genres: [...metadata.categories],
feedUrl: metadata.feedUrl,
imageUrl: metadata.image,
itunesPageUrl: '',
itunesId: '',
itunesArtistId: '',
language: '',
numEpisodes: feed.numEpisodes
}
},
init() {
this.feedMetadata = this.feeds.map(this.toFeedMetadata)
if (this.folderItems[0]) {
this.selectedFolderId = this.folderItems[0].value
}
},
async submit() {
this.processing = true
var newFeedPayloads = this.feedMetadata.map((metadata) => {
return {
path: `${this.selectedFolderPath}\\${this.$sanitizeFilename(metadata.title)}`,
folderId: this.selectedFolderId,
libraryId: this.currentLibrary.id,
media: {
metadata: {
...metadata
},
autoDownloadEpisodes: this.autoDownloadEpisodes
}
}
})
console.log('New feed payloads', newFeedPayloads)
for (const podcastPayload of newFeedPayloads) {
await this.$axios
.$post('/api/podcasts', podcastPayload)
.then(() => {
this.$toast.success(`${podcastPayload.media.metadata.title}: Podcast created successfully`)
})
.catch((error) => {
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to create podcast'
console.error('Failed to create podcast', podcastPayload, error)
this.$toast.error(`${podcastPayload.media.metadata.title}: ${errorMsg}`)
})
}
this.processing = false
this.show = false
}
},
mounted() {}
}
</script>
<style scoped>
#podcast-wrapper {
min-height: 400px;
max-height: 80vh;
}
#episodes-scroll {
max-height: calc(80vh - 200px);
}
</style>

View File

@@ -0,0 +1,75 @@
<template>
<modals-modal v-model="show" name="podcast-episode-view-modal" :width="800" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">Episode</p>
</div>
</template>
<div ref="wrapper" class="p-4 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-y-auto" style="max-height: 80vh">
<div class="flex mb-4">
<div class="w-12 h-12">
<covers-book-cover :library-item="libraryItem" :width="48" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div>
<div class="flex-grow px-2">
<p class="text-base mb-1">{{ podcastTitle }}</p>
<p class="text-xs text-gray-300">{{ podcastAuthor }}</p>
</div>
</div>
<p class="text-lg font-semibold mb-6">{{ title }}</p>
<div v-if="description" class="default-style" v-html="description" />
<p v-else class="mb-2">No description</p>
</div>
</modals-modal>
</template>
<script>
export default {
data() {
return {
processing: false
}
},
computed: {
show: {
get() {
return this.$store.state.globals.showViewPodcastEpisodeModal
},
set(val) {
this.$store.commit('globals/setShowViewPodcastEpisodeModal', val)
}
},
libraryItem() {
return this.$store.state.selectedLibraryItem
},
episode() {
return this.$store.state.globals.selectedEpisode || {}
},
episodeId() {
return this.episode.id
},
title() {
return this.episode.title || 'No Episode Title'
},
description() {
return this.episode.description || ''
},
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
},
mediaMetadata() {
return this.media.metadata || {}
},
podcastTitle() {
return this.mediaMetadata.title
},
podcastAuthor() {
return this.mediaMetadata.author
},
bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio']
}
},
methods: {},
mounted() {}
}
</script>

View File

@@ -0,0 +1,65 @@
<template>
<div class="flex pt-4 pb-2 md:pt-0 md:pb-2">
<div class="flex-grow" />
<template v-if="!loading">
<div class="cursor-pointer flex items-center justify-center text-gray-300 mr-8" @mousedown.prevent @mouseup.prevent @click.stop="restart">
<span class="material-icons text-3xl">first_page</span>
</div>
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpBackward">
<span class="material-icons text-3xl">replay_10</span>
</div>
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPause">
<span class="material-icons">{{ seekLoading ? 'autorenew' : paused ? 'play_arrow' : 'pause' }}</span>
</div>
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpForward">
<span class="material-icons text-3xl">forward_10</span>
</div>
<controls-playback-speed-control v-model="playbackRate" @input="playbackRateUpdated" @change="playbackRateChanged" />
</template>
<template v-else>
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8 animate-spin">
<span class="material-icons">autorenew</span>
</div>
</template>
<div class="flex-grow" />
</div>
</template>
<script>
export default {
props: {
loading: Boolean,
seekLoading: Boolean,
playbackRate: Number,
paused: Boolean
},
data() {
return {}
},
computed: {},
methods: {
playPause() {
this.$emit('playPause')
},
restart() {
this.$emit('restart')
},
jumpBackward() {
this.$emit('jumpBackward')
},
jumpForward() {
this.$emit('jumpForward')
},
playbackRateUpdated(playbackRate) {
this.$emit('setPlaybackRate', playbackRate)
},
playbackRateChanged(playbackRate) {
this.$emit('setPlaybackRate', playbackRate)
this.$store.dispatch('user/updateUserSettings', { playbackRate }).catch((err) => {
console.error('Failed to update settings', err)
})
}
},
mounted() {}
}
</script>

View File

@@ -0,0 +1,185 @@
<template>
<div class="relative">
<!-- Track -->
<div ref="track" class="w-full h-2 bg-gray-700 relative cursor-pointer transform duration-100 hover:scale-y-125 overflow-hidden" @mousemove="mousemoveTrack" @mouseleave="mouseleaveTrack" @click.stop="clickTrack">
<div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none" />
<div ref="bufferTrack" class="h-full bg-gray-500 absolute top-0 left-0 pointer-events-none" />
<div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-0 pointer-events-none" />
<div ref="trackCursor" class="h-full w-0.5 bg-gray-50 absolute top-0 left-0 opacity-0 pointer-events-none" />
<div v-if="loading" class="h-full w-1/4 absolute left-0 top-0 loadingTrack pointer-events-none bg-white bg-opacity-25" />
</div>
<div ref="track" class="w-full h-2 relative overflow-hidden">
<template v-for="(tick, index) in chapterTicks">
<div :key="index" :style="{ left: tick.left + 'px' }" class="absolute top-0 w-px bg-white bg-opacity-30 h-1 pointer-events-none" />
</template>
</div>
<!-- Hover timestamp -->
<div ref="hoverTimestamp" class="absolute -top-8 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
<p ref="hoverTimestampText" class="text-xs font-mono text-center px-2 py-0.5 truncate whitespace-nowrap">00:00</p>
</div>
<div ref="hoverTimestampArrow" class="absolute -top-3 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
<div class="absolute -bottom-1.5 left-0 right-0 w-full flex justify-center">
<div class="arrow-down" />
</div>
</div>
</div>
</template>
<script>
export default {
props: {
loading: Boolean,
duration: Number,
chapters: {
type: Array,
default: () => []
}
},
data() {
return {
trackWidth: 0,
currentTime: 0,
percentReady: 0,
bufferTime: 0,
chapterTicks: [],
trackOffsetLeft: 16, // Track is 16px from edge
playedTrackWidth: 0,
readyTrackWidth: 0,
bufferTrackWidth: 0
}
},
watch: {
duration: {
immediate: true,
handler() {
this.setChapterTicks()
}
}
},
computed: {},
methods: {
clickTrack(e) {
if (this.loading) return
var offsetX = e.offsetX
var perc = offsetX / this.trackWidth
var time = perc * this.duration
if (isNaN(time) || time === null) {
console.error('Invalid time', perc, time)
return
}
this.$emit('seek', time)
},
setBufferTime(time) {
this.bufferTime = time
this.updateBufferTrack()
},
updateBufferTrack() {
var bufferlen = (this.bufferTime / this.duration) * this.trackWidth
bufferlen = Math.round(bufferlen)
if (this.bufferTrackWidth === bufferlen || !this.$refs.bufferTrack) return
if (this.$refs.bufferTrack) this.$refs.bufferTrack.style.width = bufferlen + 'px'
this.bufferTrackWidth = bufferlen
},
setPercentageReady(percent) {
this.percentReady = percent
this.updateReadyTrack()
},
updateReadyTrack() {
var widthReady = Math.round(this.trackWidth * this.percentReady)
if (this.readyTrackWidth === widthReady) return
this.readyTrackWidth = widthReady
if (this.$refs.readyTrack) this.$refs.readyTrack.style.width = widthReady + 'px'
},
setCurrentTime(time) {
this.currentTime = time
this.updatePlayedTrackWidth()
},
updatePlayedTrackWidth() {
var perc = this.currentTime / this.duration
var ptWidth = Math.round(perc * this.trackWidth)
if (this.playedTrackWidth === ptWidth) {
return
}
if (this.$refs.playedTrack) this.$refs.playedTrack.style.width = ptWidth + 'px'
this.playedTrackWidth = ptWidth
},
setChapterTicks() {
this.chapterTicks = this.chapters.map((chap) => {
var perc = chap.start / this.duration
return {
title: chap.title,
left: perc * this.trackWidth
}
})
},
mousemoveTrack(e) {
var offsetX = e.offsetX
var time = (offsetX / this.trackWidth) * this.duration
console.log('Mousemove track', this.trackWidth, this.duration)
if (this.$refs.hoverTimestamp) {
var width = this.$refs.hoverTimestamp.clientWidth
this.$refs.hoverTimestamp.style.opacity = 1
var posLeft = offsetX - width / 2
if (posLeft + width + this.trackOffsetLeft > window.innerWidth) {
posLeft = window.innerWidth - width - this.trackOffsetLeft
} else if (posLeft < -this.trackOffsetLeft) {
posLeft = -this.trackOffsetLeft
}
this.$refs.hoverTimestamp.style.left = posLeft + 'px'
}
if (this.$refs.hoverTimestampArrow) {
var width = this.$refs.hoverTimestampArrow.clientWidth
var posLeft = offsetX - width / 2
this.$refs.hoverTimestampArrow.style.opacity = 1
this.$refs.hoverTimestampArrow.style.left = posLeft + 'px'
}
if (this.$refs.hoverTimestampText) {
var hoverText = this.$secondsToTimestamp(time)
var chapter = this.chapters.find((chapter) => chapter.start <= time && time < chapter.end)
if (chapter && chapter.title) {
hoverText += ` - ${chapter.title}`
}
this.$refs.hoverTimestampText.innerText = hoverText
}
if (this.$refs.trackCursor) {
this.$refs.trackCursor.style.opacity = 1
this.$refs.trackCursor.style.left = offsetX - 1 + 'px'
}
},
mouseleaveTrack() {
if (this.$refs.hoverTimestamp) {
this.$refs.hoverTimestamp.style.opacity = 0
}
if (this.$refs.hoverTimestampArrow) {
this.$refs.hoverTimestampArrow.style.opacity = 0
}
if (this.$refs.trackCursor) {
this.$refs.trackCursor.style.opacity = 0
}
},
setTrackWidth() {
if (this.$refs.track) {
this.trackWidth = this.$refs.track.clientWidth
} else {
console.error('Track not loaded', this.$refs)
}
},
windowResize() {
this.setTrackWidth()
}
},
mounted() {
this.setTrackWidth()
window.addEventListener('resize', this.windowResize)
},
beforeDestroy() {
window.removeEventListener('resize', this.windowResize)
}
}
</script>

View File

@@ -2,6 +2,8 @@
<div class="w-full -mt-6">
<div class="w-full relative mb-1">
<div class="absolute -top-10 md:top-0 right-0 md:right-2 flex items-center h-full">
<span class="material-icons text-2xl cursor-pointer" @click="toggleFullscreen(true)">expand_less</span>
<controls-volume-control ref="volumeControl" v-model="volume" @input="setVolume" class="mx-2 hidden md:block" />
<div class="cursor-pointer text-gray-300 mx-1 md:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showSleepTimer')">
@@ -21,57 +23,11 @@
</div>
</div>
<div class="flex pt-4 pb-2 md:pt-0 md:pb-2">
<div class="flex-grow" />
<template v-if="!loading">
<div class="cursor-pointer flex items-center justify-center text-gray-300 mr-8" @mousedown.prevent @mouseup.prevent @click.stop="restart">
<span class="material-icons text-3xl">first_page</span>
</div>
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpBackward">
<span class="material-icons text-3xl">replay_10</span>
</div>
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPause">
<span class="material-icons">{{ seekLoading ? 'autorenew' : paused ? 'play_arrow' : 'pause' }}</span>
</div>
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpForward">
<span class="material-icons text-3xl">forward_10</span>
</div>
<controls-playback-speed-control v-model="playbackRate" @input="playbackRateUpdated" @change="playbackRateChanged" />
</template>
<template v-else>
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8 animate-spin">
<span class="material-icons">autorenew</span>
</div>
</template>
<div class="flex-grow" />
</div>
<player-playback-controls :loading="loading" :seek-loading="seekLoading" :playback-rate="playbackRate" :paused="paused" @restart="restart" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setPlaybackRate="setPlaybackRate" @playPause="playPause" />
</div>
<div class="relative">
<!-- Track -->
<div ref="track" class="w-full h-2 bg-gray-700 relative cursor-pointer transform duration-100 hover:scale-y-125 overflow-hidden" @mousemove="mousemoveTrack" @mouseleave="mouseleaveTrack" @click.stop="clickTrack">
<div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none" />
<div ref="bufferTrack" class="h-full bg-gray-400 absolute top-0 left-0 pointer-events-none" />
<div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-0 pointer-events-none" />
<div ref="trackCursor" class="h-full w-0.5 bg-gray-50 absolute top-0 left-0 opacity-0 pointer-events-none" />
<div v-if="loading" class="h-full w-1/4 absolute left-0 top-0 loadingTrack pointer-events-none bg-white bg-opacity-25" />
</div>
<div ref="track" class="w-full h-2 relative overflow-hidden">
<template v-for="(tick, index) in chapterTicks">
<div :key="index" :style="{ left: tick.left + 'px' }" class="absolute top-0 w-px bg-white bg-opacity-30 h-1 pointer-events-none" />
</template>
</div>
<!-- Hover timestamp -->
<div ref="hoverTimestamp" class="absolute -top-8 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
<p ref="hoverTimestampText" class="text-xs font-mono text-center px-2 py-0.5 truncate whitespace-nowrap">00:00</p>
</div>
<div ref="hoverTimestampArrow" class="absolute -top-3 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
<div class="absolute -bottom-1.5 left-0 right-0 w-full flex justify-center">
<div class="arrow-down" />
</div>
</div>
</div>
<player-track-bar ref="trackbar" :loading="loading" :chapters="chapters" :duration="duration" @seek="seek" />
<div class="flex">
<p ref="currentTimestamp" class="font-mono text-sm text-gray-100 pointer-events-auto">00:00:00</p>
<p class="font-mono text-sm text-gray-100 pointer-events-auto">&nbsp;/&nbsp;{{ progressPercent }}%</p>
@@ -106,17 +62,11 @@ export default {
return {
volume: 1,
playbackRate: 1,
trackWidth: 0,
playedTrackWidth: 0,
bufferTrackWidth: 0,
readyTrackWidth: 0,
audioEl: null,
seekLoading: false,
showChaptersModal: false,
currentTime: 0,
trackOffsetLeft: 16, // Track is 16px from edge
duration: 0,
chapterTicks: []
duration: 0
}
},
computed: {
@@ -153,24 +103,38 @@ export default {
},
currentChapterName() {
return this.currentChapter ? this.currentChapter.title : ''
},
isFullscreen() {
return this.$store.state.playerIsFullscreen
}
},
methods: {
toggleFullscreen(isFullscreen) {
this.$store.commit('setPlayerIsFullscreen', isFullscreen)
var videoPlayerEl = document.getElementById('video-player')
if (videoPlayerEl) {
if (isFullscreen) {
videoPlayerEl.style.width = '100vw'
videoPlayerEl.style.height = '100vh'
videoPlayerEl.style.top = '0px'
videoPlayerEl.style.left = '0px'
} else {
videoPlayerEl.style.width = '384px'
videoPlayerEl.style.height = '216px'
videoPlayerEl.style.top = 'unset'
videoPlayerEl.style.bottom = '80px'
videoPlayerEl.style.left = '16px'
}
}
},
setDuration(duration) {
this.duration = duration
this.chapterTicks = this.chapters.map((chap) => {
var perc = chap.start / this.duration
return {
title: chap.title,
left: perc * this.trackWidth
}
})
},
setCurrentTime(time) {
this.currentTime = time
this.updateTimestamp()
this.updatePlayedTrack()
if (this.$refs.trackbar) this.$refs.trackbar.setCurrentTime(time)
},
playPause() {
this.$emit('playPause')
@@ -223,67 +187,11 @@ export default {
seek(time) {
this.$emit('seek', time)
},
playbackRateUpdated(playbackRate) {
this.setPlaybackRate(playbackRate)
},
playbackRateChanged(playbackRate) {
this.setPlaybackRate(playbackRate)
this.$store.dispatch('user/updateUserSettings', { playbackRate }).catch((err) => {
console.error('Failed to update settings', err)
})
},
mousemoveTrack(e) {
var offsetX = e.offsetX
var time = (offsetX / this.trackWidth) * this.duration
if (this.$refs.hoverTimestamp) {
var width = this.$refs.hoverTimestamp.clientWidth
this.$refs.hoverTimestamp.style.opacity = 1
var posLeft = offsetX - width / 2
if (posLeft + width + this.trackOffsetLeft > window.innerWidth) {
posLeft = window.innerWidth - width - this.trackOffsetLeft
} else if (posLeft < -this.trackOffsetLeft) {
posLeft = -this.trackOffsetLeft
}
this.$refs.hoverTimestamp.style.left = posLeft + 'px'
}
if (this.$refs.hoverTimestampArrow) {
var width = this.$refs.hoverTimestampArrow.clientWidth
var posLeft = offsetX - width / 2
this.$refs.hoverTimestampArrow.style.opacity = 1
this.$refs.hoverTimestampArrow.style.left = posLeft + 'px'
}
if (this.$refs.hoverTimestampText) {
var hoverText = this.$secondsToTimestamp(time)
var chapter = this.chapters.find((chapter) => chapter.start <= time && time < chapter.end)
if (chapter && chapter.title) {
hoverText += ` - ${chapter.title}`
}
this.$refs.hoverTimestampText.innerText = hoverText
}
if (this.$refs.trackCursor) {
this.$refs.trackCursor.style.opacity = 1
this.$refs.trackCursor.style.left = offsetX - 1 + 'px'
}
},
mouseleaveTrack() {
if (this.$refs.hoverTimestamp) {
this.$refs.hoverTimestamp.style.opacity = 0
}
if (this.$refs.hoverTimestampArrow) {
this.$refs.hoverTimestampArrow.style.opacity = 0
}
if (this.$refs.trackCursor) {
this.$refs.trackCursor.style.opacity = 0
}
},
restart() {
this.seek(0)
},
setStreamReady() {
this.readyTrackWidth = this.trackWidth
this.$refs.readyTrack.style.width = this.trackWidth + 'px'
if (this.$refs.trackbar) this.$refs.trackbar.setPercentageReady(1)
},
setChunksReady(chunks, numSegments) {
var largestSeg = 0
@@ -298,10 +206,7 @@ export default {
}
}
var percentageReady = largestSeg / numSegments
var widthReady = Math.round(this.trackWidth * percentageReady)
if (this.readyTrackWidth === widthReady) return
this.readyTrackWidth = widthReady
this.$refs.readyTrack.style.width = widthReady + 'px'
if (this.$refs.trackbar) this.$refs.trackbar.setPercentageReady(percentageReady)
},
updateTimestamp() {
var ts = this.$refs.currentTimestamp
@@ -312,36 +217,9 @@ export default {
var currTimeClean = this.$secondsToTimestamp(this.currentTime)
ts.innerText = currTimeClean
},
updatePlayedTrack() {
var perc = this.currentTime / this.duration
var ptWidth = Math.round(perc * this.trackWidth)
if (this.playedTrackWidth === ptWidth) {
return
}
this.$refs.playedTrack.style.width = ptWidth + 'px'
this.playedTrackWidth = ptWidth
},
clickTrack(e) {
if (this.loading) return
var offsetX = e.offsetX
var perc = offsetX / this.trackWidth
var time = perc * this.duration
if (isNaN(time) || time === null) {
console.error('Invalid time', perc, time)
return
}
this.seek(time)
},
setBufferTime(bufferTime) {
if (!this.audioEl) {
return
}
var bufferlen = (bufferTime / this.duration) * this.trackWidth
bufferlen = Math.round(bufferlen)
if (this.bufferTrackWidth === bufferlen || !this.$refs.bufferTrack) return
this.$refs.bufferTrack.style.width = bufferlen + 'px'
this.bufferTrackWidth = bufferlen
if (this.$refs.trackbar) this.$refs.trackbar.setBufferTime(bufferTime)
},
showChapters() {
if (!this.chapters.length) return
@@ -350,14 +228,6 @@ export default {
init() {
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
this.$emit('setPlaybackRate', this.playbackRate)
this.setTrackWidth()
},
setTrackWidth() {
if (this.$refs.track) {
this.trackWidth = this.$refs.track.clientWidth
} else {
console.error('Track not loaded', this.$refs)
}
},
settingsUpdated(settings) {
if (settings.playbackRate && this.playbackRate !== settings.playbackRate) {
@@ -365,6 +235,11 @@ export default {
}
},
closePlayer() {
if (this.isFullscreen) {
this.toggleFullscreen(false)
return
}
if (this.loading) return
this.$emit('close')
},
@@ -379,19 +254,14 @@ export default {
else if (action === this.$hotkeys.AudioPlayer.INCREASE_PLAYBACK_RATE) this.increasePlaybackRate()
else if (action === this.$hotkeys.AudioPlayer.DECREASE_PLAYBACK_RATE) this.decreasePlaybackRate()
else if (action === this.$hotkeys.AudioPlayer.CLOSE) this.closePlayer()
},
windowResize() {
this.setTrackWidth()
}
},
mounted() {
window.addEventListener('resize', this.windowResize)
this.$store.commit('user/addSettingsListener', { id: 'audioplayer', meth: this.settingsUpdated })
this.init()
this.$eventBus.$on('player-hotkey', this.hotkey)
},
beforeDestroy() {
window.removeEventListener('resize', this.windowResize)
this.$store.commit('user/removeSettingsListener', 'audioplayer')
this.$eventBus.$off('player-hotkey', this.hotkey)
}

View File

@@ -1,16 +1,18 @@
<template>
<div class="w-full px-2 py-3 overflow-hidden relative border-b border-white border-opacity-10" @mouseover="mouseover" @mouseleave="mouseleave">
<div v-if="episode" class="flex items-center h-24">
<div v-if="episode" class="flex items-center h-24 cursor-pointer" @click="$emit('view', episode)">
<div class="flex-grow px-2">
<p class="text-sm font-semibold">
{{ title }}
</p>
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5">{{ description }}</p>
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5">{{ subtitle }}</p>
<div class="flex items-center pt-2">
<div class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer" :class="userIsFinished ? 'text-white text-opacity-40' : ''" @click="playClick">
<button class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer focus:outline-none" :class="userIsFinished ? 'text-white text-opacity-40' : ''" @click.stop="playClick">
<span class="material-icons" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
<p class="pl-2 pr-1 text-sm font-semibold">{{ timeRemaining }}</p>
</div>
</button>
<ui-tooltip :text="userIsFinished ? 'Mark as Not Finished' : 'Mark as Finished'" direction="top">
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
@@ -66,10 +68,11 @@ export default {
title() {
return this.episode.title || ''
},
subtitle() {
return this.episode.subtitle || ''
},
description() {
if (this.episode.subtitle) return this.episode.subtitle
var desc = this.episode.description || ''
return desc
return this.episode.description || ''
},
duration() {
return this.$secondsToTimestamp(this.episode.duration)

View File

@@ -7,7 +7,7 @@
</div>
<p v-if="!episodes.length" class="py-4 text-center text-lg">No Episodes</p>
<template v-for="episode in episodesSorted">
<tables-podcast-episode-table-row :key="episode.id" :episode="episode" :library-item-id="libraryItem.id" class="item" @remove="removeEpisode" @edit="editEpisode" />
<tables-podcast-episode-table-row :key="episode.id" :episode="episode" :library-item-id="libraryItem.id" class="item" @remove="removeEpisode" @edit="editEpisode" @view="viewEpisode" />
</template>
<modals-podcast-remove-episode v-model="showPodcastRemoveModal" :library-item="libraryItem" :episode="selectedEpisode" />
@@ -68,6 +68,11 @@ export default {
this.$store.commit('globals/setSelectedEpisode', episode)
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
},
viewEpisode(episode) {
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
this.$store.commit('globals/setSelectedEpisode', episode)
this.$store.commit('globals/setShowViewPodcastEpisodeModal', true)
},
init() {
this.episodesCopy = this.episodes.map((ep) => ({ ...ep }))
}

View File

@@ -32,6 +32,7 @@ export default {
default: ''
},
paddingX: Number,
paddingY: Number,
small: Boolean,
loading: Boolean,
disabled: Boolean
@@ -48,14 +49,17 @@ export default {
if (this.small) {
list.push('text-sm')
if (this.paddingX === undefined) list.push('px-4')
list.push('py-1')
if (this.paddingY === undefined) list.push('py-1')
} else {
if (this.paddingX === undefined) list.push('px-8')
list.push('py-2')
if (this.paddingY === undefined) list.push('py-2')
}
if (this.paddingX !== undefined) {
list.push(`px-${this.paddingX}`)
}
if (this.paddingY !== undefined) {
list.push(`py-${this.paddingY}`)
}
if (this.disabled) {
list.push('cursor-not-allowed')
}

View File

@@ -1,6 +1,6 @@
<template>
<div>
<input ref="fileInput" id="hidden-input" type="file" :accept="accept" class="hidden" @change="inputChanged" />
<input ref="fileInput" type="file" :accept="accept" class="hidden" @change="inputChanged" />
<ui-btn @click="clickUpload" color="primary" type="text"><slot /></ui-btn>
</div>
</template>

View File

@@ -0,0 +1,75 @@
<template>
<div>
<p v-if="label" class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': disabled }">
{{ label }}
</p>
<ui-vue-trix v-model="content" :config="config" :disabled-editor="disabled" @trix-file-accept="trixFileAccept" />
</div>
</template>
<script>
export default {
props: {
value: String,
label: String,
disabled: Boolean
},
data() {
return {}
},
computed: {
content: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
config() {
return {
toolbar: {
getDefaultHTML: () => ` <div class="trix-button-row">
<span class="trix-button-group trix-button-group--text-tools" data-trix-button-group="text-tools">
<button type="button" class="trix-button trix-button--icon trix-button--icon-bold" data-trix-attribute="bold" data-trix-key="b" title="#{lang.bold}" tabindex="-1">#{lang.bold}</button>
<button type="button" class="trix-button trix-button--icon trix-button--icon-italic" data-trix-attribute="italic" data-trix-key="i" title="#{lang.italic}" tabindex="-1">#{lang.italic}</button>
<button type="button" class="trix-button trix-button--icon trix-button--icon-strike" data-trix-attribute="strike" title="#{lang.strike}" tabindex="-1">#{lang.strike}</button>
<button type="button" class="trix-button trix-button--icon trix-button--icon-link" data-trix-attribute="href" data-trix-action="link" data-trix-key="k" title="#{lang.link}" tabindex="-1">#{lang.link}</button>
</span>
<span class="trix-button-group trix-button-group--block-tools" data-trix-button-group="block-tools">
<button type="button" class="trix-button trix-button--icon trix-button--icon-bullet-list" data-trix-attribute="bullet" title="#{lang.bullets}" tabindex="-1">#{lang.bullets}</button>
<button type="button" class="trix-button trix-button--icon trix-button--icon-number-list" data-trix-attribute="number" title="#{lang.numbers}" tabindex="-1">#{lang.numbers}</button>
</span>
<span class="trix-button-group-spacer"></span>
<span class="trix-button-group trix-button-group--history-tools" data-trix-button-group="history-tools">
<button type="button" class="trix-button trix-button--icon trix-button--icon-undo" data-trix-action="undo" data-trix-key="z" title="#{lang.undo}" tabindex="-1">#{lang.undo}</button>
<button type="button" class="trix-button trix-button--icon trix-button--icon-redo" data-trix-action="redo" data-trix-key="shift+z" title="#{lang.redo}" tabindex="-1">#{lang.redo}</button>
</span>
</div>
<div class="trix-dialogs" data-trix-dialogs>
<div class="trix-dialog trix-dialog--link" data-trix-dialog="href" data-trix-dialog-attribute="href">
<div class="trix-dialog__link-fields">
<input type="url" name="href" class="trix-input trix-input--dialog" placeholder="#{lang.urlPlaceholder}" aria-label="#{lang.url}" required data-trix-input>
<div class="trix-button-group">
<input type="button" class="trix-button trix-button--dialog" value="#{lang.link}" data-trix-method="setAttribute">
<input type="button" class="trix-button trix-button--dialog" value="#{lang.unlink}" data-trix-method="removeAttribute">
</div>
</div>
</div>
</div>`
}
}
}
},
methods: {
trixFileAccept(e) {
e.preventDefault()
}
},
mounted() {},
beforeDestroy() {
console.log('Before destroy')
}
}
</script>

View File

@@ -0,0 +1,284 @@
<template>
<div>
<trix-editor :contenteditable="!disabledEditor" :class="['trix-content']" ref="trix" :input="computedId" :placeholder="placeholder" @trix-change="handleContentChange" @trix-initialize="handleInitialize" @trix-focus="processTrixFocus" @trix-blur="processTrixBlur" />
<input type="hidden" :name="inputName" :id="computedId" :value="editorContent" />
</div>
</template>
<script>
/*
ORIGINAL SOURCE: https://github.com/hanhdt/vue-trix
modified for audiobookshelf
*/
import Trix from 'trix'
import '@/assets/trix.css'
export default {
name: 'vue-trix',
model: {
prop: 'srcContent',
event: 'update'
},
props: {
/**
* This prop will put the editor in read-only mode
*/
disabledEditor: {
type: Boolean,
required: false,
default() {
return false
}
},
/**
* This is referenced `id` of the hidden input field defined.
* It is optional and will be a random string by default.
*/
inputId: {
type: String,
required: false,
default() {
return ''
}
},
/**
* This is referenced `name` of the hidden input field defined,
* default value is `content`.
*/
inputName: {
type: String,
required: false,
default() {
return 'content'
}
},
/**
* The placeholder attribute specifies a short hint
* that describes the expected value of a editor.
*/
placeholder: {
type: String,
required: false,
default() {
return ''
}
},
/**
* The source content is associcated to v-model directive.
*/
srcContent: {
type: String,
required: false,
default() {
return ''
}
},
/**
* The boolean attribute allows saving editor state into browser's localStorage
* (optional, default is `false`).
*/
localStorage: {
type: Boolean,
required: false,
default() {
return false
}
},
/**
* Focuses cursor in the editor when attached to the DOM
* (optional, default is `false`).
*/
autofocus: {
type: Boolean,
required: false,
default() {
return false
}
},
/**
* Object to override default editor configuration
*/
config: {
type: Object,
required: false,
default() {
return {}
}
}
},
data() {
return {
editorContent: this.srcContent,
isActived: null
}
},
watch: {
editorContent: {
handler: 'emitEditorState'
},
initialContent: {
handler: 'handleInitialContentChange'
},
isDisabled: {
handler: 'decorateDisabledEditor'
},
config: {
handler: 'overrideConfig',
immediate: true,
deep: true
}
},
computed: {
/**
* Compute a random id of hidden input
* when it haven't been specified.
*/
generateId() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
var r = (Math.random() * 16) | 0
var v = c === 'x' ? r : (r & 0x3) | 0x8
return v.toString(16)
})
},
computedId() {
return this.inputId || this.generateId
},
initialContent() {
return this.srcContent
},
isDisabled() {
return this.disabledEditor
}
},
methods: {
processTrixFocus(event) {
if (this.$refs.trix) {
this.isActived = true
this.$emit('trix-focus', this.$refs.trix.editor, event)
}
},
processTrixBlur(event) {
if (this.$refs.trix) {
this.isActived = false
this.$emit('trix-blur', this.$refs.trix.editor, event)
}
},
handleContentChange(event) {
this.editorContent = event.srcElement ? event.srcElement.value : event.target.value
this.$emit('input', this.editorContent)
},
handleInitialize(event) {
/**
* If autofocus is true, manually set focus to
* beginning of content (consistent with Trix behavior)
*/
if (this.autofocus) {
this.$refs.trix.editor.setSelectedRange(0)
}
this.$emit('trix-initialize', this.emitInitialize)
},
handleInitialContentChange(newContent, oldContent) {
newContent = newContent === undefined ? '' : newContent
if (this.$refs.trix.editor && this.$refs.trix.editor.innerHTML !== newContent) {
/* Update editor's content when initial content changed */
this.editorContent = newContent
/**
* If user are typing, then don't reload the editor,
* hence keep cursor's position after typing.
*/
if (!this.isActived) {
this.reloadEditorContent(this.editorContent)
}
}
},
emitEditorState(value) {
/**
* If localStorage is enabled,
* then save editor's content into storage
*/
if (this.localStorage) {
localStorage.setItem(this.storageId('VueTrix'), JSON.stringify(this.$refs.trix.editor))
}
this.$emit('update', this.editorContent)
},
storageId(component) {
if (this.inputId) {
return `${component}.${this.inputId}.content`
} else {
return `${component}.content`
}
},
reloadEditorContent(newContent) {
// Reload HTML content
this.$refs.trix.editor.loadHTML(newContent)
// Move cursor to end of new content updated
this.$refs.trix.editor.setSelectedRange(this.getContentEndPosition())
},
getContentEndPosition() {
return this.$refs.trix.editor.getDocument().toString().length - 1
},
decorateDisabledEditor(editorState) {
/** Disable toolbar and editor by pointer events styling */
if (editorState) {
this.$refs.trix.toolbarElement.style['pointer-events'] = 'none'
this.$refs.trix.contentEditable = false
this.$refs.trix.style['background'] = '#e9ecef'
} else {
this.$refs.trix.toolbarElement.style['pointer-events'] = 'unset'
this.$refs.trix.style['pointer-events'] = 'unset'
this.$refs.trix.style['background'] = 'transparent'
}
},
overrideConfig(config) {
Trix.config = this.deepMerge(Trix.config, config)
},
deepMerge(target, override) {
// deep merge the object into the target object
for (let prop in override) {
if (override.hasOwnProperty(prop)) {
if (Object.prototype.toString.call(override[prop]) === '[object Object]') {
// if the property is a nested object
target[prop] = this.deepMerge(target[prop], override[prop])
} else {
// for regular property
target[prop] = override[prop]
}
}
}
return target
}
},
mounted() {
/** Override editor configuration */
this.overrideConfig(this.config)
/** Check if editor read-only mode is required */
this.decorateDisabledEditor(this.disabledEditor)
this.$nextTick(() => {
/**
* If localStorage is enabled,
* then load editor's content from the beginning.
*/
if (this.localStorage) {
const savedValue = localStorage.getItem(this.storageId('VueTrix'))
if (savedValue && !this.srcContent) {
this.$refs.trix.editor.loadJSON(JSON.parse(savedValue))
}
}
})
}
}
</script>
<style lang="css" module>
.trix_container {
max-width: 100%;
height: auto;
}
.trix_container .trix-button-group {
background-color: white;
}
.trix_container .trix-content {
background-color: white;
}
</style>

View File

@@ -22,7 +22,7 @@
<div class="flex mt-2 -mx-1">
<div class="flex-grow px-1">
<ui-multi-select-query-input ref="seriesSelect" v-model="seriesItems" text-key="displayName" label="Series" readonly show-edit @edit="editSeriesItem" @add="addNewSeries" />
<widgets-series-input-widget v-model="details.series" />
</div>
</div>
@@ -63,27 +63,6 @@
</div>
</div>
</form>
<div v-if="showSeriesForm" class="absolute top-0 left-0 z-20 w-full h-full bg-black bg-opacity-50 rounded-lg flex items-center justify-center" @click="cancelSeriesForm">
<div class="absolute top-0 right-0 p-4">
<span class="material-icons text-gray-200 hover:text-white text-4xl cursor-pointer">close</span>
</div>
<form @submit.prevent="submitSeriesForm">
<div class="bg-bg rounded-lg p-8" @click.stop>
<div class="flex">
<div class="flex-grow p-1 min-w-80">
<ui-input-dropdown ref="newSeriesSelect" v-model="selectedSeries.name" :items="existingSeriesNames" :disabled="!selectedSeries.id.startsWith('new')" label="Series Name" />
</div>
<div class="w-40 p-1">
<ui-text-input-with-label v-model="selectedSeries.sequence" label="Sequence" />
</div>
</div>
<div class="flex justify-end mt-2 p-1">
<ui-btn type="submit">Save</ui-btn>
</div>
</div>
</form>
</div>
</div>
</template>
@@ -97,8 +76,6 @@ export default {
},
data() {
return {
selectedSeries: {},
showSeriesForm: false,
details: {
title: null,
subtitle: null,
@@ -146,24 +123,6 @@ export default {
},
filterData() {
return this.$store.state.libraries.filterData || {}
},
existingSeriesNames() {
// Only show series names not already selected
var alreadySelectedSeriesIds = this.details.series.map((se) => se.id)
return this.series.filter((se) => !alreadySelectedSeriesIds.includes(se.id)).map((se) => se.name)
},
seriesItems: {
get() {
return this.details.series.map((se) => {
return {
displayName: se.sequence ? `${se.name} #${se.sequence}` : se.name,
...se
}
})
},
set(val) {
this.details.series = val
}
}
},
methods: {
@@ -214,50 +173,6 @@ export default {
this.$refs.tagsSelect.forceBlur()
}
},
cancelSeriesForm() {
this.showSeriesForm = false
},
editSeriesItem(series) {
var _series = this.details.series.find((se) => se.id === series.id)
if (!_series) return
this.selectedSeries = {
..._series
}
this.showSeriesForm = true
},
addNewSeries() {
this.selectedSeries = {
id: `new-${Date.now()}`,
name: '',
sequence: ''
}
this.showSeriesForm = true
},
submitSeriesForm() {
if (!this.selectedSeries.name) {
this.$toast.error('Must enter a series')
return
}
if (this.$refs.newSeriesSelect) {
this.$refs.newSeriesSelect.blur()
}
var existingSeriesIndex = this.details.series.findIndex((se) => se.id === this.selectedSeries.id)
var seriesSameName = this.series.find((se) => se.name.toLowerCase() === this.selectedSeries.name.toLowerCase())
if (existingSeriesIndex < 0 && seriesSameName) {
this.selectedSeries.id = seriesSameName.id
}
if (existingSeriesIndex >= 0) {
this.details.series.splice(existingSeriesIndex, 1, { ...this.selectedSeries })
} else {
this.details.series.push({
...this.selectedSeries
})
}
this.showSeriesForm = false
},
stringArrayEqual(array1, array2) {
// return false if different
if (array1.length !== array2.length) return false

View File

@@ -0,0 +1,111 @@
<template>
<div>
<ui-multi-select-query-input v-model="seriesItems" text-key="displayName" label="Series" readonly show-edit @edit="editSeriesItem" @add="addNewSeries" />
<modals-edit-series-input-inner-modal v-model="showSeriesForm" :selected-series="selectedSeries" :existing-series-names="existingSeriesNames" @submit="submitSeriesForm" />
</div>
</template>
<script>
export default {
props: {
value: {
type: Array,
default: () => []
}
},
data() {
return {
selectedSeries: null,
showSeriesForm: false
}
},
computed: {
seriesItems: {
get() {
return (this.value || []).map((se) => {
return {
displayName: se.sequence ? `${se.name} #${se.sequence}` : se.name,
...se
}
})
},
set(val) {
this.$emit('input', val)
}
},
series() {
return this.filterData.series || []
},
filterData() {
return this.$store.state.libraries.filterData || {}
},
existingSeriesNames() {
// Only show series names not already selected
var alreadySelectedSeriesIds = (this.value || []).map((se) => se.id)
return this.series.filter((se) => !alreadySelectedSeriesIds.includes(se.id)).map((se) => se.name)
}
},
methods: {
cancelSeriesForm() {
this.showSeriesForm = false
},
editSeriesItem(series) {
var _series = this.seriesItems.find((se) => se.id === series.id)
if (!_series) return
this.selectedSeries = {
..._series
}
console.log('Selected series', this.selectedSeries)
this.showSeriesForm = true
},
addNewSeries() {
this.selectedSeries = {
id: `new-${Date.now()}`,
name: '',
sequence: ''
}
this.showSeriesForm = true
},
submitSeriesForm() {
console.log('submit series form', this.value, this.selectedSeries)
if (!this.selectedSeries.name) {
this.$toast.error('Must enter a series')
return
}
var existingSeriesIndex = this.seriesItems.findIndex((se) => se.id === this.selectedSeries.id)
var existingSeriesSameName = this.seriesItems.findIndex((se) => se.name.toLowerCase() === this.selectedSeries.name.toLowerCase())
if (existingSeriesSameName >= 0 && existingSeriesIndex < 0) {
console.error('Attempt to add duplicate series')
this.$toast.error('Cannot add two of the same series')
return
}
var seriesSameName = this.series.find((se) => se.name.toLowerCase() === this.selectedSeries.name.toLowerCase())
if (existingSeriesIndex < 0 && seriesSameName) {
this.selectedSeries.id = seriesSameName.id
}
var selectedSeriesCopy = { ...this.selectedSeries }
selectedSeriesCopy.displayName = selectedSeriesCopy.sequence ? `${selectedSeriesCopy.name} #${selectedSeriesCopy.sequence}` : selectedSeriesCopy.name
var seriesCopy = this.seriesItems.map((v) => ({ ...v }))
if (existingSeriesIndex >= 0) {
seriesCopy.splice(existingSeriesIndex, 1, selectedSeriesCopy)
this.seriesItems = seriesCopy
} else {
seriesCopy.push(selectedSeriesCopy)
this.seriesItems = seriesCopy
}
this.showSeriesForm = false
}
}
}
</script>

View File

@@ -12,8 +12,8 @@
<modals-item-edit-modal />
<modals-user-collections-modal />
<modals-edit-collection-modal />
<modals-bookshelf-texture-modal />
<modals-podcast-edit-episode />
<modals-podcast-view-episode />
<modals-authors-edit-modal />
<readers-reader />
</div>
@@ -515,23 +515,12 @@ export default {
this.$store.commit('globals/updateWindowSize', { width: window.innerWidth, height: window.innerHeight })
},
checkVersionUpdate() {
// Version check is only run if time since last check was 5 minutes
const VERSION_CHECK_BUFF = 1000 * 60 * 5 // 5 minutes
var lastVerCheck = localStorage.getItem('lastVerCheck') || 0
if (Date.now() - Number(lastVerCheck) > VERSION_CHECK_BUFF) {
this.$store
.dispatch('checkForUpdate')
.then((res) => {
localStorage.setItem('lastVerCheck', Date.now())
if (res && res.hasUpdate) this.showUpdateToast(res)
})
.catch((err) => console.error(err))
if (this.$route.query.error) {
this.$toast.error(this.$route.query.error)
this.$router.replace(this.$route.path)
}
}
this.$store
.dispatch('checkForUpdate')
.then((res) => {
if (res && res.hasUpdate) this.showUpdateToast(res)
})
.catch((err) => console.error(err))
}
},
beforeMount() {
@@ -551,6 +540,11 @@ export default {
}
this.checkVersionUpdate()
if (this.$route.query.error) {
this.$toast.error(this.$route.query.error)
this.$router.replace(this.$route.path)
}
},
beforeDestroy() {
window.removeEventListener('resize', this.resize)

View File

@@ -1,12 +1,12 @@
{
"name": "audiobookshelf-client",
"version": "2.0.14",
"version": "2.0.18",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf-client",
"version": "2.0.14",
"version": "2.0.17",
"license": "ISC",
"dependencies": {
"@nuxtjs/axios": "^5.13.6",
@@ -18,6 +18,7 @@
"libarchive.js": "^1.3.0",
"nuxt": "^2.15.8",
"nuxt-socket-io": "^1.1.18",
"trix": "^1.3.1",
"v-click-outside": "^3.1.2",
"vue-pdf": "^4.3.0",
"vue-toastification": "^1.7.11",
@@ -15285,6 +15286,11 @@
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o="
},
"node_modules/trix": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/trix/-/trix-1.3.1.tgz",
"integrity": "sha512-BbH6mb6gk+AV4f2as38mP6Ucc1LE3OD6XxkZnAgPIduWXYtvg2mI3cZhIZSLqmMh9OITEpOBCCk88IVmyjU7bA=="
},
"node_modules/ts-pnp": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/ts-pnp/-/ts-pnp-1.2.0.tgz",
@@ -29080,6 +29086,11 @@
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o="
},
"trix": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/trix/-/trix-1.3.1.tgz",
"integrity": "sha512-BbH6mb6gk+AV4f2as38mP6Ucc1LE3OD6XxkZnAgPIduWXYtvg2mI3cZhIZSLqmMh9OITEpOBCCk88IVmyjU7bA=="
},
"ts-pnp": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/ts-pnp/-/ts-pnp-1.2.0.tgz",
@@ -30624,4 +30635,4 @@
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="
}
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
"version": "2.0.16",
"version": "2.0.18",
"description": "Self-hosted audiobook and podcast client",
"main": "index.js",
"scripts": {
@@ -22,6 +22,7 @@
"libarchive.js": "^1.3.0",
"nuxt": "^2.15.8",
"nuxt-socket-io": "^1.1.18",
"trix": "^1.3.1",
"v-click-outside": "^3.1.2",
"vue-pdf": "^4.3.0",
"vue-toastification": "^1.7.11",

View File

@@ -113,6 +113,16 @@
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerPreferMatchedMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferMatchedMetadata', val)" />
<ui-tooltip :text="tooltips.scannerPreferMatchedMetadata">
<p class="pl-4 text-lg">
Scanner prefer matched metadata
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerDisableWatcher" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerDisableWatcher', val)" />
<ui-tooltip :text="tooltips.scannerDisableWatcher">
@@ -226,6 +236,7 @@ export default {
experimentalFeatures: 'Features in development that could use your feedback and help testing. Click to open github discussion.',
scannerDisableWatcher: 'Disables the automatic adding/updating of items when file changes are detected. *Requires server restart',
scannerPreferOpfMetadata: 'OPF file metadata will be used for book details over folder names',
scannerPreferMatchedMetadata: 'Matched data will overide book details when using Quick Match',
scannerPreferAudioMetadata: 'Audio file ID3 meta tags will be used for book details over folder names',
scannerParseSubtitle: 'Extract subtitles from audiobook folder names.<br>Subtitle must be seperated by " - "<br>i.e. "Book Title - A Subtitle Here" has the subtitle "A Subtitle Here"',
sortingIgnorePrefix: 'i.e. for prefix "the" book title "The Book Title" would sort as "Book Title, The"',

View File

@@ -37,7 +37,11 @@
<div class="flex flex-col md:flex-row overflow-hidden max-w-full">
<stats-daily-listening-chart :listening-stats="listeningStats" class="origin-top-left transform scale-75 lg:scale-100" />
<div class="w-80 my-6 mx-auto">
<h1 class="text-2xl mb-4 font-book">Recent Listening Sessions</h1>
<div class="flex mb-4 items-center">
<h1 class="text-2xl font-book">Recent Sessions</h1>
<div class="flex-grow" />
<ui-btn :to="`/config/users/${user.id}/sessions`" class="text-xs" :padding-x="1.5" :padding-y="1">View All</ui-btn>
</div>
<p v-if="!mostRecentListeningSessions.length">No Listening Sessions</p>
<template v-for="(item, index) in mostRecentListeningSessions">
<div :key="item.id" class="w-full py-0.5">

View File

@@ -22,6 +22,10 @@
<div class="w-full h-px bg-white bg-opacity-10 my-2" />
<div class="py-2">
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Listening Stats</h1>
<div class="flex items-center">
<p class="text-sm text-gray-300">{{ listeningSessions.length }} Listening Sessions</p>
<ui-btn :to="`/config/users/${user.id}/sessions`" class="text-xs mx-2" :padding-x="1.5" :padding-y="1">View All</ui-btn>
</div>
<p class="text-sm text-gray-300">
Total Time Listened:&nbsp;
<span class="font-mono text-base">{{ listeningTimePretty }}</span>
@@ -33,12 +37,14 @@
<div v-if="latestSession" class="mt-4">
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Last Listening Session</h1>
<p class="text-sm text-gray-300">{{ latestSession.audiobookTitle }} {{ $dateDistanceFromNow(latestSession.lastUpdate) }} for {{ $elapsedPrettyExtended(this.latestSession.timeListening) }}</p>
<p class="text-sm text-gray-300">
<strong>{{ latestSession.displayTitle }}</strong> {{ $dateDistanceFromNow(latestSession.updatedAt) }} for <span class="font-mono text-base">{{ $elapsedPrettyExtended(this.latestSession.timeListening) }}</span>
</p>
</div>
</div>
<div class="w-full h-px bg-white bg-opacity-10 my-2" />
<div class="py-2">
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Item Progress</h1>
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Saved Media Progress</h1>
<table v-if="mediaProgress.length" class="userAudiobooksTable">
<tr class="bg-primary bg-opacity-40">
<th class="w-16 text-left">Item</th>
@@ -70,7 +76,7 @@
</td>
</tr>
</table>
<p v-else class="text-white text-opacity-50">Nothing read yet...</p>
<p v-else class="text-white text-opacity-50">Nothing listened to yet...</p>
</div>
</div>
</div>

View File

@@ -0,0 +1,151 @@
<template>
<div class="w-full h-full">
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-0 sm:p-4 mb-8">
<nuxt-link :to="`/config/users/${user.id}`" class="text-white text-opacity-70 hover:text-opacity-100 hover:bg-white hover:bg-opacity-5 cursor-pointer rounded-full px-2 sm:px-0">
<div class="flex items-center">
<div class="h-10 w-10 flex items-center justify-center">
<span class="material-icons text-2xl">arrow_back</span>
</div>
<p class="pl-1">Back to User</p>
</div>
</nuxt-link>
<div class="flex items-center mb-2 mt-4 px-2 sm:px-0">
<widgets-online-indicator :value="!!userOnline" />
<h1 class="text-xl pl-2">{{ username }}</h1>
</div>
<div class="w-full h-px bg-white bg-opacity-10 my-2" />
<div class="py-2">
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Listening Sessions ({{ listeningSessions.length }})</h1>
<table v-if="listeningSessions.length" class="userSessionsTable">
<tr class="bg-primary bg-opacity-40">
<th class="flex-grow text-left">Item</th>
<th class="w-32 text-left hidden md:table-cell">Play Method</th>
<th class="w-40 text-left hidden sm:table-cell">Device Info</th>
<th class="w-20">Listened</th>
<th class="w-20">Last Time</th>
<th class="w-40 hidden sm:table-cell">Last Update</th>
</tr>
<tr v-for="session in listeningSessions" :key="session.id" class="cursor-pointer" @click="showSession(session)">
<td class="py-1">
<p class="text-sm text-gray-200">{{ session.displayTitle }}</p>
<p class="text-xs text-gray-400">{{ session.displayAuthor }}</p>
</td>
<td class="hidden md:table-cell">
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p>
</td>
<td class="hidden sm:table-cell">
<p class="text-xs" v-html="getDeviceInfoString(session.deviceInfo)" />
</td>
<td class="text-center">
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
</td>
<td class="text-center">
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
</td>
<td class="text-center hidden sm:table-cell">
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDate(session.updatedAt, 'MMMM do, yyyy HH:mm')">
<p class="text-xs">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
</ui-tooltip>
</td>
</tr>
</table>
<p v-else class="text-white text-opacity-50">No sessions yet...</p>
</div>
</div>
<modals-listening-session-modal v-model="showSessionModal" :session="selectedSession" />
</div>
</template>
<script>
export default {
async asyncData({ params, redirect, app }) {
var user = await app.$axios.$get(`/api/users/${params.id}`).catch((error) => {
console.error('Failed to get user', error)
return null
})
if (!user) return redirect('/config/users')
return {
user
}
},
data() {
return {
showSessionModal: false,
selectedSession: null,
listeningSessions: []
}
},
computed: {
username() {
return this.user.username
},
userOnline() {
return this.$store.getters['users/getIsUserOnline'](this.user.id)
}
},
methods: {
showSession(session) {
this.selectedSession = session
this.showSessionModal = true
},
getDeviceInfoString(deviceInfo) {
if (!deviceInfo) return ''
var lines = []
if (deviceInfo.osName) lines.push(`${deviceInfo.osName} ${deviceInfo.osVersion}`)
if (deviceInfo.browserName) lines.push(deviceInfo.browserName)
if (deviceInfo.manufacturer && deviceInfo.model) lines.push(`${deviceInfo.manufacturer} ${deviceInfo.model}`)
if (deviceInfo.sdkVersion) lines.push(`SDK Version: ${deviceInfo.sdkVersion}`)
return lines.join('<br>')
},
getPlayMethodName(playMethod) {
if (playMethod === this.$constants.PlayMethod.DIRECTPLAY) return 'Direct Play'
else if (playMethod === this.$constants.PlayMethod.TRANSCODE) return 'Transcode'
else if (playMethod === this.$constants.PlayMethod.DIRECTSTREAM) return 'Direct Stream'
else if (playMethod === this.$constants.PlayMethod.LOCAL) return 'Local'
return 'Unknown'
},
async init() {
console.log(navigator)
this.listeningSessions = await this.$axios.$get(`/api/users/${this.user.id}/listening-sessions`).catch((err) => {
console.error('Failed to load listening sesions', err)
return []
})
}
},
mounted() {
this.init()
}
}
</script>
<style>
.userSessionsTable {
border-collapse: collapse;
width: 100%;
border: 1px solid #474747;
}
.userSessionsTable tr:first-child {
background-color: #272727;
}
.userSessionsTable tr:not(:first-child) {
background-color: #373838;
}
.userSessionsTable tr:not(:first-child):nth-child(odd) {
background-color: #2f2f2f;
}
.userSessionsTable tr:hover:not(:first-child) {
background-color: #474747;
}
.userSessionsTable td {
padding: 4px 8px;
}
.userSessionsTable th {
padding: 4px 8px;
font-size: 0.75rem;
}
</style>

View File

@@ -31,11 +31,13 @@
<p v-if="bookSubtitle" class="sm:ml-4 text-gray-400 text-xl md:text-2xl">{{ bookSubtitle }}</p>
</div>
<p v-if="isPodcast" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">by {{ podcastAuthor || 'Unknown' }}</p>
<p v-else-if="authors.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">
by <nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">,&nbsp;</span></nuxt-link>
</p>
<p v-else class="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</p>
<template v-if="!isVideo">
<p v-if="isPodcast" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">by {{ podcastAuthor || 'Unknown' }}</p>
<p v-else-if="authors.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">
by <nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">,&nbsp;</span></nuxt-link>
</p>
<p v-else class="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</p>
</template>
<nuxt-link v-for="_series in seriesList" :key="_series.id" :to="`/library/${libraryId}/series/${_series.id}`" class="hover:underline font-sans text-gray-300 text-lg leading-7"> {{ _series.text }}</nuxt-link>
@@ -251,6 +253,9 @@ export default {
isPodcast() {
return this.libraryItem.mediaType === 'podcast'
},
isVideo() {
return this.libraryItem.mediaType === 'video'
},
isMissing() {
return this.libraryItem.isMissing
},
@@ -258,11 +263,12 @@ export default {
return this.libraryItem.isInvalid
},
invalidAudioFiles() {
if (this.isPodcast) return []
if (this.isPodcast || this.isVideo) return []
return this.libraryItem.media.audioFiles.filter((af) => af.invalid)
},
showPlayButton() {
if (this.isMissing || this.isInvalid) return false
if (this.isVideo) return !!this.videoFile
if (this.isPodcast) return this.podcastEpisodes.length
return this.tracks.length
},
@@ -348,6 +354,9 @@ export default {
ebookFile() {
return this.media.ebookFile
},
videoFile() {
return this.media.videoFile
},
showExperimentalReadAlert() {
return !this.tracks.length && this.ebookFile && !this.showExperimentalFeatures && !this.enableEReader
},

View File

@@ -1,6 +1,6 @@
<template>
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
<app-book-shelf-toolbar is-home />
<app-book-shelf-toolbar page="authors" is-home :authors="authors" />
<div id="bookshelf" class="w-full h-full p-8 overflow-y-auto">
<div class="flex flex-wrap justify-center">
<template v-for="author in authors">

View File

@@ -1,12 +1,14 @@
<template>
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
<app-book-shelf-toolbar page="podcast-search" />
<div class="w-full h-full overflow-y-auto p-12 relative">
<div class="w-full max-w-3xl mx-auto">
<form @submit.prevent="submit" class="flex">
<div class="w-full max-w-4xl mx-auto flex">
<form @submit.prevent="submit" class="flex flex-grow">
<ui-text-input v-model="searchInput" :disabled="processing" placeholder="Enter search term or RSS feed URL" class="flex-grow mr-2" />
<ui-btn type="submit" :disabled="processing">Submit</ui-btn>
</form>
<ui-file-input ref="fileInput" :accept="'.opml, .txt'" class="mx-2" @change="opmlFileUpload"> Upload OPML File </ui-file-input>
</div>
<div class="w-full max-w-3xl mx-auto py-4">
@@ -32,6 +34,7 @@
</div>
<modals-podcast-new-modal v-model="showNewPodcastModal" :podcast-data="selectedPodcast" :podcast-feed-data="selectedPodcastFeed" />
<modals-podcast-opml-feeds-modal v-model="showOPMLFeedsModal" :feeds="opmlFeeds" />
</div>
</template>
@@ -62,7 +65,9 @@ export default {
processing: false,
showNewPodcastModal: false,
selectedPodcast: null,
selectedPodcastFeed: null
selectedPodcastFeed: null,
showOPMLFeedsModal: false,
opmlFeeds: []
}
},
computed: {
@@ -71,6 +76,40 @@ export default {
}
},
methods: {
async opmlFileUpload(file) {
this.processing = true
var txt = await new Promise((resolve) => {
const reader = new FileReader()
reader.onload = () => {
resolve(reader.result)
}
reader.readAsText(file)
})
if (this.$refs.fileInput) {
this.$refs.fileInput.reset()
}
if (!txt || !txt.includes('<opml') || !txt.includes('<outline ')) {
// Quick lazy check for valid OPML
this.$toast.error('Invalid OPML file <opml> tag not found OR an <outline> tag was not found')
this.processing = false
return
}
await this.$axios
.$post(`/api/podcasts/opml`, { opmlText: txt })
.then((data) => {
console.log(data)
this.opmlFeeds = data.feeds || []
this.showOPMLFeedsModal = true
})
.catch((error) => {
console.error('Failed', error)
this.$toast.error('Failed to parse OPML file')
})
this.processing = false
},
submit() {
if (!this.searchInput) return

View File

@@ -64,8 +64,8 @@
</div>
</div>
<input ref="fileInput" id="hidden-input" type="file" multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
<input ref="fileFolderInput" id="hidden-input" type="file" webkitdirectory multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
<input ref="fileInput" type="file" multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
<input ref="fileFolderInput" type="file" webkitdirectory multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
</div>
</template>

View File

@@ -52,12 +52,12 @@ export default class CastPlayer extends EventEmitter {
return
}
// var currentItemId = media.currentItemId
var currentItemId = media.media.itemId
if (currentItemId && this.currentTrackIndex !== currentItemId - 1) {
this.currentTrackIndex = currentItemId - 1
}
// TODO: Emit finished event
if (media.playerState !== this.castPlayerState) {
this.emit('stateChange', media.playerState)
this.castPlayerState = media.playerState

View File

@@ -1,7 +1,7 @@
import Hls from 'hls.js'
import EventEmitter from 'events'
export default class LocalPlayer extends EventEmitter {
export default class LocalAudioPlayer extends EventEmitter {
constructor(ctx) {
super()
@@ -76,6 +76,7 @@ export default class LocalPlayer extends EventEmitter {
this.loadCurrentTrack()
} else {
console.log(`[LocalPlayer] Ended`)
this.emit('finished')
}
}
evtError(error) {

View File

@@ -0,0 +1,260 @@
import Hls from 'hls.js'
import EventEmitter from 'events'
export default class LocalVideoPlayer extends EventEmitter {
constructor(ctx) {
super()
this.ctx = ctx
this.player = null
this.libraryItem = null
this.videoTrack = null
this.isHlsTranscode = null
this.hlsInstance = null
this.usingNativeplayer = false
this.startTime = 0
this.playWhenReady = false
this.defaultPlaybackRate = 1
this.playableMimeTypes = []
this.initialize()
}
initialize() {
if (document.getElementById('video-player')) {
document.getElementById('video-player').remove()
}
var videoEl = document.createElement('video')
videoEl.id = 'video-player'
// videoEl.style.display = 'none'
videoEl.className = 'absolute bg-black z-50'
videoEl.style.height = '216px'
videoEl.style.width = '384px'
videoEl.style.bottom = '80px'
videoEl.style.left = '16px'
document.body.appendChild(videoEl)
this.player = videoEl
this.player.addEventListener('play', this.evtPlay.bind(this))
this.player.addEventListener('pause', this.evtPause.bind(this))
this.player.addEventListener('progress', this.evtProgress.bind(this))
this.player.addEventListener('ended', this.evtEnded.bind(this))
this.player.addEventListener('error', this.evtError.bind(this))
this.player.addEventListener('loadedmetadata', this.evtLoadedMetadata.bind(this))
this.player.addEventListener('timeupdate', this.evtTimeupdate.bind(this))
var mimeTypes = ['video/mp4']
var mimeTypeCanPlayMap = {}
mimeTypes.forEach((mt) => {
var canPlay = this.player.canPlayType(mt)
mimeTypeCanPlayMap[mt] = canPlay
if (canPlay) this.playableMimeTypes.push(mt)
})
console.log(`[LocalVideoPlayer] Supported mime types`, mimeTypeCanPlayMap, this.playableMimeTypes)
}
evtPlay() {
this.emit('stateChange', 'PLAYING')
}
evtPause() {
this.emit('stateChange', 'PAUSED')
}
evtProgress() {
var lastBufferTime = this.getLastBufferedTime()
this.emit('buffertimeUpdate', lastBufferTime)
}
evtEnded() {
console.log(`[LocalVideoPlayer] Ended`)
this.emit('finished')
}
evtError(error) {
console.error('Player error', error)
this.emit('error', error)
}
evtLoadedMetadata(data) {
if (!this.isHlsTranscode) {
this.player.currentTime = this.startTime
}
this.emit('stateChange', 'LOADED')
if (this.playWhenReady) {
this.playWhenReady = false
this.play()
}
}
evtTimeupdate() {
if (this.player.paused) {
this.emit('timeupdate', this.getCurrentTime())
}
}
destroy() {
this.destroyHlsInstance()
if (this.player) {
this.player.remove()
}
}
set(libraryItem, videoTrack, isHlsTranscode, startTime, playWhenReady = false) {
this.libraryItem = libraryItem
this.videoTrack = videoTrack
this.isHlsTranscode = isHlsTranscode
this.playWhenReady = playWhenReady
this.startTime = startTime
if (this.hlsInstance) {
this.destroyHlsInstance()
}
if (this.isHlsTranscode) {
this.setHlsStream()
} else {
this.setDirectPlay()
}
}
setHlsStream() {
// iOS does not support Media Elements but allows for HLS in the native video player
if (!Hls.isSupported()) {
console.warn('HLS is not supported - fallback to using video element')
this.usingNativeplayer = true
this.player.src = this.videoTrack.relativeContentUrl
this.player.currentTime = this.startTime
return
}
var hlsOptions = {
startPosition: this.startTime || -1
// No longer needed because token is put in a query string
// xhrSetup: (xhr) => {
// xhr.setRequestHeader('Authorization', `Bearer ${this.token}`)
// }
}
this.hlsInstance = new Hls(hlsOptions)
this.hlsInstance.attachMedia(this.player)
this.hlsInstance.on(Hls.Events.MEDIA_ATTACHED, () => {
this.hlsInstance.loadSource(this.videoTrack.relativeContentUrl)
this.hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
console.log('[HLS] Manifest Parsed')
})
this.hlsInstance.on(Hls.Events.ERROR, (e, data) => {
console.error('[HLS] Error', data.type, data.details, data)
if (data.details === Hls.ErrorDetails.BUFFER_STALLED_ERROR) {
console.error('[HLS] BUFFER STALLED ERROR')
}
})
this.hlsInstance.on(Hls.Events.DESTROYING, () => {
console.log('[HLS] Destroying HLS Instance')
})
})
}
setDirectPlay() {
this.player.src = this.videoTrack.relativeContentUrl
console.log(`[LocalVideoPlayer] Loading track src ${this.videoTrack.relativeContentUrl}`)
this.player.load()
}
destroyHlsInstance() {
if (!this.hlsInstance) return
if (this.hlsInstance.destroy) {
var temp = this.hlsInstance
temp.destroy()
}
this.hlsInstance = null
}
async resetStream(startTime) {
this.destroyHlsInstance()
await new Promise((resolve) => setTimeout(resolve, 1000))
this.set(this.libraryItem, this.videoTrack, this.isHlsTranscode, startTime, true)
}
playPause() {
if (!this.player) return
if (this.player.paused) this.play()
else this.pause()
}
play() {
if (this.player) this.player.play()
}
pause() {
if (this.player) this.player.pause()
}
getCurrentTime() {
return this.player ? this.player.currentTime : 0
}
getDuration() {
return this.videoTrack.duration
}
setPlaybackRate(playbackRate) {
if (!this.player) return
this.defaultPlaybackRate = playbackRate
this.player.playbackRate = playbackRate
}
seek(time) {
if (!this.player) return
this.player.currentTime = Math.max(0, time)
}
setVolume(volume) {
if (!this.player) return
this.player.volume = volume
}
// Utils
isValidDuration(duration) {
if (duration && !isNaN(duration) && duration !== Number.POSITIVE_INFINITY && duration !== Number.NEGATIVE_INFINITY) {
return true
}
return false
}
getBufferedRanges() {
if (!this.player) return []
const ranges = []
const seekable = this.player.buffered || []
let offset = 0
for (let i = 0, length = seekable.length; i < length; i++) {
let start = seekable.start(i)
let end = seekable.end(i)
if (!this.isValidDuration(start)) {
start = 0
}
if (!this.isValidDuration(end)) {
end = 0
continue
}
ranges.push({
start: start + offset,
end: end + offset
})
}
return ranges
}
getLastBufferedTime() {
var bufferedRanges = this.getBufferedRanges()
if (!bufferedRanges.length) return 0
var buff = bufferedRanges.find((buff) => buff.start < this.player.currentTime && buff.end > this.player.currentTime)
if (buff) return buff.end
var last = bufferedRanges[bufferedRanges.length - 1]
return last.end
}
}

View File

@@ -1,6 +1,8 @@
import LocalPlayer from './LocalPlayer'
import LocalAudioPlayer from './LocalAudioPlayer'
import LocalVideoPlayer from './LocalVideoPlayer'
import CastPlayer from './CastPlayer'
import AudioTrack from './AudioTrack'
import VideoTrack from './VideoTrack'
export default class PlayerHandler {
constructor(ctx) {
@@ -14,6 +16,7 @@ export default class PlayerHandler {
this.player = null
this.playerState = 'IDLE'
this.isHlsTranscode = false
this.isVideo = false
this.currentSessionId = null
this.startTime = 0
@@ -34,7 +37,7 @@ export default class PlayerHandler {
return this.libraryItem && (this.player instanceof CastPlayer)
}
get isPlayingLocalItem() {
return this.libraryItem && (this.player instanceof LocalPlayer)
return this.libraryItem && (this.player instanceof LocalAudioPlayer)
}
get userToken() {
return this.ctx.$store.getters['user/getToken']
@@ -48,16 +51,17 @@ export default class PlayerHandler {
}
load(libraryItem, episodeId, playWhenReady, playbackRate) {
if (!this.player) this.switchPlayer()
this.libraryItem = libraryItem
this.episodeId = episodeId
this.playWhenReady = playWhenReady
this.initialPlaybackRate = playbackRate
this.prepare()
this.isVideo = libraryItem.mediaType === 'video'
if (!this.player) this.switchPlayer(playWhenReady)
else this.prepare()
}
switchPlayer() {
switchPlayer(playWhenReady) {
if (this.isCasting && !(this.player instanceof CastPlayer)) {
console.log('[PlayerHandler] Switching to cast player')
@@ -73,10 +77,10 @@ export default class PlayerHandler {
if (this.libraryItem) {
// libraryItem was already loaded - prepare for cast
this.playWhenReady = false
this.playWhenReady = playWhenReady
this.prepare()
}
} else if (!this.isCasting && !(this.player instanceof LocalPlayer)) {
} else if (!this.isCasting && !(this.player instanceof LocalAudioPlayer) && !(this.player instanceof LocalVideoPlayer)) {
console.log('[PlayerHandler] Switching to local player')
this.stopPlayInterval()
@@ -85,12 +89,18 @@ export default class PlayerHandler {
if (this.player) {
this.player.destroy()
}
this.player = new LocalPlayer(this.ctx)
if (this.isVideo) {
this.player = new LocalVideoPlayer(this.ctx)
} else {
this.player = new LocalAudioPlayer(this.ctx)
}
this.setPlayerListeners()
if (this.libraryItem) {
// libraryItem was already loaded - prepare for local play
this.playWhenReady = false
this.playWhenReady = playWhenReady
this.prepare()
}
}
@@ -101,16 +111,27 @@ export default class PlayerHandler {
this.player.on('timeupdate', this.playerTimeupdate.bind(this))
this.player.on('buffertimeUpdate', this.playerBufferTimeUpdate.bind(this))
this.player.on('error', this.playerError.bind(this))
this.player.on('finished', this.playerFinished.bind(this))
}
playerError() {
// Switch to HLS stream on error
if (!this.isCasting && !this.currentStreamId && (this.player instanceof LocalPlayer)) {
if (!this.isCasting && !this.currentStreamId && (this.player instanceof LocalAudioPlayer)) {
console.log(`[PlayerHandler] Audio player error switching to HLS stream`)
this.prepare(true)
}
}
playerFinished() {
this.stopPlayInterval()
var currentTime = this.player.getCurrentTime()
this.ctx.setCurrentTime(currentTime)
// TODO: Add listening time between last sync and now?
this.sendProgressSync(currentTime)
}
playerStateChange(state) {
console.log('[PlayerHandler] Player state change', state)
this.playerState = state
@@ -144,7 +165,7 @@ export default class PlayerHandler {
supportedMimeTypes: this.player.playableMimeTypes,
mediaPlayer: this.isCasting ? 'chromecast' : 'html5',
forceTranscode,
forceDirectPlay: this.isCasting // TODO: add transcode support for chromecast
forceDirectPlay: this.isCasting || this.isVideo // TODO: add transcode support for chromecast
}
var path = this.episodeId ? `/api/items/${this.libraryItem.id}/play/${this.episodeId}` : `/api/items/${this.libraryItem.id}/play`
@@ -155,11 +176,12 @@ export default class PlayerHandler {
}
prepareOpenSession(session, playbackRate) { // Session opened on init socket
if (!this.player) this.switchPlayer()
this.libraryItem = session.libraryItem
this.isVideo = session.libraryItem.mediaType === 'video'
this.playWhenReady = false
this.initialPlaybackRate = playbackRate
if (!this.player) this.switchPlayer()
this.prepareSession(session)
}
@@ -170,16 +192,29 @@ export default class PlayerHandler {
this.displayAuthor = session.displayAuthor
console.log('[PlayerHandler] Preparing Session', session)
var audioTracks = session.audioTracks.map(at => new AudioTrack(at, this.userToken))
this.ctx.playerLoading = true
this.isHlsTranscode = true
if (session.playMethod === this.ctx.$constants.PlayMethod.DIRECTPLAY) {
this.isHlsTranscode = false
if (session.videoTrack) {
var videoTrack = new VideoTrack(session.videoTrack, this.userToken)
this.ctx.playerLoading = true
this.isHlsTranscode = true
if (session.playMethod === this.ctx.$constants.PlayMethod.DIRECTPLAY) {
this.isHlsTranscode = false
}
this.player.set(this.libraryItem, videoTrack, this.isHlsTranscode, this.startTime, this.playWhenReady)
} else {
var audioTracks = session.audioTracks.map(at => new AudioTrack(at, this.userToken))
this.ctx.playerLoading = true
this.isHlsTranscode = true
if (session.playMethod === this.ctx.$constants.PlayMethod.DIRECTPLAY) {
this.isHlsTranscode = false
}
this.player.set(this.libraryItem, audioTracks, this.isHlsTranscode, this.startTime, this.playWhenReady)
}
this.player.set(this.libraryItem, audioTracks, this.isHlsTranscode, this.startTime, this.playWhenReady)
// browser media session api
this.ctx.setMediaSession()
}

View File

@@ -0,0 +1,32 @@
export default class VideoTrack {
constructor(track, userToken) {
this.index = track.index || 0
this.startOffset = track.startOffset || 0 // Total time of all previous tracks
this.duration = track.duration || 0
this.title = track.title || ''
this.contentUrl = track.contentUrl || null
this.mimeType = track.mimeType
this.metadata = track.metadata || {}
this.userToken = userToken
}
get fullContentUrl() {
if (!this.contentUrl || this.contentUrl.startsWith('http')) return this.contentUrl
if (process.env.NODE_ENV === 'development') {
return `${process.env.serverUrl}${this.contentUrl}?token=${this.userToken}`
}
return `${window.location.origin}${this.contentUrl}?token=${this.userToken}`
}
get relativeContentUrl() {
if (!this.contentUrl || this.contentUrl.startsWith('http')) return this.contentUrl
if (process.env.NODE_ENV === 'development') {
return `${process.env.serverUrl}${this.contentUrl}?token=${this.userToken}`
}
return this.contentUrl + `?token=${this.userToken}`
}
}

View File

@@ -1,6 +1,6 @@
const SupportedFileTypes = {
image: ['png', 'jpg', 'jpeg', 'webp'],
audio: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'mp4', 'aac', 'wma', 'aiff'],
audio: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'mp4', 'aac', 'wma', 'aiff', 'wav'],
ebook: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'],
info: ['nfo'],
text: ['txt'],
@@ -28,7 +28,8 @@ const BookshelfView = {
const PlayMethod = {
DIRECTPLAY: 0,
DIRECTSTREAM: 1,
TRANSCODE: 2
TRANSCODE: 2,
LOCAL: 3
}
const Constants = {

View File

@@ -1,4 +1,5 @@
import Vue from 'vue'
import Path from 'path'
import vClickOutside from 'v-click-outside'
import { formatDistance, format, addDays, isDate } from 'date-fns'
@@ -57,6 +58,7 @@ Vue.prototype.$elapsedPretty = (seconds, useFullNames = false) => {
}
Vue.prototype.$secondsToTimestamp = (seconds) => {
if (!seconds) return '0:00'
var _seconds = seconds
var _minutes = Math.floor(seconds / 60)
_seconds -= _minutes * 60
@@ -118,20 +120,36 @@ Vue.prototype.$sanitizeFilename = (input, colonReplacement = ' - ') => {
if (typeof input !== 'string') {
return false
}
// Max is actually 255-260 for windows but this leaves padding incase ext wasnt put on yet
const MAX_FILENAME_LEN = 240
var replacement = ''
var illegalRe = /[\/\?<>\\:\*\|"]/g;
var controlRe = /[\x00-\x1f\x80-\x9f]/g;
var reservedRe = /^\.+$/;
var windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i;
var windowsTrailingRe = /[\. ]+$/;
var illegalRe = /[\/\?<>\\:\*\|"]/g
var controlRe = /[\x00-\x1f\x80-\x9f]/g
var reservedRe = /^\.+$/
var windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i
var windowsTrailingRe = /[\. ]+$/
var lineBreaks = /[\n\r]/g
var sanitized = input
.replace(':', colonReplacement) // Replace first occurrence of a colon
.replace(illegalRe, replacement)
.replace(controlRe, replacement)
.replace(reservedRe, replacement)
.replace(lineBreaks, replacement)
.replace(windowsReservedRe, replacement)
.replace(windowsTrailingRe, replacement);
.replace(windowsTrailingRe, replacement)
if (sanitized.length > MAX_FILENAME_LEN) {
var lenToRemove = sanitized.length - MAX_FILENAME_LEN
var ext = Path.extname(sanitized)
var basename = Path.basename(sanitized, ext)
basename = basename.slice(0, basename.length - lenToRemove)
sanitized = basename + ext
}
return sanitized
}

View File

@@ -23,14 +23,17 @@ function parseSemver(ver) {
}
return null
}
export const currentVersion = packagejson.version
export async function checkForUpdate() {
if (!packagejson.version) {
return
return null
}
var currVerObj = parseSemver('v' + packagejson.version)
if (!currVerObj) {
console.error('Invalid version', packagejson.version)
return
return null
}
var largestVer = null
await axios.get(`https://api.github.com/repos/advplyr/audiobookshelf/releases`).then((res) => {
@@ -49,7 +52,7 @@ export async function checkForUpdate() {
})
if (!largestVer) {
console.error('No valid version tags to compare with')
return
return null
}
return {

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 512 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

View File

@@ -6,11 +6,11 @@ export const state = () => ({
showUserCollectionsModal: false,
showEditCollectionModal: false,
showEditPodcastEpisode: false,
showViewPodcastEpisodeModal: false,
showEditAuthorModal: false,
selectedEpisode: null,
selectedCollection: null,
selectedAuthor: null,
showBookshelfTextureModal: false,
isCasting: false, // Actively casting
isChromecastInitialized: false // Script loaded
})
@@ -53,6 +53,9 @@ export const mutations = {
setShowEditPodcastEpisodeModal(state, val) {
state.showEditPodcastEpisode = val
},
setShowViewPodcastEpisodeModal(state, val) {
state.showViewPodcastEpisodeModal = val
},
setEditCollection(state, collection) {
state.selectedCollection = collection
state.showEditCollectionModal = true
@@ -60,9 +63,6 @@ export const mutations = {
setSelectedEpisode(state, episode) {
state.selectedEpisode = episode
},
setShowBookshelfTextureModal(state, val) {
state.showBookshelfTextureModal = val
},
showEditAuthorModal(state, author) {
state.selectedAuthor = author
state.showEditAuthorModal = true

View File

@@ -1,4 +1,4 @@
import { checkForUpdate } from '@/plugins/version'
import { checkForUpdate, currentVersion } from '@/plugins/version'
import Vue from 'vue'
export const state = () => ({
@@ -8,6 +8,7 @@ export const state = () => ({
streamLibraryItem: null,
streamEpisodeId: null,
streamIsPlaying: false,
playerIsFullscreen: false,
editModalTab: 'details',
showEditModal: false,
showEReader: false,
@@ -20,7 +21,7 @@ export const state = () => ({
backups: [],
bookshelfBookIds: [],
openModal: null,
selectedBookshelfTexture: '/textures/wood_default.jpg',
innerModalOpen: false,
lastBookshelfScrollData: {}
})
@@ -64,20 +65,44 @@ export const actions = {
})
},
checkForUpdate({ commit }) {
return checkForUpdate()
.then((res) => {
commit('setVersionData', res)
return res
})
.catch((error) => {
console.error('Update check failed', error)
return false
})
},
setBookshelfTexture({ commit, state }, img) {
let root = document.documentElement;
commit('setBookshelfTexture', img)
root.style.setProperty('--bookshelf-texture-img', `url(${img})`);
const VERSION_CHECK_BUFF = 1000 * 60 * 5 // 5 minutes
var lastVerCheck = localStorage.getItem('lastVerCheck') || 0
var savedVersionData = localStorage.getItem('versionData')
if (savedVersionData) {
try {
savedVersionData = JSON.parse(localStorage.getItem('versionData'))
} catch (error) {
console.error('Failed to parse version data', error)
savedVersionData = null
localStorage.removeItem('versionData')
}
}
var shouldCheckForUpdate = Date.now() - Number(lastVerCheck) > VERSION_CHECK_BUFF
if (!shouldCheckForUpdate && savedVersionData && savedVersionData.version !== currentVersion) {
// Version mismatch between saved data so check for update anyway
shouldCheckForUpdate = true
}
if (shouldCheckForUpdate) {
return checkForUpdate()
.then((res) => {
if (res) {
localStorage.setItem('lastVerCheck', Date.now())
localStorage.setItem('versionData', JSON.stringify(res))
commit('setVersionData', res)
}
return res && res.hasUpdate
})
.catch((error) => {
console.error('Update check failed', error)
return false
})
} else if (savedVersionData) {
commit('setVersionData', savedVersionData)
}
return null
}
}
@@ -85,6 +110,9 @@ export const mutations = {
setSource(state, source) {
state.Source = source
},
setPlayerIsFullscreen(state, val) {
state.playerIsFullscreen = val
},
setLastBookshelfScrollData(state, { scrollTop, path, name }) {
state.lastBookshelfScrollData[name] = { scrollTop, path }
},
@@ -177,7 +205,7 @@ export const mutations = {
setOpenModal(state, val) {
state.openModal = val
},
setBookshelfTexture(state, val) {
state.selectedBookshelfTexture = val
setInnerModalOpen(state, val) {
state.innerModalOpen = val
}
}

View File

@@ -8,7 +8,8 @@ module.exports = {
'bg-red-600',
'text-green-500',
'py-1.5',
'bg-info'
'bg-info',
'px-1.5'
]
}
},
@@ -37,6 +38,7 @@ module.exports = {
minWidth: {
'6': '1.5rem',
'12': '3rem',
'16': '4rem',
'24': '6rem',
'32': '8rem',
'48': '12rem',
@@ -75,6 +77,9 @@ module.exports = {
mono: ['Ubuntu Mono', ...defaultTheme.fontFamily.mono],
book: ['Gentium Book Basic', 'serif']
},
fontSize: {
xxs: '0.625rem'
},
zIndex: {
'50': 50
}

756
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.16",
"version": "2.0.18",
"description": "Self-hosted audiobook and podcast server",
"main": "index.js",
"scripts": {
@@ -38,6 +38,7 @@
"fast-sort": "^3.1.1",
"fluent-ffmpeg": "^2.1.2",
"fs-extra": "^10.0.0",
"htmlparser2": "^8.0.1",
"image-type": "^4.1.0",
"jsonwebtoken": "^8.5.1",
"libgen": "^2.1.0",
@@ -49,8 +50,6 @@
"read-chunk": "^3.1.0",
"recursive-readdir-async": "^1.1.8",
"socket.io": "^4.4.1",
"string-strip-html": "^8.3.0",
"watcher": "^1.2.0",
"xml2js": "^0.4.23"
}
}

View File

@@ -104,7 +104,6 @@ class Auth {
async login(req, res) {
var username = (req.body.username || '').toLowerCase()
var password = req.body.password || ''
Logger.debug('Check Auth', username, !!password)
var user = this.users.find(u => u.username.toLowerCase() === username)

View File

@@ -173,9 +173,6 @@ class Server {
// Metadata folder static path
app.use('/metadata', this.authMiddleware.bind(this), express.static(global.MetadataPath))
// TODO: Are these necessary?
// Downloads folder static path
// app.use('/downloads', this.authMiddleware.bind(this), express.static(this.downloadManager.downloadDirPath))
// Static folder
app.use(express.static(Path.join(global.appRoot, 'static')))
@@ -212,7 +209,8 @@ class Server {
const dyanimicRoutes = [
'/item/:id',
'/item/:id/manage',
'/item/:id/chapters',
'/author/:id',
'/audiobook/:id/chapters',
'/audiobook/:id/edit',
'/library/:library',
'/library/:library/search',
@@ -220,6 +218,7 @@ class Server {
'/library/:library/authors',
'/library/:library/series/:id?',
'/config/users/:id',
'/config/users/:id/sessions',
'/collection/:id'
]
dyanimicRoutes.forEach((route) => app.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html'))))

View File

@@ -1,6 +1,5 @@
const Path = require('path')
const EventEmitter = require('events')
const Watcher = require('watcher')
const Watcher = require('./libs/watcher/watcher')
const Logger = require('./Logger')
class FolderWatcher extends EventEmitter {

View File

@@ -485,8 +485,7 @@ class LibraryController {
}
middleware(req, res, next) {
var librariesAccessible = req.user.librariesAccessible || []
if (librariesAccessible && librariesAccessible.length && !librariesAccessible.includes(req.params.id)) {
if (!req.user.checkCanAccessLibrary(req.params.id)) {
Logger.warn(`[LibraryController] Library ${req.params.id} not accessible to user ${req.user.username}`)
return res.sendStatus(404)
}
@@ -497,7 +496,7 @@ class LibraryController {
}
req.library = library
req.libraryItems = this.db.libraryItems.filter(li => {
return li.libraryId === library.id && req.user.checkCanAccessLibraryItemWithTags(li.media.tags)
return li.libraryId === library.id && req.user.checkCanAccessLibraryItem(li)
})
next()
}

View File

@@ -51,7 +51,6 @@ class LibraryItemController {
var hasUpdates = libraryItem.update(req.body)
if (hasUpdates) {
// Turn on podcast auto download cron if not already on
if (libraryItem.mediaType == 'podcast' && req.body.media.autoDownloadEpisodes && !this.podcastManager.episodeScheduleTask) {
this.podcastManager.schedulePodcastEpisodeCron()
@@ -185,12 +184,12 @@ class LibraryItemController {
// POST: api/items/:id/play
startPlaybackSession(req, res) {
if (!req.libraryItem.media.numTracks) {
if (!req.libraryItem.media.numTracks && req.libraryItem.mediaType !== 'video') {
Logger.error(`[LibraryItemController] startPlaybackSession cannot playback ${req.libraryItem.id}`)
return res.sendStatus(404)
}
const options = req.body || {}
this.playbackSessionManager.startSessionRequest(req.user, req.libraryItem, null, options, res)
this.playbackSessionManager.startSessionRequest(req, res, null)
}
// POST: api/items/:id/play/:episodeId
@@ -206,8 +205,7 @@ class LibraryItemController {
return res.sendStatus(404)
}
const options = req.body || {}
this.playbackSessionManager.startSessionRequest(req.user, libraryItem, episodeId, options, res)
this.playbackSessionManager.startSessionRequest(req, res, episodeId)
}
// PATCH: api/items/:id/tracks

View File

@@ -104,15 +104,13 @@ class PodcastController {
return res.status(500).send('Bad response from feed request')
}
Logger.debug(`[PdocastController] Podcast feed size ${(data.data.length / 1024 / 1024).toFixed(2)}MB`)
var payload = await parsePodcastRssFeedXml(data.data, includeRaw)
var payload = await parsePodcastRssFeedXml(data.data, false, includeRaw)
if (!payload) {
return res.status(500).send('Invalid podcast RSS feed')
}
if (!payload.podcast.metadata.feedUrl) {
// Not every RSS feed will put the feed url in their metadata
payload.podcast.metadata.feedUrl = url
}
// RSS feed may be a private RSS feed
payload.podcast.metadata.feedUrl = url
res.json(payload)
}).catch((error) => {
@@ -121,6 +119,15 @@ class PodcastController {
})
}
async getOPMLFeeds(req, res) {
if (!req.body.opmlText) {
return res.sendStatus(400)
}
const rssFeedsData = await this.podcastManager.getOPMLFeeds(req.body.opmlText)
res.json(rssFeedsData)
}
async checkNewEpisodes(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[PodcastController] Non-admin user attempted to check/download episodes`, req.user)
@@ -212,7 +219,11 @@ class PodcastController {
})
}
libraryItem.media.removeEpisode(episodeId)
// Remove episode from Podcast and library file
const episodeRemoved = libraryItem.media.removeEpisode(episodeId)
if (episodeRemoved && episodeRemoved.audioFile) {
libraryItem.removeLibraryFile(episodeRemoved.audioFile.ino)
}
await this.db.updateLibraryItem(libraryItem)
this.emitter('item_updated', libraryItem.toJSONExpanded())
@@ -227,13 +238,8 @@ class PodcastController {
return res.sendStatus(500)
}
// Check user can access this library
if (!req.user.checkCanAccessLibrary(item.libraryId)) {
return res.sendStatus(403)
}
// Check user can access this library item
if (!req.user.checkCanAccessLibraryItemWithTags(item.media.tags)) {
if (!req.user.checkCanAccessLibraryItem(item)) {
return res.sendStatus(403)
}

View File

@@ -166,14 +166,14 @@ class BookFinder {
return this.iTunesApi.searchAudiobooks(title)
}
async getAudibleResults(title, author) {
var books = await this.audible.search(title, author);
async getAudibleResults(title, author, asin) {
var books = await this.audible.search(title, author, asin);
if (this.verbose) Logger.debug(`Audible Book Search Results: ${books.length || 0}`)
if (!books) return []
return books
}
async search(provider, title, author, options = {}) {
async search(provider, title, author, isbn, asin, options = {}) {
var books = []
var maxTitleDistance = !isNaN(options.titleDistance) ? Number(options.titleDistance) : 4
var maxAuthorDistance = !isNaN(options.authorDistance) ? Number(options.authorDistance) : 4
@@ -182,7 +182,7 @@ class BookFinder {
if (provider === 'google') {
return this.getGoogleBooksResults(title, author)
} else if (provider === 'audible') {
return this.getAudibleResults(title, author)
return this.getAudibleResults(title, author, asin)
} else if (provider === 'itunes') {
return this.getiTunesAudiobooksResults(title, author)
} else if (provider === 'libgen') {

5
server/libs/isJs.js Normal file
View File

File diff suppressed because one or more lines are too long

174
server/libs/requestIp.js Normal file
View File

@@ -0,0 +1,174 @@
// SOURCE: https://github.com/pbojinov/request-ip
"use strict";
function _typeof(obj) { if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") { _typeof = function _typeof(obj) { return typeof obj; }; } else { _typeof = function _typeof(obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; } return _typeof(obj); }
var is = require('./isJs');
/**
* Parse x-forwarded-for headers.
*
* @param {string} value - The value to be parsed.
* @return {string|null} First known IP address, if any.
*/
function getClientIpFromXForwardedFor(value) {
if (!is.existy(value)) {
return null;
}
if (is.not.string(value)) {
throw new TypeError("Expected a string, got \"".concat(_typeof(value), "\""));
} // x-forwarded-for may return multiple IP addresses in the format:
// "client IP, proxy 1 IP, proxy 2 IP"
// Therefore, the right-most IP address is the IP address of the most recent proxy
// and the left-most IP address is the IP address of the originating client.
// source: http://docs.aws.amazon.com/elasticloadbalancing/latest/classic/x-forwarded-headers.html
// Azure Web App's also adds a port for some reason, so we'll only use the first part (the IP)
var forwardedIps = value.split(',').map(function (e) {
var ip = e.trim();
if (ip.includes(':')) {
var splitted = ip.split(':'); // make sure we only use this if it's ipv4 (ip:port)
if (splitted.length === 2) {
return splitted[0];
}
}
return ip;
}); // Sometimes IP addresses in this header can be 'unknown' (http://stackoverflow.com/a/11285650).
// Therefore taking the left-most IP address that is not unknown
// A Squid configuration directive can also set the value to "unknown" (http://www.squid-cache.org/Doc/config/forwarded_for/)
return forwardedIps.find(is.ip);
}
/**
* Determine client IP address.
*
* @param req
* @returns {string} ip - The IP address if known, defaulting to empty string if unknown.
*/
function getClientIp(req) {
// Server is probably behind a proxy.
if (req.headers) {
// Standard headers used by Amazon EC2, Heroku, and others.
if (is.ip(req.headers['x-client-ip'])) {
return req.headers['x-client-ip'];
} // Load-balancers (AWS ELB) or proxies.
var xForwardedFor = getClientIpFromXForwardedFor(req.headers['x-forwarded-for']);
if (is.ip(xForwardedFor)) {
return xForwardedFor;
} // Cloudflare.
// @see https://support.cloudflare.com/hc/en-us/articles/200170986-How-does-Cloudflare-handle-HTTP-Request-headers-
// CF-Connecting-IP - applied to every request to the origin.
if (is.ip(req.headers['cf-connecting-ip'])) {
return req.headers['cf-connecting-ip'];
} // Fastly and Firebase hosting header (When forwared to cloud function)
if (is.ip(req.headers['fastly-client-ip'])) {
return req.headers['fastly-client-ip'];
} // Akamai and Cloudflare: True-Client-IP.
if (is.ip(req.headers['true-client-ip'])) {
return req.headers['true-client-ip'];
} // Default nginx proxy/fcgi; alternative to x-forwarded-for, used by some proxies.
if (is.ip(req.headers['x-real-ip'])) {
return req.headers['x-real-ip'];
} // (Rackspace LB and Riverbed's Stingray)
// http://www.rackspace.com/knowledge_center/article/controlling-access-to-linux-cloud-sites-based-on-the-client-ip-address
// https://splash.riverbed.com/docs/DOC-1926
if (is.ip(req.headers['x-cluster-client-ip'])) {
return req.headers['x-cluster-client-ip'];
}
if (is.ip(req.headers['x-forwarded'])) {
return req.headers['x-forwarded'];
}
if (is.ip(req.headers['forwarded-for'])) {
return req.headers['forwarded-for'];
}
if (is.ip(req.headers.forwarded)) {
return req.headers.forwarded;
}
} // Remote address checks.
if (is.existy(req.connection)) {
if (is.ip(req.connection.remoteAddress)) {
return req.connection.remoteAddress;
}
if (is.existy(req.connection.socket) && is.ip(req.connection.socket.remoteAddress)) {
return req.connection.socket.remoteAddress;
}
}
if (is.existy(req.socket) && is.ip(req.socket.remoteAddress)) {
return req.socket.remoteAddress;
}
if (is.existy(req.info) && is.ip(req.info.remoteAddress)) {
return req.info.remoteAddress;
} // AWS Api Gateway + Lambda
if (is.existy(req.requestContext) && is.existy(req.requestContext.identity) && is.ip(req.requestContext.identity.sourceIp)) {
return req.requestContext.identity.sourceIp;
}
return null;
}
/**
* Expose request IP as a middleware.
*
* @param {object} [options] - Configuration.
* @param {string} [options.attributeName] - Name of attribute to augment request object with.
* @return {*}
*/
function mw(options) {
// Defaults.
var configuration = is.not.existy(options) ? {} : options; // Validation.
if (is.not.object(configuration)) {
throw new TypeError('Options must be an object!');
}
var attributeName = configuration.attributeName || 'clientIp';
return function (req, res, next) {
var ip = getClientIp(req);
Object.defineProperty(req, attributeName, {
get: function get() {
return ip;
},
configurable: true
});
next();
};
}
module.exports = {
getClientIpFromXForwardedFor: getClientIpFromXForwardedFor,
getClientIp: getClientIp,
mw: mw
};

874
server/libs/sanitizeHtml.js Normal file
View File

@@ -0,0 +1,874 @@
/*
sanitize-html (Apostrophe Technologies)
SOURCE: https://github.com/apostrophecms/sanitize-html
LICENSE: https://github.com/apostrophecms/sanitize-html/blob/main/LICENSE
Modified for audiobookshelf
*/
const htmlparser = require('htmlparser2');
// const escapeStringRegexp = require('escape-string-regexp');
// const { isPlainObject } = require('is-plain-object');
// const deepmerge = require('deepmerge');
// const parseSrcset = require('parse-srcset');
// const { parse: postcssParse } = require('postcss');
// Tags that can conceivably represent stand-alone media.
// ABS UPDATE: Packages not necessary
// SOURCE: https://github.com/sindresorhus/escape-string-regexp/blob/main/index.js
function escapeStringRegexp(string) {
if (typeof string !== 'string') {
throw new TypeError('Expected a string');
}
// Escape characters with special meaning either inside or outside character sets.
// Use a simple backslash escape when its always valid, and a `\xnn` escape when the simpler form would be disallowed by Unicode patterns stricter grammar.
return string
.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&')
.replace(/-/g, '\\x2d');
}
// SOURCE: https://github.com/jonschlinkert/is-plain-object/blob/master/is-plain-object.js
function isObject(o) {
return Object.prototype.toString.call(o) === '[object Object]';
}
function isPlainObject(o) {
var ctor, prot;
if (isObject(o) === false) return false;
// If has modified constructor
ctor = o.constructor;
if (ctor === undefined) return true;
// If has modified prototype
prot = ctor.prototype;
if (isObject(prot) === false) return false;
// If constructor does not have an Object-specific method
if (prot.hasOwnProperty('isPrototypeOf') === false) {
return false;
}
// Most likely a plain Object
return true;
};
const mediaTags = [
'img', 'audio', 'video', 'picture', 'svg',
'object', 'map', 'iframe', 'embed'
];
// Tags that are inherently vulnerable to being used in XSS attacks.
const vulnerableTags = ['script', 'style'];
function each(obj, cb) {
if (obj) {
Object.keys(obj).forEach(function (key) {
cb(obj[key], key);
});
}
}
// Avoid false positives with .__proto__, .hasOwnProperty, etc.
function has(obj, key) {
return ({}).hasOwnProperty.call(obj, key);
}
// Returns those elements of `a` for which `cb(a)` returns truthy
function filter(a, cb) {
const n = [];
each(a, function (v) {
if (cb(v)) {
n.push(v);
}
});
return n;
}
function isEmptyObject(obj) {
for (const key in obj) {
if (has(obj, key)) {
return false;
}
}
return true;
}
function stringifySrcset(parsedSrcset) {
return parsedSrcset.map(function (part) {
if (!part.url) {
throw new Error('URL missing');
}
return (
part.url +
(part.w ? ` ${part.w}w` : '') +
(part.h ? ` ${part.h}h` : '') +
(part.d ? ` ${part.d}x` : '')
);
}).join(', ');
}
module.exports = sanitizeHtml;
// A valid attribute name.
// We use a tolerant definition based on the set of strings defined by
// html.spec.whatwg.org/multipage/parsing.html#before-attribute-name-state
// and html.spec.whatwg.org/multipage/parsing.html#attribute-name-state .
// The characters accepted are ones which can be appended to the attribute
// name buffer without triggering a parse error:
// * unexpected-equals-sign-before-attribute-name
// * unexpected-null-character
// * unexpected-character-in-attribute-name
// We exclude the empty string because it's impossible to get to the after
// attribute name state with an empty attribute name buffer.
const VALID_HTML_ATTRIBUTE_NAME = /^[^\0\t\n\f\r /<=>]+$/;
// Ignore the _recursing flag; it's there for recursive
// invocation as a guard against this exploit:
// https://github.com/fb55/htmlparser2/issues/105
function sanitizeHtml(html, options, _recursing) {
if (html == null) {
return '';
}
let result = '';
// Used for hot swapping the result variable with an empty string in order to "capture" the text written to it.
let tempResult = '';
function Frame(tag, attribs) {
const that = this;
this.tag = tag;
this.attribs = attribs || {};
this.tagPosition = result.length;
this.text = ''; // Node inner text
this.mediaChildren = [];
this.updateParentNodeText = function () {
if (stack.length) {
const parentFrame = stack[stack.length - 1];
parentFrame.text += that.text;
}
};
this.updateParentNodeMediaChildren = function () {
if (stack.length && mediaTags.includes(this.tag)) {
const parentFrame = stack[stack.length - 1];
parentFrame.mediaChildren.push(this.tag);
}
};
}
options = Object.assign({}, sanitizeHtml.defaults, options);
options.parser = Object.assign({}, htmlParserDefaults, options.parser);
// vulnerableTags
vulnerableTags.forEach(function (tag) {
if (
options.allowedTags && options.allowedTags.indexOf(tag) > -1 &&
!options.allowVulnerableTags
) {
console.warn(`\n\n⚠️ Your \`allowedTags\` option includes, \`${tag}\`, which is inherently\nvulnerable to XSS attacks. Please remove it from \`allowedTags\`.\nOr, to disable this warning, add the \`allowVulnerableTags\` option\nand ensure you are accounting for this risk.\n\n`);
}
});
// Tags that contain something other than HTML, or where discarding
// the text when the tag is disallowed makes sense for other reasons.
// If we are not allowing these tags, we should drop their content too.
// For other tags you would drop the tag but keep its content.
const nonTextTagsArray = options.nonTextTags || [
'script',
'style',
'textarea',
'option'
];
let allowedAttributesMap;
let allowedAttributesGlobMap;
if (options.allowedAttributes) {
allowedAttributesMap = {};
allowedAttributesGlobMap = {};
each(options.allowedAttributes, function (attributes, tag) {
allowedAttributesMap[tag] = [];
const globRegex = [];
attributes.forEach(function (obj) {
if (typeof obj === 'string' && obj.indexOf('*') >= 0) {
globRegex.push(escapeStringRegexp(obj).replace(/\\\*/g, '.*'));
} else {
allowedAttributesMap[tag].push(obj);
}
});
if (globRegex.length) {
allowedAttributesGlobMap[tag] = new RegExp('^(' + globRegex.join('|') + ')$');
}
});
}
const allowedClassesMap = {};
const allowedClassesGlobMap = {};
const allowedClassesRegexMap = {};
each(options.allowedClasses, function (classes, tag) {
// Implicitly allows the class attribute
if (allowedAttributesMap) {
if (!has(allowedAttributesMap, tag)) {
allowedAttributesMap[tag] = [];
}
allowedAttributesMap[tag].push('class');
}
allowedClassesMap[tag] = [];
allowedClassesRegexMap[tag] = [];
const globRegex = [];
classes.forEach(function (obj) {
if (typeof obj === 'string' && obj.indexOf('*') >= 0) {
globRegex.push(escapeStringRegexp(obj).replace(/\\\*/g, '.*'));
} else if (obj instanceof RegExp) {
allowedClassesRegexMap[tag].push(obj);
} else {
allowedClassesMap[tag].push(obj);
}
});
if (globRegex.length) {
allowedClassesGlobMap[tag] = new RegExp('^(' + globRegex.join('|') + ')$');
}
});
const transformTagsMap = {};
let transformTagsAll;
each(options.transformTags, function (transform, tag) {
let transFun;
if (typeof transform === 'function') {
transFun = transform;
} else if (typeof transform === 'string') {
transFun = sanitizeHtml.simpleTransform(transform);
}
if (tag === '*') {
transformTagsAll = transFun;
} else {
transformTagsMap[tag] = transFun;
}
});
let depth;
let stack;
let skipMap;
let transformMap;
let skipText;
let skipTextDepth;
let addedText = false;
initializeState();
const parser = new htmlparser.Parser({
onopentag: function (name, attribs) {
// If `enforceHtmlBoundary` is `true` and this has found the opening
// `html` tag, reset the state.
if (options.enforceHtmlBoundary && name === 'html') {
initializeState();
}
if (skipText) {
skipTextDepth++;
return;
}
const frame = new Frame(name, attribs);
stack.push(frame);
let skip = false;
const hasText = !!frame.text;
let transformedTag;
if (has(transformTagsMap, name)) {
transformedTag = transformTagsMap[name](name, attribs);
frame.attribs = attribs = transformedTag.attribs;
if (transformedTag.text !== undefined) {
frame.innerText = transformedTag.text;
}
if (name !== transformedTag.tagName) {
frame.name = name = transformedTag.tagName;
transformMap[depth] = transformedTag.tagName;
}
}
if (transformTagsAll) {
transformedTag = transformTagsAll(name, attribs);
frame.attribs = attribs = transformedTag.attribs;
if (name !== transformedTag.tagName) {
frame.name = name = transformedTag.tagName;
transformMap[depth] = transformedTag.tagName;
}
}
if ((options.allowedTags && options.allowedTags.indexOf(name) === -1) || (options.disallowedTagsMode === 'recursiveEscape' && !isEmptyObject(skipMap)) || (options.nestingLimit != null && depth >= options.nestingLimit)) {
skip = true;
skipMap[depth] = true;
if (options.disallowedTagsMode === 'discard') {
if (nonTextTagsArray.indexOf(name) !== -1) {
skipText = true;
skipTextDepth = 1;
}
}
skipMap[depth] = true;
}
depth++;
if (skip) {
if (options.disallowedTagsMode === 'discard') {
// We want the contents but not this tag
return;
}
tempResult = result;
result = '';
}
result += '<' + name;
if (name === 'script') {
if (options.allowedScriptHostnames || options.allowedScriptDomains) {
frame.innerText = '';
}
}
if (!allowedAttributesMap || has(allowedAttributesMap, name) || allowedAttributesMap['*']) {
each(attribs, function (value, a) {
if (!VALID_HTML_ATTRIBUTE_NAME.test(a)) {
// This prevents part of an attribute name in the output from being
// interpreted as the end of an attribute, or end of a tag.
delete frame.attribs[a];
return;
}
let parsed;
// check allowedAttributesMap for the element and attribute and modify the value
// as necessary if there are specific values defined.
let passedAllowedAttributesMapCheck = false;
if (!allowedAttributesMap ||
(has(allowedAttributesMap, name) && allowedAttributesMap[name].indexOf(a) !== -1) ||
(allowedAttributesMap['*'] && allowedAttributesMap['*'].indexOf(a) !== -1) ||
(has(allowedAttributesGlobMap, name) && allowedAttributesGlobMap[name].test(a)) ||
(allowedAttributesGlobMap['*'] && allowedAttributesGlobMap['*'].test(a))) {
passedAllowedAttributesMapCheck = true;
} else if (allowedAttributesMap && allowedAttributesMap[name]) {
for (const o of allowedAttributesMap[name]) {
if (isPlainObject(o) && o.name && (o.name === a)) {
passedAllowedAttributesMapCheck = true;
let newValue = '';
if (o.multiple === true) {
// verify the values that are allowed
const splitStrArray = value.split(' ');
for (const s of splitStrArray) {
if (o.values.indexOf(s) !== -1) {
if (newValue === '') {
newValue = s;
} else {
newValue += ' ' + s;
}
}
}
} else if (o.values.indexOf(value) >= 0) {
// verified an allowed value matches the entire attribute value
newValue = value;
}
value = newValue;
}
}
}
if (passedAllowedAttributesMapCheck) {
if (options.allowedSchemesAppliedToAttributes.indexOf(a) !== -1) {
if (naughtyHref(name, value)) {
delete frame.attribs[a];
return;
}
}
if (name === 'script' && a === 'src') {
let allowed = true;
try {
const parsed = new URL(value);
if (options.allowedScriptHostnames || options.allowedScriptDomains) {
const allowedHostname = (options.allowedScriptHostnames || []).find(function (hostname) {
return hostname === parsed.hostname;
});
const allowedDomain = (options.allowedScriptDomains || []).find(function (domain) {
return parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`);
});
allowed = allowedHostname || allowedDomain;
}
} catch (e) {
allowed = false;
}
if (!allowed) {
delete frame.attribs[a];
return;
}
}
if (name === 'iframe' && a === 'src') {
let allowed = true;
try {
// Chrome accepts \ as a substitute for / in the // at the
// start of a URL, so rewrite accordingly to prevent exploit.
// Also drop any whitespace at that point in the URL
value = value.replace(/^(\w+:)?\s*[\\/]\s*[\\/]/, '$1//');
if (value.startsWith('relative:')) {
// An attempt to exploit our workaround for base URLs being
// mandatory for relative URL validation in the WHATWG
// URL parser, reject it
throw new Error('relative: exploit attempt');
}
// naughtyHref is in charge of whether protocol relative URLs
// are cool. Here we are concerned just with allowed hostnames and
// whether to allow relative URLs.
//
// Build a placeholder "base URL" against which any reasonable
// relative URL may be parsed successfully
let base = 'relative://relative-site';
for (let i = 0; (i < 100); i++) {
base += `/${i}`;
}
const parsed = new URL(value, base);
const isRelativeUrl = parsed && parsed.hostname === 'relative-site' && parsed.protocol === 'relative:';
if (isRelativeUrl) {
// default value of allowIframeRelativeUrls is true
// unless allowedIframeHostnames or allowedIframeDomains specified
allowed = has(options, 'allowIframeRelativeUrls')
? options.allowIframeRelativeUrls
: (!options.allowedIframeHostnames && !options.allowedIframeDomains);
} else if (options.allowedIframeHostnames || options.allowedIframeDomains) {
const allowedHostname = (options.allowedIframeHostnames || []).find(function (hostname) {
return hostname === parsed.hostname;
});
const allowedDomain = (options.allowedIframeDomains || []).find(function (domain) {
return parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`);
});
allowed = allowedHostname || allowedDomain;
}
} catch (e) {
// Unparseable iframe src
allowed = false;
}
if (!allowed) {
delete frame.attribs[a];
return;
}
}
if (a === 'srcset') {
delete frame.attribs[a];
// ABS UPDATE: srcset not necessary
// try {
// parsed = parseSrcset(value);
// parsed.forEach(function (value) {
// if (naughtyHref('srcset', value.url)) {
// value.evil = true;
// }
// });
// parsed = filter(parsed, function (v) {
// return !v.evil;
// });
// if (!parsed.length) {
// delete frame.attribs[a];
// return;
// } else {
// value = stringifySrcset(filter(parsed, function (v) {
// return !v.evil;
// }));
// frame.attribs[a] = value;
// }
// } catch (e) {
// // Unparseable srcset
// delete frame.attribs[a];
// return;
// }
}
if (a === 'class') {
const allowedSpecificClasses = allowedClassesMap[name];
const allowedWildcardClasses = allowedClassesMap['*'];
const allowedSpecificClassesGlob = allowedClassesGlobMap[name];
const allowedSpecificClassesRegex = allowedClassesRegexMap[name];
const allowedWildcardClassesGlob = allowedClassesGlobMap['*'];
const allowedClassesGlobs = [
allowedSpecificClassesGlob,
allowedWildcardClassesGlob
]
.concat(allowedSpecificClassesRegex)
.filter(function (t) {
return t;
});
if (allowedSpecificClasses && allowedWildcardClasses) {
// ABS UPDATE: classes and wildcard classes not necessary now
// value = filterClasses(value, deepmerge(allowedSpecificClasses, allowedWildcardClasses), allowedClassesGlobs);
} else {
value = filterClasses(value, allowedSpecificClasses || allowedWildcardClasses, allowedClassesGlobs);
}
if (!value.length) {
delete frame.attribs[a];
return;
}
}
if (a === 'style') {
delete frame.attribs[a];
// ABS UPDATE: Styles not necessary
// try {
// const abstractSyntaxTree = postcssParse(name + ' {' + value + '}');
// const filteredAST = filterCss(abstractSyntaxTree, options.allowedStyles);
// value = stringifyStyleAttributes(filteredAST);
// if (value.length === 0) {
// delete frame.attribs[a];
// return;
// }
// } catch (e) {
// delete frame.attribs[a];
// return;
// }
}
result += ' ' + a;
if (value && value.length) {
result += '="' + escapeHtml(value, true) + '"';
}
} else {
delete frame.attribs[a];
}
});
}
if (options.selfClosing.indexOf(name) !== -1) {
result += ' />';
} else {
result += '>';
if (frame.innerText && !hasText && !options.textFilter) {
result += escapeHtml(frame.innerText);
addedText = true;
}
}
if (skip) {
result = tempResult + escapeHtml(result);
tempResult = '';
}
},
ontext: function (text) {
if (skipText) {
return;
}
const lastFrame = stack[stack.length - 1];
let tag;
if (lastFrame) {
tag = lastFrame.tag;
// If inner text was set by transform function then let's use it
text = lastFrame.innerText !== undefined ? lastFrame.innerText : text;
}
if (options.disallowedTagsMode === 'discard' && ((tag === 'script') || (tag === 'style'))) {
// htmlparser2 gives us these as-is. Escaping them ruins the content. Allowing
// script tags is, by definition, game over for XSS protection, so if that's
// your concern, don't allow them. The same is essentially true for style tags
// which have their own collection of XSS vectors.
result += text;
} else {
const escaped = escapeHtml(text, false);
if (options.textFilter && !addedText) {
result += options.textFilter(escaped, tag);
} else if (!addedText) {
result += escaped;
}
}
if (stack.length) {
const frame = stack[stack.length - 1];
frame.text += text;
}
},
onclosetag: function (name) {
if (skipText) {
skipTextDepth--;
if (!skipTextDepth) {
skipText = false;
} else {
return;
}
}
const frame = stack.pop();
if (!frame) {
// Do not crash on bad markup
return;
}
skipText = options.enforceHtmlBoundary ? name === 'html' : false;
depth--;
const skip = skipMap[depth];
if (skip) {
delete skipMap[depth];
if (options.disallowedTagsMode === 'discard') {
frame.updateParentNodeText();
return;
}
tempResult = result;
result = '';
}
if (transformMap[depth]) {
name = transformMap[depth];
delete transformMap[depth];
}
if (options.exclusiveFilter && options.exclusiveFilter(frame)) {
result = result.substr(0, frame.tagPosition);
return;
}
frame.updateParentNodeMediaChildren();
frame.updateParentNodeText();
if (options.selfClosing.indexOf(name) !== -1) {
// Already output />
if (skip) {
result = tempResult;
tempResult = '';
}
return;
}
result += '</' + name + '>';
if (skip) {
result = tempResult + escapeHtml(result);
tempResult = '';
}
addedText = false;
}
}, options.parser);
parser.write(html);
parser.end();
return result;
function initializeState() {
result = '';
depth = 0;
stack = [];
skipMap = {};
transformMap = {};
skipText = false;
skipTextDepth = 0;
}
function escapeHtml(s, quote) {
if (typeof (s) !== 'string') {
s = s + '';
}
if (options.parser.decodeEntities) {
s = s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
if (quote) {
s = s.replace(/"/g, '&quot;');
}
}
// TODO: this is inadequate because it will pass `&0;`. This approach
// will not work, each & must be considered with regard to whether it
// is followed by a 100% syntactically valid entity or not, and escaped
// if it is not. If this bothers you, don't set parser.decodeEntities
// to false. (The default is true.)
s = s.replace(/&(?![a-zA-Z0-9#]{1,20};)/g, '&amp;') // Match ampersands not part of existing HTML entity
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
if (quote) {
s = s.replace(/"/g, '&quot;');
}
return s;
}
function naughtyHref(name, href) {
// Browsers ignore character codes of 32 (space) and below in a surprising
// number of situations. Start reading here:
// https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#Embedded_tab
// eslint-disable-next-line no-control-regex
href = href.replace(/[\x00-\x20]+/g, '');
// Clobber any comments in URLs, which the browser might
// interpret inside an XML data island, allowing
// a javascript: URL to be snuck through
href = href.replace(/<!--.*?-->/g, '');
// Case insensitive so we don't get faked out by JAVASCRIPT #1
// Allow more characters after the first so we don't get faked
// out by certain schemes browsers accept
const matches = href.match(/^([a-zA-Z][a-zA-Z0-9.\-+]*):/);
if (!matches) {
// Protocol-relative URL starting with any combination of '/' and '\'
if (href.match(/^[/\\]{2}/)) {
return !options.allowProtocolRelative;
}
// No scheme
return false;
}
const scheme = matches[1].toLowerCase();
if (has(options.allowedSchemesByTag, name)) {
return options.allowedSchemesByTag[name].indexOf(scheme) === -1;
}
return !options.allowedSchemes || options.allowedSchemes.indexOf(scheme) === -1;
}
/**
* Filters user input css properties by allowlisted regex attributes.
* Modifies the abstractSyntaxTree object.
*
* @param {object} abstractSyntaxTree - Object representation of CSS attributes.
* @property {array[Declaration]} abstractSyntaxTree.nodes[0] - Each object cointains prop and value key, i.e { prop: 'color', value: 'red' }.
* @param {object} allowedStyles - Keys are properties (i.e color), value is list of permitted regex rules (i.e /green/i).
* @return {object} - The modified tree.
*/
// function filterCss(abstractSyntaxTree, allowedStyles) {
// if (!allowedStyles) {
// return abstractSyntaxTree;
// }
// const astRules = abstractSyntaxTree.nodes[0];
// let selectedRule;
// // Merge global and tag-specific styles into new AST.
// if (allowedStyles[astRules.selector] && allowedStyles['*']) {
// selectedRule = deepmerge(
// allowedStyles[astRules.selector],
// allowedStyles['*']
// );
// } else {
// selectedRule = allowedStyles[astRules.selector] || allowedStyles['*'];
// }
// if (selectedRule) {
// abstractSyntaxTree.nodes[0].nodes = astRules.nodes.reduce(filterDeclarations(selectedRule), []);
// }
// return abstractSyntaxTree;
// }
/**
* Extracts the style attributes from an AbstractSyntaxTree and formats those
* values in the inline style attribute format.
*
* @param {AbstractSyntaxTree} filteredAST
* @return {string} - Example: "color:yellow;text-align:center !important;font-family:helvetica;"
*/
function stringifyStyleAttributes(filteredAST) {
return filteredAST.nodes[0].nodes
.reduce(function (extractedAttributes, attrObject) {
extractedAttributes.push(
`${attrObject.prop}:${attrObject.value}${attrObject.important ? ' !important' : ''}`
);
return extractedAttributes;
}, [])
.join(';');
}
/**
* Filters the existing attributes for the given property. Discards any attributes
* which don't match the allowlist.
*
* @param {object} selectedRule - Example: { color: red, font-family: helvetica }
* @param {array} allowedDeclarationsList - List of declarations which pass the allowlist.
* @param {object} attributeObject - Object representing the current css property.
* @property {string} attributeObject.type - Typically 'declaration'.
* @property {string} attributeObject.prop - The CSS property, i.e 'color'.
* @property {string} attributeObject.value - The corresponding value to the css property, i.e 'red'.
* @return {function} - When used in Array.reduce, will return an array of Declaration objects
*/
function filterDeclarations(selectedRule) {
return function (allowedDeclarationsList, attributeObject) {
// If this property is allowlisted...
if (has(selectedRule, attributeObject.prop)) {
const matchesRegex = selectedRule[attributeObject.prop].some(function (regularExpression) {
return regularExpression.test(attributeObject.value);
});
if (matchesRegex) {
allowedDeclarationsList.push(attributeObject);
}
}
return allowedDeclarationsList;
};
}
function filterClasses(classes, allowed, allowedGlobs) {
if (!allowed) {
// The class attribute is allowed without filtering on this tag
return classes;
}
classes = classes.split(/\s+/);
return classes.filter(function (clss) {
return allowed.indexOf(clss) !== -1 || allowedGlobs.some(function (glob) {
return glob.test(clss);
});
}).join(' ');
}
}
// Defaults are accessible to you so that you can use them as a starting point
// programmatically if you wish
const htmlParserDefaults = {
decodeEntities: true
};
sanitizeHtml.defaults = {
allowedTags: [
// Sections derived from MDN element categories and limited to the more
// benign categories.
// https://developer.mozilla.org/en-US/docs/Web/HTML/Element
// Content sectioning
'address', 'article', 'aside', 'footer', 'header',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hgroup',
'main', 'nav', 'section',
// Text content
'blockquote', 'dd', 'div', 'dl', 'dt', 'figcaption', 'figure',
'hr', 'li', 'main', 'ol', 'p', 'pre', 'ul',
// Inline text semantics
'a', 'abbr', 'b', 'bdi', 'bdo', 'br', 'cite', 'code', 'data', 'dfn',
'em', 'i', 'kbd', 'mark', 'q',
'rb', 'rp', 'rt', 'rtc', 'ruby',
's', 'samp', 'small', 'span', 'strong', 'sub', 'sup', 'time', 'u', 'var', 'wbr',
// Table content
'caption', 'col', 'colgroup', 'table', 'tbody', 'td', 'tfoot', 'th',
'thead', 'tr'
],
disallowedTagsMode: 'discard',
allowedAttributes: {
a: ['href', 'name', 'target'],
// We don't currently allow img itself by default, but
// these attributes would make sense if we did.
img: ['src', 'srcset', 'alt', 'title', 'width', 'height', 'loading']
},
// Lots of these won't come up by default because we don't allow them
selfClosing: ['img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta'],
// URL schemes we permit
allowedSchemes: ['http', 'https', 'ftp', 'mailto', 'tel'],
allowedSchemesByTag: {},
allowedSchemesAppliedToAttributes: ['href', 'src', 'cite'],
allowProtocolRelative: true,
enforceHtmlBoundary: false
};
sanitizeHtml.simpleTransform = function (newTagName, newAttribs, merge) {
merge = (merge === undefined) ? true : merge;
newAttribs = newAttribs || {};
return function (tagName, attribs) {
let attrib;
if (merge) {
for (attrib in newAttribs) {
attribs[attrib] = newAttribs[attrib];
}
} else {
attribs = newAttribs;
}
return {
tagName: newTagName,
attribs: attribs
};
};
};

View File

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,19 @@
"use strict";
/* IMPORT */
var signal_1 = require("./signal");
/* ABORT CONTROLLER */
var AbortController = /** @class */ (function () {
function AbortController() {
/* VARIABLES */
this.signal = new signal_1.default();
}
/* API */
AbortController.prototype.abort = function () {
return this.signal.abort();
};
return AbortController;
}());
/* EXPORT */
module.exports = AbortController;
module.exports.default = AbortController;
Object.defineProperty(module.exports, "__esModule", { value: true });

View File

@@ -0,0 +1,39 @@
"use strict";
/* IMPORT */
Object.defineProperty(exports, "__esModule", { value: true });
/* ABORT SIGNAL */
var AbortSignal = /** @class */ (function () {
function AbortSignal() {
/* VARIABLES */
this.aborted = false;
this.listeners = {};
}
/* EVENTS API */
AbortSignal.prototype.addEventListener = function (event, listener) {
var listeners = this.listeners[event] || (this.listeners[event] = []);
listeners.push(listener);
};
AbortSignal.prototype.removeEventListener = function (event, listener) {
var listeners = this.listeners[event];
if (!listeners)
return;
listeners.splice(listeners.indexOf(listener), 1);
};
AbortSignal.prototype.dispatchEvent = function (event) {
var listeners = this.listeners[event];
if (!listeners)
return true;
listeners.slice().forEach(function (listener) { return listener(); });
return true;
};
/* API */
AbortSignal.prototype.abort = function () {
if (this.aborted)
return;
this.aborted = true;
this.dispatchEvent('abort');
};
return AbortSignal;
}());
/* EXPORT */
exports.default = AbortSignal;

View File

@@ -0,0 +1,24 @@
"use strict";
/* IMPORT */
var isPrimitive = require("./is-primitive");
/* ARE SHALLOW EQUAL */
var isNaN = Number.isNaN;
function areShallowEqual(x, y) {
if (x === y)
return true;
if (isNaN(x))
return isNaN(y);
if (isPrimitive(x) || isPrimitive(y))
return x === y;
for (var i in x)
if (!(i in y))
return false;
for (var i in y)
if (x[i] !== y[i])
return false;
return true;
}
/* EXPORT */
module.exports = areShallowEqual;
module.exports.default = areShallowEqual;
Object.defineProperty(module.exports, "__esModule", { value: true });

View File

@@ -0,0 +1,28 @@
"use strict";
/* CONSTS */
Object.defineProperty(exports, "__esModule", { value: true });
exports.NOOP = exports.LIMIT_FILES_DESCRIPTORS = exports.LIMIT_BASENAME_LENGTH = exports.IS_USER_ROOT = exports.IS_POSIX = exports.DEFAULT_TIMEOUT_SYNC = exports.DEFAULT_TIMEOUT_ASYNC = exports.DEFAULT_WRITE_OPTIONS = exports.DEFAULT_READ_OPTIONS = exports.DEFAULT_FOLDER_MODE = exports.DEFAULT_FILE_MODE = exports.DEFAULT_ENCODING = void 0;
const DEFAULT_ENCODING = 'utf8';
exports.DEFAULT_ENCODING = DEFAULT_ENCODING;
const DEFAULT_FILE_MODE = 0o666;
exports.DEFAULT_FILE_MODE = DEFAULT_FILE_MODE;
const DEFAULT_FOLDER_MODE = 0o777;
exports.DEFAULT_FOLDER_MODE = DEFAULT_FOLDER_MODE;
const DEFAULT_READ_OPTIONS = {};
exports.DEFAULT_READ_OPTIONS = DEFAULT_READ_OPTIONS;
const DEFAULT_WRITE_OPTIONS = {};
exports.DEFAULT_WRITE_OPTIONS = DEFAULT_WRITE_OPTIONS;
const DEFAULT_TIMEOUT_ASYNC = 5000;
exports.DEFAULT_TIMEOUT_ASYNC = DEFAULT_TIMEOUT_ASYNC;
const DEFAULT_TIMEOUT_SYNC = 100;
exports.DEFAULT_TIMEOUT_SYNC = DEFAULT_TIMEOUT_SYNC;
const IS_POSIX = !!process.getuid;
exports.IS_POSIX = IS_POSIX;
const IS_USER_ROOT = process.getuid ? !process.getuid() : false;
exports.IS_USER_ROOT = IS_USER_ROOT;
const LIMIT_BASENAME_LENGTH = 128; //TODO: fetch the real limit from the filesystem //TODO: fetch the whole-path length limit too
exports.LIMIT_BASENAME_LENGTH = LIMIT_BASENAME_LENGTH;
const LIMIT_FILES_DESCRIPTORS = 10000; //TODO: fetch the real limit from the filesystem
exports.LIMIT_FILES_DESCRIPTORS = LIMIT_FILES_DESCRIPTORS;
const NOOP = () => { };
exports.NOOP = NOOP;

View File

@@ -0,0 +1,177 @@
"use strict";
/* IMPORT */
Object.defineProperty(exports, "__esModule", { value: true });
exports.writeFileSync = exports.writeFile = exports.readFileSync = exports.readFile = void 0;
const path = require("path");
const consts_1 = require("./consts");
const fs_1 = require("./utils/fs");
const lang_1 = require("./utils/lang");
const scheduler_1 = require("./utils/scheduler");
const temp_1 = require("./utils/temp");
function readFile(filePath, options = consts_1.DEFAULT_READ_OPTIONS) {
var _a;
if (lang_1.default.isString(options))
return readFile(filePath, { encoding: options });
const timeout = Date.now() + ((_a = options.timeout) !== null && _a !== void 0 ? _a : consts_1.DEFAULT_TIMEOUT_ASYNC);
return fs_1.default.readFileRetry(timeout)(filePath, options);
}
exports.readFile = readFile;
;
function readFileSync(filePath, options = consts_1.DEFAULT_READ_OPTIONS) {
var _a;
if (lang_1.default.isString(options))
return readFileSync(filePath, { encoding: options });
const timeout = Date.now() + ((_a = options.timeout) !== null && _a !== void 0 ? _a : consts_1.DEFAULT_TIMEOUT_SYNC);
return fs_1.default.readFileSyncRetry(timeout)(filePath, options);
}
exports.readFileSync = readFileSync;
;
const writeFile = (filePath, data, options, callback) => {
if (lang_1.default.isFunction(options))
return writeFile(filePath, data, consts_1.DEFAULT_WRITE_OPTIONS, options);
const promise = writeFileAsync(filePath, data, options);
if (callback)
promise.then(callback, callback);
return promise;
};
exports.writeFile = writeFile;
const writeFileAsync = async (filePath, data, options = consts_1.DEFAULT_WRITE_OPTIONS) => {
var _a;
if (lang_1.default.isString(options))
return writeFileAsync(filePath, data, { encoding: options });
const timeout = Date.now() + ((_a = options.timeout) !== null && _a !== void 0 ? _a : consts_1.DEFAULT_TIMEOUT_ASYNC);
let schedulerCustomDisposer = null, schedulerDisposer = null, tempDisposer = null, tempPath = null, fd = null;
try {
if (options.schedule)
schedulerCustomDisposer = await options.schedule(filePath);
schedulerDisposer = await scheduler_1.default.schedule(filePath);
filePath = await fs_1.default.realpathAttempt(filePath) || filePath;
[tempPath, tempDisposer] = temp_1.default.get(filePath, options.tmpCreate || temp_1.default.create, !(options.tmpPurge === false));
const useStatChown = consts_1.IS_POSIX && lang_1.default.isUndefined(options.chown), useStatMode = lang_1.default.isUndefined(options.mode);
if (useStatChown || useStatMode) {
const stat = await fs_1.default.statAttempt(filePath);
if (stat) {
options = { ...options };
if (useStatChown)
options.chown = { uid: stat.uid, gid: stat.gid };
if (useStatMode)
options.mode = stat.mode;
}
}
const parentPath = path.dirname(filePath);
await fs_1.default.mkdirAttempt(parentPath, {
mode: consts_1.DEFAULT_FOLDER_MODE,
recursive: true
});
fd = await fs_1.default.openRetry(timeout)(tempPath, 'w', options.mode || consts_1.DEFAULT_FILE_MODE);
if (options.tmpCreated)
options.tmpCreated(tempPath);
if (lang_1.default.isString(data)) {
await fs_1.default.writeRetry(timeout)(fd, data, 0, options.encoding || consts_1.DEFAULT_ENCODING);
}
else if (!lang_1.default.isUndefined(data)) {
await fs_1.default.writeRetry(timeout)(fd, data, 0, data.length, 0);
}
if (options.fsync !== false) {
if (options.fsyncWait !== false) {
await fs_1.default.fsyncRetry(timeout)(fd);
}
else {
fs_1.default.fsyncAttempt(fd);
}
}
await fs_1.default.closeRetry(timeout)(fd);
fd = null;
if (options.chown)
await fs_1.default.chownAttempt(tempPath, options.chown.uid, options.chown.gid);
if (options.mode)
await fs_1.default.chmodAttempt(tempPath, options.mode);
try {
await fs_1.default.renameRetry(timeout)(tempPath, filePath);
}
catch (error) {
if (error.code !== 'ENAMETOOLONG')
throw error;
await fs_1.default.renameRetry(timeout)(tempPath, temp_1.default.truncate(filePath));
}
tempDisposer();
tempPath = null;
}
finally {
if (fd)
await fs_1.default.closeAttempt(fd);
if (tempPath)
temp_1.default.purge(tempPath);
if (schedulerCustomDisposer)
schedulerCustomDisposer();
if (schedulerDisposer)
schedulerDisposer();
}
};
const writeFileSync = (filePath, data, options = consts_1.DEFAULT_WRITE_OPTIONS) => {
var _a;
if (lang_1.default.isString(options))
return writeFileSync(filePath, data, { encoding: options });
const timeout = Date.now() + ((_a = options.timeout) !== null && _a !== void 0 ? _a : consts_1.DEFAULT_TIMEOUT_SYNC);
let tempDisposer = null, tempPath = null, fd = null;
try {
filePath = fs_1.default.realpathSyncAttempt(filePath) || filePath;
[tempPath, tempDisposer] = temp_1.default.get(filePath, options.tmpCreate || temp_1.default.create, !(options.tmpPurge === false));
const useStatChown = consts_1.IS_POSIX && lang_1.default.isUndefined(options.chown), useStatMode = lang_1.default.isUndefined(options.mode);
if (useStatChown || useStatMode) {
const stat = fs_1.default.statSyncAttempt(filePath);
if (stat) {
options = { ...options };
if (useStatChown)
options.chown = { uid: stat.uid, gid: stat.gid };
if (useStatMode)
options.mode = stat.mode;
}
}
const parentPath = path.dirname(filePath);
fs_1.default.mkdirSyncAttempt(parentPath, {
mode: consts_1.DEFAULT_FOLDER_MODE,
recursive: true
});
fd = fs_1.default.openSyncRetry(timeout)(tempPath, 'w', options.mode || consts_1.DEFAULT_FILE_MODE);
if (options.tmpCreated)
options.tmpCreated(tempPath);
if (lang_1.default.isString(data)) {
fs_1.default.writeSyncRetry(timeout)(fd, data, 0, options.encoding || consts_1.DEFAULT_ENCODING);
}
else if (!lang_1.default.isUndefined(data)) {
fs_1.default.writeSyncRetry(timeout)(fd, data, 0, data.length, 0);
}
if (options.fsync !== false) {
if (options.fsyncWait !== false) {
fs_1.default.fsyncSyncRetry(timeout)(fd);
}
else {
fs_1.default.fsyncAttempt(fd);
}
}
fs_1.default.closeSyncRetry(timeout)(fd);
fd = null;
if (options.chown)
fs_1.default.chownSyncAttempt(tempPath, options.chown.uid, options.chown.gid);
if (options.mode)
fs_1.default.chmodSyncAttempt(tempPath, options.mode);
try {
fs_1.default.renameSyncRetry(timeout)(tempPath, filePath);
}
catch (error) {
if (error.code !== 'ENAMETOOLONG')
throw error;
fs_1.default.renameSyncRetry(timeout)(tempPath, temp_1.default.truncate(filePath));
}
tempDisposer();
tempPath = null;
}
finally {
if (fd)
fs_1.default.closeSyncAttempt(fd);
if (tempPath)
temp_1.default.purge(tempPath);
}
};
exports.writeFileSync = writeFileSync;

View File

@@ -0,0 +1,25 @@
"use strict";
/* IMPORT */
Object.defineProperty(exports, "__esModule", { value: true });
exports.attemptifySync = exports.attemptifyAsync = void 0;
const consts_1 = require("../consts");
/* ATTEMPTIFY */
//TODO: Maybe publish this as a standalone package
//FIXME: The type castings here aren't exactly correct
const attemptifyAsync = (fn, onError = consts_1.NOOP) => {
return function () {
return fn.apply(undefined, arguments).catch(onError);
};
};
exports.attemptifyAsync = attemptifyAsync;
const attemptifySync = (fn, onError = consts_1.NOOP) => {
return function () {
try {
return fn.apply(undefined, arguments);
}
catch (error) {
return onError(error);
}
};
};
exports.attemptifySync = attemptifySync;

View File

@@ -0,0 +1,42 @@
"use strict";
/* IMPORT */
Object.defineProperty(exports, "__esModule", { value: true });
const fs = require("fs");
const util_1 = require("util");
const attemptify_1 = require("./attemptify");
const fs_handlers_1 = require("./fs_handlers");
const retryify_1 = require("./retryify");
/* FS */
const FS = {
chmodAttempt: attemptify_1.attemptifyAsync(util_1.promisify(fs.chmod), fs_handlers_1.default.onChangeError),
chownAttempt: attemptify_1.attemptifyAsync(util_1.promisify(fs.chown), fs_handlers_1.default.onChangeError),
closeAttempt: attemptify_1.attemptifyAsync(util_1.promisify(fs.close)),
fsyncAttempt: attemptify_1.attemptifyAsync(util_1.promisify(fs.fsync)),
mkdirAttempt: attemptify_1.attemptifyAsync(util_1.promisify(fs.mkdir)),
realpathAttempt: attemptify_1.attemptifyAsync(util_1.promisify(fs.realpath)),
statAttempt: attemptify_1.attemptifyAsync(util_1.promisify(fs.stat)),
unlinkAttempt: attemptify_1.attemptifyAsync(util_1.promisify(fs.unlink)),
closeRetry: retryify_1.retryifyAsync(util_1.promisify(fs.close), fs_handlers_1.default.isRetriableError),
fsyncRetry: retryify_1.retryifyAsync(util_1.promisify(fs.fsync), fs_handlers_1.default.isRetriableError),
openRetry: retryify_1.retryifyAsync(util_1.promisify(fs.open), fs_handlers_1.default.isRetriableError),
readFileRetry: retryify_1.retryifyAsync(util_1.promisify(fs.readFile), fs_handlers_1.default.isRetriableError),
renameRetry: retryify_1.retryifyAsync(util_1.promisify(fs.rename), fs_handlers_1.default.isRetriableError),
statRetry: retryify_1.retryifyAsync(util_1.promisify(fs.stat), fs_handlers_1.default.isRetriableError),
writeRetry: retryify_1.retryifyAsync(util_1.promisify(fs.write), fs_handlers_1.default.isRetriableError),
chmodSyncAttempt: attemptify_1.attemptifySync(fs.chmodSync, fs_handlers_1.default.onChangeError),
chownSyncAttempt: attemptify_1.attemptifySync(fs.chownSync, fs_handlers_1.default.onChangeError),
closeSyncAttempt: attemptify_1.attemptifySync(fs.closeSync),
mkdirSyncAttempt: attemptify_1.attemptifySync(fs.mkdirSync),
realpathSyncAttempt: attemptify_1.attemptifySync(fs.realpathSync),
statSyncAttempt: attemptify_1.attemptifySync(fs.statSync),
unlinkSyncAttempt: attemptify_1.attemptifySync(fs.unlinkSync),
closeSyncRetry: retryify_1.retryifySync(fs.closeSync, fs_handlers_1.default.isRetriableError),
fsyncSyncRetry: retryify_1.retryifySync(fs.fsyncSync, fs_handlers_1.default.isRetriableError),
openSyncRetry: retryify_1.retryifySync(fs.openSync, fs_handlers_1.default.isRetriableError),
readFileSyncRetry: retryify_1.retryifySync(fs.readFileSync, fs_handlers_1.default.isRetriableError),
renameSyncRetry: retryify_1.retryifySync(fs.renameSync, fs_handlers_1.default.isRetriableError),
statSyncRetry: retryify_1.retryifySync(fs.statSync, fs_handlers_1.default.isRetriableError),
writeSyncRetry: retryify_1.retryifySync(fs.writeSync, fs_handlers_1.default.isRetriableError)
};
/* EXPORT */
exports.default = FS;

View File

@@ -0,0 +1,28 @@
"use strict";
/* IMPORT */
Object.defineProperty(exports, "__esModule", { value: true });
const consts_1 = require("../consts");
/* FS HANDLERS */
const Handlers = {
isChangeErrorOk: (error) => {
const { code } = error;
if (code === 'ENOSYS')
return true;
if (!consts_1.IS_USER_ROOT && (code === 'EINVAL' || code === 'EPERM'))
return true;
return false;
},
isRetriableError: (error) => {
const { code } = error;
if (code === 'EMFILE' || code === 'ENFILE' || code === 'EAGAIN' || code === 'EBUSY' || code === 'EACCESS' || code === 'EACCS' || code === 'EPERM')
return true;
return false;
},
onChangeError: (error) => {
if (Handlers.isChangeErrorOk(error))
return;
throw error;
}
};
/* EXPORT */
exports.default = Handlers;

View File

@@ -0,0 +1,16 @@
"use strict";
/* LANG */
Object.defineProperty(exports, "__esModule", { value: true });
const Lang = {
isFunction: (x) => {
return typeof x === 'function';
},
isString: (x) => {
return typeof x === 'string';
},
isUndefined: (x) => {
return typeof x === 'undefined';
}
};
/* EXPORT */
exports.default = Lang;

View File

@@ -0,0 +1,45 @@
"use strict";
/* IMPORT */
Object.defineProperty(exports, "__esModule", { value: true });
exports.retryifySync = exports.retryifyAsync = void 0;
const retryify_queue_1 = require("./retryify_queue");
/* RETRYIFY */
const retryifyAsync = (fn, isRetriableError) => {
return function (timestamp) {
return function attempt() {
return retryify_queue_1.default.schedule().then(cleanup => {
return fn.apply(undefined, arguments).then(result => {
cleanup();
return result;
}, error => {
cleanup();
if (Date.now() >= timestamp)
throw error;
if (isRetriableError(error)) {
const delay = Math.round(100 + (400 * Math.random())), delayPromise = new Promise(resolve => setTimeout(resolve, delay));
return delayPromise.then(() => attempt.apply(undefined, arguments));
}
throw error;
});
});
};
};
};
exports.retryifyAsync = retryifyAsync;
const retryifySync = (fn, isRetriableError) => {
return function (timestamp) {
return function attempt() {
try {
return fn.apply(undefined, arguments);
}
catch (error) {
if (Date.now() > timestamp)
throw error;
if (isRetriableError(error))
return attempt.apply(undefined, arguments);
throw error;
}
};
};
};
exports.retryifySync = retryifySync;

View File

@@ -0,0 +1,58 @@
"use strict";
/* IMPORT */
Object.defineProperty(exports, "__esModule", { value: true });
const consts_1 = require("../consts");
/* RETRYIFY QUEUE */
const RetryfyQueue = {
interval: 25,
intervalId: undefined,
limit: consts_1.LIMIT_FILES_DESCRIPTORS,
queueActive: new Set(),
queueWaiting: new Set(),
init: () => {
if (RetryfyQueue.intervalId)
return;
RetryfyQueue.intervalId = setInterval(RetryfyQueue.tick, RetryfyQueue.interval);
},
reset: () => {
if (!RetryfyQueue.intervalId)
return;
clearInterval(RetryfyQueue.intervalId);
delete RetryfyQueue.intervalId;
},
add: (fn) => {
RetryfyQueue.queueWaiting.add(fn);
if (RetryfyQueue.queueActive.size < (RetryfyQueue.limit / 2)) { // Active queue not under preassure, executing immediately
RetryfyQueue.tick();
}
else {
RetryfyQueue.init();
}
},
remove: (fn) => {
RetryfyQueue.queueWaiting.delete(fn);
RetryfyQueue.queueActive.delete(fn);
},
schedule: () => {
return new Promise(resolve => {
const cleanup = () => RetryfyQueue.remove(resolver);
const resolver = () => resolve(cleanup);
RetryfyQueue.add(resolver);
});
},
tick: () => {
if (RetryfyQueue.queueActive.size >= RetryfyQueue.limit)
return;
if (!RetryfyQueue.queueWaiting.size)
return RetryfyQueue.reset();
for (const fn of RetryfyQueue.queueWaiting) {
if (RetryfyQueue.queueActive.size >= RetryfyQueue.limit)
break;
RetryfyQueue.queueWaiting.delete(fn);
RetryfyQueue.queueActive.add(fn);
fn();
}
}
};
/* EXPORT */
exports.default = RetryfyQueue;

View File

@@ -0,0 +1,35 @@
"use strict";
/* IMPORT */
Object.defineProperty(exports, "__esModule", { value: true });
/* VARIABLES */
const Queues = {};
/* SCHEDULER */
//TODO: Maybe publish this as a standalone package
const Scheduler = {
next: (id) => {
const queue = Queues[id];
if (!queue)
return;
queue.shift();
const job = queue[0];
if (job) {
job(() => Scheduler.next(id));
}
else {
delete Queues[id];
}
},
schedule: (id) => {
return new Promise(resolve => {
let queue = Queues[id];
if (!queue)
queue = Queues[id] = [];
queue.push(resolve);
if (queue.length > 1)
return;
resolve(() => Scheduler.next(id));
});
}
};
/* EXPORT */
exports.default = Scheduler;

View File

@@ -0,0 +1,56 @@
"use strict";
/* IMPORT */
Object.defineProperty(exports, "__esModule", { value: true });
const path = require("path");
const consts_1 = require("../consts");
const fs_1 = require("./fs");
/* TEMP */
//TODO: Maybe publish this as a standalone package
const Temp = {
store: {},
create: (filePath) => {
const randomness = `000000${Math.floor(Math.random() * 16777215).toString(16)}`.slice(-6), // 6 random-enough hex characters
timestamp = Date.now().toString().slice(-10), // 10 precise timestamp digits
prefix = 'tmp-', suffix = `.${prefix}${timestamp}${randomness}`, tempPath = `${filePath}${suffix}`;
return tempPath;
},
get: (filePath, creator, purge = true) => {
const tempPath = Temp.truncate(creator(filePath));
if (tempPath in Temp.store)
return Temp.get(filePath, creator, purge); // Collision found, try again
Temp.store[tempPath] = purge;
const disposer = () => delete Temp.store[tempPath];
return [tempPath, disposer];
},
purge: (filePath) => {
if (!Temp.store[filePath])
return;
delete Temp.store[filePath];
fs_1.default.unlinkAttempt(filePath);
},
purgeSync: (filePath) => {
if (!Temp.store[filePath])
return;
delete Temp.store[filePath];
fs_1.default.unlinkSyncAttempt(filePath);
},
purgeSyncAll: () => {
for (const filePath in Temp.store) {
Temp.purgeSync(filePath);
}
},
truncate: (filePath) => {
const basename = path.basename(filePath);
if (basename.length <= consts_1.LIMIT_BASENAME_LENGTH)
return filePath; //FIXME: Rough and quick attempt at detecting ok lengths
const truncable = /^(\.?)(.*?)((?:\.[^.]+)?(?:\.tmp-\d{10}[a-f0-9]{6})?)$/.exec(basename);
if (!truncable)
return filePath; //FIXME: No truncable part detected, can't really do much without also changing the parent path, which is unsafe, hoping for the best here
const truncationLength = basename.length - consts_1.LIMIT_BASENAME_LENGTH;
return `${filePath.slice(0, -basename.length)}${truncable[1]}${truncable[2].slice(0, -truncationLength)}${truncable[3]}`; //FIXME: The truncable part might be shorter than needed here
}
};
/* INIT */
process.on('exit', Temp.purgeSyncAll); // Ensuring purgeable temp files are purged on exit
/* EXPORT */
exports.default = Temp;

View File

@@ -0,0 +1,30 @@
"use strict";
/* IMPORT */
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.RENAME_TIMEOUT = exports.POLLING_TIMEOUT = exports.POLLING_INTERVAL = exports.PLATFORM = exports.IS_WINDOWS = exports.IS_MAC = exports.IS_LINUX = exports.HAS_NATIVE_RECURSION = exports.DEPTH = exports.DEBOUNCE = void 0;
const os_1 = __importDefault(require("os"));
/* CONSTANTS */
const DEBOUNCE = 300;
exports.DEBOUNCE = DEBOUNCE;
const DEPTH = 20;
exports.DEPTH = DEPTH;
const PLATFORM = os_1.default.platform();
exports.PLATFORM = PLATFORM;
const IS_LINUX = (PLATFORM === 'linux');
exports.IS_LINUX = IS_LINUX;
const IS_MAC = (PLATFORM === 'darwin');
exports.IS_MAC = IS_MAC;
const IS_WINDOWS = (PLATFORM === 'win32');
exports.IS_WINDOWS = IS_WINDOWS;
const HAS_NATIVE_RECURSION = IS_MAC || IS_WINDOWS;
exports.HAS_NATIVE_RECURSION = HAS_NATIVE_RECURSION;
const POLLING_INTERVAL = 3000;
exports.POLLING_INTERVAL = POLLING_INTERVAL;
const POLLING_TIMEOUT = 20000;
exports.POLLING_TIMEOUT = POLLING_TIMEOUT;
const RENAME_TIMEOUT = 1250;
exports.RENAME_TIMEOUT = RENAME_TIMEOUT;
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY29uc3RhbnRzLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vc3JjL2NvbnN0YW50cy50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiO0FBQ0EsWUFBWTs7Ozs7O0FBRVosNENBQW9CO0FBRXBCLGVBQWU7QUFFZixNQUFNLFFBQVEsR0FBRyxHQUFHLENBQUM7QUFzQmIsNEJBQVE7QUFwQmhCLE1BQU0sS0FBSyxHQUFHLEVBQUUsQ0FBQztBQW9CQyxzQkFBSztBQWxCdkIsTUFBTSxRQUFRLEdBQUcsWUFBRSxDQUFDLFFBQVEsRUFBRyxDQUFDO0FBa0I2Qyw0QkFBUTtBQWhCckYsTUFBTSxRQUFRLEdBQUcsQ0FBRSxRQUFRLEtBQUssT0FBTyxDQUFFLENBQUM7QUFnQkssNEJBQVE7QUFkdkQsTUFBTSxNQUFNLEdBQUcsQ0FBRSxRQUFRLEtBQUssUUFBUSxDQUFFLENBQUM7QUFjZ0Isd0JBQU07QUFaL0QsTUFBTSxVQUFVLEdBQUcsQ0FBRSxRQUFRLEtBQUssT0FBTyxDQUFFLENBQUM7QUFZcUIsZ0NBQVU7QUFWM0UsTUFBTSxvQkFBb0IsR0FBRyxNQUFNLElBQUksVUFBVSxDQUFDO0FBVXpCLG9EQUFvQjtBQVI3QyxNQUFNLGdCQUFnQixHQUFHLElBQUksQ0FBQztBQVF5RCw0Q0FBZ0I7QUFOdkcsTUFBTSxlQUFlLEdBQUcsS0FBSyxDQUFDO0FBTTJFLDBDQUFlO0FBSnhILE1BQU0sY0FBYyxHQUFHLElBQUksQ0FBQztBQUk4Rix3Q0FBYyJ9

View File

@@ -0,0 +1,70 @@
/**
* Returns a function, that, as long as it continues to be invoked, will not
* be triggered. The function will be called after it stops being called for
* N milliseconds. If `immediate` is passed, trigger the function on the
* leading edge, instead of the trailing. The function also has a property 'clear'
* that is a function which will clear the timer to prevent previously scheduled executions.
*
* @source underscore.js
* @see http://unscriptable.com/2009/03/20/debouncing-javascript-methods/
* @param {Function} function to wrap
* @param {Number} timeout in ms (`100`)
* @param {Boolean} whether to execute at the beginning (`false`)
* @api public
*/
function debounce(func, wait, immediate) {
var timeout, args, context, timestamp, result;
if (null == wait) wait = 100;
function later() {
var last = Date.now() - timestamp;
if (last < wait && last >= 0) {
timeout = setTimeout(later, wait - last);
} else {
timeout = null;
if (!immediate) {
result = func.apply(context, args);
context = args = null;
}
}
};
var debounced = function () {
context = this;
args = arguments;
timestamp = Date.now();
var callNow = immediate && !timeout;
if (!timeout) timeout = setTimeout(later, wait);
if (callNow) {
result = func.apply(context, args);
context = args = null;
}
return result;
};
debounced.clear = function () {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
};
debounced.flush = function () {
if (timeout) {
result = func.apply(context, args);
context = args = null;
clearTimeout(timeout);
timeout = null;
}
};
return debounced;
};
// Adds compatibility for ES modules
debounce.debounce = debounce;
module.exports = debounce;

View File

@@ -0,0 +1,4 @@
"use strict";
/* ENUMS */
Object.defineProperty(exports, "__esModule", { value: true });
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZW51bXMuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi9zcmMvZW51bXMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IjtBQUNBLFdBQVcifQ==

View File

@@ -0,0 +1,15 @@
/*!
* is-primitive <https://github.com/jonschlinkert/is-primitive>
*
* Copyright (c) 2014-present, Jon Schlinkert.
* Released under the MIT License.
*/
'use strict';
module.exports = function isPrimitive(val) {
if (typeof val === 'object') {
return val === null;
}
return typeof val !== 'function';
};

View File

@@ -0,0 +1,41 @@
"use strict";
/* IMPORT */
/* PROMISE CONCURRENCY LIMITER */
class Limiter {
/* CONSTRUCTOR */
constructor(options) {
this.concurrency = options.concurrency;
this.count = 0;
this.queue = new Set();
}
/* API */
add(fn) {
if (this.count < this.concurrency)
return this.run(fn);
return new Promise(resolve => {
const callback = () => resolve(this.run(fn));
this.queue.add(callback);
});
}
flush() {
for (const callback of this.queue) {
if (this.count >= this.concurrency)
break;
this.queue.delete(callback);
callback();
}
}
run(fn) {
this.count += 1;
const promise = fn();
const cleanup = () => {
this.count -= 1;
this.flush();
};
promise.then(cleanup, cleanup);
return promise;
}
}
module.exports = Limiter;
module.exports.default = Limiter;
Object.defineProperty(module.exports, "__esModule", { value: true });

View File

@@ -0,0 +1,19 @@
"use strict";
/* CONSTS */
Object.defineProperty(exports, "__esModule", { value: true });
exports.S_IFSOCK = exports.S_IFREG = exports.S_IFMT = exports.S_IFLNK = exports.S_IFIFO = exports.S_IFDIR = exports.S_IFCHR = exports.S_IFBLK = exports.RETRY_TIMEOUT = exports.MAX_SAFE_INTEGER = exports.IS_WINDOWS = void 0;
const IS_WINDOWS = (process.platform === 'win32');
exports.IS_WINDOWS = IS_WINDOWS;
const { MAX_SAFE_INTEGER } = Number;
exports.MAX_SAFE_INTEGER = MAX_SAFE_INTEGER;
const RETRY_TIMEOUT = 5000;
exports.RETRY_TIMEOUT = RETRY_TIMEOUT;
const { S_IFBLK, S_IFCHR, S_IFDIR, S_IFIFO, S_IFLNK, S_IFMT, S_IFREG, S_IFSOCK } = process['binding']('constants').fs;
exports.S_IFBLK = S_IFBLK;
exports.S_IFCHR = S_IFCHR;
exports.S_IFDIR = S_IFDIR;
exports.S_IFIFO = S_IFIFO;
exports.S_IFLNK = S_IFLNK;
exports.S_IFMT = S_IFMT;
exports.S_IFREG = S_IFREG;
exports.S_IFSOCK = S_IFSOCK;

View File

@@ -0,0 +1,39 @@
"use strict";
/* IMPORT */
Object.defineProperty(exports, "__esModule", { value: true });
exports.Stats = void 0;
const fs_1 = require("../atomically/utils/fs");
const path_1 = require("path");
const consts_1 = require("./consts");
const stats_1 = require("./stats");
exports.Stats = stats_1.default;
/* HELPERS */
const { stat, FSReqCallback } = process['binding']('fs');
/* RIPSTAT */
const ripstat = (filePath, timeout) => {
return new Promise((resolve, reject) => {
const req = new FSReqCallback(true);
req.oncomplete = (error, statsdata) => {
if (error) {
const { code } = error;
if (code === 'EMFILE' || code === 'ENFILE' || code === 'EAGAIN' || code === 'EBUSY' || code === 'EACCESS' || code === 'EACCS' || code === 'EPERM') { // Retriable error
fs_1.default.statRetry(timeout || consts_1.RETRY_TIMEOUT)(filePath, { bigint: true }).then(nstats => {
const statsdata = [nstats.dev, nstats.mode, nstats.nlink, nstats.uid, nstats.gid, nstats.rdev, nstats.blksize, nstats.ino, nstats.size, nstats.blocks, 0n, nstats.atimeNs, 0n, nstats.mtimeNs, 0n, nstats.ctimeNs, 0n, nstats.birthtimeNs];
const stats = new stats_1.default(statsdata);
resolve(stats);
}, reject);
}
else {
reject(error);
}
}
else {
const stats = new stats_1.default(statsdata);
resolve(stats);
}
};
stat(path_1.toNamespacedPath(filePath), true, req);
});
};
/* EXPORT */
exports.default = ripstat;

View File

@@ -0,0 +1,55 @@
"use strict";
/* IMPORT */
Object.defineProperty(exports, "__esModule", { value: true });
const consts_1 = require("./consts");
/* HELPERS */
const { floor } = Math;
const toNumber = Number;
/* STATS */
class Stats {
/* CONSTRUCTOR */
constructor(stats) {
this.dev = toNumber(stats[0]);
this.mode = toNumber(stats[1]);
this.nlink = toNumber(stats[2]);
this.uid = toNumber(stats[3]);
this.gid = toNumber(stats[4]);
this.rdev = toNumber(stats[5]);
this.blksize = toNumber(stats[6]);
this.ino = (stats[7] <= consts_1.MAX_SAFE_INTEGER) ? toNumber(stats[7]) : stats[7];
this.size = toNumber(stats[8]);
this.blocks = toNumber(stats[9]);
this.atimeMs = (toNumber(stats[10]) * 1000) + floor(toNumber(stats[11]) / 1000000);
this.mtimeMs = (toNumber(stats[12]) * 1000) + floor(toNumber(stats[13]) / 1000000);
this.ctimeMs = (toNumber(stats[14]) * 1000) + floor(toNumber(stats[15]) / 1000000);
this.birthtimeMs = (toNumber(stats[16]) * 1000) + floor(toNumber(stats[17]) / 1000000);
}
/* HELPERS */
_isMode(mode) {
return (this.mode & consts_1.S_IFMT) === mode;
}
/* API */
isDirectory() {
return this._isMode(consts_1.S_IFDIR);
}
isFile() {
return this._isMode(consts_1.S_IFREG);
}
isBlockDevice() {
return !consts_1.IS_WINDOWS && this._isMode(consts_1.S_IFBLK);
}
isCharacterDevice() {
return this._isMode(consts_1.S_IFCHR);
}
isSymbolicLink() {
return this._isMode(consts_1.S_IFLNK);
}
isFIFO() {
return !consts_1.IS_WINDOWS && this._isMode(consts_1.S_IFIFO);
}
isSocket() {
return !consts_1.IS_WINDOWS && this._isMode(consts_1.S_IFSOCK);
}
}
/* EXPORT */
exports.default = Stats;

View File

@@ -0,0 +1,17 @@
"use strict";
/* STRING INDEXES */
function indexes(str, substr) {
var indexes = [], rangeLength = substr.length;
var indexFrom = 0;
while (true) {
var index = str.indexOf(substr, indexFrom);
if (index === -1)
return indexes;
indexes.push(index);
indexFrom = index + rangeLength;
}
}
/* EXPORT */
module.exports = indexes;
module.exports.default = indexes;
Object.defineProperty(module.exports, "__esModule", { value: true });

View File

@@ -0,0 +1,104 @@
"use strict";
/* IMPORT */
const fs = require("fs");
const path = require("path");
const promise_concurrency_limiter_1 = require("./promise-concurrency-limiter");
/* HELPERS */
const limiter = new promise_concurrency_limiter_1.default({ concurrency: 500 });
/* TINY READDIR */
const readdir = (rootPath, options) => {
var _a, _b, _c, _d;
const followSymlinks = (_a = options === null || options === void 0 ? void 0 : options.followSymlinks) !== null && _a !== void 0 ? _a : false, maxDepth = (_b = options === null || options === void 0 ? void 0 : options.depth) !== null && _b !== void 0 ? _b : Infinity, isIgnored = (_c = options === null || options === void 0 ? void 0 : options.ignore) !== null && _c !== void 0 ? _c : (() => false), signal = (_d = options === null || options === void 0 ? void 0 : options.signal) !== null && _d !== void 0 ? _d : { aborted: false }, directories = [], files = [], symlinks = [], map = {}, resultEmpty = { directories: [], files: [], symlinks: [], map: {} }, result = { directories, files, symlinks, map };
const handleDirectory = (dirmap, subPath, depth) => {
dirmap.directories.push(subPath);
directories.push(subPath);
if (depth >= maxDepth)
return;
// if depth > 1 and the limiter is full, then we cannot queue this function or the current promise will never return
if (depth > 1 && limiter.count >= limiter.concurrency) return populateResultFromPath(subPath, depth + 1)
return limiter.add(() => populateResultFromPath(subPath, depth + 1));
};
const handleFile = (dirmap, subPath) => {
dirmap.files.push(subPath);
files.push(subPath);
};
const handleSymlink = (dirmap, subPath, depth) => {
dirmap.symlinks.push(subPath);
symlinks.push(subPath);
if (!followSymlinks)
return;
if (depth >= maxDepth)
return;
return limiter.add(() => populateResultFromSymlink(subPath, depth + 1));
};
const handleStat = (dirmap, rootPath, stat, depth) => {
if (signal.aborted)
return;
if (isIgnored(rootPath))
return;
if (stat.isDirectory()) {
return handleDirectory(dirmap, rootPath, depth);
}
else if (stat.isFile()) {
return handleFile(dirmap, rootPath);
}
else if (stat.isSymbolicLink()) {
return handleSymlink(dirmap, rootPath, depth);
}
};
const handleDirent = (dirmap, rootPath, dirent, depth) => {
if (signal.aborted)
return;
const subPath = `${rootPath}${path.sep}${dirent.name}`;
if (isIgnored(subPath))
return;
if (dirent.isDirectory()) {
return handleDirectory(dirmap, subPath, depth);
}
else if (dirent.isFile()) {
return handleFile(dirmap, subPath);
}
else if (dirent.isSymbolicLink()) {
return handleSymlink(dirmap, subPath, depth);
}
};
const handleDirents = (dirmap, rootPath, dirents, depth) => {
return Promise.all(dirents.map((dirent) => {
return handleDirent(dirmap, rootPath, dirent, depth);
}));
};
const populateResultFromPath = async (rootPath, depth) => {
if (signal.aborted)
return;
if (depth > maxDepth)
return;
const dirents = await fs.promises.readdir(rootPath, { withFileTypes: true }).catch(() => []);
if (signal.aborted)
return;
const dirmap = map[rootPath] = { directories: [], files: [], symlinks: [] };
if (!dirents.length)
return;
await handleDirents(dirmap, rootPath, dirents, depth);
};
const populateResultFromSymlink = async (rootPath, depth) => {
try {
const realPath = await fs.promises.realpath(rootPath), stat = await fs.promises.stat(realPath), dirmap = map[rootPath] = { directories: [], files: [], symlinks: [] };
await handleStat(dirmap, realPath, stat, depth);
}
catch (_a) { }
};
const getResult = async (rootPath, depth = 1) => {
rootPath = path.normalize(rootPath);
await populateResultFromPath(rootPath, depth);
if (signal.aborted)
return resultEmpty;
return result;
};
return getResult(rootPath);
};
/* EXPORT */
module.exports = readdir;
module.exports.default = readdir;
Object.defineProperty(module.exports, "__esModule", { value: true });

View File

@@ -0,0 +1,12 @@
"use strict";
/* IMPORT */
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.WatcherStats = exports.Stats = void 0;
const ripstat_1 = require("ripstat");
Object.defineProperty(exports, "Stats", { enumerable: true, get: function () { return ripstat_1.Stats; } });
const watcher_stats_1 = __importDefault(require("./watcher_stats"));
exports.WatcherStats = watcher_stats_1.default;
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidHlwZXMuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi9zcmMvdHlwZXMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IjtBQUNBLFlBQVk7Ozs7OztBQUdaLHFDQUE4QjtBQWlGbUUsc0ZBakZ6RixlQUFLLE9BaUZ5RjtBQTlFdEcsb0VBQTJDO0FBOEUwSyx1QkE5RTlNLHVCQUFZLENBOEU4TSJ9

View File

@@ -0,0 +1,88 @@
"use strict";
/* IMPORT */
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const are_shallow_equal_1 = __importDefault(require("./are-shallow-equal"));
const debounce_1 = __importDefault(require("./debounce"));
const path_1 = __importDefault(require("path"));
const ripstat_1 = __importDefault(require("./ripstat"));
const tiny_readdir_1 = __importDefault(require("./tiny-readdir"));
const constants_1 = require("./constants");
/* UTILS */
const Utils = {
lang: {
areShallowEqual: //TODO: Import all these utilities from "nanodash" instead
are_shallow_equal_1.default,
debounce: debounce_1.default,
attempt: (fn) => {
try {
return fn();
}
catch (error) {
return Utils.lang.castError(error);
}
},
castArray: (x) => {
return Utils.lang.isArray(x) ? x : [x];
},
castError(exception) {
if (Utils.lang.isError(exception))
return exception;
if (Utils.lang.isString(exception))
return new Error(exception);
return new Error('Unknown error');
},
defer: (callback) => {
return setTimeout(callback, 0);
},
isArray: (x) => {
return Array.isArray(x);
},
isError(x) {
return x instanceof Error;
},
isFunction: (x) => {
return typeof x === 'function';
},
isNumber: (x) => {
return typeof x === 'number';
},
isString: (x) => {
return typeof x === 'string';
},
isUndefined: (x) => {
return x === undefined;
},
noop: () => {
return;
},
uniq: (arr) => {
if (arr.length < 2)
return arr;
return Array.from(new Set(arr));
}
},
fs: {
isSubPath: (targetPath, subPath) => {
return (subPath.startsWith(targetPath) && subPath[targetPath.length] === path_1.default.sep && (subPath.length - targetPath.length) > path_1.default.sep.length);
},
poll: (targetPath, timeout = constants_1.POLLING_TIMEOUT) => {
return ripstat_1.default(targetPath, timeout).catch(Utils.lang.noop);
},
readdir: async (rootPath, ignore, depth = Infinity, signal, readdirMap) => {
if (readdirMap && depth === 1 && rootPath in readdirMap) { // Reusing cached data
const result = readdirMap[rootPath];
return [result.directories, result.files];
}
else { // Retrieving fresh data
const result = await tiny_readdir_1.default(rootPath, { depth, ignore, signal });
return [result.directories, result.files];
}
}
}
};
/* EXPORT */
exports.default = Utils;
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidXRpbHMuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi9zcmMvdXRpbHMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IjtBQUNBLFlBQVk7Ozs7O0FBRVosMEVBQWdEO0FBQ2hELHdEQUFnQztBQUNoQyxnREFBd0I7QUFDeEIsc0RBQThCO0FBQzlCLGdFQUFtQztBQUNuQywyQ0FBNEM7QUFHNUMsV0FBVztBQUVYLE1BQU0sS0FBSyxHQUFHO0lBRVosSUFBSSxFQUFFO1FBRUosZUFBZSxFQUZULDBEQUEwRDtRQUVoRSwyQkFBZTtRQUVmLFFBQVEsRUFBUixrQkFBUTtRQUVSLE9BQU8sRUFBRSxDQUFNLEVBQVcsRUFBYyxFQUFFO1lBRXhDLElBQUk7Z0JBRUYsT0FBTyxFQUFFLEVBQUcsQ0FBQzthQUVkO1lBQUMsT0FBUSxLQUFjLEVBQUc7Z0JBRXpCLE9BQU8sS0FBSyxDQUFDLElBQUksQ0FBQyxTQUFTLENBQUcsS0FBSyxDQUFFLENBQUM7YUFFdkM7UUFFSCxDQUFDO1FBRUQsU0FBUyxFQUFFLENBQU0sQ0FBVSxFQUFRLEVBQUU7WUFFbkMsT0FBTyxLQUFLLENBQUMsSUFBSSxDQUFDLE9BQU8sQ0FBRyxDQUFDLENBQUUsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDO1FBRTVDLENBQUM7UUFFRCxTQUFTLENBQUcsU0FBa0I7WUFFNUIsSUFBSyxLQUFLLENBQUMsSUFBSSxDQUFDLE9BQU8sQ0FBRyxTQUFTLENBQUU7Z0JBQUcsT0FBTyxTQUFTLENBQUM7WUFFekQsSUFBSyxLQUFLLENBQUMsSUFBSSxDQUFDLFFBQVEsQ0FBRyxTQUFTLENBQUU7Z0JBQUcsT0FBTyxJQUFJLEtBQUssQ0FBRyxTQUFTLENBQUUsQ0FBQztZQUV4RSxPQUFPLElBQUksS0FBSyxDQUFHLGVBQWUsQ0FBRSxDQUFDO1FBRXZDLENBQUM7UUFFRCxLQUFLLEVBQUUsQ0FBRSxRQUFrQixFQUFtQixFQUFFO1lBRTlDLE9BQU8sVUFBVSxDQUFHLFFBQVEsRUFBRSxDQUFDLENBQUUsQ0FBQztRQUVwQyxDQUFDO1FBRUQsT0FBTyxFQUFFLENBQUUsQ0FBTSxFQUFlLEVBQUU7WUFFaEMsT0FBTyxLQUFLLENBQUMsT0FBTyxDQUFHLENBQUMsQ0FBRSxDQUFDO1FBRTdCLENBQUM7UUFFRCxPQUFPLENBQUcsQ0FBTTtZQUVkLE9BQU8sQ0FBQyxZQUFZLEtBQUssQ0FBQztRQUU1QixDQUFDO1FBRUQsVUFBVSxFQUFFLENBQUUsQ0FBTSxFQUFrQixFQUFFO1lBRXRDLE9BQU8sT0FBTyxDQUFDLEtBQUssVUFBVSxDQUFDO1FBRWpDLENBQUM7UUFFRCxRQUFRLEVBQUUsQ0FBRSxDQUFNLEVBQWdCLEVBQUU7WUFFbEMsT0FBTyxPQUFPLENBQUMsS0FBSyxRQUFRLENBQUM7UUFFL0IsQ0FBQztRQUVELFFBQVEsRUFBRSxDQUFFLENBQU0sRUFBZ0IsRUFBRTtZQUVsQyxPQUFPLE9BQU8sQ0FBQyxLQUFLLFFBQVEsQ0FBQztRQUUvQixDQUFDO1FBRUQsV0FBVyxFQUFFLENBQUUsQ0FBTSxFQUFtQixFQUFFO1lBRXhDLE9BQU8sQ0FBQyxLQUFLLFNBQVMsQ0FBQztRQUV6QixDQUFDO1FBRUQsSUFBSSxFQUFFLEdBQWMsRUFBRTtZQUVwQixPQUFPO1FBRVQsQ0FBQztRQUVELElBQUksRUFBRSxDQUFNLEdBQVEsRUFBUSxFQUFFO1lBRTVCLElBQUssR0FBRyxDQUFDLE1BQU0sR0FBRyxDQUFDO2dCQUFHLE9BQU8sR0FBRyxDQUFDO1lBRWpDLE9BQU8sS0FBSyxDQUFDLElBQUksQ0FBRyxJQUFJLEdBQUcsQ0FBRyxHQUFHLENBQUUsQ0FBRSxDQUFDO1FBRXhDLENBQUM7S0FFRjtJQUVELEVBQUUsRUFBRTtRQUVGLFNBQVMsRUFBRSxDQUFFLFVBQWtCLEVBQUUsT0FBZSxFQUFZLEVBQUU7WUFFNUQsT0FBTyxDQUFFLE9BQU8sQ0FBQyxVQUFVLENBQUcsVUFBVSxDQUFFLElBQUksT0FBTyxDQUFDLFVBQVUsQ0FBQyxNQUFNLENBQUMsS0FBSyxjQUFJLENBQUMsR0FBRyxJQUFJLENBQUUsT0FBTyxDQUFDLE1BQU0sR0FBRyxVQUFVLENBQUMsTUFBTSxDQUFFLEdBQUcsY0FBSSxDQUFDLEdBQUcsQ0FBQyxNQUFNLENBQUUsQ0FBQztRQUV0SixDQUFDO1FBRUQsSUFBSSxFQUFFLENBQUUsVUFBa0IsRUFBRSxVQUFrQiwyQkFBZSxFQUErQixFQUFFO1lBRTVGLE9BQU8saUJBQU8sQ0FBRyxVQUFVLEVBQUUsT0FBTyxDQUFFLENBQUMsS0FBSyxDQUFHLEtBQUssQ0FBQyxJQUFJLENBQUMsSUFBSSxDQUFFLENBQUM7UUFFbkUsQ0FBQztRQUVELE9BQU8sRUFBRSxLQUFLLEVBQUcsUUFBZ0IsRUFBRSxNQUFlLEVBQUUsUUFBZ0IsUUFBUSxFQUFFLE1BQTZCLEVBQUUsVUFBdUIsRUFBa0MsRUFBRTtZQUV0SyxJQUFLLFVBQVUsSUFBSSxLQUFLLEtBQUssQ0FBQyxJQUFJLFFBQVEsSUFBSSxVQUFVLEVBQUcsRUFBRSxzQkFBc0I7Z0JBRWpGLE1BQU0sTUFBTSxHQUFHLFVBQVUsQ0FBQyxRQUFRLENBQUMsQ0FBQztnQkFFcEMsT0FBTyxDQUFDLE1BQU0sQ0FBQyxXQUFXLEVBQUUsTUFBTSxDQUFDLEtBQUssQ0FBQyxDQUFDO2FBRTNDO2lCQUFNLEVBQUUsd0JBQXdCO2dCQUUvQixNQUFNLE1BQU0sR0FBRyxNQUFNLHNCQUFPLENBQUcsUUFBUSxFQUFFLEVBQUUsS0FBSyxFQUFFLE1BQU0sRUFBRSxNQUFNLEVBQUUsQ0FBRSxDQUFDO2dCQUVyRSxPQUFPLENBQUMsTUFBTSxDQUFDLFdBQVcsRUFBRSxNQUFNLENBQUMsS0FBSyxDQUFDLENBQUM7YUFFM0M7UUFFSCxDQUFDO0tBRUY7Q0FFRixDQUFDO0FBRUYsWUFBWTtBQUVaLGtCQUFlLEtBQUssQ0FBQyJ9

View File

File diff suppressed because one or more lines are too long

View File

File diff suppressed because one or more lines are too long

View File

File diff suppressed because one or more lines are too long

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