mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-01-06 06:31:19 -05:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03963aa9a1 | ||
|
|
315592efe5 | ||
|
|
cd6e99b4c3 | ||
|
|
9715c53332 | ||
|
|
120c70622a | ||
|
|
04f92c33c2 | ||
|
|
0c168b3da4 | ||
|
|
2ed0468b33 | ||
|
|
d9c8aa287d | ||
|
|
1d118d1364 | ||
|
|
c94d9e620c | ||
|
|
ff68440d26 | ||
|
|
125a8a8e32 | ||
|
|
2d507a455e | ||
|
|
32bc9d5282 | ||
|
|
59d12ef5de |
@@ -6,6 +6,7 @@ npm-debug.log
|
||||
/config
|
||||
/audiobooks
|
||||
/audiobooks2
|
||||
/media/
|
||||
/metadata
|
||||
dev.js
|
||||
test/
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,6 +4,7 @@ node_modules/
|
||||
/config/
|
||||
/audiobooks/
|
||||
/audiobooks2/
|
||||
/media/
|
||||
/metadata/
|
||||
test/
|
||||
/client/.nuxt/
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@import url('./transitions.css');
|
||||
@import url('./draggable.css');
|
||||
|
||||
.page {
|
||||
width: 100%;
|
||||
|
||||
38
client/assets/draggable.css
Normal file
38
client/assets/draggable.css
Normal file
@@ -0,0 +1,38 @@
|
||||
.flip-list-move {
|
||||
transition: transform 0.5s;
|
||||
}
|
||||
.no-move {
|
||||
transition: transform 0s;
|
||||
}
|
||||
.ghost {
|
||||
opacity: 0.5;
|
||||
background-color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
.list-group {
|
||||
min-height: 30px;
|
||||
}
|
||||
#librariesTable .item {
|
||||
cursor: n-resize;
|
||||
}
|
||||
.list-group-item:not(.exclude) {
|
||||
cursor: n-resize;
|
||||
}
|
||||
.list-group-item.exclude {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.list-group-item:not(.ghost):not(.exclude):hover {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.list-group-item:nth-child(even):not(.ghost):not(.exclude) {
|
||||
background-color: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.list-group-item:nth-child(even):not(.ghost):not(.exclude):hover {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.list-group-item.exclude:not(.ghost) {
|
||||
background-color: rgba(255, 0, 0, 0.25);
|
||||
}
|
||||
.list-group-item.exclude:not(.ghost):hover {
|
||||
background-color: rgba(223, 0, 0, 0.25);
|
||||
}
|
||||
506
client/assets/ebooks/basic.js
Normal file
506
client/assets/ebooks/basic.js
Normal file
@@ -0,0 +1,506 @@
|
||||
/*
|
||||
Calibres stylesheet
|
||||
*/
|
||||
|
||||
export default `
|
||||
@charset "UTF-8";
|
||||
|
||||
/*
|
||||
Calibre styles
|
||||
*/
|
||||
.arabic {
|
||||
display: block;
|
||||
list-style-type: decimal;
|
||||
margin-bottom: 1em;
|
||||
margin-right: 0;
|
||||
margin-top: 1em;
|
||||
text-align: justify
|
||||
}
|
||||
.attribution {
|
||||
display: block;
|
||||
font-size: 1em;
|
||||
line-height: 1.2;
|
||||
text-align: left;
|
||||
margin: 0.3em 0
|
||||
}
|
||||
.big {
|
||||
font-size: 1.375em;
|
||||
line-height: 1.2
|
||||
}
|
||||
.big1 {
|
||||
font-size: 1em
|
||||
}
|
||||
.block {
|
||||
display: block;
|
||||
text-align: justify;
|
||||
margin: 1em 1em 2em
|
||||
}
|
||||
.block1 {
|
||||
display: block;
|
||||
text-align: justify;
|
||||
margin: 1em 4em
|
||||
}
|
||||
.block2 {
|
||||
display: block;
|
||||
text-align: justify;
|
||||
margin: 1em 1em 1em 2em
|
||||
}
|
||||
.bullet {
|
||||
display: block;
|
||||
list-style-type: disc;
|
||||
margin-bottom: 1em;
|
||||
margin-right: 0;
|
||||
margin-top: 1em;
|
||||
text-align: disc
|
||||
}
|
||||
.calibre {
|
||||
background-color: #000007;
|
||||
display: block;
|
||||
font-family: Charis, "Times New Roman", Verdana, Arial;
|
||||
font-size: 1.125em;
|
||||
line-height: 1.2;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
text-align: center;
|
||||
margin: 0 5pt
|
||||
}
|
||||
.calibre1 {
|
||||
display: block
|
||||
}
|
||||
.calibre2 {
|
||||
height: auto;
|
||||
width: auto
|
||||
}
|
||||
.calibre3:not(strong) {
|
||||
display: block;
|
||||
font-family: Charis, "Times New Roman", Verdana, Arial;
|
||||
font-size: 1.125em;
|
||||
line-height: 1.2;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
margin: 0 5pt
|
||||
}
|
||||
.calibre4 {
|
||||
font-weight: bold
|
||||
}
|
||||
.calibre5 {
|
||||
font-style: italic
|
||||
}
|
||||
.calibre6 {
|
||||
background-color: #FFF;
|
||||
display: block;
|
||||
font-family: Charis, "Times New Roman", Verdana, Arial;
|
||||
font-size: 1.125em;
|
||||
line-height: 1.2;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
text-align: center;
|
||||
margin: 0 5pt
|
||||
}
|
||||
.calibre7 {
|
||||
display: list-item
|
||||
}
|
||||
.calibre8 {
|
||||
font-size: 1em;
|
||||
line-height: 1.2;
|
||||
vertical-align: super
|
||||
}
|
||||
.calibre9 {
|
||||
border-collapse: separate;
|
||||
border-spacing: 2px;
|
||||
display: table;
|
||||
margin-bottom: 0;
|
||||
margin-top: 0;
|
||||
text-indent: 0
|
||||
}
|
||||
.calibre10 {
|
||||
display: table-row;
|
||||
vertical-align: middle
|
||||
}
|
||||
.calibre11 {
|
||||
display: table-cell;
|
||||
text-align: right;
|
||||
vertical-align: inherit;
|
||||
padding: 1px
|
||||
}
|
||||
.calibre12 {
|
||||
display: table-cell;
|
||||
text-align: left;
|
||||
vertical-align: inherit;
|
||||
padding: 1px
|
||||
}
|
||||
.calibre13 {
|
||||
height: 1em;
|
||||
width: auto
|
||||
}
|
||||
.calibre14 {
|
||||
font-size: 0.88889em;
|
||||
line-height: 1.2;
|
||||
vertical-align: super
|
||||
}
|
||||
.calibre15 {
|
||||
font-size: 1em;
|
||||
line-height: 1.2;
|
||||
vertical-align: sub
|
||||
}
|
||||
.calibre16 {
|
||||
display: block;
|
||||
list-style-type: decimal;
|
||||
margin-bottom: 1em;
|
||||
margin-right: 0;
|
||||
margin-top: 1em
|
||||
}
|
||||
.calibre17 {
|
||||
display: block;
|
||||
font-size: 1.125em;
|
||||
font-weight: bold;
|
||||
line-height: 1.2;
|
||||
margin: 0.83em 0
|
||||
}
|
||||
.center {
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin: 1em 0
|
||||
}
|
||||
.center1 {
|
||||
display: block;
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
margin: -2em 0 3em
|
||||
}
|
||||
.center2 {
|
||||
display: block;
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
margin: 2em 0 1em
|
||||
}
|
||||
.center3 {
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin: -1em 0 1em
|
||||
}
|
||||
.center4 {
|
||||
display: block;
|
||||
text-align: center;
|
||||
text-indent: 3%;
|
||||
margin: 1em 0
|
||||
}
|
||||
.chapter {
|
||||
display: block;
|
||||
font-size: 1.125em;
|
||||
font-weight: bold;
|
||||
line-height: 2em;
|
||||
text-align: center;
|
||||
margin: 2em 0 1em
|
||||
}
|
||||
.chapter1 {
|
||||
display: block;
|
||||
font-size: 0.88889em;
|
||||
line-height: 1.2;
|
||||
margin-left: 0.5em;
|
||||
margin-right: 0.5em;
|
||||
margin-top: 2em
|
||||
}
|
||||
.chapter2 {
|
||||
display: block;
|
||||
font-size: 1.125em;
|
||||
font-weight: bold;
|
||||
line-height: 2em;
|
||||
text-align: center;
|
||||
margin: 2em 0 3em
|
||||
}
|
||||
.copyright {
|
||||
display: block;
|
||||
font-size: 0.88889em;
|
||||
line-height: 1.2;
|
||||
margin-top: 4em;
|
||||
text-align: center
|
||||
}
|
||||
.dedication {
|
||||
display: block;
|
||||
font-size: 0.88889em;
|
||||
line-height: 1.2;
|
||||
margin-top: 4em
|
||||
}
|
||||
.dropcaps {
|
||||
float: left;
|
||||
font-size: 3.4375rem;
|
||||
line-height: 50px;
|
||||
margin-right: 0.09em;
|
||||
margin-top: -0.05em;
|
||||
padding-top: 1px
|
||||
}
|
||||
.dropcaps1 {
|
||||
float: left;
|
||||
font-size: 3.4375rem;
|
||||
line-height: 50px;
|
||||
margin-right: 0.09em;
|
||||
margin-top: 0.15em;
|
||||
padding-top: 1px
|
||||
}
|
||||
.extract {
|
||||
display: block;
|
||||
text-align: justify;
|
||||
margin: 2em 0 0.3em
|
||||
}
|
||||
.extract1 {
|
||||
display: block;
|
||||
text-align: justify;
|
||||
text-indent: 3%;
|
||||
margin: 2em 0 0.3em
|
||||
}
|
||||
.extract2 {
|
||||
display: block;
|
||||
text-align: justify;
|
||||
margin: 1em 0 0.3em
|
||||
}
|
||||
.footnote {
|
||||
border-bottom-style: solid;
|
||||
border-bottom-width: 0;
|
||||
border-left-style: solid;
|
||||
border-left-width: 0;
|
||||
border-right-style: solid;
|
||||
border-right-width: 0;
|
||||
border-top-style: solid;
|
||||
border-top-width: 1px;
|
||||
display: block;
|
||||
font-size: 1em;
|
||||
line-height: 1.2;
|
||||
margin-top: 2 em
|
||||
}
|
||||
.footnote1 {
|
||||
display: block;
|
||||
text-align: justify;
|
||||
margin: 0.3em 0 0.3em 2
|
||||
}
|
||||
.footnote2 {
|
||||
border-bottom-style: solid;
|
||||
border-bottom-width: 0;
|
||||
border-left-style: solid;
|
||||
border-left-width: 0;
|
||||
border-right-style: solid;
|
||||
border-right-width: 0;
|
||||
border-top-style: solid;
|
||||
border-top-width: 1px;
|
||||
display: block;
|
||||
font-size: 0.88889em;
|
||||
line-height: 1.2;
|
||||
margin-top: 2 em
|
||||
}
|
||||
.hanging {
|
||||
display: block;
|
||||
font-size: 0.88889em;
|
||||
line-height: 1.2;
|
||||
text-align: left;
|
||||
text-indent: -1em;
|
||||
margin: 0.5em 0 0.3em 1em
|
||||
}
|
||||
.hanging1 {
|
||||
display: block;
|
||||
font-size: 0.88889em;
|
||||
line-height: 1.2;
|
||||
text-align: left;
|
||||
text-indent: -1em;
|
||||
margin: 0.5em 0 0.3em 1.5em
|
||||
}
|
||||
.hanging2 {
|
||||
display: block;
|
||||
font-size: 1em;
|
||||
line-height: 1.2;
|
||||
text-indent: -1em;
|
||||
margin: 0.5em 0 0.3em 1em
|
||||
}
|
||||
.hanging3 {
|
||||
display: block;
|
||||
font-size: 1em;
|
||||
line-height: 1.2;
|
||||
text-align: left;
|
||||
text-indent: 1em;
|
||||
margin: 0.1em 0 0.3em 1em
|
||||
}
|
||||
.hanging4 {
|
||||
display: block;
|
||||
font-size: 1em;
|
||||
line-height: 1.2;
|
||||
text-align: left;
|
||||
text-indent: 0.1em;
|
||||
margin: 0.1em 0 0.3em 1em
|
||||
}
|
||||
a.hlink {
|
||||
text-decoration: none
|
||||
}
|
||||
.indent {
|
||||
display: block;
|
||||
text-align: justify;
|
||||
text-indent: 1em;
|
||||
margin: 0.3em 0
|
||||
}
|
||||
.line {
|
||||
border-top: currentColor solid 1px;
|
||||
border-bottom: currentColor solid 1px
|
||||
}
|
||||
.loweralpha {
|
||||
display: block;
|
||||
list-style-type: lower-alpha;
|
||||
margin-bottom: 1em;
|
||||
margin-right: 0;
|
||||
margin-top: 1em;
|
||||
text-align: justify
|
||||
}
|
||||
.none {
|
||||
display: block;
|
||||
list-style-type: none;
|
||||
margin-bottom: 1em;
|
||||
margin-right: 0;
|
||||
margin-top: 1em;
|
||||
text-align: justify
|
||||
}
|
||||
.none1 {
|
||||
display: block;
|
||||
list-style-type: none;
|
||||
margin-bottom: 0;
|
||||
margin-right: 0;
|
||||
margin-top: 0;
|
||||
text-align: justify
|
||||
}
|
||||
.nonindent {
|
||||
display: block;
|
||||
text-align: justify;
|
||||
margin: 0.3em 0
|
||||
}
|
||||
.nonindent1 {
|
||||
display: block;
|
||||
font-size: 1.125em;
|
||||
line-height: 1.2;
|
||||
text-indent: -1em;
|
||||
margin: 0.5em 0 0.3em 0.1em
|
||||
}
|
||||
.nonindent2 {
|
||||
display: block;
|
||||
font-size: 1.125em;
|
||||
line-height: 1.2;
|
||||
text-indent: -1em;
|
||||
margin: 0.5em 0 0.3em -0.5em
|
||||
}
|
||||
.nonindent3 {
|
||||
display: block;
|
||||
text-align: justify;
|
||||
text-indent: 3%;
|
||||
margin: 0.3em 0
|
||||
}
|
||||
.part {
|
||||
display: block;
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
line-height: 2em;
|
||||
text-align: center;
|
||||
margin: 4em 0 1em
|
||||
}
|
||||
.preface {
|
||||
display: block;
|
||||
font-size: 0.88889em;
|
||||
line-height: 1.2;
|
||||
margin-left: 2em;
|
||||
margin-right: 2em;
|
||||
text-align: justify
|
||||
}
|
||||
.pubhlink {
|
||||
color: green;
|
||||
text-decoration: none
|
||||
}
|
||||
.right {
|
||||
display: block;
|
||||
text-align: right;
|
||||
margin: 0.3em 0
|
||||
}
|
||||
.section {
|
||||
display: block;
|
||||
font-size: 1.125em;
|
||||
font-weight: bold;
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
margin: 2em 0 0.5em
|
||||
}
|
||||
.section1 {
|
||||
display: block;
|
||||
font-size: 1.125em;
|
||||
font-weight: bold;
|
||||
line-height: 1.2;
|
||||
text-align: left;
|
||||
margin: 2em 0 0.3em
|
||||
}
|
||||
.section2 {
|
||||
display: block;
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
line-height: 1.2;
|
||||
text-align: left;
|
||||
margin: 2em 0 0.3em 1em
|
||||
}
|
||||
.small {
|
||||
font-size: 0.66667em
|
||||
}
|
||||
.small1 {
|
||||
font-size: 0.75em
|
||||
}
|
||||
.subchapter {
|
||||
display: block;
|
||||
font-size: 1.125em;
|
||||
font-weight: bold;
|
||||
line-height: 1.2;
|
||||
margin: 1em 0
|
||||
}
|
||||
.textbox {
|
||||
background-color: #E4E4E4;
|
||||
display: block;
|
||||
line-height: 1.5em;
|
||||
margin-bottom: 2em;
|
||||
margin-top: 2em;
|
||||
text-align: justify;
|
||||
border-top: currentColor double 2px;
|
||||
border-bottom: currentColor double 2px
|
||||
}
|
||||
.textbox1 {
|
||||
display: block;
|
||||
text-align: justify;
|
||||
margin: 0.3em 0.5em 0.3em 0.8em
|
||||
}
|
||||
.textbox2 {
|
||||
display: block;
|
||||
text-align: justify;
|
||||
text-indent: 1em;
|
||||
margin: 0.3em 0.5em
|
||||
}
|
||||
.textbox3 {
|
||||
display: block;
|
||||
text-align: justify;
|
||||
text-indent: 3%;
|
||||
margin: 0.3em 0.5em 0.3em 0.8em
|
||||
}
|
||||
.titlepage {
|
||||
display: block;
|
||||
margin-left: -0.4em;
|
||||
margin-top: 1.2em
|
||||
}
|
||||
.toc {
|
||||
display: block;
|
||||
font-size: 1em;
|
||||
line-height: 1.2;
|
||||
text-align: center
|
||||
}
|
||||
.toc1 {
|
||||
display: block;
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
margin: 0.67em 0 3em
|
||||
}
|
||||
.underline {
|
||||
text-decoration: underline
|
||||
}
|
||||
`
|
||||
248
client/assets/ebooks/htmlParser.js
Normal file
248
client/assets/ebooks/htmlParser.js
Normal file
@@ -0,0 +1,248 @@
|
||||
/*
|
||||
This is borrowed from koodo-reader https://github.com/troyeguo/koodo-reader/tree/master/src
|
||||
*/
|
||||
|
||||
export const isTitle = (
|
||||
line,
|
||||
isContainDI = false,
|
||||
isContainChapter = false,
|
||||
isContainCHAPTER = false
|
||||
) => {
|
||||
return (
|
||||
line.length < 30 &&
|
||||
line.indexOf("[") === -1 &&
|
||||
line.indexOf("(") === -1 &&
|
||||
(line.startsWith("CHAPTER") ||
|
||||
line.startsWith("Chapter") ||
|
||||
line.startsWith("序章") ||
|
||||
line.startsWith("前言") ||
|
||||
line.startsWith("声明") ||
|
||||
line.startsWith("聲明") ||
|
||||
line.startsWith("写在前面的话") ||
|
||||
line.startsWith("后记") ||
|
||||
line.startsWith("楔子") ||
|
||||
line.startsWith("后序") ||
|
||||
line.startsWith("寫在前面的話") ||
|
||||
line.startsWith("後記") ||
|
||||
line.startsWith("後序") ||
|
||||
/(?=[MDCLXVI])M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$/.test(
|
||||
line
|
||||
) ||
|
||||
(line.startsWith("第") && startWithDI(line)) ||
|
||||
(line.startsWith("卷") && startWithJUAN(line)) ||
|
||||
startWithRomanNum(line) ||
|
||||
(!isContainDI &&
|
||||
!isContainChapter &&
|
||||
!isContainCHAPTER &&
|
||||
line.indexOf("第") > -1 &&
|
||||
(line[line.indexOf("第") - 1] === " " ||
|
||||
line[line.indexOf("第") - 1] === " " ||
|
||||
line[line.indexOf("第") - 1] === "、" ||
|
||||
line[line.indexOf("第") - 1] === ":" ||
|
||||
line[line.indexOf("第") - 1] === ":") &&
|
||||
startWithDI(line.substr(line.indexOf("第")))) ||
|
||||
(!isContainDI &&
|
||||
!isContainChapter &&
|
||||
!isContainCHAPTER &&
|
||||
line.indexOf(" ") &&
|
||||
startWithNumAndSpace(line)) ||
|
||||
(!isContainDI &&
|
||||
!isContainChapter &&
|
||||
!isContainCHAPTER &&
|
||||
line.indexOf(" ") &&
|
||||
startWithNumAndSpace(line)) ||
|
||||
(!isContainDI &&
|
||||
!isContainChapter &&
|
||||
!isContainCHAPTER &&
|
||||
line.indexOf("、") &&
|
||||
startWithNumAndPause(line)) ||
|
||||
(!isContainDI &&
|
||||
!isContainChapter &&
|
||||
!isContainCHAPTER &&
|
||||
line.indexOf(":") &&
|
||||
startWithNumAndColon(line)) ||
|
||||
(!isContainDI &&
|
||||
!isContainChapter &&
|
||||
!isContainCHAPTER &&
|
||||
line.indexOf(":") &&
|
||||
startWithNumAndColon(line)))
|
||||
);
|
||||
};
|
||||
const startWithDI = (line) => {
|
||||
let keywords = [
|
||||
"章",
|
||||
"节",
|
||||
"回",
|
||||
"節",
|
||||
"卷",
|
||||
"部",
|
||||
"輯",
|
||||
"辑",
|
||||
"話",
|
||||
"集",
|
||||
"话",
|
||||
"篇",
|
||||
];
|
||||
let flag = false;
|
||||
for (let i = 0; i < keywords.length; i++) {
|
||||
if (
|
||||
(line.indexOf(keywords[i]) > -1 &&
|
||||
(line[line.indexOf(keywords[i]) + 1] === " " ||
|
||||
line[line.indexOf(keywords[i]) + 1] === " " ||
|
||||
line[line.indexOf(keywords[i]) + 1] === "、" ||
|
||||
line[line.indexOf(keywords[i]) + 1] === ":" ||
|
||||
line[line.indexOf(keywords[i]) + 1] === ":")) ||
|
||||
!line[line.indexOf(keywords[i]) + 1]
|
||||
) {
|
||||
if (
|
||||
/^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test(
|
||||
line.substring(1, line.indexOf(keywords[i])).trim()
|
||||
) ||
|
||||
/^\d+$/.test(line.substring(1, line.indexOf(keywords[i])).trim())
|
||||
) {
|
||||
flag = true;
|
||||
}
|
||||
if (flag) break;
|
||||
}
|
||||
}
|
||||
return flag;
|
||||
};
|
||||
const startWithJUAN = (line) => {
|
||||
if (
|
||||
/^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test(
|
||||
line.substring(1, line.indexOf(" "))
|
||||
) ||
|
||||
/^\d+$/.test(line.substring(1, line.indexOf(" ")))
|
||||
)
|
||||
return true;
|
||||
if (
|
||||
/^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test(
|
||||
line.substring(1, line.indexOf(" "))
|
||||
) ||
|
||||
/^\d+$/.test(line.substring(1, line.indexOf(" ")))
|
||||
)
|
||||
return true;
|
||||
if (
|
||||
/^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test(
|
||||
line.substring(1)
|
||||
) ||
|
||||
/^\d+$/.test(line.substring(1))
|
||||
)
|
||||
return true;
|
||||
return false;
|
||||
};
|
||||
const startWithRomanNum = (line) => {
|
||||
if (
|
||||
/(?=[MDCLXVI])M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$/.test(
|
||||
line.substring(0, line.indexOf(" "))
|
||||
)
|
||||
)
|
||||
return true;
|
||||
if (
|
||||
/(?=[MDCLXVI])M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$/.test(
|
||||
line.substring(0, line.indexOf("."))
|
||||
)
|
||||
)
|
||||
return true;
|
||||
if (
|
||||
/(?=[MDCLXVI])M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$/.test(
|
||||
line.trim()
|
||||
)
|
||||
)
|
||||
return true;
|
||||
return false;
|
||||
};
|
||||
const startWithNumAndSpace = (line) => {
|
||||
if (
|
||||
/^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test(
|
||||
line.substring(0, line.indexOf(" "))
|
||||
)
|
||||
)
|
||||
return true;
|
||||
if (
|
||||
/^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test(
|
||||
line.substring(0, line.indexOf(" "))
|
||||
)
|
||||
)
|
||||
return true;
|
||||
|
||||
if (/^\d+$/.test(line.substring(0, line.indexOf(" ")))) return true;
|
||||
if (/^\d+$/.test(line.substring(0, line.indexOf(" ")))) return true;
|
||||
return false;
|
||||
};
|
||||
const startWithNumAndColon = (line) => {
|
||||
if (
|
||||
/^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test(
|
||||
line.substring(0, line.indexOf(":"))
|
||||
)
|
||||
)
|
||||
return true;
|
||||
if (
|
||||
/^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test(
|
||||
line.substring(0, line.indexOf(":"))
|
||||
)
|
||||
)
|
||||
return true;
|
||||
|
||||
if (/^\d+$/.test(line.substring(0, line.indexOf(":")))) return true;
|
||||
if (/^\d+$/.test(line.substring(0, line.indexOf(":")))) return true;
|
||||
return false;
|
||||
};
|
||||
const startWithNumAndPause = (line) => {
|
||||
if (
|
||||
/^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test(
|
||||
line.substring(0, line.indexOf("、"))
|
||||
)
|
||||
)
|
||||
return true;
|
||||
|
||||
if (/^\d+$/.test(line.substring(0, line.indexOf("、")))) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
|
||||
class HtmlParser {
|
||||
bookDoc;
|
||||
contentList;
|
||||
contentTitleList;
|
||||
constructor(bookDoc) {
|
||||
this.bookDoc = bookDoc;
|
||||
this.contentList = [];
|
||||
this.contentTitleList = [];
|
||||
this.getContent(bookDoc);
|
||||
}
|
||||
getContent(bookDoc) {
|
||||
this.contentList = Array.from(
|
||||
bookDoc.querySelectorAll("h1,h2,h3,h4,h5,b,font")
|
||||
).filter((item, index) => {
|
||||
return isTitle(item.innerText.trim());
|
||||
});
|
||||
|
||||
for (let i = 0; i < this.contentList.length; i++) {
|
||||
let random = Math.floor(Math.random() * 900000) + 100000;
|
||||
this.contentTitleList.push({
|
||||
label: this.contentList[i].innerText,
|
||||
id: "title" + random,
|
||||
href: "#title" + random,
|
||||
subitems: [],
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < this.contentList.length; i++) {
|
||||
this.contentList[i].id = this.contentTitleList[i].id;
|
||||
}
|
||||
}
|
||||
getAnchoredDoc() {
|
||||
return this.bookDoc;
|
||||
}
|
||||
getContentList() {
|
||||
return this.contentTitleList.filter((item, index) => {
|
||||
if (index > 0) {
|
||||
return item.label !== this.contentTitleList[index - 1].label;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default HtmlParser;
|
||||
450
client/assets/ebooks/mobi.js
Normal file
450
client/assets/ebooks/mobi.js
Normal file
@@ -0,0 +1,450 @@
|
||||
/*
|
||||
This is borrowed from koodo-reader https://github.com/troyeguo/koodo-reader/tree/master/src
|
||||
*/
|
||||
|
||||
function ab2str(buf) {
|
||||
if (buf instanceof ArrayBuffer) {
|
||||
buf = new Uint8Array(buf);
|
||||
}
|
||||
return new TextDecoder("utf-8").decode(buf);
|
||||
}
|
||||
|
||||
var domParser = new DOMParser();
|
||||
|
||||
class Buffer {
|
||||
capacity;
|
||||
fragment_list;
|
||||
imageArray;
|
||||
cur_fragment;
|
||||
constructor(capacity) {
|
||||
this.capacity = capacity;
|
||||
this.fragment_list = [];
|
||||
this.imageArray = [];
|
||||
this.cur_fragment = new Fragment(capacity);
|
||||
this.fragment_list.push(this.cur_fragment);
|
||||
}
|
||||
write(byte) {
|
||||
var result = this.cur_fragment.write(byte);
|
||||
if (!result) {
|
||||
this.cur_fragment = new Fragment(this.capacity);
|
||||
this.fragment_list.push(this.cur_fragment);
|
||||
this.cur_fragment.write(byte);
|
||||
}
|
||||
}
|
||||
get(idx) {
|
||||
var fi = 0;
|
||||
while (fi < this.fragment_list.length) {
|
||||
var frag = this.fragment_list[fi];
|
||||
if (idx < frag.size) {
|
||||
return frag.get(idx);
|
||||
}
|
||||
idx -= frag.size;
|
||||
fi += 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
size() {
|
||||
var s = 0;
|
||||
for (var i = 0; i < this.fragment_list.length; i++) {
|
||||
s += this.fragment_list[i].size;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
shrink() {
|
||||
var total_buffer = new Uint8Array(this.size());
|
||||
var offset = 0;
|
||||
for (var i = 0; i < this.fragment_list.length; i++) {
|
||||
var frag = this.fragment_list[i];
|
||||
if (frag.full()) {
|
||||
total_buffer.set(frag.buffer, offset);
|
||||
} else {
|
||||
total_buffer.set(frag.buffer.slice(0, frag.size), offset);
|
||||
}
|
||||
offset += frag.size;
|
||||
}
|
||||
return total_buffer;
|
||||
}
|
||||
}
|
||||
|
||||
var copagesne_uint8array = function (buffers) {
|
||||
var total_size = 0;
|
||||
for (let i = 0; i < buffers.length; i++) {
|
||||
var buffer = buffers[i];
|
||||
total_size += buffer.length;
|
||||
}
|
||||
var total_buffer = new Uint8Array(total_size);
|
||||
var offset = 0;
|
||||
for (let i = 0; i < buffers.length; i++) {
|
||||
buffer = buffers[i];
|
||||
total_buffer.set(buffer, offset);
|
||||
offset += buffer.length;
|
||||
}
|
||||
return total_buffer;
|
||||
};
|
||||
|
||||
class Fragment {
|
||||
buffer;
|
||||
capacity;
|
||||
size;
|
||||
constructor(capacity) {
|
||||
this.buffer = new Uint8Array(capacity);
|
||||
this.capacity = capacity;
|
||||
this.size = 0;
|
||||
}
|
||||
|
||||
write(byte) {
|
||||
if (this.size >= this.capacity) {
|
||||
return false;
|
||||
}
|
||||
this.buffer[this.size] = byte;
|
||||
this.size += 1;
|
||||
return true;
|
||||
}
|
||||
full() {
|
||||
return this.size === this.capacity;
|
||||
}
|
||||
get(idx) {
|
||||
return this.buffer[idx];
|
||||
}
|
||||
}
|
||||
|
||||
var uncompression_lz77 = function (data) {
|
||||
var length = data.length;
|
||||
var offset = 0; // Current offset into data
|
||||
var buffer = new Buffer(data.length);
|
||||
|
||||
while (offset < length) {
|
||||
var char = data[offset];
|
||||
offset += 1;
|
||||
|
||||
if (char === 0) {
|
||||
buffer.write(char);
|
||||
} else if (char <= 8) {
|
||||
for (var i = offset; i < offset + char; i++) {
|
||||
buffer.write(data[i]);
|
||||
}
|
||||
offset += char;
|
||||
} else if (char <= 0x7f) {
|
||||
buffer.write(char);
|
||||
} else if (char <= 0xbf) {
|
||||
var next = data[offset];
|
||||
offset += 1;
|
||||
var distance = (((char << 8) | next) >> 3) & 0x7ff;
|
||||
var lz_length = (next & 0x7) + 3;
|
||||
|
||||
var buffer_size = buffer.size();
|
||||
for (let i = 0; i < lz_length; i++) {
|
||||
buffer.write(buffer.get(buffer_size - distance));
|
||||
buffer_size += 1;
|
||||
}
|
||||
} else {
|
||||
buffer.write(32);
|
||||
buffer.write(char ^ 0x80);
|
||||
}
|
||||
}
|
||||
return buffer;
|
||||
};
|
||||
|
||||
class MobiFile {
|
||||
view;
|
||||
buffer;
|
||||
offset;
|
||||
header;
|
||||
palm_header;
|
||||
mobi_header;
|
||||
reclist;
|
||||
constructor(data) {
|
||||
this.view = new DataView(data);
|
||||
this.buffer = this.view.buffer;
|
||||
this.offset = 0;
|
||||
this.header = null;
|
||||
}
|
||||
|
||||
parse() { }
|
||||
|
||||
getUint8() {
|
||||
var v = this.view.getUint8(this.offset);
|
||||
this.offset += 1;
|
||||
return v;
|
||||
}
|
||||
|
||||
getUint16() {
|
||||
var v = this.view.getUint16(this.offset);
|
||||
this.offset += 2;
|
||||
return v;
|
||||
}
|
||||
|
||||
getUint32() {
|
||||
var v = this.view.getUint32(this.offset);
|
||||
this.offset += 4;
|
||||
return v;
|
||||
}
|
||||
|
||||
getStr(size) {
|
||||
var v = ab2str(this.buffer.slice(this.offset, this.offset + size));
|
||||
this.offset += size;
|
||||
return v;
|
||||
}
|
||||
|
||||
skip(size) {
|
||||
this.offset += size;
|
||||
}
|
||||
|
||||
setoffset(_of) {
|
||||
this.offset = _of;
|
||||
}
|
||||
|
||||
get_record_extrasize(data, flags) {
|
||||
var pos = data.length - 1;
|
||||
var extra = 0;
|
||||
for (var i = 15; i > 0; i--) {
|
||||
if (flags & (1 << i)) {
|
||||
var res = this.buffer_get_varlen(data, pos);
|
||||
var size = res[0];
|
||||
var l = res[1];
|
||||
pos = res[2];
|
||||
pos -= size - l;
|
||||
extra += size;
|
||||
}
|
||||
}
|
||||
if (flags & 1) {
|
||||
var a = data[pos];
|
||||
extra += (a & 0x3) + 1;
|
||||
}
|
||||
return extra;
|
||||
}
|
||||
|
||||
// data should be uint8array
|
||||
buffer_get_varlen(data, pos) {
|
||||
var l = 0;
|
||||
var size = 0;
|
||||
var byte_count = 0;
|
||||
var mask = 0x7f;
|
||||
var stop_flag = 0x80;
|
||||
var shift = 0;
|
||||
for (var i = 0; ; i++) {
|
||||
var byte = data[pos];
|
||||
size |= (byte & mask) << shift;
|
||||
shift += 7;
|
||||
l += 1;
|
||||
byte_count += 1;
|
||||
pos -= 1;
|
||||
|
||||
var to_stop = byte & stop_flag;
|
||||
if (byte_count >= 4 || to_stop > 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return [size, l, pos];
|
||||
}
|
||||
// 读出文本内容
|
||||
read_text() {
|
||||
var text_end = this.palm_header.record_count;
|
||||
var buffers = [];
|
||||
for (var i = 1; i <= text_end; i++) {
|
||||
buffers.push(this.read_text_record(i));
|
||||
}
|
||||
var all = copagesne_uint8array(buffers);
|
||||
return ab2str(all);
|
||||
}
|
||||
|
||||
read_text_record(i) {
|
||||
var flags = this.mobi_header.extra_flags;
|
||||
var begin = this.reclist[i].offset;
|
||||
var end = this.reclist[i + 1].offset;
|
||||
|
||||
var data = new Uint8Array(this.buffer.slice(begin, end));
|
||||
var ex = this.get_record_extrasize(data, flags);
|
||||
|
||||
data = new Uint8Array(this.buffer.slice(begin, end - ex));
|
||||
if (this.palm_header.compression === 2) {
|
||||
var buffer = uncompression_lz77(data);
|
||||
return buffer.shrink();
|
||||
} else {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
// 从buffer中读出image
|
||||
read_image(idx) {
|
||||
var first_image_idx = this.mobi_header.first_image_idx;
|
||||
var begin = this.reclist[first_image_idx + idx].offset;
|
||||
var end = this.reclist[first_image_idx + idx + 1].offset;
|
||||
var data = new Uint8Array(this.buffer.slice(begin, end));
|
||||
return new Blob([data.buffer]);
|
||||
}
|
||||
|
||||
load() {
|
||||
this.header = this.load_pdbheader();
|
||||
this.reclist = this.load_reclist();
|
||||
this.load_record0();
|
||||
}
|
||||
|
||||
load_pdbheader() {
|
||||
var header = {};
|
||||
header.name = this.getStr(32);
|
||||
header.attr = this.getUint16();
|
||||
header.version = this.getUint16();
|
||||
header.ctime = this.getUint32();
|
||||
header.mtime = this.getUint32();
|
||||
header.btime = this.getUint32();
|
||||
header.mod_num = this.getUint32();
|
||||
header.appinfo_offset = this.getUint32();
|
||||
header.sortinfo_offset = this.getUint32();
|
||||
header.type = this.getStr(4);
|
||||
header.creator = this.getStr(4);
|
||||
header.uid = this.getUint32();
|
||||
header.next_rec = this.getUint32();
|
||||
header.record_num = this.getUint16();
|
||||
return header;
|
||||
}
|
||||
|
||||
load_reclist() {
|
||||
var reclist = [];
|
||||
for (var i = 0; i < this.header.record_num; i++) {
|
||||
var record = {};
|
||||
record.offset = this.getUint32();
|
||||
// TODO(zz) change
|
||||
record.attr = this.getUint32();
|
||||
reclist.push(record);
|
||||
}
|
||||
return reclist;
|
||||
}
|
||||
load_record0() {
|
||||
this.palm_header = this.load_record0_header();
|
||||
this.mobi_header = this.load_mobi_header();
|
||||
}
|
||||
|
||||
load_record0_header() {
|
||||
var p_header = {};
|
||||
var first_record = this.reclist[0];
|
||||
this.setoffset(first_record.offset);
|
||||
|
||||
p_header.compression = this.getUint16();
|
||||
this.skip(2);
|
||||
p_header.text_length = this.getUint32();
|
||||
p_header.record_count = this.getUint16();
|
||||
p_header.record_size = this.getUint16();
|
||||
p_header.encryption_type = this.getUint16();
|
||||
this.skip(2);
|
||||
|
||||
return p_header;
|
||||
}
|
||||
|
||||
load_mobi_header() {
|
||||
var mobi_header = {};
|
||||
|
||||
var start_offset = this.offset;
|
||||
|
||||
mobi_header.identifier = this.getUint32();
|
||||
mobi_header.header_length = this.getUint32();
|
||||
mobi_header.mobi_type = this.getUint32();
|
||||
mobi_header.text_encoding = this.getUint32();
|
||||
mobi_header.uid = this.getUint32();
|
||||
mobi_header.generator_version = this.getUint32();
|
||||
|
||||
this.skip(40);
|
||||
|
||||
mobi_header.first_nonbook_index = this.getUint32();
|
||||
mobi_header.full_name_offset = this.getUint32();
|
||||
mobi_header.full_name_length = this.getUint32();
|
||||
|
||||
mobi_header.language = this.getUint32();
|
||||
mobi_header.input_language = this.getUint32();
|
||||
mobi_header.output_language = this.getUint32();
|
||||
mobi_header.min_version = this.getUint32();
|
||||
mobi_header.first_image_idx = this.getUint32();
|
||||
|
||||
mobi_header.huff_rec_index = this.getUint32();
|
||||
mobi_header.huff_rec_count = this.getUint32();
|
||||
mobi_header.datp_rec_index = this.getUint32();
|
||||
mobi_header.datp_rec_count = this.getUint32();
|
||||
|
||||
mobi_header.exth_flags = this.getUint32();
|
||||
|
||||
this.skip(36);
|
||||
|
||||
mobi_header.drm_offset = this.getUint32();
|
||||
mobi_header.drm_count = this.getUint32();
|
||||
mobi_header.drm_size = this.getUint32();
|
||||
mobi_header.drm_flags = this.getUint32();
|
||||
|
||||
this.skip(8);
|
||||
|
||||
// TODO (zz) fdst_index
|
||||
this.skip(4);
|
||||
|
||||
this.skip(46);
|
||||
|
||||
mobi_header.extra_flags = this.getUint16();
|
||||
|
||||
this.setoffset(start_offset + mobi_header.header_length);
|
||||
|
||||
return mobi_header;
|
||||
}
|
||||
load_exth_header() {
|
||||
// TODO
|
||||
return {};
|
||||
}
|
||||
extractContent(s) {
|
||||
var span = document.createElement("span");
|
||||
span.innerHTML = s;
|
||||
return span.textContent || span.innerText;
|
||||
}
|
||||
render(isElectron = false) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.load();
|
||||
var content = this.read_text();
|
||||
var bookDoc = domParser.parseFromString(content, "text/html")
|
||||
.documentElement;
|
||||
let lines = Array.from(
|
||||
bookDoc.querySelectorAll("p,b,font,h3,h2,h1")
|
||||
);
|
||||
let parseContent = [];
|
||||
for (let i = 0, len = lines.length; i < len - 1; i++) {
|
||||
lines[i].innerText &&
|
||||
lines[i].innerText !== parseContent[parseContent.length - 1] &&
|
||||
parseContent.push(lines[i].innerText);
|
||||
let imgDoms = lines[i].getElementsByTagName("img");
|
||||
if (imgDoms.length > 0) {
|
||||
for (let i = 0; i < imgDoms.length; i++) {
|
||||
parseContent.push("#image");
|
||||
}
|
||||
}
|
||||
}
|
||||
const handleImage = async () => {
|
||||
var imgDoms = bookDoc.getElementsByTagName("img");
|
||||
parseContent.push("~image");
|
||||
for (let i = 0; i < imgDoms.length; i++) {
|
||||
const src = await this.render_image(imgDoms, i);
|
||||
parseContent.push(
|
||||
src + " " + imgDoms[i].width + " " + imgDoms[i].height
|
||||
);
|
||||
}
|
||||
if (imgDoms.length > 200 || !isElectron) {
|
||||
resolve(bookDoc);
|
||||
} else {
|
||||
resolve(parseContent.join("\n \n"));
|
||||
}
|
||||
};
|
||||
handleImage();
|
||||
});
|
||||
}
|
||||
render_image = (imgDoms, i) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
var imgDom = imgDoms[i];
|
||||
var idx = +imgDom.getAttribute("recindex");
|
||||
var blob = this.read_image(idx - 1);
|
||||
var imgReader = new FileReader();
|
||||
imgReader.onload = (e) => {
|
||||
imgDom.src = e.target?.result;
|
||||
resolve(e.target?.result);
|
||||
};
|
||||
imgReader.onerror = function (err) {
|
||||
reject(err);
|
||||
};
|
||||
imgReader.readAsDataURL(blob);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export default MobiFile;
|
||||
@@ -7,24 +7,15 @@
|
||||
<span class="material-icons text-4xl text-white">arrow_back</span>
|
||||
</a>
|
||||
<h1 class="text-2xl font-book mr-6">AudioBookshelf</h1>
|
||||
<!-- <div class="-mb-2 mr-6"> -->
|
||||
<!-- <h1 class="text-base font-book leading-3 px-1">AudioBookshelf</h1> -->
|
||||
|
||||
<!-- <div class="bg-black bg-opacity-20 rounded-sm py-1 px-2 flex items-center border border-bg mt-1.5 cursor-pointer" @click="clickLibrary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-white text-opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
||||
</svg>
|
||||
|
||||
<p class="text-sm text-white text-opacity-70 leading-3 font-book pl-2">{{ libraryName }}</p>
|
||||
</div> -->
|
||||
<!-- </div> -->
|
||||
<div class="bg-black bg-opacity-20 rounded-sm py-1.5 px-3 flex items-center border border-bg text-white text-opacity-70 cursor-pointer hover:bg-opacity-10 hover:text-opacity-90" @click="clickLibrary">
|
||||
<!-- <div class="bg-black bg-opacity-20 rounded-md py-1.5 px-3 flex items-center text-white text-opacity-70 cursor-pointer hover:bg-opacity-10 hover:text-opacity-90" @click="clickLibrary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
||||
</svg>
|
||||
|
||||
<p class="text-sm leading-3 font-sans pl-2">{{ libraryName }}</p>
|
||||
</div>
|
||||
</div> -->
|
||||
<ui-libraries-dropdown />
|
||||
|
||||
<controls-global-search />
|
||||
<div class="flex-grow" />
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<template v-for="entity in shelf">
|
||||
<cards-group-card v-if="showGroups" :key="entity.id" :width="bookCoverWidth" :group="entity" @click="clickGroup" />
|
||||
<!-- <cards-book-3d :key="entity.id" v-else :width="100" :src="$store.getters['audiobooks/getBookCoverSrc'](entity.book)" /> -->
|
||||
<cards-book-card v-else :key="entity.id" :show-volume-number="!!selectedSeries" :width="bookCoverWidth" :user-progress="userAudiobooks[entity.id]" :audiobook="entity" />
|
||||
<cards-book-card v-else :key="entity.id" :show-volume-number="!!selectedSeries" :width="bookCoverWidth" :user-progress="userAudiobooks[entity.id]" :audiobook="entity" @edit="editBook" />
|
||||
</template>
|
||||
</div>
|
||||
<div class="bookshelfDivider h-4 w-full absolute bottom-0 left-0 right-0 z-10" />
|
||||
@@ -77,6 +77,11 @@ export default {
|
||||
this.$store.commit('audiobooks/setSearchResults', this.searchResults)
|
||||
this.setBookshelfEntities()
|
||||
})
|
||||
},
|
||||
'$route.query.filter'() {
|
||||
if (this.$route.query.filter && this.$route.query.filter !== this.filterBy) {
|
||||
this.$store.dispatch('user/updateUserSettings', { filterBy: this.$route.query.filter })
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -133,6 +138,11 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
editBook(audiobook) {
|
||||
var bookIds = this.entities.map((e) => e.id)
|
||||
this.$store.commit('setBookshelfBookIds', bookIds)
|
||||
this.$store.commit('showEditModal', audiobook)
|
||||
},
|
||||
clickGroup(group) {
|
||||
this.$emit('update:selectedSeries', group.name)
|
||||
},
|
||||
@@ -171,6 +181,7 @@ export default {
|
||||
this.currSearchParams = this.buildSearchParams()
|
||||
|
||||
var entities = this.entities
|
||||
|
||||
var groups = []
|
||||
var currentRow = 0
|
||||
var currentGroup = []
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="w-full h-full" :style="{ marginTop: sizeMultiplier + 'rem' }">
|
||||
<div class="flex items-center -mb-2">
|
||||
<template v-for="entity in shelf.books">
|
||||
<cards-book-card :key="entity.id" :width="bookCoverWidth" :user-progress="userAudiobooks[entity.id]" :audiobook="entity" @hook:updated="updatedBookCard" />
|
||||
<cards-book-card :key="entity.id" :width="bookCoverWidth" :user-progress="userAudiobooks[entity.id]" :audiobook="entity" @hook:updated="updatedBookCard" @edit="editBook" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -53,6 +53,11 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
editBook(audiobook) {
|
||||
var bookIds = this.shelf.books.map((e) => e.id)
|
||||
this.$store.commit('setBookshelfBookIds', bookIds)
|
||||
this.$store.commit('showEditModal', audiobook)
|
||||
},
|
||||
scrolled() {
|
||||
clearTimeout(this.scrollTimer)
|
||||
this.scrollTimer = setTimeout(() => {
|
||||
|
||||
@@ -4,13 +4,16 @@
|
||||
<template v-if="page !== 'search' && !isHome">
|
||||
<p v-if="!selectedSeries" class="font-book">{{ numShowing }} {{ entityName }}</p>
|
||||
<div v-else class="flex items-center">
|
||||
<div @click="seriesBackArrow" class="rounded-full h-10 w-10 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
|
||||
<span class="material-icons text-3xl text-white">west</span>
|
||||
<div @click="seriesBackArrow" class="rounded-full h-9 w-9 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
|
||||
<span class="material-icons text-2xl text-white">west</span>
|
||||
</div>
|
||||
<!-- <span class="material-icons text-2xl cursor-pointer" @click="seriesBackArrow">west</span> -->
|
||||
<p class="pl-4 font-book text-lg">
|
||||
{{ selectedSeries }} <span class="ml-3 font-mono text-lg bg-black bg-opacity-30 rounded-lg px-1 py-0.5">{{ numShowing }}</span>
|
||||
{{ selectedSeries }}
|
||||
</p>
|
||||
<div class="w-6 h-6 rounded-full bg-black bg-opacity-30 flex items-center justify-center ml-3">
|
||||
<span class="font-mono">{{ numShowing }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
|
||||
|
||||
74
client/components/app/PdfReader.vue
Normal file
74
client/components/app/PdfReader.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div class="w-full pt-20">
|
||||
<div :style="{ height: pdfHeight + 'px' }" class="overflow-hidden m-auto">
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="px-12">
|
||||
<span class="material-icons text-5xl text-black" :class="!canGoPrev ? 'text-opacity-10' : 'cursor-pointer text-opacity-30 hover:text-opacity-90'" @click.stop.prevent="goPrevPage" @mousedown.prevent>arrow_back_ios</span>
|
||||
</div>
|
||||
<div :style="{ width: pdfWidth + 'px', height: pdfHeight + 'px' }" class="w-full h-full overflow-auto">
|
||||
<div v-if="loadedRatio > 0 && loadedRatio < 1" style="background-color: green; color: white; text-align: center" :style="{ width: loadedRatio * 100 + '%' }">{{ Math.floor(loadedRatio * 100) }}%</div>
|
||||
<pdf ref="pdf" class="m-auto z-10 border border-black border-opacity-20 shadow-md" :src="src" :page="page" :rotate="rotate" @progress="loadedRatio = $event" @error="error" @num-pages="numPagesLoaded" @link-clicked="page = $event"></pdf>
|
||||
</div>
|
||||
|
||||
<div class="px-12">
|
||||
<span class="material-icons text-5xl text-black" :class="!canGoNext ? 'text-opacity-10' : 'cursor-pointer text-opacity-30 hover:text-opacity-90'" @click.stop.prevent="goNextPage" @mousedown.prevent>arrow_forward_ios</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center py-2 text-lg">
|
||||
<p>{{ page }} / {{ numPages }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import pdf from 'vue-pdf'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
pdf
|
||||
},
|
||||
props: {
|
||||
src: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
rotate: 0,
|
||||
loadedRatio: 0,
|
||||
page: 1,
|
||||
numPages: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
pdfWidth() {
|
||||
return this.pdfHeight * 0.6667
|
||||
},
|
||||
pdfHeight() {
|
||||
return window.innerHeight - 120
|
||||
},
|
||||
canGoNext() {
|
||||
return this.page < this.numPages
|
||||
},
|
||||
canGoPrev() {
|
||||
return this.page > 1
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
numPagesLoaded(e) {
|
||||
this.numPages = e
|
||||
},
|
||||
goPrevPage() {
|
||||
if (this.page <= 1) return
|
||||
this.page--
|
||||
},
|
||||
goNextPage() {
|
||||
if (this.page >= this.numPages) return
|
||||
this.page++
|
||||
},
|
||||
error(err) {
|
||||
console.error(err)
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div v-if="value" class="w-screen h-screen fixed top-0 left-0 z-50 bg-white text-black">
|
||||
<div v-if="show" class="w-screen h-screen fixed top-0 left-0 z-50 bg-white text-black">
|
||||
<div class="absolute top-4 right-4 z-10">
|
||||
<span class="material-icons cursor-pointer text-4xl" @click="show = false">close</span>
|
||||
</div>
|
||||
@@ -9,11 +9,12 @@
|
||||
</select>
|
||||
</div> -->
|
||||
<div class="absolute top-4 left-4 font-book">
|
||||
<h1 class="text-2xl mb-1">{{ title }}</h1>
|
||||
|
||||
<p v-if="author">by {{ author }}</p>
|
||||
<h1 class="text-2xl mb-1">{{ title || abTitle }}</h1>
|
||||
<p v-if="author || abAuthor">by {{ author || abAuthor }}</p>
|
||||
</div>
|
||||
<div class="h-full flex items-center">
|
||||
|
||||
<!-- EPUB -->
|
||||
<div v-if="ebookType === 'epub'" class="h-full flex items-center">
|
||||
<div style="width: 100px; max-width: 100px" class="h-full flex items-center overflow-x-hidden">
|
||||
<span v-show="hasPrev" class="material-icons text-black text-opacity-30 hover:text-opacity-80 cursor-pointer text-8xl" @mousedown.prevent @click="pageLeft">chevron_left</span>
|
||||
</div>
|
||||
@@ -28,19 +29,31 @@
|
||||
<span v-show="hasNext" class="material-icons text-black text-opacity-30 hover:text-opacity-80 cursor-pointer text-8xl" @mousedown.prevent @click="pageRight">chevron_right</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- MOBI/AZW3 -->
|
||||
<div v-else-if="ebookType === 'mobi'" class="h-full max-h-full w-full">
|
||||
<div class="ebook-viewer absolute overflow-y-scroll left-0 right-0 top-12 w-full max-w-4xl m-auto z-10 border border-black border-opacity-20">
|
||||
<iframe title="html-viewer" width="100%"> Loading </iframe>
|
||||
</div>
|
||||
</div>
|
||||
<!-- PDF -->
|
||||
<div v-else-if="ebookType === 'pdf'" class="h-full flex items-center">
|
||||
<app-pdf-reader :src="ebookUrl" />
|
||||
</div>
|
||||
|
||||
<div class="absolute bottom-2 left-2">{{ ebookType }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ePub from 'epubjs'
|
||||
import MobiParser from '@/assets/ebooks/mobi.js'
|
||||
import HtmlParser from '@/assets/ebooks/htmlParser.js'
|
||||
import defaultCss from '@/assets/ebooks/basic.js'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
url: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
scale: 1,
|
||||
book: null,
|
||||
rendition: null,
|
||||
chapters: [],
|
||||
@@ -48,7 +61,9 @@ export default {
|
||||
author: '',
|
||||
progress: 0,
|
||||
hasNext: true,
|
||||
hasPrev: false
|
||||
hasPrev: false,
|
||||
ebookType: '',
|
||||
ebookUrl: ''
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -63,21 +78,50 @@ export default {
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
return this.$store.state.showEReader
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
this.$store.commit('setShowEReader', val)
|
||||
}
|
||||
},
|
||||
abTitle() {
|
||||
return this.selectedAudiobook.book.title
|
||||
},
|
||||
abAuthor() {
|
||||
return this.selectedAudiobook.book.author
|
||||
},
|
||||
selectedAudiobook() {
|
||||
return this.$store.state.selectedAudiobook
|
||||
},
|
||||
libraryId() {
|
||||
return this.selectedAudiobook.libraryId
|
||||
},
|
||||
folderId() {
|
||||
return this.selectedAudiobook.folderId
|
||||
},
|
||||
ebooks() {
|
||||
return this.selectedAudiobook.ebooks || []
|
||||
},
|
||||
epubEbook() {
|
||||
return this.ebooks.find((eb) => eb.ext === '.epub')
|
||||
},
|
||||
mobiEbook() {
|
||||
return this.ebooks.find((eb) => eb.ext === '.mobi' || eb.ext === '.azw3')
|
||||
},
|
||||
pdfEbook() {
|
||||
return this.ebooks.find((eb) => eb.ext === '.pdf')
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
selectedAudiobookFile() {
|
||||
return this.$store.state.selectedAudiobookFile
|
||||
}
|
||||
// fullUrl() {
|
||||
// var serverUrl = process.env.serverUrl || `/s/book/${this.audiobookId}`
|
||||
// return `${serverUrl}/${this.url}`
|
||||
// }
|
||||
},
|
||||
methods: {
|
||||
getEbookUrl(path) {
|
||||
return `/ebook/${this.libraryId}/${this.folderId}/${path}`
|
||||
},
|
||||
changedChapter() {
|
||||
if (this.rendition) {
|
||||
this.rendition.display(this.selectedChapter)
|
||||
@@ -115,14 +159,119 @@ export default {
|
||||
},
|
||||
init() {
|
||||
this.registerListeners()
|
||||
if (this.selectedAudiobookFile) {
|
||||
this.ebookUrl = this.getEbookUrl(this.selectedAudiobookFile.path)
|
||||
if (this.selectedAudiobookFile.ext === '.pdf') {
|
||||
this.ebookType = 'pdf'
|
||||
} else if (this.selectedAudiobookFile.ext === '.mobi' || this.selectedAudiobookFile.ext === '.azw3') {
|
||||
this.ebookType = 'mobi'
|
||||
this.initMobi()
|
||||
} else if (this.selectedAudiobookFile.ext === '.epub') {
|
||||
this.ebookType = 'epub'
|
||||
this.initEpub()
|
||||
}
|
||||
} else if (this.epubEbook) {
|
||||
this.ebookType = 'epub'
|
||||
this.ebookUrl = this.getEbookUrl(this.epubEbook.path)
|
||||
this.initEpub()
|
||||
} else if (this.mobiEbook) {
|
||||
this.ebookType = 'mobi'
|
||||
this.ebookUrl = this.getEbookUrl(this.mobiEbook.path)
|
||||
this.initMobi()
|
||||
} else if (this.pdfEbook) {
|
||||
this.ebookType = 'pdf'
|
||||
this.ebookUrl = this.getEbookUrl(this.pdfEbook.path)
|
||||
}
|
||||
},
|
||||
addHtmlCss() {
|
||||
let iframe = document.getElementsByTagName('iframe')[0]
|
||||
if (!iframe) return
|
||||
let doc = iframe.contentDocument
|
||||
if (!doc) return
|
||||
let style = doc.createElement('style')
|
||||
style.id = 'default-style'
|
||||
style.textContent = defaultCss
|
||||
doc.head.appendChild(style)
|
||||
},
|
||||
handleIFrameHeight(iFrame) {
|
||||
const isElement = (obj) => !!(obj && obj.nodeType === 1)
|
||||
|
||||
console.log('epub', this.url)
|
||||
var body = iFrame.contentWindow.document.body,
|
||||
html = iFrame.contentWindow.document.documentElement
|
||||
iFrame.height = Math.max(body.scrollHeight, body.offsetHeight, html.clientHeight, html.scrollHeight, html.offsetHeight) * 2
|
||||
|
||||
setTimeout(() => {
|
||||
let lastchild = body.lastElementChild
|
||||
let lastEle = body.lastChild
|
||||
|
||||
let itemAs = body.querySelectorAll('a')
|
||||
let itemPs = body.querySelectorAll('p')
|
||||
let lastItemA = itemAs[itemAs.length - 1]
|
||||
let lastItemP = itemPs[itemPs.length - 1]
|
||||
let lastItem
|
||||
if (isElement(lastItemA) && isElement(lastItemP)) {
|
||||
if (lastItemA.clientHeight + lastItemA.offsetTop > lastItemP.clientHeight + lastItemP.offsetTop) {
|
||||
lastItem = lastItemA
|
||||
} else {
|
||||
lastItem = lastItemP
|
||||
}
|
||||
}
|
||||
|
||||
if (!lastchild && !lastItem && !lastEle) return
|
||||
if (lastEle.nodeType === 3 && !lastchild && !lastItem) return
|
||||
|
||||
let nodeHeight = 0
|
||||
if (lastEle.nodeType === 3 && document.createRange) {
|
||||
let range = document.createRange()
|
||||
range.selectNodeContents(lastEle)
|
||||
if (range.getBoundingClientRect) {
|
||||
let rect = range.getBoundingClientRect()
|
||||
if (rect) {
|
||||
nodeHeight = rect.bottom - rect.top
|
||||
}
|
||||
}
|
||||
}
|
||||
var lastChildHeight = isElement(lastchild) ? lastchild.clientHeight + lastchild.offsetTop : 0
|
||||
var lastEleHeight = isElement(lastEle) ? lastEle.clientHeight + lastEle.offsetTop : 0
|
||||
var lastItemHeight = isElement(lastItem) ? lastItem.clientHeight + lastItem.offsetTop : 0
|
||||
iFrame.height = Math.max(lastChildHeight, lastEleHeight, lastItemHeight) + 100 + nodeHeight
|
||||
}, 500)
|
||||
},
|
||||
async initMobi() {
|
||||
// Fetch mobi file as blob
|
||||
var buff = await this.$axios.$get(this.ebookUrl, {
|
||||
responseType: 'blob'
|
||||
})
|
||||
var reader = new FileReader()
|
||||
reader.onload = async (event) => {
|
||||
var file_content = event.target.result
|
||||
|
||||
let mobiFile = new MobiParser(file_content)
|
||||
|
||||
let content = await mobiFile.render()
|
||||
let htmlParser = new HtmlParser(new DOMParser().parseFromString(content.outerHTML, 'text/html'))
|
||||
var anchoredDoc = htmlParser.getAnchoredDoc()
|
||||
|
||||
let iFrame = document.getElementsByTagName('iframe')[0]
|
||||
iFrame.contentDocument.body.innerHTML = anchoredDoc.documentElement.outerHTML
|
||||
|
||||
// Add css
|
||||
let style = iFrame.contentDocument.createElement('style')
|
||||
style.id = 'default-style'
|
||||
style.textContent = defaultCss
|
||||
iFrame.contentDocument.head.appendChild(style)
|
||||
|
||||
this.handleIFrameHeight(iFrame)
|
||||
}
|
||||
reader.readAsArrayBuffer(buff)
|
||||
},
|
||||
initEpub() {
|
||||
// var book = ePub(this.url, {
|
||||
// requestHeaders: {
|
||||
// Authorization: `Bearer ${this.userToken}`
|
||||
// }
|
||||
// })
|
||||
var book = ePub(this.url)
|
||||
var book = ePub(this.ebookUrl)
|
||||
this.book = book
|
||||
|
||||
this.rendition = book.renderTo('viewer', {
|
||||
@@ -188,4 +337,11 @@ export default {
|
||||
this.unregisterListeners()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* @import url(@/assets/calibre/basic.css); */
|
||||
.ebook-viewer {
|
||||
height: calc(100% - 96px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="flex h-full px-1 overflow-hidden">
|
||||
<cards-book-cover :audiobook="audiobook" :width="40" />
|
||||
<cards-book-cover :audiobook="audiobook" :width="50" />
|
||||
<div class="flex-grow px-2 searchCardContent h-full">
|
||||
<p class="truncate text-sm">{{ title }}</p>
|
||||
<p class="text-xs text-gray-200 truncate">by {{ author }}</p>
|
||||
@@ -38,7 +38,7 @@ export default {
|
||||
<style>
|
||||
.searchCardContent {
|
||||
width: calc(100% - 80px);
|
||||
height: calc(40px * 1.5);
|
||||
height: calc(50px * 1.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
32
client/components/cards/AuthorSearchCard.vue
Normal file
32
client/components/cards/AuthorSearchCard.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<div class="flex h-full px-1 overflow-hidden">
|
||||
<img src="https://rpgplanner.com/wp-content/uploads/2020/06/no-photo-available.png" class="w-40 h-40 max-h-40 object-contain" style="max-height: 40px; max-width: 40px" />
|
||||
<div class="flex-grow px-2 authorSearchCardContent h-full">
|
||||
<p class="truncate text-sm">{{ author }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
author: String
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.searchCardContent {
|
||||
width: calc(100% - 80px);
|
||||
height: 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -14,11 +14,16 @@
|
||||
<cards-book-cover :audiobook="audiobook" :author-override="authorFormat" :width="width" />
|
||||
|
||||
<div v-show="isHovering || isSelectionMode" class="absolute top-0 left-0 w-full h-full bg-black rounded" :class="overlayWrapperClasslist">
|
||||
<div v-show="!isSelectionMode && !isMissing" class="h-full flex items-center justify-center">
|
||||
<div v-show="showPlayButton" class="h-full flex items-center justify-center">
|
||||
<div class="hover:text-gray-200 hover:scale-110 transform duration-200" @click.stop.prevent="play">
|
||||
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">play_circle_filled</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="showReadButton" class="h-full flex items-center justify-center">
|
||||
<div class="hover:text-gray-200 hover:scale-110 transform duration-200" @click.stop.prevent="clickReadEBook">
|
||||
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">auto_stories</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="userCanUpdate" v-show="!isSelectionMode" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-50" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="editClick">
|
||||
<span class="material-icons" :style="{ fontSize: sizeMultiplier + 'rem' }">edit</span>
|
||||
@@ -34,9 +39,14 @@
|
||||
</div>
|
||||
|
||||
<!-- EBook Icon -->
|
||||
<div v-if="showExperimentalFeatures && hasEbook" class="absolute rounded-full bg-blue-500 w-6 h-6 flex items-center justify-center bg-opacity-90" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
<div
|
||||
v-if="showSmallEBookIcon"
|
||||
class="absolute rounded-full bg-blue-500 flex items-center justify-center bg-opacity-90 hover:scale-125 transform duration-200"
|
||||
:style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem`, width: 1.5 * sizeMultiplier + 'rem', height: 1.5 * sizeMultiplier + 'rem' }"
|
||||
@click.stop.prevent="clickReadEBook"
|
||||
>
|
||||
<!-- <p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">EBook</p> -->
|
||||
<span class="material-icons text-white text-base">auto_stories</span>
|
||||
<span class="material-icons text-white" :style="{ fontSize: sizeMultiplier * 1 + 'rem' }">auto_stories</span>
|
||||
</div>
|
||||
|
||||
<div v-show="!isSelectionMode" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full" :class="userIsRead ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
||||
@@ -90,8 +100,10 @@ export default {
|
||||
hasEbook() {
|
||||
return this.audiobook.numEbooks
|
||||
},
|
||||
hasTracks() {
|
||||
return this.audiobook.numTracks
|
||||
},
|
||||
isSelectionMode() {
|
||||
// return this.$store.getters['getNumAudiobooksSelected']
|
||||
return !!this.selectedAudiobooks.length
|
||||
},
|
||||
selectedAudiobooks() {
|
||||
@@ -150,11 +162,23 @@ export default {
|
||||
return this.userProgress ? !!this.userProgress.isRead : false
|
||||
},
|
||||
showError() {
|
||||
return this.hasMissingParts || this.hasInvalidParts || this.isMissing
|
||||
return this.hasMissingParts || this.hasInvalidParts || this.isMissing || this.isIncomplete
|
||||
},
|
||||
showReadButton() {
|
||||
return !this.isSelectionMode && this.showExperimentalFeatures && !this.showPlayButton && this.hasEbook
|
||||
},
|
||||
showPlayButton() {
|
||||
return !this.isSelectionMode && !this.isMissing && !this.isIncomplete && this.hasTracks
|
||||
},
|
||||
showSmallEBookIcon() {
|
||||
return !this.isSelectionMode && this.showExperimentalFeatures && this.hasEbook
|
||||
},
|
||||
isMissing() {
|
||||
return this.audiobook.isMissing
|
||||
},
|
||||
isIncomplete() {
|
||||
return this.audiobook.isIncomplete
|
||||
},
|
||||
hasMissingParts() {
|
||||
return this.audiobook.hasMissingParts
|
||||
},
|
||||
@@ -163,6 +187,7 @@ export default {
|
||||
},
|
||||
errorText() {
|
||||
if (this.isMissing) return 'Audiobook directory is missing!'
|
||||
else if (this.isIncomplete) return 'Audiobook has no audio tracks & ebook'
|
||||
var txt = ''
|
||||
if (this.hasMissingParts) {
|
||||
txt = `${this.hasMissingParts} missing parts.`
|
||||
@@ -203,7 +228,8 @@ export default {
|
||||
this.$root.socket.emit('open_stream', this.audiobookId)
|
||||
},
|
||||
editClick() {
|
||||
this.$store.commit('showEditModal', this.audiobook)
|
||||
// this.$store.commit('showEditModal', this.audiobook)
|
||||
this.$emit('edit', this.audiobook)
|
||||
},
|
||||
clickCard(e) {
|
||||
if (this.isSelectionMode) {
|
||||
@@ -211,6 +237,9 @@ export default {
|
||||
e.preventDefault()
|
||||
this.selectBtnClick()
|
||||
}
|
||||
},
|
||||
clickReadEBook() {
|
||||
this.$store.commit('showEReader', this.audiobook)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
<template>
|
||||
<div class="relative rounded-sm overflow-hidden" :style="{ height: width * 1.6 + 'px', width: width + 'px', maxWidth: width + 'px', minWidth: width + 'px' }">
|
||||
<div class="relative rounded-sm overflow-hidden" :style="{ height: width * 1.6 + 'px', width: width + 'px', maxWidth: width + 'px', minWidth: width + 'px' }" @mouseover="isHovering = true" @mouseleave="isHovering = false">
|
||||
<div class="w-full h-full relative">
|
||||
<div v-if="showCoverBg" class="bg-primary absolute top-0 left-0 w-full h-full">
|
||||
<div class="w-full h-full z-0" ref="coverBg" />
|
||||
</div>
|
||||
<img ref="cover" :src="cover" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0" :class="showCoverBg ? 'object-contain' : 'object-cover'" />
|
||||
|
||||
<a v-if="!imageFailed && showOpenNewTab && isHovering" :href="cover" @click.stop target="_blank" class="absolute bg-primary flex items-center justify-center shadow-sm rounded-full hover:scale-110 transform duration-100" :style="{ top: sizeMultiplier * 0.5 + 'rem', right: sizeMultiplier * 0.5 + 'rem', width: 2.5 * sizeMultiplier + 'rem', height: 2.5 * sizeMultiplier + 'rem' }">
|
||||
<span class="material-icons" :style="{ fontSize: sizeMultiplier * 1.75 + 'rem' }">open_in_new</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div v-if="imageFailed" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-red-100" :style="{ padding: placeholderCoverPadding + 'rem' }">
|
||||
@@ -23,12 +27,16 @@ export default {
|
||||
width: {
|
||||
type: Number,
|
||||
default: 120
|
||||
}
|
||||
},
|
||||
showOpenNewTab: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
imageFailed: false,
|
||||
showCoverBg: false
|
||||
showCoverBg: false,
|
||||
isHovering: false,
|
||||
naturalHeight: 0,
|
||||
naturalWidth: 0
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -60,6 +68,9 @@ export default {
|
||||
imageLoaded() {
|
||||
if (this.$refs.cover) {
|
||||
var { naturalWidth, naturalHeight } = this.$refs.cover
|
||||
this.naturalHeight = naturalHeight
|
||||
this.naturalWidth = naturalWidth
|
||||
|
||||
var aspectRatio = naturalHeight / naturalWidth
|
||||
var arDiff = Math.abs(aspectRatio - 1.6)
|
||||
|
||||
|
||||
36
client/components/cards/SeriesSearchCard.vue
Normal file
36
client/components/cards/SeriesSearchCard.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<div class="flex h-full px-1 overflow-hidden">
|
||||
<cards-group-cover :name="series" :book-items="bookItems" :width="60" :height="60" />
|
||||
<div class="flex-grow px-2 seriesSearchCardContent h-full">
|
||||
<p class="truncate text-sm">{{ series }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
series: String,
|
||||
bookItems: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.seriesSearchCardContent {
|
||||
width: calc(100% - 80px);
|
||||
height: 60px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="w-64 ml-8 relative">
|
||||
<div class="w-80 ml-6 relative">
|
||||
<form @submit.prevent="submitSearch">
|
||||
<ui-text-input ref="input" v-model="search" placeholder="Search.." @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
|
||||
</form>
|
||||
@@ -7,23 +7,42 @@
|
||||
<span v-if="!search" class="material-icons" style="font-size: 1.2rem">search</span>
|
||||
<span v-else class="material-icons" style="font-size: 1.2rem">close</span>
|
||||
</div>
|
||||
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
|
||||
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 px-2 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalSearchMenu">
|
||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<li v-if="isTyping" class="py-2 px-2">
|
||||
<p>Typing...</p>
|
||||
<p>Thinking...</p>
|
||||
</li>
|
||||
<li v-else-if="isFetching" class="py-2 px-2">
|
||||
<p>Fetching...</p>
|
||||
</li>
|
||||
<li v-else-if="!items.length" class="py-2 px-2">
|
||||
<li v-else-if="!totalResults" class="py-2 px-2">
|
||||
<p>No Results</p>
|
||||
</li>
|
||||
<template v-else>
|
||||
<template v-for="item in items">
|
||||
<li :key="item.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickedOption(item)">
|
||||
<template v-if="item.type === 'audiobook'">
|
||||
<cards-audiobook-search-card :audiobook="item.data" />
|
||||
</template>
|
||||
<p class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">Books</p>
|
||||
<template v-for="item in audiobookResults">
|
||||
<li :key="item.audiobook.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
||||
<nuxt-link :to="`/audiobook/${item.audiobook.id}`">
|
||||
<cards-audiobook-search-card :audiobook="item.audiobook" />
|
||||
</nuxt-link>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<p v-if="authorResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Authors</p>
|
||||
<template v-for="item in authorResults">
|
||||
<li :key="item.author" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(item.author)}`">
|
||||
<cards-author-search-card :author="item.author" />
|
||||
</nuxt-link>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<p v-if="seriesResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Series</p>
|
||||
<template v-for="item in seriesResults">
|
||||
<li :key="item.series" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickedOption(item.series)">
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf/series?series=${$encode(item.series)}`">
|
||||
<cards-series-search-card :series="item.series" :book-items="item.audiobooks" />
|
||||
</nuxt-link>
|
||||
</li>
|
||||
</template>
|
||||
</template>
|
||||
@@ -42,7 +61,9 @@ export default {
|
||||
isTyping: false,
|
||||
isFetching: false,
|
||||
search: null,
|
||||
items: [],
|
||||
audiobookResults: [],
|
||||
authorResults: [],
|
||||
seriesResults: [],
|
||||
searchTimeout: null,
|
||||
lastSearch: null
|
||||
}
|
||||
@@ -53,6 +74,9 @@ export default {
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
totalResults() {
|
||||
return this.audiobookResults.length + this.seriesResults.length + this.authorResults.length
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -61,7 +85,9 @@ export default {
|
||||
this.$router.push(`/library/${this.currentLibraryId}/bookshelf/search?query=${this.search}`)
|
||||
|
||||
this.search = null
|
||||
this.items = []
|
||||
this.audiobookResults = []
|
||||
this.authorResults = []
|
||||
this.seriesResults = []
|
||||
this.showMenu = false
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.input) {
|
||||
@@ -86,22 +112,19 @@ export default {
|
||||
return
|
||||
}
|
||||
this.isFetching = true
|
||||
var results = await this.$axios.$get(`/api/audiobooks?q=${value}`).catch((error) => {
|
||||
|
||||
var searchResults = await this.$axios.$get(`/api/library/${this.currentLibraryId}/search?q=${value}`).catch((error) => {
|
||||
console.error('Search error', error)
|
||||
return []
|
||||
})
|
||||
this.audiobookResults = searchResults.audiobooks || []
|
||||
this.authorResults = searchResults.authors || []
|
||||
this.seriesResults = searchResults.series || []
|
||||
|
||||
this.isFetching = false
|
||||
if (!this.showMenu) {
|
||||
return
|
||||
}
|
||||
|
||||
this.items = results.map((res) => {
|
||||
return {
|
||||
id: res.id,
|
||||
data: res,
|
||||
type: 'audiobook'
|
||||
}
|
||||
})
|
||||
},
|
||||
inputUpdate(val) {
|
||||
clearTimeout(this.searchTimeout)
|
||||
@@ -114,21 +137,25 @@ export default {
|
||||
this.searchTimeout = setTimeout(() => {
|
||||
this.isTyping = false
|
||||
this.runSearch(val)
|
||||
}, 1000)
|
||||
},
|
||||
clickedOption(option) {
|
||||
if (option.type === 'audiobook') {
|
||||
this.$router.push(`/audiobook/${option.data.id}`)
|
||||
}
|
||||
}, 750)
|
||||
},
|
||||
clickClear() {
|
||||
if (this.search) {
|
||||
this.search = null
|
||||
this.items = []
|
||||
this.lastSearch = null
|
||||
this.audiobookResults = []
|
||||
this.authorResults = []
|
||||
this.seriesResults = []
|
||||
this.showMenu = false
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.globalSearchMenu {
|
||||
max-height: 80vh;
|
||||
}
|
||||
</style>
|
||||
@@ -10,6 +10,14 @@
|
||||
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-show="canGoPrev" class="absolute -left-24 top-0 bottom-0 h-full pointer-events-none flex items-center px-6">
|
||||
<div class="material-icons text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goPrevBook" @mousedown.prevent>arrow_back_ios</div>
|
||||
</div>
|
||||
<div v-show="canGoNext" class="absolute -right-24 top-0 bottom-0 h-full pointer-events-none flex items-center px-6">
|
||||
<div class="material-icons text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goNextBook" @mousedown.prevent>arrow_forward_ios</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full h-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300">
|
||||
<keep-alive>
|
||||
<component v-if="audiobook" :is="tabName" :audiobook="audiobook" :processing.sync="processing" @close="show = false" />
|
||||
@@ -36,11 +44,11 @@ export default {
|
||||
title: 'Cover',
|
||||
component: 'modals-edit-tabs-cover'
|
||||
},
|
||||
{
|
||||
id: 'match',
|
||||
title: 'Match',
|
||||
component: 'modals-edit-tabs-match'
|
||||
},
|
||||
// {
|
||||
// id: 'match',
|
||||
// title: 'Match',
|
||||
// component: 'modals-edit-tabs-match'
|
||||
// },
|
||||
{
|
||||
id: 'tracks',
|
||||
title: 'Tracks',
|
||||
@@ -51,6 +59,11 @@ export default {
|
||||
title: 'Chapters',
|
||||
component: 'modals-edit-tabs-chapters'
|
||||
},
|
||||
{
|
||||
id: 'files',
|
||||
title: 'Files',
|
||||
component: 'modals-edit-tabs-files'
|
||||
},
|
||||
{
|
||||
id: 'download',
|
||||
title: 'Download',
|
||||
@@ -68,6 +81,7 @@ export default {
|
||||
this.show = false
|
||||
return
|
||||
}
|
||||
|
||||
if (!availableTabIds.includes(this.selectedTab)) {
|
||||
this.selectedTab = availableTabIds[0]
|
||||
}
|
||||
@@ -137,9 +151,44 @@ export default {
|
||||
},
|
||||
title() {
|
||||
return this.book.title || 'No Title'
|
||||
},
|
||||
bookshelfBookIds() {
|
||||
return this.$store.state.bookshelfBookIds || []
|
||||
},
|
||||
currentBookshelfIndex() {
|
||||
if (!this.bookshelfBookIds.length) return 0
|
||||
return this.bookshelfBookIds.findIndex((bid) => bid === this.selectedAudiobookId)
|
||||
},
|
||||
canGoPrev() {
|
||||
return this.bookshelfBookIds.length && this.currentBookshelfIndex > 0
|
||||
},
|
||||
canGoNext() {
|
||||
return this.bookshelfBookIds.length && this.currentBookshelfIndex < this.bookshelfBookIds.length - 1
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
goPrevBook() {
|
||||
if (this.currentBookshelfIndex - 1 < 0) return
|
||||
var prevBookId = this.bookshelfBookIds[this.currentBookshelfIndex - 1]
|
||||
var prevBook = this.$store.getters['audiobooks/getAudiobook'](prevBookId)
|
||||
if (prevBook) {
|
||||
this.$store.commit('showEditModalOnTab', { audiobook: prevBook, tab: this.selectedTab })
|
||||
this.$nextTick(this.init)
|
||||
} else {
|
||||
console.error('Book not found', prevBookId)
|
||||
}
|
||||
},
|
||||
goNextBook() {
|
||||
if (this.currentBookshelfIndex >= this.bookshelfBookIds.length) return
|
||||
var nextBookId = this.bookshelfBookIds[this.currentBookshelfIndex + 1]
|
||||
var nextBook = this.$store.getters['audiobooks/getAudiobook'](nextBookId)
|
||||
if (nextBook) {
|
||||
this.$store.commit('showEditModalOnTab', { audiobook: nextBook, tab: this.selectedTab })
|
||||
this.$nextTick(this.init)
|
||||
} else {
|
||||
console.error('Book not found', nextBookId)
|
||||
}
|
||||
},
|
||||
selectTab(tab) {
|
||||
this.selectedTab = tab
|
||||
},
|
||||
@@ -155,9 +204,12 @@ export default {
|
||||
},
|
||||
async fetchFull() {
|
||||
try {
|
||||
this.processing = true
|
||||
this.audiobook = await this.$axios.$get(`/api/audiobook/${this.selectedAudiobookId}`)
|
||||
this.processing = false
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch audiobook', this.selectedAudiobookId, error)
|
||||
this.processing = false
|
||||
this.show = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 200px; max-height: 80vh">
|
||||
<div v-if="show" class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 200px; max-height: 80vh">
|
||||
<div v-if="!showAddLibrary" class="w-full h-full flex flex-col justify-center px-4">
|
||||
<div class="flex items-center mb-4">
|
||||
<p>{{ libraries.length }} Libraries</p>
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
<div v-if="showLocalCovers" class="flex items-center justify-center">
|
||||
<template v-for="cover in localCovers">
|
||||
<div :key="cover.path" class="m-0.5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover.localPath === imageUrl ? 'border-yellow-300' : ''" @click="setCover(cover.localPath)">
|
||||
<div :key="cover.path" class="m-0.5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover.localPath === imageUrl ? 'border-yellow-300' : ''" @click="setCover(cover)">
|
||||
<div class="h-24 bg-primary" style="width: 60px">
|
||||
<img :src="`${cover.localPath}?token=${userToken}`" class="h-full w-full object-contain" />
|
||||
</div>
|
||||
@@ -53,14 +53,11 @@
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div v-if="hasSearched" class="flex items-center flex-wrap justify-center max-h-60 overflow-y-scroll mt-2 max-w-full">
|
||||
<div v-if="hasSearched" class="flex items-center flex-wrap justify-center max-h-80 overflow-y-scroll mt-2 max-w-full">
|
||||
<p v-if="!coversFound.length">No Covers Found</p>
|
||||
<template v-for="cover in coversFound">
|
||||
<div :key="cover" class="m-0.5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover === imageUrl ? 'border-yellow-300' : ''" @click="setCover(cover)">
|
||||
<div class="h-24 bg-primary" style="width: 60px">
|
||||
<img :src="cover" class="h-full w-full object-contain" />
|
||||
</div>
|
||||
<!-- <img :src="cover" class="h-24 object-cover" style="width: 60px" /> -->
|
||||
<cards-preview-cover :src="cover" :width="80" show-open-new-tab />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -268,8 +265,24 @@ export default {
|
||||
this.isProcessing = false
|
||||
this.hasSearched = true
|
||||
},
|
||||
setCover(cover) {
|
||||
this.updateCover(cover)
|
||||
setCover(coverFile) {
|
||||
this.isProcessing = true
|
||||
this.$axios
|
||||
.$patch(`/api/audiobook/${this.audiobook.id}/coverfile`, coverFile)
|
||||
.then((data) => {
|
||||
console.log('response data', data)
|
||||
if (data && typeof data === 'string') {
|
||||
this.$toast.success(data)
|
||||
}
|
||||
this.isProcessing = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
if (error.response && error.response.data) {
|
||||
this.$toast.error(error.response.data)
|
||||
}
|
||||
this.isProcessing = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,8 +214,6 @@ export default {
|
||||
this.details.volumeNumber = this.book.volumeNumber
|
||||
this.details.publishYear = this.book.publishYear
|
||||
|
||||
console.log('INIT', this.details)
|
||||
|
||||
this.newTags = this.audiobook.tags || []
|
||||
},
|
||||
resetProgress() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
|
||||
<p class="text-center text-lg mb-4 py-8">Preparing downloads can take several minutes and will be stored in <span class="bg-primary bg-opacity-75 font-mono p-1 text-base">/metadata/downloads</span>. After the download is ready, it will remain available for 60 minutes, then be deleted.<br />Download will timeout after 15 minutes.</p>
|
||||
<div class="w-full border border-black-200 p-4 my-4">
|
||||
<div v-if="showM4bDownload" class="w-full border border-black-200 p-4 my-4">
|
||||
<div class="flex items-center">
|
||||
<div>
|
||||
<p class="text-lg">M4B Audiobook File <span class="text-error">*</span></p>
|
||||
@@ -44,7 +44,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full flex items-center justify-center absolute bottom-4 left-0 right-0 text-center">
|
||||
<div v-if="showM4bDownload" class="w-full flex items-center justify-center absolute bottom-4 left-0 right-0 text-center">
|
||||
<p class="text-error text-lg">* <strong>Experimental:</strong> Merging multiple .m4b files may have issues. <a href="https://github.com/advplyr/audiobookshelf/issues" class="underline text-blue-600" target="_blank">Report issues here.</a></p>
|
||||
</div>
|
||||
|
||||
@@ -89,6 +89,9 @@ export default {
|
||||
audiobookId() {
|
||||
return this.audiobook ? this.audiobook.id : null
|
||||
},
|
||||
_audiobook() {
|
||||
return this.audiobook || {}
|
||||
},
|
||||
downloads() {
|
||||
return this.$store.getters['downloads/getDownloads'](this.audiobookId)
|
||||
},
|
||||
@@ -120,6 +123,9 @@ export default {
|
||||
},
|
||||
totalFiles() {
|
||||
return this.audioFiles.length + this.otherFiles.length
|
||||
},
|
||||
showM4bDownload() {
|
||||
return !this._audiobook.isMissing && !this._audiobook.isIncomplete && this._audiobook.tracks.length
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
21
client/components/modals/edit-tabs/Files.vue
Normal file
21
client/components/modals/edit-tabs/Files.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
||||
<tables-all-files-table :audiobook="audiobook" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
audiobook: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,41 +1,44 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
||||
<div class="w-full bg-primary px-4 py-2 flex items-center">
|
||||
<div class="h-7 w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
|
||||
<span class="text-sm font-mono">{{ tracks.length }}</span>
|
||||
<template v-if="hasTracks">
|
||||
<div class="w-full bg-primary px-4 py-2 flex items-center">
|
||||
<div class="h-7 w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
|
||||
<span class="text-sm font-mono">{{ tracks.length }}</span>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
|
||||
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobook.id}/edit`" class="mr-4">
|
||||
<ui-btn small color="primary">Manage Tracks</ui-btn>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
|
||||
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobook.id}/edit`" class="mr-4">
|
||||
<ui-btn small color="primary">Manage Tracks</ui-btn>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<table class="text-sm tracksTable">
|
||||
<tr class="font-book">
|
||||
<th>#</th>
|
||||
<th class="text-left">Filename</th>
|
||||
<th class="text-left">Size</th>
|
||||
<th class="text-left">Duration</th>
|
||||
<th v-if="showDownload" class="text-center">Download</th>
|
||||
</tr>
|
||||
<template v-for="track in tracksCleaned">
|
||||
<tr :key="track.index">
|
||||
<td class="text-center">
|
||||
<p>{{ track.index }}</p>
|
||||
</td>
|
||||
<td class="font-sans">{{ showFullPath ? track.fullPath : track.filename }}</td>
|
||||
<td class="font-mono">
|
||||
{{ $bytesPretty(track.size) }}
|
||||
</td>
|
||||
<td class="font-mono">
|
||||
{{ $secondsToTimestamp(track.duration) }}
|
||||
</td>
|
||||
<td v-if="showDownload" class="font-mono text-center">
|
||||
<a :href="`/s/book/${audiobook.id}/${track.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
|
||||
</td>
|
||||
<table class="text-sm tracksTable">
|
||||
<tr class="font-book">
|
||||
<th>#</th>
|
||||
<th class="text-left">Filename</th>
|
||||
<th class="text-left">Size</th>
|
||||
<th class="text-left">Duration</th>
|
||||
<th v-if="showDownload" class="text-center">Download</th>
|
||||
</tr>
|
||||
</template>
|
||||
</table>
|
||||
<template v-for="track in tracksCleaned">
|
||||
<tr :key="track.index">
|
||||
<td class="text-center">
|
||||
<p>{{ track.index }}</p>
|
||||
</td>
|
||||
<td class="font-sans">{{ showFullPath ? track.fullPath : track.filename }}</td>
|
||||
<td class="font-mono">
|
||||
{{ $bytesPretty(track.size) }}
|
||||
</td>
|
||||
<td class="font-mono">
|
||||
{{ $secondsToTimestamp(track.duration) }}
|
||||
</td>
|
||||
<td v-if="showDownload" class="font-mono text-center">
|
||||
<a :href="`/s/book/${audiobook.id}/${track.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</table>
|
||||
</template>
|
||||
<div v-else class="flex my-4 text-center justify-center text-xl">No Audio Tracks</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -50,7 +53,6 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
tracks: null,
|
||||
audioFiles: null,
|
||||
showFullPath: false
|
||||
}
|
||||
},
|
||||
@@ -94,11 +96,13 @@ export default {
|
||||
},
|
||||
showDownload() {
|
||||
return this.userCanDownload && !this.isMissing
|
||||
},
|
||||
hasTracks() {
|
||||
return this.audiobook.tracks.length
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
init() {
|
||||
this.audioFiles = this.audiobook.audioFiles
|
||||
this.tracks = this.audiobook.tracks
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
<template>
|
||||
<div class="w-full px-4 h-12 border border-white border-opacity-10 cursor-pointer flex items-center relative -mt-px" :class="selected ? 'bg-primary bg-opacity-50' : 'hover:bg-primary hover:bg-opacity-25'" @mouseover="mouseover = true" @mouseleave="mouseover = false" @click="itemClicked">
|
||||
<div class="w-full px-4 h-12 border border-white border-opacity-10 flex items-center relative -mt-px" :class="selected ? 'bg-primary bg-opacity-50' : 'hover:bg-primary hover:bg-opacity-25'" @mouseover="mouseover = true" @mouseleave="mouseover = false">
|
||||
<div v-show="selected" class="absolute top-0 left-0 h-full w-0.5 bg-warning z-10" />
|
||||
<svg v-if="!libraryScan" xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-white" :class="mouseover ? 'text-opacity-90' : 'text-opacity-50'" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<svg v-if="!libraryScan" xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-white" :class="isHovering ? 'text-opacity-90' : 'text-opacity-50'" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 24 24" class="h-6 w-6 text-white text-opacity-50 animate-spin">
|
||||
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
||||
</svg>
|
||||
<p class="text-xl font-book pl-4" :class="mouseover ? 'underline' : ''">{{ library.name }}</p>
|
||||
<p class="text-xl font-book pl-4 hover:underline cursor-pointer" @click.stop="$emit('click', library)">{{ library.name }}</p>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn v-show="mouseover && !libraryScan && canScan" small color="bg" @click.stop="scan">Scan</ui-btn>
|
||||
<span v-show="mouseover && !libraryScan && showEdit && canEdit" class="material-icons text-xl text-gray-300 hover:text-gray-50 ml-4" @click.stop="editClick">edit</span>
|
||||
<span v-show="!libraryScan && mouseover && showEdit && canDelete" class="material-icons text-xl text-gray-300 ml-3" :class="isMain ? 'text-opacity-5 cursor-not-allowed' : 'hover:text-gray-50'" @click.stop="deleteClick">delete</span>
|
||||
<ui-btn v-show="isHovering && !libraryScan && canScan" small color="bg" @click.stop="scan">Scan</ui-btn>
|
||||
<span v-show="isHovering && !libraryScan && showEdit && canEdit" class="material-icons text-xl text-gray-300 hover:text-gray-50 ml-4 cursor-pointer" @click.stop="editClick">edit</span>
|
||||
<span v-show="!libraryScan && isHovering && showEdit && canDelete && !isDeleting" class="material-icons text-xl text-gray-300 ml-3" :class="isMain ? 'text-opacity-5 cursor-not-allowed' : 'hover:text-gray-50 cursor-pointer'" @click.stop="deleteClick">delete</span>
|
||||
<div v-show="isDeleting" class="text-xl text-gray-300 ml-3 animate-spin">
|
||||
<svg viewBox="0 0 24 24" class="w-6 h-6">
|
||||
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -23,14 +28,19 @@ export default {
|
||||
default: () => {}
|
||||
},
|
||||
selected: Boolean,
|
||||
showEdit: Boolean
|
||||
showEdit: Boolean,
|
||||
dragging: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
mouseover: false
|
||||
mouseover: false,
|
||||
isDeleting: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isHovering() {
|
||||
return this.mouseover && !this.dragging
|
||||
},
|
||||
isMain() {
|
||||
return this.library.id === 'main'
|
||||
},
|
||||
@@ -49,17 +59,34 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
itemClicked() {
|
||||
this.$emit('click', this.library)
|
||||
// this.$emit('click', this.library)
|
||||
},
|
||||
editClick() {
|
||||
this.$emit('edit', this.library)
|
||||
},
|
||||
deleteClick() {
|
||||
if (this.isMain) return
|
||||
this.$emit('delete', this.library)
|
||||
},
|
||||
scan() {
|
||||
this.$root.socket.emit('scan', this.library.id)
|
||||
},
|
||||
deleteClick() {
|
||||
if (this.isMain) return
|
||||
if (confirm(`Are you sure you want to permanently delete library "${this.library.name}"?`)) {
|
||||
this.isDeleting = true
|
||||
this.$axios
|
||||
.$delete(`/api/library/${this.library.id}`)
|
||||
.then((data) => {
|
||||
this.isDeleting = false
|
||||
if (data.error) {
|
||||
this.$toast.error(data.error)
|
||||
} else {
|
||||
this.$toast.success('Library deleted')
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to delete library', error)
|
||||
this.$toast.error('Failed to delete library')
|
||||
this.isDeleting = false
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
|
||||
109
client/components/tables/AllFilesTable.vue
Normal file
109
client/components/tables/AllFilesTable.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div class="w-full my-2">
|
||||
<div class="w-full bg-primary px-6 py-2 flex items-center cursor-pointer">
|
||||
<p class="pr-4">Files</p>
|
||||
<span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ allFiles.length }}</span>
|
||||
<div class="flex-grow" />
|
||||
|
||||
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<table class="text-sm tracksTable">
|
||||
<tr class="font-book">
|
||||
<th class="text-left px-4">Path</th>
|
||||
<th class="text-left px-4 w-24">Filetype</th>
|
||||
<th v-if="userCanDownload" class="text-center w-20">Download</th>
|
||||
</tr>
|
||||
<template v-for="file in allFiles">
|
||||
<tr :key="file.path">
|
||||
<td class="font-book pl-2">
|
||||
{{ showFullPath ? file.fullPath : file.path }}
|
||||
</td>
|
||||
<td class="text-xs">
|
||||
<p>{{ file.filetype }}</p>
|
||||
</td>
|
||||
<td v-if="userCanDownload" class="text-center">
|
||||
<a :href="`/s/book/${audiobookId}/${file.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
audiobook: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showFullPath: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
audiobookId() {
|
||||
return this.audiobook.id
|
||||
},
|
||||
audiobookPath() {
|
||||
return this.audiobook.path
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
isMissing() {
|
||||
return this.audiobook.isMissing
|
||||
},
|
||||
showDownload() {
|
||||
return this.userCanDownload && !this.isMissing
|
||||
},
|
||||
otherFiles() {
|
||||
return this.audiobook.otherFiles || []
|
||||
},
|
||||
audioFiles() {
|
||||
return this.audiobook.audioFiles || []
|
||||
},
|
||||
audioFilesCleaned() {
|
||||
return this.audioFiles.map((af) => {
|
||||
return {
|
||||
path: af.path,
|
||||
fullPath: af.fullPath,
|
||||
relativePath: this.getRelativePath(af.path),
|
||||
filetype: 'audio'
|
||||
}
|
||||
})
|
||||
},
|
||||
otherFilesCleaned() {
|
||||
return this.otherFiles.map((af) => {
|
||||
return {
|
||||
path: af.path,
|
||||
fullPath: af.fullPath,
|
||||
relativePath: this.getRelativePath(af.path),
|
||||
filetype: af.filetype
|
||||
}
|
||||
})
|
||||
},
|
||||
allFiles() {
|
||||
return this.audioFilesCleaned.concat(this.otherFilesCleaned)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getRelativePath(path) {
|
||||
var filePath = path.replace(/\\/g, '/')
|
||||
var audiobookPath = this.audiobookPath.replace(/\\/g, '/')
|
||||
return filePath
|
||||
.replace(audiobookPath + '/', '')
|
||||
.replace(/%/g, '%25')
|
||||
.replace(/#/g, '%23')
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,25 +1,42 @@
|
||||
<template>
|
||||
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
|
||||
<div id="librariesTable" class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
|
||||
<div class="flex items-center mb-2">
|
||||
<h1 class="text-xl">Libraries</h1>
|
||||
<div class="mx-2 w-7 h-7 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center" @click="clickAddLibrary">
|
||||
<span class="material-icons" style="font-size: 1.4rem">add</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-for="library in libraries">
|
||||
<modals-libraries-library-item :key="library.id" :library="library" :selected="currentLibraryId === library.id" :show-edit="true" @edit="editLibrary" @delete="deleteLibrary" @click="clickLibrary" />
|
||||
</template>
|
||||
<draggable v-model="libraryCopies" v-bind="dragOptions" class="list-group" draggable=".item" tag="div" @start="startDrag" @end="endDrag">
|
||||
<!-- <transition-group type="transition" :name="!drag ? 'flip-list' : null"> -->
|
||||
<template v-for="library in libraryCopies">
|
||||
<modals-libraries-library-item :key="library.id" :library="library" :selected="currentLibraryId === library.id" :show-edit="true" :dragging="drag" @edit="editLibrary" @click="setLibrary" class="item" />
|
||||
</template>
|
||||
<!-- </transition-group> -->
|
||||
</draggable>
|
||||
<modals-edit-library-modal v-model="showLibraryModal" :library="selectedLibrary" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import draggable from 'vuedraggable'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
draggable
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
libraryCopies: [],
|
||||
currentOrder: [],
|
||||
showLibraryModal: false,
|
||||
selectedLibrary: null
|
||||
selectedLibrary: null,
|
||||
drag: false,
|
||||
dragOptions: {
|
||||
animation: 200,
|
||||
group: 'description',
|
||||
ghostClass: 'ghost'
|
||||
},
|
||||
orderTimeout: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -30,35 +47,49 @@ export default {
|
||||
return this.currentLibrary ? this.currentLibrary.id : null
|
||||
},
|
||||
libraries() {
|
||||
return this.$store.state.libraries.libraries
|
||||
return this.$store.getters['libraries/getSortedLibraries']()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async clickLibrary(library) {
|
||||
startDrag() {
|
||||
this.drag = true
|
||||
clearTimeout(this.orderTimeout)
|
||||
},
|
||||
endDrag() {
|
||||
this.drag = false
|
||||
this.checkOrder()
|
||||
console.log('DRAG END')
|
||||
},
|
||||
checkOrder() {
|
||||
clearTimeout(this.orderTimeout)
|
||||
this.orderTimeout = setTimeout(() => {
|
||||
this.saveOrder()
|
||||
}, 500)
|
||||
},
|
||||
saveOrder() {
|
||||
var _newOrder = 1
|
||||
var currOrder = this.libraries.map((lib) => lib.id).join(',')
|
||||
var libraryOrderData = this.libraryCopies.map((library) => {
|
||||
return {
|
||||
newOrder: _newOrder++,
|
||||
oldOrder: library.displayOrder,
|
||||
id: library.id
|
||||
}
|
||||
})
|
||||
var newOrder = libraryOrderData.map((lib) => lib.id).join(',')
|
||||
if (currOrder !== newOrder) {
|
||||
this.$axios.$patch('/api/libraries/order', libraryOrderData).then((libraries) => {
|
||||
if (libraries && libraries.length) {
|
||||
this.$toast.success('Library order saved', { timeout: 1500 })
|
||||
this.$store.commit('libraries/set', libraries)
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
async setLibrary(library) {
|
||||
await this.$store.dispatch('libraries/fetch', library.id)
|
||||
this.$router.push(`/library/${library.id}`)
|
||||
},
|
||||
deleteLibrary(library) {
|
||||
if (library.id === 'main') return
|
||||
// if (confirm(`Are you sure you want to permanently delete user "${user.username}"?`)) {
|
||||
// this.isDeletingUser = true
|
||||
// this.$axios
|
||||
// .$delete(`/api/user/${user.id}`)
|
||||
// .then((data) => {
|
||||
// this.isDeletingUser = false
|
||||
// if (data.error) {
|
||||
// this.$toast.error(data.error)
|
||||
// } else {
|
||||
// this.$toast.success('User deleted')
|
||||
// }
|
||||
// })
|
||||
// .catch((error) => {
|
||||
// console.error('Failed to delete user', error)
|
||||
// this.$toast.error('Failed to delete user')
|
||||
// this.isDeletingUser = false
|
||||
// })
|
||||
// }
|
||||
},
|
||||
clickAddLibrary() {
|
||||
this.selectedLibrary = null
|
||||
this.showLibraryModal = true
|
||||
@@ -67,7 +98,11 @@ export default {
|
||||
this.selectedLibrary = library
|
||||
this.showLibraryModal = true
|
||||
},
|
||||
init() {}
|
||||
init() {
|
||||
this.libraryCopies = this.libraries.map((lib) => {
|
||||
return { ...lib }
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<tr class="font-book">
|
||||
<th class="text-left px-4">Path</th>
|
||||
<th class="text-left px-4 w-24">Filetype</th>
|
||||
<th v-if="userCanDownload" class="text-center w-20">Download</th>
|
||||
<th v-if="userCanDownload && !isMissing" class="text-center w-20">Download</th>
|
||||
</tr>
|
||||
<template v-for="file in otherFilesCleaned">
|
||||
<tr :key="file.path">
|
||||
@@ -28,9 +28,12 @@
|
||||
{{ showFullPath ? file.fullPath : file.path }}
|
||||
</td>
|
||||
<td class="text-xs">
|
||||
<p>{{ file.filetype }}</p>
|
||||
<div class="flex items-center">
|
||||
<span v-if="file.filetype === 'ebook'" class="material-icons text-base mr-1 cursor-pointer text-white text-opacity-60 hover:text-opacity-100" @click="readEbookClick(file)">auto_stories </span>
|
||||
<p>{{ file.filetype }}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td v-if="userCanDownload" class="text-center">
|
||||
<td v-if="userCanDownload && !isMissing" class="text-center">
|
||||
<a :href="`/s/book/${audiobookId}/${file.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -83,11 +86,17 @@ export default {
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
isMissing() {
|
||||
return this.audiobook.isMissing
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
readEbookClick(file) {
|
||||
this.$store.commit('showEReaderForFile', { audiobook: this.audiobook, file })
|
||||
},
|
||||
clickBar() {
|
||||
this.showFiles = !this.showFiles
|
||||
}
|
||||
|
||||
@@ -11,17 +11,43 @@
|
||||
<table id="accounts">
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Account Type</th>
|
||||
<th style="width: 200px">Created At</th>
|
||||
<th style="width: 100px"></th>
|
||||
<th class="w-20">Type</th>
|
||||
<th>Activity</th>
|
||||
<th class="w-32">Last Seen</th>
|
||||
<th class="w-32">Created</th>
|
||||
<th class="w-32"></th>
|
||||
</tr>
|
||||
<tr v-for="user in users" :key="user.id" :class="user.isActive ? '' : 'bg-error bg-opacity-20'">
|
||||
<td>
|
||||
{{ user.username }} <span class="text-xs text-gray-400 italic pl-4">({{ user.id }})</span>
|
||||
<div class="flex items-center">
|
||||
<span v-if="usersOnline[user.id]" class="w-3 h-3 text-sm mr-2 text-success animate-pulse"
|
||||
><svg viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" /></svg
|
||||
></span>
|
||||
<svg v-else class="w-3 h-3 mr-2 text-white text-opacity-20" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" />
|
||||
</svg>
|
||||
{{ user.username }} <span v-show="$isDev" class="text-xs text-gray-400 italic pl-4">({{ user.id }})</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ user.type }}</td>
|
||||
<td class="text-sm font-mono">
|
||||
{{ new Date(user.createdAt).toISOString() }}
|
||||
<td class="text-sm">{{ user.type }}</td>
|
||||
<td>
|
||||
<div v-if="usersOnline[user.id] && usersOnline[user.id].stream && usersOnline[user.id].stream.audiobook && usersOnline[user.id].stream.audiobook.book">
|
||||
<p class="truncate text-xs">Reading: {{ usersOnline[user.id].stream.audiobook.book.title || '' }}</p>
|
||||
</div>
|
||||
<div v-else-if="user.audiobooks && getLastRead(user.audiobooks)">
|
||||
<p class="truncate text-xs">Last: {{ getLastRead(user.audiobooks) }}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-xs font-mono">
|
||||
<ui-tooltip v-if="user.lastSeen" direction="top" :text="$formatDate(user.lastSeen, 'MMMM do, yyyy HH:mm')">
|
||||
{{ $dateDistanceFromNow(user.lastSeen) }}
|
||||
</ui-tooltip>
|
||||
</td>
|
||||
<td class="text-xs font-mono">
|
||||
<ui-tooltip direction="top" :text="$formatDate(user.createdAt, 'MMMM do, yyyy HH:mm')">
|
||||
{{ $formatDate(user.createdAt, 'MMM d, yyyy') }}
|
||||
</ui-tooltip>
|
||||
</td>
|
||||
<td>
|
||||
<div class="w-full flex justify-center">
|
||||
@@ -47,8 +73,41 @@ export default {
|
||||
isDeletingUser: false
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
computed: {
|
||||
currentUserId() {
|
||||
return this.$store.state.user.user.id
|
||||
},
|
||||
userStream() {
|
||||
return this.$store.state.streamAudiobook
|
||||
},
|
||||
usersOnline() {
|
||||
var _users = this.$store.state.users.users
|
||||
|
||||
var currUserStream = null
|
||||
if (this.userStream) {
|
||||
currUserStream = {
|
||||
audiobook: this.userStream
|
||||
}
|
||||
}
|
||||
var usermap = {
|
||||
[this.currentUserId]: {
|
||||
online: true,
|
||||
stream: currUserStream
|
||||
}
|
||||
}
|
||||
_users.forEach((u) => (usermap[u.id] = { online: true, stream: u.stream }))
|
||||
return usermap
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getLastRead(audiobooks) {
|
||||
var abs = Object.values(audiobooks)
|
||||
if (abs.length) {
|
||||
abs = abs.sort((a, b) => a.lastUpdate - b.lastUpdate)
|
||||
return abs[0] && abs[0].audiobookTitle ? abs[0].audiobookTitle : null
|
||||
}
|
||||
return null
|
||||
},
|
||||
deleteUserClick(user) {
|
||||
if (this.isDeletingUser) return
|
||||
if (confirm(`Are you sure you want to permanently delete user "${user.username}"?`)) {
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<template>
|
||||
<div class="relative w-full" v-click-outside="clickOutside">
|
||||
<p class="text-sm text-opacity-75 mb-1">{{ label }}</p>
|
||||
<button type="button" :disabled="disabled" class="relative h-10 w-full bg-fg border border-gray-500 rounded shadow-sm pl-3 pr-10 py-2 text-left focus:outline-none sm:text-sm cursor-pointer bg-primary" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
||||
<button type="button" :disabled="disabled" class="relative h-10 w-full border border-gray-500 rounded shadow-sm pl-3 pr-10 py-2 text-left focus:outline-none sm:text-sm cursor-pointer bg-primary" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
||||
<span class="flex items-center">
|
||||
<span class="block truncate">{{ selectedText }}</span>
|
||||
</span>
|
||||
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<span class="material-icons text-gray-100">chevron_down</span>
|
||||
<span class="material-icons text-gray-100">expand_more</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<transition name="menu">
|
||||
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox" aria-activedescendant="listbox-option-3">
|
||||
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox">
|
||||
<template v-for="item in items">
|
||||
<li :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
|
||||
<div class="flex items-center">
|
||||
|
||||
@@ -116,9 +116,6 @@ export default {
|
||||
this.textInput = null
|
||||
this.currentSearch = null
|
||||
this.input = item
|
||||
|
||||
// this.input = this.textInput ? this.textInput.trim() : null
|
||||
console.log('Clicked option', item)
|
||||
if (this.$refs.input) this.$refs.input.blur()
|
||||
}
|
||||
},
|
||||
|
||||
71
client/components/ui/LibrariesDropdown.vue
Normal file
71
client/components/ui/LibrariesDropdown.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div v-if="currentLibrary" class="relative w-36 h-8" v-click-outside="clickOutside">
|
||||
<button type="button" :disabled="disabled" class="relative h-full w-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm pl-3 pr-10 text-left focus:outline-none cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
||||
<span class="flex items-center">
|
||||
<widgets-library-icon :icon="currentLibraryIcon" class="mr-2" />
|
||||
<span class="block truncate text-sm">{{ currentLibrary.name }}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<transition name="menu">
|
||||
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox">
|
||||
<template v-for="library in libraries">
|
||||
<li :key="library.id" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="selectLibrary(library)">
|
||||
<div class="flex items-center px-3">
|
||||
<widgets-library-icon :icon="currentLibraryIcon" class="mr-2" />
|
||||
<span class="font-normal block truncate font-sans text-sm">{{ library.name }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
showMenu: false,
|
||||
disabled: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
currentLibrary() {
|
||||
return this.libraries.find((lib) => lib.id === this.currentLibraryId)
|
||||
},
|
||||
currentLibraryIcon() {
|
||||
return this.currentLibrary ? this.currentLibrary.icon || 'database' : 'database'
|
||||
},
|
||||
libraries() {
|
||||
return this.$store.getters['libraries/getSortedLibraries']()
|
||||
},
|
||||
libraryItems() {
|
||||
return this.libraries.map((lib) => ({ value: lib.id, text: lib.name }))
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickShowMenu() {
|
||||
if (this.disabled) return
|
||||
this.showMenu = !this.showMenu
|
||||
},
|
||||
clickOutside() {
|
||||
this.showMenu = false
|
||||
},
|
||||
selectLibrary(library) {
|
||||
this.updateLibrary(library)
|
||||
this.showMenu = false
|
||||
},
|
||||
async updateLibrary(library) {
|
||||
this.disabled = true
|
||||
await this.$store.dispatch('libraries/fetch', library.id)
|
||||
this.$router.push(`/library/${library.id}`)
|
||||
this.disabled = false
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -127,6 +127,7 @@ export default {
|
||||
return
|
||||
}
|
||||
this.isFocused = false
|
||||
if (this.textInput) this.submitForm()
|
||||
}, 50)
|
||||
},
|
||||
focus() {
|
||||
@@ -145,6 +146,7 @@ export default {
|
||||
var newSelected = null
|
||||
if (this.selected.includes(itemValue)) {
|
||||
newSelected = this.selected.filter((s) => s !== itemValue)
|
||||
this.$emit('removedItem', itemValue)
|
||||
} else {
|
||||
newSelected = this.selected.concat([itemValue])
|
||||
}
|
||||
@@ -164,6 +166,7 @@ export default {
|
||||
removeItem(item) {
|
||||
var remaining = this.selected.filter((i) => i !== item)
|
||||
this.$emit('input', remaining)
|
||||
this.$emit('removedItem', item)
|
||||
this.$nextTick(() => {
|
||||
this.recalcMenuPos()
|
||||
})
|
||||
@@ -171,6 +174,7 @@ export default {
|
||||
insertNewItem(item) {
|
||||
this.selected.push(item)
|
||||
this.$emit('input', this.selected)
|
||||
this.$emit('newItem', item)
|
||||
this.textInput = null
|
||||
this.currentSearch = null
|
||||
this.$nextTick(() => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<button class="icon-btn rounded-md bg-primary border border-gray-600 flex items-center justify-center h-9 w-9 relative" @click="clickBtn">
|
||||
<div class="w-5 h-5 text-white relative">
|
||||
<svg v-if="isRead" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<svg v-if="isRead" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="rgb(63, 181, 68)">
|
||||
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z" />
|
||||
</svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<input ref="input" v-model="inputValue" :type="type" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="rounded bg-primary text-gray-200 focus:border-gray-500 focus:outline-none border border-gray-600 h-full w-full" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
|
||||
<input ref="input" v-model="inputValue" :type="type" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="rounded bg-primary text-gray-200 focus:border-gray-300 focus:bg-bg focus:outline-none border border-gray-600 h-full w-full" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
|
||||
<div v-if="clearable && inputValue" class="absolute top-0 right-0 h-full px-2 flex items-center justify-center">
|
||||
<span class="material-icons text-gray-300 cursor-pointer" style="font-size: 1.1rem" @click.stop.prevent="clear">close</span>
|
||||
</div>
|
||||
|
||||
21
client/components/widgets/LibraryIcon.vue
Normal file
21
client/components/widgets/LibraryIcon.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<div class="h-4 w-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
icon: String
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -7,6 +7,7 @@
|
||||
<app-stream-container ref="streamContainer" />
|
||||
<modals-libraries-modal />
|
||||
<modals-edit-modal />
|
||||
<app-reader />
|
||||
<!-- <widgets-scan-alert /> -->
|
||||
</div>
|
||||
</template>
|
||||
@@ -162,6 +163,15 @@ export default {
|
||||
this.$store.commit('user/setSettings', user.settings)
|
||||
}
|
||||
},
|
||||
userOnline(user) {
|
||||
this.$store.commit('users/updateUser', user)
|
||||
},
|
||||
userOffline(user) {
|
||||
this.$store.commit('users/removeUser', user)
|
||||
},
|
||||
userStreamUpdate(user) {
|
||||
this.$store.commit('users/updateUser', user)
|
||||
},
|
||||
downloadToastClick(download) {
|
||||
if (!download || !download.audiobookId) {
|
||||
return console.error('Invalid download object', download)
|
||||
@@ -267,6 +277,9 @@ export default {
|
||||
|
||||
// User Listeners
|
||||
this.socket.on('user_updated', this.userUpdated)
|
||||
this.socket.on('user_online', this.userOnline)
|
||||
this.socket.on('user_offline', this.userOffline)
|
||||
this.socket.on('user_stream_update', this.userStreamUpdate)
|
||||
|
||||
// Scan Listeners
|
||||
this.socket.on('scan_start', this.scanStart)
|
||||
|
||||
86
client/package-lock.json
generated
86
client/package-lock.json
generated
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "1.3.4",
|
||||
"version": "1.4.8",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
@@ -3415,6 +3415,11 @@
|
||||
"@babel/helper-define-polyfill-provider": "^0.2.2"
|
||||
}
|
||||
},
|
||||
"babel-plugin-syntax-dynamic-import": {
|
||||
"version": "6.18.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-dynamic-import/-/babel-plugin-syntax-dynamic-import-6.18.0.tgz",
|
||||
"integrity": "sha1-jWomIpyDdFqZgqRBBRVyyqF5sdo="
|
||||
},
|
||||
"backo2": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz",
|
||||
@@ -4995,6 +5000,11 @@
|
||||
"type": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"date-fns": {
|
||||
"version": "2.25.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.25.0.tgz",
|
||||
"integrity": "sha512-ovYRFnTrbGPD4nqaEqescPEv1mNwvt+UTqI3Ay9SzNtey9NZnYu6E2qCcBBgJ6/2VF1zGGygpyTDITqpQQ5e+w=="
|
||||
},
|
||||
"de-indent": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
|
||||
@@ -8489,6 +8499,11 @@
|
||||
"sha.js": "^2.4.8"
|
||||
}
|
||||
},
|
||||
"pdfjs-dist": {
|
||||
"version": "2.6.347",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-2.6.347.tgz",
|
||||
"integrity": "sha512-QC+h7hG2su9v/nU1wEI3SnpPIrqJODL7GTDFvR74ANKGq1AFJW16PH8VWnhpiTi9YcLSFV9xLeWSgq+ckHLdVQ=="
|
||||
},
|
||||
"picomatch": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz",
|
||||
@@ -11234,6 +11249,37 @@
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="
|
||||
},
|
||||
"raw-loader": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/raw-loader/-/raw-loader-4.0.2.tgz",
|
||||
"integrity": "sha512-ZnScIV3ag9A4wPX/ZayxL/jZH+euYb6FcUinPcgiQW0+UBtEv0O6Q3lGd3cqJ+GHH+rksEv3Pj99oxJ3u3VIKA==",
|
||||
"requires": {
|
||||
"loader-utils": "^2.0.0",
|
||||
"schema-utils": "^3.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"loader-utils": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.0.tgz",
|
||||
"integrity": "sha512-rP4F0h2RaWSvPEkD7BLDFQnvSf+nK+wr3ESUjNTyAGobqrijmW92zc+SO6d4p4B1wh7+B/Jg1mkQe5NYUEHtHQ==",
|
||||
"requires": {
|
||||
"big.js": "^5.2.2",
|
||||
"emojis-list": "^3.0.0",
|
||||
"json5": "^2.1.2"
|
||||
}
|
||||
},
|
||||
"schema-utils": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
|
||||
"integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
|
||||
"requires": {
|
||||
"@types/json-schema": "^7.0.8",
|
||||
"ajv": "^6.12.5",
|
||||
"ajv-keywords": "^3.5.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"rc9": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/rc9/-/rc9-1.2.0.tgz",
|
||||
@@ -13309,6 +13355,24 @@
|
||||
"resolved": "https://registry.npmjs.org/vue-no-ssr/-/vue-no-ssr-1.1.1.tgz",
|
||||
"integrity": "sha512-ZMjqRpWabMPqPc7gIrG0Nw6vRf1+itwf0Itft7LbMXs2g3Zs/NFmevjZGN1x7K3Q95GmIjWbQZTVerxiBxI+0g=="
|
||||
},
|
||||
"vue-pdf": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/vue-pdf/-/vue-pdf-4.3.0.tgz",
|
||||
"integrity": "sha512-zd3lJj6CbtrawgaaDDciTDjkJMUKiLWtbEmBg5CvFn9Noe9oAO/GNy/fc5c59qGuFCJ14ibIV1baw4S07e5bSQ==",
|
||||
"requires": {
|
||||
"babel-plugin-syntax-dynamic-import": "^6.18.0",
|
||||
"loader-utils": "^1.4.0",
|
||||
"pdfjs-dist": "2.6.347",
|
||||
"raw-loader": "^4.0.2",
|
||||
"vue-resize-sensor": "^2.0.0",
|
||||
"worker-loader": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"vue-resize-sensor": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/vue-resize-sensor/-/vue-resize-sensor-2.0.0.tgz",
|
||||
"integrity": "sha512-W+y2EAI/BxS4Vlcca9scQv8ifeBFck56DRtSwWJ2H4Cw1GLNUYxiZxUHHkuzuI5JPW/cYtL1bPO5xPyEXx4LmQ=="
|
||||
},
|
||||
"vue-router": {
|
||||
"version": "3.5.2",
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-3.5.2.tgz",
|
||||
@@ -14176,6 +14240,26 @@
|
||||
"errno": "~0.1.7"
|
||||
}
|
||||
},
|
||||
"worker-loader": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/worker-loader/-/worker-loader-2.0.0.tgz",
|
||||
"integrity": "sha512-tnvNp4K3KQOpfRnD20m8xltE3eWh89Ye+5oj7wXEEHKac1P4oZ6p9oTj8/8ExqoSBnk9nu5Pr4nKfQ1hn2APJw==",
|
||||
"requires": {
|
||||
"loader-utils": "^1.0.0",
|
||||
"schema-utils": "^0.4.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"schema-utils": {
|
||||
"version": "0.4.7",
|
||||
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-0.4.7.tgz",
|
||||
"integrity": "sha512-v/iwU6wvwGK8HbU9yi3/nhGzP0yGSuhQMzL6ySiec1FSrZZDkhm4noOSWzrNFo/jEc+SJY6jRTwuwbSXJPDUnQ==",
|
||||
"requires": {
|
||||
"ajv": "^6.1.0",
|
||||
"ajv-keywords": "^3.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.9",
|
||||
"description": "Audiobook manager and player",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "nuxt",
|
||||
"dev2": "nuxt --hostname localhost --port 1337",
|
||||
"build": "nuxt build",
|
||||
"start": "nuxt start",
|
||||
"generate": "nuxt generate"
|
||||
@@ -15,10 +16,12 @@
|
||||
"@nuxtjs/axios": "^5.13.6",
|
||||
"@nuxtjs/proxy": "^2.1.0",
|
||||
"core-js": "^3.16.0",
|
||||
"date-fns": "^2.25.0",
|
||||
"epubjs": "^0.3.88",
|
||||
"hls.js": "^1.0.7",
|
||||
"nuxt": "^2.15.7",
|
||||
"nuxt-socket-io": "^1.1.18",
|
||||
"vue-pdf": "^4.3.0",
|
||||
"vue-toastification": "^1.7.11",
|
||||
"vuedraggable": "^2.24.3"
|
||||
},
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<div class="flex items-center py-2">
|
||||
<p v-if="isRoot" class="text-error py-2 text-xs">* Root user is the only user that can have an empty password</p>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn v-show="password && newPassword && confirmPassword" type="submit" :loading="changingPassword" color="success">Submit</ui-btn>
|
||||
<ui-btn v-show="(password && newPassword && confirmPassword) || isRoot" type="submit" :loading="changingPassword" color="success">Submit</ui-btn>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -224,41 +224,3 @@ export default {
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.flip-list-move {
|
||||
transition: transform 0.5s;
|
||||
}
|
||||
.no-move {
|
||||
transition: transform 0s;
|
||||
}
|
||||
.ghost {
|
||||
opacity: 0.5;
|
||||
background-color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
.list-group {
|
||||
min-height: 30px;
|
||||
}
|
||||
.list-group-item:not(.exclude) {
|
||||
cursor: n-resize;
|
||||
}
|
||||
.list-group-item.exclude {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.list-group-item:not(.ghost):not(.exclude):hover {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.list-group-item:nth-child(even):not(.ghost):not(.exclude) {
|
||||
background-color: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.list-group-item:nth-child(even):not(.ghost):not(.exclude):hover {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.list-group-item.exclude:not(.ghost) {
|
||||
background-color: rgba(255, 0, 0, 0.25);
|
||||
}
|
||||
.list-group-item.exclude:not(.ghost):hover {
|
||||
background-color: rgba(223, 0, 0, 0.25);
|
||||
}
|
||||
</style>
|
||||
@@ -22,8 +22,7 @@
|
||||
by <nuxt-link v-if="author" :to="`/library/${libraryId}/bookshelf?filter=authors.${$encode(author)}`" class="hover:underline">{{ author }}</nuxt-link
|
||||
><span v-else>Unknown</span>
|
||||
</p>
|
||||
|
||||
<h3 v-if="series" class="font-sans text-gray-300 text-lg leading-7 mb-4">{{ seriesText }}</h3>
|
||||
<nuxt-link v-if="series" :to="`/library/${libraryId}/bookshelf/series?series=${$encode(series)}`" class="hover:underline font-sans text-gray-300 text-lg leading-7 mb-4"> {{ seriesText }}</nuxt-link>
|
||||
|
||||
<!-- <div class="w-min">
|
||||
<ui-tooltip :text="authorTooltipText" direction="bottom">
|
||||
@@ -57,7 +56,7 @@
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex py-0.5">
|
||||
<div v-if="tracks.length" class="flex py-0.5">
|
||||
<div class="w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">Duration</span>
|
||||
</div>
|
||||
@@ -65,7 +64,7 @@
|
||||
{{ durationPretty }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex py-0.5">
|
||||
<div v-if="tracks.length" class="flex py-0.5">
|
||||
<div class="w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">Size</span>
|
||||
</div>
|
||||
@@ -73,16 +72,20 @@
|
||||
{{ sizePretty }}
|
||||
</div>
|
||||
</div>
|
||||
<!--
|
||||
<p v-if="narrator" class="text-base">
|
||||
<span class="text-white text-opacity-60">By:</span> <nuxt-link :to="`/library/${libraryId}/bookshelf?filter=authors.${$encode(author)}`" class="hover:underline">{{ author }}</nuxt-link>
|
||||
</p> -->
|
||||
<!-- <p v-if="narrator" class="text-base"><span class="text-white text-opacity-60">Narrated by:</span> {{ narrator }}</p>
|
||||
<p v-if="publishYear" class="text-base"><span class="text-white text-opacity-60">Publish year:</span> {{ publishYear }}</p>
|
||||
<p v-if="genres.length" class="text-base"><span class="text-white text-opacity-60">Genres:</span> {{ genres.join(', ') }}</p> -->
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
</div>
|
||||
|
||||
<!-- Alerts -->
|
||||
<div v-show="showExperimentalReadAlert" class="bg-error p-4 rounded-xl flex items-center">
|
||||
<span class="material-icons text-2xl">warning_amber</span>
|
||||
<p class="ml-4">Book has no audio tracks but has valid ebook files. The e-reader is experimental and can be turned on in config.</p>
|
||||
</div>
|
||||
<div v-show="showEpubAlert" class="bg-error p-4 rounded-xl flex items-center mt-2">
|
||||
<span class="material-icons text-2xl">warning_amber</span>
|
||||
<p class="ml-4">Book has valid ebook files, but the experimental e-reader currently only supports epub files.</p>
|
||||
</div>
|
||||
|
||||
<div v-if="progressPercent > 0 && progressPercent < 1" class="px-4 py-2 mt-4 bg-primary text-sm font-semibold rounded-md text-gray-200 relative max-w-max" :class="resettingProgress ? 'opacity-25' : ''">
|
||||
<p class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p>
|
||||
<p class="text-gray-400 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p>
|
||||
@@ -92,16 +95,16 @@
|
||||
</div>
|
||||
|
||||
<div class="flex items-center pt-4">
|
||||
<ui-btn v-if="!isMissing" :disabled="streaming" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="startStream">
|
||||
<ui-btn v-if="showPlayButton" :disabled="streaming" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="startStream">
|
||||
<span v-show="!streaming" class="material-icons -ml-2 pr-1 text-white">play_arrow</span>
|
||||
{{ streaming ? 'Streaming' : 'Play' }}
|
||||
</ui-btn>
|
||||
<ui-btn v-else color="error" :padding-x="4" small class="flex items-center h-9 mr-2">
|
||||
<ui-btn v-else-if="isMissing || isIncomplete" color="error" :padding-x="4" small class="flex items-center h-9 mr-2">
|
||||
<span v-show="!streaming" class="material-icons -ml-2 pr-1 text-white">error</span>
|
||||
Missing
|
||||
{{ isMissing ? 'Missing' : 'Incomplete' }}
|
||||
</ui-btn>
|
||||
|
||||
<ui-btn v-if="showExperimentalFeatures && epubEbook" color="info" :padding-x="4" small class="flex items-center h-9 mr-2" @click="openEbook">
|
||||
<ui-btn v-if="showExperimentalFeatures && (epubEbook || mobiEbook)" color="info" :padding-x="4" small class="flex items-center h-9 mr-2" @click="openEbook">
|
||||
<span class="material-icons -ml-2 pr-2 text-white">auto_stories</span>
|
||||
Read
|
||||
</ui-btn>
|
||||
@@ -141,7 +144,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<tables-tracks-table :tracks="tracks" :audiobook="audiobook" class="mt-6" />
|
||||
<tables-tracks-table v-if="tracks.length" :tracks="tracks" :audiobook="audiobook" class="mt-6" />
|
||||
|
||||
<tables-audio-files-table v-if="otherAudioFiles.length" :audiobook-id="audiobook.id" :files="otherAudioFiles" class="mt-6" />
|
||||
|
||||
@@ -150,7 +153,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<app-reader v-if="showExperimentalFeatures" v-model="showReader" :url="epubUrl" />
|
||||
<!-- <app-reader v-if="showExperimentalFeatures" v-model="showReader" :url="epubUrl" /> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -175,7 +178,6 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showReader: false,
|
||||
isRead: false,
|
||||
resettingProgress: false,
|
||||
isProcessingReadUpdate: false
|
||||
@@ -230,6 +232,12 @@ export default {
|
||||
isMissing() {
|
||||
return this.audiobook.isMissing
|
||||
},
|
||||
isIncomplete() {
|
||||
return this.audiobook.isIncomplete
|
||||
},
|
||||
showPlayButton() {
|
||||
return !this.isMissing && !this.isIncomplete && this.tracks.length
|
||||
},
|
||||
missingParts() {
|
||||
return this.audiobook.missingParts || []
|
||||
},
|
||||
@@ -313,15 +321,17 @@ export default {
|
||||
ebooks() {
|
||||
return this.audiobook.ebooks
|
||||
},
|
||||
showEpubAlert() {
|
||||
return this.ebooks.length && !this.epubEbook && !this.tracks.length
|
||||
},
|
||||
showExperimentalReadAlert() {
|
||||
return !this.tracks.length && this.ebooks.length && !this.showExperimentalFeatures
|
||||
},
|
||||
epubEbook() {
|
||||
return this.audiobook.ebooks.find((eb) => eb.ext === '.epub')
|
||||
return this.ebooks.find((eb) => eb.ext === '.epub')
|
||||
},
|
||||
epubPath() {
|
||||
return this.epubEbook ? this.epubEbook.path : null
|
||||
},
|
||||
epubUrl() {
|
||||
if (!this.epubPath) return null
|
||||
return `/ebook/${this.libraryId}/${this.folderId}/${this.epubPath}`
|
||||
mobiEbook() {
|
||||
return this.ebooks.find((eb) => eb.ext === '.mobi' || eb.ext === '.azw3')
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
@@ -365,7 +375,7 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
openEbook() {
|
||||
this.showReader = true
|
||||
this.$store.commit('showEReader', this.audiobook)
|
||||
},
|
||||
toggleRead() {
|
||||
var updatePayload = {
|
||||
@@ -401,6 +411,7 @@ export default {
|
||||
this.$root.socket.emit('open_stream', this.audiobook.id)
|
||||
},
|
||||
editClick() {
|
||||
this.$store.commit('setBookshelfBookIds', [])
|
||||
this.$store.commit('showEditModal', this.audiobook)
|
||||
},
|
||||
lookupMetadata(index) {
|
||||
@@ -447,6 +458,11 @@ export default {
|
||||
},
|
||||
mounted() {
|
||||
this.$store.commit('audiobooks/addListener', { id: 'audiobook', audiobookId: this.audiobookId, meth: this.audiobookUpdated })
|
||||
|
||||
// If a library has not yet been loaded, use this audiobooks library id as the current
|
||||
if (!this.$store.state.audiobooks.loadedLibraryId && this.libraryId) {
|
||||
this.$store.commit('libraries/setCurrentLibrary', this.libraryId)
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$store.commit('audiobooks/removeListener', 'audiobook')
|
||||
|
||||
@@ -33,10 +33,10 @@
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<ui-multi-select v-model="audiobook.book.genres" label="Genres" :items="genres" />
|
||||
<ui-multi-select v-model="audiobook.book.genres" label="Genres" :items="genreItems" @newItem="newGenreItem" @removedItem="removedGenreItem" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-multi-select v-model="audiobook.tags" label="Tags" :items="tags" />
|
||||
<ui-multi-select v-model="audiobook.tags" label="Tags" :items="tagItems" @newItem="newTagItem" @removedItem="removedTagItem" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -76,7 +76,9 @@ export default {
|
||||
isProcessing: false,
|
||||
audiobookCopies: [],
|
||||
isScrollable: false,
|
||||
newSeriesItems: []
|
||||
newSeriesItems: [],
|
||||
newTagItems: [],
|
||||
newGenreItems: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -86,9 +88,15 @@ export default {
|
||||
genres() {
|
||||
return this.$store.state.audiobooks.genres
|
||||
},
|
||||
genreItems() {
|
||||
return this.genres.concat(this.newGenreItems)
|
||||
},
|
||||
tags() {
|
||||
return this.$store.state.audiobooks.tags
|
||||
},
|
||||
tagItems() {
|
||||
return this.tags.concat(this.newTagItems)
|
||||
},
|
||||
series() {
|
||||
return this.$store.state.audiobooks.series
|
||||
},
|
||||
@@ -100,9 +108,42 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
newTagItem(item) {
|
||||
if (item && !this.newTagItems.includes(item)) {
|
||||
this.newTagItems.push(item)
|
||||
}
|
||||
},
|
||||
removedTagItem(item) {
|
||||
// If newly added, remove if not used on any other audiobooks
|
||||
if (item && this.newTagItems.includes(item)) {
|
||||
var usedByOtherAb = this.audiobookCopies.find((ab) => {
|
||||
return ab.tags && ab.tags.includes(item)
|
||||
})
|
||||
if (!usedByOtherAb) {
|
||||
this.newTagItems = this.newTagItems.filter((t) => t !== item)
|
||||
}
|
||||
}
|
||||
},
|
||||
newGenreItem(item) {
|
||||
if (item && !this.newGenreItems.includes(item)) {
|
||||
this.newGenreItems.push(item)
|
||||
}
|
||||
},
|
||||
removedGenreItem(item) {
|
||||
// If newly added, remove if not used on any other audiobooks
|
||||
if (item && this.newGenreItems.includes(item)) {
|
||||
var usedByOtherAb = this.audiobookCopies.find((ab) => {
|
||||
return ab.book.genres && ab.book.genres.includes(item)
|
||||
})
|
||||
if (!usedByOtherAb) {
|
||||
this.newGenreItems = this.newGenreItems.filter((t) => t !== item)
|
||||
}
|
||||
}
|
||||
},
|
||||
newSeriesItem(item) {
|
||||
if (!item) return
|
||||
this.newSeriesItems.push(item)
|
||||
if (item && !this.newSeriesItems.includes(item)) {
|
||||
this.newSeriesItems.push(item)
|
||||
}
|
||||
},
|
||||
seriesChanged() {
|
||||
this.newSeriesItems = this.newSeriesItems.filter((item) => {
|
||||
|
||||
@@ -21,9 +21,7 @@
|
||||
<script>
|
||||
export default {
|
||||
asyncData({ redirect, store }) {
|
||||
var currentLibraryId = store.state.libraries.currentLibraryId
|
||||
console.log('Redir', currentLibraryId)
|
||||
redirect(`/library/${currentLibraryId}`)
|
||||
redirect(`/library/${store.state.libraries.currentLibraryId}`)
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
|
||||
@@ -31,6 +31,7 @@ export default {
|
||||
return []
|
||||
})
|
||||
store.commit('audiobooks/setSearchResults', searchResults)
|
||||
if (searchResults.length) searchResults.forEach((ab) => store.commit('audiobooks/addUpdate', ab))
|
||||
}
|
||||
var selectedSeries = query.series ? app.$decode(query.series) : null
|
||||
store.commit('audiobooks/setSelectedSeries', selectedSeries)
|
||||
|
||||
@@ -129,9 +129,9 @@ export default {
|
||||
title: null,
|
||||
author: null,
|
||||
series: null,
|
||||
acceptedAudioFormats: ['.mp3', '.m4b', '.m4a', '.flac', '.opus'],
|
||||
acceptedAudioFormats: ['.mp3', '.m4b', '.m4a', '.flac', '.opus', '.mp4'],
|
||||
acceptedImageFormats: ['.png', '.jpg', '.jpeg', '.webp'],
|
||||
inputAccept: '.png, .jpg, .jpeg, .webp, .mp3, .m4b, .m4a, .flac, .opus',
|
||||
inputAccept: '.png, .jpg, .jpeg, .webp, .mp3, .m4b, .m4a, .flac, .opus, .mp4',
|
||||
isDragOver: false,
|
||||
showUploader: true,
|
||||
validAudioFiles: [],
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import Vue from 'vue'
|
||||
import { formatDistance, format } from 'date-fns'
|
||||
|
||||
Vue.prototype.$isDev = process.env.NODE_ENV !== 'production'
|
||||
|
||||
Vue.prototype.$dateDistanceFromNow = (unixms) => {
|
||||
if (!unixms) return ''
|
||||
return formatDistance(unixms, Date.now(), { addSuffix: true })
|
||||
}
|
||||
Vue.prototype.$formatDate = (unixms, fnsFormat = 'MM/dd/yyyy HH:mm') => {
|
||||
if (!unixms) return ''
|
||||
return format(unixms, fnsFormat)
|
||||
}
|
||||
|
||||
Vue.prototype.$bytesPretty = (bytes, decimals = 2) => {
|
||||
if (bytes === 0) {
|
||||
return '0 Bytes'
|
||||
|
||||
@@ -7,7 +7,9 @@ export const state = () => ({
|
||||
streamAudiobook: null,
|
||||
editModalTab: 'details',
|
||||
showEditModal: false,
|
||||
showEReader: false,
|
||||
selectedAudiobook: null,
|
||||
selectedAudiobookFile: null,
|
||||
playOnLoad: false,
|
||||
developerMode: false,
|
||||
selectedAudiobooks: [],
|
||||
@@ -15,7 +17,8 @@ export const state = () => ({
|
||||
previousPath: '/',
|
||||
routeHistory: [],
|
||||
showExperimentalFeatures: false,
|
||||
backups: []
|
||||
backups: [],
|
||||
bookshelfBookIds: []
|
||||
})
|
||||
|
||||
export const getters = {
|
||||
@@ -65,6 +68,9 @@ export const actions = {
|
||||
}
|
||||
|
||||
export const mutations = {
|
||||
setBookshelfBookIds(state, val) {
|
||||
state.bookshelfBookIds = val || []
|
||||
},
|
||||
setRouteHistory(state, val) {
|
||||
state.routeHistory = val
|
||||
},
|
||||
@@ -111,6 +117,21 @@ export const mutations = {
|
||||
setShowEditModal(state, val) {
|
||||
state.showEditModal = val
|
||||
},
|
||||
showEReader(state, audiobook) {
|
||||
state.selectedAudiobookFile = null
|
||||
state.selectedAudiobook = audiobook
|
||||
|
||||
state.showEReader = true
|
||||
},
|
||||
showEReaderForFile(state, { audiobook, file }) {
|
||||
state.selectedAudiobookFile = file
|
||||
state.selectedAudiobook = audiobook
|
||||
|
||||
state.showEReader = true
|
||||
},
|
||||
setShowEReader(state, val) {
|
||||
state.showEReader = val
|
||||
},
|
||||
setDeveloperMode(state, val) {
|
||||
state.developerMode = val
|
||||
},
|
||||
|
||||
@@ -11,6 +11,9 @@ export const state = () => ({
|
||||
export const getters = {
|
||||
getCurrentLibrary: state => {
|
||||
return state.libraries.find(lib => lib.id === state.currentLibraryId)
|
||||
},
|
||||
getSortedLibraries: state => () => {
|
||||
return state.libraries.map(lib => ({ ...lib })).sort((a, b) => a.displayOrder - b.displayOrder)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
26
client/store/users.js
Normal file
26
client/store/users.js
Normal file
@@ -0,0 +1,26 @@
|
||||
|
||||
export const state = () => ({
|
||||
users: []
|
||||
})
|
||||
|
||||
export const getters = {
|
||||
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
|
||||
}
|
||||
|
||||
export const mutations = {
|
||||
updateUser(state, user) {
|
||||
var index = state.users.findIndex(u => u.id === user.id)
|
||||
if (index >= 0) {
|
||||
state.users.splice(index, 1, user)
|
||||
} else {
|
||||
state.users.push(user)
|
||||
}
|
||||
},
|
||||
removeUser(state, user) {
|
||||
state.users = state.users.filter(u => u.id !== user.id)
|
||||
}
|
||||
}
|
||||
BIN
images/LibraryStream.png
Normal file
BIN
images/LibraryStream.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 936 KiB |
1
images/banner.svg
Normal file
1
images/banner.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 6702.73 1277.37"><defs><style>.cls-1{fill:#fff;}.cls-2{fill:url(#linear-gradient);}.cls-3{font-size:800px;fill:#c9c9c9;font-family:GentiumBookBasic, Gentium Book Basic;}.cls-4{font-size:420px;fill:#474747;font-family:GentiumBasic, Gentium Basic;}</style><linearGradient id="linear-gradient" x1="617.37" y1="20.7" x2="617.37" y2="1216.56" gradientUnits="userSpaceOnUse"><stop offset="0.32" stop-color="#cd9d49"/><stop offset="0.99" stop-color="#875d27"/></linearGradient></defs><title>bgAsset 6</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_2-2" data-name="Layer 2"><g id="Layer_4" data-name="Layer 4"><g id="Layer_5" data-name="Layer 5"><circle class="cls-1" cx="618.63" cy="618.63" r="618.63"/></g><circle class="cls-2" cx="617.37" cy="618.63" r="597.93"/></g><path class="cls-1" d="M1005.57,574.08c-4.84-4-12.37-10-22.58-17v-79.2c0-201.93-163.69-365.63-365.62-365.63h0c-201.93,0-365.63,163.7-365.63,365.63v79.2c-10.21,7-17.74,13-22.58,17A18.15,18.15,0,0,0,222.63,588v94.89a18.15,18.15,0,0,0,6.53,14c11.29,9.4,37.19,29.1,77.52,49.31v9.22c0,24.88,16,45,35.84,45h0c19.79,0,35.84-20.16,35.84-45V527.83c0-24.87-16.05-45-35.84-45h0c-19,0-34.48,18.51-35.75,41.94l-.09,0v-46.9c0-171.59,139.1-310.69,310.69-310.69h0c171.58,0,310.68,139.1,310.68,310.69v46.9l-.08,0c-1.27-23.43-16.79-41.94-35.76-41.94h0c-19.79,0-35.83,20.17-35.83,45V755.4c0,24.88,16,45,35.83,45h0c19.8,0,35.84-20.16,35.84-45v-9.22c40.33-20.21,66.24-39.91,77.52-49.31a18.15,18.15,0,0,0,6.53-14V588A18.15,18.15,0,0,0,1005.57,574.08Z"/><path class="cls-1" d="M489.87,969.71a43.31,43.31,0,0,0,43.3-43.3V441.64a43.3,43.3,0,0,0-43.3-43.29H445.15a43.3,43.3,0,0,0-43.3,43.29V926.41a43.31,43.31,0,0,0,43.3,43.3Zm-71.69-455.1h98.67v10.31H418.18Z"/><path class="cls-1" d="M639.73,969.71A43.3,43.3,0,0,0,683,926.41V441.64a43.29,43.29,0,0,0-43.29-43.29H595a43.29,43.29,0,0,0-43.29,43.29V926.41A43.3,43.3,0,0,0,595,969.71ZM568,514.61H666.7v10.31H568Z"/><path class="cls-1" d="M789.59,969.71a43.3,43.3,0,0,0,43.29-43.3V441.64a43.29,43.29,0,0,0-43.29-43.29H744.86a43.3,43.3,0,0,0-43.3,43.29V926.41a43.31,43.31,0,0,0,43.3,43.3Zm-71.7-455.1h98.67v10.31H717.89Z"/><rect class="cls-1" x="294.5" y="984.69" width="645.74" height="65.25" rx="32.63"/></g><g id="Layer_6" data-name="Layer 6"><text class="cls-3" transform="translate(1492.27 670.42)">audiobookshelf</text><text class="cls-4" transform="translate(1492.27 1128.69)">self-hosted audiobook server</text></g></g></svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 168 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB |
11
package-lock.json
generated
11
package-lock.json
generated
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "1.4.1",
|
||||
"version": "1.4.3",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
@@ -411,15 +411,6 @@
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz",
|
||||
"integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg=="
|
||||
},
|
||||
"cookie-parser": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.5.tgz",
|
||||
"integrity": "sha512-f13bPUj/gG/5mDr+xLmSxxDsB9DQiTIfhJS/sqjrmfAWiAN+x2O4i/XguTL9yDZ+/IFDanJ+5x7hC4CXT9Tdzw==",
|
||||
"requires": {
|
||||
"cookie": "0.4.0",
|
||||
"cookie-signature": "1.0.6"
|
||||
}
|
||||
},
|
||||
"cookie-signature": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.9",
|
||||
"description": "Self-hosted audiobook server for managing and playing audiobooks",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
@@ -8,7 +8,7 @@
|
||||
"start": "node index.js",
|
||||
"client": "cd client && npm install && npm run generate",
|
||||
"prod": "npm run client && npm install && node prod.js",
|
||||
"build-win": "npm run build-prep && pkg -t node12-win-x64 -o ./dist/win/audiobookshelf .",
|
||||
"build-win": "pkg -t node12-win-x64 -o ./dist/win/audiobookshelf .",
|
||||
"build-linux": "build/linuxpackager"
|
||||
},
|
||||
"bin": "prod.js",
|
||||
@@ -26,7 +26,6 @@
|
||||
"axios": "^0.21.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"command-line-args": "^5.2.0",
|
||||
"cookie-parser": "^1.4.5",
|
||||
"date-and-time": "^2.0.1",
|
||||
"epub": "^1.2.1",
|
||||
"express": "^4.17.1",
|
||||
|
||||
35
readme.md
35
readme.md
@@ -1,15 +1,36 @@
|
||||
# AudioBookshelf
|
||||
<br />
|
||||
<div align="center">
|
||||
<img alt="Audiobookshelf Banner" src="https://github.com/advplyr/audiobookshelf/raw/master/images/banner.svg" width="600">
|
||||
|
||||
AudioBookshelf is a self-hosted audiobook server for managing and playing your audiobooks.
|
||||
<p align="center">
|
||||
<br />
|
||||
<a href="https://audiobookshelf.org/docs">Documentation</a>
|
||||
·
|
||||
<a href="https://audiobookshelf.org/install">Install Guides</a>
|
||||
·
|
||||
<a href="https://audiobookshelf.org/showcase">Showcase</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
See [Install guides](https://audiobookshelf.org/install) and [documentation](https://audiobookshelf.org/docs)
|
||||
## About
|
||||
|
||||
Audiobookshelf is a self-hosted audiobook server for managing and playing your audiobooks.
|
||||
|
||||
### Features
|
||||
|
||||
* Fully **open-source**, including the [android app](https://github.com/advplyr/audiobookshelf-app) *(in beta)*
|
||||
* Stream all audiobook formats on the fly
|
||||
* Multi-user support w/ custom permissions
|
||||
* Keeps progress per user and syncs across devices
|
||||
* Auto-detects library updates, no need to re-scan
|
||||
* Upload full audiobooks and covers
|
||||
* Backup your metadata + automated daily backups
|
||||
|
||||
Is there a feature you are looking for? [Suggest it](https://github.com/advplyr/audiobookshelf/issues/new)
|
||||
|
||||
Android app is in beta, try it out on the [Google Play Store](https://play.google.com/store/apps/details?id=com.audiobookshelf.app)
|
||||
|
||||
**Free & open source Android/iOS app is in development**
|
||||
|
||||
<img alt="Screenshot1" src="https://github.com/advplyr/audiobookshelf/raw/master/images/ss_streaming.png" />
|
||||
|
||||
<img alt="Library Screenshot" src="https://github.com/advplyr/audiobookshelf/raw/master/images/LibraryStream.png" />
|
||||
|
||||
## Organizing your audiobooks
|
||||
|
||||
|
||||
@@ -30,6 +30,8 @@ class ApiController {
|
||||
this.router.get('/find/:method', this.find.bind(this))
|
||||
|
||||
this.router.get('/libraries', this.getLibraries.bind(this))
|
||||
this.router.patch('/libraries/order', this.reorderLibraries.bind(this))
|
||||
this.router.get('/library/:id/search', this.searchLibrary.bind(this))
|
||||
this.router.get('/library/:id', this.getLibrary.bind(this))
|
||||
this.router.delete('/library/:id', this.deleteLibrary.bind(this))
|
||||
this.router.patch('/library/:id', this.updateLibrary.bind(this))
|
||||
@@ -45,6 +47,7 @@ class ApiController {
|
||||
this.router.delete('/audiobook/:id', this.deleteAudiobook.bind(this))
|
||||
this.router.patch('/audiobook/:id/tracks', this.updateAudiobookTracks.bind(this))
|
||||
this.router.post('/audiobook/:id/cover', this.uploadAudiobookCover.bind(this))
|
||||
this.router.patch('/audiobook/:id/coverfile', this.updateAudiobookCoverFromFile.bind(this))
|
||||
this.router.patch('/audiobook/:id', this.updateAudiobook.bind(this))
|
||||
|
||||
this.router.patch('/match/:id', this.match.bind(this))
|
||||
@@ -97,6 +100,83 @@ class ApiController {
|
||||
res.json(libraries)
|
||||
}
|
||||
|
||||
async reorderLibraries(req, res) {
|
||||
if (!req.user || !req.user.isRoot) {
|
||||
Logger.error('[ApiController] ReorderLibraries invalid user', req.user)
|
||||
return res.sendStatus(401)
|
||||
}
|
||||
|
||||
var orderdata = req.body
|
||||
var hasUpdates = false
|
||||
for (let i = 0; i < orderdata.length; i++) {
|
||||
var library = this.db.libraries.find(lib => lib.id === orderdata[i].id)
|
||||
if (!library) {
|
||||
Logger.error(`[ApiController] Invalid library not found in reorder ${orderdata[i].id}`)
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
if (library.update({ displayOrder: orderdata[i].newOrder })) {
|
||||
hasUpdates = true
|
||||
await this.db.updateEntity('library', library)
|
||||
}
|
||||
}
|
||||
|
||||
if (hasUpdates) {
|
||||
Logger.info(`[ApiController] Updated library display orders`)
|
||||
} else {
|
||||
Logger.info(`[ApiController] Library orders were up to date`)
|
||||
}
|
||||
|
||||
var libraries = this.db.libraries.map(lib => lib.toJSON())
|
||||
res.json(libraries)
|
||||
}
|
||||
|
||||
searchLibrary(req, res) {
|
||||
var library = this.db.libraries.find(lib => lib.id === req.params.id)
|
||||
if (!library) {
|
||||
return res.status(404).send('Library not found')
|
||||
}
|
||||
if (!req.query.q) {
|
||||
return res.status(400).send('No query string')
|
||||
}
|
||||
var maxResults = req.query.max || 3
|
||||
|
||||
var bookMatches = []
|
||||
var authorMatches = {}
|
||||
var seriesMatches = {}
|
||||
|
||||
var audiobooksInLibrary = this.db.audiobooks.filter(ab => ab.libraryId === library.id)
|
||||
audiobooksInLibrary.forEach((ab) => {
|
||||
var queryResult = ab.searchQuery(req.query.q)
|
||||
if (queryResult.book) {
|
||||
bookMatches.push({
|
||||
audiobook: ab,
|
||||
matchKey: queryResult.book
|
||||
})
|
||||
}
|
||||
if (queryResult.author && !authorMatches[queryResult.author]) {
|
||||
authorMatches[queryResult.author] = {
|
||||
author: queryResult.author
|
||||
}
|
||||
}
|
||||
if (queryResult.series) {
|
||||
if (!seriesMatches[queryResult.series]) {
|
||||
seriesMatches[queryResult.series] = {
|
||||
series: queryResult.series,
|
||||
audiobooks: [ab]
|
||||
}
|
||||
} else {
|
||||
seriesMatches[queryResult.series].audiobooks.push(ab)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
res.json({
|
||||
audiobooks: bookMatches.slice(0, maxResults),
|
||||
authors: Object.values(authorMatches).slice(0, maxResults),
|
||||
series: Object.values(seriesMatches).slice(0, maxResults)
|
||||
})
|
||||
}
|
||||
|
||||
getLibrary(req, res) {
|
||||
var library = this.db.libraries.find(lib => lib.id === req.params.id)
|
||||
if (!library) {
|
||||
@@ -178,6 +258,7 @@ class ApiController {
|
||||
}
|
||||
|
||||
var library = new Library()
|
||||
newLibraryPayload.displayOrder = this.db.libraries.length + 1
|
||||
library.setData(newLibraryPayload)
|
||||
await this.db.insertEntity('library', library)
|
||||
this.emitter('library_added', library.toJSON())
|
||||
@@ -365,6 +446,26 @@ class ApiController {
|
||||
})
|
||||
}
|
||||
|
||||
async updateAudiobookCoverFromFile(req, res) {
|
||||
if (!req.user.canUpdate) {
|
||||
Logger.warn('User attempted to update without permission', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
var audiobook = this.db.audiobooks.find(a => a.id === req.params.id)
|
||||
if (!audiobook) return res.sendStatus(404)
|
||||
|
||||
var coverFile = req.body
|
||||
var updated = await audiobook.setCoverFromFile(coverFile)
|
||||
|
||||
if (updated) {
|
||||
await this.db.updateAudiobook(audiobook)
|
||||
this.emitter('audiobook_updated', audiobook.toJSONMinified())
|
||||
}
|
||||
|
||||
if (updated) res.status(200).send('Cover updated successfully')
|
||||
else res.status(200).send('No update was made to cover')
|
||||
}
|
||||
|
||||
async updateAudiobook(req, res) {
|
||||
if (!req.user.canUpdate) {
|
||||
Logger.warn('User attempted to update without permission', req.user)
|
||||
@@ -563,8 +664,14 @@ class ApiController {
|
||||
if (!settingsUpdate || !isObject(settingsUpdate)) {
|
||||
return res.status(500).send('Invalid settings update object')
|
||||
}
|
||||
|
||||
var madeUpdates = this.db.serverSettings.update(settingsUpdate)
|
||||
if (madeUpdates) {
|
||||
// If backup schedule is updated - update backup manager
|
||||
if (settingsUpdate.backupSchedule !== undefined) {
|
||||
this.backupManager.updateCronSchedule()
|
||||
}
|
||||
|
||||
await this.db.updateEntity('settings', this.db.serverSettings)
|
||||
}
|
||||
return res.json({
|
||||
@@ -638,7 +745,7 @@ class ApiController {
|
||||
var path = Path.join(relpath, dirname)
|
||||
|
||||
var isDir = (await fs.lstat(fullPath)).isDirectory()
|
||||
if (isDir && !excludedDirs.includes(dirname)) {
|
||||
if (isDir && !excludedDirs.includes(path) && dirname !== 'node_modules') {
|
||||
return {
|
||||
path,
|
||||
dirname,
|
||||
@@ -659,12 +766,16 @@ class ApiController {
|
||||
}
|
||||
|
||||
async getFileSystemPaths(req, res) {
|
||||
var excludedDirs = ['node_modules', 'client', 'server', '.git', 'static', 'build', 'dist', 'metadata', 'config', 'sys', 'proc']
|
||||
var excludedDirs = ['node_modules', 'client', 'server', '.git', 'static', 'build', 'dist', 'metadata', 'config', 'sys', 'proc'].map(dirname => {
|
||||
return Path.sep + dirname
|
||||
})
|
||||
|
||||
// Do not include existing mapped library paths in response
|
||||
this.db.libraries.forEach(lib => {
|
||||
lib.folders.forEach((folder) => {
|
||||
excludedDirs.push(Path.basename(folder.fullPath))
|
||||
var dir = folder.fullPath
|
||||
if (dir.includes(global.appRoot)) dir = dir.replace(global.appRoot, '')
|
||||
excludedDirs.push(dir)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -21,14 +21,19 @@ class BackupManager {
|
||||
this.Gid = Gid
|
||||
this.db = db
|
||||
|
||||
this.scheduleTask = null
|
||||
|
||||
this.backups = []
|
||||
|
||||
// If backup exceeds this value it will be aborted
|
||||
this.MaxBytesBeforeAbort = 1000000000 // ~ 1GB
|
||||
}
|
||||
|
||||
get serverSettings() {
|
||||
return this.db.serverSettings || {}
|
||||
}
|
||||
|
||||
async init(overrideCron = null) {
|
||||
async init() {
|
||||
var backupsDirExists = await fs.pathExists(this.BackupPath)
|
||||
if (!backupsDirExists) {
|
||||
await fs.ensureDir(this.BackupPath)
|
||||
@@ -36,16 +41,34 @@ class BackupManager {
|
||||
}
|
||||
|
||||
await this.loadBackups()
|
||||
this.scheduleCron()
|
||||
}
|
||||
|
||||
scheduleCron() {
|
||||
if (!this.serverSettings.backupSchedule) {
|
||||
Logger.info(`[BackupManager] Auto Backups are disabled`)
|
||||
return
|
||||
}
|
||||
try {
|
||||
var cronSchedule = overrideCron || this.serverSettings.backupSchedule
|
||||
cron.schedule(cronSchedule, this.runBackup.bind(this))
|
||||
var cronSchedule = this.serverSettings.backupSchedule
|
||||
this.scheduleTask = cron.schedule(cronSchedule, this.runBackup.bind(this))
|
||||
} catch (error) {
|
||||
Logger.error(`[BackupManager] Failed to schedule backup cron ${this.serverSettings.backupSchedule}`)
|
||||
Logger.error(`[BackupManager] Failed to schedule backup cron ${this.serverSettings.backupSchedule}`, error)
|
||||
}
|
||||
}
|
||||
|
||||
updateCronSchedule() {
|
||||
if (this.scheduleTask && !this.serverSettings.backupSchedule) {
|
||||
Logger.info(`[BackupManager] Disabling backup schedule`)
|
||||
if (this.scheduleTask.destroy) this.scheduleTask.destroy()
|
||||
this.scheduleTask = null
|
||||
} else if (!this.scheduleTask && this.serverSettings.backupSchedule) {
|
||||
Logger.info(`[BackupManager] Starting backup schedule ${this.serverSettings.backupSchedule}`)
|
||||
this.scheduleCron()
|
||||
} else if (this.serverSettings.backupSchedule) {
|
||||
Logger.info(`[BackupManager] Restarting backup schedule ${this.serverSettings.backupSchedule}`)
|
||||
if (this.scheduleTask.destroy) this.scheduleTask.destroy()
|
||||
this.scheduleCron()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,6 +194,7 @@ class BackupManager {
|
||||
}
|
||||
|
||||
async runBackup() {
|
||||
// Check if Metadata Path is inside Config Path (otherwise there will be an infinite loop as the archiver tries to zip itself)
|
||||
Logger.info(`[BackupManager] Running Backup`)
|
||||
var metadataBooksPath = this.serverSettings.backupMetadataCovers ? Path.join(this.MetadataPath, 'books') : null
|
||||
|
||||
@@ -213,6 +237,7 @@ class BackupManager {
|
||||
|
||||
async removeBackup(backup) {
|
||||
try {
|
||||
Logger.debug(`[BackupManager] Removing Backup "${backup.fullPath}"`)
|
||||
await fs.remove(backup.fullPath)
|
||||
this.backups = this.backups.filter(b => b.id !== backup.id)
|
||||
Logger.info(`[BackupManager] Backup "${backup.id}" Removed`)
|
||||
@@ -243,6 +268,15 @@ class BackupManager {
|
||||
Logger.debug('Data has been drained')
|
||||
})
|
||||
|
||||
output.on('finish', () => {
|
||||
Logger.debug('Write Stream Finished')
|
||||
})
|
||||
|
||||
output.on('error', (err) => {
|
||||
Logger.debug('Write Stream Error', err)
|
||||
reject(err)
|
||||
})
|
||||
|
||||
// good practice to catch warnings (ie stat failures and other non-blocking errors)
|
||||
archive.on('warning', function (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
@@ -259,11 +293,30 @@ class BackupManager {
|
||||
Logger.error(`[BackupManager] Archiver error: ${err.message}`)
|
||||
reject(err)
|
||||
})
|
||||
archive.on('progress', ({ fs: fsobj }) => {
|
||||
if (fsobj.processedBytes > this.MaxBytesBeforeAbort) {
|
||||
Logger.error(`[BackupManager] Archiver is too large - aborting to prevent endless loop, Bytes Processed: ${fsobj.processedBytes}`)
|
||||
archive.abort()
|
||||
setTimeout(() => {
|
||||
this.removeBackup(backup)
|
||||
output.destroy('Backup too large') // Promise is reject in write stream error evt
|
||||
}, 500)
|
||||
}
|
||||
})
|
||||
|
||||
// pipe archive data to the file
|
||||
archive.pipe(output)
|
||||
|
||||
archive.directory(configPath, 'config')
|
||||
var audiobooksDbDir = Path.join(configPath, 'audiobooks')
|
||||
var librariesDbDir = Path.join(configPath, 'libraries')
|
||||
var settingsDbDir = Path.join(configPath, 'settings')
|
||||
var usersDbDir = Path.join(configPath, 'users')
|
||||
|
||||
archive.directory(audiobooksDbDir, 'config/audiobooks')
|
||||
archive.directory(librariesDbDir, 'config/libraries')
|
||||
archive.directory(settingsDbDir, 'config/settings')
|
||||
archive.directory(usersDbDir, 'config/users')
|
||||
|
||||
if (metadataBooksPath) {
|
||||
Logger.debug(`[BackupManager] Backing up Metadata Books "${metadataBooksPath}"`)
|
||||
archive.directory(metadataBooksPath, 'metadata-books')
|
||||
|
||||
@@ -241,10 +241,13 @@ class DownloadManager {
|
||||
|
||||
if (shouldIncludeCover) {
|
||||
var _cover = audiobook.book.coverFullPath
|
||||
|
||||
// Supporting old local file prefix
|
||||
if (!_cover && audiobook.book.cover && audiobook.book.cover.startsWith(Path.sep + 'local')) {
|
||||
_cover = Path.join(this.AudiobookPath, _cover.replace(Path.sep + 'local', ''))
|
||||
Logger.debug('Local cover url', _cover)
|
||||
}
|
||||
|
||||
ffmpegInputs.push({
|
||||
input: _cover,
|
||||
options: ['-f image2pipe']
|
||||
|
||||
@@ -143,18 +143,21 @@ class Scanner {
|
||||
forceAudioFileScan = true
|
||||
}
|
||||
|
||||
// ino is now set for every file in scandir
|
||||
// inode is required
|
||||
audiobookData.audioFiles = audiobookData.audioFiles.filter(af => af.ino)
|
||||
|
||||
// REMOVE: No valid audio files
|
||||
// TODO: Label as incomplete, do not actually delete
|
||||
if (!audiobookData.audioFiles.length) {
|
||||
Logger.error(`[Scanner] "${existingAudiobook.title}" no valid audio files found - removing audiobook`)
|
||||
|
||||
await this.db.removeEntity('audiobook', existingAudiobook.id)
|
||||
this.emitter('audiobook_removed', existingAudiobook.toJSONMinified())
|
||||
|
||||
return ScanResult.REMOVED
|
||||
// No valid ebook and audio files found, mark as incomplete
|
||||
var ebookFiles = audiobookData.otherFiles.filter(f => f.filetype === 'ebook')
|
||||
if (!audiobookData.audioFiles.length && !ebookFiles.length) {
|
||||
Logger.error(`[Scanner] "${existingAudiobook.title}" no valid book files found - marking as incomplete`)
|
||||
existingAudiobook.setLastScan(version)
|
||||
existingAudiobook.isIncomplete = true
|
||||
await this.db.updateAudiobook(existingAudiobook)
|
||||
this.emitter('audiobook_updated', existingAudiobook.toJSONMinified())
|
||||
return ScanResult.UPDATED
|
||||
} else if (existingAudiobook.isIncomplete) { // Was incomplete but now is not
|
||||
Logger.info(`[Scanner] "${existingAudiobook.title}" was incomplete but now has book files`)
|
||||
existingAudiobook.isIncomplete = false
|
||||
}
|
||||
|
||||
// Check for audio files that were removed
|
||||
@@ -219,14 +222,15 @@ class Scanner {
|
||||
await audioFileScanner.scanAudioFiles(existingAudiobook, newAudioFiles)
|
||||
}
|
||||
|
||||
// If after a scan no valid audio tracks remain
|
||||
// TODO: Label as incomplete, do not actually delete
|
||||
if (!existingAudiobook.tracks.length) {
|
||||
Logger.error(`[Scanner] "${existingAudiobook.title}" has no valid tracks after update - removing audiobook`)
|
||||
|
||||
await this.db.removeEntity('audiobook', existingAudiobook.id)
|
||||
this.emitter('audiobook_removed', existingAudiobook.toJSONMinified())
|
||||
return ScanResult.REMOVED
|
||||
// After scanning audio files, some may no longer be valid
|
||||
// so make sure the directory still has valid book files
|
||||
if (!existingAudiobook.tracks.length && !ebookFiles.length) {
|
||||
Logger.error(`[Scanner] "${existingAudiobook.title}" no valid book files found after update - marking as incomplete`)
|
||||
existingAudiobook.setLastScan(version)
|
||||
existingAudiobook.isIncomplete = true
|
||||
await this.db.updateAudiobook(existingAudiobook)
|
||||
this.emitter('audiobook_updated', existingAudiobook.toJSONMinified())
|
||||
return ScanResult.UPDATED
|
||||
}
|
||||
|
||||
var hasUpdates = hasUpdatedIno || hasUpdatedLibraryOrFolder || removedAudioFiles.length || removedAudioTracks.length || newAudioFiles.length || hasUpdatedAudioFiles
|
||||
@@ -269,8 +273,9 @@ class Scanner {
|
||||
}
|
||||
|
||||
async scanNewAudiobook(audiobookData) {
|
||||
if (!audiobookData.audioFiles.length) {
|
||||
Logger.error('[Scanner] No valid audio tracks for Audiobook', audiobookData.path)
|
||||
var ebookFiles = audiobookData.otherFiles.map(f => f.filetype === 'ebook')
|
||||
if (!audiobookData.audioFiles.length && !ebookFiles.length) {
|
||||
Logger.error('[Scanner] No valid audio files and ebooks for Audiobook', audiobookData.path)
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -279,8 +284,9 @@ class Scanner {
|
||||
|
||||
// Scan audio files and set tracks, pulls metadata
|
||||
await audioFileScanner.scanAudioFiles(audiobook, audiobookData.audioFiles)
|
||||
if (!audiobook.tracks.length) {
|
||||
Logger.warn('[Scanner] Invalid audiobook, no valid tracks', audiobook.title)
|
||||
|
||||
if (!audiobook.tracks.length && !audiobook.ebooks.length) {
|
||||
Logger.warn('[Scanner] Invalid audiobook, no valid audio tracks and ebook files', audiobook.title)
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -92,6 +92,10 @@ class Server {
|
||||
})
|
||||
}
|
||||
|
||||
authMiddleware(req, res, next) {
|
||||
this.auth.authMiddleware(req, res, next)
|
||||
}
|
||||
|
||||
async init() {
|
||||
Logger.info('[Server] Init v' + version)
|
||||
await this.streamManager.ensureStreamsDir()
|
||||
@@ -270,6 +274,8 @@ class Server {
|
||||
Logger.info('[SOCKET] Unauth socket disconnected ' + socket.id)
|
||||
delete this.clients[socket.id]
|
||||
} else {
|
||||
socket.broadcast.emit('user_offline', _client.user.toJSONForPublic(this.streamManager.streams))
|
||||
|
||||
const disconnectTime = Date.now() - _client.connected_at
|
||||
Logger.info(`[SOCKET] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms`)
|
||||
delete this.clients[socket.id]
|
||||
@@ -341,10 +347,6 @@ class Server {
|
||||
return purged
|
||||
}
|
||||
|
||||
authMiddleware(req, res, next) {
|
||||
this.auth.authMiddleware(req, res, next)
|
||||
}
|
||||
|
||||
async handleUpload(req, res) {
|
||||
if (!req.user.canUpload) {
|
||||
Logger.warn('User attempted to upload without permission', req.user)
|
||||
@@ -460,6 +462,11 @@ class Server {
|
||||
}
|
||||
}
|
||||
|
||||
socket.broadcast.emit('user_online', client.user.toJSONForPublic(this.streamManager.streams))
|
||||
|
||||
user.lastSeen = Date.now()
|
||||
await this.db.updateEntity('user', user)
|
||||
|
||||
const initialPayload = {
|
||||
serverSettings: this.serverSettings.toJSON(),
|
||||
audiobookPath: this.AudiobookPath,
|
||||
|
||||
@@ -111,6 +111,8 @@ class StreamManager {
|
||||
|
||||
var stream = await this.openStream(client, audiobook)
|
||||
this.db.updateUserStream(client.user.id, stream.id)
|
||||
|
||||
socket.broadcast.emit('user_stream_update', client.user.toJSONForPublic(this.streams))
|
||||
}
|
||||
|
||||
async closeStreamRequest(socket) {
|
||||
@@ -126,6 +128,8 @@ class StreamManager {
|
||||
client.user.stream = null
|
||||
client.stream = null
|
||||
this.db.updateUserStream(client.user.id, null)
|
||||
|
||||
socket.broadcast.emit('user_stream_update', client.user.toJSONForPublic(this.streams))
|
||||
}
|
||||
|
||||
async openTestStream(StreamsPath, audiobookId) {
|
||||
|
||||
@@ -37,6 +37,8 @@ class Audiobook {
|
||||
|
||||
// Audiobook was scanned and not found
|
||||
this.isMissing = false
|
||||
// Audiobook no longer has "book" files
|
||||
this.isInvalid = false
|
||||
|
||||
if (audiobook) {
|
||||
this.construct(audiobook)
|
||||
@@ -70,6 +72,7 @@ class Audiobook {
|
||||
}
|
||||
|
||||
this.isMissing = !!audiobook.isMissing
|
||||
this.isInvalid = !!audiobook.isInvalid
|
||||
}
|
||||
|
||||
get title() {
|
||||
@@ -125,7 +128,15 @@ class Audiobook {
|
||||
}
|
||||
|
||||
get hasEpub() {
|
||||
return this.otherFiles.find(file => file.ext === '.epub')
|
||||
return this.ebooks.find(file => file.ext === '.epub')
|
||||
}
|
||||
|
||||
get hasMobi() {
|
||||
return this.ebooks.find(file => file.ext === '.mobi' || file.ext === '.azw3')
|
||||
}
|
||||
|
||||
get hasPdf() {
|
||||
return this.ebooks.find(file => file.ext === '.pdf')
|
||||
}
|
||||
|
||||
get hasMissingIno() {
|
||||
@@ -175,7 +186,8 @@ class Audiobook {
|
||||
audioFiles: this._audioFiles.map(audioFile => audioFile.toJSON()),
|
||||
otherFiles: this._otherFiles.map(otherFile => otherFile.toJSON()),
|
||||
chapters: this.chapters || [],
|
||||
isMissing: !!this.isMissing
|
||||
isMissing: !!this.isMissing,
|
||||
isInvalid: !!this.isInvalid
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,10 +209,12 @@ class Audiobook {
|
||||
hasMissingParts: this.missingParts ? this.missingParts.length : 0,
|
||||
hasInvalidParts: this.invalidParts ? this.invalidParts.length : 0,
|
||||
// numEbooks: this.ebooks.length,
|
||||
numEbooks: this.hasEpub ? 1 : 0,
|
||||
ebooks: this.ebooks.map(ebook => ebook.toJSON()),
|
||||
numEbooks: (this.hasEpub || this.hasMobi || this.hasPdf) ? 1 : 0, // Only supporting epubs in the reader currently
|
||||
numTracks: this.tracks.length,
|
||||
chapters: this.chapters || [],
|
||||
isMissing: !!this.isMissing
|
||||
isMissing: !!this.isMissing,
|
||||
isInvalid: !!this.isInvalid
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,15 +234,17 @@ class Audiobook {
|
||||
sizePretty: this.sizePretty,
|
||||
missingParts: this.missingParts,
|
||||
invalidParts: this.invalidParts,
|
||||
audioFiles: (this.audioFiles || []).map(audioFile => audioFile.toJSON()),
|
||||
otherFiles: (this.otherFiles || []).map(otherFile => otherFile.toJSON()),
|
||||
ebooks: (this.ebooks || []).map(ebook => ebook.toJSON()),
|
||||
numEbooks: this.hasEpub ? 1 : 0,
|
||||
audioFiles: this._audioFiles.map(audioFile => audioFile.toJSON()),
|
||||
otherFiles: this._otherFiles.map(otherFile => otherFile.toJSON()),
|
||||
ebooks: this.ebooks.map(ebook => ebook.toJSON()),
|
||||
numEbooks: (this.hasEpub || this.hasMobi || this.hasPdf) ? 1 : 0,
|
||||
numTracks: this.tracks.length,
|
||||
tags: this.tags,
|
||||
book: this.bookToJSON(),
|
||||
tracks: this.tracksToJSON(),
|
||||
chapters: this.chapters || [],
|
||||
isMissing: !!this.isMissing
|
||||
isMissing: !!this.isMissing,
|
||||
isInvalid: !!this.isInvalid
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,6 +368,19 @@ class Audiobook {
|
||||
this.book.setData(data)
|
||||
}
|
||||
|
||||
setCoverFromFile(file) {
|
||||
if (!file || !file.fullPath || !file.path) {
|
||||
Logger.error(`[Audiobook] "${this.title}" Invalid file for setCoverFromFile`, file)
|
||||
return false
|
||||
}
|
||||
var updateBookPayload = {}
|
||||
updateBookPayload.coverFullPath = Path.normalize(file.fullPath)
|
||||
// Set ab local static path from file relative path
|
||||
var relImagePath = file.path.replace(this.path, '')
|
||||
updateBookPayload.cover = Path.normalize(Path.join(`/s/book/${this.id}`, relImagePath))
|
||||
return this.book.update(updateBookPayload)
|
||||
}
|
||||
|
||||
addTrack(trackData) {
|
||||
var track = new AudioTrack()
|
||||
track.setData(trackData)
|
||||
@@ -449,11 +478,12 @@ class Audiobook {
|
||||
|
||||
var current_index = 1
|
||||
var missingParts = []
|
||||
|
||||
for (let i = 0; i < this.tracks.length; i++) {
|
||||
var _track = this.tracks[i]
|
||||
if (_track.index > current_index) {
|
||||
var num_parts_missing = _track.index - current_index
|
||||
for (let x = 0; x < num_parts_missing; x++) {
|
||||
for (let x = 0; x < num_parts_missing && x < 9999; x++) {
|
||||
missingParts.push(current_index + x)
|
||||
}
|
||||
}
|
||||
@@ -623,6 +653,10 @@ class Audiobook {
|
||||
return this.book.isSearchMatch(search.toLowerCase().trim())
|
||||
}
|
||||
|
||||
searchQuery(search) {
|
||||
return this.book.getQueryMatches(search.toLowerCase().trim())
|
||||
}
|
||||
|
||||
getAudioFileByIno(ino) {
|
||||
return this.audioFiles.find(af => af.ino === ino)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
class AudiobookProgress {
|
||||
constructor(progress) {
|
||||
this.audiobookId = null
|
||||
this.audiobookTitle = null
|
||||
this.totalDuration = null // seconds
|
||||
this.progress = null // 0 to 1
|
||||
this.currentTime = null // seconds
|
||||
@@ -17,6 +18,7 @@ class AudiobookProgress {
|
||||
toJSON() {
|
||||
return {
|
||||
audiobookId: this.audiobookId,
|
||||
audiobookTitle: this.audiobookTitle,
|
||||
totalDuration: this.totalDuration,
|
||||
progress: this.progress,
|
||||
currentTime: this.currentTime,
|
||||
@@ -29,6 +31,7 @@ class AudiobookProgress {
|
||||
|
||||
construct(progress) {
|
||||
this.audiobookId = progress.audiobookId
|
||||
this.audiobookTitle = progress.audiobookTitle || null
|
||||
this.totalDuration = progress.totalDuration
|
||||
this.progress = progress.progress
|
||||
this.currentTime = progress.currentTime
|
||||
@@ -40,6 +43,7 @@ class AudiobookProgress {
|
||||
|
||||
updateFromStream(stream) {
|
||||
this.audiobookId = stream.audiobookId
|
||||
this.audiobookTitle = stream.audiobookTitle
|
||||
this.totalDuration = stream.totalDuration
|
||||
this.progress = stream.clientProgress
|
||||
this.currentTime = stream.clientCurrentTime
|
||||
|
||||
@@ -67,7 +67,6 @@ class Backup {
|
||||
this.filename = this.id + '.audiobookshelf'
|
||||
this.path = Path.join('backups', this.filename)
|
||||
this.fullPath = Path.join(this.backupDirPath, this.filename)
|
||||
console.log('Backup fullpath', this.fullPath)
|
||||
|
||||
this.createdAt = Date.now()
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ const parseAuthors = require('../utils/parseAuthors')
|
||||
|
||||
class Book {
|
||||
constructor(book = null) {
|
||||
this.olid = null
|
||||
this.title = null
|
||||
this.subtitle = null
|
||||
this.author = null
|
||||
@@ -46,7 +45,6 @@ class Book {
|
||||
}
|
||||
|
||||
construct(book) {
|
||||
this.olid = book.olid
|
||||
this.title = book.title
|
||||
this.subtitle = book.subtitle || null
|
||||
this.author = book.author
|
||||
@@ -69,7 +67,6 @@ class Book {
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
olid: this.olid,
|
||||
title: this.title,
|
||||
subtitle: this.subtitle,
|
||||
author: this.author,
|
||||
@@ -111,7 +108,6 @@ class Book {
|
||||
}
|
||||
|
||||
setData(data) {
|
||||
this.olid = data.olid || null
|
||||
this.title = data.title || null
|
||||
this.subtitle = data.subtitle || null
|
||||
this.author = data.author || null
|
||||
@@ -136,12 +132,18 @@ class Book {
|
||||
update(payload) {
|
||||
var hasUpdates = false
|
||||
|
||||
// Normalize cover paths if passed
|
||||
if (payload.cover) {
|
||||
// If updating to local cover then normalize path
|
||||
if (!payload.cover.startsWith('http:') && !payload.cover.startsWith('https:')) {
|
||||
payload.cover = Path.normalize(payload.cover)
|
||||
if (payload.coverFullPath) payload.coverFullPath = Path.normalize(payload.coverFullPath)
|
||||
else {
|
||||
Logger.warn(`[Book] "${this.title}" updating book cover to "${payload.cover}" but no full path was passed`)
|
||||
}
|
||||
}
|
||||
} else if (payload.coverFullPath) {
|
||||
Logger.warn(`[Book] "${this.title}" updating book full cover path to "${payload.coverFullPath}" but no relative path was passed`)
|
||||
payload.coverFullPath = Path.normalize(payload.coverFullPath)
|
||||
}
|
||||
|
||||
for (const key in payload) {
|
||||
@@ -217,6 +219,17 @@ class Book {
|
||||
return this._title.toLowerCase().includes(search) || this._subtitle.toLowerCase().includes(search) || this._author.toLowerCase().includes(search) || this._series.toLowerCase().includes(search)
|
||||
}
|
||||
|
||||
getQueryMatches(search) {
|
||||
var titleMatch = this._title.toLowerCase().includes(search) || this._subtitle.toLowerCase().includes(search)
|
||||
var authorMatch = this._author.toLowerCase().includes(search)
|
||||
var seriesMatch = this._series.toLowerCase().includes(search)
|
||||
return {
|
||||
book: titleMatch ? 'title' : authorMatch ? 'author' : seriesMatch ? 'series' : false,
|
||||
author: authorMatch ? this._author : false,
|
||||
series: seriesMatch ? this._series : false
|
||||
}
|
||||
}
|
||||
|
||||
setDetailsFromFileMetadata(audioFileMetadata) {
|
||||
const MetadataMapArray = [
|
||||
{
|
||||
|
||||
@@ -5,6 +5,8 @@ class Library {
|
||||
this.id = null
|
||||
this.name = null
|
||||
this.folders = []
|
||||
this.displayOrder = 1
|
||||
this.icon = 'database'
|
||||
|
||||
this.lastScan = 0
|
||||
|
||||
@@ -24,6 +26,9 @@ class Library {
|
||||
this.id = library.id
|
||||
this.name = library.name
|
||||
this.folders = (library.folders || []).map(f => new Folder(f))
|
||||
this.displayOrder = library.displayOrder || 1
|
||||
this.icon = library.icon || 'database'
|
||||
|
||||
this.createdAt = library.createdAt
|
||||
this.lastUpdate = library.lastUpdate
|
||||
}
|
||||
@@ -33,6 +38,8 @@ class Library {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
folders: (this.folders || []).map(f => f.toJSON()),
|
||||
displayOrder: this.displayOrder,
|
||||
icon: this.icon,
|
||||
createdAt: this.createdAt,
|
||||
lastUpdate: this.lastUpdate
|
||||
}
|
||||
@@ -55,6 +62,8 @@ class Library {
|
||||
return newFolder
|
||||
})
|
||||
}
|
||||
this.displayOrder = data.displayOrder || 1
|
||||
this.icon = data.icon || 'database'
|
||||
this.createdAt = Date.now()
|
||||
this.lastUpdate = Date.now()
|
||||
}
|
||||
@@ -65,6 +74,10 @@ class Library {
|
||||
this.name = payload.name
|
||||
hasUpdates = true
|
||||
}
|
||||
if (!isNaN(payload.displayOrder) && payload.displayOrder !== this.displayOrder) {
|
||||
this.displayOrder = Number(payload.displayOrder)
|
||||
hasUpdates = true
|
||||
}
|
||||
if (payload.folders) {
|
||||
var newFolders = payload.folders.filter(f => !f.id)
|
||||
var removedFolders = this.folders.filter(f => !payload.folders.find(_f => _f.id === f.id))
|
||||
|
||||
@@ -10,6 +10,7 @@ class User {
|
||||
this.token = null
|
||||
this.isActive = true
|
||||
this.isLocked = false
|
||||
this.lastSeen = null
|
||||
this.createdAt = null
|
||||
this.audiobooks = null
|
||||
|
||||
@@ -78,6 +79,7 @@ class User {
|
||||
audiobooks: this.audiobooksToJSON(),
|
||||
isActive: this.isActive,
|
||||
isLocked: this.isLocked,
|
||||
lastSeen: this.lastSeen,
|
||||
createdAt: this.createdAt,
|
||||
settings: this.settings,
|
||||
permissions: this.permissions
|
||||
@@ -94,12 +96,25 @@ class User {
|
||||
audiobooks: this.audiobooksToJSON(),
|
||||
isActive: this.isActive,
|
||||
isLocked: this.isLocked,
|
||||
lastSeen: this.lastSeen,
|
||||
createdAt: this.createdAt,
|
||||
settings: this.settings,
|
||||
permissions: this.permissions
|
||||
}
|
||||
}
|
||||
|
||||
toJSONForPublic(streams) {
|
||||
var stream = this.stream && streams ? streams.find(s => s.id === this.stream) : null
|
||||
return {
|
||||
id: this.id,
|
||||
username: this.username,
|
||||
type: this.type,
|
||||
stream: stream ? stream.toJSON() : null,
|
||||
lastSeen: this.lastSeen,
|
||||
createdAt: this.createdAt
|
||||
}
|
||||
}
|
||||
|
||||
construct(user) {
|
||||
this.id = user.id
|
||||
this.username = user.username
|
||||
@@ -117,6 +132,7 @@ class User {
|
||||
}
|
||||
this.isActive = (user.isActive === undefined || user.type === 'root') ? true : !!user.isActive
|
||||
this.isLocked = user.type === 'root' ? false : !!user.isLocked
|
||||
this.lastSeen = user.lastSeen || null
|
||||
this.createdAt = user.createdAt || Date.now()
|
||||
this.settings = user.settings || this.getDefaultUserSettings()
|
||||
this.permissions = user.permissions || this.getDefaultUserPermissions()
|
||||
|
||||
@@ -12,6 +12,7 @@ function getDefaultAudioStream(audioStreams) {
|
||||
}
|
||||
|
||||
async function scan(path) {
|
||||
Logger.debug(`Scanning path "${path}"`)
|
||||
var probeData = await prober(path)
|
||||
if (!probeData || !probeData.audio_streams || !probeData.audio_streams.length) {
|
||||
return {
|
||||
@@ -86,7 +87,7 @@ function getTrackNumberFromFilename(title, author, series, publishYear, filename
|
||||
// Remove eg. "disc 1" from path
|
||||
partbasename = partbasename.replace(/ disc \d\d? /i, '')
|
||||
|
||||
var numbersinpath = partbasename.match(/\d+/g)
|
||||
var numbersinpath = partbasename.match(/\d{1,4}/g)
|
||||
if (!numbersinpath) return null
|
||||
|
||||
var number = numbersinpath.length ? parseInt(numbersinpath[0]) : null
|
||||
@@ -99,6 +100,8 @@ async function scanAudioFiles(audiobook, newAudioFiles) {
|
||||
return
|
||||
}
|
||||
|
||||
Logger.debug('[AudioFileScanner] Scanning audio files')
|
||||
|
||||
var tracks = []
|
||||
var numDuplicateTracks = 0
|
||||
var numInvalidTracks = 0
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const globals = {
|
||||
SupportedImageTypes: ['png', 'jpg', 'jpeg', 'webp'],
|
||||
SupportedAudioTypes: ['m4b', 'mp3', 'm4a', 'flac', 'opus'],
|
||||
SupportedEbookTypes: ['epub', 'pdf', 'mobi']
|
||||
SupportedAudioTypes: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'mp4'],
|
||||
SupportedEbookTypes: ['epub', 'pdf', 'mobi', 'azw3']
|
||||
}
|
||||
|
||||
module.exports = globals
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const Path = require('path')
|
||||
const fs = require('fs-extra')
|
||||
const dir = require('node-dir')
|
||||
const Logger = require('../Logger')
|
||||
const { getIno } = require('./index')
|
||||
@@ -16,11 +17,12 @@ function getPaths(path) {
|
||||
})
|
||||
}
|
||||
|
||||
function isAudioFile(path) {
|
||||
function isBookFile(path) {
|
||||
if (!path) return false
|
||||
var ext = Path.extname(path)
|
||||
if (!ext) return false
|
||||
return globals.SupportedAudioTypes.includes(ext.slice(1).toLowerCase())
|
||||
var extclean = ext.slice(1).toLowerCase()
|
||||
return globals.SupportedAudioTypes.includes(extclean) || globals.SupportedEbookTypes.includes(extclean)
|
||||
}
|
||||
|
||||
// Input: array of relative file paths
|
||||
@@ -36,17 +38,18 @@ function groupFilesIntoAudiobookPaths(paths, useAllFileTypes = false) {
|
||||
return pathsA - pathsB
|
||||
})
|
||||
|
||||
// Step 2.5: Seperate audio files and other files (optional)
|
||||
var audioFilePaths = []
|
||||
// Step 2.5: Seperate audio/ebook files and other files (optional)
|
||||
// - Directories without an audio or ebook file will not be included
|
||||
var bookFilePaths = []
|
||||
var otherFilePaths = []
|
||||
pathsFiltered.forEach(path => {
|
||||
if (isAudioFile(path) || useAllFileTypes) audioFilePaths.push(path)
|
||||
if (isBookFile(path) || useAllFileTypes) bookFilePaths.push(path)
|
||||
else otherFilePaths.push(path)
|
||||
})
|
||||
|
||||
// Step 3: Group audio files in audiobooks
|
||||
var audiobookGroup = {}
|
||||
audioFilePaths.forEach((path) => {
|
||||
bookFilePaths.forEach((path) => {
|
||||
var dirparts = Path.dirname(path).split(Path.sep)
|
||||
var numparts = dirparts.length
|
||||
var _path = ''
|
||||
@@ -118,6 +121,12 @@ async function scanRootDir(folder, serverSettings = {}) {
|
||||
var folderPath = folder.fullPath
|
||||
var parseSubtitle = !!serverSettings.scannerParseSubtitle
|
||||
|
||||
var pathExists = await fs.pathExists(folderPath)
|
||||
if (!pathExists) {
|
||||
Logger.error(`[scandir] Invalid folder path does not exist "${folderPath}"`)
|
||||
return []
|
||||
}
|
||||
|
||||
var pathdata = await getPaths(folderPath)
|
||||
var filepaths = pathdata.files.map(filepath => {
|
||||
return Path.normalize(filepath).replace(folderPath, '')
|
||||
|
||||
Reference in New Issue
Block a user