mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-05-24 01:13:00 -04:00
Merge master
This commit is contained in:
commit
a5dacd7821
@ -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 ###
|
||||
|
@ -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);
|
||||
|
55
client/assets/defaultStyles.css
Normal file
55
client/assets/defaultStyles.css
Normal 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
563
client/assets/trix.css
Normal 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%;
|
||||
}
|
@ -33,8 +33,8 @@ export default {
|
||||
showMenu: false,
|
||||
items: [
|
||||
{
|
||||
text: 'Current',
|
||||
value: 'index'
|
||||
text: 'Pub Date',
|
||||
value: 'publishedAt'
|
||||
},
|
||||
{
|
||||
text: 'Title',
|
||||
@ -47,10 +47,6 @@ export default {
|
||||
{
|
||||
text: 'Episode',
|
||||
value: 'episode'
|
||||
},
|
||||
{
|
||||
text: 'Pub Date',
|
||||
value: 'publishedAt'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
150
client/components/modals/ListeningSessionModal.vue
Normal file
150
client/components/modals/ListeningSessionModal.vue
Normal 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>
|
@ -6,12 +6,12 @@
|
||||
</div>
|
||||
</template>
|
||||
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
|
||||
<form @submit.prevent="submitForm">
|
||||
<form v-if="author" @submit.prevent="submitForm">
|
||||
<div class="flex">
|
||||
<div class="w-40 p-2">
|
||||
<div class="w-full h-45 relative">
|
||||
<covers-author-image :author="author" />
|
||||
<div v-show="!processing" class="absolute top-0 left-0 w-full h-full opacity-0 hover:opacity-100">
|
||||
<div v-show="!processing && author.imagePath" class="absolute top-0 left-0 w-full h-full opacity-0 hover:opacity-100">
|
||||
<span class="absolute top-2 right-2 material-icons text-error transform hover:scale-125 transition-transform cursor-pointer text-lg" @click="removeCover">delete</span>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -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">
|
||||
|
90
client/components/modals/podcast/RemoveEpisode.vue
Normal file
90
client/components/modals/podcast/RemoveEpisode.vue
Normal file
@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="podcast-episode-remove-modal" :width="500" :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="px-8 py-6 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
|
||||
<div class="mb-4">
|
||||
<p class="text-lg text-gray-200 mb-4">
|
||||
Are you sure you want to remove episode<br /><span class="text-base">{{ episodeTitle }}</span
|
||||
>?
|
||||
</p>
|
||||
<p class="text-xs font-semibold text-warning text-opacity-90">Note: This does not delete the audio file unless toggling "Hard delete file"</p>
|
||||
</div>
|
||||
<div class="flex justify-between items-center pt-4">
|
||||
<ui-checkbox v-model="hardDeleteFile" label="Hard delete file" check-color="error" checkbox-bg="bg" small label-class="text-base text-gray-200 pl-3" />
|
||||
|
||||
<ui-btn @click="submit">{{ hardDeleteFile ? 'Delete episode' : 'Remove episode' }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
episode: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
hardDeleteFile: false,
|
||||
processing: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
value(newVal) {
|
||||
if (newVal) this.hardDeleteFile = false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
title() {
|
||||
return 'Remove Episode'
|
||||
},
|
||||
episodeId() {
|
||||
return this.episode ? this.episode.id : null
|
||||
},
|
||||
episodeTitle() {
|
||||
return this.episode ? this.episode.title : null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
submit() {
|
||||
this.processing = true
|
||||
|
||||
var queryString = this.hardDeleteFile ? '?hard=1' : ''
|
||||
this.$axios
|
||||
.$delete(`/api/podcasts/${this.libraryItem.id}/episode/${this.episodeId}${queryString}`)
|
||||
.then(() => {
|
||||
this.processing = false
|
||||
this.$toast.success('Podcast episode removed')
|
||||
this.show = false
|
||||
})
|
||||
.catch((error) => {
|
||||
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed remove episode'
|
||||
console.error('Failed update episode', error)
|
||||
this.processing = false
|
||||
this.$toast.error(errorMsg)
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
75
client/components/modals/podcast/ViewEpisode.vue
Normal file
75
client/components/modals/podcast/ViewEpisode.vue
Normal 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>
|
@ -1,21 +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-show="userCanUpdate" class="w-12 min-w-12 max-w-16 h-full">
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<span class="material-icons drag-handle text-lg text-white text-opacity-50 hover:text-opacity-100">menu</span>
|
||||
</div>
|
||||
</div>
|
||||
<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" />
|
||||
@ -49,8 +46,7 @@ export default {
|
||||
episode: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
isDragging: Boolean
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@ -59,15 +55,6 @@ export default {
|
||||
isHovering: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
isDragging: {
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.isHovering = false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
@ -81,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)
|
||||
@ -117,7 +105,7 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
mouseover() {
|
||||
if (this.isDragging) return
|
||||
// if (this.isDragging) return
|
||||
this.isHovering = true
|
||||
},
|
||||
mouseleave() {
|
||||
@ -154,22 +142,7 @@ export default {
|
||||
})
|
||||
},
|
||||
removeClick() {
|
||||
if (confirm(`Are you sure you want to remove episode ${this.title}?\nNote: Does not delete from file system`)) {
|
||||
this.processingRemove = true
|
||||
|
||||
this.$axios
|
||||
.$delete(`/api/items/${this.libraryItemId}/episode/${this.episode.id}`)
|
||||
.then((updatedPodcast) => {
|
||||
console.log(`Episode removed from podcast`, updatedPodcast)
|
||||
this.$toast.success('Episode removed from podcast')
|
||||
this.processingRemove = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to remove episode from podcast', error)
|
||||
this.$toast.error('Failed to remove episode from podcast')
|
||||
this.processingRemove = false
|
||||
})
|
||||
}
|
||||
this.$emit('remove', this.episode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -3,29 +3,19 @@
|
||||
<div class="flex items-center mb-4">
|
||||
<p class="text-lg mb-0 font-semibold">Episodes</p>
|
||||
<div class="flex-grow" />
|
||||
<controls-episode-sort-select v-model="sortKey" :descending.sync="sortDesc" class="w-36 sm:w-44 md:w-48 h-9 ml-1 sm:ml-4" @change="changeSort" />
|
||||
<div v-if="userCanUpdate" class="w-12">
|
||||
<ui-icon-btn v-if="orderChanged" :loading="savingOrder" icon="save" bg-color="primary" class="ml-auto" @click="saveOrder" />
|
||||
</div>
|
||||
<controls-episode-sort-select v-model="sortKey" :descending.sync="sortDesc" class="w-36 sm:w-44 md:w-48 h-9 ml-1 sm:ml-4" />
|
||||
</div>
|
||||
<p v-if="!episodes.length" class="py-4 text-center text-lg">No Episodes</p>
|
||||
<draggable v-model="episodesCopy" v-bind="dragOptions" class="list-group" handle=".drag-handle" draggable=".item" tag="div" @start="drag = true" @end="drag = false" @update="draggableUpdate">
|
||||
<transition-group type="transition" :name="!drag ? 'episode' : null">
|
||||
<template v-for="episode in episodesCopy">
|
||||
<tables-podcast-episode-table-row :key="episode.id" :is-dragging="drag" :episode="episode" :library-item-id="libraryItem.id" class="item" :class="drag ? '' : 'episode'" @edit="editEpisode" />
|
||||
</template>
|
||||
</transition-group>
|
||||
</draggable>
|
||||
<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" @view="viewEpisode" />
|
||||
</template>
|
||||
|
||||
<modals-podcast-remove-episode v-model="showPodcastRemoveModal" :library-item="libraryItem" :episode="selectedEpisode" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import draggable from 'vuedraggable'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
draggable
|
||||
},
|
||||
props: {
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
@ -34,30 +24,19 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
sortKey: 'index',
|
||||
sortDesc: true,
|
||||
drag: false,
|
||||
episodesCopy: [],
|
||||
orderChanged: false,
|
||||
savingOrder: false
|
||||
sortKey: 'publishedAt',
|
||||
sortDesc: true,
|
||||
selectedEpisode: null,
|
||||
showPodcastRemoveModal: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
libraryItem: {
|
||||
handler(newVal) {
|
||||
this.init()
|
||||
}
|
||||
libraryItem() {
|
||||
this.init()
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
dragOptions() {
|
||||
return {
|
||||
animation: 200,
|
||||
group: 'description',
|
||||
ghostClass: 'ghost',
|
||||
disabled: !this.userCanUpdate
|
||||
}
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
@ -69,64 +48,33 @@ export default {
|
||||
},
|
||||
episodes() {
|
||||
return this.media.episodes || []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
changeSort() {
|
||||
this.episodesCopy.sort((a, b) => {
|
||||
},
|
||||
episodesSorted() {
|
||||
return this.episodesCopy.sort((a, b) => {
|
||||
if (this.sortDesc) {
|
||||
return String(b[this.sortKey]).localeCompare(String(a[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' })
|
||||
}
|
||||
return String(a[this.sortKey]).localeCompare(String(b[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' })
|
||||
})
|
||||
|
||||
this.orderChanged = this.checkHasOrderChanged()
|
||||
},
|
||||
checkHasOrderChanged() {
|
||||
for (let i = 0; i < this.episodesCopy.length; i++) {
|
||||
var epc = this.episodesCopy[i]
|
||||
var ep = this.episodes[i]
|
||||
if (epc.index != ep.index) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
removeEpisode(episode) {
|
||||
this.selectedEpisode = episode
|
||||
this.showPodcastRemoveModal = true
|
||||
},
|
||||
editEpisode(episode) {
|
||||
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
|
||||
this.$store.commit('globals/setSelectedEpisode', episode)
|
||||
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
|
||||
},
|
||||
draggableUpdate() {
|
||||
this.orderChanged = this.checkHasOrderChanged()
|
||||
},
|
||||
async saveOrder() {
|
||||
if (!this.userCanUpdate) return
|
||||
|
||||
this.savingOrder = true
|
||||
|
||||
var episodesUpdate = {
|
||||
episodes: this.episodesCopy.map((b) => b.id)
|
||||
}
|
||||
await this.$axios
|
||||
.$patch(`/api/items/${this.libraryItem.id}/episodes`, episodesUpdate)
|
||||
.then((podcast) => {
|
||||
console.log('Podcast updated', podcast)
|
||||
this.$toast.success('Saved episode order')
|
||||
this.orderChanged = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update podcast', error)
|
||||
this.$toast.error('Failed to save podcast episode order')
|
||||
})
|
||||
this.savingOrder = false
|
||||
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) => {
|
||||
return {
|
||||
...ep
|
||||
}
|
||||
})
|
||||
this.episodesCopy = this.episodes.map((ep) => ({ ...ep }))
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
@ -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')
|
||||
}
|
||||
|
75
client/components/ui/RichTextEditor.vue
Normal file
75
client/components/ui/RichTextEditor.vue
Normal 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>
|
284
client/components/ui/VueTrix.vue
Normal file
284
client/components/ui/VueTrix.vue
Normal 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>
|
@ -14,6 +14,7 @@
|
||||
<modals-edit-collection-modal />
|
||||
<modals-bookshelf-texture-modal />
|
||||
<modals-podcast-edit-episode />
|
||||
<modals-podcast-view-episode />
|
||||
<modals-authors-edit-modal />
|
||||
<readers-reader />
|
||||
</div>
|
||||
|
15
client/package-lock.json
generated
15
client/package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.0.14",
|
||||
"version": "2.0.17",
|
||||
"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",
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.17",
|
||||
"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",
|
||||
@ -32,4 +33,4 @@
|
||||
"@nuxtjs/tailwindcss": "^4.2.1",
|
||||
"postcss": "^8.3.6"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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">
|
||||
|
@ -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:
|
||||
<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>
|
151
client/pages/config/users/_id/sessions.vue
Normal file
151
client/pages/config/users/_id/sessions.vue
Normal 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>
|
@ -28,7 +28,8 @@ const BookshelfView = {
|
||||
const PlayMethod = {
|
||||
DIRECTPLAY: 0,
|
||||
DIRECTSTREAM: 1,
|
||||
TRANSCODE: 2
|
||||
TRANSCODE: 2,
|
||||
LOCAL: 3
|
||||
}
|
||||
|
||||
const Constants = {
|
||||
|
@ -57,6 +57,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
|
||||
|
@ -6,6 +6,7 @@ export const state = () => ({
|
||||
showUserCollectionsModal: false,
|
||||
showEditCollectionModal: false,
|
||||
showEditPodcastEpisode: false,
|
||||
showViewPodcastEpisodeModal: false,
|
||||
showEditAuthorModal: false,
|
||||
selectedEpisode: null,
|
||||
selectedCollection: null,
|
||||
@ -53,6 +54,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
|
||||
|
360
package-lock.json
generated
360
package-lock.json
generated
@ -1,12 +1,11 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.0.14",
|
||||
"version": "2.0.17",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.0.14",
|
||||
"version": "2.0.17",
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"archiver": "^5.3.0",
|
||||
@ -20,6 +19,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",
|
||||
@ -31,24 +31,11 @@
|
||||
"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"
|
||||
},
|
||||
"bin": {
|
||||
"audiobookshelf": "prod.js"
|
||||
},
|
||||
"devDependencies": {}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.17.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.17.9.tgz",
|
||||
"integrity": "sha512-lSiBBvodq29uShpWGNbgFdKYNiFDo5/HIYsaCEY9ff4sb10x9jizo2+pRrSyF4jKZCXqgzuqBOQKbUm90gQwJg==",
|
||||
"dependencies": {
|
||||
"regenerator-runtime": "^0.13.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sindresorhus/is": {
|
||||
@ -625,6 +612,57 @@
|
||||
"node": ">=4.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"entities": "^4.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domelementtype": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
]
|
||||
},
|
||||
"node_modules/domhandler": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.0.1.tgz",
|
||||
"integrity": "sha512-z08c1l761iKhDFtfXO04C7kTdPBLi41zwOZl00WS8b5eiaebNpY00HKbztwBq+e3vyqWNwWF3mP9YLUeqIrF+Q==",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
@ -711,6 +749,17 @@
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.3.0.tgz",
|
||||
"integrity": "sha512-/iP1rZrSEJ0DTlPiX+jbzlA3eVkY/e8L8SozroF395fIqE3TYF/Nz7YOMAawta+vLmyJ/hkGNNPcSbMADCCXbg==",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-html": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
@ -994,10 +1043,23 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/html-entities": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.3.tgz",
|
||||
"integrity": "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="
|
||||
"node_modules/htmlparser2": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.1.tgz",
|
||||
"integrity": "sha512-4lVbmc1diZC7GUJQtRQ5yBAeUCL1exyMwmForWkRLnwyzWBFxN633SALPMGYaWZvKe9j1pRZJpauvmxENSp/EA==",
|
||||
"funding": [
|
||||
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"domutils": "^3.0.1",
|
||||
"entities": "^4.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-cache-semantics": {
|
||||
"version": "4.1.0",
|
||||
@ -1225,11 +1287,6 @@
|
||||
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
|
||||
"integrity": "sha1-soqmKIorn8ZRA1x3EfZathkDMaY="
|
||||
},
|
||||
"node_modules/lodash.clonedeep": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
|
||||
"integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8="
|
||||
},
|
||||
"node_modules/lodash.defaults": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
||||
@ -1280,21 +1337,11 @@
|
||||
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||
"integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w="
|
||||
},
|
||||
"node_modules/lodash.trim": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.trim/-/lodash.trim-4.5.1.tgz",
|
||||
"integrity": "sha1-NkJefukL5KpeJ7zruFt9EepHqlc="
|
||||
},
|
||||
"node_modules/lodash.union": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz",
|
||||
"integrity": "sha1-SLtQiECfFvGCFmZkHETdGqrjzYg="
|
||||
},
|
||||
"node_modules/lodash.without": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.without/-/lodash.without-4.4.0.tgz",
|
||||
"integrity": "sha1-PNRXSgC2e643OpS3SHcmQFB7eqw="
|
||||
},
|
||||
"node_modules/lowercase-keys": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
|
||||
@ -1625,44 +1672,6 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ranges-apply": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ranges-apply/-/ranges-apply-5.1.0.tgz",
|
||||
"integrity": "sha512-VF3a0XUuYS/BQHv2RaIyX1K7S1hbfrs64hkGKgPVk0Y7p4XFwSucjTTttrBqmkcmB/PZx5ISTZdxErRZi/89aQ==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.14.0",
|
||||
"ranges-merge": "^7.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ranges-merge": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ranges-merge/-/ranges-merge-7.1.0.tgz",
|
||||
"integrity": "sha512-coTHcyAEIhoEdsBs9f5f+q0rmy7UHvS/5nfuXzuj5oLX/l/tbqM5uxRb6eh8WMdetXia3lK67ZO4tarH4ieulQ==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.14.0",
|
||||
"ranges-push": "^5.1.0",
|
||||
"ranges-sort": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ranges-push": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ranges-push/-/ranges-push-5.1.0.tgz",
|
||||
"integrity": "sha512-vqGcaGq7GWV1zBa9w83E+dzYkOvE9/3pIRUPvLf12c+mGQCf1nesrkBI7Ob8taN2CC9V1HDSJx0KAQl0SgZftA==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.14.0",
|
||||
"ranges-merge": "^7.1.0",
|
||||
"string-collapse-leading-whitespace": "^5.1.0",
|
||||
"string-trim-spaces-only": "^3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ranges-sort": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ranges-sort/-/ranges-sort-4.1.0.tgz",
|
||||
"integrity": "sha512-GOQgk6UtsrfKFeYa53YLiBVnLINwYmOk5l2QZG1csZpT6GdImUwooh+/cRrp7b+fYawZX/rnyA3Ul+pdgQBIzA==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/raw-body": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz",
|
||||
@ -1718,11 +1727,6 @@
|
||||
"node": ">=7.6"
|
||||
}
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.13.9",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz",
|
||||
"integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA=="
|
||||
},
|
||||
"node_modules/resolve-alpn": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz",
|
||||
@ -1987,52 +1991,11 @@
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
|
||||
},
|
||||
"node_modules/string-collapse-leading-whitespace": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/string-collapse-leading-whitespace/-/string-collapse-leading-whitespace-5.1.0.tgz",
|
||||
"integrity": "sha512-mYz9/Kb5uvRB4DZj46zILwI4y9lD9JsvXG9Xb7zjbwm0I/R40G7oFfMsqJ28l2d7gWMTLJL569NfJQVLQbnHCw==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string-indexes": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/string-indexes/-/string-indexes-1.0.0.tgz",
|
||||
"integrity": "sha512-RUlx+2YydZJNlRAvoh1siPYWj/Xfk6t1sQLkA5n1tMGRCKkRLzkRtJhHk4qRmKergEBh8R3pWhsUsDqia/bolw=="
|
||||
},
|
||||
"node_modules/string-left-right": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/string-left-right/-/string-left-right-4.1.0.tgz",
|
||||
"integrity": "sha512-ic/WvfNVUygWWsgg8akzSzp2NuttfhrdbH7QmSnda5b5RFmT9aCEDiS/M+gmTJwtFy7+b/2AXU4Z6vejcePQqQ==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.14.0",
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
"lodash.isplainobject": "^4.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/string-strip-html": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string-strip-html/-/string-strip-html-8.3.0.tgz",
|
||||
"integrity": "sha512-1+rjTPt0JjpFr1w0bfNL1S6O0I9fJDqM+P3pFTpC6eEEpIXhmBvPLnaQoEuWarswiH219qCefDSxTLxGQyHKUg==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.14.0",
|
||||
"html-entities": "^2.3.2",
|
||||
"lodash.isplainobject": "^4.0.6",
|
||||
"lodash.trim": "^4.5.1",
|
||||
"lodash.without": "^4.4.0",
|
||||
"ranges-apply": "^5.1.0",
|
||||
"ranges-push": "^5.1.0",
|
||||
"string-left-right": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string-trim-spaces-only": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/string-trim-spaces-only/-/string-trim-spaces-only-3.1.0.tgz",
|
||||
"integrity": "sha512-AW7RSi3+QtE6wR+4m/kmwlyy39neBbCIzrzzu1/RGzNRiPKQOeB3rGzr4ubg4UIQgYtr2w0PrxhKPXgyqJ0vaQ==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-stream": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
|
||||
@ -2229,14 +2192,6 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": {
|
||||
"version": "7.17.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.17.9.tgz",
|
||||
"integrity": "sha512-lSiBBvodq29uShpWGNbgFdKYNiFDo5/HIYsaCEY9ff4sb10x9jizo2+pRrSyF4jKZCXqgzuqBOQKbUm90gQwJg==",
|
||||
"requires": {
|
||||
"regenerator-runtime": "^0.13.4"
|
||||
}
|
||||
},
|
||||
"@sindresorhus/is": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
|
||||
@ -2683,6 +2638,39 @@
|
||||
"streamsearch": "0.1.2"
|
||||
}
|
||||
},
|
||||
"dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||
"requires": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"entities": "^4.2.0"
|
||||
}
|
||||
},
|
||||
"domelementtype": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="
|
||||
},
|
||||
"domhandler": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||
"requires": {
|
||||
"domelementtype": "^2.3.0"
|
||||
}
|
||||
},
|
||||
"domutils": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.0.1.tgz",
|
||||
"integrity": "sha512-z08c1l761iKhDFtfXO04C7kTdPBLi41zwOZl00WS8b5eiaebNpY00HKbztwBq+e3vyqWNwWF3mP9YLUeqIrF+Q==",
|
||||
"requires": {
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
@ -2751,6 +2739,11 @@
|
||||
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.0.4.tgz",
|
||||
"integrity": "sha512-+nVFp+5z1E3HcToEnO7ZIj3g+3k9389DvWtvJZz0T6/eOCPIyyxehFcedoYrZQrp0LgQbD9pPXhpMBKMd5QURg=="
|
||||
},
|
||||
"entities": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.3.0.tgz",
|
||||
"integrity": "sha512-/iP1rZrSEJ0DTlPiX+jbzlA3eVkY/e8L8SozroF395fIqE3TYF/Nz7YOMAawta+vLmyJ/hkGNNPcSbMADCCXbg=="
|
||||
},
|
||||
"escape-html": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
@ -2960,10 +2953,16 @@
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz",
|
||||
"integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A=="
|
||||
},
|
||||
"html-entities": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.3.tgz",
|
||||
"integrity": "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="
|
||||
"htmlparser2": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.1.tgz",
|
||||
"integrity": "sha512-4lVbmc1diZC7GUJQtRQ5yBAeUCL1exyMwmForWkRLnwyzWBFxN633SALPMGYaWZvKe9j1pRZJpauvmxENSp/EA==",
|
||||
"requires": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"domutils": "^3.0.1",
|
||||
"entities": "^4.3.0"
|
||||
}
|
||||
},
|
||||
"http-cache-semantics": {
|
||||
"version": "4.1.0",
|
||||
@ -3154,11 +3153,6 @@
|
||||
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
|
||||
"integrity": "sha1-soqmKIorn8ZRA1x3EfZathkDMaY="
|
||||
},
|
||||
"lodash.clonedeep": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
|
||||
"integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8="
|
||||
},
|
||||
"lodash.defaults": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
||||
@ -3209,21 +3203,11 @@
|
||||
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||
"integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w="
|
||||
},
|
||||
"lodash.trim": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.trim/-/lodash.trim-4.5.1.tgz",
|
||||
"integrity": "sha1-NkJefukL5KpeJ7zruFt9EepHqlc="
|
||||
},
|
||||
"lodash.union": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz",
|
||||
"integrity": "sha1-SLtQiECfFvGCFmZkHETdGqrjzYg="
|
||||
},
|
||||
"lodash.without": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.without/-/lodash.without-4.4.0.tgz",
|
||||
"integrity": "sha1-PNRXSgC2e643OpS3SHcmQFB7eqw="
|
||||
},
|
||||
"lowercase-keys": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
|
||||
@ -3451,44 +3435,6 @@
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="
|
||||
},
|
||||
"ranges-apply": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ranges-apply/-/ranges-apply-5.1.0.tgz",
|
||||
"integrity": "sha512-VF3a0XUuYS/BQHv2RaIyX1K7S1hbfrs64hkGKgPVk0Y7p4XFwSucjTTttrBqmkcmB/PZx5ISTZdxErRZi/89aQ==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.14.0",
|
||||
"ranges-merge": "^7.1.0"
|
||||
}
|
||||
},
|
||||
"ranges-merge": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ranges-merge/-/ranges-merge-7.1.0.tgz",
|
||||
"integrity": "sha512-coTHcyAEIhoEdsBs9f5f+q0rmy7UHvS/5nfuXzuj5oLX/l/tbqM5uxRb6eh8WMdetXia3lK67ZO4tarH4ieulQ==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.14.0",
|
||||
"ranges-push": "^5.1.0",
|
||||
"ranges-sort": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"ranges-push": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ranges-push/-/ranges-push-5.1.0.tgz",
|
||||
"integrity": "sha512-vqGcaGq7GWV1zBa9w83E+dzYkOvE9/3pIRUPvLf12c+mGQCf1nesrkBI7Ob8taN2CC9V1HDSJx0KAQl0SgZftA==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.14.0",
|
||||
"ranges-merge": "^7.1.0",
|
||||
"string-collapse-leading-whitespace": "^5.1.0",
|
||||
"string-trim-spaces-only": "^3.1.0"
|
||||
}
|
||||
},
|
||||
"ranges-sort": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ranges-sort/-/ranges-sort-4.1.0.tgz",
|
||||
"integrity": "sha512-GOQgk6UtsrfKFeYa53YLiBVnLINwYmOk5l2QZG1csZpT6GdImUwooh+/cRrp7b+fYawZX/rnyA3Ul+pdgQBIzA==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.14.0"
|
||||
}
|
||||
},
|
||||
"raw-body": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz",
|
||||
@ -3532,11 +3478,6 @@
|
||||
"resolved": "https://registry.npmjs.org/recursive-readdir-async/-/recursive-readdir-async-1.2.1.tgz",
|
||||
"integrity": "sha512-fU8aySmHIhrycTlXn+hI7dS/p7GnrMHzr2xDdBSd8HZ16mbLkmfIEccIE80gLHftrkTt9oDJiGEJNIPY6n0v6A=="
|
||||
},
|
||||
"regenerator-runtime": {
|
||||
"version": "0.13.9",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz",
|
||||
"integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA=="
|
||||
},
|
||||
"resolve-alpn": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz",
|
||||
@ -3748,52 +3689,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"string-collapse-leading-whitespace": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/string-collapse-leading-whitespace/-/string-collapse-leading-whitespace-5.1.0.tgz",
|
||||
"integrity": "sha512-mYz9/Kb5uvRB4DZj46zILwI4y9lD9JsvXG9Xb7zjbwm0I/R40G7oFfMsqJ28l2d7gWMTLJL569NfJQVLQbnHCw==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.14.0"
|
||||
}
|
||||
},
|
||||
"string-indexes": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/string-indexes/-/string-indexes-1.0.0.tgz",
|
||||
"integrity": "sha512-RUlx+2YydZJNlRAvoh1siPYWj/Xfk6t1sQLkA5n1tMGRCKkRLzkRtJhHk4qRmKergEBh8R3pWhsUsDqia/bolw=="
|
||||
},
|
||||
"string-left-right": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/string-left-right/-/string-left-right-4.1.0.tgz",
|
||||
"integrity": "sha512-ic/WvfNVUygWWsgg8akzSzp2NuttfhrdbH7QmSnda5b5RFmT9aCEDiS/M+gmTJwtFy7+b/2AXU4Z6vejcePQqQ==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.14.0",
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
"lodash.isplainobject": "^4.0.6"
|
||||
}
|
||||
},
|
||||
"string-strip-html": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string-strip-html/-/string-strip-html-8.3.0.tgz",
|
||||
"integrity": "sha512-1+rjTPt0JjpFr1w0bfNL1S6O0I9fJDqM+P3pFTpC6eEEpIXhmBvPLnaQoEuWarswiH219qCefDSxTLxGQyHKUg==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.14.0",
|
||||
"html-entities": "^2.3.2",
|
||||
"lodash.isplainobject": "^4.0.6",
|
||||
"lodash.trim": "^4.5.1",
|
||||
"lodash.without": "^4.4.0",
|
||||
"ranges-apply": "^5.1.0",
|
||||
"ranges-push": "^5.1.0",
|
||||
"string-left-right": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"string-trim-spaces-only": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/string-trim-spaces-only/-/string-trim-spaces-only-3.1.0.tgz",
|
||||
"integrity": "sha512-AW7RSi3+QtE6wR+4m/kmwlyy39neBbCIzrzzu1/RGzNRiPKQOeB3rGzr4ubg4UIQgYtr2w0PrxhKPXgyqJ0vaQ==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.14.0"
|
||||
}
|
||||
},
|
||||
"tar-stream": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.17",
|
||||
"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,7 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -162,13 +162,6 @@ class FolderWatcher extends EventEmitter {
|
||||
}
|
||||
var folderFullPath = folder.fullPath.replace(/\\/g, '/')
|
||||
|
||||
// Check if file was added to root directory
|
||||
var dir = Path.dirname(path)
|
||||
if (dir === folderFullPath) {
|
||||
Logger.warn(`[Watcher] New file "${Path.basename(path)}" added to folder root - ignoring it`)
|
||||
return
|
||||
}
|
||||
|
||||
var relPath = path.replace(folderFullPath, '')
|
||||
|
||||
var hasDotPath = relPath.split('/').find(p => p.startsWith('.'))
|
||||
|
@ -189,8 +189,8 @@ class LibraryItemController {
|
||||
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 +206,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
|
||||
@ -224,38 +223,6 @@ class LibraryItemController {
|
||||
res.json(libraryItem.toJSON())
|
||||
}
|
||||
|
||||
// PATCH: api/items/:id/episodes
|
||||
async updateEpisodes(req, res) { // For updating podcast episode order
|
||||
var libraryItem = req.libraryItem
|
||||
var orderedFileData = req.body.episodes
|
||||
if (!libraryItem.media.setEpisodeOrder) {
|
||||
Logger.error(`[LibraryItemController] updateEpisodes invalid media type ${libraryItem.id}`)
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
libraryItem.media.setEpisodeOrder(orderedFileData)
|
||||
await this.db.updateLibraryItem(libraryItem)
|
||||
this.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
res.json(libraryItem.toJSON())
|
||||
}
|
||||
|
||||
// DELETE: api/items/:id/episode/:episodeId
|
||||
async removeEpisode(req, res) {
|
||||
var episodeId = req.params.episodeId
|
||||
var libraryItem = req.libraryItem
|
||||
if (libraryItem.mediaType !== 'podcast') {
|
||||
Logger.error(`[LibraryItemController] removeEpisode invalid media type ${libraryItem.id}`)
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
if (!libraryItem.media.episodes.find(ep => ep.id === episodeId)) {
|
||||
Logger.error(`[LibraryItemController] removeEpisode episode ${episodeId} not found for item ${libraryItem.id}`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
libraryItem.media.removeEpisode(episodeId)
|
||||
await this.db.updateLibraryItem(libraryItem)
|
||||
this.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
res.json(libraryItem.toJSON())
|
||||
}
|
||||
|
||||
// POST api/items/:id/match
|
||||
async match(req, res) {
|
||||
var libraryItem = req.libraryItem
|
||||
|
@ -109,10 +109,8 @@ class PodcastController {
|
||||
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) => {
|
||||
@ -190,6 +188,35 @@ class PodcastController {
|
||||
res.json(libraryItem.toJSONExpanded())
|
||||
}
|
||||
|
||||
// DELETE: api/podcasts/:id/episode/:episodeId
|
||||
async removeEpisode(req, res) {
|
||||
var episodeId = req.params.episodeId
|
||||
var libraryItem = req.libraryItem
|
||||
var hardDelete = req.query.hard === '1'
|
||||
|
||||
var episode = libraryItem.media.episodes.find(ep => ep.id === episodeId)
|
||||
if (!episode) {
|
||||
Logger.error(`[PodcastController] removeEpisode episode ${episodeId} not found for item ${libraryItem.id}`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
if (hardDelete) {
|
||||
var audioFile = episode.audioFile
|
||||
// TODO: this will trigger the watcher. should maybe handle this gracefully
|
||||
await fs.remove(audioFile.metadata.path).then(() => {
|
||||
Logger.info(`[PodcastController] Hard deleted episode file at "${audioFile.metadata.path}"`)
|
||||
}).catch((error) => {
|
||||
Logger.error(`[PodcastController] Failed to hard delete episode file at "${audioFile.metadata.path}"`, error)
|
||||
})
|
||||
}
|
||||
|
||||
libraryItem.media.removeEpisode(episodeId)
|
||||
|
||||
await this.db.updateLibraryItem(libraryItem)
|
||||
this.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||
res.json(libraryItem.toJSON())
|
||||
}
|
||||
|
||||
middleware(req, res, next) {
|
||||
var item = this.db.libraryItems.find(li => li.id === req.params.id)
|
||||
if (!item || !item.media) return res.sendStatus(404)
|
||||
|
5
server/libs/isJs.js
Normal file
5
server/libs/isJs.js
Normal file
File diff suppressed because one or more lines are too long
174
server/libs/requestIp.js
Normal file
174
server/libs/requestIp.js
Normal 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
874
server/libs/sanitizeHtml.js
Normal 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 it’s 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
if (quote) {
|
||||
s = s.replace(/"/g, '"');
|
||||
}
|
||||
}
|
||||
// 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, '&') // Match ampersands not part of existing HTML entity
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
if (quote) {
|
||||
s = s.replace(/"/g, '"');
|
||||
}
|
||||
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
|
||||
};
|
||||
};
|
||||
};
|
4
server/libs/uaParserJs.js
Normal file
4
server/libs/uaParserJs.js
Normal file
File diff suppressed because one or more lines are too long
@ -1,11 +1,16 @@
|
||||
const Path = require('path')
|
||||
const date = require('date-and-time')
|
||||
const serverVersion = require('../../package.json').version
|
||||
const { PlayMethod } = require('../utils/constants')
|
||||
const PlaybackSession = require('../objects/PlaybackSession')
|
||||
const DeviceInfo = require('../objects/DeviceInfo')
|
||||
const Stream = require('../objects/Stream')
|
||||
const Logger = require('../Logger')
|
||||
const fs = require('fs-extra')
|
||||
|
||||
const uaParserJs = require('../libs/uaParserJs')
|
||||
const requestIp = require('../libs/requestIp')
|
||||
|
||||
class PlaybackSessionManager {
|
||||
constructor(db, emitter, clientEmitter) {
|
||||
this.db = db
|
||||
@ -27,8 +32,21 @@ class PlaybackSessionManager {
|
||||
return session ? session.stream : null
|
||||
}
|
||||
|
||||
async startSessionRequest(user, libraryItem, episodeId, options, res) {
|
||||
const session = await this.startSession(user, libraryItem, episodeId, options)
|
||||
getDeviceInfo(req) {
|
||||
const ua = uaParserJs(req.headers['user-agent'])
|
||||
const ip = requestIp.getClientIp(req)
|
||||
const clientDeviceInfo = req.body ? req.body.deviceInfo || null : null // From mobile client
|
||||
|
||||
const deviceInfo = new DeviceInfo()
|
||||
deviceInfo.setData(ip, ua, clientDeviceInfo, serverVersion)
|
||||
return deviceInfo
|
||||
}
|
||||
|
||||
async startSessionRequest(req, res, episodeId) {
|
||||
const deviceInfo = this.getDeviceInfo(req)
|
||||
|
||||
const { user, libraryItem, body: options } = req
|
||||
const session = await this.startSession(user, deviceInfo, libraryItem, episodeId, options)
|
||||
res.json(session.toJSONForClient(libraryItem))
|
||||
}
|
||||
|
||||
@ -84,7 +102,7 @@ class PlaybackSessionManager {
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
async startSession(user, libraryItem, episodeId, options) {
|
||||
async startSession(user, deviceInfo, libraryItem, episodeId, options) {
|
||||
// Close any sessions already open for user
|
||||
var userSessions = this.sessions.filter(playbackSession => playbackSession.userId === user.id)
|
||||
for (const session of userSessions) {
|
||||
@ -99,7 +117,7 @@ class PlaybackSessionManager {
|
||||
var userStartTime = 0
|
||||
if (userProgress) userStartTime = Number.parseFloat(userProgress.currentTime) || 0
|
||||
const newPlaybackSession = new PlaybackSession()
|
||||
newPlaybackSession.setData(libraryItem, user, mediaPlayer, episodeId)
|
||||
newPlaybackSession.setData(libraryItem, user, mediaPlayer, deviceInfo, userStartTime, episodeId)
|
||||
|
||||
var audioTracks = []
|
||||
if (shouldDirectPlay) {
|
||||
@ -122,7 +140,6 @@ class PlaybackSessionManager {
|
||||
})
|
||||
}
|
||||
|
||||
newPlaybackSession.currentTime = userStartTime
|
||||
newPlaybackSession.audioTracks = audioTracks
|
||||
|
||||
// Will save on the first sync
|
||||
|
74
server/objects/DeviceInfo.js
Normal file
74
server/objects/DeviceInfo.js
Normal file
@ -0,0 +1,74 @@
|
||||
class DeviceInfo {
|
||||
constructor(deviceInfo = null) {
|
||||
this.ipAddress = null
|
||||
|
||||
// From User Agent (see: https://www.npmjs.com/package/ua-parser-js)
|
||||
this.browserName = null
|
||||
this.browserVersion = null
|
||||
this.osName = null
|
||||
this.osVersion = null
|
||||
this.deviceType = null
|
||||
|
||||
// From client
|
||||
this.clientVersion = null
|
||||
this.manufacturer = null
|
||||
this.model = null
|
||||
this.sdkVersion = null // Android Only
|
||||
|
||||
this.serverVersion = null
|
||||
|
||||
if (deviceInfo) {
|
||||
this.construct(deviceInfo)
|
||||
}
|
||||
}
|
||||
|
||||
construct(deviceInfo) {
|
||||
for (const key in deviceInfo) {
|
||||
if (deviceInfo[key] !== undefined && this[key] !== undefined) {
|
||||
this[key] = deviceInfo[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
const obj = {
|
||||
ipAddress: this.ipAddress,
|
||||
browserName: this.browserName,
|
||||
browserVersion: this.browserVersion,
|
||||
osName: this.osName,
|
||||
osVersion: this.osVersion,
|
||||
deviceType: this.deviceType,
|
||||
clientVersion: this.clientVersion,
|
||||
manufacturer: this.manufacturer,
|
||||
model: this.model,
|
||||
sdkVersion: this.sdkVersion,
|
||||
serverVersion: this.serverVersion
|
||||
}
|
||||
for (const key in obj) {
|
||||
if (obj[key] === null || obj[key] === undefined) {
|
||||
delete obj[key]
|
||||
}
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
setData(ip, ua, clientDeviceInfo, serverVersion) {
|
||||
this.ipAddress = ip || null
|
||||
|
||||
const uaObj = ua || {}
|
||||
this.browserName = uaObj.browser.name || null
|
||||
this.browserVersion = uaObj.browser.version || null
|
||||
this.osName = uaObj.os.name || null
|
||||
this.osVersion = uaObj.os.version || null
|
||||
this.deviceType = uaObj.device.type || null
|
||||
|
||||
var cdi = clientDeviceInfo || {}
|
||||
this.clientVersion = cdi.clientVersion || null
|
||||
this.manufacturer = cdi.manufacturer || null
|
||||
this.model = cdi.model || null
|
||||
this.sdkVersion = cdi.sdkVersion || null
|
||||
|
||||
this.serverVersion = serverVersion || null
|
||||
}
|
||||
}
|
||||
module.exports = DeviceInfo
|
@ -3,11 +3,13 @@ const { getId } = require('../utils/index')
|
||||
const { PlayMethod } = require('../utils/constants')
|
||||
const BookMetadata = require('./metadata/BookMetadata')
|
||||
const PodcastMetadata = require('./metadata/PodcastMetadata')
|
||||
const DeviceInfo = require('./DeviceInfo')
|
||||
|
||||
class PlaybackSession {
|
||||
constructor(session) {
|
||||
this.id = null
|
||||
this.userId = null
|
||||
this.libraryId = null
|
||||
this.libraryItemId = null
|
||||
this.episodeId = null
|
||||
|
||||
@ -21,18 +23,21 @@ class PlaybackSession {
|
||||
|
||||
this.playMethod = null
|
||||
this.mediaPlayer = null
|
||||
this.deviceInfo = null
|
||||
|
||||
this.date = null
|
||||
this.dayOfWeek = null
|
||||
|
||||
this.timeListening = null
|
||||
this.startTime = null // media current time at start of playback
|
||||
this.currentTime = 0 // Last current time set
|
||||
|
||||
this.startedAt = null
|
||||
this.updatedAt = null
|
||||
|
||||
// Not saved in DB
|
||||
this.lastSave = 0
|
||||
this.audioTracks = []
|
||||
this.currentTime = 0
|
||||
this.stream = null
|
||||
|
||||
if (session) {
|
||||
@ -43,8 +48,8 @@ class PlaybackSession {
|
||||
toJSON() {
|
||||
return {
|
||||
id: this.id,
|
||||
sessionType: this.sessionType,
|
||||
userId: this.userId,
|
||||
libraryId: this.libraryId,
|
||||
libraryItemId: this.libraryItemId,
|
||||
episodeId: this.episodeId,
|
||||
mediaType: this.mediaType,
|
||||
@ -56,10 +61,13 @@ class PlaybackSession {
|
||||
duration: this.duration,
|
||||
playMethod: this.playMethod,
|
||||
mediaPlayer: this.mediaPlayer,
|
||||
deviceInfo: this.deviceInfo ? this.deviceInfo.toJSON() : null,
|
||||
date: this.date,
|
||||
dayOfWeek: this.dayOfWeek,
|
||||
timeListening: this.timeListening,
|
||||
lastUpdate: this.lastUpdate,
|
||||
startTime: this.startTime,
|
||||
currentTime: this.currentTime,
|
||||
startedAt: this.startedAt,
|
||||
updatedAt: this.updatedAt
|
||||
}
|
||||
}
|
||||
@ -67,8 +75,8 @@ class PlaybackSession {
|
||||
toJSONForClient(libraryItem) {
|
||||
return {
|
||||
id: this.id,
|
||||
sessionType: this.sessionType,
|
||||
userId: this.userId,
|
||||
libraryId: this.libraryId,
|
||||
libraryItemId: this.libraryItemId,
|
||||
episodeId: this.episodeId,
|
||||
mediaType: this.mediaType,
|
||||
@ -80,27 +88,30 @@ class PlaybackSession {
|
||||
duration: this.duration,
|
||||
playMethod: this.playMethod,
|
||||
mediaPlayer: this.mediaPlayer,
|
||||
deviceInfo: this.deviceInfo ? this.deviceInfo.toJSON() : null,
|
||||
date: this.date,
|
||||
dayOfWeek: this.dayOfWeek,
|
||||
timeListening: this.timeListening,
|
||||
lastUpdate: this.lastUpdate,
|
||||
startTime: this.startTime,
|
||||
currentTime: this.currentTime,
|
||||
startedAt: this.startedAt,
|
||||
updatedAt: this.updatedAt,
|
||||
audioTracks: this.audioTracks.map(at => at.toJSON()),
|
||||
currentTime: this.currentTime,
|
||||
libraryItem: libraryItem.toJSONExpanded()
|
||||
}
|
||||
}
|
||||
|
||||
construct(session) {
|
||||
this.id = session.id
|
||||
this.sessionType = session.sessionType
|
||||
this.userId = session.userId
|
||||
this.libraryId = session.libraryId || null
|
||||
this.libraryItemId = session.libraryItemId
|
||||
this.episodeId = session.episodeId
|
||||
this.mediaType = session.mediaType
|
||||
this.duration = session.duration
|
||||
this.playMethod = session.playMethod
|
||||
this.mediaPlayer = session.mediaPlayer || null
|
||||
this.deviceInfo = new DeviceInfo(session.deviceInfo)
|
||||
this.chapters = session.chapters || []
|
||||
|
||||
this.mediaMetadata = null
|
||||
@ -118,6 +129,9 @@ class PlaybackSession {
|
||||
this.dayOfWeek = session.dayOfWeek
|
||||
|
||||
this.timeListening = session.timeListening || null
|
||||
this.startTime = session.startTime || 0
|
||||
this.currentTime = session.currentTime || 0
|
||||
|
||||
this.startedAt = session.startedAt
|
||||
this.updatedAt = session.updatedAt || null
|
||||
}
|
||||
@ -127,9 +141,10 @@ class PlaybackSession {
|
||||
return Math.max(0, Math.min(this.currentTime / this.duration, 1))
|
||||
}
|
||||
|
||||
setData(libraryItem, user, mediaPlayer, episodeId = null) {
|
||||
setData(libraryItem, user, mediaPlayer, deviceInfo, startTime, episodeId = null) {
|
||||
this.id = getId('play')
|
||||
this.userId = user.id
|
||||
this.libraryId = libraryItem.libraryId
|
||||
this.libraryItemId = libraryItem.id
|
||||
this.episodeId = episodeId
|
||||
this.mediaType = libraryItem.mediaType
|
||||
@ -146,8 +161,12 @@ class PlaybackSession {
|
||||
}
|
||||
|
||||
this.mediaPlayer = mediaPlayer
|
||||
this.deviceInfo = deviceInfo || new DeviceInfo()
|
||||
|
||||
this.timeListening = 0
|
||||
this.startTime = startTime
|
||||
this.currentTime = startTime
|
||||
|
||||
this.date = date.format(new Date(), 'YYYY-MM-DD')
|
||||
this.dayOfWeek = date.format(new Date(), 'dddd')
|
||||
this.startedAt = Date.now()
|
||||
|
@ -1,4 +1,3 @@
|
||||
const { stripHtml } = require('string-strip-html')
|
||||
const { getId } = require('../../utils/index')
|
||||
const AudioFile = require('../files/AudioFile')
|
||||
const AudioTrack = require('../files/AudioTrack')
|
||||
@ -78,8 +77,7 @@ class PodcastEpisode {
|
||||
episodeType: this.episodeType,
|
||||
title: this.title,
|
||||
subtitle: this.subtitle,
|
||||
// description: this.description,
|
||||
description: this.descriptionPlain, // Temporary stripping HTML until proper cleaning is implemented
|
||||
description: this.description,
|
||||
enclosure: this.enclosure ? { ...this.enclosure } : null,
|
||||
pubDate: this.pubDate,
|
||||
audioFile: this.audioFile.toJSON(),
|
||||
@ -108,10 +106,6 @@ class PodcastEpisode {
|
||||
if (this.episode) return `${this.episode} - ${this.title}`
|
||||
return this.title
|
||||
}
|
||||
get descriptionPlain() {
|
||||
if (!this.description) return ''
|
||||
return stripHtml(this.description).result
|
||||
}
|
||||
|
||||
setData(data, index = 1) {
|
||||
this.id = getId('ep')
|
||||
|
@ -224,18 +224,10 @@ class Podcast {
|
||||
this.episodes.push(pe)
|
||||
}
|
||||
|
||||
setEpisodeOrder(episodeIds) {
|
||||
episodeIds.reverse() // episode Ids will already be in descending order
|
||||
this.episodes = this.episodes.map(ep => {
|
||||
var indexOf = episodeIds.findIndex(id => id === ep.id)
|
||||
ep.index = indexOf + 1
|
||||
return ep
|
||||
})
|
||||
this.episodes.sort((a, b) => b.index - a.index)
|
||||
}
|
||||
|
||||
reorderEpisodes() {
|
||||
var hasUpdates = false
|
||||
|
||||
// TODO: Sort by published date
|
||||
this.episodes = naturalSort(this.episodes).asc((ep) => ep.bestFilename)
|
||||
for (let i = 0; i < this.episodes.length; i++) {
|
||||
if (this.episodes[i].index !== (i + 1)) {
|
||||
|
@ -1,16 +1,16 @@
|
||||
const axios = require('axios')
|
||||
const { stripHtml } = require('string-strip-html')
|
||||
const htmlSanitizer = require('../utils/htmlSanitizer')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
class Audible {
|
||||
constructor() { }
|
||||
|
||||
cleanResult(item) {
|
||||
var { title, subtitle, asin, authors, narrators, publisherName, summary, releaseDate, image, genres, seriesPrimary, seriesSecondary, language } = item;
|
||||
var { title, subtitle, asin, authors, narrators, publisherName, summary, releaseDate, image, genres, seriesPrimary, seriesSecondary, language } = item
|
||||
|
||||
var series = []
|
||||
if(seriesPrimary) series.push(seriesPrimary)
|
||||
if(seriesSecondary) series.push(seriesSecondary)
|
||||
if (seriesPrimary) series.push(seriesPrimary)
|
||||
if (seriesSecondary) series.push(seriesSecondary)
|
||||
|
||||
var genresFiltered = genres ? genres.filter(g => g.type == "genre") : []
|
||||
var tagsFiltered = genres ? genres.filter(g => g.type == "tag") : []
|
||||
@ -22,12 +22,12 @@ class Audible {
|
||||
narrator: narrators ? narrators.map(({ name }) => name).join(', ') : null,
|
||||
publisher: publisherName,
|
||||
publishedYear: releaseDate ? releaseDate.split('-')[0] : null,
|
||||
description: summary ? stripHtml(summary).result : null,
|
||||
description: summary ? htmlSanitizer.stripAllTags(summary) : null,
|
||||
cover: image,
|
||||
asin,
|
||||
genres: genresFiltered.length > 0 ? genresFiltered.map(({ name }) => name).join(', ') : null,
|
||||
tags: tagsFiltered.length > 0 ? tagsFiltered.map(({ name }) => name).join(', ') : null,
|
||||
series: series != [] ? series.map(({name, position}) => ({ series: name, volumeNumber: position })) : null,
|
||||
series: series != [] ? series.map(({ name, position }) => ({ series: name, volumeNumber: position })) : null,
|
||||
language: language ? language.charAt(0).toUpperCase() + language.slice(1) : null
|
||||
}
|
||||
}
|
||||
@ -49,17 +49,17 @@ class Audible {
|
||||
})
|
||||
}
|
||||
|
||||
async search(title, author, asin) {
|
||||
async search(title, author, asin) {
|
||||
var items
|
||||
if(asin) {
|
||||
if (asin) {
|
||||
items = [await this.asinSearch(asin)]
|
||||
}
|
||||
|
||||
|
||||
if (!items && this.isProbablyAsin(title)) {
|
||||
items = [await this.asinSearch(title)]
|
||||
}
|
||||
|
||||
if(!items) {
|
||||
if (!items) {
|
||||
var queryObj = {
|
||||
num_results: '10',
|
||||
products_sort_by: 'Relevance',
|
||||
|
@ -1,6 +1,7 @@
|
||||
const axios = require('axios')
|
||||
const Logger = require('../Logger')
|
||||
const { stripHtml } = require('string-strip-html')
|
||||
const htmlSanitizer = require('../utils/htmlSanitizer')
|
||||
|
||||
class iTunes {
|
||||
constructor() { }
|
||||
|
||||
@ -64,7 +65,7 @@ class iTunes {
|
||||
artistId: data.artistId,
|
||||
title: data.collectionName,
|
||||
author: data.artistName,
|
||||
description: stripHtml(data.description || '').result,
|
||||
description: htmlSanitizer.stripAllTags(data.description || ''),
|
||||
publishedYear: data.releaseDate ? data.releaseDate.split('-')[0] : null,
|
||||
genres: data.primaryGenreName ? [data.primaryGenreName] : [],
|
||||
cover: this.getCoverArtwork(data)
|
||||
@ -83,7 +84,8 @@ class iTunes {
|
||||
artistId: data.artistId || null,
|
||||
title: data.collectionName,
|
||||
artistName: data.artistName,
|
||||
description: stripHtml(data.description || '').result,
|
||||
description: htmlSanitizer.sanitize(data.description || ''),
|
||||
descriptionPlain: htmlSanitizer.stripAllTags(data.description || ''),
|
||||
releaseDate: data.releaseDate,
|
||||
genres: data.genres || [],
|
||||
cover: this.getCoverArtwork(data),
|
||||
|
@ -90,8 +90,6 @@ class ApiRouter {
|
||||
this.router.post('/items/:id/play', LibraryItemController.middleware.bind(this), LibraryItemController.startPlaybackSession.bind(this))
|
||||
this.router.post('/items/:id/play/:episodeId', LibraryItemController.middleware.bind(this), LibraryItemController.startEpisodePlaybackSession.bind(this))
|
||||
this.router.patch('/items/:id/tracks', LibraryItemController.middleware.bind(this), LibraryItemController.updateTracks.bind(this))
|
||||
this.router.patch('/items/:id/episodes', LibraryItemController.middleware.bind(this), LibraryItemController.updateEpisodes.bind(this))
|
||||
this.router.delete('/items/:id/episode/:episodeId', LibraryItemController.middleware.bind(this), LibraryItemController.removeEpisode.bind(this))
|
||||
this.router.get('/items/:id/scan', LibraryItemController.middleware.bind(this), LibraryItemController.scan.bind(this))
|
||||
this.router.get('/items/:id/audio-metadata', LibraryItemController.middleware.bind(this), LibraryItemController.updateAudioFileMetadata.bind(this))
|
||||
this.router.post('/items/:id/chapters', LibraryItemController.middleware.bind(this), LibraryItemController.updateMediaChapters.bind(this))
|
||||
@ -111,7 +109,7 @@ class ApiRouter {
|
||||
this.router.patch('/users/:id', UserController.update.bind(this))
|
||||
this.router.delete('/users/:id', UserController.delete.bind(this))
|
||||
|
||||
this.router.get('/users/:id/listening-sessions', UserController.getListeningStats.bind(this))
|
||||
this.router.get('/users/:id/listening-sessions', UserController.getListeningSessions.bind(this))
|
||||
this.router.get('/users/:id/listening-stats', UserController.getListeningStats.bind(this))
|
||||
|
||||
//
|
||||
@ -189,6 +187,7 @@ class ApiRouter {
|
||||
this.router.get('/podcasts/:id/clear-queue', PodcastController.middleware.bind(this), PodcastController.clearEpisodeDownloadQueue.bind(this))
|
||||
this.router.post('/podcasts/:id/download-episodes', PodcastController.middleware.bind(this), PodcastController.downloadEpisodes.bind(this))
|
||||
this.router.patch('/podcasts/:id/episode/:episodeId', PodcastController.middleware.bind(this), PodcastController.updateEpisode.bind(this))
|
||||
this.router.delete('/podcasts/:id/episode/:episodeId', PodcastController.middleware.bind(this), PodcastController.removeEpisode.bind(this))
|
||||
|
||||
//
|
||||
// Misc Routes
|
||||
|
@ -17,7 +17,9 @@ class StaticRouter {
|
||||
if (!item) return res.status(404).send('Item not found with id ' + req.params.id)
|
||||
|
||||
var remainingPath = req.params['0']
|
||||
var fullPath = Path.join(item.path, remainingPath)
|
||||
var fullPath = null
|
||||
if (item.isFile) fullPath = item.path
|
||||
else fullPath = Path.join(item.path, remainingPath)
|
||||
res.sendFile(fullPath)
|
||||
})
|
||||
}
|
||||
|
@ -62,7 +62,8 @@ class Scanner {
|
||||
}
|
||||
|
||||
async scanLibraryItem(libraryMediaType, folder, libraryItem) {
|
||||
var libraryItemData = await getLibraryItemFileData(libraryMediaType, folder, libraryItem.path, this.db.serverSettings)
|
||||
// TODO: Support for single media item
|
||||
var libraryItemData = await getLibraryItemFileData(libraryMediaType, folder, libraryItem.path, false, this.db.serverSettings)
|
||||
if (!libraryItemData) {
|
||||
return ScanResult.NOTHING
|
||||
}
|
||||
@ -499,7 +500,11 @@ class Scanner {
|
||||
continue;
|
||||
}
|
||||
var relFilePaths = folderGroups[folderId].fileUpdates.map(fileUpdate => fileUpdate.relPath)
|
||||
var fileUpdateGroup = groupFilesIntoLibraryItemPaths(relFilePaths, true)
|
||||
var fileUpdateGroup = groupFilesIntoLibraryItemPaths(library.mediaType, relFilePaths)
|
||||
if (!Object.keys(fileUpdateGroup).length) {
|
||||
Logger.info(`[Scanner] No important changes to scan for in folder "${folderId}"`)
|
||||
continue;
|
||||
}
|
||||
var folderScanResults = await this.scanFolderUpdates(library, folder, fileUpdateGroup)
|
||||
Logger.debug(`[Scanner] Folder scan results`, folderScanResults)
|
||||
}
|
||||
@ -513,6 +518,8 @@ class Scanner {
|
||||
// Test Case: Moving audio files from library item folder to author folder should trigger a re-scan of the item
|
||||
var updateGroup = { ...fileUpdateGroup }
|
||||
for (const itemDir in updateGroup) {
|
||||
if (itemDir == fileUpdateGroup[itemDir]) continue; // Media in root path
|
||||
|
||||
var itemDirNestedFiles = fileUpdateGroup[itemDir].filter(b => b.includes('/'))
|
||||
if (!itemDirNestedFiles.length) continue;
|
||||
|
||||
@ -582,7 +589,8 @@ class Scanner {
|
||||
}
|
||||
|
||||
Logger.debug(`[Scanner] Folder update group must be a new item "${itemDir}" in library "${library.name}"`)
|
||||
var newLibraryItem = await this.scanPotentialNewLibraryItem(library.mediaType, folder, fullPath)
|
||||
var isSingleMediaItem = itemDir === fileUpdateGroup[itemDir]
|
||||
var newLibraryItem = await this.scanPotentialNewLibraryItem(library.mediaType, folder, fullPath, isSingleMediaItem)
|
||||
if (newLibraryItem) {
|
||||
await this.createNewAuthorsAndSeries(newLibraryItem)
|
||||
await this.db.insertLibraryItem(newLibraryItem)
|
||||
@ -594,8 +602,8 @@ class Scanner {
|
||||
return itemGroupingResults
|
||||
}
|
||||
|
||||
async scanPotentialNewLibraryItem(libraryMediaType, folder, fullPath) {
|
||||
var libraryItemData = await getLibraryItemFileData(libraryMediaType, folder, fullPath, this.db.serverSettings)
|
||||
async scanPotentialNewLibraryItem(libraryMediaType, folder, fullPath, isSingleMediaItem = false) {
|
||||
var libraryItemData = await getLibraryItemFileData(libraryMediaType, folder, fullPath, isSingleMediaItem, this.db.serverSettings)
|
||||
if (!libraryItemData) return null
|
||||
var serverSettings = this.db.serverSettings
|
||||
return this.scanNewLibraryItem(libraryItemData, libraryMediaType, serverSettings.scannerPreferAudioMetadata, serverSettings.scannerPreferOpfMetadata, serverSettings.scannerFindCovers)
|
||||
|
28
server/utils/htmlSanitizer.js
Normal file
28
server/utils/htmlSanitizer.js
Normal file
@ -0,0 +1,28 @@
|
||||
const sanitizeHtml = require('../libs/sanitizeHtml')
|
||||
|
||||
function sanitize(html) {
|
||||
const sanitizerOptions = {
|
||||
allowedTags: [
|
||||
'p', 'ol', 'ul', 'li', 'a', 'strong', 'em', 'del'
|
||||
],
|
||||
disallowedTagsMode: 'discard',
|
||||
allowedAttributes: {
|
||||
a: ['href', 'name', 'target']
|
||||
},
|
||||
allowedSchemes: ['https'],
|
||||
allowProtocolRelative: false
|
||||
}
|
||||
|
||||
return sanitizeHtml(html, sanitizerOptions)
|
||||
}
|
||||
module.exports.sanitize = sanitize
|
||||
|
||||
function stripAllTags(html) {
|
||||
const sanitizerOptions = {
|
||||
allowedTags: [],
|
||||
disallowedTagsMode: 'discard'
|
||||
}
|
||||
|
||||
return sanitizeHtml(html, sanitizerOptions)
|
||||
}
|
||||
module.exports.stripAllTags = stripAllTags
|
@ -1,5 +1,5 @@
|
||||
const { xmlToJSON } = require('./index')
|
||||
const { stripHtml } = require("string-strip-html")
|
||||
const htmlSanitizer = require('./htmlSanitizer')
|
||||
|
||||
function parseCreators(metadata) {
|
||||
if (!metadata['dc:creator']) return null
|
||||
@ -57,8 +57,7 @@ function fetchDescription(metadata) {
|
||||
// check if description is HTML or plain text. only plain text allowed
|
||||
// calibre stores < and > as < and >
|
||||
description = description.replace(/</g, '<').replace(/>/g, '>')
|
||||
if (description.match(/<!DOCTYPE html>|<\/?\s*[a-z-][^>]*\s*>|(\&(?:[\w\d]+|#\d+|#x[a-f\d]+);)/)) return stripHtml(description).result
|
||||
return description
|
||||
return htmlSanitizer.stripAllTags(description)
|
||||
}
|
||||
|
||||
function fetchGenres(metadata) {
|
||||
|
@ -1,6 +1,6 @@
|
||||
const Logger = require('../Logger')
|
||||
const { xmlToJSON } = require('./index')
|
||||
const { stripHtml } = require('string-strip-html')
|
||||
const htmlSanitizer = require('../utils/htmlSanitizer')
|
||||
|
||||
function extractFirstArrayItem(json, key) {
|
||||
if (!json[key] || !json[key].length) return null
|
||||
@ -55,8 +55,9 @@ function extractPodcastMetadata(channel) {
|
||||
}
|
||||
|
||||
if (channel['description']) {
|
||||
metadata.description = extractFirstArrayItem(channel, 'description')
|
||||
metadata.descriptionPlain = stripHtml(metadata.description || '').result
|
||||
const rawDescription = extractFirstArrayItem(channel, 'description') || ''
|
||||
metadata.description = htmlSanitizer.sanitize(rawDescription)
|
||||
metadata.descriptionPlain = htmlSanitizer.stripAllTags(rawDescription)
|
||||
}
|
||||
|
||||
var arrayFields = ['title', 'language', 'itunes:explicit', 'itunes:author', 'pubDate', 'link']
|
||||
@ -80,9 +81,17 @@ function extractEpisodeData(item) {
|
||||
}
|
||||
}
|
||||
|
||||
// Full description with html
|
||||
if (item['content:encoded']) {
|
||||
const rawDescription = (extractFirstArrayItem(item, 'content:encoded') || '').trim()
|
||||
episode.description = htmlSanitizer.sanitize(rawDescription)
|
||||
}
|
||||
|
||||
// Supposed to be the plaintext description but not always followed
|
||||
if (item['description']) {
|
||||
episode.description = extractFirstArrayItem(item, 'description')
|
||||
episode.descriptionPlain = stripHtml(episode.description || '').result
|
||||
const rawDescription = extractFirstArrayItem(item, 'description') || ''
|
||||
if (!episode.description) episode.description = htmlSanitizer.sanitize(rawDescription)
|
||||
episode.descriptionPlain = htmlSanitizer.stripAllTags(rawDescription)
|
||||
}
|
||||
|
||||
var arrayFields = ['title', 'pubDate', 'itunes:episodeType', 'itunes:season', 'itunes:episode', 'itunes:author', 'itunes:duration', 'itunes:explicit', 'itunes:subtitle']
|
||||
|
@ -17,11 +17,14 @@ function isMediaFile(mediaType, ext) {
|
||||
// TODO: Function needs to be re-done
|
||||
// Input: array of relative file paths
|
||||
// Output: map of files grouped into potential item dirs
|
||||
function groupFilesIntoLibraryItemPaths(paths) {
|
||||
// Step 1: Clean path, Remove leading "/", Filter out files in root dir
|
||||
function groupFilesIntoLibraryItemPaths(mediaType, paths) {
|
||||
// Step 1: Clean path, Remove leading "/", Filter out non-media files in root dir
|
||||
var pathsFiltered = paths.map(path => {
|
||||
return path.startsWith('/') ? path.slice(1) : path
|
||||
}).filter(path => Path.parse(path).dir)
|
||||
}).filter(path => {
|
||||
let parsedPath = Path.parse(path)
|
||||
return parsedPath.dir || (mediaType === 'book' && isMediaFile(mediaType, parsedPath.ext))
|
||||
})
|
||||
|
||||
// Step 2: Sort by least number of directories
|
||||
pathsFiltered.sort((a, b) => {
|
||||
@ -33,25 +36,30 @@ function groupFilesIntoLibraryItemPaths(paths) {
|
||||
// Step 3: Group files in dirs
|
||||
var itemGroup = {}
|
||||
pathsFiltered.forEach((path) => {
|
||||
var dirparts = Path.dirname(path).split('/')
|
||||
var dirparts = Path.dirname(path).split('/').filter(p => !!p && p !== '.') // dirname returns . if no directory
|
||||
var numparts = dirparts.length
|
||||
var _path = ''
|
||||
|
||||
// Iterate over directories in path
|
||||
for (let i = 0; i < numparts; i++) {
|
||||
var dirpart = dirparts.shift()
|
||||
_path = Path.posix.join(_path, dirpart)
|
||||
if (!numparts) {
|
||||
// Media file in root
|
||||
itemGroup[path] = path
|
||||
} else {
|
||||
// Iterate over directories in path
|
||||
for (let i = 0; i < numparts; i++) {
|
||||
var dirpart = dirparts.shift()
|
||||
_path = Path.posix.join(_path, dirpart)
|
||||
|
||||
if (itemGroup[_path]) { // Directory already has files, add file
|
||||
var relpath = Path.posix.join(dirparts.join('/'), Path.basename(path))
|
||||
itemGroup[_path].push(relpath)
|
||||
return
|
||||
} else if (!dirparts.length) { // This is the last directory, create group
|
||||
itemGroup[_path] = [Path.basename(path)]
|
||||
return
|
||||
} else if (dirparts.length === 1 && /^cd\d{1,3}$/i.test(dirparts[0])) { // Next directory is the last and is a CD dir, create group
|
||||
itemGroup[_path] = [Path.posix.join(dirparts[0], Path.basename(path))]
|
||||
return
|
||||
if (itemGroup[_path]) { // Directory already has files, add file
|
||||
var relpath = Path.posix.join(dirparts.join('/'), Path.basename(path))
|
||||
itemGroup[_path].push(relpath)
|
||||
return
|
||||
} else if (!dirparts.length) { // This is the last directory, create group
|
||||
itemGroup[_path] = [Path.basename(path)]
|
||||
return
|
||||
} else if (dirparts.length === 1 && /^cd\d{1,3}$/i.test(dirparts[0])) { // Next directory is the last and is a CD dir, create group
|
||||
itemGroup[_path] = [Path.posix.join(dirparts[0], Path.basename(path))]
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@ -62,9 +70,9 @@ module.exports.groupFilesIntoLibraryItemPaths = groupFilesIntoLibraryItemPaths
|
||||
// Input: array of relative file items (see recurseFiles)
|
||||
// Output: map of files grouped into potential libarary item dirs
|
||||
function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems) {
|
||||
// Step 1: Filter out non-media files in root dir (with depth of 0)
|
||||
// Step 1: Filter out non-book-media files in root dir (with depth of 0)
|
||||
var itemsFiltered = fileItems.filter(i => {
|
||||
return i.deep > 0 || isMediaFile(mediaType, i.extension)
|
||||
return i.deep > 0 || (mediaType === 'book' && isMediaFile(mediaType, i.extension))
|
||||
})
|
||||
|
||||
// Step 2: Seperate media files and other files
|
||||
@ -128,7 +136,7 @@ function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems) {
|
||||
}
|
||||
|
||||
function cleanFileObjects(libraryItemPath, files) {
|
||||
return Promise.all(files.map(async (file) => {
|
||||
return Promise.all(files.map(async(file) => {
|
||||
var filePath = Path.posix.join(libraryItemPath, file)
|
||||
var newLibraryFile = new LibraryFile()
|
||||
await newLibraryFile.setDataFromPath(filePath, file)
|
||||
@ -147,16 +155,6 @@ async function scanFolder(libraryMediaType, folder, serverSettings = {}) {
|
||||
}
|
||||
|
||||
var fileItems = await recurseFiles(folderPath)
|
||||
var basePath = folderPath
|
||||
|
||||
const isOpenAudibleFolder = fileItems.find(fi => fi.deep === 0 && fi.name === 'books.json')
|
||||
if (isOpenAudibleFolder) {
|
||||
Logger.info(`[scandir] Detected Open Audible Folder, looking in books folder`)
|
||||
basePath = Path.posix.join(folderPath, 'books')
|
||||
fileItems = await recurseFiles(basePath)
|
||||
Logger.debug(`[scandir] ${fileItems.length} files found in books folder`)
|
||||
}
|
||||
|
||||
var libraryItemGrouping = groupFileItemsIntoLibraryItemDirs(libraryMediaType, fileItems)
|
||||
|
||||
if (!Object.keys(libraryItemGrouping).length) {
|
||||
@ -175,10 +173,10 @@ async function scanFolder(libraryMediaType, folder, serverSettings = {}) {
|
||||
mediaMetadata: {
|
||||
title: Path.basename(libraryItemPath, Path.extname(libraryItemPath))
|
||||
},
|
||||
path: Path.posix.join(basePath, libraryItemPath),
|
||||
path: Path.posix.join(folderPath, libraryItemPath),
|
||||
relPath: libraryItemPath
|
||||
}
|
||||
fileObjs = await cleanFileObjects(basePath, [libraryItemPath])
|
||||
fileObjs = await cleanFileObjects(folderPath, [libraryItemPath])
|
||||
isFile = true
|
||||
} else {
|
||||
libraryItemData = getDataFromMediaDir(libraryMediaType, folderPath, libraryItemPath, serverSettings)
|
||||
@ -211,83 +209,16 @@ function getBookDataFromDir(folderPath, relPath, parseSubtitle = false) {
|
||||
relPath = relPath.replace(/\\/g, '/')
|
||||
var splitDir = relPath.split('/')
|
||||
|
||||
// Audio files will always be in the directory named for the title
|
||||
var [title, narrators] = getTitleAndNarrator(splitDir.pop())
|
||||
var folder = splitDir.pop() // Audio files will always be in the directory named for the title
|
||||
series = (splitDir.length > 1) ? splitDir.pop() : null // If there are at least 2 more directories, next furthest will be the series
|
||||
author = (splitDir.length > 0) ? splitDir.pop() : null // There could be many more directories, but only the top 3 are used for naming /author/series/title/
|
||||
|
||||
var series = null
|
||||
var author = null
|
||||
// If there are at least 2 more directories, next furthest will be the series
|
||||
if (splitDir.length > 1) series = splitDir.pop()
|
||||
if (splitDir.length > 0) author = splitDir.pop()
|
||||
// There could be many more directories, but only the top 3 are used for naming /author/series/title/
|
||||
|
||||
|
||||
// If in a series directory check for volume number match
|
||||
/* ACCEPTS
|
||||
Book 2 - Title Here - Subtitle Here
|
||||
Title Here - Subtitle Here - Vol 12
|
||||
Title Here - volume 9 - Subtitle Here
|
||||
Vol. 3 Title Here - Subtitle Here
|
||||
1980 - Book 2-Title Here
|
||||
Title Here-Volume 999-Subtitle Here
|
||||
2 - Book Title
|
||||
100 - Book Title
|
||||
0.5 - Book Title
|
||||
*/
|
||||
var volumeNumber = null
|
||||
if (series) {
|
||||
// Added 1.7.1: If title starts with a # that is 3 digits or less (or w/ 2 decimal), then use as volume number
|
||||
var volumeMatch = title.match(/^(\d{1,3}(?:\.\d{1,2})?) - ./)
|
||||
if (volumeMatch && volumeMatch.length > 1) {
|
||||
volumeNumber = volumeMatch[1]
|
||||
title = title.replace(`${volumeNumber} - `, '')
|
||||
} else {
|
||||
// Match volumes with decimal (OLD: /(-? ?)\b((?:Book|Vol.?|Volume) (\d{1,3}))\b( ?-?)/i)
|
||||
var volumeMatch = title.match(/(-? ?)\b((?:Book|Vol.?|Volume) (\d{0,3}(?:\.\d{1,2})?))\b( ?-?)/i)
|
||||
if (volumeMatch && volumeMatch.length > 3 && volumeMatch[2] && volumeMatch[3]) {
|
||||
volumeNumber = volumeMatch[3]
|
||||
var replaceChunk = volumeMatch[2]
|
||||
|
||||
// "1980 - Book 2-Title Here"
|
||||
// Group 1 would be "- "
|
||||
// Group 3 would be "-"
|
||||
// Only remove the first group
|
||||
if (volumeMatch[1]) {
|
||||
replaceChunk = volumeMatch[1] + replaceChunk
|
||||
} else if (volumeMatch[4]) {
|
||||
replaceChunk += volumeMatch[4]
|
||||
}
|
||||
title = title.replace(replaceChunk, '').trim()
|
||||
}
|
||||
}
|
||||
|
||||
if (volumeNumber != null && !isNaN(volumeNumber)) {
|
||||
volumeNumber = String(Number(volumeNumber)) // Strips leading zeros
|
||||
}
|
||||
}
|
||||
|
||||
var publishedYear = null
|
||||
// If Title is of format 1999 OR (1999) - Title, then use 1999 as publish year
|
||||
var publishYearMatch = title.match(/^(\(?[0-9]{4}\)?) - (.+)/)
|
||||
if (publishYearMatch && publishYearMatch.length > 2 && publishYearMatch[1]) {
|
||||
// Strip parentheses
|
||||
if (publishYearMatch[1].startsWith('(') && publishYearMatch[1].endsWith(')')) {
|
||||
publishYearMatch[1] = publishYearMatch[1].slice(1, -1)
|
||||
}
|
||||
if (!isNaN(publishYearMatch[1])) {
|
||||
publishedYear = publishYearMatch[1]
|
||||
title = publishYearMatch[2]
|
||||
}
|
||||
}
|
||||
|
||||
// Subtitle can be parsed from the title if user enabled
|
||||
// Subtitle is everything after " - "
|
||||
var subtitle = null
|
||||
if (parseSubtitle && title.includes(' - ')) {
|
||||
var splitOnSubtitle = title.split(' - ')
|
||||
title = splitOnSubtitle.shift()
|
||||
subtitle = splitOnSubtitle.join(' - ')
|
||||
}
|
||||
// The may contain various other pieces of metadata, these functions extract it.
|
||||
var [folder, narrators] = getNarrator(folder)
|
||||
if (series) { var [folder, sequence] = getSequence(folder) }
|
||||
var [folder, sequence] = series ? getSequence(folder) : [folder, null]
|
||||
var [folder, publishedYear] = getPublishedYear(folder)
|
||||
var [title, subtitle] = parseSubtitle ? getSubtitle(folder) : [folder, null]
|
||||
|
||||
return {
|
||||
mediaMetadata: {
|
||||
@ -295,7 +226,7 @@ function getBookDataFromDir(folderPath, relPath, parseSubtitle = false) {
|
||||
title,
|
||||
subtitle,
|
||||
series,
|
||||
sequence: volumeNumber,
|
||||
sequence,
|
||||
publishedYear,
|
||||
narrators,
|
||||
},
|
||||
@ -304,10 +235,65 @@ function getBookDataFromDir(folderPath, relPath, parseSubtitle = false) {
|
||||
}
|
||||
}
|
||||
|
||||
function getTitleAndNarrator(folder) {
|
||||
let pattern = /^(?<title>.*)\{(?<narrators>.*)\} *$/
|
||||
function getNarrator(folder) {
|
||||
let pattern = /^(?<title>.*) \{(?<narrators>.*)\}$/
|
||||
let match = folder.match(pattern)
|
||||
return match ? [match.groups.title.trimEnd(), match.groups.narrators] : [folder, null]
|
||||
return match ? [match.groups.title, match.groups.narrators] : [folder, null]
|
||||
}
|
||||
|
||||
function getSequence(folder) {
|
||||
// Valid ways of including a volume number:
|
||||
// [
|
||||
// 'Book 2 - Title - Subtitle',
|
||||
// 'Title - Subtitle - Vol 12',
|
||||
// 'Title - volume 9 - Subtitle',
|
||||
// 'Vol. 3 Title Here - Subtitle',
|
||||
// '1980 - Book 2 - Title',
|
||||
// 'Volume 12. Title - Subtitle',
|
||||
// '100 - Book Title',
|
||||
// '2 - Book Title',
|
||||
// '6. Title',
|
||||
// '0.5 - Book Title'
|
||||
// ]
|
||||
|
||||
// Matches a valid volume string. Also matches a book whose title starts with a 1 to 3 digit number. Will handle that later.
|
||||
let pattern = /^(?<volumeLabel>vol\.? |volume |book )?(?<sequence>\d{1,3}(?:\.\d{1,2})?)(?<trailingDot>\.?)(?: (?<suffix>.*))?/i
|
||||
|
||||
let volumeNumber = null
|
||||
let parts = folder.split(' - ')
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
let match = parts[i].match(pattern)
|
||||
|
||||
// This excludes '101 Dalmations' but includes '101. Dalmations'
|
||||
if (match && !(match.groups.suffix && !(match.groups.volumeLabel || match.groups.trailingDot))) {
|
||||
volumeNumber = match.groups.sequence
|
||||
parts[i] = match.groups.suffix
|
||||
if (!parts[i]) { parts.splice(i, 1) }
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
folder = parts.join(' - ')
|
||||
return [folder, volumeNumber]
|
||||
}
|
||||
|
||||
function getPublishedYear(folder) {
|
||||
var publishedYear = null
|
||||
|
||||
pattern = /^ *\(?([0-9]{4})\)? * - *(.+)/ //Matches #### - title or (####) - title
|
||||
var match = folder.match(pattern)
|
||||
if (match) {
|
||||
publishedYear = match[1]
|
||||
folder = match[2]
|
||||
}
|
||||
|
||||
return [folder, publishedYear]
|
||||
}
|
||||
|
||||
function getSubtitle(folder) {
|
||||
// Subtitle is everything after " - "
|
||||
var splitTitle = folder.split(' - ')
|
||||
return [splitTitle.shift(), splitTitle.join(' - ')]
|
||||
}
|
||||
|
||||
function getPodcastDataFromDir(folderPath, relPath) {
|
||||
@ -335,14 +321,34 @@ function getDataFromMediaDir(libraryMediaType, folderPath, relPath, serverSettin
|
||||
}
|
||||
|
||||
// Called from Scanner.js
|
||||
async function getLibraryItemFileData(libraryMediaType, folder, libraryItemPath, serverSettings = {}) {
|
||||
var fileItems = await recurseFiles(libraryItemPath)
|
||||
|
||||
async function getLibraryItemFileData(libraryMediaType, folder, libraryItemPath, isSingleMediaItem, serverSettings = {}) {
|
||||
libraryItemPath = libraryItemPath.replace(/\\/g, '/')
|
||||
var folderFullPath = folder.fullPath.replace(/\\/g, '/')
|
||||
|
||||
var libraryItemDir = libraryItemPath.replace(folderFullPath, '').slice(1)
|
||||
var libraryItemData = getDataFromMediaDir(libraryMediaType, folderFullPath, libraryItemDir, serverSettings)
|
||||
var libraryItemData = {}
|
||||
|
||||
var fileItems = []
|
||||
|
||||
if (isSingleMediaItem) { // Single media item in root of folder
|
||||
fileItems = [
|
||||
{
|
||||
fullpath: libraryItemPath,
|
||||
path: libraryItemDir // actually the relPath (only filename here)
|
||||
}
|
||||
]
|
||||
libraryItemData = {
|
||||
path: libraryItemPath, // full path
|
||||
relPath: libraryItemDir, // only filename
|
||||
mediaMetadata: {
|
||||
title: Path.basename(libraryItemDir, Path.extname(libraryItemDir))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fileItems = await recurseFiles(libraryItemPath)
|
||||
libraryItemData = getDataFromMediaDir(libraryMediaType, folderFullPath, libraryItemDir, serverSettings)
|
||||
}
|
||||
|
||||
var libraryItemDirStats = await getFileTimestampsWithIno(libraryItemData.path)
|
||||
var libraryItem = {
|
||||
ino: libraryItemDirStats.ino,
|
||||
@ -353,6 +359,7 @@ async function getLibraryItemFileData(libraryMediaType, folder, libraryItemPath,
|
||||
libraryId: folder.libraryId,
|
||||
path: libraryItemData.path,
|
||||
relPath: libraryItemData.relPath,
|
||||
isFile: isSingleMediaItem,
|
||||
media: {
|
||||
metadata: libraryItemData.mediaMetadata || null
|
||||
},
|
||||
|
Loading…
x
Reference in New Issue
Block a user