mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-01-04 05:30:26 -05:00
Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1331fb3f8 | ||
|
|
17d15144eb | ||
|
|
74d26eece4 | ||
|
|
474a7d08d0 | ||
|
|
639c930779 | ||
|
|
c6323f8ad9 | ||
|
|
caea6c6371 | ||
|
|
d285845e04 | ||
|
|
5a6867e98a | ||
|
|
621444114f | ||
|
|
5591704aad | ||
|
|
cc1181b301 | ||
|
|
095f49824e | ||
|
|
b330030f50 | ||
|
|
a7d422e23f | ||
|
|
f51a31c8ca | ||
|
|
290340a385 | ||
|
|
0137f6dfeb | ||
|
|
7f27eabf3e | ||
|
|
4f7588c87d | ||
|
|
a19b6370c4 | ||
|
|
fbd7ae10d1 | ||
|
|
f94c706fc8 | ||
|
|
9de4b1069a | ||
|
|
8fbe3c3884 | ||
|
|
abf9120363 | ||
|
|
69f250cba5 | ||
|
|
2103edfcdc | ||
|
|
02ba147bd4 | ||
|
|
230b548921 | ||
|
|
f34ebdc016 | ||
|
|
69ad651671 | ||
|
|
edc919b3f5 | ||
|
|
c8c7a9ece5 | ||
|
|
8702ac1ccf | ||
|
|
33833e0a36 | ||
|
|
6b98baafdf | ||
|
|
cc285bb685 | ||
|
|
ef0243f1d7 | ||
|
|
7a7d53f92e | ||
|
|
2e070227ab | ||
|
|
195a30096f | ||
|
|
55c40658f2 | ||
|
|
db48a486e5 | ||
|
|
d869a9836e | ||
|
|
55680cbc98 | ||
|
|
9b7e6a6058 | ||
|
|
a482e5d316 | ||
|
|
5ac342defd |
@@ -48,7 +48,7 @@ Description: $DESCRIPTION"
|
||||
echo "$controlfile" > dist/debian/DEBIAN/control;
|
||||
|
||||
# Package debian
|
||||
pkg -t node12-linux-x64 -o dist/debian/usr/share/audiobookshelf/audiobookshelf .
|
||||
pkg -t node16-linux-x64 -o dist/debian/usr/share/audiobookshelf/audiobookshelf .
|
||||
|
||||
fakeroot dpkg-deb --build dist/debian
|
||||
|
||||
|
||||
@@ -12,18 +12,30 @@
|
||||
height: calc(100% - 64px);
|
||||
max-height: calc(100% - 64px);
|
||||
}
|
||||
|
||||
.page.streaming {
|
||||
height: calc(100% - 64px - 165px);
|
||||
max-height: calc(100% - 64px - 165px);
|
||||
}
|
||||
|
||||
#bookshelf {
|
||||
height: calc(100% - 40px);
|
||||
background-image: linear-gradient(to right bottom, #2e2e2e, #303030, #313131, #333333, #353535, #343434, #323232, #313131, #2c2c2c, #282828, #232323, #1f1f1f);
|
||||
}
|
||||
|
||||
.bookshelf-row {
|
||||
/* Sidebar width + scrollbar width */
|
||||
width: calc(100vw - 88px);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#bookshelf {
|
||||
height: calc(100% - 80px);
|
||||
}
|
||||
|
||||
.bookshelf-row {
|
||||
width: 100vw;
|
||||
}
|
||||
}
|
||||
|
||||
#page-wrapper {
|
||||
@@ -34,36 +46,25 @@
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar:horizontal {
|
||||
height: 8px;
|
||||
}
|
||||
/* ::-webkit-scrollbar:horizontal { */
|
||||
/* height: 16px; */
|
||||
/* height: 24px;
|
||||
} */
|
||||
|
||||
/* Track */
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: rgba(0,0,0,0);
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
/* ::-webkit-scrollbar-track:horizontal { */
|
||||
/* background: rgb(149, 119, 90); */
|
||||
/* background: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%); */
|
||||
/* background: linear-gradient(180deg, rgb(117, 88, 60) 0%, rgb(65, 41, 17) 17%, rgb(71, 43, 15) 88%, rgb(3, 2, 1) 100%);
|
||||
box-shadow: 2px 14px 8px #111111aa;
|
||||
} */
|
||||
|
||||
/* Handle */
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #855620;
|
||||
background: #855620;
|
||||
border-radius: 4px;
|
||||
}
|
||||
/* ::-webkit-scrollbar-thumb:horizontal { */
|
||||
/* background: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%); */
|
||||
/* box-shadow: 2px 14px 8px #111111aa;
|
||||
border-radius: 4px;
|
||||
} */
|
||||
|
||||
/* Handle on hover */
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #704922;
|
||||
background: #704922;
|
||||
}
|
||||
|
||||
.no-scroll::-webkit-scrollbar {
|
||||
@@ -71,6 +72,13 @@
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.no-scroll {
|
||||
-ms-overflow-style: none;
|
||||
/* IE and Edge */
|
||||
scrollbar-width: none;
|
||||
/* Firefox */
|
||||
}
|
||||
|
||||
/* Chrome, Safari, Edge, Opera */
|
||||
.no-spinner::-webkit-outer-spin-button,
|
||||
.no-spinner::-webkit-inner-spin-button {
|
||||
@@ -89,18 +97,23 @@ input[type=number] {
|
||||
width: 100%;
|
||||
border: 1px solid #474747;
|
||||
}
|
||||
|
||||
.tracksTable tr:nth-child(even) {
|
||||
background-color: #2e2e2e;
|
||||
}
|
||||
|
||||
.tracksTable tr {
|
||||
background-color: #373838;
|
||||
}
|
||||
|
||||
.tracksTable tr:hover {
|
||||
background-color: #474747;
|
||||
}
|
||||
|
||||
.tracksTable td {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.tracksTable th {
|
||||
padding: 4px 8px;
|
||||
font-size: 0.75rem;
|
||||
@@ -113,13 +126,22 @@ input[type=number] {
|
||||
border-right: 6px solid transparent;
|
||||
border-top: 6px solid white;
|
||||
}
|
||||
|
||||
.arrow-down-small {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
border-top: 4px solid currentColor;
|
||||
}
|
||||
|
||||
.triangle-right {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 8px solid transparent;
|
||||
border-bottom: 8px solid transparent;
|
||||
border-top: 8px solid rgb(34,127,35);
|
||||
border-right: 8px solid rgb(34,127,35);
|
||||
border-top: 8px solid rgb(34, 127, 35);
|
||||
border-right: 8px solid rgb(34, 127, 35);
|
||||
}
|
||||
|
||||
.icon-text {
|
||||
@@ -149,6 +171,7 @@ input[type=number] {
|
||||
.box-shadow-book {
|
||||
box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166;
|
||||
}
|
||||
|
||||
.shadow-height {
|
||||
height: calc(100% - 4px);
|
||||
}
|
||||
@@ -165,9 +188,9 @@ input[type=number] {
|
||||
Bookshelf Label
|
||||
*/
|
||||
.categoryPlacard {
|
||||
background-image: url(https://image.freepik.com/free-photo/brown-wooden-textured-flooring-background_53876-128537.jpg);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.shinyBlack {
|
||||
background-color: #2d3436;
|
||||
background-image: linear-gradient(315deg, #19191a 0%, rgb(15, 15, 15) 74%);
|
||||
@@ -194,8 +217,11 @@ Bookshelf Label
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
line-height: 16px; /* fallback */
|
||||
max-height: 32px; /* fallback */
|
||||
-webkit-line-clamp: 2; /* number of lines to show */
|
||||
line-height: 16px;
|
||||
/* fallback */
|
||||
max-height: 32px;
|
||||
/* fallback */
|
||||
-webkit-line-clamp: 2;
|
||||
/* number of lines to show */
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
|
||||
@font-face {
|
||||
font-family: 'Material Icons';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url(/fonts/MaterialIcons.woff2) format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Material Icons Outlined';
|
||||
font-style: normal;
|
||||
@@ -23,12 +23,13 @@
|
||||
white-space: nowrap;
|
||||
word-wrap: normal;
|
||||
direction: ltr;
|
||||
-webkit-font-feature-settings: 'liga';
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.material-icons:not(.text-xs):not(.text-sm):not(.text-md):not(.text-base):not(.text-lg):not(.text-xl):not(.text-2xl):not(.text-3xl):not(.text-4xl):not(.text-5xl):not(.text-6xl):not(.text-7xl):not(.text-8xl) {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.material-icons-outlined {
|
||||
font-family: 'Material Icons Outlined';
|
||||
font-weight: normal;
|
||||
@@ -40,9 +41,9 @@
|
||||
white-space: nowrap;
|
||||
word-wrap: normal;
|
||||
direction: ltr;
|
||||
-webkit-font-feature-settings: 'liga';
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.material-icons-outlined:not(.text-xs):not(.text-sm):not(.text-md):not(.text-base):not(.text-lg):not(.text-xl):not(.text-2xl):not(.text-3xl):not(.text-4xl):not(.text-5xl):not(.text-6xl):not(.text-7xl):not(.text-8xl) {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
@@ -56,6 +57,7 @@
|
||||
src: url(/fonts/GentiumBookBasic.woff2) format('woff2');
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Gentium Book Basic';
|
||||
@@ -64,4 +66,274 @@
|
||||
font-display: swap;
|
||||
src: url(/fonts/GentiumBookBasic.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
|
||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
|
||||
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
/* greek-ext */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
|
||||
/* greek */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
|
||||
unicode-range: U+0370-03FF;
|
||||
}
|
||||
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
|
||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
|
||||
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
/* greek-ext */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
|
||||
/* greek */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
|
||||
unicode-range: U+0370-03FF;
|
||||
}
|
||||
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
|
||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
|
||||
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
/* greek-ext */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
|
||||
/* greek */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
|
||||
unicode-range: U+0370-03FF;
|
||||
}
|
||||
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Ubuntu Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
|
||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: 'Ubuntu Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
|
||||
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
/* greek-ext */
|
||||
@font-face {
|
||||
font-family: 'Ubuntu Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
|
||||
/* greek */
|
||||
@font-face {
|
||||
font-family: 'Ubuntu Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
|
||||
unicode-range: U+0370-03FF;
|
||||
}
|
||||
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Ubuntu Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Ubuntu Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@@ -2,11 +2,13 @@
|
||||
<div class="w-full h-16 bg-primary relative">
|
||||
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-50">
|
||||
<div class="flex h-full items-center">
|
||||
<img v-if="!showBack" src="/icon48.png" class="w-10 h-10 md:w-12 md:h-12 mr-4" />
|
||||
<a v-if="showBack" @click="back" class="rounded-full h-12 w-12 flex items-center justify-center hover:bg-white hover:bg-opacity-10 mr-4 cursor-pointer">
|
||||
<span class="material-icons text-4xl text-white">arrow_back</span>
|
||||
</a>
|
||||
<h1 class="text-2xl font-book mr-6 hidden lg:block">audiobookshelf</h1>
|
||||
<nuxt-link to="/">
|
||||
<img src="/icon48.png" class="w-10 h-10 md:w-12 md:h-12 mr-4" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link to="/">
|
||||
<h1 class="text-2xl font-book mr-6 hidden lg:block hover:underline">audiobookshelf</h1>
|
||||
</nuxt-link>
|
||||
|
||||
<ui-libraries-dropdown />
|
||||
|
||||
@@ -30,7 +32,7 @@
|
||||
<span class="material-icons" aria-label="Upload Media" role="button">upload</span>
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="isRootUser" to="/config" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
||||
<nuxt-link v-if="userIsAdminOrUp" to="/config" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
||||
<span class="material-icons" aria-label="System Settings" role="button">settings</span>
|
||||
</nuxt-link>
|
||||
|
||||
@@ -94,14 +96,11 @@ export default {
|
||||
isHome() {
|
||||
return this.$route.name === 'library-library'
|
||||
},
|
||||
showBack() {
|
||||
return this.$route.name !== 'library-library-bookshelf-id' && !this.isHome
|
||||
},
|
||||
user() {
|
||||
return this.$store.state.user.user
|
||||
},
|
||||
isRootUser() {
|
||||
return this.$store.getters['user/getIsRoot']
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
username() {
|
||||
return this.user ? this.user.username : 'err'
|
||||
@@ -151,12 +150,6 @@ export default {
|
||||
toggleBookshelfTexture() {
|
||||
this.$store.dispatch('setBookshelfTexture', 'wood2.png')
|
||||
},
|
||||
async back() {
|
||||
var popped = await this.$store.dispatch('popRoute')
|
||||
if (popped) this.$store.commit('setIsRoutingBack', true)
|
||||
var backTo = popped || '/'
|
||||
this.$router.push(backTo)
|
||||
},
|
||||
cancelSelectionMode() {
|
||||
if (this.processingBatchDelete) return
|
||||
this.$store.commit('setSelectedLibraryItems', [])
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<template>
|
||||
<div id="bookshelf" ref="wrapper" class="w-full h-full overflow-y-scroll relative">
|
||||
<div id="bookshelf" ref="wrapper" class="w-full max-w-full h-full overflow-y-scroll relative">
|
||||
<!-- Cover size widget -->
|
||||
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
|
||||
<!-- Experimental Bookshelf Texture -->
|
||||
<div v-show="showExperimentalFeatures" class="fixed bottom-4 right-28 z-40">
|
||||
<div v-show="showExperimentalFeatures && !isAlternativeBookshelfView" class="fixed bottom-4 right-28 z-40">
|
||||
<div class="rounded-full py-1 bg-primary hover:bg-bg cursor-pointer px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent @click="showBookshelfTextureModal"><p class="text-sm py-0.5">Texture</p></div>
|
||||
</div>
|
||||
|
||||
<div v-if="loaded && !shelves.length && isRootUser && !search" class="w-full flex flex-col items-center justify-center py-12">
|
||||
<div v-if="loaded && !shelves.length && !search" class="w-full flex flex-col items-center justify-center py-12">
|
||||
<p class="text-center text-2xl font-book mb-4 py-4">{{ libraryName }} Library is empty!</p>
|
||||
<div class="flex">
|
||||
<div v-if="userIsAdminOrUp" class="flex">
|
||||
<ui-btn to="/config" color="primary" class="w-52 mr-2">Configure Scanner</ui-btn>
|
||||
<ui-btn color="success" class="w-52" @click="scan">Scan Library</ui-btn>
|
||||
</div>
|
||||
@@ -17,7 +17,25 @@
|
||||
<div v-else-if="loaded && !shelves.length && search" class="w-full h-40 flex items-center justify-center">
|
||||
<p class="text-center text-xl font-book py-4">No results for query</p>
|
||||
</div>
|
||||
<div v-else class="w-full flex flex-col items-center">
|
||||
<!-- Alternate plain view -->
|
||||
<div v-else-if="isAlternativeBookshelfView" class="w-full mb-24">
|
||||
<template v-for="(shelf, index) in shelves">
|
||||
<widgets-item-slider v-if="shelf.type === 'book' || shelf.type === 'podcast'" :key="index + '.'" :items="shelf.entities" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
|
||||
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ shelf.label }}</p>
|
||||
</widgets-item-slider>
|
||||
<widgets-episode-slider v-else-if="shelf.type === 'episode'" :key="index + '.'" :items="shelf.entities" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
|
||||
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ shelf.label }}</p>
|
||||
</widgets-episode-slider>
|
||||
<widgets-series-slider v-else-if="shelf.type === 'series'" :key="index + '.'" :items="shelf.entities" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
|
||||
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ shelf.label }}</p>
|
||||
</widgets-series-slider>
|
||||
<widgets-authors-slider v-else-if="shelf.type === 'authors'" :key="index + '.'" :items="shelf.entities" :height="192 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
|
||||
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ shelf.label }}</p>
|
||||
</widgets-authors-slider>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Regular bookshelf view -->
|
||||
<div v-else class="w-full">
|
||||
<template v-for="(shelf, index) in shelves">
|
||||
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</template>
|
||||
@@ -44,8 +62,8 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isRootUser() {
|
||||
return this.$store.getters['user/getIsRoot']
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
@@ -56,6 +74,12 @@ export default {
|
||||
libraryName() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryName']
|
||||
},
|
||||
bookshelfView() {
|
||||
return this.$store.getters['getServerSetting']('bookshelfView')
|
||||
},
|
||||
isAlternativeBookshelfView() {
|
||||
return this.bookshelfView === this.$constants.BookshelfView.TITLES
|
||||
},
|
||||
bookCoverWidth() {
|
||||
var coverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
|
||||
if (this.isCoverSquareAspectRatio) return coverSize * 1.6
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div ref="shelf" class="w-full max-w-full categorizedBookshelfRow relative overflow-x-scroll overflow-y-hidden z-10" :style="{ paddingLeft: paddingLeft * sizeMultiplier + 'rem', height: shelfHeight + 'px' }" @scroll="scrolled">
|
||||
<div ref="shelf" class="w-full max-w-full bookshelf-row categorizedBookshelfRow relative overflow-x-scroll overflow-y-hidden z-10" :style="{ paddingLeft: paddingLeft * sizeMultiplier + 'rem', height: shelfHeight + 'px' }" @scroll="scrolled">
|
||||
<div class="w-full h-full pt-6">
|
||||
<div v-if="shelf.type === 'book' || shelf.type === 'podcast'" class="flex items-center">
|
||||
<template v-for="(entity, index) in shelf.entities">
|
||||
<cards-lazy-book-card :key="entity.id" :ref="`shelf-book-${entity.id}`" :index="index" :width="bookCoverWidth" :height="bookCoverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :book-mount="entity" class="relative mx-2" @hook:updated="updatedBookCard" @select="selectItem" @edit="editBook" />
|
||||
<cards-lazy-book-card :key="entity.id" :ref="`shelf-book-${entity.id}`" :index="index" :width="bookCoverWidth" :height="bookCoverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :book-mount="entity" class="relative mx-2" @hook:updated="updatedBookCard" @select="selectItem" @edit="editItem" />
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="shelf.type === 'episode'" class="flex items-center">
|
||||
<template v-for="(entity, index) in shelf.entities">
|
||||
<cards-lazy-book-card :key="entity.recentEpisode.id" :ref="`shelf-episode-${entity.recentEpisode.id}`" :index="index" :width="bookCoverWidth" :height="bookCoverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :book-mount="entity" class="relative mx-2" @hook:updated="updatedBookCard" @select="selectItem" @edit="editEpisode" />
|
||||
<cards-lazy-book-card :key="entity.recentEpisode.id" :ref="`shelf-episode-${entity.recentEpisode.id}`" :index="index" :width="bookCoverWidth" :height="bookCoverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :book-mount="entity" class="relative mx-2" @hook:updated="updatedBookCard" @select="selectItem" @editPodcast="editItem" @edit="editEpisode" />
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="shelf.type === 'series'" class="flex items-center">
|
||||
@@ -17,18 +17,9 @@
|
||||
<cards-lazy-series-card :key="entity.name" :series-mount="entity" :height="bookCoverHeight" :width="bookCoverWidth * 2" :book-cover-aspect-ratio="bookCoverAspectRatio" class="relative mx-2" @hook:updated="updatedBookCard" />
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="shelf.type === 'tags'" class="flex items-center">
|
||||
<template v-for="entity in shelf.entities">
|
||||
<nuxt-link :key="entity.name" :to="`/library/${currentLibraryId}/bookshelf?filter=tags.${$encode(entity.name)}`">
|
||||
<cards-group-card is-categorized :width="bookCoverWidth" :group="entity" :book-cover-aspect-ratio="bookCoverAspectRatio" @hook:updated="updatedBookCard" />
|
||||
</nuxt-link>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="shelf.type === 'authors'" class="flex items-center">
|
||||
<template v-for="entity in shelf.entities">
|
||||
<nuxt-link :key="entity.id" :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(entity.id)}`">
|
||||
<cards-author-card :width="bookCoverWidth / 1.25" :height="bookCoverWidth" :author="entity" :size-multiplier="sizeMultiplier" @hook:updated="updatedBookCard" class="pb-6 mx-2" @edit="editAuthor" />
|
||||
</nuxt-link>
|
||||
<cards-author-card :key="entity.id" :width="bookCoverWidth / 1.25" :height="bookCoverWidth" :author="entity" :size-multiplier="sizeMultiplier" @hook:updated="updatedBookCard" class="pb-6 mx-2" @edit="editAuthor" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -48,7 +39,6 @@
|
||||
<div v-show="canScrollRight && !isScrolling" class="hidden sm:flex absolute top-0 right-0 w-32 pl-8 bg-black book-shelf-arrow-right items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-30" @click="scrollRight">
|
||||
<span class="material-icons text-6xl text-white">chevron_right</span>
|
||||
</div>
|
||||
<modals-authors-edit-modal v-model="showAuthorModal" :author="selectedAuthor" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -70,9 +60,7 @@ export default {
|
||||
canScrollLeft: false,
|
||||
isScrolling: false,
|
||||
scrollTimer: null,
|
||||
updateTimer: null,
|
||||
showAuthorModal: false,
|
||||
selectedAuthor: null
|
||||
updateTimer: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -98,13 +86,12 @@ export default {
|
||||
this.updateSelectionMode(false)
|
||||
},
|
||||
editAuthor(author) {
|
||||
this.selectedAuthor = author
|
||||
this.showAuthorModal = true
|
||||
this.$store.commit('globals/showEditAuthorModal', author)
|
||||
},
|
||||
editBook(audiobook) {
|
||||
var bookIds = this.shelf.entities.map((e) => e.id)
|
||||
this.$store.commit('setBookshelfBookIds', bookIds)
|
||||
this.$store.commit('showEditModal', audiobook)
|
||||
editItem(libraryItem) {
|
||||
var itemIds = this.shelf.entities.map((e) => e.id)
|
||||
this.$store.commit('setBookshelfBookIds', itemIds)
|
||||
this.$store.commit('showEditModal', libraryItem)
|
||||
},
|
||||
editEpisode({ libraryItem, episode }) {
|
||||
this.$store.commit('setSelectedLibraryItem', libraryItem)
|
||||
@@ -197,25 +184,13 @@ export default {
|
||||
<style>
|
||||
.categorizedBookshelfRow {
|
||||
scroll-behavior: smooth;
|
||||
width: calc(100vw - 80px);
|
||||
|
||||
/* background-color: rgb(214, 116, 36); */
|
||||
background-image: var(--bookshelf-texture-img);
|
||||
/* background-position: center; */
|
||||
/* background-size: contain; */
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.categorizedBookshelfRow {
|
||||
width: 100vw;
|
||||
}
|
||||
}
|
||||
|
||||
.bookshelfDividerCategorized {
|
||||
background: rgb(149, 119, 90);
|
||||
/* background: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%); */
|
||||
background: linear-gradient(180deg, rgb(122, 94, 68) 0%, rgb(92, 62, 31) 17%, rgb(82, 54, 26) 88%, rgba(71, 48, 25, 1) 100%);
|
||||
/* background: linear-gradient(180deg, rgb(114, 85, 59) 0%, rgb(73, 48, 22) 17%, rgb(71, 43, 15) 88%, rgb(61, 41, 20) 100%); */
|
||||
box-shadow: 2px 14px 8px #111111aa;
|
||||
}
|
||||
|
||||
|
||||
@@ -143,7 +143,7 @@ export default {
|
||||
return this.$store.getters['user/getUserSetting']('filterBy')
|
||||
},
|
||||
isIssuesFilter() {
|
||||
return this.filterBy === 'issues'
|
||||
return this.filterBy === 'issues' && this.$route.query.filter === 'issues'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -25,11 +25,11 @@ export default {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
userIsRoot() {
|
||||
return this.$store.getters['user/getIsRoot']
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
configRoutes() {
|
||||
if (!this.userIsRoot) {
|
||||
if (!this.userIsAdminOrUp) {
|
||||
return [
|
||||
{
|
||||
id: 'config-stats',
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="initialized && !totalShelves && !hasFilter && isRootUser && entityName === 'books'" class="w-full flex flex-col items-center justify-center py-12">
|
||||
<div v-if="initialized && !totalShelves && !hasFilter && entityName === 'books'" class="w-full flex flex-col items-center justify-center py-12">
|
||||
<p class="text-center text-2xl font-book mb-4 py-4">{{ libraryName }} Library is empty!</p>
|
||||
<div class="flex">
|
||||
<div v-if="userIsAdminOrUp" class="flex">
|
||||
<ui-btn to="/config" color="primary" class="w-52 mr-2">Configure Scanner</ui-btn>
|
||||
<ui-btn color="success" class="w-52" @click="scan">Scan Library</ui-btn>
|
||||
</div>
|
||||
@@ -22,8 +22,9 @@
|
||||
</div>
|
||||
|
||||
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
|
||||
|
||||
<!-- Experimental Bookshelf Texture -->
|
||||
<div v-show="showExperimentalFeatures" class="fixed bottom-4 right-28 z-40">
|
||||
<div v-show="showExperimentalFeatures && !isAlternativeBookshelfView" class="fixed bottom-4 right-28 z-40">
|
||||
<div class="rounded-full py-1 bg-primary hover:bg-bg cursor-pointer px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent @click="showBookshelfTextureModal">
|
||||
<p class="text-sm py-0.5">Texture</p>
|
||||
</div>
|
||||
@@ -79,8 +80,8 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isRootUser() {
|
||||
return this.$store.getters['user/getIsRoot']
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
@@ -126,7 +127,7 @@ export default {
|
||||
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE
|
||||
},
|
||||
isAlternativeBookshelfView() {
|
||||
if (!this.isEntityBook) return false // Only used for bookshelf showing books
|
||||
// if (!this.isEntityBook) return false // Only used for bookshelf showing books
|
||||
return this.bookshelfView === this.$constants.BookshelfView.TITLES
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
@@ -185,7 +186,10 @@ export default {
|
||||
return 6
|
||||
},
|
||||
shelfHeight() {
|
||||
if (this.isAlternativeBookshelfView) return this.entityHeight + 80 * this.sizeMultiplier
|
||||
if (this.isAlternativeBookshelfView) {
|
||||
var extraTitleSpace = this.isEntityBook ? 80 : 40
|
||||
return this.entityHeight + extraTitleSpace * this.sizeMultiplier
|
||||
}
|
||||
return this.entityHeight + 40
|
||||
},
|
||||
totalEntityCardWidth() {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<span class="material-icons text-sm">person</span>
|
||||
<p v-if="podcastAuthor">{{ podcastAuthor }}</p>
|
||||
<p v-else-if="authors.length" class="pl-1.5 text-sm sm:text-base">
|
||||
<nuxt-link v-for="(author, index) in authors" :key="index" :to="`/library/${libraryId}/bookshelf?filter=authors.${$encode(author.id)}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link>
|
||||
<nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link>
|
||||
</p>
|
||||
<p v-else class="text-sm sm:text-base cursor-pointer pl-2">Unknown</p>
|
||||
</div>
|
||||
|
||||
@@ -1,32 +1,34 @@
|
||||
<template>
|
||||
<div @mouseover="mouseover" @mouseout="mouseout">
|
||||
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
|
||||
<!-- Image or placeholder -->
|
||||
<covers-author-image :author="author" />
|
||||
<nuxt-link :to="`/author/${author.id}`">
|
||||
<div @mouseover="mouseover" @mouseleave="mouseleave">
|
||||
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
|
||||
<!-- Image or placeholder -->
|
||||
<covers-author-image :author="author" />
|
||||
|
||||
<!-- Author name & num books overlay -->
|
||||
<div v-show="!searching && !nameBelow" class="absolute bottom-0 left-0 w-full py-1 bg-black bg-opacity-60 px-2">
|
||||
<p class="text-center font-semibold truncate" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
|
||||
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.65 + 'rem' }">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p>
|
||||
</div>
|
||||
<!-- Author name & num books overlay -->
|
||||
<div v-show="!searching && !nameBelow" class="absolute bottom-0 left-0 w-full py-1 bg-black bg-opacity-60 px-2">
|
||||
<p class="text-center font-semibold truncate" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
|
||||
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.65 + 'rem' }">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Search icon btn -->
|
||||
<div v-show="!searching && isHovering && userCanUpdate" class="absolute top-0 left-0 p-2 cursor-pointer hover:text-white text-gray-200 transform transition-transform hover:scale-125" @click.prevent.stop="searchAuthor">
|
||||
<span class="material-icons text-lg">search</span>
|
||||
</div>
|
||||
<div v-show="isHovering && !searching && userCanUpdate" class="absolute top-0 right-0 p-2 cursor-pointer hover:text-white text-gray-200 transform transition-transform hover:scale-125" @click.prevent.stop="$emit('edit', author)">
|
||||
<span class="material-icons text-lg">edit</span>
|
||||
</div>
|
||||
<!-- Search icon btn -->
|
||||
<div v-show="!searching && isHovering && userCanUpdate" class="absolute top-0 left-0 p-2 cursor-pointer hover:text-white text-gray-200 transform hover:scale-125 duration-150" @click.prevent.stop="searchAuthor">
|
||||
<span class="material-icons text-lg">search</span>
|
||||
</div>
|
||||
<div v-show="isHovering && !searching && userCanUpdate" class="absolute top-0 right-0 p-2 cursor-pointer hover:text-white text-gray-200 transform hover:scale-125 duration-150" @click.prevent.stop="$emit('edit', author)">
|
||||
<span class="material-icons text-lg">edit</span>
|
||||
</div>
|
||||
|
||||
<!-- Loading spinner -->
|
||||
<div v-show="searching" class="absolute top-0 left-0 z-10 w-full h-full bg-black bg-opacity-50 flex items-center justify-center">
|
||||
<widgets-loading-spinner size="" />
|
||||
<!-- Loading spinner -->
|
||||
<div v-show="searching" class="absolute top-0 left-0 z-10 w-full h-full bg-black bg-opacity-50 flex items-center justify-center">
|
||||
<widgets-loading-spinner size="" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="nameBelow" class="w-full py-1 px-2">
|
||||
<p class="text-center font-semibold truncate text-gray-200" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="nameBelow" class="w-full py-1 px-2">
|
||||
<p class="text-center font-semibold truncate text-gray-200" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -74,7 +76,7 @@ export default {
|
||||
mouseover() {
|
||||
this.isHovering = true
|
||||
},
|
||||
mouseout() {
|
||||
mouseleave() {
|
||||
this.isHovering = false
|
||||
},
|
||||
async searchAuthor() {
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
</div>
|
||||
|
||||
<!-- Alternative bookshelf title/author/sort -->
|
||||
<div v-if="isAlternativeBookshelfView" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }">
|
||||
<div v-if="isAlternativeBookshelfView || isAuthorBookshelfView" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }">
|
||||
<p class="truncate" :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
|
||||
{{ displayTitle }}
|
||||
</p>
|
||||
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayAuthor || ' ' }}</p>
|
||||
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayLineTwo || ' ' }}</p>
|
||||
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
|
||||
</div>
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="userCanUpdate" v-show="!isSelectionMode" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-50 top-0 right-0" :style="{ padding: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="editClick">
|
||||
<div v-if="userCanUpdate" v-show="!isSelectionMode" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-150 top-0 right-0" :style="{ padding: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="editClick">
|
||||
<span class="material-icons" :style="{ fontSize: sizeMultiplier + 'rem' }">edit</span>
|
||||
</div>
|
||||
|
||||
@@ -60,7 +60,8 @@
|
||||
<span class="material-icons" :class="selected ? 'text-yellow-400' : ''" :style="{ fontSize: 1.25 * sizeMultiplier + 'rem' }">{{ selected ? 'radio_button_checked' : 'radio_button_unchecked' }}</span>
|
||||
</div>
|
||||
|
||||
<div ref="moreIcon" v-show="!isSelectionMode && !recentEpisode" class="hidden md:block absolute cursor-pointer hover:text-yellow-300" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickShowMore">
|
||||
<!-- More Menu Icon -->
|
||||
<div ref="moreIcon" v-show="!isSelectionMode" class="hidden md:block absolute cursor-pointer hover:text-yellow-300 300 hover:scale-125 transform duration-150" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickShowMore">
|
||||
<span class="material-icons" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">more_vert</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -246,8 +247,11 @@ export default {
|
||||
}
|
||||
return this.title
|
||||
},
|
||||
displayAuthor() {
|
||||
displayLineTwo() {
|
||||
if (this.isPodcast) return this.author
|
||||
if (this.isAuthorBookshelfView) {
|
||||
return this.mediaMetadata.publishedYear || ''
|
||||
}
|
||||
if (this.orderBy === 'media.metadata.authorNameLF') return this.authorLF
|
||||
return this.author
|
||||
},
|
||||
@@ -255,8 +259,9 @@ export default {
|
||||
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs)
|
||||
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs)
|
||||
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt)
|
||||
if (this.orderBy === 'duration') return 'Duration: ' + this.$elapsedPrettyExtended(this.media.duration, false)
|
||||
if (this.orderBy === 'media.duration') return 'Duration: ' + this.$elapsedPrettyExtended(this.media.duration, false)
|
||||
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size)
|
||||
if (this.orderBy === 'media.numTracks') return `${this.numEpisodes} Episodes`
|
||||
return null
|
||||
},
|
||||
episodeProgress() {
|
||||
@@ -341,10 +346,23 @@ export default {
|
||||
userCanDownload() {
|
||||
return this.store.getters['user/getUserCanDownload']
|
||||
},
|
||||
userIsRoot() {
|
||||
return this.store.getters['user/getIsRoot']
|
||||
userIsAdminOrUp() {
|
||||
return this.store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
moreMenuItems() {
|
||||
if (this.recentEpisode) {
|
||||
return [
|
||||
{
|
||||
func: 'editPodcast',
|
||||
text: 'Edit Podcast'
|
||||
},
|
||||
{
|
||||
func: 'toggleFinished',
|
||||
text: `Mark as ${this.itemIsFinished ? 'Not Finished' : 'Finished'}`
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
var items = []
|
||||
if (!this.isPodcast) {
|
||||
items = [
|
||||
@@ -368,7 +386,7 @@ export default {
|
||||
text: 'Match'
|
||||
})
|
||||
}
|
||||
if (this.userIsRoot && !this.isFile) {
|
||||
if (this.userIsAdminOrUp && !this.isFile) {
|
||||
items.push({
|
||||
func: 'rescan',
|
||||
text: 'Re-Scan'
|
||||
@@ -409,8 +427,12 @@ export default {
|
||||
var constants = this.$constants || this.$nuxt.$constants
|
||||
return this.bookshelfView === constants.BookshelfView.TITLES
|
||||
},
|
||||
isAuthorBookshelfView() {
|
||||
var constants = this.$constants || this.$nuxt.$constants
|
||||
return this.bookshelfView === constants.BookshelfView.AUTHOR
|
||||
},
|
||||
titleDisplayBottomOffset() {
|
||||
if (!this.isAlternativeBookshelfView) return 0
|
||||
if (!this.isAlternativeBookshelfView && !this.isAuthorBookshelfView) return 0
|
||||
else if (!this.displaySortLine) return 3 * this.sizeMultiplier
|
||||
return 4.25 * this.sizeMultiplier
|
||||
}
|
||||
@@ -447,10 +469,14 @@ export default {
|
||||
isFinished: !this.itemIsFinished
|
||||
}
|
||||
this.isProcessingReadUpdate = true
|
||||
|
||||
var apiEndpoint = `/api/me/progress/${this.libraryItemId}`
|
||||
if (this.recentEpisode) apiEndpoint += `/${this.recentEpisode.id}`
|
||||
|
||||
var toast = this.$toast || this.$nuxt.$toast
|
||||
var axios = this.$axios || this.$nuxt.$axios
|
||||
axios
|
||||
.$patch(`/api/me/progress/${this.libraryItemId}`, updatePayload)
|
||||
.$patch(apiEndpoint, updatePayload)
|
||||
.then(() => {
|
||||
this.isProcessingReadUpdate = false
|
||||
toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
@@ -461,6 +487,9 @@ export default {
|
||||
toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
})
|
||||
},
|
||||
editPodcast() {
|
||||
this.$emit('editPodcast', this.libraryItem)
|
||||
},
|
||||
rescan() {
|
||||
this.rescanning = true
|
||||
this.$axios
|
||||
|
||||
@@ -5,20 +5,18 @@
|
||||
<covers-collection-cover ref="cover" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
<div v-show="isHovering" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 pointer-events-none">
|
||||
<!-- <div class="absolute pointer-events-auto" :style="{ top: 0.5 * sizeMultiplier + 'rem', left: 0.5 * sizeMultiplier + 'rem' }" @click.stop.prevent="toggleSelected">
|
||||
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">radio_button_unchecked</span>
|
||||
</div> -->
|
||||
<div class="absolute pointer-events-auto" :style="{ top: 0.5 * sizeMultiplier + 'rem', right: 0.5 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickEdit">
|
||||
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">edit</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div v-if="isHovering || isSelectionMode" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-40">
|
||||
</div> -->
|
||||
<div class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
|
||||
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
|
||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -28,7 +26,11 @@ export default {
|
||||
index: Number,
|
||||
width: Number,
|
||||
height: Number,
|
||||
bookCoverAspectRatio: Number
|
||||
bookCoverAspectRatio: Number,
|
||||
bookshelfView: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -58,6 +60,10 @@ export default {
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.store.state.libraries.currentLibraryId
|
||||
},
|
||||
isAlternativeBookshelfView() {
|
||||
const constants = this.$constants || this.$nuxt.$constants
|
||||
return this.bookshelfView == constants.BookshelfView.TITLES
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -13,11 +13,14 @@
|
||||
<p class="font-book" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ title }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!isCategorized" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
|
||||
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
|
||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -28,6 +31,10 @@ export default {
|
||||
width: Number,
|
||||
height: Number,
|
||||
bookCoverAspectRatio: Number,
|
||||
bookshelfView: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
isCategorized: Boolean,
|
||||
seriesMount: {
|
||||
type: Object,
|
||||
@@ -89,6 +96,10 @@ export default {
|
||||
hasValidCovers() {
|
||||
var validCovers = this.books.map((bookItem) => bookItem.media.coverPath)
|
||||
return !!validCovers.length
|
||||
},
|
||||
isAlternativeBookshelfView() {
|
||||
const constants = this.$constants || this.$nuxt.$constants
|
||||
return this.bookshelfView == constants.BookshelfView.TITLES
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -40,6 +40,10 @@ export default {
|
||||
text: 'Title',
|
||||
value: 'title'
|
||||
},
|
||||
{
|
||||
text: 'Season',
|
||||
value: 'season'
|
||||
},
|
||||
{
|
||||
text: 'Episode',
|
||||
value: 'episode'
|
||||
|
||||
@@ -52,6 +52,10 @@ export default {
|
||||
text: 'Size',
|
||||
value: 'size'
|
||||
},
|
||||
{
|
||||
text: 'Duration',
|
||||
value: 'media.duration'
|
||||
},
|
||||
{
|
||||
text: 'File Birthtime',
|
||||
value: 'birthtimeMs'
|
||||
@@ -78,6 +82,10 @@ export default {
|
||||
text: 'Size',
|
||||
value: 'size'
|
||||
},
|
||||
{
|
||||
text: '# of Episodes',
|
||||
value: 'media.numTracks'
|
||||
},
|
||||
{
|
||||
text: 'File Birthtime',
|
||||
value: 'birthtimeMs'
|
||||
|
||||
@@ -93,12 +93,13 @@ export default {
|
||||
this.show = false
|
||||
},
|
||||
clickBg(ev) {
|
||||
if (!this.show) return
|
||||
if (this.preventClickoutside) {
|
||||
this.preventClickoutside = false
|
||||
return
|
||||
}
|
||||
if (this.processing && this.persistent) return
|
||||
if (ev.srcElement.classList.contains('modal-bg')) {
|
||||
if (ev.srcElement && ev.srcElement.classList.contains('modal-bg')) {
|
||||
this.show = false
|
||||
}
|
||||
},
|
||||
|
||||
@@ -43,13 +43,13 @@
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
author: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
// props: {
|
||||
// value: Boolean,
|
||||
// author: {
|
||||
// type: Object,
|
||||
// default: () => {}
|
||||
// }
|
||||
// },
|
||||
data() {
|
||||
return {
|
||||
authorCopy: {
|
||||
@@ -73,12 +73,15 @@ export default {
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
return this.$store.state.globals.showEditAuthorModal
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
this.$store.commit('globals/setShowEditAuthorModal', val)
|
||||
}
|
||||
},
|
||||
author() {
|
||||
return this.$store.state.globals.selectedAuthor
|
||||
},
|
||||
authorId() {
|
||||
if (!this.author) return ''
|
||||
return this.author.id
|
||||
@@ -112,8 +115,10 @@ export default {
|
||||
return null
|
||||
})
|
||||
if (result) {
|
||||
if (result.updated) this.$toast.success('Author updated')
|
||||
else this.$toast.info('No updates were needed')
|
||||
if (result.updated) {
|
||||
this.$toast.success('Author updated')
|
||||
this.show = false
|
||||
} else this.$toast.info('No updates were needed')
|
||||
}
|
||||
this.processing = false
|
||||
},
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<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 relative">
|
||||
<div class="w-full h-full max-h-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative">
|
||||
<component v-if="libraryItem && show" :is="tabName" :library-item="libraryItem" :processing.sync="processing" @close="show = false" @selectTab="selectTab" />
|
||||
</div>
|
||||
</modals-modal>
|
||||
|
||||
@@ -1,32 +1,11 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
||||
<div class="w-full mb-4">
|
||||
<div v-if="chapters.length" class="w-full p-4 bg-primary">
|
||||
<p>Audiobook Chapters</p>
|
||||
<tables-chapters-table v-if="chapters.length" :library-item="libraryItem" keep-open />
|
||||
<div v-if="!chapters.length" class="py-4 text-center">
|
||||
<p class="mb-8 text-xl">No Chapters</p>
|
||||
<ui-btn v-if="userCanUpdate" :to="`/audiobook/${libraryItem.id}/chapters`">Add Chapters</ui-btn>
|
||||
</div>
|
||||
<div v-if="!chapters.length" class="flex my-4 text-center justify-center text-xl">No Chapters</div>
|
||||
<table v-else class="text-sm tracksTable">
|
||||
<tr class="font-book">
|
||||
<th class="text-left w-16"><span class="px-4">Id</span></th>
|
||||
<th class="text-left">Title</th>
|
||||
<th class="text-center">Start</th>
|
||||
<th class="text-center">End</th>
|
||||
</tr>
|
||||
<tr v-for="chapter in chapters" :key="chapter.id">
|
||||
<td class="text-left">
|
||||
<p class="px-4">{{ chapter.id }}</p>
|
||||
</td>
|
||||
<td class="font-book">
|
||||
{{ chapter.title }}
|
||||
</td>
|
||||
<td class="font-mono text-center">
|
||||
{{ $secondsToTimestamp(chapter.start) }}
|
||||
</td>
|
||||
<td class="font-mono text-center">
|
||||
{{ $secondsToTimestamp(chapter.end) }}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -48,6 +27,9 @@ export default {
|
||||
},
|
||||
chapters() {
|
||||
return this.media.chapters || []
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
}
|
||||
},
|
||||
methods: {}
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
<template>
|
||||
<div class="w-full h-full relative">
|
||||
<widgets-book-details-edit v-if="mediaType == 'book'" ref="itemDetailsEdit" :library-item="libraryItem" @submit="submitForm" />
|
||||
<widgets-podcast-details-edit v-else ref="itemDetailsEdit" :library-item="libraryItem" @submit="submitForm" />
|
||||
<div id="formWrapper" class="w-full overflow-y-auto">
|
||||
<widgets-book-details-edit v-if="mediaType == 'book'" ref="itemDetailsEdit" :library-item="libraryItem" @submit="saveAndClose" />
|
||||
<widgets-podcast-details-edit v-else ref="itemDetailsEdit" :library-item="libraryItem" @submit="saveAndClose" />
|
||||
</div>
|
||||
|
||||
<div class="absolute bottom-0 left-0 w-full py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'box-shadow-sm-up border-t border-primary border-opacity-50'">
|
||||
<div class="absolute bottom-0 left-0 w-full py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'border-t border-white border-opacity-5'">
|
||||
<div class="flex items-center px-4">
|
||||
<ui-btn v-if="userCanDelete" color="error" type="button" class="h-8" :padding-x="3" small @click.stop.prevent="removeItem">Remove</ui-btn>
|
||||
|
||||
<div class="flex-grow" />
|
||||
|
||||
<ui-tooltip v-if="mediaType == 'book'" :disabled="!!quickMatching" :text="`(Root User Only) Populate empty book details & cover with first book result from '${libraryProvider}'. Does not overwrite details.`" direction="bottom" class="mr-4">
|
||||
<ui-btn v-if="isRootUser" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">Quick Match</ui-btn>
|
||||
<ui-btn v-if="userIsAdminOrUp" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">Quick Match</ui-btn>
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip :disabled="!!libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="mr-4">
|
||||
<ui-btn v-if="isRootUser && !isFile" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn>
|
||||
<ui-btn v-if="userIsAdminOrUp && !isFile" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn>
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-btn @click="submitForm">Submit</ui-btn>
|
||||
<ui-btn @click="save" class="mx-2">Save</ui-btn>
|
||||
|
||||
<ui-btn @click="saveAndClose">Save & Close</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -52,8 +56,8 @@ export default {
|
||||
isFile() {
|
||||
return !!this.libraryItem && this.libraryItem.isFile
|
||||
},
|
||||
isRootUser() {
|
||||
return this.$store.getters['user/getIsRoot']
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
isMissing() {
|
||||
return !!this.libraryItem && !!this.libraryItem.isMissing
|
||||
@@ -142,19 +146,23 @@ export default {
|
||||
this.rescanning = false
|
||||
})
|
||||
},
|
||||
submitForm() {
|
||||
async saveAndClose() {
|
||||
const wasUpdated = await this.save()
|
||||
if (wasUpdated !== null) this.$emit('close')
|
||||
},
|
||||
async save() {
|
||||
if (this.isProcessing) {
|
||||
return
|
||||
return null
|
||||
}
|
||||
if (!this.$refs.itemDetailsEdit) {
|
||||
return
|
||||
return null
|
||||
}
|
||||
var updatedDetails = this.$refs.itemDetailsEdit.getDetails()
|
||||
if (!updatedDetails.hasChanges) {
|
||||
this.$toast.info('No changes were made')
|
||||
return
|
||||
return false
|
||||
}
|
||||
this.updateDetails(updatedDetails)
|
||||
return this.updateDetails(updatedDetails)
|
||||
},
|
||||
async updateDetails(updatedDetails) {
|
||||
this.isProcessing = true
|
||||
@@ -166,11 +174,12 @@ export default {
|
||||
if (updateResult) {
|
||||
if (updateResult.updated) {
|
||||
this.$toast.success('Item details updated')
|
||||
this.$emit('close')
|
||||
return true
|
||||
} else {
|
||||
this.$toast.info('No updates were necessary')
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
removeItem() {
|
||||
if (confirm(`Are you sure you want to remove this item?\n\n*Does not delete your files, only removes the item from audiobookshelf`)) {
|
||||
@@ -224,8 +233,8 @@ export default {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.details-form-wrapper {
|
||||
height: calc(100% - 70px);
|
||||
max-height: calc(100% - 70px);
|
||||
#formWrapper {
|
||||
height: calc(100% - 80px);
|
||||
max-height: calc(100% - 80px);
|
||||
}
|
||||
</style>
|
||||
@@ -8,13 +8,13 @@
|
||||
</div>
|
||||
<p v-if="globalWatcherDisabled" class="text-xs text-warning">*Watcher is disabled globally in server settings</p>
|
||||
</div>
|
||||
<div class="py-3">
|
||||
<div v-if="mediaType == 'book'" class="py-3">
|
||||
<div class="flex items-center">
|
||||
<ui-toggle-switch v-model="skipMatchingMediaWithAsin" @input="formUpdated" />
|
||||
<p class="pl-4 text-lg">Skip matching books that already have an ASIN</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="py-3">
|
||||
<div v-if="mediaType == 'book'" class="py-3">
|
||||
<div class="flex items-center">
|
||||
<ui-toggle-switch v-model="skipMatchingMediaWithIsbn" @input="formUpdated" />
|
||||
<p class="pl-4 text-lg">Skip matching books that already have an ISBN</p>
|
||||
@@ -37,7 +37,7 @@ export default {
|
||||
provider: null,
|
||||
disableWatcher: false,
|
||||
skipMatchingMediaWithAsin: false,
|
||||
skipMatchingMediaWithIsbn: false,
|
||||
skipMatchingMediaWithIsbn: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
||||
@@ -7,13 +7,16 @@
|
||||
</template>
|
||||
<div ref="wrapper" class="p-4 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
|
||||
<div class="flex flex-wrap">
|
||||
<div class="w-1/3 p-1">
|
||||
<div class="w-1/5 p-1">
|
||||
<ui-text-input-with-label v-model="newEpisode.season" label="Season" />
|
||||
</div>
|
||||
<div class="w-1/5 p-1">
|
||||
<ui-text-input-with-label v-model="newEpisode.episode" label="Episode" />
|
||||
</div>
|
||||
<div class="w-1/3 p-1">
|
||||
<div class="w-1/5 p-1">
|
||||
<ui-text-input-with-label v-model="newEpisode.episodeType" label="Episode Type" />
|
||||
</div>
|
||||
<div class="w-1/3 p-1">
|
||||
<div class="w-2/5 p-1">
|
||||
<ui-text-input-with-label v-model="pubDateInput" @input="updatePubDate" type="datetime-local" label="Pub Date" />
|
||||
</div>
|
||||
<div class="w-full p-1">
|
||||
@@ -39,6 +42,7 @@ export default {
|
||||
return {
|
||||
processing: false,
|
||||
newEpisode: {
|
||||
season: null,
|
||||
episode: null,
|
||||
episodeType: null,
|
||||
title: null,
|
||||
@@ -92,6 +96,7 @@ export default {
|
||||
}
|
||||
},
|
||||
init() {
|
||||
this.newEpisode.season = this.episode.season || ''
|
||||
this.newEpisode.episode = this.episode.episode || ''
|
||||
this.newEpisode.episodeType = this.episode.episodeType || ''
|
||||
this.newEpisode.title = this.episode.title || ''
|
||||
|
||||
@@ -6,18 +6,31 @@
|
||||
</div>
|
||||
</template>
|
||||
<div ref="wrapper" class="px-8 py-6 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
|
||||
<div class="w-full">
|
||||
<div v-if="currentFeedUrl" class="w-full">
|
||||
<p class="text-lg font-semibold mb-4">Podcast RSS Feed is Open</p>
|
||||
|
||||
<div class="w-full relative">
|
||||
<ui-text-input v-model="feedUrl" readonly />
|
||||
<ui-text-input v-model="currentFeedUrl" readonly />
|
||||
|
||||
<span class="material-icons absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(feedUrl)">content_copy</span>
|
||||
<span class="material-icons absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(currentFeedUrl)">content_copy</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="w-full">
|
||||
<p class="text-lg font-semibold mb-4">Open RSS Feed</p>
|
||||
|
||||
<div class="w-full relative mb-2">
|
||||
<ui-text-input-with-label v-model="newFeedSlug" label="RSS Feed Slug" />
|
||||
<p class="text-xs text-gray-400 py-0.5 px-1">Feed will be {{ demoFeedUrl }}</p>
|
||||
</div>
|
||||
|
||||
<p v-if="isHttp" class="w-full pt-2 text-warning text-xs">Warning: Most podcast apps will require the RSS feed URL is using HTTPS</p>
|
||||
<p v-if="hasEpisodesWithoutPubDate" class="w-full pt-2 text-warning text-xs">Warning: 1 or more of your episodes do not have a Pub Date. Some podcast apps require this.</p>
|
||||
</div>
|
||||
<div v-show="userIsAdminOrUp" class="flex items-center pt-6">
|
||||
<p class="text-xs text-gray-300">Note: RSS feed URLs are not authenticated</p>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn color="error" small @click="closeFeed">Close RSS Feed</ui-btn>
|
||||
<ui-btn v-if="currentFeedUrl" color="error" small @click="closeFeed">Close RSS Feed</ui-btn>
|
||||
<ui-btn v-else color="success" small @click="openFeed">Open RSS Feed</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
@@ -35,7 +48,9 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
processing: false
|
||||
processing: false,
|
||||
newFeedSlug: null,
|
||||
currentFeedUrl: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -57,6 +72,9 @@ export default {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
libraryItemId() {
|
||||
return this.libraryItem.id
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem.media || {}
|
||||
},
|
||||
@@ -68,9 +86,57 @@ export default {
|
||||
},
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
demoFeedUrl() {
|
||||
return `${window.origin}/feed/${this.newFeedSlug}`
|
||||
},
|
||||
isHttp() {
|
||||
return window.origin.startsWith('http://')
|
||||
},
|
||||
episodes() {
|
||||
return this.media.episodes || []
|
||||
},
|
||||
hasEpisodesWithoutPubDate() {
|
||||
return this.episodes.some((ep) => !ep.pubDate)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
openFeed() {
|
||||
if (!this.newFeedSlug) {
|
||||
this.$toast.error('Must set a feed slug')
|
||||
return
|
||||
}
|
||||
|
||||
var sanitized = this.$sanitizeSlug(this.newFeedSlug)
|
||||
if (this.newFeedSlug !== sanitized) {
|
||||
this.newFeedSlug = sanitized
|
||||
this.$toast.warning('Slug had to be modified - Run again')
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
serverAddress: window.origin,
|
||||
slug: this.newFeedSlug
|
||||
}
|
||||
if (this.$isDev) payload.serverAddress = 'http://localhost:3333'
|
||||
|
||||
console.log('Payload', payload)
|
||||
this.$axios
|
||||
.$post(`/api/podcasts/${this.libraryItemId}/open-feed`, payload)
|
||||
.then((data) => {
|
||||
if (data.success) {
|
||||
console.log('Opened RSS Feed', data)
|
||||
this.currentFeedUrl = data.feedUrl
|
||||
} else {
|
||||
const errorMsg = data.error || 'Unknown error'
|
||||
this.$toast.error(errorMsg)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to open RSS Feed', error)
|
||||
this.$toast.error()
|
||||
})
|
||||
},
|
||||
copyToClipboard(str) {
|
||||
this.$copyToClipboard(str, this)
|
||||
},
|
||||
@@ -89,7 +155,11 @@ export default {
|
||||
this.$toast.error()
|
||||
})
|
||||
},
|
||||
init() {}
|
||||
init() {
|
||||
if (!this.libraryItem) return
|
||||
this.newFeedSlug = this.libraryItem.id
|
||||
this.currentFeedUrl = this.feedUrl
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
|
||||
273
client/components/stats/Heatmap.vue
Normal file
273
client/components/stats/Heatmap.vue
Normal file
@@ -0,0 +1,273 @@
|
||||
<template>
|
||||
<div id="heatmap" class="w-full">
|
||||
<div class="mx-auto" :style="{ height: innerHeight + 160 + 'px', width: innerWidth + 52 + 'px' }" style="background-color: rgba(13, 17, 23, 0)">
|
||||
<p class="mb-2 px-1 text-sm text-gray-200">{{ Object.values(daysListening).length }} listening sessions in the last year</p>
|
||||
<div class="border border-opacity-25 rounded py-2 w-full" style="background-color: #232323" :style="{ height: innerHeight + 80 + 'px' }">
|
||||
<div :style="{ width: innerWidth + 'px', height: innerHeight + 'px' }" class="ml-10 mt-5 absolute" @mouseover="mouseover" @mouseout="mouseout">
|
||||
<div v-for="dayLabel in dayLabels" :key="dayLabel.label" :style="dayLabel.style" class="absolute top-0 left-0 text-gray-300">{{ dayLabel.label }}</div>
|
||||
|
||||
<div v-for="monthLabel in monthLabels" :key="monthLabel.id" :style="monthLabel.style" class="absolute top-0 left-0 text-gray-300">{{ monthLabel.label }}</div>
|
||||
|
||||
<div v-for="(block, index) in data" :key="block.dateString" :style="block.style" :data-index="index" class="absolute top-0 left-0 h-2.5 w-2.5 rounded-sm" />
|
||||
|
||||
<div class="flex py-2 px-4" :style="{ marginTop: innerHeight + 'px' }">
|
||||
<div class="flex-grow" />
|
||||
<p style="font-size: 10px; line-height: 10px" class="text-gray-400 px-1">Less</p>
|
||||
<div v-for="block in legendBlocks" :key="block.id" :style="block.style" class="h-2.5 w-2.5 rounded-sm" style="margin-left: 1.5px; margin-right: 1.5px" />
|
||||
<p style="font-size: 10px; line-height: 10px" class="text-gray-400 px-1">More</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
daysListening: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
contentWidth: 0,
|
||||
maxInnerWidth: 0,
|
||||
innerHeight: 13 * 7,
|
||||
blockWidth: 13,
|
||||
data: [],
|
||||
monthLabels: [],
|
||||
tooltipEl: null,
|
||||
tooltipTextEl: null,
|
||||
tooltipArrowEl: null,
|
||||
showingTooltipIndex: -1,
|
||||
outlineColors: ['rgba(27, 31, 35, 0.06)', 'rgba(255,255,255,0.03)'],
|
||||
bgColors: ['rgb(45,45,45)', 'rgb(14, 68, 41)', 'rgb(0, 109, 50)', 'rgb(38, 166, 65)', 'rgb(57, 211, 83)']
|
||||
// GH Colors
|
||||
// outlineColors: ['rgba(27, 31, 35, 0.06)', 'rgba(255,255,255,0.05)'],
|
||||
// bgColors: ['rgb(22, 27, 34)', 'rgb(14, 68, 41)', 'rgb(0, 109, 50)', 'rgb(38, 166, 65)', 'rgb(57, 211, 83)']
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
weeksToShow() {
|
||||
return Math.min(52, Math.floor(this.maxInnerWidth / this.blockWidth) - 1)
|
||||
},
|
||||
innerWidth() {
|
||||
return (this.weeksToShow + 1) * 13
|
||||
},
|
||||
daysToShow() {
|
||||
return this.weeksToShow * 7 + this.dayOfWeekToday
|
||||
},
|
||||
dayOfWeekToday() {
|
||||
return new Date().getDay()
|
||||
},
|
||||
firstWeekStart() {
|
||||
return this.$addDaysToToday(-this.daysToShow)
|
||||
},
|
||||
dayLabels() {
|
||||
return [
|
||||
{
|
||||
label: 'Mon',
|
||||
style: {
|
||||
transform: `translate(${-25}px, ${13}px)`,
|
||||
lineHeight: '10px',
|
||||
fontSize: '10px'
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Wed',
|
||||
style: {
|
||||
transform: `translate(${-25}px, ${13 * 3}px)`,
|
||||
lineHeight: '10px',
|
||||
fontSize: '10px'
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Fri',
|
||||
style: {
|
||||
transform: `translate(${-25}px, ${13 * 5}px)`,
|
||||
lineHeight: '10px',
|
||||
fontSize: '10px'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
legendBlocks() {
|
||||
return [
|
||||
{
|
||||
id: 'legend-0',
|
||||
style: `background-color:${this.bgColors[0]};outline:1px solid ${this.outlineColors[0]};outline-offset:-1px;`
|
||||
},
|
||||
{
|
||||
id: 'legend-1',
|
||||
style: `background-color:${this.bgColors[1]};outline:1px solid ${this.outlineColors[1]};outline-offset:-1px;`
|
||||
},
|
||||
{
|
||||
id: 'legend-2',
|
||||
style: `background-color:${this.bgColors[2]};outline:1px solid ${this.outlineColors[1]};outline-offset:-1px;`
|
||||
},
|
||||
{
|
||||
id: 'legend-3',
|
||||
style: `background-color:${this.bgColors[3]};outline:1px solid ${this.outlineColors[1]};outline-offset:-1px;`
|
||||
},
|
||||
{
|
||||
id: 'legend-4',
|
||||
style: `background-color:${this.bgColors[4]};outline:1px solid ${this.outlineColors[1]};outline-offset:-1px;`
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
destroyTooltip() {
|
||||
if (this.tooltipEl) this.tooltipEl.remove()
|
||||
this.tooltipEl = null
|
||||
this.showingTooltipIndex = -1
|
||||
},
|
||||
createTooltip() {
|
||||
const tooltip = document.createElement('div')
|
||||
tooltip.className = 'absolute top-0 left-0 rounded bg-gray-500 text-white p-2 text-white max-w-xs pointer-events-none'
|
||||
tooltip.style.display = 'none'
|
||||
tooltip.id = 'heatmap-tooltip'
|
||||
|
||||
const tooltipText = document.createElement('p')
|
||||
tooltipText.innerText = 'Tooltip'
|
||||
tooltipText.style.fontSize = '10px'
|
||||
tooltipText.style.lineHeight = '10px'
|
||||
tooltip.appendChild(tooltipText)
|
||||
|
||||
const tooltipArrow = document.createElement('div')
|
||||
tooltipArrow.className = 'text-gray-500 arrow-down-small absolute -bottom-1 left-0 right-0 mx-auto'
|
||||
tooltip.appendChild(tooltipArrow)
|
||||
|
||||
this.tooltipEl = tooltip
|
||||
this.tooltipTextEl = tooltipText
|
||||
this.tooltipArrowEl = tooltipArrow
|
||||
|
||||
document.body.appendChild(this.tooltipEl)
|
||||
},
|
||||
showTooltip(index, block, rect) {
|
||||
if (this.tooltipEl && this.showingTooltipIndex === index) return
|
||||
if (!this.tooltipEl) {
|
||||
this.createTooltip()
|
||||
}
|
||||
|
||||
this.showingTooltipIndex = index
|
||||
this.tooltipEl.style.display = 'block'
|
||||
this.tooltipTextEl.innerHTML = block.value ? `<strong>${this.$elapsedPretty(block.value, true)} listening</strong> on ${block.datePretty}` : `No listening sessions on ${block.datePretty}`
|
||||
|
||||
const calculateRect = this.tooltipEl.getBoundingClientRect()
|
||||
|
||||
const w = calculateRect.width / 2
|
||||
var left = rect.x - w
|
||||
var offsetX = 0
|
||||
if (left < 0) {
|
||||
offsetX = Math.abs(left)
|
||||
left = 0
|
||||
} else if (rect.x + w > window.innerWidth - 10) {
|
||||
offsetX = window.innerWidth - 10 - (rect.x + w)
|
||||
left += offsetX
|
||||
}
|
||||
|
||||
this.tooltipEl.style.transform = `translate(${left}px, ${rect.y - 32}px)`
|
||||
this.tooltipArrowEl.style.transform = `translate(${5 - offsetX}px, 0px)`
|
||||
},
|
||||
hideTooltip() {
|
||||
if (this.showingTooltipIndex >= 0 && this.tooltipEl) {
|
||||
this.tooltipEl.style.display = 'none'
|
||||
this.showingTooltipIndex = -1
|
||||
}
|
||||
},
|
||||
mouseover(e) {
|
||||
if (isNaN(e.target.dataset.index)) {
|
||||
this.hideTooltip()
|
||||
return
|
||||
}
|
||||
var block = this.data[e.target.dataset.index]
|
||||
var rect = e.target.getBoundingClientRect()
|
||||
this.showTooltip(e.target.dataset.index, block, rect)
|
||||
},
|
||||
mouseout(e) {
|
||||
this.hideTooltip()
|
||||
},
|
||||
buildData() {
|
||||
this.data = []
|
||||
|
||||
var maxValue = 0
|
||||
var minValue = 0
|
||||
Object.values(this.daysListening).forEach((val) => {
|
||||
if (val > maxValue) maxValue = val
|
||||
if (!minValue || val < minValue) minValue = val
|
||||
})
|
||||
const range = maxValue - minValue + 0.01
|
||||
|
||||
for (let i = 0; i < this.daysToShow + 1; i++) {
|
||||
const col = Math.floor(i / 7)
|
||||
const row = i % 7
|
||||
|
||||
const date = i === 0 ? this.firstWeekStart : this.$addDaysToDate(this.firstWeekStart, i)
|
||||
const dateString = this.$formatJsDate(date, 'yyyy-MM-dd')
|
||||
const datePretty = this.$formatJsDate(date, 'MMM d, yyyy')
|
||||
const monthString = this.$formatJsDate(date, 'MMM')
|
||||
const value = this.daysListening[dateString] || 0
|
||||
const x = col * 13
|
||||
const y = row * 13
|
||||
|
||||
var bgColor = this.bgColors[0]
|
||||
var outlineColor = this.outlineColors[0]
|
||||
if (value) {
|
||||
outlineColor = this.outlineColors[1]
|
||||
var percentOfAvg = (value - minValue) / range
|
||||
var bgIndex = Math.floor(percentOfAvg * 4) + 1
|
||||
bgColor = this.bgColors[bgIndex] || 'red'
|
||||
}
|
||||
|
||||
this.data.push({
|
||||
date,
|
||||
dateString,
|
||||
datePretty,
|
||||
monthString,
|
||||
dayOfMonth: Number(dateString.split('-').pop()),
|
||||
yearString: dateString.split('-').shift(),
|
||||
value,
|
||||
col,
|
||||
row,
|
||||
style: `transform:translate(${x}px,${y}px);background-color:${bgColor};outline:1px solid ${outlineColor};outline-offset:-1px;`
|
||||
})
|
||||
}
|
||||
console.log('Data', this.data)
|
||||
|
||||
this.monthLabels = []
|
||||
var lastMonth = null
|
||||
for (let i = 0; i < this.data.length; i++) {
|
||||
if (this.data[i].monthString !== lastMonth) {
|
||||
const weekOfMonth = Math.floor(this.data[i].dayOfMonth / 7)
|
||||
if (weekOfMonth <= 2) {
|
||||
this.monthLabels.push({
|
||||
id: this.data[i].dateString + '-ml',
|
||||
label: this.data[i].monthString,
|
||||
style: {
|
||||
transform: `translate(${this.data[i].col * 13}px, -15px)`,
|
||||
lineHeight: '10px',
|
||||
fontSize: '10px'
|
||||
}
|
||||
})
|
||||
lastMonth = this.data[i].monthString
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
init() {
|
||||
const heatmapEl = document.getElementById('heatmap')
|
||||
this.contentWidth = heatmapEl.clientWidth
|
||||
this.maxInnerWidth = this.contentWidth - 52
|
||||
this.buildData()
|
||||
}
|
||||
},
|
||||
updated() {},
|
||||
mounted() {
|
||||
this.init()
|
||||
},
|
||||
beforeDestroy() {}
|
||||
}
|
||||
</script>
|
||||
74
client/components/tables/ChaptersTable.vue
Normal file
74
client/components/tables/ChaptersTable.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div class="w-full my-2">
|
||||
<div class="w-full bg-primary px-6 py-2 flex items-center cursor-pointer" @click.stop="clickBar">
|
||||
<p class="pr-4">Chapters</p>
|
||||
<span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ chapters.length }}</span>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn v-if="userCanUpdate" small :to="`/audiobook/${libraryItemId}/chapters`" color="primary" class="mr-2">Edit Chapters</ui-btn>
|
||||
<div v-if="!keepOpen" class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="expanded ? 'transform rotate-180' : ''">
|
||||
<span class="material-icons text-4xl">expand_more</span>
|
||||
</div>
|
||||
</div>
|
||||
<transition name="slide">
|
||||
<table class="text-sm tracksTable" v-show="expanded || keepOpen">
|
||||
<tr class="font-book">
|
||||
<th class="text-left w-16"><span class="px-4">Id</span></th>
|
||||
<th class="text-left">Title</th>
|
||||
<th class="text-center">Start</th>
|
||||
<th class="text-center">End</th>
|
||||
</tr>
|
||||
<tr v-for="chapter in chapters" :key="chapter.id">
|
||||
<td class="text-left">
|
||||
<p class="px-4">{{ chapter.id }}</p>
|
||||
</td>
|
||||
<td class="font-book">
|
||||
{{ chapter.title }}
|
||||
</td>
|
||||
<td class="font-mono text-center">
|
||||
{{ $secondsToTimestamp(chapter.start) }}
|
||||
</td>
|
||||
<td class="font-mono text-center">
|
||||
{{ $secondsToTimestamp(chapter.end) }}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
keepOpen: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
expanded: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
libraryItemId() {
|
||||
return this.libraryItem.id
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||
},
|
||||
chapters() {
|
||||
return this.media.chapters || []
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickBar() {
|
||||
this.expanded = !this.expanded
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -45,8 +45,8 @@
|
||||
</td>
|
||||
<td class="py-0">
|
||||
<div class="w-full flex justify-center">
|
||||
<!-- <span class="material-icons hover:text-gray-400 cursor-pointer text-base pr-2" @click.stop="editUser(user)">edit</span> -->
|
||||
<div class="h-8 w-8 flex items-center justify-center text-white text-opacity-50 hover:text-opacity-100 cursor-pointer" @click.stop="editUser(user)">
|
||||
<!-- Dont show edit for non-root users -->
|
||||
<div v-if="user.type !== 'root' || userIsRoot" class="h-8 w-8 flex items-center justify-center text-white text-opacity-50 hover:text-opacity-100 cursor-pointer" @click.stop="editUser(user)">
|
||||
<span class="material-icons text-base">edit</span>
|
||||
</div>
|
||||
<div v-show="user.type !== 'root'" class="h-8 w-8 flex items-center justify-center text-white text-opacity-50 hover:text-error cursor-pointer" @click.stop="deleteUserClick(user)">
|
||||
@@ -76,6 +76,9 @@ export default {
|
||||
currentUserId() {
|
||||
return this.$store.state.user.user.id
|
||||
},
|
||||
userIsRoot() {
|
||||
return this.$store.getters['user/getIsRoot']
|
||||
},
|
||||
usersOnline() {
|
||||
var usermap = {}
|
||||
this.$store.state.users.users.forEach((u) => (usermap[u.id] = { online: true, session: u.session }))
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<ui-tooltip :text="userIsFinished ? 'Mark as Not Finished' : 'Mark as Finished'" direction="top">
|
||||
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
|
||||
</ui-tooltip>
|
||||
<p v-if="episode.season" class="px-4 text-sm text-gray-300">Season #{{ episode.season }}</p>
|
||||
<p v-if="episode.episode" class="px-4 text-sm text-gray-300">Episode #{{ episode.episode }}</p>
|
||||
<p v-if="publishedAt" class="px-4 text-sm text-gray-300">Published {{ $formatDate(publishedAt, 'MMM do, yyyy') }}</p>
|
||||
</div>
|
||||
|
||||
112
client/components/widgets/AuthorsSlider.vue
Normal file
112
client/components/widgets/AuthorsSlider.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div class="flex items-center py-3">
|
||||
<slot />
|
||||
<div class="flex-grow" />
|
||||
<button v-if="isScrollable" class="w-8 h-8 mx-1 flex items-center justify-center rounded-full" :class="canScrollLeft ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollLeft">
|
||||
<span class="material-icons text-2xl">chevron_left</span>
|
||||
</button>
|
||||
<button v-if="isScrollable" class="w-8 h-8 mx-1 flex items-center justify-center rounded-full" :class="canScrollRight ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollRight">
|
||||
<span class="material-icons text-2xl">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll -mx-2" style="scroll-behavior: smooth" @scroll="scrolled">
|
||||
<div class="flex" :style="{ height: height + 'px' }">
|
||||
<template v-for="(item, index) in items">
|
||||
<cards-author-card :key="item.id" :ref="`slider-item-${item.id}`" :index="index" :author="item" :height="cardHeight" :width="cardWidth" class="relative mx-2" @edit="editAuthor" @hook:updated="setScrollVars" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
height: {
|
||||
type: Number,
|
||||
default: 192
|
||||
},
|
||||
bookshelfView: {
|
||||
type: Number,
|
||||
default: 1
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isScrollable: false,
|
||||
canScrollLeft: false,
|
||||
canScrollRight: false,
|
||||
clientWidth: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
},
|
||||
cardScaleMulitiplier() {
|
||||
return this.height / 192
|
||||
},
|
||||
cardHeight() {
|
||||
return this.height
|
||||
},
|
||||
cardWidth() {
|
||||
return this.cardHeight / this.bookCoverAspectRatio / 1.25
|
||||
},
|
||||
booksPerPage() {
|
||||
return Math.floor(this.clientWidth / (this.cardWidth + 16))
|
||||
},
|
||||
isSelectionMode() {
|
||||
return this.$store.getters['getNumLibraryItemsSelected'] > 0
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
editAuthor(author) {
|
||||
this.$store.commit('globals/showEditAuthorModal', author)
|
||||
},
|
||||
scrolled() {
|
||||
this.setScrollVars()
|
||||
},
|
||||
scrollRight() {
|
||||
if (!this.canScrollRight) return
|
||||
const slider = this.$refs.slider
|
||||
if (!slider) return
|
||||
const scrollAmount = this.booksPerPage * this.cardWidth
|
||||
const maxScrollLeft = slider.scrollWidth - slider.clientWidth
|
||||
|
||||
const newScrollLeft = Math.min(maxScrollLeft, slider.scrollLeft + scrollAmount)
|
||||
slider.scrollLeft = newScrollLeft
|
||||
},
|
||||
scrollLeft() {
|
||||
if (!this.canScrollLeft) return
|
||||
const slider = this.$refs.slider
|
||||
if (!slider) return
|
||||
|
||||
const scrollAmount = this.booksPerPage * this.cardWidth
|
||||
|
||||
const newScrollLeft = Math.max(0, slider.scrollLeft - scrollAmount)
|
||||
slider.scrollLeft = newScrollLeft
|
||||
},
|
||||
setScrollVars() {
|
||||
const slider = this.$refs.slider
|
||||
if (!slider) return
|
||||
const { scrollLeft, scrollWidth, clientWidth } = slider
|
||||
const scrollPercent = (scrollLeft + clientWidth) / scrollWidth
|
||||
|
||||
this.clientWidth = clientWidth
|
||||
this.isScrollable = scrollWidth > clientWidth
|
||||
this.canScrollRight = scrollPercent < 1
|
||||
this.canScrollLeft = scrollLeft > 0
|
||||
}
|
||||
},
|
||||
updated() {
|
||||
this.setScrollVars()
|
||||
},
|
||||
mounted() {},
|
||||
beforeDestroy() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,66 +1,64 @@
|
||||
<template>
|
||||
<div class="w-full h-full relative">
|
||||
<form class="w-full h-full" @submit.prevent="submitForm">
|
||||
<div id="formWrapper" class="px-4 py-6 details-form-wrapper w-full overflow-hidden overflow-y-auto">
|
||||
<div class="flex -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<ui-text-input-with-label ref="titleInput" v-model="details.title" label="Title" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-text-input-with-label ref="subtitleInput" v-model="details.subtitle" label="Subtitle" />
|
||||
</div>
|
||||
<form class="w-full h-full px-4 py-6" @submit.prevent="submitForm">
|
||||
<div class="flex -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<ui-text-input-with-label ref="titleInput" v-model="details.title" label="Title" />
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-3/4 px-1">
|
||||
<!-- Authors filter only contains authors in this library, use query input to query all authors -->
|
||||
<ui-multi-select-query-input ref="authorsSelect" v-model="details.authors" label="Authors" endpoint="authors/search" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-text-input-with-label ref="publishYearInput" v-model="details.publishedYear" type="number" label="Publish Year" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-text-input-with-label ref="subtitleInput" v-model="details.subtitle" label="Subtitle" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="flex-grow px-1">
|
||||
<ui-multi-select-query-input ref="seriesSelect" v-model="seriesItems" text-key="displayName" label="Series" readonly show-edit @edit="editSeriesItem" @add="addNewSeries" />
|
||||
</div>
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-3/4 px-1">
|
||||
<!-- Authors filter only contains authors in this library, use query input to query all authors -->
|
||||
<ui-multi-select-query-input ref="authorsSelect" v-model="details.authors" label="Authors" endpoint="authors/search" />
|
||||
</div>
|
||||
|
||||
<ui-textarea-with-label ref="descriptionInput" v-model="details.description" :rows="3" label="Description" class="mt-2" />
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<ui-multi-select ref="genresSelect" v-model="details.genres" label="Genres" :items="genres" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-multi-select ref="tagsSelect" v-model="newTags" label="Tags" :items="tags" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-text-input-with-label ref="publishYearInput" v-model="details.publishedYear" type="number" label="Publish Year" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<ui-multi-select ref="narratorsSelect" v-model="details.narrators" label="Narrators" :items="narrators" />
|
||||
</div>
|
||||
<div class="w-1/4 px-1">
|
||||
<ui-text-input-with-label ref="isbnInput" v-model="details.isbn" label="ISBN" />
|
||||
</div>
|
||||
<div class="w-1/4 px-1">
|
||||
<ui-text-input-with-label ref="asinInput" v-model="details.asin" label="ASIN" />
|
||||
</div>
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="flex-grow px-1">
|
||||
<ui-multi-select-query-input ref="seriesSelect" v-model="seriesItems" text-key="displayName" label="Series" readonly show-edit @edit="editSeriesItem" @add="addNewSeries" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<ui-text-input-with-label ref="publisherInput" v-model="details.publisher" label="Publisher" />
|
||||
</div>
|
||||
<div class="w-1/4 px-1">
|
||||
<ui-text-input-with-label ref="languageInput" v-model="details.language" label="Language" />
|
||||
</div>
|
||||
<div class="flex-grow px-1 pt-6">
|
||||
<div class="flex justify-center">
|
||||
<ui-checkbox v-model="details.explicit" label="Explicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||
</div>
|
||||
<ui-textarea-with-label ref="descriptionInput" v-model="details.description" :rows="3" label="Description" class="mt-2" />
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<ui-multi-select ref="genresSelect" v-model="details.genres" label="Genres" :items="genres" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-multi-select ref="tagsSelect" v-model="newTags" label="Tags" :items="tags" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<ui-multi-select ref="narratorsSelect" v-model="details.narrators" label="Narrators" :items="narrators" />
|
||||
</div>
|
||||
<div class="w-1/4 px-1">
|
||||
<ui-text-input-with-label ref="isbnInput" v-model="details.isbn" label="ISBN" />
|
||||
</div>
|
||||
<div class="w-1/4 px-1">
|
||||
<ui-text-input-with-label ref="asinInput" v-model="details.asin" label="ASIN" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<ui-text-input-with-label ref="publisherInput" v-model="details.publisher" label="Publisher" />
|
||||
</div>
|
||||
<div class="w-1/4 px-1">
|
||||
<ui-text-input-with-label ref="languageInput" v-model="details.language" label="Language" />
|
||||
</div>
|
||||
<div class="flex-grow px-1 pt-6">
|
||||
<div class="flex justify-center">
|
||||
<ui-checkbox v-model="details.explicit" label="Explicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
148
client/components/widgets/EpisodeSlider.vue
Normal file
148
client/components/widgets/EpisodeSlider.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div class="flex items-center py-3">
|
||||
<slot />
|
||||
<div class="flex-grow" />
|
||||
<button v-if="isScrollable" class="w-8 h-8 mx-1 flex items-center justify-center rounded-full" :class="canScrollLeft ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollLeft">
|
||||
<span class="material-icons text-2xl">chevron_left</span>
|
||||
</button>
|
||||
<button v-if="isScrollable" class="w-8 h-8 mx-1 flex items-center justify-center rounded-full" :class="canScrollRight ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollRight">
|
||||
<span class="material-icons text-2xl">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll -mx-2" style="scroll-behavior: smooth" @scroll="scrolled">
|
||||
<div class="flex" :style="{ height: height + 'px' }">
|
||||
<template v-for="(item, index) in items">
|
||||
<cards-lazy-book-card :key="item.id" :ref="`slider-episode-${item.recentEpisode.id}`" :index="index" :book-mount="item" :height="cardHeight" :width="cardWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" :bookshelf-view="bookshelfView" class="relative mx-2" @edit="editEpisode" @editPodcast="editPodcast" @select="selectItem" @hook:updated="setScrollVars" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
height: {
|
||||
type: Number,
|
||||
default: 192
|
||||
},
|
||||
bookshelfView: {
|
||||
type: Number,
|
||||
default: 1
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isScrollable: false,
|
||||
canScrollLeft: false,
|
||||
canScrollRight: false,
|
||||
clientWidth: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
},
|
||||
cardScaleMulitiplier() {
|
||||
return this.height / 192
|
||||
},
|
||||
cardHeight() {
|
||||
return this.height - 40 * this.cardScaleMulitiplier
|
||||
},
|
||||
cardWidth() {
|
||||
return this.cardHeight / this.bookCoverAspectRatio
|
||||
},
|
||||
booksPerPage() {
|
||||
return Math.floor(this.clientWidth / (this.cardWidth + 16))
|
||||
},
|
||||
isSelectionMode() {
|
||||
return this.$store.getters['getNumLibraryItemsSelected'] > 0
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clearSelectedEntities() {
|
||||
this.updateSelectionMode(false)
|
||||
},
|
||||
editEpisode({ libraryItem, episode }) {
|
||||
this.$store.commit('setSelectedLibraryItem', libraryItem)
|
||||
this.$store.commit('globals/setSelectedEpisode', episode)
|
||||
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
|
||||
},
|
||||
editPodcast(libraryItem) {
|
||||
var itemIds = this.items.map((e) => e.id)
|
||||
this.$store.commit('setBookshelfBookIds', itemIds)
|
||||
this.$store.commit('showEditModal', libraryItem)
|
||||
},
|
||||
selectItem(libraryItem) {
|
||||
this.$store.commit('toggleLibraryItemSelected', libraryItem.id)
|
||||
this.$nextTick(() => {
|
||||
this.$eventBus.$emit('item-selected', libraryItem)
|
||||
})
|
||||
},
|
||||
itemSelectedEvt() {
|
||||
this.updateSelectionMode(this.isSelectionMode)
|
||||
},
|
||||
updateSelectionMode(val) {
|
||||
var selectedLibraryItems = this.$store.state.selectedLibraryItems
|
||||
|
||||
this.items.forEach((ent) => {
|
||||
var component = this.$refs[`slider-episode-${ent.recentEpisode.id}`]
|
||||
if (!component || !component.length) return
|
||||
component = component[0]
|
||||
component.setSelectionMode(val)
|
||||
component.selected = selectedLibraryItems.includes(ent.id)
|
||||
})
|
||||
},
|
||||
scrolled() {
|
||||
this.setScrollVars()
|
||||
},
|
||||
scrollRight() {
|
||||
if (!this.canScrollRight) return
|
||||
const slider = this.$refs.slider
|
||||
if (!slider) return
|
||||
const scrollAmount = this.booksPerPage * this.cardWidth
|
||||
const maxScrollLeft = slider.scrollWidth - slider.clientWidth
|
||||
|
||||
const newScrollLeft = Math.min(maxScrollLeft, slider.scrollLeft + scrollAmount)
|
||||
slider.scrollLeft = newScrollLeft
|
||||
},
|
||||
scrollLeft() {
|
||||
if (!this.canScrollLeft) return
|
||||
const slider = this.$refs.slider
|
||||
if (!slider) return
|
||||
|
||||
const scrollAmount = this.booksPerPage * this.cardWidth
|
||||
|
||||
const newScrollLeft = Math.max(0, slider.scrollLeft - scrollAmount)
|
||||
slider.scrollLeft = newScrollLeft
|
||||
},
|
||||
setScrollVars() {
|
||||
const slider = this.$refs.slider
|
||||
if (!slider) return
|
||||
const { scrollLeft, scrollWidth, clientWidth } = slider
|
||||
const scrollPercent = (scrollLeft + clientWidth) / scrollWidth
|
||||
|
||||
this.clientWidth = clientWidth
|
||||
this.isScrollable = scrollWidth > clientWidth
|
||||
this.canScrollRight = scrollPercent < 1
|
||||
this.canScrollLeft = scrollLeft > 0
|
||||
}
|
||||
},
|
||||
updated() {
|
||||
this.setScrollVars()
|
||||
},
|
||||
mounted() {
|
||||
this.$eventBus.$on('bookshelf-clear-selection', this.clearSelectedEntities)
|
||||
this.$eventBus.$on('item-selected', this.itemSelectedEvt)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$eventBus.$off('bookshelf-clear-selection', this.clearSelectedEntities)
|
||||
this.$eventBus.$off('item-selected', this.itemSelectedEvt)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
143
client/components/widgets/ItemSlider.vue
Normal file
143
client/components/widgets/ItemSlider.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div class="flex items-center py-3">
|
||||
<slot />
|
||||
<div class="flex-grow" />
|
||||
<button v-if="isScrollable" class="w-8 h-8 mx-1 flex items-center justify-center rounded-full" :class="canScrollLeft ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollLeft">
|
||||
<span class="material-icons text-2xl">chevron_left</span>
|
||||
</button>
|
||||
<button v-if="isScrollable" class="w-8 h-8 mx-1 flex items-center justify-center rounded-full" :class="canScrollRight ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollRight">
|
||||
<span class="material-icons text-2xl">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll -mx-2" style="scroll-behavior: smooth" @scroll="scrolled">
|
||||
<div class="flex" :style="{ height: height + 'px' }">
|
||||
<template v-for="(item, index) in items">
|
||||
<cards-lazy-book-card :key="item.id" :ref="`slider-item-${item.id}`" :index="index" :book-mount="item" :height="cardHeight" :width="cardWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" :bookshelf-view="bookshelfView" class="relative mx-2" @edit="editItem" @select="selectItem" @hook:updated="setScrollVars" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
height: {
|
||||
type: Number,
|
||||
default: 192
|
||||
},
|
||||
bookshelfView: {
|
||||
type: Number,
|
||||
default: 1
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isScrollable: false,
|
||||
canScrollLeft: false,
|
||||
canScrollRight: false,
|
||||
clientWidth: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
},
|
||||
cardScaleMulitiplier() {
|
||||
return this.height / 192
|
||||
},
|
||||
cardHeight() {
|
||||
return this.height - 40 * this.cardScaleMulitiplier
|
||||
},
|
||||
cardWidth() {
|
||||
return this.cardHeight / this.bookCoverAspectRatio
|
||||
},
|
||||
booksPerPage() {
|
||||
return Math.floor(this.clientWidth / (this.cardWidth + 16))
|
||||
},
|
||||
isSelectionMode() {
|
||||
return this.$store.getters['getNumLibraryItemsSelected'] > 0
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clearSelectedEntities() {
|
||||
this.updateSelectionMode(false)
|
||||
},
|
||||
editItem(libraryItem) {
|
||||
var itemIds = this.items.map((e) => e.id)
|
||||
this.$store.commit('setBookshelfBookIds', itemIds)
|
||||
this.$store.commit('showEditModal', libraryItem)
|
||||
},
|
||||
selectItem(libraryItem) {
|
||||
this.$store.commit('toggleLibraryItemSelected', libraryItem.id)
|
||||
this.$nextTick(() => {
|
||||
this.$eventBus.$emit('item-selected', libraryItem)
|
||||
})
|
||||
},
|
||||
itemSelectedEvt() {
|
||||
this.updateSelectionMode(this.isSelectionMode)
|
||||
},
|
||||
updateSelectionMode(val) {
|
||||
var selectedLibraryItems = this.$store.state.selectedLibraryItems
|
||||
|
||||
this.items.forEach((item) => {
|
||||
var component = this.$refs[`slider-item-${item.id}`]
|
||||
if (!component || !component.length) return
|
||||
component = component[0]
|
||||
component.setSelectionMode(val)
|
||||
component.selected = selectedLibraryItems.includes(item.id)
|
||||
})
|
||||
},
|
||||
scrolled() {
|
||||
this.setScrollVars()
|
||||
},
|
||||
scrollRight() {
|
||||
if (!this.canScrollRight) return
|
||||
const slider = this.$refs.slider
|
||||
if (!slider) return
|
||||
const scrollAmount = this.booksPerPage * this.cardWidth
|
||||
const maxScrollLeft = slider.scrollWidth - slider.clientWidth
|
||||
|
||||
const newScrollLeft = Math.min(maxScrollLeft, slider.scrollLeft + scrollAmount)
|
||||
slider.scrollLeft = newScrollLeft
|
||||
},
|
||||
scrollLeft() {
|
||||
if (!this.canScrollLeft) return
|
||||
const slider = this.$refs.slider
|
||||
if (!slider) return
|
||||
|
||||
const scrollAmount = this.booksPerPage * this.cardWidth
|
||||
|
||||
const newScrollLeft = Math.max(0, slider.scrollLeft - scrollAmount)
|
||||
slider.scrollLeft = newScrollLeft
|
||||
},
|
||||
setScrollVars() {
|
||||
const slider = this.$refs.slider
|
||||
if (!slider) return
|
||||
const { scrollLeft, scrollWidth, clientWidth } = slider
|
||||
const scrollPercent = (scrollLeft + clientWidth) / scrollWidth
|
||||
|
||||
this.clientWidth = clientWidth
|
||||
this.isScrollable = scrollWidth > clientWidth
|
||||
this.canScrollRight = scrollPercent < 1
|
||||
this.canScrollLeft = scrollLeft > 0
|
||||
}
|
||||
},
|
||||
updated() {
|
||||
this.setScrollVars()
|
||||
},
|
||||
mounted() {
|
||||
this.$eventBus.$on('bookshelf-clear-selection', this.clearSelectedEntities)
|
||||
this.$eventBus.$on('item-selected', this.itemSelectedEvt)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$eventBus.$off('bookshelf-clear-selection', this.clearSelectedEntities)
|
||||
this.$eventBus.$off('item-selected', this.itemSelectedEvt)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,50 +1,48 @@
|
||||
<template>
|
||||
<div class="w-full h-full relative">
|
||||
<form class="w-full h-full" @submit.prevent="submitForm">
|
||||
<div id="formWrapper" class="px-4 py-6 details-form-wrapper w-full overflow-hidden overflow-y-auto">
|
||||
<div class="flex -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<ui-text-input-with-label ref="titleInput" v-model="details.title" label="Title" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-text-input-with-label ref="authorInput" v-model="details.author" label="Author" />
|
||||
</div>
|
||||
<form class="w-full h-full px-4 py-6" @submit.prevent="submitForm">
|
||||
<div class="flex -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<ui-text-input-with-label ref="titleInput" v-model="details.title" label="Title" />
|
||||
</div>
|
||||
|
||||
<ui-text-input-with-label ref="feedUrlInput" v-model="details.feedUrl" label="RSS Feed URL" class="mt-2" />
|
||||
|
||||
<ui-textarea-with-label ref="descriptionInput" v-model="details.description" :rows="3" label="Description" class="mt-2" />
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<ui-multi-select ref="genresSelect" v-model="details.genres" label="Genres" :items="genres" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-multi-select ref="tagsSelect" v-model="newTags" label="Tags" :items="tags" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-text-input-with-label ref="authorInput" v-model="details.author" label="Author" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/4 px-1">
|
||||
<ui-text-input-with-label ref="releaseDateInput" v-model="details.releaseDate" label="Release Date" />
|
||||
</div>
|
||||
<div class="w-1/4 px-1">
|
||||
<ui-text-input-with-label ref="itunesIdInput" v-model="details.itunesId" label="iTunes ID" />
|
||||
</div>
|
||||
<div class="w-1/4 px-1">
|
||||
<ui-text-input-with-label ref="languageInput" v-model="details.language" label="Language" />
|
||||
</div>
|
||||
<div class="flex-grow px-1 pt-6">
|
||||
<div class="flex justify-center">
|
||||
<ui-checkbox v-model="details.explicit" label="Explicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||
</div>
|
||||
</div>
|
||||
<ui-text-input-with-label ref="feedUrlInput" v-model="details.feedUrl" label="RSS Feed URL" class="mt-2" />
|
||||
|
||||
<ui-textarea-with-label ref="descriptionInput" v-model="details.description" :rows="3" label="Description" class="mt-2" />
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<ui-multi-select ref="genresSelect" v-model="details.genres" label="Genres" :items="genres" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-multi-select ref="tagsSelect" v-model="newTags" label="Tags" :items="tags" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/4 px-1">
|
||||
<ui-text-input-with-label ref="releaseDateInput" v-model="details.releaseDate" label="Release Date" />
|
||||
</div>
|
||||
<div class="w-1/4 px-1">
|
||||
<ui-text-input-with-label ref="itunesIdInput" v-model="details.itunesId" label="iTunes ID" />
|
||||
</div>
|
||||
<div class="w-1/4 px-1">
|
||||
<ui-text-input-with-label ref="languageInput" v-model="details.language" label="Language" />
|
||||
</div>
|
||||
<div class="flex-grow px-1 pt-6">
|
||||
<ui-checkbox v-model="autoDownloadEpisodes" label="Auto Download New Episodes" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||
<div class="flex justify-center">
|
||||
<ui-checkbox v-model="details.explicit" label="Explicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-grow px-1 pt-6">
|
||||
<ui-checkbox v-model="autoDownloadEpisodes" label="Auto Download New Episodes" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
109
client/components/widgets/SeriesSlider.vue
Normal file
109
client/components/widgets/SeriesSlider.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div class="flex items-center py-3">
|
||||
<slot />
|
||||
<div class="flex-grow" />
|
||||
<button v-if="isScrollable" class="w-8 h-8 mx-1 flex items-center justify-center rounded-full" :class="canScrollLeft ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollLeft">
|
||||
<span class="material-icons text-2xl">chevron_left</span>
|
||||
</button>
|
||||
<button v-if="isScrollable" class="w-8 h-8 mx-1 flex items-center justify-center rounded-full" :class="canScrollRight ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollRight">
|
||||
<span class="material-icons text-2xl">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll -mx-2" style="scroll-behavior: smooth" @scroll="scrolled">
|
||||
<div class="flex" :style="{ height: height + 'px' }">
|
||||
<template v-for="(item, index) in items">
|
||||
<cards-lazy-series-card :key="item.id" :ref="`slider-item-${item.id}`" :index="index" :series-mount="item" :height="cardHeight" :width="cardWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" :bookshelf-view="$constants.BookshelfView.TITLES" class="relative mx-2" @hook:updated="setScrollVars" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
height: {
|
||||
type: Number,
|
||||
default: 192
|
||||
},
|
||||
bookshelfView: {
|
||||
type: Number,
|
||||
default: 1
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isScrollable: false,
|
||||
canScrollLeft: false,
|
||||
canScrollRight: false,
|
||||
clientWidth: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
},
|
||||
cardScaleMulitiplier() {
|
||||
return this.height / 192
|
||||
},
|
||||
cardHeight() {
|
||||
return this.height - 40 * this.cardScaleMulitiplier
|
||||
},
|
||||
cardWidth() {
|
||||
return 2 * (this.cardHeight / this.bookCoverAspectRatio)
|
||||
},
|
||||
booksPerPage() {
|
||||
return Math.floor(this.clientWidth / (this.cardWidth + 16))
|
||||
},
|
||||
isSelectionMode() {
|
||||
return this.$store.getters['getNumLibraryItemsSelected'] > 0
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
scrolled() {
|
||||
this.setScrollVars()
|
||||
},
|
||||
scrollRight() {
|
||||
if (!this.canScrollRight) return
|
||||
const slider = this.$refs.slider
|
||||
if (!slider) return
|
||||
const scrollAmount = this.booksPerPage * this.cardWidth
|
||||
const maxScrollLeft = slider.scrollWidth - slider.clientWidth
|
||||
|
||||
const newScrollLeft = Math.min(maxScrollLeft, slider.scrollLeft + scrollAmount)
|
||||
slider.scrollLeft = newScrollLeft
|
||||
},
|
||||
scrollLeft() {
|
||||
if (!this.canScrollLeft) return
|
||||
const slider = this.$refs.slider
|
||||
if (!slider) return
|
||||
|
||||
const scrollAmount = this.booksPerPage * this.cardWidth
|
||||
|
||||
const newScrollLeft = Math.max(0, slider.scrollLeft - scrollAmount)
|
||||
slider.scrollLeft = newScrollLeft
|
||||
},
|
||||
setScrollVars() {
|
||||
const slider = this.$refs.slider
|
||||
if (!slider) return
|
||||
const { scrollLeft, scrollWidth, clientWidth } = slider
|
||||
const scrollPercent = (scrollLeft + clientWidth) / scrollWidth
|
||||
|
||||
this.clientWidth = clientWidth
|
||||
this.isScrollable = scrollWidth > clientWidth
|
||||
this.canScrollRight = scrollPercent < 1
|
||||
this.canScrollLeft = scrollLeft > 0
|
||||
}
|
||||
},
|
||||
updated() {
|
||||
this.setScrollVars()
|
||||
},
|
||||
mounted() {},
|
||||
beforeDestroy() {}
|
||||
}
|
||||
</script>
|
||||
@@ -11,6 +11,7 @@
|
||||
<modals-edit-collection-modal />
|
||||
<modals-bookshelf-texture-modal />
|
||||
<modals-podcast-edit-episode />
|
||||
<modals-authors-edit-modal />
|
||||
<readers-reader />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
export default function (context) {
|
||||
if (process.client) {
|
||||
var route = context.route
|
||||
var from = context.from
|
||||
var store = context.store
|
||||
|
||||
if (route.name === 'login' || from.name === 'login') return
|
||||
|
||||
if (!route.name) {
|
||||
console.warn('No Route name', route)
|
||||
return
|
||||
}
|
||||
|
||||
if (store.state.isRoutingBack) {
|
||||
// pressing back button in appbar do not add to route history
|
||||
store.commit('setIsRoutingBack', false)
|
||||
return
|
||||
}
|
||||
|
||||
if (route.name.startsWith('config') || route.name === 'upload' || route.name === 'account' || route.name.startsWith('audiobook-id') || route.name.startsWith('collection-id')) {
|
||||
if (from.name !== route.name && from.name !== 'audiobook-id-edit' && !from.name.startsWith('config') && from.name !== 'upload' && from.name !== 'account') {
|
||||
var _history = [...store.state.routeHistory]
|
||||
if (!_history.length || _history[_history.length - 1] !== from.fullPath) {
|
||||
_history.push(from.fullPath)
|
||||
store.commit('setRouteHistory', _history)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -112,11 +112,22 @@ export default {
|
||||
items: []
|
||||
})
|
||||
var newtreemap = currtreemap.items[currtreemap.items.length - 1]
|
||||
dirReader.readEntries((entries) => {
|
||||
let entriesPromises = []
|
||||
for (let entr of entries) entriesPromises.push(traverseFileTreePromise(entr, newtreemap))
|
||||
resolve(Promise.all(entriesPromises))
|
||||
})
|
||||
|
||||
let entriesPromises = []
|
||||
// readEntries returns 100 items max, continue calling readEntries until empty
|
||||
function readEntries() {
|
||||
dirReader.readEntries((entries) => {
|
||||
if (entries.length > 0) {
|
||||
for (let entr of entries) {
|
||||
entriesPromises.push(traverseFileTreePromise(entr, newtreemap))
|
||||
}
|
||||
readEntries()
|
||||
} else {
|
||||
resolve(Promise.all(entriesPromises))
|
||||
}
|
||||
})
|
||||
}
|
||||
readEntries()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ module.exports = {
|
||||
serverUrl: process.env.NODE_ENV === 'production' ? '' : 'http://localhost:3333',
|
||||
chromecastReceiver: 'FD1F76C5'
|
||||
},
|
||||
// rootDir: process.env.NODE_ENV !== 'production' ? 'client/' : '',
|
||||
telemetry: false,
|
||||
|
||||
publicRuntimeConfig: {
|
||||
@@ -33,14 +32,11 @@ module.exports = {
|
||||
}
|
||||
],
|
||||
link: [
|
||||
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
|
||||
{ rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Ubuntu+Mono&family=Source+Sans+Pro:wght@300;400;600' },
|
||||
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }
|
||||
]
|
||||
},
|
||||
|
||||
router: {
|
||||
middleware: ['routed']
|
||||
},
|
||||
router: {},
|
||||
|
||||
// Global CSS: https://go.nuxtjs.dev/config-css
|
||||
css: [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.0.9",
|
||||
"version": "2.0.13",
|
||||
"description": "Audiobook manager and player",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
422
client/pages/audiobook/_id/chapters.vue
Normal file
422
client/pages/audiobook/_id/chapters.vue
Normal file
@@ -0,0 +1,422 @@
|
||||
<template>
|
||||
<div id="page-wrapper" class="bg-bg page overflow-y-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
|
||||
<div class="flex items-center py-4 max-w-7xl mx-auto">
|
||||
<nuxt-link :to="`/item/${libraryItem.id}`" class="hover:underline">
|
||||
<h1 class="text-xl">{{ title }}</h1>
|
||||
</nuxt-link>
|
||||
<button class="w-7 h-7 flex items-center justify-center mx-4 hover:scale-110 duration-100 transform text-gray-200 hover:text-white" @click="editItem">
|
||||
<span class="material-icons text-base">edit</span>
|
||||
</button>
|
||||
<div class="flex-grow" />
|
||||
<p class="text-base">Duration:</p>
|
||||
<p class="text-base font-mono ml-8">{{ mediaDuration }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap-reverse justify-center py-4">
|
||||
<div class="w-full max-w-3xl py-4">
|
||||
<div class="flex items-center">
|
||||
<p class="text-lg mb-4 font-semibold">Audiobook Chapters</p>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn color="primary" small class="mx-2" @click="showFindChaptersModal = true">Lookup</ui-btn>
|
||||
<ui-btn color="success" small @click="saveChapters">Save</ui-btn>
|
||||
<div class="w-40" />
|
||||
</div>
|
||||
|
||||
<div class="flex text-xs uppercase text-gray-300 font-semibold mb-2">
|
||||
<div class="w-12"></div>
|
||||
<div class="w-32 px-2">Start</div>
|
||||
<div class="flex-grow px-2">Title</div>
|
||||
<div class="w-40"></div>
|
||||
</div>
|
||||
<template v-for="chapter in newChapters">
|
||||
<div :key="chapter.id" class="flex py-1">
|
||||
<div class="w-12">#{{ chapter.id + 1 }}</div>
|
||||
<div class="w-32 px-1">
|
||||
<ui-text-input v-model="chapter.start" type="number" class="text-xs" @change="checkChapters" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-text-input v-model="chapter.title" class="text-xs" />
|
||||
</div>
|
||||
<div class="w-40 px-2 py-1">
|
||||
<div class="flex items-center">
|
||||
<button v-if="newChapters.length > 1" class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-error transform hover:scale-110 duration-150" @click="removeChapter(chapter)">
|
||||
<span class="material-icons-outlined text-base">remove</span>
|
||||
</button>
|
||||
|
||||
<ui-tooltip text="Insert chapter below" direction="bottom">
|
||||
<button class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-success transform hover:scale-110 duration-150" @click="addChapter(chapter)">
|
||||
<span class="material-icons text-lg">add</span>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
|
||||
<button class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-white transform hover:scale-110 duration-150" @click="playChapter(chapter)">
|
||||
<widgets-loading-spinner v-if="selectedChapterId === chapter.id && isLoadingChapter" />
|
||||
<span v-else-if="selectedChapterId === chapter.id && isPlayingChapter" class="material-icons-outlined text-base">pause</span>
|
||||
<span v-else class="material-icons-outlined text-base">play_arrow</span>
|
||||
</button>
|
||||
|
||||
<ui-tooltip v-if="chapter.error" :text="chapter.error" direction="left">
|
||||
<button class="w-7 h-7 rounded-full flex items-center justify-center text-error">
|
||||
<span class="material-icons-outlined text-lg">error_outline</span>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="w-full max-w-xl py-4">
|
||||
<p class="text-lg mb-4 font-semibold py-1">Audio Tracks</p>
|
||||
<div class="flex text-xs uppercase text-gray-300 font-semibold mb-2">
|
||||
<div class="flex-grow">Filename</div>
|
||||
<div class="w-20">Duration</div>
|
||||
<div class="w-20 text-center">Chapters</div>
|
||||
</div>
|
||||
<template v-for="track in audioTracks">
|
||||
<div :key="track.ino" class="flex items-center py-2" :class="currentTrackIndex === track.index && isPlayingChapter ? 'bg-success bg-opacity-10' : ''">
|
||||
<div class="flex-grow">
|
||||
<p class="text-xs truncate max-w-sm">{{ track.metadata.filename }}</p>
|
||||
</div>
|
||||
<div class="w-20" style="min-width: 80px">
|
||||
<p class="text-xs font-mono text-gray-200">{{ track.duration }}</p>
|
||||
</div>
|
||||
<div class="w-20 flex justify-center" style="min-width: 80px">
|
||||
<span v-if="(track.chapters || []).length" class="material-icons text-success text-sm">check</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="saving" class="w-full h-full absolute top-0 left-0 bottom-0 right-0 z-30 bg-black bg-opacity-25 flex items-center justify-center">
|
||||
<ui-loading-indicator />
|
||||
</div>
|
||||
|
||||
<modals-modal v-model="showFindChaptersModal" name="edit-book" :width="500" :processing="findingChapters">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none">
|
||||
<p class="font-book text-3xl text-white truncate pointer-events-none">Find Chapters</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="w-full h-full max-h-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative">
|
||||
<div v-if="!chapterData" class="flex p-20">
|
||||
<ui-text-input-with-label v-model="asinInput" label="ASIN" />
|
||||
<ui-btn small color="primary" class="mt-5 ml-2" @click="findChapters">Find</ui-btn>
|
||||
</div>
|
||||
<div v-else class="w-full p-4">
|
||||
<p class="mb-4">Duration found: {{ chapterData.runtimeLengthSec }}</p>
|
||||
<div v-if="chapterData.runtimeLengthSec > mediaDuration" class="w-full bg-error bg-opacity-25 p-4 text-center mb-2 rounded border border-white border-opacity-10 text-gray-100 text-sm">
|
||||
<p>Chapter data invalid duration<br />Your media duration is shorter than duration found</p>
|
||||
</div>
|
||||
|
||||
<div class="flex py-0.5 text-xs font-semibold uppercase text-gray-300 mb-1">
|
||||
<div class="w-24 px-2">Start</div>
|
||||
<div class="flex-grow px-2">Title</div>
|
||||
</div>
|
||||
<div class="w-full max-h-80 overflow-y-auto my-2">
|
||||
<div v-for="(chapter, index) in chapterData.chapters" :key="index" class="flex py-0.5 text-xs" :class="chapter.startOffsetSec > mediaDuration ? 'bg-error bg-opacity-20' : chapter.startOffsetSec + chapter.lengthMs / 1000 > mediaDuration ? 'bg-warning bg-opacity-20' : index % 2 === 0 ? 'bg-primary bg-opacity-30' : ''">
|
||||
<div class="w-24 min-w-24 px-2">
|
||||
<p class="font-mono">{{ $secondsToTimestamp(chapter.startOffsetSec) }}</p>
|
||||
</div>
|
||||
<div class="flex-grow px-2">
|
||||
<p class="truncate max-w-sm">{{ chapter.title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex pt-2">
|
||||
<div class="flex-grow" />
|
||||
<ui-btn small color="success" @click="applyChapterData">Apply Chapters</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
async asyncData({ store, params, app, redirect, route }) {
|
||||
if (!store.getters['user/getUserCanUpdate']) {
|
||||
return redirect('/?error=unauthorized')
|
||||
}
|
||||
var libraryItem = await app.$axios.$get(`/api/items/${params.id}?expanded=1`).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return false
|
||||
})
|
||||
if (!libraryItem) {
|
||||
console.error('Not found...', params.id)
|
||||
return redirect('/')
|
||||
}
|
||||
if (libraryItem.mediaType != 'book') {
|
||||
console.error('Invalid media type')
|
||||
return redirect('/')
|
||||
}
|
||||
return {
|
||||
libraryItem
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
newChapters: [],
|
||||
selectedChapter: null,
|
||||
audioEl: null,
|
||||
isPlayingChapter: false,
|
||||
isLoadingChapter: false,
|
||||
currentTrackIndex: 0,
|
||||
saving: false,
|
||||
asinInput: null,
|
||||
findingChapters: false,
|
||||
showFindChaptersModal: false,
|
||||
chapterData: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem.media || {}
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
title() {
|
||||
return this.mediaMetadata.title
|
||||
},
|
||||
mediaDuration() {
|
||||
return this.media.duration
|
||||
},
|
||||
chapters() {
|
||||
return this.media.chapters || []
|
||||
},
|
||||
tracks() {
|
||||
return this.media.tracks || []
|
||||
},
|
||||
audioFiles() {
|
||||
return this.media.audioFiles || []
|
||||
},
|
||||
audioTracks() {
|
||||
return this.audioFiles.filter((af) => !af.exclude && !af.invalid)
|
||||
},
|
||||
selectedChapterId() {
|
||||
return this.selectedChapter ? this.selectedChapter.id : null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
editItem() {
|
||||
this.$store.commit('showEditModal', this.libraryItem)
|
||||
},
|
||||
addChapter(chapter) {
|
||||
console.log('Add chapter', chapter)
|
||||
const newChapter = {
|
||||
id: chapter.id + 1,
|
||||
start: chapter.start,
|
||||
end: chapter.end,
|
||||
title: ''
|
||||
}
|
||||
this.newChapters.splice(chapter.id + 1, 0, newChapter)
|
||||
this.checkChapters()
|
||||
},
|
||||
removeChapter(chapter) {
|
||||
this.newChapters = this.newChapters.filter((ch) => ch.id !== chapter.id)
|
||||
this.checkChapters()
|
||||
},
|
||||
checkChapters() {
|
||||
var previousStart = 0
|
||||
for (let i = 0; i < this.newChapters.length; i++) {
|
||||
this.newChapters[i].id = i
|
||||
this.newChapters[i].start = Number(this.newChapters[i].start)
|
||||
|
||||
if (i === 0 && this.newChapters[i].start !== 0) {
|
||||
this.newChapters[i].error = 'First chapter must start at 0'
|
||||
} else if (this.newChapters[i].start <= previousStart && i > 0) {
|
||||
this.newChapters[i].error = 'Invalid start time must be >= previous chapter start time'
|
||||
} else if (this.newChapters[i].start >= this.mediaDuration) {
|
||||
this.newChapters[i].error = 'Invalid start time must be < duration'
|
||||
} else {
|
||||
this.newChapters[i].error = null
|
||||
}
|
||||
previousStart = this.newChapters[i].start
|
||||
}
|
||||
},
|
||||
playChapter(chapter) {
|
||||
console.log('Play Chapter', chapter.id)
|
||||
if (this.selectedChapterId === chapter.id) {
|
||||
console.log('Chapter already playing', this.isLoadingChapter, this.isPlayingChapter)
|
||||
if (this.isLoadingChapter) return
|
||||
if (this.isPlayingChapter) {
|
||||
console.log('Destroying chapter')
|
||||
this.destroyAudioEl()
|
||||
return
|
||||
}
|
||||
}
|
||||
if (this.selectedChapterId) {
|
||||
this.destroyAudioEl()
|
||||
}
|
||||
|
||||
const audioTrack = this.tracks.find((at) => {
|
||||
return chapter.start >= at.startOffset && chapter.start < at.startOffset + at.duration
|
||||
})
|
||||
console.log('audio track', audioTrack)
|
||||
|
||||
this.selectedChapter = chapter
|
||||
this.isLoadingChapter = true
|
||||
|
||||
const trackOffset = chapter.start - audioTrack.startOffset
|
||||
this.playTrackAtTime(audioTrack, trackOffset)
|
||||
},
|
||||
playTrackAtTime(audioTrack, trackOffset) {
|
||||
this.currentTrackIndex = audioTrack.index
|
||||
|
||||
const audioEl = this.audioEl || document.createElement('audio')
|
||||
var src = audioTrack.contentUrl + `?token=${this.userToken}`
|
||||
if (this.$isDev) {
|
||||
src = `http://localhost:3333${src}`
|
||||
}
|
||||
console.log('src', src)
|
||||
|
||||
audioEl.src = src
|
||||
audioEl.id = 'chapter-audio'
|
||||
document.body.appendChild(audioEl)
|
||||
|
||||
audioEl.addEventListener('loadeddata', () => {
|
||||
console.log('Audio loaded data', audioEl.duration)
|
||||
audioEl.currentTime = trackOffset
|
||||
audioEl.play()
|
||||
console.log('Playing audio at current time', trackOffset)
|
||||
})
|
||||
audioEl.addEventListener('play', () => {
|
||||
console.log('Audio playing')
|
||||
this.isLoadingChapter = false
|
||||
this.isPlayingChapter = true
|
||||
})
|
||||
audioEl.addEventListener('ended', () => {
|
||||
console.log('Audio ended')
|
||||
const nextTrack = this.tracks.find((t) => t.index === this.currentTrackIndex + 1)
|
||||
if (nextTrack) {
|
||||
console.log('Playing next track', nextTrack.index)
|
||||
this.currentTrackIndex = nextTrack.index
|
||||
this.playTrackAtTime(nextTrack, 0)
|
||||
} else {
|
||||
console.log('No next track')
|
||||
this.destroyAudioEl()
|
||||
}
|
||||
})
|
||||
this.audioEl = audioEl
|
||||
},
|
||||
destroyAudioEl() {
|
||||
if (!this.audioEl) return
|
||||
this.audioEl.remove()
|
||||
this.audioEl = null
|
||||
this.selectedChapter = null
|
||||
this.isPlayingChapter = false
|
||||
this.isLoadingChapter = false
|
||||
},
|
||||
saveChapters() {
|
||||
this.checkChapters()
|
||||
|
||||
for (let i = 0; i < this.newChapters.length; i++) {
|
||||
if (this.newChapters[i].error) {
|
||||
this.$toast.error('Chapters have errors')
|
||||
return
|
||||
}
|
||||
if (!this.newChapters[i].title) {
|
||||
this.$toast.error('Chapters must have titles')
|
||||
return
|
||||
}
|
||||
|
||||
const nextChapter = this.newChapters[i + 1]
|
||||
if (nextChapter) {
|
||||
this.newChapters[i].end = nextChapter.start
|
||||
} else {
|
||||
this.newChapters[i].end = this.mediaDuration
|
||||
}
|
||||
}
|
||||
|
||||
this.saving = true
|
||||
|
||||
console.log('udpated chapters', this.newChapters)
|
||||
const payload = {
|
||||
chapters: this.newChapters
|
||||
}
|
||||
this.$axios
|
||||
.$post(`/api/items/${this.libraryItem.id}/chapters`, payload)
|
||||
.then((data) => {
|
||||
this.saving = false
|
||||
if (data.updated) {
|
||||
this.$toast.success('Chapters updated')
|
||||
this.$router.push(`/item/${this.libraryItem.id}`)
|
||||
} else {
|
||||
this.$toast.info('No changes needed updating')
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
this.saving = false
|
||||
console.error('Failed to update chapters', error)
|
||||
this.$toast.error('Failed to update chapters')
|
||||
})
|
||||
},
|
||||
applyChapterData() {
|
||||
var index = 0
|
||||
this.newChapters = this.chapterData.chapters
|
||||
.filter((chap) => chap.startOffsetSec < this.mediaDuration)
|
||||
.map((chap) => {
|
||||
var chapEnd = Math.min(this.mediaDuration, (chap.startOffsetMs + chap.lengthMs) / 1000)
|
||||
return {
|
||||
id: index++,
|
||||
start: chap.startOffsetMs / 1000,
|
||||
end: chapEnd,
|
||||
title: chap.title
|
||||
}
|
||||
})
|
||||
this.showFindChaptersModal = false
|
||||
this.chapterData = null
|
||||
},
|
||||
findChapters() {
|
||||
if (!this.asinInput) {
|
||||
this.$toast.error('Must input an ASIN')
|
||||
return
|
||||
}
|
||||
this.findingChapters = true
|
||||
this.chapterData = null
|
||||
this.$axios
|
||||
.$get(`/api/search/chapters?asin=${this.asinInput}`)
|
||||
.then((data) => {
|
||||
this.findingChapters = false
|
||||
|
||||
if (data.error) {
|
||||
this.$toast.error(data.error)
|
||||
this.showFindChaptersModal = false
|
||||
} else {
|
||||
console.log('Chapter data', data)
|
||||
this.chapterData = data
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
this.findingChapters = false
|
||||
console.error('Failed to get chapter data', error)
|
||||
this.$toast.error('Failed to find chapters')
|
||||
this.showFindChaptersModal = false
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.asinInput = this.mediaMetadata.asin || null
|
||||
this.newChapters = this.chapters.map((c) => ({ ...c }))
|
||||
if (!this.newChapters.length) {
|
||||
this.newChapters = [
|
||||
{
|
||||
id: 0,
|
||||
start: 0,
|
||||
end: this.mediaDuration,
|
||||
title: ''
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -39,8 +39,6 @@
|
||||
<draggable v-model="files" v-bind="dragOptions" class="list-group border border-gray-600" draggable=".item" tag="ul" @start="drag = true" @end="drag = false" @update="draggableUpdate">
|
||||
<transition-group type="transition" :name="!drag ? 'flip-list' : null">
|
||||
<li v-for="(audio, index) in files" :key="audio.ino" :class="audio.include ? 'item' : 'exclude'" class="w-full list-group-item flex items-center relative">
|
||||
<div v-if="audiofilesEncoding[audio.ino]" class="absolute top-0 left-0 w-full h-full bg-success bg-opacity-25" />
|
||||
|
||||
<div class="font-book text-center px-4 py-1 w-12">
|
||||
{{ audio.include ? index - numExcluded + 1 : -1 }}
|
||||
</div>
|
||||
@@ -91,9 +89,6 @@ export default {
|
||||
draggable
|
||||
},
|
||||
async asyncData({ store, params, app, redirect, route }) {
|
||||
if (!store.state.user.user) {
|
||||
return redirect(`/login?redirect=${route.path}`)
|
||||
}
|
||||
if (!store.getters['user/getUserCanUpdate']) {
|
||||
return redirect('/?error=unauthorized')
|
||||
}
|
||||
@@ -134,9 +129,6 @@ export default {
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
},
|
||||
isRootUser() {
|
||||
return this.$store.getters['user/getIsRoot']
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem.media || {}
|
||||
},
|
||||
|
||||
108
client/pages/author/_id.vue
Normal file
108
client/pages/author/_id.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div id="page-wrapper" class="bg-bg page overflow-y-auto p-8" :class="streamLibraryItem ? 'streaming' : ''">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<div class="flex mb-6">
|
||||
<div class="w-48 min-w-48">
|
||||
<div class="w-full h-52">
|
||||
<covers-author-image :author="author" rounded="0" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow px-8">
|
||||
<div class="flex items-center mb-8">
|
||||
<h1 class="text-2xl">{{ author.name }}</h1>
|
||||
|
||||
<button v-if="userCanUpdate" class="w-8 h-8 rounded-full flex items-center justify-center mx-4 cursor-pointer text-gray-300 hover:text-warning transform hover:scale-125 duration-100" @click="editAuthor">
|
||||
<span class="material-icons text-base">edit</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="author.description" class="text-white text-opacity-60 uppercase text-xs mb-2">Description</p>
|
||||
<p class="text-white max-w-3xl text-sm leading-5">{{ author.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-4">
|
||||
<widgets-item-slider :items="libraryItems" :bookshelf-view="$constants.BookshelfView.AUTHOR">
|
||||
<h2 class="text-lg">{{ libraryItems.length }} Books</h2>
|
||||
</widgets-item-slider>
|
||||
</div>
|
||||
|
||||
<div v-for="series in authorSeries" :key="series.id" class="py-4">
|
||||
<widgets-item-slider :items="series.items" :bookshelf-view="$constants.BookshelfView.AUTHOR">
|
||||
<h2 class="text-lg">{{ series.name }}</h2>
|
||||
<p class="text-white text-opacity-40 text-base px-2">Series</p>
|
||||
</widgets-item-slider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
async asyncData({ store, app, params, redirect }) {
|
||||
const author = await app.$axios.$get(`/api/authors/${params.id}?include=items,series`).catch((error) => {
|
||||
console.error('Failed to get author', error)
|
||||
return null
|
||||
})
|
||||
|
||||
if (!author) {
|
||||
return redirect(`/library/${store.state.libraries.currentLibraryId}/authors`)
|
||||
}
|
||||
|
||||
return {
|
||||
author
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
libraryItems() {
|
||||
return this.author.libraryItems || []
|
||||
},
|
||||
authorSeries() {
|
||||
return this.author.series || []
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
editAuthor() {
|
||||
this.$store.commit('globals/showEditAuthorModal', this.author)
|
||||
},
|
||||
authorUpdated(author) {
|
||||
if (author.id === this.author.id) {
|
||||
console.log('Author was updated', author)
|
||||
this.author = {
|
||||
...author,
|
||||
series: this.authorSeries,
|
||||
libraryItems: this.libraryItems
|
||||
}
|
||||
}
|
||||
},
|
||||
authorRemoved(author) {
|
||||
if (author.id === this.author.id) {
|
||||
console.warn('Author was removed')
|
||||
this.$router.replace(`/library/${this.currentLibraryId}/authors`)
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (!this.author) this.$router.replace('/')
|
||||
|
||||
this.$root.socket.on('author_updated', this.authorUpdated)
|
||||
this.$root.socket.on('author_removed', this.authorRemoved)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$root.socket.off('author_updated', this.authorUpdated)
|
||||
this.$root.socket.off('author_removed', this.authorRemoved)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div id="page-wrapper" class="page p-6 overflow-y-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
|
||||
<div id="page-wrapper" class="page p-2 md:p-6 overflow-y-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
|
||||
<app-config-side-nav :is-open.sync="sideDrawerOpen" />
|
||||
<div class="configContent" :class="`page-${currentPage}`">
|
||||
<div v-show="isMobile" class="w-full pb-4 px-2 flex border-b border-white border-opacity-10 mb-2">
|
||||
@@ -15,7 +15,7 @@
|
||||
<script>
|
||||
export default {
|
||||
asyncData({ store, redirect, route }) {
|
||||
if (!store.getters['user/getIsRoot']) {
|
||||
if (!store.getters['user/getIsAdminOrUp']) {
|
||||
// Non-Root user only has access to the listening stats page
|
||||
if (route.name !== 'config-stats') {
|
||||
redirect('/config/stats')
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
<ui-toggle-switch v-model="useAlternativeBookshelfView" :disabled="updatingServerSettings" @input="updateAlternativeBookshelfView" />
|
||||
<ui-tooltip :text="tooltips.bookshelfView">
|
||||
<p class="pl-4 text-lg">
|
||||
Use alternative library bookshelf view
|
||||
Use alternative bookshelf view
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
@@ -213,7 +213,7 @@ export default {
|
||||
scannerParseSubtitle: 'Extract subtitles from audiobook folder names.<br>Subtitle must be seperated by " - "<br>i.e. "Book Title - A Subtitle Here" has the subtitle "A Subtitle Here"',
|
||||
sortingIgnorePrefix: 'i.e. for prefix "the" book title "The Book Title" would sort as "Book Title, The"',
|
||||
scannerFindCovers: 'If your audiobook does not have an embedded cover or a cover image inside the folder, the scanner will attempt to find a cover.<br>Note: This will extend scan time',
|
||||
bookshelfView: 'Alternative bookshelf view that shows title & author under book covers',
|
||||
bookshelfView: 'Alternative view without wooden bookshelf',
|
||||
storeCoverWithItem: 'By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named "cover" will be kept',
|
||||
storeMetadataWithItem: 'By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders. Uses .abs file extension',
|
||||
coverAspectRatio: 'Prefer to use square covers over standard 1.6:1 book covers'
|
||||
|
||||
@@ -34,11 +34,6 @@
|
||||
|
||||
<script>
|
||||
export default {
|
||||
asyncData({ store, redirect }) {
|
||||
if (!store.getters['user/getIsRoot']) {
|
||||
redirect('/?error=unauthorized')
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
search: null,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div>
|
||||
<div class="flex justify-center">
|
||||
<div class="flex p-2">
|
||||
<svg class="h-14 w-14 md:h-18 md:w-18" viewBox="0 0 24 24">
|
||||
<svg class="hidden sm:block h-14 w-14 lg:h-18 lg:w-18" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M19 1L14 6V17L19 12.5V1M21 5V18.5C19.9 18.15 18.7 18 17.5 18C15.8 18 13.35 18.65 12 19.5V6C10.55 4.9 8.45 4.5 6.5 4.5C4.55 4.5 2.45 4.9 1 6V20.65C1 20.9 1.25 21.15 1.5 21.15C1.6 21.15 1.65 21.1 1.75 21.1C3.1 20.45 5.05 20 6.5 20C8.45 20 10.55 20.4 12 21.5C13.35 20.65 15.8 20 17.5 20C19.15 20 20.85 20.3 22.25 21.05C22.35 21.1 22.4 21.1 22.5 21.1C22.75 21.1 23 20.85 23 20.6V6C22.4 5.55 21.75 5.25 21 5M10 18.41C8.75 18.09 7.5 18 6.5 18C5.44 18 4.18 18.19 3 18.5V7.13C3.91 6.73 5.14 6.5 6.5 6.5C7.86 6.5 9.09 6.73 10 7.13V18.41Z"
|
||||
@@ -15,7 +15,9 @@
|
||||
</div>
|
||||
|
||||
<div class="flex p-2">
|
||||
<span class="material-icons-outlined" style="font-size: 4.1rem">event</span>
|
||||
<div class="hidden sm:block">
|
||||
<span class="hidden sm:block material-icons-outlined text-5xl lg:text-6xl">event</span>
|
||||
</div>
|
||||
<div class="px-1">
|
||||
<p class="text-4xl md:text-5xl font-bold">{{ totalDaysListened }}</p>
|
||||
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Days Listened</p>
|
||||
@@ -23,15 +25,17 @@
|
||||
</div>
|
||||
|
||||
<div class="flex p-2">
|
||||
<span class="material-icons-outlined" style="font-size: 4.1rem">watch_later</span>
|
||||
<div class="hidden sm:block">
|
||||
<span class="material-icons-outlined text-5xl lg:text-6xl">watch_later</span>
|
||||
</div>
|
||||
<div class="px-1">
|
||||
<p class="text-4xl md:text-5xl font-bold">{{ totalMinutesListening }}</p>
|
||||
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Minutes Listening</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row">
|
||||
<stats-daily-listening-chart :listening-stats="listeningStats" />
|
||||
<div class="flex flex-col md:flex-row overflow-hidden max-w-full">
|
||||
<stats-daily-listening-chart :listening-stats="listeningStats" class="origin-top-left transform scale-75 lg:scale-100" />
|
||||
<div class="w-80 my-6 mx-auto">
|
||||
<h1 class="text-2xl mb-4 font-book">Recent Listening Sessions</h1>
|
||||
<p v-if="!mostRecentListeningSessions.length">No Listening Sessions</p>
|
||||
@@ -52,6 +56,8 @@
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<stats-heatmap v-if="listeningStats" :days-listening="listeningStats.days" class="my-2" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -59,7 +65,8 @@
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
listeningStats: null
|
||||
listeningStats: null,
|
||||
windowWidth: 0
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<h1 class="text-xl pl-2">{{ username }}</h1>
|
||||
</div>
|
||||
<div class="cursor-pointer text-gray-400 hover:text-white" @click="copyToClipboard(userToken)">
|
||||
<p class="py-2 text-xs">
|
||||
<p v-if="userToken" class="py-2 text-xs">
|
||||
<strong class="text-white">API Token: </strong><br /><span class="text-white">{{ userToken }}</span
|
||||
><span class="material-icons pl-2 text-base">content_copy</span>
|
||||
</p>
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
<p v-if="isPodcast" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">by {{ podcastAuthor || 'Unknown' }}</p>
|
||||
<p v-else-if="authors.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">
|
||||
by <nuxt-link v-for="(author, index) in authors" :key="index" :to="`/library/${libraryId}/bookshelf?filter=authors.${$encode(author.id)}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link>
|
||||
by <nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link>
|
||||
</p>
|
||||
<p v-else class="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</p>
|
||||
|
||||
@@ -177,6 +177,8 @@
|
||||
|
||||
<tables-podcast-episodes-table v-if="isPodcast" :library-item="libraryItem" />
|
||||
|
||||
<tables-chapters-table v-if="chapters.length" :library-item="libraryItem" class="mt-6" />
|
||||
|
||||
<tables-library-files-table v-if="libraryFiles.length" :is-missing="isMissing" :library-item-id="libraryItemId" :files="libraryFiles" class="mt-6" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -275,6 +277,9 @@ export default {
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
chapters() {
|
||||
return this.media.chapters || []
|
||||
},
|
||||
tracks() {
|
||||
return this.media.tracks || []
|
||||
},
|
||||
@@ -378,7 +383,8 @@ export default {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
},
|
||||
showRssFeedBtn() {
|
||||
if (!this.showExperimentalFeatures) return false
|
||||
if (!this.rssFeedUrl && !this.podcastEpisodes.length) return false // Cannot open RSS feed with no episodes
|
||||
|
||||
// If rss feed is open then show feed url to users otherwise just show to admins
|
||||
return this.isPodcast && (this.userIsAdminOrUp || this.rssFeedUrl)
|
||||
}
|
||||
@@ -492,33 +498,7 @@ export default {
|
||||
this.$store.commit('globals/setShowUserCollectionsModal', true)
|
||||
},
|
||||
clickRSSFeed() {
|
||||
if (!this.rssFeedUrl) {
|
||||
if (confirm(`Are you sure you want to open an RSS Feed for this podcast?`)) {
|
||||
this.openRSSFeed()
|
||||
}
|
||||
} else {
|
||||
this.showRssFeedModal = true
|
||||
}
|
||||
},
|
||||
openRSSFeed() {
|
||||
const payload = {
|
||||
serverAddress: window.origin
|
||||
}
|
||||
if (this.$isDev) payload.serverAddress = 'http://localhost:3333'
|
||||
|
||||
console.log('Payload', payload)
|
||||
this.$axios
|
||||
.$post(`/api/podcasts/${this.libraryItemId}/open-feed`, payload)
|
||||
.then((data) => {
|
||||
if (data.success) {
|
||||
console.log('Opened RSS Feed', data)
|
||||
this.rssFeedUrl = data.feedUrl
|
||||
this.showRssFeedModal = true
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to open RSS Feed', error)
|
||||
})
|
||||
this.showRssFeedModal = true
|
||||
},
|
||||
episodeDownloadQueued(episodeDownload) {
|
||||
if (episodeDownload.libraryItemId === this.libraryItemId) {
|
||||
|
||||
@@ -7,15 +7,12 @@
|
||||
<div id="bookshelf" class="w-full h-full p-8 overflow-y-auto">
|
||||
<div class="flex flex-wrap justify-center">
|
||||
<template v-for="author in authors">
|
||||
<nuxt-link :key="author.id" :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(author.id)}`">
|
||||
<cards-author-card :author="author" :width="160" :height="200" class="p-3" @edit="editAuthor" />
|
||||
</nuxt-link>
|
||||
<cards-author-card :key="author.id" :author="author" :width="160" :height="200" class="p-3" @edit="editAuthor" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<modals-authors-edit-modal v-model="showAuthorModal" :author="selectedAuthor" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -40,9 +37,7 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
authors: [],
|
||||
showAuthorModal: false,
|
||||
selectedAuthor: null
|
||||
authors: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -51,6 +46,9 @@ export default {
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
selectedAuthor() {
|
||||
return this.$store.state.globals.selectedAuthor
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -68,7 +66,7 @@ export default {
|
||||
},
|
||||
authorUpdated(author) {
|
||||
if (this.selectedAuthor && this.selectedAuthor.id === author.id) {
|
||||
this.selectedAuthor = author
|
||||
this.$store.commit('globals/setSelectedAuthor', author)
|
||||
}
|
||||
this.authors = this.authors.map((au) => {
|
||||
if (au.id === author.id) {
|
||||
@@ -81,8 +79,7 @@ export default {
|
||||
this.authors = this.authors.filter((au) => au.id !== author.id)
|
||||
},
|
||||
editAuthor(author) {
|
||||
this.selectedAuthor = author
|
||||
this.showAuthorModal = true
|
||||
this.$store.commit('globals/showEditAuthorModal', author)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
||||
@@ -7,9 +7,6 @@
|
||||
<app-book-shelf-categorized v-if="hasResults" ref="bookshelf" search :results="results" />
|
||||
<div v-else class="w-full py-16">
|
||||
<p class="text-xl text-center">No Search results for "{{ query }}"</p>
|
||||
<div class="flex justify-center">
|
||||
<ui-btn class="w-52 my-4" @click="back">Back</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -79,12 +76,6 @@ export default {
|
||||
this.$refs.bookshelf.setShelvesFromSearch()
|
||||
}
|
||||
})
|
||||
},
|
||||
async back() {
|
||||
var popped = await this.$store.dispatch('popRoute')
|
||||
if (popped) this.$store.commit('setIsRoutingBack', true)
|
||||
var backTo = popped || '/'
|
||||
this.$router.push(backTo)
|
||||
}
|
||||
},
|
||||
mounted() {},
|
||||
|
||||
@@ -86,6 +86,13 @@ export default {
|
||||
uploadFinished: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
selectedLibrary(newVal) {
|
||||
if (newVal && !this.selectedFolderId) {
|
||||
this.setDefaultFolder()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
inputAccept() {
|
||||
var extensions = []
|
||||
|
||||
@@ -21,7 +21,8 @@ const BookCoverAspectRatio = {
|
||||
|
||||
const BookshelfView = {
|
||||
STANDARD: 0,
|
||||
TITLES: 1
|
||||
TITLES: 1,
|
||||
AUTHOR: 2 // Books shown on author page
|
||||
}
|
||||
|
||||
const PlayMethod = {
|
||||
|
||||
@@ -23,6 +23,11 @@ Vue.prototype.$addDaysToToday = (daysToAdd) => {
|
||||
if (!date || !isDate(date)) return null
|
||||
return date
|
||||
}
|
||||
Vue.prototype.$addDaysToDate = (jsdate, daysToAdd) => {
|
||||
var date = addDays(jsdate, daysToAdd)
|
||||
if (!date || !isDate(date)) return null
|
||||
return date
|
||||
}
|
||||
|
||||
Vue.prototype.$bytesPretty = (bytes, decimals = 2) => {
|
||||
if (isNaN(bytes) || bytes == 0) {
|
||||
@@ -35,17 +40,20 @@ Vue.prototype.$bytesPretty = (bytes, decimals = 2) => {
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
Vue.prototype.$elapsedPretty = (seconds) => {
|
||||
Vue.prototype.$elapsedPretty = (seconds, useFullNames = false) => {
|
||||
if (seconds < 60) {
|
||||
return `${Math.floor(seconds)} sec${useFullNames ? 'onds' : ''}`
|
||||
}
|
||||
var minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 70) {
|
||||
return `${minutes} min`
|
||||
return `${minutes} min${useFullNames ? `ute${minutes === 1 ? '' : 's'}` : ''}`
|
||||
}
|
||||
var hours = Math.floor(minutes / 60)
|
||||
minutes -= hours * 60
|
||||
if (!minutes) {
|
||||
return `${hours} hr`
|
||||
return `${hours} ${useFullNames ? 'hours' : 'hr'}`
|
||||
}
|
||||
return `${hours} hr ${minutes} min`
|
||||
return `${hours} ${useFullNames ? `hour${hours === 1 ? '' : 's'}` : 'hr'} ${minutes} ${useFullNames ? `minute${minutes === 1 ? '' : 's'}` : 'min'}`
|
||||
}
|
||||
|
||||
Vue.prototype.$secondsToTimestamp = (seconds) => {
|
||||
@@ -125,6 +133,31 @@ Vue.prototype.$sanitizeFilename = (input, replacement = '') => {
|
||||
return sanitized
|
||||
}
|
||||
|
||||
// SOURCE: https://gist.github.com/spyesx/561b1d65d4afb595f295
|
||||
// modified: allowed underscores
|
||||
Vue.prototype.$sanitizeSlug = (str) => {
|
||||
if (!str) return ''
|
||||
|
||||
str = str.replace(/^\s+|\s+$/g, '') // trim
|
||||
str = str.toLowerCase()
|
||||
|
||||
// remove accents, swap ñ for n, etc
|
||||
var from = "àáäâèéëêìíïîòóöôùúüûñçěščřžýúůďťň·/,:;"
|
||||
var to = "aaaaeeeeiiiioooouuuuncescrzyuudtn-----"
|
||||
|
||||
for (var i = 0, l = from.length; i < l; i++) {
|
||||
str = str.replace(new RegExp(from.charAt(i), 'g'), to.charAt(i))
|
||||
}
|
||||
|
||||
str = str.replace('.', '-') // replace a dot by a dash
|
||||
.replace(/[^a-z0-9 -_]/g, '') // remove invalid chars
|
||||
.replace(/\s+/g, '-') // collapse whitespace and replace by a dash
|
||||
.replace(/-+/g, '-') // collapse dashes
|
||||
.replace(/\//g, '') // collapse all forward-slashes
|
||||
|
||||
return str
|
||||
}
|
||||
|
||||
Vue.prototype.$copyToClipboard = (str, ctx) => {
|
||||
return new Promise((resolve) => {
|
||||
if (!navigator.clipboard) {
|
||||
|
||||
93
client/static/fonts/Source_Sans_Pro/OFL.txt
Normal file
93
client/static/fonts/Source_Sans_Pro/OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
||||
Copyright 2010, 2012, 2014 Adobe Systems Incorporated (http://www.adobe.com/), with Reserved Font Name ‘Source’.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
BIN
client/static/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf
Normal file
BIN
client/static/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf
Normal file
Binary file not shown.
BIN
client/static/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf
Normal file
BIN
client/static/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf
Normal file
Binary file not shown.
BIN
client/static/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf
Normal file
BIN
client/static/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf
Normal file
Binary file not shown.
96
client/static/fonts/Ubuntu_Mono/UFL.txt
Normal file
96
client/static/fonts/Ubuntu_Mono/UFL.txt
Normal file
@@ -0,0 +1,96 @@
|
||||
-------------------------------
|
||||
UBUNTU FONT LICENCE Version 1.0
|
||||
-------------------------------
|
||||
|
||||
PREAMBLE
|
||||
This licence allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely. The fonts, including any derivative works, can be
|
||||
bundled, embedded, and redistributed provided the terms of this licence
|
||||
are met. The fonts and derivatives, however, cannot be released under
|
||||
any other licence. The requirement for fonts to remain under this
|
||||
licence does not require any document created using the fonts or their
|
||||
derivatives to be published under this licence, as long as the primary
|
||||
purpose of the document is not to be a vehicle for the distribution of
|
||||
the fonts.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this licence and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Original Version" refers to the collection of Font Software components
|
||||
as received under this licence.
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to
|
||||
a new environment.
|
||||
|
||||
"Copyright Holder(s)" refers to all individuals and companies who have a
|
||||
copyright ownership of the Font Software.
|
||||
|
||||
"Substantially Changed" refers to Modified Versions which can be easily
|
||||
identified as dissimilar to the Font Software by users of the Font
|
||||
Software comparing the Original Version with the Modified Version.
|
||||
|
||||
To "Propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification and with or without charging
|
||||
a redistribution fee), making available to the public, and in some
|
||||
countries other activities as well.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
This licence does not grant any rights under trademark law and all such
|
||||
rights are reserved.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a
|
||||
copy of the Font Software, to propagate the Font Software, subject to
|
||||
the below conditions:
|
||||
|
||||
1) Each copy of the Font Software must contain the above copyright
|
||||
notice and this licence. These can be included either as stand-alone
|
||||
text files, human-readable headers or in the appropriate machine-
|
||||
readable metadata fields within text or binary files as long as those
|
||||
fields can be easily viewed by the user.
|
||||
|
||||
2) The font name complies with the following:
|
||||
(a) The Original Version must retain its name, unmodified.
|
||||
(b) Modified Versions which are Substantially Changed must be renamed to
|
||||
avoid use of the name of the Original Version or similar names entirely.
|
||||
(c) Modified Versions which are not Substantially Changed must be
|
||||
renamed to both (i) retain the name of the Original Version and (ii) add
|
||||
additional naming elements to distinguish the Modified Version from the
|
||||
Original Version. The name of such Modified Versions must be the name of
|
||||
the Original Version, with "derivative X" where X represents the name of
|
||||
the new work, appended to that name.
|
||||
|
||||
3) The name(s) of the Copyright Holder(s) and any contributor to the
|
||||
Font Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except (i) as required by this licence, (ii) to
|
||||
acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with
|
||||
their explicit written permission.
|
||||
|
||||
4) The Font Software, modified or unmodified, in part or in whole, must
|
||||
be distributed entirely under this licence, and must not be distributed
|
||||
under any other licence. The requirement for fonts to remain under this
|
||||
licence does not affect any document created using the Font Software,
|
||||
except any version of the Font Software extracted from a document
|
||||
created using the Font Software may only be distributed under this
|
||||
licence.
|
||||
|
||||
TERMINATION
|
||||
This licence becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
|
||||
COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
|
||||
DEALINGS IN THE FONT SOFTWARE.
|
||||
BIN
client/static/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf
Normal file
BIN
client/static/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf
Normal file
Binary file not shown.
@@ -6,8 +6,10 @@ export const state = () => ({
|
||||
showUserCollectionsModal: false,
|
||||
showEditCollectionModal: false,
|
||||
showEditPodcastEpisode: false,
|
||||
showEditAuthorModal: false,
|
||||
selectedEpisode: null,
|
||||
selectedCollection: null,
|
||||
selectedAuthor: null,
|
||||
showBookshelfTextureModal: false,
|
||||
isCasting: false, // Actively casting
|
||||
isChromecastInitialized: false // Script loaded
|
||||
@@ -61,6 +63,16 @@ export const mutations = {
|
||||
setShowBookshelfTextureModal(state, val) {
|
||||
state.showBookshelfTextureModal = val
|
||||
},
|
||||
showEditAuthorModal(state, author) {
|
||||
state.selectedAuthor = author
|
||||
state.showEditAuthorModal = true
|
||||
},
|
||||
setShowEditAuthorModal(state, val) {
|
||||
state.showEditAuthorModal = val
|
||||
},
|
||||
setSelectedAuthor(state, author) {
|
||||
state.selectedAuthor = author
|
||||
},
|
||||
setChromecastInitialized(state, val) {
|
||||
state.isChromecastInitialized = val
|
||||
},
|
||||
|
||||
@@ -15,8 +15,6 @@ export const state = () => ({
|
||||
selectedLibraryItems: [],
|
||||
processingBatch: false,
|
||||
previousPath: '/',
|
||||
routeHistory: [],
|
||||
isRoutingBack: false,
|
||||
showExperimentalFeatures: false,
|
||||
backups: [],
|
||||
bookshelfBookIds: [],
|
||||
@@ -33,7 +31,7 @@ export const getters = {
|
||||
return state.serverSettings[key]
|
||||
},
|
||||
getBookCoverAspectRatio: state => {
|
||||
if (!state.serverSettings || !state.serverSettings.coverAspectRatio) return 1
|
||||
if (!state.serverSettings || isNaN(state.serverSettings.coverAspectRatio)) return 1
|
||||
return state.serverSettings.coverAspectRatio === 0 ? 1.6 : 1
|
||||
},
|
||||
getNumLibraryItemsSelected: state => state.selectedLibraryItems.length,
|
||||
@@ -74,15 +72,6 @@ export const actions = {
|
||||
return false
|
||||
})
|
||||
},
|
||||
popRoute({ commit, state }) {
|
||||
if (!state.routeHistory.length) {
|
||||
return null
|
||||
}
|
||||
var _history = [...state.routeHistory]
|
||||
var last = _history.pop()
|
||||
commit('setRouteHistory', _history)
|
||||
return last
|
||||
},
|
||||
setBookshelfTexture({ commit, state }, img) {
|
||||
let root = document.documentElement;
|
||||
commit('setBookshelfTexture', img)
|
||||
@@ -94,12 +83,6 @@ export const mutations = {
|
||||
setBookshelfBookIds(state, val) {
|
||||
state.bookshelfBookIds = val || []
|
||||
},
|
||||
setRouteHistory(state, val) {
|
||||
state.routeHistory = val
|
||||
},
|
||||
setIsRoutingBack(state, val) {
|
||||
state.isRoutingBack = val
|
||||
},
|
||||
setPreviousPath(state, val) {
|
||||
state.previousPath = val
|
||||
},
|
||||
|
||||
@@ -72,6 +72,9 @@ export const actions = {
|
||||
if (state.settings.orderBy == 'media.metadata.authorName' || state.settings.orderBy == 'media.metadata.authorNameLF') {
|
||||
settingsUpdate.orderBy = 'media.metadata.author'
|
||||
}
|
||||
if (state.settings.orderBy == 'media.duration') {
|
||||
settingsUpdate.orderBy = 'media.numTracks'
|
||||
}
|
||||
var invalidFilters = ['series', 'authors', 'narrators', 'languages', 'progress', 'issues']
|
||||
var filterByFirstPart = (state.settings.filterBy || '').split('.').shift()
|
||||
if (invalidFilters.includes(filterByFirstPart)) {
|
||||
@@ -81,6 +84,9 @@ export const actions = {
|
||||
if (state.settings.orderBy == 'media.metadata.author') {
|
||||
settingsUpdate.orderBy = 'media.metadata.authorName'
|
||||
}
|
||||
if (state.settings.orderBy == 'media.numTracks') {
|
||||
settingsUpdate.orderBy = 'media.duration'
|
||||
}
|
||||
}
|
||||
if (Object.keys(settingsUpdate).length) {
|
||||
dispatch('updateUserSettings', settingsUpdate)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.0.9",
|
||||
"version": "2.0.13",
|
||||
"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": "pkg -t node12-win-x64 -o ./dist/win/audiobookshelf .",
|
||||
"build-win": "pkg -t node16-win-x64 -o ./dist/win/audiobookshelf .",
|
||||
"build-linux": "build/linuxpackager",
|
||||
"docker": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 --push . -t advplyr/audiobookshelf",
|
||||
"deploy": "node dist/autodeploy"
|
||||
|
||||
@@ -64,7 +64,7 @@ Available using Test Flight: https://testflight.apple.com/join/wiic7QIW - [Join
|
||||
Available in Unraid Community Apps
|
||||
|
||||
```bash
|
||||
docker pull advplyr/audiobookshelf
|
||||
docker pull ghcr.io/advplyr/audiobookshelf
|
||||
|
||||
docker run -d \
|
||||
-e AUDIOBOOKSHELF_UID=99 \
|
||||
|
||||
@@ -140,7 +140,6 @@ class FolderWatcher extends EventEmitter {
|
||||
return
|
||||
}
|
||||
Logger.debug(`[Watcher] Rename ${pathFrom} => ${pathTo}`)
|
||||
this.addFileUpdate(libraryId, pathFrom, 'renamed')
|
||||
this.addFileUpdate(libraryId, pathTo, 'renamed')
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,60 @@
|
||||
const Logger = require('../Logger')
|
||||
const { reqSupportsWebp } = require('../utils/index')
|
||||
const { createNewSortInstance } = require('fast-sort')
|
||||
|
||||
const naturalSort = createNewSortInstance({
|
||||
comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
|
||||
})
|
||||
class AuthorController {
|
||||
constructor() { }
|
||||
|
||||
async findOne(req, res) {
|
||||
return res.json(req.author)
|
||||
const include = (req.query.include || '').split(',')
|
||||
|
||||
const authorJson = req.author.toJSON()
|
||||
|
||||
// Used on author landing page to include library items and items grouped in series
|
||||
if (include.includes('items')) {
|
||||
authorJson.libraryItems = this.db.libraryItems.filter(li => {
|
||||
if (!req.user.checkCanAccessLibraryItem(li)) return false // filter out library items user cannot access
|
||||
return li.media.metadata.hasAuthor && li.media.metadata.hasAuthor(req.author.id)
|
||||
})
|
||||
|
||||
if (include.includes('series')) {
|
||||
const seriesMap = {}
|
||||
// Group items into series
|
||||
authorJson.libraryItems.forEach((li) => {
|
||||
if (li.media.metadata.series) {
|
||||
li.media.metadata.series.forEach((series) => {
|
||||
|
||||
const itemWithSeries = li.toJSONMinified()
|
||||
itemWithSeries.media.metadata.series = series
|
||||
|
||||
if (seriesMap[series.id]) {
|
||||
seriesMap[series.id].items.push(itemWithSeries)
|
||||
} else {
|
||||
seriesMap[series.id] = {
|
||||
id: series.id,
|
||||
name: series.name,
|
||||
items: [itemWithSeries]
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
// Sort series items
|
||||
for (const key in seriesMap) {
|
||||
seriesMap[key].items = naturalSort(seriesMap[key].items).asc(li => li.media.metadata.series.sequence)
|
||||
}
|
||||
|
||||
authorJson.series = Object.values(seriesMap)
|
||||
}
|
||||
|
||||
// Minify library items
|
||||
authorJson.libraryItems = authorJson.libraryItems.map(li => li.toJSONMinified())
|
||||
}
|
||||
|
||||
return res.json(authorJson)
|
||||
}
|
||||
|
||||
async update(req, res) {
|
||||
@@ -41,6 +90,7 @@ class AuthorController {
|
||||
}).length
|
||||
this.emitter('author_updated', req.author.toJSONExpanded(numBooks))
|
||||
}
|
||||
|
||||
res.json({
|
||||
author: req.author.toJSON(),
|
||||
updated: hasUpdated
|
||||
|
||||
@@ -4,16 +4,16 @@ class BackupController {
|
||||
constructor() { }
|
||||
|
||||
async create(req, res) {
|
||||
if (!req.user.isRoot) {
|
||||
Logger.error(`[BackupController] Non-Root user attempting to craete backup`, req.user)
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[BackupController] Non-admin user attempting to craete backup`, req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
this.backupManager.requestCreateBackup(res)
|
||||
}
|
||||
|
||||
async delete(req, res) {
|
||||
if (!req.user.isRoot) {
|
||||
Logger.error(`[BackupController] Non-Root user attempting to delete backup`, req.user)
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[BackupController] Non-admin user attempting to delete backup`, req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
var backup = this.backupManager.backups.find(b => b.id === req.params.id)
|
||||
@@ -25,8 +25,8 @@ class BackupController {
|
||||
}
|
||||
|
||||
async upload(req, res) {
|
||||
if (!req.user.isRoot) {
|
||||
Logger.error(`[BackupController] Non-Root user attempting to upload backup`, req.user)
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[BackupController] Non-admin user attempting to upload backup`, req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
if (!req.files.file) {
|
||||
@@ -37,8 +37,8 @@ class BackupController {
|
||||
}
|
||||
|
||||
async apply(req, res) {
|
||||
if (!req.user.isRoot) {
|
||||
Logger.error(`[BackupController] Non-Root user attempting to apply backup`, req.user)
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[BackupController] Non-admin user attempting to apply backup`, req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
var backup = this.backupManager.backups.find(b => b.id === req.params.id)
|
||||
|
||||
@@ -320,7 +320,7 @@ class LibraryController {
|
||||
|
||||
// PATCH: Change the order of libraries
|
||||
async reorder(req, res) {
|
||||
if (!req.user.isRoot) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error('[LibraryController] ReorderLibraries invalid user', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
@@ -457,7 +457,7 @@ class LibraryController {
|
||||
}
|
||||
|
||||
async matchAll(req, res) {
|
||||
if (!req.user.isRoot) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[LibraryController] Non-root user attempted to match library items`, req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
@@ -467,7 +467,7 @@ class LibraryController {
|
||||
|
||||
// GET: api/scan (Root)
|
||||
async scan(req, res) {
|
||||
if (!req.user.isRoot) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[LibraryController] Non-root user attempted to scan library`, req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
@@ -331,8 +331,8 @@ class LibraryItemController {
|
||||
|
||||
// DELETE: api/items/all
|
||||
async deleteAll(req, res) {
|
||||
if (!req.user.isRoot) {
|
||||
Logger.warn('User other than root attempted to delete all library items', req.user)
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.warn('User other than admin attempted to delete all library items', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
Logger.info('Removing all Library Items')
|
||||
@@ -341,10 +341,10 @@ class LibraryItemController {
|
||||
else res.sendStatus(500)
|
||||
}
|
||||
|
||||
// GET: api/items/:id/scan (Root)
|
||||
// GET: api/items/:id/scan (admin)
|
||||
async scan(req, res) {
|
||||
if (!req.user.isRoot) {
|
||||
Logger.error(`[LibraryItemController] Non-root user attempted to scan library item`, req.user)
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[LibraryItemController] Non-admin user attempted to scan library item`, req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
@@ -359,9 +359,9 @@ class LibraryItemController {
|
||||
})
|
||||
}
|
||||
|
||||
// POST: api/items/:id/audio-metadata
|
||||
// GET: api/items/:id/audio-metadata
|
||||
async updateAudioFileMetadata(req, res) {
|
||||
if (!req.user.isRoot) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[LibraryItemController] Non-root user attempted to update audio metadata`, req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
@@ -375,17 +375,42 @@ class LibraryItemController {
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
// POST: api/items/:id/chapters
|
||||
async updateMediaChapters(req, res) {
|
||||
if (!req.user.canUpdate) {
|
||||
Logger.error(`[LibraryItemController] User attempted to update chapters with invalid permissions`, req.user.username)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) {
|
||||
Logger.error(`[LibraryItemController] Invalid library item`)
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
|
||||
const chapters = req.body.chapters || []
|
||||
if (!chapters.length) {
|
||||
Logger.error(`[LibraryItemController] Invalid payload`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
||||
const wasUpdated = req.libraryItem.media.updateChapters(chapters)
|
||||
if (wasUpdated) {
|
||||
await this.db.updateLibraryItem(req.libraryItem)
|
||||
this.emitter('item_updated', req.libraryItem.toJSONExpanded())
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
updated: wasUpdated
|
||||
})
|
||||
}
|
||||
|
||||
middleware(req, res, next) {
|
||||
var item = this.db.libraryItems.find(li => li.id === req.params.id)
|
||||
if (!item || !item.media) return res.sendStatus(404)
|
||||
|
||||
// Check user can access this library
|
||||
if (!req.user.checkCanAccessLibrary(item.libraryId)) {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
// Check user can access this library item
|
||||
if (!req.user.checkCanAccessLibraryItemWithTags(item.media.tags)) {
|
||||
if (!req.user.checkCanAccessLibraryItem(item)) {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
|
||||
@@ -159,10 +159,10 @@ class MiscController {
|
||||
res.json(downloads)
|
||||
}
|
||||
|
||||
// PATCH: api/settings (Root)
|
||||
// PATCH: api/settings (admin)
|
||||
async updateServerSettings(req, res) {
|
||||
if (!req.user.isRoot) {
|
||||
Logger.error('User other than root attempting to update server settings', req.user)
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error('User other than admin attempting to update server settings', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
var settingsUpdate = req.body
|
||||
@@ -185,9 +185,9 @@ class MiscController {
|
||||
})
|
||||
}
|
||||
|
||||
// POST: api/purgecache (Root)
|
||||
// POST: api/purgecache (admin)
|
||||
async purgeCache(req, res) {
|
||||
if (!req.user.isRoot) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
Logger.info(`[ApiRouter] Purging all cache`)
|
||||
@@ -225,6 +225,15 @@ class MiscController {
|
||||
res.json(author)
|
||||
}
|
||||
|
||||
async findChapters(req, res) {
|
||||
var asin = req.query.asin
|
||||
var chapterData = await this.bookFinder.findChapters(asin)
|
||||
if (!chapterData) {
|
||||
return res.json({ error: 'Chapters not found' })
|
||||
}
|
||||
res.json(chapterData)
|
||||
}
|
||||
|
||||
authorize(req, res) {
|
||||
if (!req.user) {
|
||||
Logger.error('Invalid user in authorize')
|
||||
@@ -239,8 +248,8 @@ class MiscController {
|
||||
}
|
||||
|
||||
getAllTags(req, res) {
|
||||
if (!req.user.isRoot) {
|
||||
Logger.error(`[MiscController] Non-root user attempted to getAllTags`)
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[MiscController] Non-admin user attempted to getAllTags`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
var tags = []
|
||||
|
||||
@@ -173,6 +173,12 @@ class PodcastController {
|
||||
}
|
||||
|
||||
const feedData = this.rssFeedManager.openPodcastFeed(req.user, req.libraryItem, req.body)
|
||||
if (feedData.error) {
|
||||
return res.json({
|
||||
success: false,
|
||||
error: feedData.error
|
||||
})
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
|
||||
@@ -7,14 +7,15 @@ class UserController {
|
||||
constructor() { }
|
||||
|
||||
findAll(req, res) {
|
||||
if (!req.user.isRoot) return res.sendStatus(403)
|
||||
var users = this.db.users.map(u => this.userJsonWithItemProgressDetails(u))
|
||||
if (!req.user.isAdminOrUp) return res.sendStatus(403)
|
||||
const hideRootToken = !req.user.isRoot
|
||||
var users = this.db.users.map(u => this.userJsonWithItemProgressDetails(u, hideRootToken))
|
||||
res.json(users)
|
||||
}
|
||||
|
||||
findOne(req, res) {
|
||||
if (!req.user.isRoot) {
|
||||
Logger.error('User other than root attempting to get user', req.user)
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error('User other than admin attempting to get user', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
@@ -23,12 +24,12 @@ class UserController {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
res.json(this.userJsonWithItemProgressDetails(user))
|
||||
res.json(this.userJsonWithItemProgressDetails(user, !req.user.isRoot))
|
||||
}
|
||||
|
||||
async create(req, res) {
|
||||
if (!req.user.isRoot) {
|
||||
Logger.warn('Non-root user attempted to create user', req.user)
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.warn('Non-admin user attempted to create user', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
var account = req.body
|
||||
@@ -57,8 +58,8 @@ class UserController {
|
||||
}
|
||||
|
||||
async update(req, res) {
|
||||
if (!req.user.isRoot) {
|
||||
Logger.error('User other than root attempting to update user', req.user)
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error('[UserController] User other than admin attempting to update user', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
@@ -67,6 +68,11 @@ class UserController {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
if (user.type === 'root' && !req.user.isRoot) {
|
||||
Logger.error(`[UserController] Admin user attempted to update root user`, req.user.username)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
var account = req.body
|
||||
|
||||
if (account.username !== undefined && account.username !== user.username) {
|
||||
@@ -95,8 +101,8 @@ class UserController {
|
||||
}
|
||||
|
||||
async delete(req, res) {
|
||||
if (!req.user.isRoot) {
|
||||
Logger.error('User other than root attempting to delete user', req.user)
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error('User other than admin attempting to delete user', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
if (req.params.id === 'root') {
|
||||
@@ -133,7 +139,7 @@ class UserController {
|
||||
|
||||
// GET: api/users/:id/listening-sessions
|
||||
async getListeningSessions(req, res) {
|
||||
if (!req.user.isRoot && req.user.id !== req.params.id) {
|
||||
if (!req.user.isAdminOrUp && req.user.id !== req.params.id) {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
var listeningSessions = await this.getUserListeningSessionsHelper(req.params.id)
|
||||
@@ -142,7 +148,7 @@ class UserController {
|
||||
|
||||
// GET: api/users/:id/listening-stats
|
||||
async getListeningStats(req, res) {
|
||||
if (!req.user.isRoot && req.user.id !== req.params.id) {
|
||||
if (!req.user.isAdminOrUp && req.user.id !== req.params.id) {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
var listeningStats = await this.getUserListeningStatsHelpers(req.params.id)
|
||||
|
||||
@@ -21,7 +21,7 @@ class AuthorFinder {
|
||||
|
||||
async findAuthorByName(name, options = {}) {
|
||||
if (!name) return null
|
||||
const maxLevenshtein = !isNaN(options.maxLevenshtein) ? Number(options.maxLevenshtein) : 2
|
||||
const maxLevenshtein = !isNaN(options.maxLevenshtein) ? Number(options.maxLevenshtein) : 3
|
||||
|
||||
var author = await this.audnexus.findAuthorByName(name, maxLevenshtein)
|
||||
if (!author || !author.name) {
|
||||
|
||||
@@ -3,6 +3,7 @@ const LibGen = require('../providers/LibGen')
|
||||
const GoogleBooks = require('../providers/GoogleBooks')
|
||||
const Audible = require('../providers/Audible')
|
||||
const iTunes = require('../providers/iTunes')
|
||||
const Audnexus = require('../providers/Audnexus')
|
||||
const Logger = require('../Logger')
|
||||
const { levenshteinDistance } = require('../utils/index')
|
||||
|
||||
@@ -13,6 +14,7 @@ class BookFinder {
|
||||
this.googleBooks = new GoogleBooks()
|
||||
this.audible = new Audible()
|
||||
this.iTunesApi = new iTunes()
|
||||
this.audnexus = new Audnexus()
|
||||
|
||||
this.verbose = false
|
||||
}
|
||||
@@ -226,5 +228,9 @@ class BookFinder {
|
||||
})
|
||||
return covers
|
||||
}
|
||||
|
||||
findChapters(asin) {
|
||||
return this.audnexus.getChaptersByASIN(asin)
|
||||
}
|
||||
}
|
||||
module.exports = BookFinder
|
||||
@@ -131,8 +131,21 @@ class BackupManager {
|
||||
var filename = filesInDir[i]
|
||||
if (filename.endsWith('.audiobookshelf')) {
|
||||
var fullFilePath = Path.join(this.BackupPath, filename)
|
||||
const zip = new StreamZip.async({ file: fullFilePath })
|
||||
const data = await zip.entryData('details')
|
||||
|
||||
let zip = null
|
||||
let data = null
|
||||
try {
|
||||
zip = new StreamZip.async({ file: fullFilePath })
|
||||
data = await zip.entryData('details')
|
||||
} catch (error) {
|
||||
if (error.message === "Bad archive") {
|
||||
Logger.warn(`[BackupManager] Backup appears to be corrupted: ${fullFilePath}`)
|
||||
continue;
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
var details = data.toString('utf8').split('\n')
|
||||
|
||||
var backup = new Backup({ details, fullPath: fullFilePath })
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const Path = require('path')
|
||||
const date = require('date-and-time')
|
||||
const { PlayMethod } = require('../utils/constants')
|
||||
const PlaybackSession = require('../objects/PlaybackSession')
|
||||
const Stream = require('../objects/Stream')
|
||||
@@ -40,6 +41,10 @@ class PlaybackSessionManager {
|
||||
|
||||
async syncLocalSessionRequest(user, sessionJson, res) {
|
||||
var libraryItem = this.db.getLibraryItem(sessionJson.libraryItemId)
|
||||
if (!libraryItem) {
|
||||
Logger.error(`[PlaybackSessionManager] syncLocalSessionRequest: Library item not found for session "${sessionJson.libraryItemId}"`)
|
||||
return res.sendStatus(200)
|
||||
}
|
||||
|
||||
var session = await this.db.getPlaybackSession(sessionJson.id)
|
||||
if (!session) {
|
||||
@@ -49,6 +54,8 @@ class PlaybackSessionManager {
|
||||
} else {
|
||||
session.timeListening = sessionJson.timeListening
|
||||
session.updatedAt = sessionJson.updatedAt
|
||||
session.date = date.format(new Date(), 'YYYY-MM-DD')
|
||||
session.dayOfWeek = date.format(new Date(), 'dddd')
|
||||
await this.db.updateEntity('session', session)
|
||||
}
|
||||
|
||||
|
||||
@@ -183,8 +183,15 @@ class PodcastManager {
|
||||
|
||||
for (const libraryItem of podcastsWithAutoDownload) {
|
||||
const lastEpisodeCheckDate = new Date(libraryItem.media.lastEpisodeCheck || 0)
|
||||
Logger.info(`[PodcastManager] checkForNewEpisodes Cron for "${libraryItem.media.metadata.title}" - Last episode check: ${lastEpisodeCheckDate}`)
|
||||
var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem)
|
||||
const latestEpisodePublishedAt = libraryItem.media.latestEpisodePublished
|
||||
Logger.info(`[PodcastManager] checkForNewEpisodes: "${libraryItem.media.metadata.title}" | Last check: ${lastEpisodeCheckDate} | ${latestEpisodePublishedAt ? `Latest episode pubDate: ${new Date(latestEpisodePublishedAt)}` : 'No latest episode'}`)
|
||||
|
||||
// Use latest episode pubDate if exists OR fallback to using lastEpisodeCheckDate
|
||||
// lastEpisodeCheckDate will be the current time when adding a new podcast
|
||||
const dateToCheckForEpisodesAfter = latestEpisodePublishedAt || lastEpisodeCheckDate
|
||||
Logger.debug(`[PodcastManager] checkForNewEpisodes: "${libraryItem.media.metadata.title}" checking for episodes after ${new Date(dateToCheckForEpisodesAfter)}`)
|
||||
|
||||
var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, dateToCheckForEpisodesAfter)
|
||||
Logger.debug(`[PodcastManager] checkForNewEpisodes checked result ${newEpisodes ? newEpisodes.length : 'N/A'}`)
|
||||
|
||||
if (!newEpisodes) { // Failed
|
||||
@@ -214,7 +221,7 @@ class PodcastManager {
|
||||
}
|
||||
}
|
||||
|
||||
async checkPodcastForNewEpisodes(podcastLibraryItem) {
|
||||
async checkPodcastForNewEpisodes(podcastLibraryItem, dateToCheckForEpisodesAfter) {
|
||||
if (!podcastLibraryItem.media.metadata.feedUrl) {
|
||||
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes no feed url for ${podcastLibraryItem.media.metadata.title} (ID: ${podcastLibraryItem.id})`)
|
||||
return false
|
||||
@@ -225,15 +232,8 @@ class PodcastManager {
|
||||
return false
|
||||
}
|
||||
|
||||
// Added for testing
|
||||
Logger.debug(`[PodcastManager] checkPodcastForNewEpisodes: ${feed.episodes.length} episodes in feed for "${podcastLibraryItem.media.metadata.title}"`)
|
||||
const latestEpisodes = feed.episodes.slice(0, 3)
|
||||
latestEpisodes.forEach((ep) => {
|
||||
Logger.debug(`[PodcastManager] checkPodcastForNewEpisodes: Recent episode "${ep.title}", pubDate=${ep.pubDate}, publishedAt=${ep.publishedAt}/${new Date(ep.publishedAt)} for "${podcastLibraryItem.media.metadata.title}"`)
|
||||
})
|
||||
|
||||
// Filter new and not already has
|
||||
var newEpisodes = feed.episodes.filter(ep => ep.publishedAt > podcastLibraryItem.media.lastEpisodeCheck && !podcastLibraryItem.media.checkHasEpisodeByFeedUrl(ep.enclosure.url))
|
||||
var newEpisodes = feed.episodes.filter(ep => ep.publishedAt > dateToCheckForEpisodesAfter && !podcastLibraryItem.media.checkHasEpisodeByFeedUrl(ep.enclosure.url))
|
||||
// Max new episodes for safety = 3
|
||||
newEpisodes = newEpisodes.slice(0, 3)
|
||||
return newEpisodes
|
||||
@@ -242,7 +242,7 @@ class PodcastManager {
|
||||
async checkAndDownloadNewEpisodes(libraryItem) {
|
||||
const lastEpisodeCheckDate = new Date(libraryItem.media.lastEpisodeCheck || 0)
|
||||
Logger.info(`[PodcastManager] checkAndDownloadNewEpisodes for "${libraryItem.media.metadata.title}" - Last episode check: ${lastEpisodeCheckDate}`)
|
||||
var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem)
|
||||
var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, libraryItem.media.lastEpisodeCheck)
|
||||
if (newEpisodes.length) {
|
||||
Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast "${libraryItem.media.metadata.title}" - starting download`)
|
||||
this.downloadPodcastEpisodes(libraryItem, newEpisodes)
|
||||
|
||||
@@ -59,23 +59,23 @@ class RssFeedManager {
|
||||
readStream.pipe(res)
|
||||
}
|
||||
|
||||
openFeed(userId, feedId, libraryItem, serverAddress) {
|
||||
openFeed(userId, slug, libraryItem, serverAddress) {
|
||||
const podcast = libraryItem.media
|
||||
|
||||
const feedUrl = `${serverAddress}/feed/${feedId}`
|
||||
const feedUrl = `${serverAddress}/feed/${slug}`
|
||||
// Removed Podcast npm package and ip package
|
||||
const feed = new Podcast({
|
||||
title: podcast.metadata.title,
|
||||
description: podcast.metadata.description,
|
||||
feedUrl,
|
||||
siteUrl: serverAddress,
|
||||
imageUrl: podcast.coverPath ? `${serverAddress}/feed/${feedId}/cover` : `${serverAddress}/Logo.png`,
|
||||
imageUrl: podcast.coverPath ? `${serverAddress}/feed/${slug}/cover` : `${serverAddress}/Logo.png`,
|
||||
author: podcast.metadata.author || 'advplyr',
|
||||
language: 'en'
|
||||
})
|
||||
podcast.episodes.forEach((episode) => {
|
||||
var contentUrl = episode.audioTrack.contentUrl.replace(/\\/g, '/')
|
||||
contentUrl = contentUrl.replace(`/s/item/${libraryItem.id}`, `/feed/${feedId}/item`)
|
||||
contentUrl = contentUrl.replace(`/s/item/${libraryItem.id}`, `/feed/${slug}/item`)
|
||||
|
||||
feed.addItem({
|
||||
title: episode.title,
|
||||
@@ -92,7 +92,8 @@ class RssFeedManager {
|
||||
})
|
||||
|
||||
const feedData = {
|
||||
id: feedId,
|
||||
id: slug,
|
||||
slug,
|
||||
userId,
|
||||
libraryItemId: libraryItem.id,
|
||||
libraryItemPath: libraryItem.path,
|
||||
@@ -101,14 +102,22 @@ class RssFeedManager {
|
||||
feedUrl,
|
||||
feed
|
||||
}
|
||||
this.feeds[feedId] = feedData
|
||||
this.feeds[slug] = feedData
|
||||
return feedData
|
||||
}
|
||||
|
||||
openPodcastFeed(user, libraryItem, options) {
|
||||
const serverAddress = options.serverAddress
|
||||
const feedId = getId('feed')
|
||||
const feedData = this.openFeed(user.id, feedId, libraryItem, serverAddress)
|
||||
const slug = options.slug
|
||||
|
||||
if (this.feeds[slug]) {
|
||||
Logger.error(`[RssFeedManager] Slug already in use`)
|
||||
return {
|
||||
error: `Slug "${slug}" already in use`
|
||||
}
|
||||
}
|
||||
|
||||
const feedData = this.openFeed(user.id, slug, libraryItem, serverAddress)
|
||||
Logger.debug(`[RssFeedManager] Opened podcast feed ${feedData.feedUrl}`)
|
||||
this.emitter('rss_feed_open', { libraryItemId: libraryItem.id, feedUrl: feedData.feedUrl })
|
||||
return feedData
|
||||
|
||||
@@ -288,7 +288,6 @@ class LibraryItem {
|
||||
// FileMetadata keys
|
||||
['filename', 'ext', 'mtimeMs', 'ctimeMs', 'birthtimeMs', 'size'].forEach((key) => {
|
||||
if (existingFile.metadata[key] !== fileFound.metadata[key]) {
|
||||
|
||||
// Add modified flag on file data object if exists and was changed
|
||||
if (key === 'mtimeMs' && existingFile.metadata[key]) {
|
||||
fileFound.metadata.wasModified = true
|
||||
@@ -348,7 +347,7 @@ class LibraryItem {
|
||||
var fileFoundCheck = this.checkFileFound(lf, true)
|
||||
if (fileFoundCheck === null) {
|
||||
newLibraryFiles.push(lf)
|
||||
} else if (fileFoundCheck) {
|
||||
} else if (fileFoundCheck && lf.metadata.format !== 'abs') { // Ignore abs file updates
|
||||
hasUpdated = true
|
||||
existingLibraryFiles.push(lf)
|
||||
} else {
|
||||
|
||||
@@ -9,6 +9,7 @@ class PodcastEpisode {
|
||||
this.id = null
|
||||
this.index = null
|
||||
|
||||
this.season = null
|
||||
this.episode = null
|
||||
this.episodeType = null
|
||||
this.title = null
|
||||
@@ -31,6 +32,7 @@ class PodcastEpisode {
|
||||
this.libraryItemId = episode.libraryItemId
|
||||
this.id = episode.id
|
||||
this.index = episode.index
|
||||
this.season = episode.season
|
||||
this.episode = episode.episode
|
||||
this.episodeType = episode.episodeType
|
||||
this.title = episode.title
|
||||
@@ -51,6 +53,7 @@ class PodcastEpisode {
|
||||
libraryItemId: this.libraryItemId,
|
||||
id: this.id,
|
||||
index: this.index,
|
||||
season: this.season,
|
||||
episode: this.episode,
|
||||
episodeType: this.episodeType,
|
||||
title: this.title,
|
||||
@@ -70,6 +73,7 @@ class PodcastEpisode {
|
||||
libraryItemId: this.libraryItemId,
|
||||
id: this.id,
|
||||
index: this.index,
|
||||
season: this.season,
|
||||
episode: this.episode,
|
||||
episodeType: this.episodeType,
|
||||
title: this.title,
|
||||
@@ -117,6 +121,7 @@ class PodcastEpisode {
|
||||
this.pubDate = data.pubDate || ''
|
||||
this.description = data.description || ''
|
||||
this.enclosure = data.enclosure ? { ...data.enclosure } : null
|
||||
this.season = data.season || ''
|
||||
this.episode = data.episode || ''
|
||||
this.episodeType = data.episodeType || ''
|
||||
this.publishedAt = data.publishedAt || 0
|
||||
|
||||
@@ -153,6 +153,30 @@ class Book {
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
updateChapters(chapters) {
|
||||
var hasUpdates = this.chapters.length !== chapters.length
|
||||
if (hasUpdates) {
|
||||
this.chapters = chapters.map(ch => ({
|
||||
id: ch.id,
|
||||
start: ch.start,
|
||||
end: ch.end,
|
||||
title: ch.title
|
||||
}))
|
||||
} else {
|
||||
for (let i = 0; i < this.chapters.length; i++) {
|
||||
const currChapter = this.chapters[i]
|
||||
const newChapter = chapters[i]
|
||||
if (!hasUpdates && (currChapter.title !== newChapter.title || currChapter.start !== newChapter.start || currChapter.end !== newChapter.end)) {
|
||||
hasUpdates = true
|
||||
}
|
||||
this.chapters[i].title = newChapter.title
|
||||
this.chapters[i].start = newChapter.start
|
||||
this.chapters[i].end = newChapter.end
|
||||
}
|
||||
}
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
updateCover(coverPath) {
|
||||
coverPath = coverPath.replace(/\\/g, '/')
|
||||
if (this.coverPath === coverPath) return false
|
||||
@@ -381,19 +405,27 @@ class Book {
|
||||
// If audio file has chapters use chapters
|
||||
if (file.chapters && file.chapters.length) {
|
||||
file.chapters.forEach((chapter) => {
|
||||
var chapterDuration = chapter.end - chapter.start
|
||||
if (chapterDuration > 0) {
|
||||
var title = `Chapter ${currChapterId}`
|
||||
if (chapter.title) {
|
||||
title += ` (${chapter.title})`
|
||||
if (chapter.start > this.duration) {
|
||||
Logger.warn(`[Book] Invalid chapter start time > duration`)
|
||||
} else {
|
||||
var chapterAlreadyExists = this.chapters.find(ch => ch.start === chapter.start)
|
||||
if (!chapterAlreadyExists) {
|
||||
var chapterDuration = chapter.end - chapter.start
|
||||
if (chapterDuration > 0) {
|
||||
var title = `Chapter ${currChapterId}`
|
||||
if (chapter.title) {
|
||||
title += ` (${chapter.title})`
|
||||
}
|
||||
var endTime = Math.min(this.duration, currStartTime + chapterDuration)
|
||||
this.chapters.push({
|
||||
id: currChapterId++,
|
||||
start: currStartTime,
|
||||
end: endTime,
|
||||
title
|
||||
})
|
||||
currStartTime += chapterDuration
|
||||
}
|
||||
}
|
||||
this.chapters.push({
|
||||
id: currChapterId++,
|
||||
start: currStartTime,
|
||||
end: currStartTime + chapterDuration,
|
||||
title
|
||||
})
|
||||
currStartTime += chapterDuration
|
||||
}
|
||||
})
|
||||
} else if (file.duration) {
|
||||
|
||||
@@ -104,6 +104,15 @@ class Podcast {
|
||||
get numTracks() {
|
||||
return this.episodes.length
|
||||
}
|
||||
get latestEpisodePublished() {
|
||||
var largestPublishedAt = 0
|
||||
this.episodes.forEach((ep) => {
|
||||
if (ep.publishedAt && ep.publishedAt > largestPublishedAt) {
|
||||
largestPublishedAt = ep.publishedAt
|
||||
}
|
||||
})
|
||||
return largestPublishedAt
|
||||
}
|
||||
|
||||
update(payload) {
|
||||
var json = this.toJSON()
|
||||
|
||||
@@ -341,6 +341,11 @@ class User {
|
||||
return this.itemTagsAccessible.some(tag => tags.includes(tag))
|
||||
}
|
||||
|
||||
checkCanAccessLibraryItem(libraryItem) {
|
||||
if (!this.checkCanAccessLibrary(libraryItem.libraryId)) return false
|
||||
return this.checkCanAccessLibraryItemWithTags(libraryItem.media.tags)
|
||||
}
|
||||
|
||||
findBookmark(libraryItemId, time) {
|
||||
return this.bookmarks.find(bm => bm.libraryItemId === libraryItemId && bm.time == time)
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ class Audnexus {
|
||||
})
|
||||
}
|
||||
|
||||
async findAuthorByName(name, maxLevenshtein = 2) {
|
||||
async findAuthorByName(name, maxLevenshtein = 3) {
|
||||
Logger.debug(`[Audnexus] Looking up author by name ${name}`)
|
||||
var asins = await this.authorASINsRequest(name)
|
||||
var matchingAsin = asins.find(obj => levenshteinDistance(obj.name, name) <= maxLevenshtein)
|
||||
@@ -45,5 +45,15 @@ class Audnexus {
|
||||
name: author.name
|
||||
}
|
||||
}
|
||||
|
||||
async getChaptersByASIN(asin) {
|
||||
Logger.debug(`[Audnexus] Get chapters for ASIN ${asin}`)
|
||||
return axios.get(`${this.baseUrl}/books/${asin}/chapters`).then((res) => {
|
||||
return res.data
|
||||
}).catch((error) => {
|
||||
Logger.error(`[Audnexus] Chapter ASIN request failed for ${asin}`, error)
|
||||
return null
|
||||
})
|
||||
}
|
||||
}
|
||||
module.exports = Audnexus
|
||||
@@ -92,8 +92,9 @@ class ApiRouter {
|
||||
this.router.patch('/items/:id/tracks', LibraryItemController.middleware.bind(this), LibraryItemController.updateTracks.bind(this))
|
||||
this.router.patch('/items/:id/episodes', LibraryItemController.middleware.bind(this), LibraryItemController.updateEpisodes.bind(this))
|
||||
this.router.delete('/items/:id/episode/:episodeId', LibraryItemController.middleware.bind(this), LibraryItemController.removeEpisode.bind(this))
|
||||
this.router.get('/items/:id/scan', LibraryItemController.middleware.bind(this), LibraryItemController.scan.bind(this)) // Root only
|
||||
this.router.get('/items/:id/audio-metadata', LibraryItemController.middleware.bind(this), LibraryItemController.updateAudioFileMetadata.bind(this)) // Root only
|
||||
this.router.get('/items/:id/scan', LibraryItemController.middleware.bind(this), LibraryItemController.scan.bind(this))
|
||||
this.router.get('/items/:id/audio-metadata', LibraryItemController.middleware.bind(this), LibraryItemController.updateAudioFileMetadata.bind(this))
|
||||
this.router.post('/items/:id/chapters', LibraryItemController.middleware.bind(this), LibraryItemController.updateMediaChapters.bind(this))
|
||||
|
||||
this.router.post('/items/batch/delete', LibraryItemController.batchDelete.bind(this))
|
||||
this.router.post('/items/batch/update', LibraryItemController.batchUpdate.bind(this))
|
||||
@@ -204,6 +205,7 @@ class ApiRouter {
|
||||
this.router.get('/search/books', MiscController.findBooks.bind(this))
|
||||
this.router.get('/search/podcast', MiscController.findPodcasts.bind(this))
|
||||
this.router.get('/search/authors', MiscController.findAuthor.bind(this))
|
||||
this.router.get('/search/chapters', MiscController.findChapters.bind(this))
|
||||
this.router.get('/tags', MiscController.getAllTags.bind(this))
|
||||
}
|
||||
|
||||
@@ -239,8 +241,11 @@ class ApiRouter {
|
||||
//
|
||||
// Helper Methods
|
||||
//
|
||||
userJsonWithItemProgressDetails(user) {
|
||||
userJsonWithItemProgressDetails(user, hideRootToken = false) {
|
||||
var json = user.toJSONForBrowser()
|
||||
if (json.type === 'root' && hideRootToken) {
|
||||
json.token = ''
|
||||
}
|
||||
|
||||
json.mediaProgress = json.mediaProgress.map(lip => {
|
||||
var libraryItem = this.db.libraryItems.find(li => li.id === lip.id)
|
||||
|
||||
@@ -5,6 +5,7 @@ const Path = require('path')
|
||||
const Logger = require('../Logger')
|
||||
const { groupFilesIntoLibraryItemPaths, getLibraryItemFileData, scanFolder } = require('../utils/scandir')
|
||||
const { comparePaths } = require('../utils/index')
|
||||
const { getIno } = require('../utils/fileUtils')
|
||||
const { ScanResult, LogLevel } = require('../utils/constants')
|
||||
|
||||
const AudioFileScanner = require('./AudioFileScanner')
|
||||
@@ -291,12 +292,13 @@ class Scanner {
|
||||
return this.rescanLibraryItem(lid, libraryScan)
|
||||
}))
|
||||
|
||||
itemsUpdated = itemsUpdated.filter(li => li) // Filter out nulls
|
||||
|
||||
for (const libraryItem of itemsUpdated) {
|
||||
// Temp authors & series are inserted - create them if found
|
||||
await this.createNewAuthorsAndSeries(libraryItem)
|
||||
}
|
||||
|
||||
itemsUpdated = itemsUpdated.filter(li => li) // Filter out nulls
|
||||
if (itemsUpdated.length) {
|
||||
libraryScan.resultsUpdated += itemsUpdated.length
|
||||
await this.db.updateLibraryItems(itemsUpdated)
|
||||
@@ -537,9 +539,19 @@ class Scanner {
|
||||
var itemGroupingResults = {}
|
||||
for (const itemDir in fileUpdateGroup) {
|
||||
var fullPath = Path.posix.join(folder.fullPath.replace(/\\/g, '/'), itemDir)
|
||||
const dirIno = await getIno(fullPath)
|
||||
|
||||
// Check if book dir group is already an item
|
||||
var existingLibraryItem = this.db.libraryItems.find(li => fullPath.startsWith(li.path))
|
||||
if (!existingLibraryItem) {
|
||||
existingLibraryItem = this.db.libraryItems.find(li => li.ino === dirIno)
|
||||
if (existingLibraryItem) {
|
||||
Logger.debug(`[Scanner] scanFolderUpdates: Library item found by inode value "${existingLibraryItem.relPath} => ${itemDir}"`)
|
||||
// Update library item paths for scan and all library item paths will get updated in LibraryItem.checkScanData
|
||||
existingLibraryItem.path = fullPath
|
||||
existingLibraryItem.relPath = itemDir
|
||||
}
|
||||
}
|
||||
if (existingLibraryItem) {
|
||||
// Is the item exactly - check if was deleted
|
||||
if (existingLibraryItem.path === fullPath) {
|
||||
@@ -728,16 +740,14 @@ class Scanner {
|
||||
var libraryItem = itemsInLibrary[i]
|
||||
|
||||
if (libraryItem.media.metadata.asin && library.settings.skipMatchingMediaWithAsin) {
|
||||
Logger.debug(`[Scanner] matchLibraryItems: Skipping "${
|
||||
libraryItem.media.metadata.title
|
||||
}" because it already has an ASIN (${i + 1} of ${itemsInLibrary.length})`)
|
||||
Logger.debug(`[Scanner] matchLibraryItems: Skipping "${libraryItem.media.metadata.title
|
||||
}" because it already has an ASIN (${i + 1} of ${itemsInLibrary.length})`)
|
||||
continue;
|
||||
}
|
||||
|
||||
if (libraryItem.media.metadata.isbn && library.settings.skipMatchingMediaWithIsbn) {
|
||||
Logger.debug(`[Scanner] matchLibraryItems: Skipping "${
|
||||
libraryItem.media.metadata.title
|
||||
}" because it already has an ISBN (${i + 1} of ${itemsInLibrary.length})`)
|
||||
Logger.debug(`[Scanner] matchLibraryItems: Skipping "${libraryItem.media.metadata.title
|
||||
}" because it already has an ISBN (${i + 1} of ${itemsInLibrary.length})`)
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,9 @@ module.exports.getId = (prepend = '') => {
|
||||
}
|
||||
|
||||
function elapsedPretty(seconds) {
|
||||
if (seconds < 60) {
|
||||
return `${Math.floor(seconds)} sec`
|
||||
}
|
||||
var minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 70) {
|
||||
return `${minutes} min`
|
||||
|
||||
@@ -85,7 +85,7 @@ function extractEpisodeData(item) {
|
||||
episode.descriptionPlain = stripHtml(episode.description || '').result
|
||||
}
|
||||
|
||||
var arrayFields = ['title', 'pubDate', 'itunes:episodeType', 'itunes:episode', 'itunes:author', 'itunes:duration', 'itunes:explicit', 'itunes:subtitle']
|
||||
var arrayFields = ['title', 'pubDate', 'itunes:episodeType', 'itunes:season', 'itunes:episode', 'itunes:author', 'itunes:duration', 'itunes:explicit', 'itunes:subtitle']
|
||||
arrayFields.forEach((key) => {
|
||||
var cleanKey = key.split(':').pop()
|
||||
episode[cleanKey] = extractFirstArrayItem(item, key)
|
||||
@@ -101,6 +101,7 @@ function cleanEpisodeData(data) {
|
||||
descriptionPlain: data.descriptionPlain || '',
|
||||
pubDate: data.pubDate || '',
|
||||
episodeType: data.episodeType || '',
|
||||
season: data.season || '',
|
||||
episode: data.episode || '',
|
||||
author: data.author || '',
|
||||
duration: data.duration || '',
|
||||
|
||||
Reference in New Issue
Block a user