mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-06-04 09:50:42 +02:00
Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 944a5b3e92 | |||
| 9b9de84740 | |||
| 2746e61cb3 | |||
| 7f1d797fb2 | |||
| 2059c9f14a | |||
| 0e16a9c8de | |||
| b6a33bf7bb | |||
| ce88ac9f33 | |||
| 678dceefed | |||
| 8b38dda229 | |||
| 7373c7159b | |||
| e34a39dde4 | |||
| d4cd8c6db9 | |||
| 9e93a3c7e6 | |||
| 4a8bcc90ea | |||
| 84c12a6e7e | |||
| 2a513ac8b8 | |||
| 97687c96cd | |||
| a42c13aec2 | |||
| 5f0f8b92d1 | |||
| 78ca6aa679 | |||
| 22e3d4a150 | |||
| e3fba1fb2b | |||
| 4d95250990 | |||
| 4776368501 | |||
| 8b0ed2bf29 |
@@ -13,8 +13,6 @@ on:
|
|||||||
- server/**
|
- server/**
|
||||||
- index.js
|
- index.js
|
||||||
- package.json
|
- package.json
|
||||||
release:
|
|
||||||
types: [published, edited]
|
|
||||||
# Allows you to run workflow manually from Actions tab
|
# Allows you to run workflow manually from Actions tab
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -48,7 +48,7 @@ Description: $DESCRIPTION"
|
|||||||
echo "$controlfile" > dist/debian/DEBIAN/control;
|
echo "$controlfile" > dist/debian/DEBIAN/control;
|
||||||
|
|
||||||
# Package debian
|
# 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
|
fakeroot dpkg-deb --build dist/debian
|
||||||
|
|
||||||
|
|||||||
+40
-22
@@ -12,18 +12,30 @@
|
|||||||
height: calc(100% - 64px);
|
height: calc(100% - 64px);
|
||||||
max-height: calc(100% - 64px);
|
max-height: calc(100% - 64px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page.streaming {
|
.page.streaming {
|
||||||
height: calc(100% - 64px - 165px);
|
height: calc(100% - 64px - 165px);
|
||||||
max-height: calc(100% - 64px - 165px);
|
max-height: calc(100% - 64px - 165px);
|
||||||
}
|
}
|
||||||
|
|
||||||
#bookshelf {
|
#bookshelf {
|
||||||
height: calc(100% - 40px);
|
height: calc(100% - 40px);
|
||||||
background-image: linear-gradient(to right bottom, #2e2e2e, #303030, #313131, #333333, #353535, #343434, #323232, #313131, #2c2c2c, #282828, #232323, #1f1f1f);
|
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) {
|
@media (max-width: 768px) {
|
||||||
#bookshelf {
|
#bookshelf {
|
||||||
height: calc(100% - 80px);
|
height: calc(100% - 80px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bookshelf-row {
|
||||||
|
width: 100vw;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#page-wrapper {
|
#page-wrapper {
|
||||||
@@ -34,33 +46,22 @@
|
|||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar:horizontal {
|
::-webkit-scrollbar:horizontal {
|
||||||
height: 8px;
|
height: 8px;
|
||||||
}
|
}
|
||||||
/* ::-webkit-scrollbar:horizontal { */
|
|
||||||
/* height: 16px; */
|
|
||||||
/* height: 24px;
|
|
||||||
} */
|
|
||||||
/* Track */
|
/* Track */
|
||||||
::-webkit-scrollbar-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 */
|
/* Handle */
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: #855620;
|
background: #855620;
|
||||||
border-radius: 4px;
|
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 */
|
/* Handle on hover */
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: #704922;
|
background: #704922;
|
||||||
@@ -71,6 +72,13 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.no-scroll {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
/* IE and Edge */
|
||||||
|
scrollbar-width: none;
|
||||||
|
/* Firefox */
|
||||||
|
}
|
||||||
|
|
||||||
/* Chrome, Safari, Edge, Opera */
|
/* Chrome, Safari, Edge, Opera */
|
||||||
.no-spinner::-webkit-outer-spin-button,
|
.no-spinner::-webkit-outer-spin-button,
|
||||||
.no-spinner::-webkit-inner-spin-button {
|
.no-spinner::-webkit-inner-spin-button {
|
||||||
@@ -89,18 +97,23 @@ input[type=number] {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px solid #474747;
|
border: 1px solid #474747;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tracksTable tr:nth-child(even) {
|
.tracksTable tr:nth-child(even) {
|
||||||
background-color: #2e2e2e;
|
background-color: #2e2e2e;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tracksTable tr {
|
.tracksTable tr {
|
||||||
background-color: #373838;
|
background-color: #373838;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tracksTable tr:hover {
|
.tracksTable tr:hover {
|
||||||
background-color: #474747;
|
background-color: #474747;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tracksTable td {
|
.tracksTable td {
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tracksTable th {
|
.tracksTable th {
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
@@ -113,13 +126,14 @@ input[type=number] {
|
|||||||
border-right: 6px solid transparent;
|
border-right: 6px solid transparent;
|
||||||
border-top: 6px solid white;
|
border-top: 6px solid white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.triangle-right {
|
.triangle-right {
|
||||||
width: 0;
|
width: 0;
|
||||||
height: 0;
|
height: 0;
|
||||||
border-left: 8px solid transparent;
|
border-left: 8px solid transparent;
|
||||||
border-bottom: 8px solid transparent;
|
border-bottom: 8px solid transparent;
|
||||||
border-top: 8px solid rgb(34,127,35);
|
border-top: 8px solid rgb(34, 127, 35);
|
||||||
border-right: 8px solid rgb(34,127,35);
|
border-right: 8px solid rgb(34, 127, 35);
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-text {
|
.icon-text {
|
||||||
@@ -149,6 +163,7 @@ input[type=number] {
|
|||||||
.box-shadow-book {
|
.box-shadow-book {
|
||||||
box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166;
|
box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shadow-height {
|
.shadow-height {
|
||||||
height: calc(100% - 4px);
|
height: calc(100% - 4px);
|
||||||
}
|
}
|
||||||
@@ -165,9 +180,9 @@ input[type=number] {
|
|||||||
Bookshelf Label
|
Bookshelf Label
|
||||||
*/
|
*/
|
||||||
.categoryPlacard {
|
.categoryPlacard {
|
||||||
background-image: url(https://image.freepik.com/free-photo/brown-wooden-textured-flooring-background_53876-128537.jpg);
|
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shinyBlack {
|
.shinyBlack {
|
||||||
background-color: #2d3436;
|
background-color: #2d3436;
|
||||||
background-image: linear-gradient(315deg, #19191a 0%, rgb(15, 15, 15) 74%);
|
background-image: linear-gradient(315deg, #19191a 0%, rgb(15, 15, 15) 74%);
|
||||||
@@ -194,8 +209,11 @@ Bookshelf Label
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
line-height: 16px; /* fallback */
|
line-height: 16px;
|
||||||
max-height: 32px; /* fallback */
|
/* fallback */
|
||||||
-webkit-line-clamp: 2; /* number of lines to show */
|
max-height: 32px;
|
||||||
|
/* fallback */
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
/* number of lines to show */
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
}
|
}
|
||||||
+275
-3
@@ -1,10 +1,10 @@
|
|||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Material Icons';
|
font-family: 'Material Icons';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
src: url(/fonts/MaterialIcons.woff2) format('woff2');
|
src: url(/fonts/MaterialIcons.woff2) format('woff2');
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Material Icons Outlined';
|
font-family: 'Material Icons Outlined';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
@@ -23,12 +23,13 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
word-wrap: normal;
|
word-wrap: normal;
|
||||||
direction: ltr;
|
direction: ltr;
|
||||||
-webkit-font-feature-settings: 'liga';
|
|
||||||
-webkit-font-smoothing: antialiased;
|
-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) {
|
.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;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.material-icons-outlined {
|
.material-icons-outlined {
|
||||||
font-family: 'Material Icons Outlined';
|
font-family: 'Material Icons Outlined';
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
@@ -40,9 +41,9 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
word-wrap: normal;
|
word-wrap: normal;
|
||||||
direction: ltr;
|
direction: ltr;
|
||||||
-webkit-font-feature-settings: 'liga';
|
|
||||||
-webkit-font-smoothing: antialiased;
|
-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) {
|
.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;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
@@ -56,6 +57,7 @@
|
|||||||
src: url(/fonts/GentiumBookBasic.woff2) format('woff2');
|
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;
|
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 */
|
/* latin */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Gentium Book Basic';
|
font-family: 'Gentium Book Basic';
|
||||||
@@ -65,3 +67,273 @@
|
|||||||
src: url(/fonts/GentiumBookBasic.woff2) format('woff2');
|
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;
|
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 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 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">
|
<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" />
|
<nuxt-link to="/">
|
||||||
<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">
|
<img src="/icon48.png" class="w-10 h-10 md:w-12 md:h-12 mr-4" />
|
||||||
<span class="material-icons text-4xl text-white">arrow_back</span>
|
</nuxt-link>
|
||||||
</a>
|
|
||||||
<h1 class="text-2xl font-book mr-6 hidden lg:block">audiobookshelf</h1>
|
<nuxt-link to="/">
|
||||||
|
<h1 class="text-2xl font-book mr-6 hidden lg:block hover:underline">audiobookshelf</h1>
|
||||||
|
</nuxt-link>
|
||||||
|
|
||||||
<ui-libraries-dropdown />
|
<ui-libraries-dropdown />
|
||||||
|
|
||||||
@@ -30,7 +32,7 @@
|
|||||||
<span class="material-icons" aria-label="Upload Media" role="button">upload</span>
|
<span class="material-icons" aria-label="Upload Media" role="button">upload</span>
|
||||||
</nuxt-link>
|
</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>
|
<span class="material-icons" aria-label="System Settings" role="button">settings</span>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
@@ -94,14 +96,11 @@ export default {
|
|||||||
isHome() {
|
isHome() {
|
||||||
return this.$route.name === 'library-library'
|
return this.$route.name === 'library-library'
|
||||||
},
|
},
|
||||||
showBack() {
|
|
||||||
return this.$route.name !== 'library-library-bookshelf-id' && !this.isHome
|
|
||||||
},
|
|
||||||
user() {
|
user() {
|
||||||
return this.$store.state.user.user
|
return this.$store.state.user.user
|
||||||
},
|
},
|
||||||
isRootUser() {
|
userIsAdminOrUp() {
|
||||||
return this.$store.getters['user/getIsRoot']
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
},
|
},
|
||||||
username() {
|
username() {
|
||||||
return this.user ? this.user.username : 'err'
|
return this.user ? this.user.username : 'err'
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
<template>
|
<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 -->
|
<!-- Cover size widget -->
|
||||||
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
|
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
|
||||||
<!-- Experimental Bookshelf Texture -->
|
<!-- 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 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>
|
||||||
|
|
||||||
<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>
|
<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 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>
|
<ui-btn color="success" class="w-52" @click="scan">Scan Library</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
@@ -17,7 +17,25 @@
|
|||||||
<div v-else-if="loaded && !shelves.length && search" class="w-full h-40 flex items-center justify-center">
|
<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>
|
<p class="text-center text-xl font-book py-4">No results for query</p>
|
||||||
</div>
|
</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">
|
<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" />
|
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
</template>
|
</template>
|
||||||
@@ -44,8 +62,8 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
isRootUser() {
|
userIsAdminOrUp() {
|
||||||
return this.$store.getters['user/getIsRoot']
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
},
|
},
|
||||||
showExperimentalFeatures() {
|
showExperimentalFeatures() {
|
||||||
return this.$store.state.showExperimentalFeatures
|
return this.$store.state.showExperimentalFeatures
|
||||||
@@ -56,6 +74,12 @@ export default {
|
|||||||
libraryName() {
|
libraryName() {
|
||||||
return this.$store.getters['libraries/getCurrentLibraryName']
|
return this.$store.getters['libraries/getCurrentLibraryName']
|
||||||
},
|
},
|
||||||
|
bookshelfView() {
|
||||||
|
return this.$store.getters['getServerSetting']('bookshelfView')
|
||||||
|
},
|
||||||
|
isAlternativeBookshelfView() {
|
||||||
|
return this.bookshelfView === this.$constants.BookshelfView.TITLES
|
||||||
|
},
|
||||||
bookCoverWidth() {
|
bookCoverWidth() {
|
||||||
var coverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
|
var coverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
|
||||||
if (this.isCoverSquareAspectRatio) return coverSize * 1.6
|
if (this.isCoverSquareAspectRatio) return coverSize * 1.6
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative">
|
<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 class="w-full h-full pt-6">
|
||||||
<div v-if="shelf.type === 'book' || shelf.type === 'podcast'" class="flex items-center">
|
<div v-if="shelf.type === 'book' || shelf.type === 'podcast'" class="flex items-center">
|
||||||
<template v-for="(entity, index) in shelf.entities">
|
<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>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="shelf.type === 'episode'" class="flex items-center">
|
<div v-if="shelf.type === 'episode'" class="flex items-center">
|
||||||
<template v-for="(entity, index) in shelf.entities">
|
<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>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="shelf.type === 'series'" class="flex items-center">
|
<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" />
|
<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>
|
</template>
|
||||||
</div>
|
</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">
|
<div v-if="shelf.type === 'authors'" class="flex items-center">
|
||||||
<template v-for="entity in shelf.entities">
|
<template v-for="entity in shelf.entities">
|
||||||
<nuxt-link :key="entity.id" :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(entity.id)}`">
|
<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" />
|
||||||
<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>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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>
|
<span class="material-icons text-6xl text-white">chevron_right</span>
|
||||||
</div>
|
</div>
|
||||||
<modals-authors-edit-modal v-model="showAuthorModal" :author="selectedAuthor" />
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -70,9 +60,7 @@ export default {
|
|||||||
canScrollLeft: false,
|
canScrollLeft: false,
|
||||||
isScrolling: false,
|
isScrolling: false,
|
||||||
scrollTimer: null,
|
scrollTimer: null,
|
||||||
updateTimer: null,
|
updateTimer: null
|
||||||
showAuthorModal: false,
|
|
||||||
selectedAuthor: null
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -98,13 +86,12 @@ export default {
|
|||||||
this.updateSelectionMode(false)
|
this.updateSelectionMode(false)
|
||||||
},
|
},
|
||||||
editAuthor(author) {
|
editAuthor(author) {
|
||||||
this.selectedAuthor = author
|
this.$store.commit('globals/showEditAuthorModal', author)
|
||||||
this.showAuthorModal = true
|
|
||||||
},
|
},
|
||||||
editBook(audiobook) {
|
editItem(libraryItem) {
|
||||||
var bookIds = this.shelf.entities.map((e) => e.id)
|
var itemIds = this.shelf.entities.map((e) => e.id)
|
||||||
this.$store.commit('setBookshelfBookIds', bookIds)
|
this.$store.commit('setBookshelfBookIds', itemIds)
|
||||||
this.$store.commit('showEditModal', audiobook)
|
this.$store.commit('showEditModal', libraryItem)
|
||||||
},
|
},
|
||||||
editEpisode({ libraryItem, episode }) {
|
editEpisode({ libraryItem, episode }) {
|
||||||
this.$store.commit('setSelectedLibraryItem', libraryItem)
|
this.$store.commit('setSelectedLibraryItem', libraryItem)
|
||||||
@@ -197,25 +184,13 @@ export default {
|
|||||||
<style>
|
<style>
|
||||||
.categorizedBookshelfRow {
|
.categorizedBookshelfRow {
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
width: calc(100vw - 80px);
|
|
||||||
|
|
||||||
/* background-color: rgb(214, 116, 36); */
|
|
||||||
background-image: var(--bookshelf-texture-img);
|
background-image: var(--bookshelf-texture-img);
|
||||||
/* background-position: center; */
|
|
||||||
/* background-size: contain; */
|
|
||||||
background-repeat: repeat-x;
|
background-repeat: repeat-x;
|
||||||
}
|
}
|
||||||
@media (max-width: 768px) {
|
|
||||||
.categorizedBookshelfRow {
|
|
||||||
width: 100vw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.bookshelfDividerCategorized {
|
.bookshelfDividerCategorized {
|
||||||
background: rgb(149, 119, 90);
|
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(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;
|
box-shadow: 2px 14px 8px #111111aa;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,11 +25,11 @@ export default {
|
|||||||
return {}
|
return {}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
userIsRoot() {
|
userIsAdminOrUp() {
|
||||||
return this.$store.getters['user/getIsRoot']
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
},
|
},
|
||||||
configRoutes() {
|
configRoutes() {
|
||||||
if (!this.userIsRoot) {
|
if (!this.userIsAdminOrUp) {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: 'config-stats',
|
id: 'config-stats',
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
<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 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>
|
<ui-btn color="success" class="w-52" @click="scan">Scan Library</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
@@ -22,8 +22,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
|
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
|
||||||
|
|
||||||
<!-- Experimental Bookshelf Texture -->
|
<!-- 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">
|
<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>
|
<p class="text-sm py-0.5">Texture</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -79,8 +80,8 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
isRootUser() {
|
userIsAdminOrUp() {
|
||||||
return this.$store.getters['user/getIsRoot']
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
},
|
},
|
||||||
showExperimentalFeatures() {
|
showExperimentalFeatures() {
|
||||||
return this.$store.state.showExperimentalFeatures
|
return this.$store.state.showExperimentalFeatures
|
||||||
@@ -126,7 +127,7 @@ export default {
|
|||||||
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE
|
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE
|
||||||
},
|
},
|
||||||
isAlternativeBookshelfView() {
|
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
|
return this.bookshelfView === this.$constants.BookshelfView.TITLES
|
||||||
},
|
},
|
||||||
bookCoverAspectRatio() {
|
bookCoverAspectRatio() {
|
||||||
@@ -185,7 +186,10 @@ export default {
|
|||||||
return 6
|
return 6
|
||||||
},
|
},
|
||||||
shelfHeight() {
|
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
|
return this.entityHeight + 40
|
||||||
},
|
},
|
||||||
totalEntityCardWidth() {
|
totalEntityCardWidth() {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<span class="material-icons text-sm">person</span>
|
<span class="material-icons text-sm">person</span>
|
||||||
<p v-if="podcastAuthor">{{ podcastAuthor }}</p>
|
<p v-if="podcastAuthor">{{ podcastAuthor }}</p>
|
||||||
<p v-else-if="authors.length" class="pl-1.5 text-sm sm:text-base">
|
<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>
|
||||||
<p v-else class="text-sm sm:text-base cursor-pointer pl-2">Unknown</p>
|
<p v-else class="text-sm sm:text-base cursor-pointer pl-2">Unknown</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,32 +1,34 @@
|
|||||||
<template>
|
<template>
|
||||||
<div @mouseover="mouseover" @mouseout="mouseout">
|
<nuxt-link :to="`/author/${author.id}`">
|
||||||
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
|
<div @mouseover="mouseover" @mouseleave="mouseleave">
|
||||||
<!-- Image or placeholder -->
|
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
|
||||||
<covers-author-image :author="author" />
|
<!-- Image or placeholder -->
|
||||||
|
<covers-author-image :author="author" />
|
||||||
|
|
||||||
<!-- Author name & num books overlay -->
|
<!-- 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">
|
<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 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>
|
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.65 + 'rem' }">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search icon btn -->
|
<!-- Search icon btn -->
|
||||||
<div v-show="!searching && isHovering" 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">
|
<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>
|
<span class="material-icons text-lg">search</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="isHovering && !searching" 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)">
|
<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>
|
<span class="material-icons text-lg">edit</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading spinner -->
|
<!-- 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">
|
<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="" />
|
<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>
|
</div>
|
||||||
<div v-show="nameBelow" class="w-full py-1 px-2">
|
</nuxt-link>
|
||||||
<p class="text-center font-semibold truncate text-gray-200" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -65,13 +67,16 @@ export default {
|
|||||||
},
|
},
|
||||||
numBooks() {
|
numBooks() {
|
||||||
return this._author.numBooks || 0
|
return this._author.numBooks || 0
|
||||||
|
},
|
||||||
|
userCanUpdate() {
|
||||||
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
mouseover() {
|
mouseover() {
|
||||||
this.isHovering = true
|
this.isHovering = true
|
||||||
},
|
},
|
||||||
mouseout() {
|
mouseleave() {
|
||||||
this.isHovering = false
|
this.isHovering = false
|
||||||
},
|
},
|
||||||
async searchAuthor() {
|
async searchAuthor() {
|
||||||
|
|||||||
@@ -6,11 +6,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Alternative bookshelf title/author/sort -->
|
<!-- 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' }">
|
<p class="truncate" :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
|
||||||
{{ displayTitle }}
|
{{ displayTitle }}
|
||||||
</p>
|
</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>
|
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<span class="material-icons" :style="{ fontSize: sizeMultiplier + 'rem' }">edit</span>
|
||||||
</div>
|
</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>
|
<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>
|
||||||
|
|
||||||
<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>
|
<span class="material-icons" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">more_vert</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -246,8 +247,11 @@ export default {
|
|||||||
}
|
}
|
||||||
return this.title
|
return this.title
|
||||||
},
|
},
|
||||||
displayAuthor() {
|
displayLineTwo() {
|
||||||
if (this.isPodcast) return this.author
|
if (this.isPodcast) return this.author
|
||||||
|
if (this.isAuthorBookshelfView) {
|
||||||
|
return this.mediaMetadata.publishedYear || ''
|
||||||
|
}
|
||||||
if (this.orderBy === 'media.metadata.authorNameLF') return this.authorLF
|
if (this.orderBy === 'media.metadata.authorNameLF') return this.authorLF
|
||||||
return this.author
|
return this.author
|
||||||
},
|
},
|
||||||
@@ -255,8 +259,9 @@ export default {
|
|||||||
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs)
|
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 === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs)
|
||||||
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt)
|
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 === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size)
|
||||||
|
if (this.orderBy === 'media.numTracks') return `${this.numEpisodes} Episodes`
|
||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
episodeProgress() {
|
episodeProgress() {
|
||||||
@@ -341,10 +346,23 @@ export default {
|
|||||||
userCanDownload() {
|
userCanDownload() {
|
||||||
return this.store.getters['user/getUserCanDownload']
|
return this.store.getters['user/getUserCanDownload']
|
||||||
},
|
},
|
||||||
userIsRoot() {
|
userIsAdminOrUp() {
|
||||||
return this.store.getters['user/getIsRoot']
|
return this.store.getters['user/getIsAdminOrUp']
|
||||||
},
|
},
|
||||||
moreMenuItems() {
|
moreMenuItems() {
|
||||||
|
if (this.recentEpisode) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
func: 'editPodcast',
|
||||||
|
text: 'Edit Podcast'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
func: 'toggleFinished',
|
||||||
|
text: `Mark as ${this.itemIsFinished ? 'Not Finished' : 'Finished'}`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
var items = []
|
var items = []
|
||||||
if (!this.isPodcast) {
|
if (!this.isPodcast) {
|
||||||
items = [
|
items = [
|
||||||
@@ -368,7 +386,7 @@ export default {
|
|||||||
text: 'Match'
|
text: 'Match'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (this.userIsRoot && !this.isFile) {
|
if (this.userIsAdminOrUp && !this.isFile) {
|
||||||
items.push({
|
items.push({
|
||||||
func: 'rescan',
|
func: 'rescan',
|
||||||
text: 'Re-Scan'
|
text: 'Re-Scan'
|
||||||
@@ -409,8 +427,12 @@ export default {
|
|||||||
var constants = this.$constants || this.$nuxt.$constants
|
var constants = this.$constants || this.$nuxt.$constants
|
||||||
return this.bookshelfView === constants.BookshelfView.TITLES
|
return this.bookshelfView === constants.BookshelfView.TITLES
|
||||||
},
|
},
|
||||||
|
isAuthorBookshelfView() {
|
||||||
|
var constants = this.$constants || this.$nuxt.$constants
|
||||||
|
return this.bookshelfView === constants.BookshelfView.AUTHOR
|
||||||
|
},
|
||||||
titleDisplayBottomOffset() {
|
titleDisplayBottomOffset() {
|
||||||
if (!this.isAlternativeBookshelfView) return 0
|
if (!this.isAlternativeBookshelfView && !this.isAuthorBookshelfView) return 0
|
||||||
else if (!this.displaySortLine) return 3 * this.sizeMultiplier
|
else if (!this.displaySortLine) return 3 * this.sizeMultiplier
|
||||||
return 4.25 * this.sizeMultiplier
|
return 4.25 * this.sizeMultiplier
|
||||||
}
|
}
|
||||||
@@ -447,10 +469,14 @@ export default {
|
|||||||
isFinished: !this.itemIsFinished
|
isFinished: !this.itemIsFinished
|
||||||
}
|
}
|
||||||
this.isProcessingReadUpdate = true
|
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 toast = this.$toast || this.$nuxt.$toast
|
||||||
var axios = this.$axios || this.$nuxt.$axios
|
var axios = this.$axios || this.$nuxt.$axios
|
||||||
axios
|
axios
|
||||||
.$patch(`/api/me/progress/${this.libraryItemId}`, updatePayload)
|
.$patch(apiEndpoint, updatePayload)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.isProcessingReadUpdate = false
|
this.isProcessingReadUpdate = false
|
||||||
toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
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'}`)
|
toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
editPodcast() {
|
||||||
|
this.$emit('editPodcast', this.libraryItem)
|
||||||
|
},
|
||||||
rescan() {
|
rescan() {
|
||||||
this.rescanning = true
|
this.rescanning = true
|
||||||
this.$axios
|
this.$axios
|
||||||
|
|||||||
@@ -5,20 +5,18 @@
|
|||||||
<covers-collection-cover ref="cover" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-collection-cover ref="cover" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
</div>
|
</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 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">
|
<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>
|
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">edit</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- <div v-if="isHovering || isSelectionMode" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-40">
|
<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> -->
|
|
||||||
<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 class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
|
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
|
||||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -28,7 +26,11 @@ export default {
|
|||||||
index: Number,
|
index: Number,
|
||||||
width: Number,
|
width: Number,
|
||||||
height: Number,
|
height: Number,
|
||||||
bookCoverAspectRatio: Number
|
bookCoverAspectRatio: Number,
|
||||||
|
bookshelfView: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -58,6 +60,10 @@ export default {
|
|||||||
},
|
},
|
||||||
currentLibraryId() {
|
currentLibraryId() {
|
||||||
return this.store.state.libraries.currentLibraryId
|
return this.store.state.libraries.currentLibraryId
|
||||||
|
},
|
||||||
|
isAlternativeBookshelfView() {
|
||||||
|
const constants = this.$constants || this.$nuxt.$constants
|
||||||
|
return this.bookshelfView == constants.BookshelfView.TITLES
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -13,11 +13,14 @@
|
|||||||
<p class="font-book" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ title }}</p>
|
<p class="font-book" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ title }}</p>
|
||||||
</div>
|
</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` }">
|
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
|
||||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -28,6 +31,10 @@ export default {
|
|||||||
width: Number,
|
width: Number,
|
||||||
height: Number,
|
height: Number,
|
||||||
bookCoverAspectRatio: Number,
|
bookCoverAspectRatio: Number,
|
||||||
|
bookshelfView: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
isCategorized: Boolean,
|
isCategorized: Boolean,
|
||||||
seriesMount: {
|
seriesMount: {
|
||||||
type: Object,
|
type: Object,
|
||||||
@@ -89,6 +96,10 @@ export default {
|
|||||||
hasValidCovers() {
|
hasValidCovers() {
|
||||||
var validCovers = this.books.map((bookItem) => bookItem.media.coverPath)
|
var validCovers = this.books.map((bookItem) => bookItem.media.coverPath)
|
||||||
return !!validCovers.length
|
return !!validCovers.length
|
||||||
|
},
|
||||||
|
isAlternativeBookshelfView() {
|
||||||
|
const constants = this.$constants || this.$nuxt.$constants
|
||||||
|
return this.bookshelfView == constants.BookshelfView.TITLES
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ export default {
|
|||||||
text: 'Title',
|
text: 'Title',
|
||||||
value: 'title'
|
value: 'title'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
text: 'Season',
|
||||||
|
value: 'season'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
text: 'Episode',
|
text: 'Episode',
|
||||||
value: 'episode'
|
value: 'episode'
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ export default {
|
|||||||
text: 'Size',
|
text: 'Size',
|
||||||
value: 'size'
|
value: 'size'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
text: 'Duration',
|
||||||
|
value: 'media.duration'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
text: 'File Birthtime',
|
text: 'File Birthtime',
|
||||||
value: 'birthtimeMs'
|
value: 'birthtimeMs'
|
||||||
@@ -78,6 +82,10 @@ export default {
|
|||||||
text: 'Size',
|
text: 'Size',
|
||||||
value: 'size'
|
value: 'size'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
text: '# of Episodes',
|
||||||
|
value: 'media.numTracks'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
text: 'File Birthtime',
|
text: 'File Birthtime',
|
||||||
value: 'birthtimeMs'
|
value: 'birthtimeMs'
|
||||||
|
|||||||
@@ -86,7 +86,6 @@
|
|||||||
<ui-toggle-switch v-model="newUser.permissions.accessAllTags" @input="accessAllTagsToggled" />
|
<ui-toggle-switch v-model="newUser.permissions.accessAllTags" @input="accessAllTagsToggled" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!newUser.permissions.accessAllTags" class="my-4">
|
<div v-if="!newUser.permissions.accessAllTags" class="my-4">
|
||||||
<ui-multi-select-dropdown v-model="newUser.itemTagsAccessible" :items="itemTags" label="Tags Accessible to User" />
|
<ui-multi-select-dropdown v-model="newUser.itemTagsAccessible" :items="itemTags" label="Tags Accessible to User" />
|
||||||
</div>
|
</div>
|
||||||
@@ -181,9 +180,7 @@ export default {
|
|||||||
if (this.$refs.modal) this.$refs.modal.setHide()
|
if (this.$refs.modal) this.$refs.modal.setHide()
|
||||||
},
|
},
|
||||||
accessAllTagsToggled(val) {
|
accessAllTagsToggled(val) {
|
||||||
if (!val && !this.newUser.itemTagsAccessible.length) {
|
if (val && this.newUser.itemTagsAccessible.length) {
|
||||||
this.newUser.itemTagsAccessible = this.libraries.map((l) => l.id)
|
|
||||||
} else if (val && this.newUser.itemTagsAccessible.length) {
|
|
||||||
this.newUser.itemTagsAccessible = []
|
this.newUser.itemTagsAccessible = []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -216,6 +213,10 @@ export default {
|
|||||||
this.$toast.error('Must select at least one library')
|
this.$toast.error('Must select at least one library')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!this.newUser.permissions.accessAllTags && !this.newUser.itemTagsAccessible.length) {
|
||||||
|
this.$toast.error('Must select at least one tag')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (this.isNew) {
|
if (this.isNew) {
|
||||||
this.submitCreateAccount()
|
this.submitCreateAccount()
|
||||||
|
|||||||
@@ -43,13 +43,13 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
props: {
|
// props: {
|
||||||
value: Boolean,
|
// value: Boolean,
|
||||||
author: {
|
// author: {
|
||||||
type: Object,
|
// type: Object,
|
||||||
default: () => {}
|
// default: () => {}
|
||||||
}
|
// }
|
||||||
},
|
// },
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
authorCopy: {
|
authorCopy: {
|
||||||
@@ -73,12 +73,15 @@ export default {
|
|||||||
computed: {
|
computed: {
|
||||||
show: {
|
show: {
|
||||||
get() {
|
get() {
|
||||||
return this.value
|
return this.$store.state.globals.showEditAuthorModal
|
||||||
},
|
},
|
||||||
set(val) {
|
set(val) {
|
||||||
this.$emit('input', val)
|
this.$store.commit('globals/setShowEditAuthorModal', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
author() {
|
||||||
|
return this.$store.state.globals.selectedAuthor
|
||||||
|
},
|
||||||
authorId() {
|
authorId() {
|
||||||
if (!this.author) return ''
|
if (!this.author) return ''
|
||||||
return this.author.id
|
return this.author.id
|
||||||
@@ -112,8 +115,10 @@ export default {
|
|||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
if (result) {
|
if (result) {
|
||||||
if (result.updated) this.$toast.success('Author updated')
|
if (result.updated) {
|
||||||
else this.$toast.info('No updates were needed')
|
this.$toast.success('Author updated')
|
||||||
|
this.show = false
|
||||||
|
} else this.$toast.info('No updates were needed')
|
||||||
}
|
}
|
||||||
this.processing = false
|
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 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>
|
||||||
|
|
||||||
<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" />
|
<component v-if="libraryItem && show" :is="tabName" :library-item="libraryItem" :processing.sync="processing" @close="show = false" @selectTab="selectTab" />
|
||||||
</div>
|
</div>
|
||||||
</modals-modal>
|
</modals-modal>
|
||||||
@@ -62,9 +62,9 @@ export default {
|
|||||||
component: 'modals-item-tabs-match'
|
component: 'modals-item-tabs-match'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'merge',
|
id: 'manage',
|
||||||
title: 'Merge',
|
title: 'Manage',
|
||||||
component: 'modals-item-tabs-merge',
|
component: 'modals-item-tabs-manage',
|
||||||
experimental: true
|
experimental: true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -123,12 +123,12 @@ export default {
|
|||||||
if (!this.userCanUpdate && !this.userCanDownload) return []
|
if (!this.userCanUpdate && !this.userCanDownload) return []
|
||||||
return this.tabs.filter((tab) => {
|
return this.tabs.filter((tab) => {
|
||||||
if (tab.experimental && !this.showExperimentalFeatures) return false
|
if (tab.experimental && !this.showExperimentalFeatures) return false
|
||||||
if (tab.id === 'merge' && (this.isMissing || this.mediaType !== 'book')) return false
|
if (tab.id === 'manage' && (this.isMissing || this.mediaType !== 'book')) return false
|
||||||
if (this.mediaType == 'podcast' && tab.id == 'chapters') return false
|
if (this.mediaType == 'podcast' && tab.id == 'chapters') return false
|
||||||
if (this.mediaType == 'book' && tab.id == 'episodes') return false
|
if (this.mediaType == 'book' && tab.id == 'episodes') return false
|
||||||
|
|
||||||
if ((tab.id === 'merge' || tab.id === 'files') && this.userCanDownload) return true
|
if ((tab.id === 'manage' || tab.id === 'files') && this.userCanDownload) return true
|
||||||
if (tab.id !== 'merge' && tab.id !== 'files' && this.userCanUpdate) return true
|
if (tab.id !== 'manage' && tab.id !== 'files' && this.userCanUpdate) return true
|
||||||
if (tab.id === 'match' && this.userCanUpdate) return true
|
if (tab.id === 'match' && this.userCanUpdate) return true
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,23 +1,27 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-full relative">
|
<div class="w-full h-full relative">
|
||||||
<widgets-book-details-edit v-if="mediaType == 'book'" ref="itemDetailsEdit" :library-item="libraryItem" @submit="submitForm" />
|
<div id="formWrapper" class="w-full overflow-y-auto">
|
||||||
<widgets-podcast-details-edit v-else ref="itemDetailsEdit" :library-item="libraryItem" @submit="submitForm" />
|
<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">
|
<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>
|
<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" />
|
<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-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>
|
||||||
|
|
||||||
<ui-tooltip :disabled="!!libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="mr-4">
|
<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-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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -52,8 +56,8 @@ export default {
|
|||||||
isFile() {
|
isFile() {
|
||||||
return !!this.libraryItem && this.libraryItem.isFile
|
return !!this.libraryItem && this.libraryItem.isFile
|
||||||
},
|
},
|
||||||
isRootUser() {
|
userIsAdminOrUp() {
|
||||||
return this.$store.getters['user/getIsRoot']
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
},
|
},
|
||||||
isMissing() {
|
isMissing() {
|
||||||
return !!this.libraryItem && !!this.libraryItem.isMissing
|
return !!this.libraryItem && !!this.libraryItem.isMissing
|
||||||
@@ -142,19 +146,23 @@ export default {
|
|||||||
this.rescanning = false
|
this.rescanning = false
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
submitForm() {
|
async saveAndClose() {
|
||||||
|
const wasUpdated = await this.save()
|
||||||
|
if (wasUpdated !== null) this.$emit('close')
|
||||||
|
},
|
||||||
|
async save() {
|
||||||
if (this.isProcessing) {
|
if (this.isProcessing) {
|
||||||
return
|
return null
|
||||||
}
|
}
|
||||||
if (!this.$refs.itemDetailsEdit) {
|
if (!this.$refs.itemDetailsEdit) {
|
||||||
return
|
return null
|
||||||
}
|
}
|
||||||
var updatedDetails = this.$refs.itemDetailsEdit.getDetails()
|
var updatedDetails = this.$refs.itemDetailsEdit.getDetails()
|
||||||
if (!updatedDetails.hasChanges) {
|
if (!updatedDetails.hasChanges) {
|
||||||
this.$toast.info('No changes were made')
|
this.$toast.info('No changes were made')
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
this.updateDetails(updatedDetails)
|
return this.updateDetails(updatedDetails)
|
||||||
},
|
},
|
||||||
async updateDetails(updatedDetails) {
|
async updateDetails(updatedDetails) {
|
||||||
this.isProcessing = true
|
this.isProcessing = true
|
||||||
@@ -166,11 +174,12 @@ export default {
|
|||||||
if (updateResult) {
|
if (updateResult) {
|
||||||
if (updateResult.updated) {
|
if (updateResult.updated) {
|
||||||
this.$toast.success('Item details updated')
|
this.$toast.success('Item details updated')
|
||||||
// this.$emit('close')
|
return true
|
||||||
} else {
|
} else {
|
||||||
this.$toast.info('No updates were necessary')
|
this.$toast.info('No updates were necessary')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
},
|
},
|
||||||
removeItem() {
|
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`)) {
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.details-form-wrapper {
|
#formWrapper {
|
||||||
height: calc(100% - 70px);
|
height: calc(100% - 80px);
|
||||||
max-height: calc(100% - 70px);
|
max-height: calc(100% - 80px);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
+57
-7
@@ -1,10 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
|
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
|
||||||
|
<!-- Merge to m4b -->
|
||||||
<div v-if="showM4bDownload" class="w-full border border-black-200 p-4 my-8">
|
<div v-if="showM4bDownload" class="w-full border border-black-200 p-4 my-8">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-lg">M4B Audiobook File <span class="text-error">*</span></p>
|
<p class="text-lg">Make M4B Audiobook File <span class="text-error">*</span></p>
|
||||||
<p class="max-w-xs text-sm pt-2 text-gray-300">Generate a .M4B audiobook file with embedded cover image and chapters.</p>
|
<p class="max-w-sm text-sm pt-2 text-gray-300">Generate a .M4B audiobook file with embedded metadata, cover image, and chapters. <br /><span class="text-warning">*</span> Does not delete existing audio files.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<div>
|
<div>
|
||||||
@@ -24,13 +25,55 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-left text-base mb-4 py-4">
|
<!-- Split to mp3 -->
|
||||||
|
<div v-if="showMp3Split" class="w-full border border-black-200 p-4 my-8">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div>
|
||||||
|
<p class="text-lg">Split M4B to MP3's</p>
|
||||||
|
<p class="max-w-sm text-sm pt-2 text-gray-300">Generate multiple MP3's split by chapters with embedded metadata, cover image, and chapters. <br /><span class="text-warning">*</span> Does not delete existing audio files.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<div>
|
||||||
|
<p v-if="abmergeStatus === $constants.DownloadStatus.FAILED" class="text-error mb-2">Download Failed</p>
|
||||||
|
<p v-if="abmergeStatus === $constants.DownloadStatus.READY" class="text-success mb-2">Download Ready!</p>
|
||||||
|
<p v-if="abmergeStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
|
||||||
|
|
||||||
|
<ui-btn v-if="abmergeStatus !== $constants.DownloadStatus.READY" :loading="abmergeStatus === $constants.DownloadStatus.PENDING" :disabled="true" @click="startAudiobookMerge">Not yet implemented</ui-btn>
|
||||||
|
<div v-else>
|
||||||
|
<div class="flex">
|
||||||
|
<ui-btn @click="downloadWithProgress(abmergeDownload)">Download</ui-btn>
|
||||||
|
<ui-icon-btn small icon="delete" bg-color="error" class="ml-2" @click="removeDownload" />
|
||||||
|
</div>
|
||||||
|
<p class="px-0.5 py-1 text-sm font-mono text-center">Size: {{ $bytesPretty(abmergeDownload.size) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Embed Metadata -->
|
||||||
|
<div v-if="mediaTracks.length" class="w-full border border-black-200 p-4 my-8">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div>
|
||||||
|
<p class="text-lg">Embed Metadata</p>
|
||||||
|
<p class="max-w-sm text-sm pt-2 text-gray-300">Embed metadata into audio files including cover image and chapters. <br /><span class="text-warning">*</span> Modifies audio files.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<div>
|
||||||
|
<ui-btn :to="`/item/${libraryItemId}/manage`" class="flex items-center"
|
||||||
|
>Open Manager
|
||||||
|
<span class="material-icons text-lg ml-2">launch</span>
|
||||||
|
</ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="showM4bDownload" class="text-left text-base mb-4 py-4">
|
||||||
<span class="text-error">* <strong>Experimental</strong></span
|
<span class="text-error">* <strong>Experimental</strong></span
|
||||||
> - M4b merge can take several minutes and will be stored in <span class="bg-primary bg-opacity-75 font-mono p-1 text-base">/metadata/downloads</span>. After the download is ready, it will remain available for 60 minutes, then be deleted. Download will timeout after 20 minutes.
|
> - M4b merge can take several minutes and will be stored in <span class="bg-primary bg-opacity-75 font-mono p-1 text-base">/metadata/downloads</span>. After the download is ready, it will remain available for 60 minutes, then be deleted. Download will timeout after 20 minutes.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p v-if="isSingleM4b" class="text-lg text-center my-8">Audiobook is already a single m4b!</p>
|
<!-- <p v-if="isSingleM4b" class="text-lg text-center my-8">Audiobook is already a single m4b!</p> -->
|
||||||
<p v-else-if="!mediaTracks.length" class="text-lg text-center my-8">No audio tracks to merge</p>
|
<p v-if="!mediaTracks.length" class="text-lg text-center my-8">No audio tracks to merge</p>
|
||||||
|
|
||||||
<div v-if="isDownloading" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
<div v-if="isDownloading" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||||
<div class="w-80 border border-black-400 bg-bg rounded-xl h-20">
|
<div class="w-80 border border-black-400 bg-bg rounded-xl h-20">
|
||||||
@@ -97,9 +140,16 @@ export default {
|
|||||||
isSingleM4b() {
|
isSingleM4b() {
|
||||||
return this.mediaTracks.length === 1 && this.mediaTracks[0].metadata.ext.toLowerCase() === '.m4b'
|
return this.mediaTracks.length === 1 && this.mediaTracks[0].metadata.ext.toLowerCase() === '.m4b'
|
||||||
},
|
},
|
||||||
|
chapters() {
|
||||||
|
return this.media.chapters || []
|
||||||
|
},
|
||||||
showM4bDownload() {
|
showM4bDownload() {
|
||||||
if (this.libraryItem.isMissing || !this.mediaTracks.length) return false
|
if (!this.mediaTracks.length) return false
|
||||||
return !this.isSingleM4b && this.mediaTracks.length > 0
|
return !this.isSingleM4b
|
||||||
|
},
|
||||||
|
showMp3Split() {
|
||||||
|
if (!this.mediaTracks.length) return false
|
||||||
|
return this.isSingleM4b && this.chapters.length
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -8,13 +8,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<p v-if="globalWatcherDisabled" class="text-xs text-warning">*Watcher is disabled globally in server settings</p>
|
<p v-if="globalWatcherDisabled" class="text-xs text-warning">*Watcher is disabled globally in server settings</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="py-3">
|
<div v-if="mediaType == 'book'" class="py-3">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<ui-toggle-switch v-model="skipMatchingMediaWithAsin" @input="formUpdated" />
|
<ui-toggle-switch v-model="skipMatchingMediaWithAsin" @input="formUpdated" />
|
||||||
<p class="pl-4 text-lg">Skip matching books that already have an ASIN</p>
|
<p class="pl-4 text-lg">Skip matching books that already have an ASIN</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="py-3">
|
<div v-if="mediaType == 'book'" class="py-3">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<ui-toggle-switch v-model="skipMatchingMediaWithIsbn" @input="formUpdated" />
|
<ui-toggle-switch v-model="skipMatchingMediaWithIsbn" @input="formUpdated" />
|
||||||
<p class="pl-4 text-lg">Skip matching books that already have an ISBN</p>
|
<p class="pl-4 text-lg">Skip matching books that already have an ISBN</p>
|
||||||
@@ -37,7 +37,7 @@ export default {
|
|||||||
provider: null,
|
provider: null,
|
||||||
disableWatcher: false,
|
disableWatcher: false,
|
||||||
skipMatchingMediaWithAsin: false,
|
skipMatchingMediaWithAsin: false,
|
||||||
skipMatchingMediaWithIsbn: false,
|
skipMatchingMediaWithIsbn: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
|||||||
@@ -7,13 +7,16 @@
|
|||||||
</template>
|
</template>
|
||||||
<div ref="wrapper" class="p-4 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
|
<div ref="wrapper" class="p-4 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
|
||||||
<div class="flex flex-wrap">
|
<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" />
|
<ui-text-input-with-label v-model="newEpisode.episode" label="Episode" />
|
||||||
</div>
|
</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" />
|
<ui-text-input-with-label v-model="newEpisode.episodeType" label="Episode Type" />
|
||||||
</div>
|
</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" />
|
<ui-text-input-with-label v-model="pubDateInput" @input="updatePubDate" type="datetime-local" label="Pub Date" />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full p-1">
|
<div class="w-full p-1">
|
||||||
@@ -39,6 +42,7 @@ export default {
|
|||||||
return {
|
return {
|
||||||
processing: false,
|
processing: false,
|
||||||
newEpisode: {
|
newEpisode: {
|
||||||
|
season: null,
|
||||||
episode: null,
|
episode: null,
|
||||||
episodeType: null,
|
episodeType: null,
|
||||||
title: null,
|
title: null,
|
||||||
@@ -92,6 +96,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
init() {
|
init() {
|
||||||
|
this.newEpisode.season = this.episode.season || ''
|
||||||
this.newEpisode.episode = this.episode.episode || ''
|
this.newEpisode.episode = this.episode.episode || ''
|
||||||
this.newEpisode.episodeType = this.episode.episodeType || ''
|
this.newEpisode.episodeType = this.episode.episodeType || ''
|
||||||
this.newEpisode.title = this.episode.title || ''
|
this.newEpisode.title = this.episode.title || ''
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
<template>
|
||||||
|
<modals-modal v-model="show" name="rss-feed-modal" :width="600" :height="'unset'" :processing="processing">
|
||||||
|
<template #outer>
|
||||||
|
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||||
|
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div ref="wrapper" class="px-8 py-6 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
|
||||||
|
<div 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="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(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">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div v-show="userIsAdminOrUp" class="flex items-center pt-6">
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: Boolean,
|
||||||
|
libraryItem: {
|
||||||
|
type: Object,
|
||||||
|
default: () => null
|
||||||
|
},
|
||||||
|
feedUrl: String
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
processing: false,
|
||||||
|
newFeedSlug: null,
|
||||||
|
currentFeedUrl: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
show: {
|
||||||
|
immediate: true,
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
libraryItemId() {
|
||||||
|
return this.libraryItem.id
|
||||||
|
},
|
||||||
|
media() {
|
||||||
|
return this.libraryItem.media || {}
|
||||||
|
},
|
||||||
|
mediaMetadata() {
|
||||||
|
return this.media.metadata || {}
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
return this.mediaMetadata.title
|
||||||
|
},
|
||||||
|
userIsAdminOrUp() {
|
||||||
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
|
},
|
||||||
|
demoFeedUrl() {
|
||||||
|
return `${window.origin}/feed/${this.newFeedSlug}`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
closeFeed() {
|
||||||
|
this.processing = true
|
||||||
|
this.$axios
|
||||||
|
.$post(`/api/podcasts/${this.libraryItem.id}/close-feed`)
|
||||||
|
.then(() => {
|
||||||
|
this.$toast.success('RSS Feed Closed')
|
||||||
|
this.show = false
|
||||||
|
this.processing = false
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to close RSS feed', error)
|
||||||
|
this.processing = false
|
||||||
|
this.$toast.error()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
if (!this.libraryItem) return
|
||||||
|
this.newFeedSlug = this.libraryItem.id
|
||||||
|
this.currentFeedUrl = this.feedUrl
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -45,8 +45,8 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="py-0">
|
<td class="py-0">
|
||||||
<div class="w-full flex justify-center">
|
<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> -->
|
<!-- Dont show edit for non-root users -->
|
||||||
<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)">
|
<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>
|
<span class="material-icons text-base">edit</span>
|
||||||
</div>
|
</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)">
|
<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() {
|
currentUserId() {
|
||||||
return this.$store.state.user.user.id
|
return this.$store.state.user.user.id
|
||||||
},
|
},
|
||||||
|
userIsRoot() {
|
||||||
|
return this.$store.getters['user/getIsRoot']
|
||||||
|
},
|
||||||
usersOnline() {
|
usersOnline() {
|
||||||
var usermap = {}
|
var usermap = {}
|
||||||
this.$store.state.users.users.forEach((u) => (usermap[u.id] = { online: true, session: u.session }))
|
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-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-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
|
||||||
</ui-tooltip>
|
</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="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>
|
<p v-if="publishedAt" class="px-4 text-sm text-gray-300">Published {{ $formatDate(publishedAt, 'MMM do, yyyy') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
<template>
|
||||||
<div class="w-full h-full relative">
|
<div class="w-full h-full relative">
|
||||||
<form class="w-full h-full" @submit.prevent="submitForm">
|
<form class="w-full h-full px-4 py-6" @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="flex -mx-1">
|
<div class="w-1/2 px-1">
|
||||||
<div class="w-1/2 px-1">
|
<ui-text-input-with-label ref="titleInput" v-model="details.title" label="Title" />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex-grow px-1">
|
||||||
<div class="flex mt-2 -mx-1">
|
<ui-text-input-with-label ref="subtitleInput" v-model="details.subtitle" label="Subtitle" />
|
||||||
<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>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex mt-2 -mx-1">
|
<div class="flex mt-2 -mx-1">
|
||||||
<div class="flex-grow px-1">
|
<div class="w-3/4 px-1">
|
||||||
<ui-multi-select-query-input ref="seriesSelect" v-model="seriesItems" text-key="displayName" label="Series" readonly show-edit @edit="editSeriesItem" @add="addNewSeries" />
|
<!-- Authors filter only contains authors in this library, use query input to query all authors -->
|
||||||
</div>
|
<ui-multi-select-query-input ref="authorsSelect" v-model="details.authors" label="Authors" endpoint="authors/search" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex-grow px-1">
|
||||||
<ui-textarea-with-label ref="descriptionInput" v-model="details.description" :rows="3" label="Description" class="mt-2" />
|
<ui-text-input-with-label ref="publishYearInput" v-model="details.publishedYear" type="number" label="Publish Year" />
|
||||||
|
|
||||||
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex mt-2 -mx-1">
|
<div class="flex mt-2 -mx-1">
|
||||||
<div class="w-1/2 px-1">
|
<div class="flex-grow px-1">
|
||||||
<ui-multi-select ref="narratorsSelect" v-model="details.narrators" label="Narrators" :items="narrators" />
|
<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="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>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex mt-2 -mx-1">
|
<ui-textarea-with-label ref="descriptionInput" v-model="details.description" :rows="3" label="Description" class="mt-2" />
|
||||||
<div class="w-1/2 px-1">
|
|
||||||
<ui-text-input-with-label ref="publisherInput" v-model="details.publisher" label="Publisher" />
|
<div class="flex mt-2 -mx-1">
|
||||||
</div>
|
<div class="w-1/2 px-1">
|
||||||
<div class="w-1/4 px-1">
|
<ui-multi-select ref="genresSelect" v-model="details.genres" label="Genres" :items="genres" />
|
||||||
<ui-text-input-with-label ref="languageInput" v-model="details.language" label="Language" />
|
</div>
|
||||||
</div>
|
<div class="flex-grow px-1">
|
||||||
<div class="flex-grow px-1 pt-6">
|
<ui-multi-select ref="tagsSelect" v-model="newTags" label="Tags" :items="tags" />
|
||||||
<div class="flex justify-center">
|
</div>
|
||||||
<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 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
<template>
|
||||||
<div class="w-full h-full relative">
|
<div class="w-full h-full relative">
|
||||||
<form class="w-full h-full" @submit.prevent="submitForm">
|
<form class="w-full h-full px-4 py-6" @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="flex -mx-1">
|
<div class="w-1/2 px-1">
|
||||||
<div class="w-1/2 px-1">
|
<ui-text-input-with-label ref="titleInput" v-model="details.title" label="Title" />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex-grow px-1">
|
||||||
<ui-text-input-with-label ref="feedUrlInput" v-model="details.feedUrl" label="RSS Feed URL" class="mt-2" />
|
<ui-text-input-with-label ref="authorInput" v-model="details.author" label="Author" />
|
||||||
|
|
||||||
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex mt-2 -mx-1">
|
<ui-text-input-with-label ref="feedUrlInput" v-model="details.feedUrl" label="RSS Feed URL" class="mt-2" />
|
||||||
<div class="w-1/4 px-1">
|
|
||||||
<ui-text-input-with-label ref="releaseDateInput" v-model="details.releaseDate" label="Release Date" />
|
<ui-textarea-with-label ref="descriptionInput" v-model="details.description" :rows="3" label="Description" class="mt-2" />
|
||||||
</div>
|
|
||||||
<div class="w-1/4 px-1">
|
<div class="flex mt-2 -mx-1">
|
||||||
<ui-text-input-with-label ref="itunesIdInput" v-model="details.itunesId" label="iTunes ID" />
|
<div class="w-1/2 px-1">
|
||||||
</div>
|
<ui-multi-select ref="genresSelect" v-model="details.genres" label="Genres" :items="genres" />
|
||||||
<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>
|
</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">
|
<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>
|
</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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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-edit-collection-modal />
|
||||||
<modals-bookshelf-texture-modal />
|
<modals-bookshelf-texture-modal />
|
||||||
<modals-podcast-edit-episode />
|
<modals-podcast-edit-episode />
|
||||||
|
<modals-authors-edit-modal />
|
||||||
<readers-reader />
|
<readers-reader />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -112,11 +112,22 @@ export default {
|
|||||||
items: []
|
items: []
|
||||||
})
|
})
|
||||||
var newtreemap = currtreemap.items[currtreemap.items.length - 1]
|
var newtreemap = currtreemap.items[currtreemap.items.length - 1]
|
||||||
dirReader.readEntries((entries) => {
|
|
||||||
let entriesPromises = []
|
let entriesPromises = []
|
||||||
for (let entr of entries) entriesPromises.push(traverseFileTreePromise(entr, newtreemap))
|
// readEntries returns 100 items max, continue calling readEntries until empty
|
||||||
resolve(Promise.all(entriesPromises))
|
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',
|
serverUrl: process.env.NODE_ENV === 'production' ? '' : 'http://localhost:3333',
|
||||||
chromecastReceiver: 'FD1F76C5'
|
chromecastReceiver: 'FD1F76C5'
|
||||||
},
|
},
|
||||||
// rootDir: process.env.NODE_ENV !== 'production' ? 'client/' : '',
|
|
||||||
telemetry: false,
|
telemetry: false,
|
||||||
|
|
||||||
publicRuntimeConfig: {
|
publicRuntimeConfig: {
|
||||||
@@ -33,8 +32,7 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
link: [
|
link: [
|
||||||
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
|
{ 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' },
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.0.8",
|
"version": "2.0.12",
|
||||||
"description": "Audiobook manager and player",
|
"description": "Audiobook manager and player",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<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">
|
<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">
|
<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">
|
<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 class="font-book text-center px-4 py-1 w-12">
|
<div class="font-book text-center px-4 py-1 w-12">
|
||||||
{{ audio.include ? index - numExcluded + 1 : -1 }}
|
{{ audio.include ? index - numExcluded + 1 : -1 }}
|
||||||
</div>
|
</div>
|
||||||
@@ -71,7 +71,7 @@
|
|||||||
<div class="font-sans text-xs font-normal w-56">
|
<div class="font-sans text-xs font-normal w-56">
|
||||||
{{ audio.error }}
|
{{ audio.error }}
|
||||||
</div>
|
</div>
|
||||||
<div class="font-sans text-xs font-normal w-40 flex justify-center">
|
<div class="font-sans text-xs font-normal w-40 flex items-center justify-center">
|
||||||
<ui-toggle-switch v-model="audio.include" :off-color="'error'" @input="includeToggled(audio)" />
|
<ui-toggle-switch v-model="audio.include" :off-color="'error'" @input="includeToggled(audio)" />
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -129,6 +129,9 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
showExperimentalFeatures() {
|
||||||
|
return this.$store.state.showExperimentalFeatures
|
||||||
|
},
|
||||||
media() {
|
media() {
|
||||||
return this.libraryItem.media || {}
|
return this.libraryItem.media || {}
|
||||||
},
|
},
|
||||||
@@ -162,9 +165,6 @@ export default {
|
|||||||
},
|
},
|
||||||
streamLibraryItem() {
|
streamLibraryItem() {
|
||||||
return this.$store.state.streamLibraryItem
|
return this.$store.state.streamLibraryItem
|
||||||
},
|
|
||||||
showExperimentalFeatures() {
|
|
||||||
return this.$store.state.showExperimentalFeatures
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
asyncData({ store, redirect, route }) {
|
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
|
// Non-Root user only has access to the listening stats page
|
||||||
if (route.name !== 'config-stats') {
|
if (route.name !== 'config-stats') {
|
||||||
redirect('/config/stats')
|
redirect('/config/stats')
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
<ui-toggle-switch v-model="useAlternativeBookshelfView" :disabled="updatingServerSettings" @input="updateAlternativeBookshelfView" />
|
<ui-toggle-switch v-model="useAlternativeBookshelfView" :disabled="updatingServerSettings" @input="updateAlternativeBookshelfView" />
|
||||||
<ui-tooltip :text="tooltips.bookshelfView">
|
<ui-tooltip :text="tooltips.bookshelfView">
|
||||||
<p class="pl-4 text-lg">
|
<p class="pl-4 text-lg">
|
||||||
Use alternative library bookshelf view
|
Use alternative bookshelf view
|
||||||
<span class="material-icons icon-text">info_outlined</span>
|
<span class="material-icons icon-text">info_outlined</span>
|
||||||
</p>
|
</p>
|
||||||
</ui-tooltip>
|
</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"',
|
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"',
|
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',
|
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',
|
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',
|
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'
|
coverAspectRatio: 'Prefer to use square covers over standard 1.6:1 book covers'
|
||||||
|
|||||||
@@ -34,11 +34,6 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
asyncData({ store, redirect }) {
|
|
||||||
if (!store.getters['user/getIsRoot']) {
|
|
||||||
redirect('/?error=unauthorized')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
search: null,
|
search: null,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<h1 class="text-xl pl-2">{{ username }}</h1>
|
<h1 class="text-xl pl-2">{{ username }}</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="cursor-pointer text-gray-400 hover:text-white" @click="copyToClipboard(userToken)">
|
<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
|
<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>
|
><span class="material-icons pl-2 text-base">content_copy</span>
|
||||||
</p>
|
</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-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">
|
<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>
|
||||||
<p v-else class="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</p>
|
<p v-else class="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</p>
|
||||||
|
|
||||||
@@ -156,6 +156,11 @@
|
|||||||
<ui-tooltip v-if="isPodcast && userIsAdminOrUp" text="Find Episodes" direction="top">
|
<ui-tooltip v-if="isPodcast && userIsAdminOrUp" text="Find Episodes" direction="top">
|
||||||
<ui-icon-btn icon="search" class="mx-0.5" :loading="fetchingRSSFeed" outlined @click="findEpisodesClick" />
|
<ui-icon-btn icon="search" class="mx-0.5" :loading="fetchingRSSFeed" outlined @click="findEpisodesClick" />
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|
||||||
|
<!-- Experimental RSS feed open -->
|
||||||
|
<ui-tooltip v-if="showRssFeedBtn" text="Open RSS Feed" direction="top">
|
||||||
|
<ui-icon-btn icon="rss_feed" class="mx-0.5" :bg-color="rssFeedUrl ? 'success' : 'primary'" outlined @click="clickRSSFeed" />
|
||||||
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="my-4 max-w-2xl">
|
<div class="my-4 max-w-2xl">
|
||||||
@@ -178,6 +183,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<modals-podcast-episode-feed v-model="showPodcastEpisodeFeed" :library-item="libraryItem" :episodes="podcastFeedEpisodes" />
|
<modals-podcast-episode-feed v-model="showPodcastEpisodeFeed" :library-item="libraryItem" :episodes="podcastFeedEpisodes" />
|
||||||
|
<modals-rssfeed-view-modal v-model="showRssFeedModal" :library-item="libraryItem" :feed-url="rssFeedUrl" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -189,7 +195,7 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Include episode downloads for podcasts
|
// Include episode downloads for podcasts
|
||||||
var item = await app.$axios.$get(`/api/items/${params.id}?expanded=1&include=authors,downloads`).catch((error) => {
|
var item = await app.$axios.$get(`/api/items/${params.id}?expanded=1&include=authors,downloads,rssfeed`).catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
@@ -198,7 +204,8 @@ export default {
|
|||||||
return redirect('/')
|
return redirect('/')
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
libraryItem: item
|
libraryItem: item,
|
||||||
|
rssFeedUrl: item.rssFeedUrl || null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
@@ -209,7 +216,8 @@ export default {
|
|||||||
showPodcastEpisodeFeed: false,
|
showPodcastEpisodeFeed: false,
|
||||||
podcastFeedEpisodes: [],
|
podcastFeedEpisodes: [],
|
||||||
episodesDownloading: [],
|
episodesDownloading: [],
|
||||||
episodeDownloadsQueued: []
|
episodeDownloadsQueued: [],
|
||||||
|
showRssFeedModal: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -368,6 +376,11 @@ export default {
|
|||||||
},
|
},
|
||||||
userCanDownload() {
|
userCanDownload() {
|
||||||
return this.$store.getters['user/getUserCanDownload']
|
return this.$store.getters['user/getUserCanDownload']
|
||||||
|
},
|
||||||
|
showRssFeedBtn() {
|
||||||
|
if (!this.showExperimentalFeatures) return false
|
||||||
|
// If rss feed is open then show feed url to users otherwise just show to admins
|
||||||
|
return this.isPodcast && (this.userIsAdminOrUp || this.rssFeedUrl)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -478,6 +491,9 @@ export default {
|
|||||||
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
|
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
|
||||||
this.$store.commit('globals/setShowUserCollectionsModal', true)
|
this.$store.commit('globals/setShowUserCollectionsModal', true)
|
||||||
},
|
},
|
||||||
|
clickRSSFeed() {
|
||||||
|
this.showRssFeedModal = true
|
||||||
|
},
|
||||||
episodeDownloadQueued(episodeDownload) {
|
episodeDownloadQueued(episodeDownload) {
|
||||||
if (episodeDownload.libraryItemId === this.libraryItemId) {
|
if (episodeDownload.libraryItemId === this.libraryItemId) {
|
||||||
this.episodeDownloadsQueued.push(episodeDownload)
|
this.episodeDownloadsQueued.push(episodeDownload)
|
||||||
@@ -494,6 +510,18 @@ export default {
|
|||||||
this.episodeDownloadsQueued = this.episodeDownloadsQueued.filter((d) => d.id !== episodeDownload.id)
|
this.episodeDownloadsQueued = this.episodeDownloadsQueued.filter((d) => d.id !== episodeDownload.id)
|
||||||
this.episodesDownloading = this.episodesDownloading.filter((d) => d.id !== episodeDownload.id)
|
this.episodesDownloading = this.episodesDownloading.filter((d) => d.id !== episodeDownload.id)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
rssFeedOpen(data) {
|
||||||
|
if (data.libraryItemId === this.libraryItemId) {
|
||||||
|
console.log('RSS Feed Opened', data)
|
||||||
|
this.rssFeedUrl = data.feedUrl
|
||||||
|
}
|
||||||
|
},
|
||||||
|
rssFeedClosed(data) {
|
||||||
|
if (data.libraryItemId === this.libraryItemId) {
|
||||||
|
console.log('RSS Feed Closed', data)
|
||||||
|
this.rssFeedUrl = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
@@ -506,12 +534,16 @@ export default {
|
|||||||
this.$store.commit('libraries/setCurrentLibrary', this.libraryId)
|
this.$store.commit('libraries/setCurrentLibrary', this.libraryId)
|
||||||
}
|
}
|
||||||
this.$root.socket.on('item_updated', this.libraryItemUpdated)
|
this.$root.socket.on('item_updated', this.libraryItemUpdated)
|
||||||
|
this.$root.socket.on('rss_feed_open', this.rssFeedOpen)
|
||||||
|
this.$root.socket.on('rss_feed_closed', this.rssFeedClosed)
|
||||||
this.$root.socket.on('episode_download_queued', this.episodeDownloadQueued)
|
this.$root.socket.on('episode_download_queued', this.episodeDownloadQueued)
|
||||||
this.$root.socket.on('episode_download_started', this.episodeDownloadStarted)
|
this.$root.socket.on('episode_download_started', this.episodeDownloadStarted)
|
||||||
this.$root.socket.on('episode_download_finished', this.episodeDownloadFinished)
|
this.$root.socket.on('episode_download_finished', this.episodeDownloadFinished)
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
this.$root.socket.off('item_updated', this.libraryItemUpdated)
|
this.$root.socket.off('item_updated', this.libraryItemUpdated)
|
||||||
|
this.$root.socket.off('rss_feed_open', this.rssFeedOpen)
|
||||||
|
this.$root.socket.off('rss_feed_closed', this.rssFeedClosed)
|
||||||
this.$root.socket.off('episode_download_queued', this.episodeDownloadQueued)
|
this.$root.socket.off('episode_download_queued', this.episodeDownloadQueued)
|
||||||
this.$root.socket.off('episode_download_started', this.episodeDownloadStarted)
|
this.$root.socket.off('episode_download_started', this.episodeDownloadStarted)
|
||||||
this.$root.socket.off('episode_download_finished', this.episodeDownloadFinished)
|
this.$root.socket.off('episode_download_finished', this.episodeDownloadFinished)
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
<template>
|
||||||
|
<div id="page-wrapper" class="bg-bg page p-8 overflow-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
|
||||||
|
<div class="flex justify-center mb-2">
|
||||||
|
<div class="w-full max-w-2xl">
|
||||||
|
<p class="text-xl">Metadata to embed</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-full max-w-2xl"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-center flex-wrap">
|
||||||
|
<div class="w-full max-w-2xl border border-opacity-10 bg-bg mx-2">
|
||||||
|
<div class="flex py-2 px-4">
|
||||||
|
<div class="w-1/3 text-xs font-semibold uppercase text-gray-200">Meta Tag</div>
|
||||||
|
<div class="w-2/3 text-xs font-semibold uppercase text-gray-200">Value</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-full max-h-72 overflow-auto">
|
||||||
|
<template v-for="(keyValue, index) in metadataKeyValues">
|
||||||
|
<div :key="keyValue.key" class="flex py-1 px-4 text-sm" :class="index % 2 === 0 ? 'bg-primary bg-opacity-25' : ''">
|
||||||
|
<div class="w-1/3 font-semibold">{{ keyValue.key }}</div>
|
||||||
|
<div class="w-2/3">
|
||||||
|
{{ keyValue.value }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-full max-w-2xl border border-opacity-10 bg-bg mx-2">
|
||||||
|
<div class="flex py-2 px-4">
|
||||||
|
<div class="flex-grow text-xs font-semibold uppercase text-gray-200">Chapter Title</div>
|
||||||
|
<div class="w-24 text-xs font-semibold uppercase text-gray-200">Start</div>
|
||||||
|
<div class="w-24 text-xs font-semibold uppercase text-gray-200">End</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-full max-h-72 overflow-auto">
|
||||||
|
<template v-for="(chapter, index) in metadataChapters">
|
||||||
|
<div :key="index" class="flex py-1 px-4 text-sm" :class="index % 2 === 0 ? 'bg-primary bg-opacity-25' : ''">
|
||||||
|
<div class="flex-grow font-semibold">{{ chapter.title }}</div>
|
||||||
|
<div class="w-24">
|
||||||
|
{{ chapter.start.toFixed(2) }}
|
||||||
|
</div>
|
||||||
|
<div class="w-24">
|
||||||
|
{{ chapter.end.toFixed(2) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full h-px bg-white bg-opacity-10 my-8" />
|
||||||
|
|
||||||
|
<div class="w-full max-w-4xl mx-auto">
|
||||||
|
<div class="w-full flex justify-between items-center mb-4">
|
||||||
|
<p class="text-warning text-lg font-semibold">Warning: Modifies your audio files</p>
|
||||||
|
<ui-btn v-if="!embedFinished" color="primary" :loading="updatingMetadata" @click="updateAudioFileMetadata">Embed Metadata</ui-btn>
|
||||||
|
<p v-else class="text-success text-lg font-semibold">Embed Finished!</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-full mx-auto border border-opacity-10 bg-bg">
|
||||||
|
<div class="flex py-2 px-4">
|
||||||
|
<div class="w-10 text-xs font-semibold text-gray-200">#</div>
|
||||||
|
<div class="flex-grow text-xs font-semibold uppercase text-gray-200">Filename</div>
|
||||||
|
<div class="w-16 text-xs font-semibold uppercase text-gray-200">Size</div>
|
||||||
|
<div class="w-24"></div>
|
||||||
|
</div>
|
||||||
|
<template v-for="file in audioFiles">
|
||||||
|
<div :key="file.index" class="flex py-2 px-4 text-sm" :class="file.index % 2 === 0 ? 'bg-primary bg-opacity-25' : ''">
|
||||||
|
<div class="w-10">{{ file.index }}</div>
|
||||||
|
<div class="flex-grow">
|
||||||
|
{{ file.metadata.filename }}
|
||||||
|
</div>
|
||||||
|
<div class="w-16 font-mono text-gray-200">
|
||||||
|
{{ $bytesPretty(file.metadata.size) }}
|
||||||
|
</div>
|
||||||
|
<div class="w-24">
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<span v-if="audiofilesFinished[file.ino]" class="material-icons text-xl text-success leading-none">check_circle</span>
|
||||||
|
<div v-else-if="audiofilesEncoding[file.ino]">
|
||||||
|
<widgets-loading-spinner />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
async asyncData({ store, params, app, redirect, route }) {
|
||||||
|
if (!store.state.user.user) {
|
||||||
|
return redirect(`/login?redirect=${route.path}`)
|
||||||
|
}
|
||||||
|
if (!store.getters['user/getIsAdminOrUp']) {
|
||||||
|
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('/?error=not found')
|
||||||
|
}
|
||||||
|
if (libraryItem.mediaType !== 'book') {
|
||||||
|
console.error('Invalid media type')
|
||||||
|
return redirect('/?error=invalid media type')
|
||||||
|
}
|
||||||
|
if (!libraryItem.media.audioFiles.length) {
|
||||||
|
cnosole.error('No audio files')
|
||||||
|
return redirect('/?error=no audio files')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
libraryItem
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
audiofilesEncoding: {},
|
||||||
|
audiofilesFinished: {},
|
||||||
|
updatingMetadata: false,
|
||||||
|
embedFinished: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
libraryItemId() {
|
||||||
|
return this.libraryItem.id
|
||||||
|
},
|
||||||
|
media() {
|
||||||
|
return this.libraryItem.media || {}
|
||||||
|
},
|
||||||
|
mediaMetadata() {
|
||||||
|
return this.media.metadata || {}
|
||||||
|
},
|
||||||
|
audioFiles() {
|
||||||
|
return this.media.audioFiles || []
|
||||||
|
},
|
||||||
|
streamLibraryItem() {
|
||||||
|
return this.$store.state.streamLibraryItem
|
||||||
|
},
|
||||||
|
metadataKeyValues() {
|
||||||
|
const keyValues = [
|
||||||
|
{
|
||||||
|
key: 'title',
|
||||||
|
value: this.mediaMetadata.title
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'artist',
|
||||||
|
value: this.mediaMetadata.authorName
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'album_artist',
|
||||||
|
value: this.mediaMetadata.authorName
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'date',
|
||||||
|
value: this.mediaMetadata.publishedYear
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'description',
|
||||||
|
value: this.mediaMetadata.description
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'genre',
|
||||||
|
value: this.mediaMetadata.genres.join(';')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'performer',
|
||||||
|
value: this.mediaMetadata.narratorName
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
if (this.mediaMetadata.subtitle) {
|
||||||
|
keyValues.push({
|
||||||
|
key: 'subtitle',
|
||||||
|
value: this.mediaMetadata.subtitle
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.mediaMetadata.asin) {
|
||||||
|
keyValues.push({
|
||||||
|
key: 'asin',
|
||||||
|
value: this.mediaMetadata.asin
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (this.mediaMetadata.isbn) {
|
||||||
|
keyValues.push({
|
||||||
|
key: 'isbn',
|
||||||
|
value: this.mediaMetadata.isbn
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (this.mediaMetadata.language) {
|
||||||
|
keyValues.push({
|
||||||
|
key: 'language',
|
||||||
|
value: this.mediaMetadata.language
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (this.mediaMetadata.series.length) {
|
||||||
|
var firstSeries = this.mediaMetadata.series[0]
|
||||||
|
keyValues.push({
|
||||||
|
key: 'series',
|
||||||
|
value: firstSeries.name
|
||||||
|
})
|
||||||
|
if (firstSeries.sequence) {
|
||||||
|
keyValues.push({
|
||||||
|
key: 'series-part',
|
||||||
|
value: firstSeries.sequence
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return keyValues
|
||||||
|
},
|
||||||
|
metadataChapters() {
|
||||||
|
var chapters = this.media.chapters || []
|
||||||
|
return chapters.concat(chapters)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
updateAudioFileMetadata() {
|
||||||
|
if (confirm(`Warning!\n\nThis will modify the audio files for this audiobook.\nMake sure your audio files are backed up before using this feature.`)) {
|
||||||
|
this.updatingMetadata = true
|
||||||
|
this.$axios
|
||||||
|
.$get(`/api/items/${this.libraryItemId}/audio-metadata`)
|
||||||
|
.then(() => {
|
||||||
|
console.log('Audio metadata encode started')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Audio metadata encode failed', error)
|
||||||
|
this.updatingMetadata = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
audioMetadataStarted(data) {
|
||||||
|
console.log('audio metadata started', data)
|
||||||
|
if (data.libraryItemId !== this.libraryItemId) return
|
||||||
|
this.audiofilesFinished = {}
|
||||||
|
this.updatingMetadata = true
|
||||||
|
},
|
||||||
|
audioMetadataFinished(data) {
|
||||||
|
console.log('audio metadata finished', data)
|
||||||
|
if (data.libraryItemId !== this.libraryItemId) return
|
||||||
|
this.updatingMetadata = false
|
||||||
|
this.embedFinished = true
|
||||||
|
this.audiofilesEncoding = {}
|
||||||
|
this.$toast.success('Audio file metadata updated')
|
||||||
|
},
|
||||||
|
audiofileMetadataStarted(data) {
|
||||||
|
if (data.libraryItemId !== this.libraryItemId) return
|
||||||
|
this.$set(this.audiofilesEncoding, data.ino, true)
|
||||||
|
},
|
||||||
|
audiofileMetadataFinished(data) {
|
||||||
|
if (data.libraryItemId !== this.libraryItemId) return
|
||||||
|
this.$set(this.audiofilesEncoding, data.ino, false)
|
||||||
|
this.$set(this.audiofilesFinished, data.ino, true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.$root.socket.on('audio_metadata_started', this.audioMetadataStarted)
|
||||||
|
this.$root.socket.on('audio_metadata_finished', this.audioMetadataFinished)
|
||||||
|
this.$root.socket.on('audiofile_metadata_started', this.audiofileMetadataStarted)
|
||||||
|
this.$root.socket.on('audiofile_metadata_finished', this.audiofileMetadataFinished)
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
this.$root.socket.off('audio_metadata_started', this.audioMetadataStarted)
|
||||||
|
this.$root.socket.off('audio_metadata_finished', this.audioMetadataFinished)
|
||||||
|
this.$root.socket.off('audiofile_metadata_started', this.audiofileMetadataStarted)
|
||||||
|
this.$root.socket.off('audiofile_metadata_finished', this.audiofileMetadataFinished)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -7,15 +7,12 @@
|
|||||||
<div id="bookshelf" class="w-full h-full p-8 overflow-y-auto">
|
<div id="bookshelf" class="w-full h-full p-8 overflow-y-auto">
|
||||||
<div class="flex flex-wrap justify-center">
|
<div class="flex flex-wrap justify-center">
|
||||||
<template v-for="author in authors">
|
<template v-for="author in authors">
|
||||||
<nuxt-link :key="author.id" :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(author.id)}`">
|
<cards-author-card :key="author.id" :author="author" :width="160" :height="200" class="p-3" @edit="editAuthor" />
|
||||||
<cards-author-card :author="author" :width="160" :height="200" class="p-3" @edit="editAuthor" />
|
|
||||||
</nuxt-link>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<modals-authors-edit-modal v-model="showAuthorModal" :author="selectedAuthor" />
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -40,9 +37,7 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
loading: true,
|
loading: true,
|
||||||
authors: [],
|
authors: []
|
||||||
showAuthorModal: false,
|
|
||||||
selectedAuthor: null
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -51,6 +46,9 @@ export default {
|
|||||||
},
|
},
|
||||||
currentLibraryId() {
|
currentLibraryId() {
|
||||||
return this.$store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
|
},
|
||||||
|
selectedAuthor() {
|
||||||
|
return this.$store.state.globals.selectedAuthor
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -68,7 +66,7 @@ export default {
|
|||||||
},
|
},
|
||||||
authorUpdated(author) {
|
authorUpdated(author) {
|
||||||
if (this.selectedAuthor && this.selectedAuthor.id === author.id) {
|
if (this.selectedAuthor && this.selectedAuthor.id === author.id) {
|
||||||
this.selectedAuthor = author
|
this.$store.commit('globals/setSelectedAuthor', author)
|
||||||
}
|
}
|
||||||
this.authors = this.authors.map((au) => {
|
this.authors = this.authors.map((au) => {
|
||||||
if (au.id === author.id) {
|
if (au.id === author.id) {
|
||||||
@@ -81,8 +79,7 @@ export default {
|
|||||||
this.authors = this.authors.filter((au) => au.id !== author.id)
|
this.authors = this.authors.filter((au) => au.id !== author.id)
|
||||||
},
|
},
|
||||||
editAuthor(author) {
|
editAuthor(author) {
|
||||||
this.selectedAuthor = author
|
this.$store.commit('globals/showEditAuthorModal', author)
|
||||||
this.showAuthorModal = true
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ const BookCoverAspectRatio = {
|
|||||||
|
|
||||||
const BookshelfView = {
|
const BookshelfView = {
|
||||||
STANDARD: 0,
|
STANDARD: 0,
|
||||||
TITLES: 1
|
TITLES: 1,
|
||||||
|
AUTHOR: 2 // Books shown on author page
|
||||||
}
|
}
|
||||||
|
|
||||||
const PlayMethod = {
|
const PlayMethod = {
|
||||||
|
|||||||
@@ -125,6 +125,31 @@ Vue.prototype.$sanitizeFilename = (input, replacement = '') => {
|
|||||||
return sanitized
|
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) => {
|
Vue.prototype.$copyToClipboard = (str, ctx) => {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
if (!navigator.clipboard) {
|
if (!navigator.clipboard) {
|
||||||
|
|||||||
@@ -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.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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.
|
||||||
Binary file not shown.
@@ -6,8 +6,10 @@ export const state = () => ({
|
|||||||
showUserCollectionsModal: false,
|
showUserCollectionsModal: false,
|
||||||
showEditCollectionModal: false,
|
showEditCollectionModal: false,
|
||||||
showEditPodcastEpisode: false,
|
showEditPodcastEpisode: false,
|
||||||
|
showEditAuthorModal: false,
|
||||||
selectedEpisode: null,
|
selectedEpisode: null,
|
||||||
selectedCollection: null,
|
selectedCollection: null,
|
||||||
|
selectedAuthor: null,
|
||||||
showBookshelfTextureModal: false,
|
showBookshelfTextureModal: false,
|
||||||
isCasting: false, // Actively casting
|
isCasting: false, // Actively casting
|
||||||
isChromecastInitialized: false // Script loaded
|
isChromecastInitialized: false // Script loaded
|
||||||
@@ -61,6 +63,16 @@ export const mutations = {
|
|||||||
setShowBookshelfTextureModal(state, val) {
|
setShowBookshelfTextureModal(state, val) {
|
||||||
state.showBookshelfTextureModal = 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) {
|
setChromecastInitialized(state, val) {
|
||||||
state.isChromecastInitialized = val
|
state.isChromecastInitialized = val
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export const getters = {
|
|||||||
return state.serverSettings[key]
|
return state.serverSettings[key]
|
||||||
},
|
},
|
||||||
getBookCoverAspectRatio: state => {
|
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
|
return state.serverSettings.coverAspectRatio === 0 ? 1.6 : 1
|
||||||
},
|
},
|
||||||
getNumLibraryItemsSelected: state => state.selectedLibraryItems.length,
|
getNumLibraryItemsSelected: state => state.selectedLibraryItems.length,
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ export const actions = {
|
|||||||
if (state.settings.orderBy == 'media.metadata.authorName' || state.settings.orderBy == 'media.metadata.authorNameLF') {
|
if (state.settings.orderBy == 'media.metadata.authorName' || state.settings.orderBy == 'media.metadata.authorNameLF') {
|
||||||
settingsUpdate.orderBy = 'media.metadata.author'
|
settingsUpdate.orderBy = 'media.metadata.author'
|
||||||
}
|
}
|
||||||
|
if (state.settings.orderBy == 'media.duration') {
|
||||||
|
settingsUpdate.orderBy = 'media.numTracks'
|
||||||
|
}
|
||||||
var invalidFilters = ['series', 'authors', 'narrators', 'languages', 'progress', 'issues']
|
var invalidFilters = ['series', 'authors', 'narrators', 'languages', 'progress', 'issues']
|
||||||
var filterByFirstPart = (state.settings.filterBy || '').split('.').shift()
|
var filterByFirstPart = (state.settings.filterBy || '').split('.').shift()
|
||||||
if (invalidFilters.includes(filterByFirstPart)) {
|
if (invalidFilters.includes(filterByFirstPart)) {
|
||||||
@@ -81,6 +84,9 @@ export const actions = {
|
|||||||
if (state.settings.orderBy == 'media.metadata.author') {
|
if (state.settings.orderBy == 'media.metadata.author') {
|
||||||
settingsUpdate.orderBy = 'media.metadata.authorName'
|
settingsUpdate.orderBy = 'media.metadata.authorName'
|
||||||
}
|
}
|
||||||
|
if (state.settings.orderBy == 'media.numTracks') {
|
||||||
|
settingsUpdate.orderBy = 'media.duration'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (Object.keys(settingsUpdate).length) {
|
if (Object.keys(settingsUpdate).length) {
|
||||||
dispatch('updateUserSettings', settingsUpdate)
|
dispatch('updateUserSettings', settingsUpdate)
|
||||||
|
|||||||
Generated
+81
-3
@@ -1,12 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "1.7.3",
|
"version": "2.0.8",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "audiobookshelf",
|
"version": "2.0.8",
|
||||||
"version": "1.7.3",
|
|
||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"archiver": "^5.3.0",
|
"archiver": "^5.3.0",
|
||||||
@@ -26,6 +25,7 @@
|
|||||||
"node-cron": "^3.0.0",
|
"node-cron": "^3.0.0",
|
||||||
"node-ffprobe": "^3.0.0",
|
"node-ffprobe": "^3.0.0",
|
||||||
"node-stream-zip": "^1.15.0",
|
"node-stream-zip": "^1.15.0",
|
||||||
|
"podcast": "^2.0.0",
|
||||||
"proper-lockfile": "^4.1.2",
|
"proper-lockfile": "^4.1.2",
|
||||||
"read-chunk": "^3.1.0",
|
"read-chunk": "^3.1.0",
|
||||||
"recursive-readdir-async": "^1.1.8",
|
"recursive-readdir-async": "^1.1.8",
|
||||||
@@ -1477,6 +1477,14 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/podcast": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/podcast/-/podcast-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-1NZe7cVabfkcMe39yOOhBMJrXC6OROLOIBkfEPayiJL59ncyJmOeO5bxolSCSGroho8jQ0zURMczyICI1U/xbw==",
|
||||||
|
"dependencies": {
|
||||||
|
"rss": "^1.2.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/process-nextick-args": {
|
"node_modules/process-nextick-args": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||||
@@ -1675,6 +1683,34 @@
|
|||||||
"atomically": "^1.7.0"
|
"atomically": "^1.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rss": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/rss/-/rss-1.2.2.tgz",
|
||||||
|
"integrity": "sha1-UKFpiHYTgTOnT5oF0r3I240nqSE=",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-types": "2.1.13",
|
||||||
|
"xml": "1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/rss/node_modules/mime-db": {
|
||||||
|
"version": "1.25.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.25.0.tgz",
|
||||||
|
"integrity": "sha1-wY29fHOl2/b0SgJNwNFloeexw5I=",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/rss/node_modules/mime-types": {
|
||||||
|
"version": "2.1.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.13.tgz",
|
||||||
|
"integrity": "sha1-4HqqnGxrmnyjASxpADrSWjnpKog=",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": "~1.25.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/safe-buffer": {
|
"node_modules/safe-buffer": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
@@ -2071,6 +2107,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xml": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
|
||||||
|
"integrity": "sha1-eLpyAgApxbyHuKgaPPzXS0ovweU="
|
||||||
|
},
|
||||||
"node_modules/xml2js": {
|
"node_modules/xml2js": {
|
||||||
"version": "0.4.23",
|
"version": "0.4.23",
|
||||||
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
|
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
|
||||||
@@ -3222,6 +3263,14 @@
|
|||||||
"resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz",
|
||||||
"integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g=="
|
"integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g=="
|
||||||
},
|
},
|
||||||
|
"podcast": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/podcast/-/podcast-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-1NZe7cVabfkcMe39yOOhBMJrXC6OROLOIBkfEPayiJL59ncyJmOeO5bxolSCSGroho8jQ0zURMczyICI1U/xbw==",
|
||||||
|
"requires": {
|
||||||
|
"rss": "^1.2.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"process-nextick-args": {
|
"process-nextick-args": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||||
@@ -3387,6 +3436,30 @@
|
|||||||
"atomically": "^1.7.0"
|
"atomically": "^1.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"rss": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/rss/-/rss-1.2.2.tgz",
|
||||||
|
"integrity": "sha1-UKFpiHYTgTOnT5oF0r3I240nqSE=",
|
||||||
|
"requires": {
|
||||||
|
"mime-types": "2.1.13",
|
||||||
|
"xml": "1.0.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": {
|
||||||
|
"version": "1.25.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.25.0.tgz",
|
||||||
|
"integrity": "sha1-wY29fHOl2/b0SgJNwNFloeexw5I="
|
||||||
|
},
|
||||||
|
"mime-types": {
|
||||||
|
"version": "2.1.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.13.tgz",
|
||||||
|
"integrity": "sha1-4HqqnGxrmnyjASxpADrSWjnpKog=",
|
||||||
|
"requires": {
|
||||||
|
"mime-db": "~1.25.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"safe-buffer": {
|
"safe-buffer": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
@@ -3690,6 +3763,11 @@
|
|||||||
"integrity": "sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA==",
|
"integrity": "sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA==",
|
||||||
"requires": {}
|
"requires": {}
|
||||||
},
|
},
|
||||||
|
"xml": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
|
||||||
|
"integrity": "sha1-eLpyAgApxbyHuKgaPPzXS0ovweU="
|
||||||
|
},
|
||||||
"xml2js": {
|
"xml2js": {
|
||||||
"version": "0.4.23",
|
"version": "0.4.23",
|
||||||
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
|
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
|
||||||
|
|||||||
+3
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.0.8",
|
"version": "2.0.12",
|
||||||
"description": "Self-hosted audiobook server for managing and playing audiobooks",
|
"description": "Self-hosted audiobook server for managing and playing audiobooks",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
"start": "node index.js",
|
"start": "node index.js",
|
||||||
"client": "cd client && npm install && npm run generate",
|
"client": "cd client && npm install && npm run generate",
|
||||||
"prod": "npm run client && npm install && node prod.js",
|
"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",
|
"build-linux": "build/linuxpackager",
|
||||||
"docker": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 --push . -t advplyr/audiobookshelf",
|
"docker": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 --push . -t advplyr/audiobookshelf",
|
||||||
"deploy": "node dist/autodeploy"
|
"deploy": "node dist/autodeploy"
|
||||||
@@ -44,6 +44,7 @@
|
|||||||
"node-cron": "^3.0.0",
|
"node-cron": "^3.0.0",
|
||||||
"node-ffprobe": "^3.0.0",
|
"node-ffprobe": "^3.0.0",
|
||||||
"node-stream-zip": "^1.15.0",
|
"node-stream-zip": "^1.15.0",
|
||||||
|
"podcast": "^2.0.0",
|
||||||
"proper-lockfile": "^4.1.2",
|
"proper-lockfile": "^4.1.2",
|
||||||
"read-chunk": "^3.1.0",
|
"read-chunk": "^3.1.0",
|
||||||
"recursive-readdir-async": "^1.1.8",
|
"recursive-readdir-async": "^1.1.8",
|
||||||
|
|||||||
@@ -64,13 +64,14 @@ Available using Test Flight: https://testflight.apple.com/join/wiic7QIW - [Join
|
|||||||
Available in Unraid Community Apps
|
Available in Unraid Community Apps
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull advplyr/audiobookshelf
|
docker pull ghcr.io/advplyr/audiobookshelf
|
||||||
|
|
||||||
docker run -d \
|
docker run -d \
|
||||||
-e AUDIOBOOKSHELF_UID=99 \
|
-e AUDIOBOOKSHELF_UID=99 \
|
||||||
-e AUDIOBOOKSHELF_GID=100 \
|
-e AUDIOBOOKSHELF_GID=100 \
|
||||||
-p 13378:80 \
|
-p 13378:80 \
|
||||||
-v </path/to/audiobooks>:/audiobooks \
|
-v </path/to/audiobooks>:/audiobooks \
|
||||||
|
-v </path/to/your/podcasts>:/podcasts \
|
||||||
-v </path/to/config>:/config \
|
-v </path/to/config>:/config \
|
||||||
-v </path/to/metadata>:/metadata \
|
-v </path/to/metadata>:/metadata \
|
||||||
--name audiobookshelf \
|
--name audiobookshelf \
|
||||||
@@ -87,19 +88,45 @@ docker start audiobookshelf
|
|||||||
|
|
||||||
### Running with Docker Compose
|
### Running with Docker Compose
|
||||||
|
|
||||||
```bash
|
```yaml
|
||||||
### docker-compose.yml ###
|
### docker-compose.yml ###
|
||||||
services:
|
services:
|
||||||
audiobookshelf:
|
audiobookshelf:
|
||||||
image: ghcr.io/advplyr/audiobookshelf
|
image: ghcr.io/advplyr/audiobookshelf
|
||||||
|
environment:
|
||||||
|
- AUDIOBOOKSHELF_UID=99
|
||||||
|
- AUDIOBOOKSHELF_GID=100
|
||||||
ports:
|
ports:
|
||||||
- 13378:80
|
- 13378:80
|
||||||
volumes:
|
volumes:
|
||||||
- <path/to/your/audiobooks>:/audiobooks
|
- </path/to/your/audiobooks>:/audiobooks
|
||||||
- <path/to/metadata>:/metadata
|
- </path/to/your/podcasts>:/podcasts
|
||||||
- <path/to/config>:/config
|
- </path/to/config>:/config
|
||||||
|
- </path/to/metadata>:/metadata
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Docker Compose Update
|
||||||
|
|
||||||
|
Depending on the version of Docker Compose please run one of the two commands. If not sure on which version you are running you can run the following command and check.
|
||||||
|
|
||||||
|
#### Version Check
|
||||||
|
|
||||||
|
docker-compose --version or docker compose version
|
||||||
|
|
||||||
|
#### v2 Update
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --file <path/to/config>/docker-compose.yml pull
|
||||||
|
docker compose --file <path/to/config>/docker-compose.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
#### V1 Update
|
||||||
|
```bash
|
||||||
|
docker-compose --file <path/to/config>/docker-compose.yml pull
|
||||||
|
docker-compose --file <path/to/config>/docker-compose.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
** We recommend updating the the latest version of Docker Compose
|
||||||
|
|
||||||
### Linux (amd64) Install
|
### Linux (amd64) Install
|
||||||
|
|
||||||
@@ -107,29 +134,15 @@ Debian package will use this config file `/etc/default/audiobookshelf` if exists
|
|||||||
|
|
||||||
### Ubuntu Install via PPA
|
### Ubuntu Install via PPA
|
||||||
|
|
||||||
A PPA is hosted on [github](https://github.com/advplyr/audiobookshelf-ppa), add and install:
|
A PPA is hosted on [github](https://github.com/advplyr/audiobookshelf-ppa)
|
||||||
|
|
||||||
```bash
|
See [install docs](https://www.audiobookshelf.org/install/#ubuntu)
|
||||||
curl -s --compressed "https://advplyr.github.io/audiobookshelf-ppa/KEY.gpg" | sudo apt-key add -
|
|
||||||
|
|
||||||
sudo curl -s --compressed -o /etc/apt/sources.list.d/audiobookshelf.list "https://advplyr.github.io/audiobookshelf-ppa/audiobookshelf.list"
|
|
||||||
|
|
||||||
sudo apt update
|
|
||||||
|
|
||||||
sudo apt install audiobookshelf
|
|
||||||
```
|
|
||||||
|
|
||||||
or use a single command
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s --compressed "https://advplyr.github.io/audiobookshelf-ppa/KEY.gpg" | sudo apt-key add - && sudo curl -s --compressed -o /etc/apt/sources.list.d/audiobookshelf.list "https://advplyr.github.io/audiobookshelf-ppa/audiobookshelf.list" && sudo apt update && sudo apt install audiobookshelf
|
|
||||||
```
|
|
||||||
|
|
||||||
### Install via debian package
|
### Install via debian package
|
||||||
|
|
||||||
Get the `deb` file from the [github repo](https://github.com/advplyr/audiobookshelf-ppa).
|
Get the `deb` file from the [github repo](https://github.com/advplyr/audiobookshelf-ppa).
|
||||||
|
|
||||||
See [instructions](https://www.audiobookshelf.org/install#debian)
|
See [install docs](https://www.audiobookshelf.org/install#debian)
|
||||||
|
|
||||||
|
|
||||||
#### Linux file locations
|
#### Linux file locations
|
||||||
@@ -235,6 +248,17 @@ For this to work you must enable at least the following mods using `a2enmod`:
|
|||||||
|
|
||||||
[from @silentArtifact](https://github.com/advplyr/audiobookshelf/issues/241#issuecomment-1036732329)
|
[from @silentArtifact](https://github.com/advplyr/audiobookshelf/issues/241#issuecomment-1036732329)
|
||||||
|
|
||||||
|
### [Traefik Reverse Proxy](https://doc.traefik.io/traefik/)
|
||||||
|
|
||||||
|
Middleware relating to CORS will cause the app to report Unknown Error when logging in. To prevent this don't apply any of the following headers to the router for this site:
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>accessControlAllowMethods</li>
|
||||||
|
<li>accessControlAllowOriginList</li>
|
||||||
|
<li>accessControlMaxAge</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
From [@Dondochaka](https://discord.com/channels/942908292873723984/942914154254176257/945074590374318170) and [@BeastleeUK](https://discord.com/channels/942908292873723984/942914154254176257/970366039294611506)
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
# Run from source
|
# Run from source
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
// const Podcast = require('podcast')
|
|
||||||
const express = require('express')
|
|
||||||
// const ip = require('ip')
|
|
||||||
const Logger = require('./Logger')
|
|
||||||
|
|
||||||
// Not functional at the moment - just an idea
|
|
||||||
class RssFeeds {
|
|
||||||
constructor(Port, db) {
|
|
||||||
this.Port = Port
|
|
||||||
this.db = db
|
|
||||||
this.feeds = {}
|
|
||||||
|
|
||||||
this.router = express()
|
|
||||||
this.init()
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {
|
|
||||||
this.router.get('/:id', this.getFeed.bind(this))
|
|
||||||
}
|
|
||||||
|
|
||||||
getFeed(req, res) {
|
|
||||||
Logger.info('Get Feed', req.params.id, this.feeds[req.params.id])
|
|
||||||
|
|
||||||
var feed = this.feeds[req.params.id]
|
|
||||||
if (!feed) return null
|
|
||||||
var xml = feed.buildXml()
|
|
||||||
res.set('Content-Type', 'text/xml')
|
|
||||||
res.send(xml)
|
|
||||||
}
|
|
||||||
|
|
||||||
openFeed(audiobook) {
|
|
||||||
// Removed Podcast npm package and ip package
|
|
||||||
return null
|
|
||||||
// var ipAddress = ip.address('public', 'ipv4')
|
|
||||||
// var serverAddress = 'http://' + ipAddress + ':' + this.Port
|
|
||||||
// Logger.info('Open RSS Feed', 'Server address', serverAddress)
|
|
||||||
|
|
||||||
// var feedId = (Date.now() + Math.floor(Math.random() * 1000)).toString(36)
|
|
||||||
// const feed = new Podcast({
|
|
||||||
// title: audiobook.title,
|
|
||||||
// description: 'AudioBookshelf RSS Feed',
|
|
||||||
// feed_url: `${serverAddress}/feeds/${feedId}`,
|
|
||||||
// image_url: `${serverAddress}/Logo.png`,
|
|
||||||
// author: 'advplyr',
|
|
||||||
// language: 'en'
|
|
||||||
// })
|
|
||||||
// audiobook.tracks.forEach((track) => {
|
|
||||||
// feed.addItem({
|
|
||||||
// title: `Track ${track.index}`,
|
|
||||||
// description: `AudioBookshelf Audiobook Track #${track.index}`,
|
|
||||||
// url: `${serverAddress}/feeds/${feedId}?track=${track.index}`,
|
|
||||||
// author: 'advplyr'
|
|
||||||
// })
|
|
||||||
// })
|
|
||||||
// this.feeds[feedId] = feed
|
|
||||||
// return feed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
module.exports = RssFeeds
|
|
||||||
+18
-1
@@ -30,6 +30,8 @@ const LogManager = require('./managers/LogManager')
|
|||||||
const BackupManager = require('./managers/BackupManager')
|
const BackupManager = require('./managers/BackupManager')
|
||||||
const PlaybackSessionManager = require('./managers/PlaybackSessionManager')
|
const PlaybackSessionManager = require('./managers/PlaybackSessionManager')
|
||||||
const PodcastManager = require('./managers/PodcastManager')
|
const PodcastManager = require('./managers/PodcastManager')
|
||||||
|
const AudioMetadataMangaer = require('./managers/AudioMetadataManager')
|
||||||
|
const RssFeedManager = require('./managers/RssFeedManager')
|
||||||
|
|
||||||
class Server {
|
class Server {
|
||||||
constructor(PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) {
|
constructor(PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) {
|
||||||
@@ -72,11 +74,13 @@ class Server {
|
|||||||
this.playbackSessionManager = new PlaybackSessionManager(this.db, this.emitter.bind(this), this.clientEmitter.bind(this))
|
this.playbackSessionManager = new PlaybackSessionManager(this.db, this.emitter.bind(this), this.clientEmitter.bind(this))
|
||||||
this.coverManager = new CoverManager(this.db, this.cacheManager)
|
this.coverManager = new CoverManager(this.db, this.cacheManager)
|
||||||
this.podcastManager = new PodcastManager(this.db, this.watcher, this.emitter.bind(this))
|
this.podcastManager = new PodcastManager(this.db, this.watcher, this.emitter.bind(this))
|
||||||
|
this.audioMetadataManager = new AudioMetadataMangaer(this.db, this.emitter.bind(this), this.clientEmitter.bind(this))
|
||||||
|
this.rssFeedManager = new RssFeedManager(this.db, this.emitter.bind(this))
|
||||||
|
|
||||||
this.scanner = new Scanner(this.db, this.coverManager, this.emitter.bind(this))
|
this.scanner = new Scanner(this.db, this.coverManager, this.emitter.bind(this))
|
||||||
|
|
||||||
// Routers
|
// Routers
|
||||||
this.apiRouter = new ApiRouter(this.db, this.auth, this.scanner, this.playbackSessionManager, this.abMergeManager, this.coverManager, this.backupManager, this.watcher, this.cacheManager, this.podcastManager, this.emitter.bind(this), this.clientEmitter.bind(this))
|
this.apiRouter = new ApiRouter(this.db, this.auth, this.scanner, this.playbackSessionManager, this.abMergeManager, this.coverManager, this.backupManager, this.watcher, this.cacheManager, this.podcastManager, this.audioMetadataManager, this.rssFeedManager, this.emitter.bind(this), this.clientEmitter.bind(this))
|
||||||
this.hlsRouter = new HlsRouter(this.db, this.auth, this.playbackSessionManager, this.emitter.bind(this))
|
this.hlsRouter = new HlsRouter(this.db, this.auth, this.playbackSessionManager, this.emitter.bind(this))
|
||||||
this.staticRouter = new StaticRouter(this.db)
|
this.staticRouter = new StaticRouter(this.db)
|
||||||
|
|
||||||
@@ -196,6 +200,19 @@ class Server {
|
|||||||
res.sendFile(fullPath)
|
res.sendFile(fullPath)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// RSS Feed temp route
|
||||||
|
app.get('/feed/:id', (req, res) => {
|
||||||
|
Logger.info(`[Server] requesting rss feed ${req.params.id}`)
|
||||||
|
this.rssFeedManager.getFeed(req, res)
|
||||||
|
})
|
||||||
|
app.get('/feed/:id/cover', (req, res) => {
|
||||||
|
this.rssFeedManager.getFeedCover(req, res)
|
||||||
|
})
|
||||||
|
app.get('/feed/:id/item/*', (req, res) => {
|
||||||
|
Logger.info(`[Server] requesting rss feed ${req.params.id}`)
|
||||||
|
this.rssFeedManager.getFeedItem(req, res)
|
||||||
|
})
|
||||||
|
|
||||||
// Client dynamic routes
|
// Client dynamic routes
|
||||||
app.get('/item/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
app.get('/item/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
||||||
app.get('/audiobook/:id/edit', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
app.get('/audiobook/:id/edit', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
||||||
|
|||||||
@@ -1,11 +1,60 @@
|
|||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const { reqSupportsWebp } = require('../utils/index')
|
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 {
|
class AuthorController {
|
||||||
constructor() { }
|
constructor() { }
|
||||||
|
|
||||||
async findOne(req, res) {
|
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) {
|
async update(req, res) {
|
||||||
@@ -41,6 +90,7 @@ class AuthorController {
|
|||||||
}).length
|
}).length
|
||||||
this.emitter('author_updated', req.author.toJSONExpanded(numBooks))
|
this.emitter('author_updated', req.author.toJSONExpanded(numBooks))
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
author: req.author.toJSON(),
|
author: req.author.toJSON(),
|
||||||
updated: hasUpdated
|
updated: hasUpdated
|
||||||
|
|||||||
@@ -4,16 +4,16 @@ class BackupController {
|
|||||||
constructor() { }
|
constructor() { }
|
||||||
|
|
||||||
async create(req, res) {
|
async create(req, res) {
|
||||||
if (!req.user.isRoot) {
|
if (!req.user.isAdminOrUp) {
|
||||||
Logger.error(`[BackupController] Non-Root user attempting to craete backup`, req.user)
|
Logger.error(`[BackupController] Non-admin user attempting to craete backup`, req.user)
|
||||||
return res.sendStatus(403)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
this.backupManager.requestCreateBackup(res)
|
this.backupManager.requestCreateBackup(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(req, res) {
|
async delete(req, res) {
|
||||||
if (!req.user.isRoot) {
|
if (!req.user.isAdminOrUp) {
|
||||||
Logger.error(`[BackupController] Non-Root user attempting to delete backup`, req.user)
|
Logger.error(`[BackupController] Non-admin user attempting to delete backup`, req.user)
|
||||||
return res.sendStatus(403)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
var backup = this.backupManager.backups.find(b => b.id === req.params.id)
|
var backup = this.backupManager.backups.find(b => b.id === req.params.id)
|
||||||
@@ -25,8 +25,8 @@ class BackupController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async upload(req, res) {
|
async upload(req, res) {
|
||||||
if (!req.user.isRoot) {
|
if (!req.user.isAdminOrUp) {
|
||||||
Logger.error(`[BackupController] Non-Root user attempting to upload backup`, req.user)
|
Logger.error(`[BackupController] Non-admin user attempting to upload backup`, req.user)
|
||||||
return res.sendStatus(403)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
if (!req.files.file) {
|
if (!req.files.file) {
|
||||||
@@ -37,8 +37,8 @@ class BackupController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async apply(req, res) {
|
async apply(req, res) {
|
||||||
if (!req.user.isRoot) {
|
if (!req.user.isAdminOrUp) {
|
||||||
Logger.error(`[BackupController] Non-Root user attempting to apply backup`, req.user)
|
Logger.error(`[BackupController] Non-admin user attempting to apply backup`, req.user)
|
||||||
return res.sendStatus(403)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
var backup = this.backupManager.backups.find(b => b.id === req.params.id)
|
var backup = this.backupManager.backups.find(b => b.id === req.params.id)
|
||||||
|
|||||||
@@ -320,7 +320,7 @@ class LibraryController {
|
|||||||
|
|
||||||
// PATCH: Change the order of libraries
|
// PATCH: Change the order of libraries
|
||||||
async reorder(req, res) {
|
async reorder(req, res) {
|
||||||
if (!req.user.isRoot) {
|
if (!req.user.isAdminOrUp) {
|
||||||
Logger.error('[LibraryController] ReorderLibraries invalid user', req.user)
|
Logger.error('[LibraryController] ReorderLibraries invalid user', req.user)
|
||||||
return res.sendStatus(403)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
@@ -457,7 +457,7 @@ class LibraryController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async matchAll(req, res) {
|
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)
|
Logger.error(`[LibraryController] Non-root user attempted to match library items`, req.user)
|
||||||
return res.sendStatus(403)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
@@ -467,7 +467,7 @@ class LibraryController {
|
|||||||
|
|
||||||
// GET: api/scan (Root)
|
// GET: api/scan (Root)
|
||||||
async scan(req, res) {
|
async scan(req, res) {
|
||||||
if (!req.user.isRoot) {
|
if (!req.user.isAdminOrUp) {
|
||||||
Logger.error(`[LibraryController] Non-root user attempted to scan library`, req.user)
|
Logger.error(`[LibraryController] Non-root user attempted to scan library`, req.user)
|
||||||
return res.sendStatus(403)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ class LibraryItemController {
|
|||||||
item.userMediaProgress = req.user.getMediaProgress(item.id, episodeId)
|
item.userMediaProgress = req.user.getMediaProgress(item.id, episodeId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (includeEntities.includes('rssfeed')) {
|
||||||
|
var feedData = this.rssFeedManager.findFeedForItem(item.id)
|
||||||
|
item.rssFeedUrl = feedData ? feedData.feedUrl : null
|
||||||
|
}
|
||||||
|
|
||||||
if (item.mediaType == 'book') {
|
if (item.mediaType == 'book') {
|
||||||
if (includeEntities.includes('authors')) {
|
if (includeEntities.includes('authors')) {
|
||||||
item.media.metadata.authors = item.media.metadata.authors.map(au => {
|
item.media.metadata.authors = item.media.metadata.authors.map(au => {
|
||||||
@@ -326,8 +331,8 @@ class LibraryItemController {
|
|||||||
|
|
||||||
// DELETE: api/items/all
|
// DELETE: api/items/all
|
||||||
async deleteAll(req, res) {
|
async deleteAll(req, res) {
|
||||||
if (!req.user.isRoot) {
|
if (!req.user.isAdminOrUp) {
|
||||||
Logger.warn('User other than root attempted to delete all library items', req.user)
|
Logger.warn('User other than admin attempted to delete all library items', req.user)
|
||||||
return res.sendStatus(403)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
Logger.info('Removing all Library Items')
|
Logger.info('Removing all Library Items')
|
||||||
@@ -336,10 +341,10 @@ class LibraryItemController {
|
|||||||
else res.sendStatus(500)
|
else res.sendStatus(500)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET: api/items/:id/scan (Root)
|
// GET: api/items/:id/scan (admin)
|
||||||
async scan(req, res) {
|
async scan(req, res) {
|
||||||
if (!req.user.isRoot) {
|
if (!req.user.isAdminOrUp) {
|
||||||
Logger.error(`[LibraryItemController] Non-root user attempted to scan library item`, req.user)
|
Logger.error(`[LibraryItemController] Non-admin user attempted to scan library item`, req.user)
|
||||||
return res.sendStatus(403)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,17 +359,28 @@ class LibraryItemController {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST: api/items/:id/audio-metadata
|
||||||
|
async updateAudioFileMetadata(req, res) {
|
||||||
|
if (!req.user.isAdminOrUp) {
|
||||||
|
Logger.error(`[LibraryItemController] Non-root user attempted to update audio metadata`, req.user)
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) {
|
||||||
|
Logger.error(`[LibraryItemController] Invalid library item`)
|
||||||
|
return res.sendStatus(500)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.audioMetadataManager.updateAudioFileMetadataForItem(req.user, req.libraryItem)
|
||||||
|
res.sendStatus(200)
|
||||||
|
}
|
||||||
|
|
||||||
middleware(req, res, next) {
|
middleware(req, res, next) {
|
||||||
var item = this.db.libraryItems.find(li => li.id === req.params.id)
|
var item = this.db.libraryItems.find(li => li.id === req.params.id)
|
||||||
if (!item || !item.media) return res.sendStatus(404)
|
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
|
// Check user can access this library item
|
||||||
if (!req.user.checkCanAccessLibraryItemWithTags(item.media.tags)) {
|
if (!req.user.checkCanAccessLibraryItem(item)) {
|
||||||
return res.sendStatus(403)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -159,10 +159,10 @@ class MiscController {
|
|||||||
res.json(downloads)
|
res.json(downloads)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PATCH: api/settings (Root)
|
// PATCH: api/settings (admin)
|
||||||
async updateServerSettings(req, res) {
|
async updateServerSettings(req, res) {
|
||||||
if (!req.user.isRoot) {
|
if (!req.user.isAdminOrUp) {
|
||||||
Logger.error('User other than root attempting to update server settings', req.user)
|
Logger.error('User other than admin attempting to update server settings', req.user)
|
||||||
return res.sendStatus(403)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
var settingsUpdate = req.body
|
var settingsUpdate = req.body
|
||||||
@@ -185,9 +185,9 @@ class MiscController {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST: api/purgecache (Root)
|
// POST: api/purgecache (admin)
|
||||||
async purgeCache(req, res) {
|
async purgeCache(req, res) {
|
||||||
if (!req.user.isRoot) {
|
if (!req.user.isAdminOrUp) {
|
||||||
return res.sendStatus(403)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
Logger.info(`[ApiRouter] Purging all cache`)
|
Logger.info(`[ApiRouter] Purging all cache`)
|
||||||
@@ -239,8 +239,8 @@ class MiscController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getAllTags(req, res) {
|
getAllTags(req, res) {
|
||||||
if (!req.user.isRoot) {
|
if (!req.user.isAdminOrUp) {
|
||||||
Logger.error(`[MiscController] Non-root user attempted to getAllTags`)
|
Logger.error(`[MiscController] Non-admin user attempted to getAllTags`)
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
}
|
}
|
||||||
var tags = []
|
var tags = []
|
||||||
|
|||||||
@@ -120,14 +120,7 @@ class PodcastController {
|
|||||||
return res.sendStatus(500)
|
return res.sendStatus(500)
|
||||||
}
|
}
|
||||||
|
|
||||||
var libraryItem = this.db.getLibraryItem(req.params.id)
|
var libraryItem = req.libraryItem
|
||||||
if (!libraryItem || libraryItem.mediaType !== 'podcast') {
|
|
||||||
return res.sendStatus(404)
|
|
||||||
}
|
|
||||||
if (!req.user.checkCanAccessLibrary(libraryItem.libraryId)) {
|
|
||||||
Logger.error(`[PodcastController] User attempted to check/download episodes for a library without permission`, req.user)
|
|
||||||
return res.sendStatus(500)
|
|
||||||
}
|
|
||||||
if (!libraryItem.media.metadata.feedUrl) {
|
if (!libraryItem.media.metadata.feedUrl) {
|
||||||
Logger.error(`[PodcastController] checkNewEpisodes no feed url for item ${libraryItem.id}`)
|
Logger.error(`[PodcastController] checkNewEpisodes no feed url for item ${libraryItem.id}`)
|
||||||
return res.status(500).send('Podcast has no rss feed url')
|
return res.status(500).send('Podcast has no rss feed url')
|
||||||
@@ -149,10 +142,8 @@ class PodcastController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getEpisodeDownloads(req, res) {
|
getEpisodeDownloads(req, res) {
|
||||||
var libraryItem = this.db.getLibraryItem(req.params.id)
|
var libraryItem = req.libraryItem
|
||||||
if (!libraryItem || libraryItem.mediaType !== 'podcast') {
|
|
||||||
return res.sendStatus(404)
|
|
||||||
}
|
|
||||||
var downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(libraryItem.id)
|
var downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(libraryItem.id)
|
||||||
res.json({
|
res.json({
|
||||||
downloads: downloadsInQueue.map(d => d.toJSONForClient())
|
downloads: downloadsInQueue.map(d => d.toJSONForClient())
|
||||||
@@ -164,15 +155,7 @@ class PodcastController {
|
|||||||
Logger.error(`[PodcastController] Non-admin user attempted to download episodes`, req.user)
|
Logger.error(`[PodcastController] Non-admin user attempted to download episodes`, req.user)
|
||||||
return res.sendStatus(500)
|
return res.sendStatus(500)
|
||||||
}
|
}
|
||||||
|
var libraryItem = req.libraryItem
|
||||||
var libraryItem = this.db.getLibraryItem(req.params.id)
|
|
||||||
if (!libraryItem || libraryItem.mediaType !== 'podcast') {
|
|
||||||
return res.sendStatus(404)
|
|
||||||
}
|
|
||||||
if (!req.user.checkCanAccessLibrary(libraryItem.libraryId)) {
|
|
||||||
Logger.error(`[PodcastController] User attempted to download episodes for library without permission`, req.user)
|
|
||||||
return res.sendStatus(404)
|
|
||||||
}
|
|
||||||
|
|
||||||
var episodes = req.body
|
var episodes = req.body
|
||||||
if (!episodes || !episodes.length) {
|
if (!episodes || !episodes.length) {
|
||||||
@@ -183,14 +166,39 @@ class PodcastController {
|
|||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async openPodcastFeed(req, res) {
|
||||||
|
if (!req.user.isAdminOrUp) {
|
||||||
|
Logger.error(`[PodcastController] Non-admin user attempted to open podcast feed`, req.user.username)
|
||||||
|
return res.sendStatus(500)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
feedUrl: feedData.feedUrl
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async closePodcastFeed(req, res) {
|
||||||
|
if (!req.user.isAdminOrUp) {
|
||||||
|
Logger.error(`[PodcastController] Non-admin user attempted to close podcast feed`, req.user.username)
|
||||||
|
return res.sendStatus(500)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.rssFeedManager.closePodcastFeedForItem(req.params.id)
|
||||||
|
|
||||||
|
res.sendStatus(200)
|
||||||
|
}
|
||||||
|
|
||||||
async updateEpisode(req, res) {
|
async updateEpisode(req, res) {
|
||||||
var libraryItem = this.db.getLibraryItem(req.params.id)
|
var libraryItem = req.libraryItem
|
||||||
if (!libraryItem || libraryItem.mediaType !== 'podcast') {
|
|
||||||
return res.sendStatus(404)
|
|
||||||
}
|
|
||||||
if (!req.user.canUpload || !req.user.checkCanAccessLibrary(libraryItem.libraryId)) {
|
|
||||||
return res.sendStatus(404)
|
|
||||||
}
|
|
||||||
|
|
||||||
var episodeId = req.params.episodeId
|
var episodeId = req.params.episodeId
|
||||||
if (!libraryItem.media.checkHasEpisode(episodeId)) {
|
if (!libraryItem.media.checkHasEpisode(episodeId)) {
|
||||||
@@ -205,5 +213,35 @@ class PodcastController {
|
|||||||
|
|
||||||
res.json(libraryItem.toJSONExpanded())
|
res.json(libraryItem.toJSONExpanded())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
middleware(req, res, next) {
|
||||||
|
var item = this.db.libraryItems.find(li => li.id === req.params.id)
|
||||||
|
if (!item || !item.media) return res.sendStatus(404)
|
||||||
|
|
||||||
|
if (!item.isPodcast) {
|
||||||
|
return res.sendStatus(500)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check user can access this library
|
||||||
|
if (!req.user.checkCanAccessLibrary(item.libraryId)) {
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check user can access this library item
|
||||||
|
if (!req.user.checkCanAccessLibraryItemWithTags(item.media.tags)) {
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method == 'DELETE' && !req.user.canDelete) {
|
||||||
|
Logger.warn(`[PodcastController] User attempted to delete without permission`, req.user.username)
|
||||||
|
return res.sendStatus(403)
|
||||||
|
} else if ((req.method == 'PATCH' || req.method == 'POST') && !req.user.canUpdate) {
|
||||||
|
Logger.warn('[PodcastController] User attempted to update without permission', req.user.username)
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.libraryItem = item
|
||||||
|
next()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
module.exports = new PodcastController()
|
module.exports = new PodcastController()
|
||||||
@@ -7,14 +7,15 @@ class UserController {
|
|||||||
constructor() { }
|
constructor() { }
|
||||||
|
|
||||||
findAll(req, res) {
|
findAll(req, res) {
|
||||||
if (!req.user.isRoot) return res.sendStatus(403)
|
if (!req.user.isAdminOrUp) return res.sendStatus(403)
|
||||||
var users = this.db.users.map(u => this.userJsonWithItemProgressDetails(u))
|
const hideRootToken = !req.user.isRoot
|
||||||
|
var users = this.db.users.map(u => this.userJsonWithItemProgressDetails(u, hideRootToken))
|
||||||
res.json(users)
|
res.json(users)
|
||||||
}
|
}
|
||||||
|
|
||||||
findOne(req, res) {
|
findOne(req, res) {
|
||||||
if (!req.user.isRoot) {
|
if (!req.user.isAdminOrUp) {
|
||||||
Logger.error('User other than root attempting to get user', req.user)
|
Logger.error('User other than admin attempting to get user', req.user)
|
||||||
return res.sendStatus(403)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,12 +24,12 @@ class UserController {
|
|||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(this.userJsonWithItemProgressDetails(user))
|
res.json(this.userJsonWithItemProgressDetails(user, !req.user.isRoot))
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(req, res) {
|
async create(req, res) {
|
||||||
if (!req.user.isRoot) {
|
if (!req.user.isAdminOrUp) {
|
||||||
Logger.warn('Non-root user attempted to create user', req.user)
|
Logger.warn('Non-admin user attempted to create user', req.user)
|
||||||
return res.sendStatus(403)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
var account = req.body
|
var account = req.body
|
||||||
@@ -57,8 +58,8 @@ class UserController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async update(req, res) {
|
async update(req, res) {
|
||||||
if (!req.user.isRoot) {
|
if (!req.user.isAdminOrUp) {
|
||||||
Logger.error('User other than root attempting to update user', req.user)
|
Logger.error('[UserController] User other than admin attempting to update user', req.user)
|
||||||
return res.sendStatus(403)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +68,11 @@ class UserController {
|
|||||||
return res.sendStatus(404)
|
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
|
var account = req.body
|
||||||
|
|
||||||
if (account.username !== undefined && account.username !== user.username) {
|
if (account.username !== undefined && account.username !== user.username) {
|
||||||
@@ -95,8 +101,8 @@ class UserController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async delete(req, res) {
|
async delete(req, res) {
|
||||||
if (!req.user.isRoot) {
|
if (!req.user.isAdminOrUp) {
|
||||||
Logger.error('User other than root attempting to delete user', req.user)
|
Logger.error('User other than admin attempting to delete user', req.user)
|
||||||
return res.sendStatus(403)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
if (req.params.id === 'root') {
|
if (req.params.id === 'root') {
|
||||||
@@ -133,7 +139,7 @@ class UserController {
|
|||||||
|
|
||||||
// GET: api/users/:id/listening-sessions
|
// GET: api/users/:id/listening-sessions
|
||||||
async getListeningSessions(req, res) {
|
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)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
var listeningSessions = await this.getUserListeningSessionsHelper(req.params.id)
|
var listeningSessions = await this.getUserListeningSessionsHelper(req.params.id)
|
||||||
@@ -142,7 +148,7 @@ class UserController {
|
|||||||
|
|
||||||
// GET: api/users/:id/listening-stats
|
// GET: api/users/:id/listening-stats
|
||||||
async getListeningStats(req, res) {
|
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)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
var listeningStats = await this.getUserListeningStatsHelpers(req.params.id)
|
var listeningStats = await this.getUserListeningStatsHelpers(req.params.id)
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class AuthorFinder {
|
|||||||
|
|
||||||
async findAuthorByName(name, options = {}) {
|
async findAuthorByName(name, options = {}) {
|
||||||
if (!name) return null
|
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)
|
var author = await this.audnexus.findAuthorByName(name, maxLevenshtein)
|
||||||
if (!author || !author.name) {
|
if (!author || !author.name) {
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ class AbMergeManager {
|
|||||||
'-acodec aac',
|
'-acodec aac',
|
||||||
'-ac 2',
|
'-ac 2',
|
||||||
'-b:a 64k',
|
'-b:a 64k',
|
||||||
'-id3v2_version 3'
|
'-movflags use_metadata_tags'
|
||||||
])
|
])
|
||||||
} else {
|
} else {
|
||||||
ffmpegOptions.push('-max_muxing_queue_size 1000')
|
ffmpegOptions.push('-max_muxing_queue_size 1000')
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
const Path = require('path')
|
||||||
|
const fs = require('fs-extra')
|
||||||
|
const workerThreads = require('worker_threads')
|
||||||
|
const Logger = require('../Logger')
|
||||||
|
const filePerms = require('../utils/filePerms')
|
||||||
|
const { secondsToTimestamp } = require('../utils/index')
|
||||||
|
const { writeMetadataFile } = require('../utils/ffmpegHelpers')
|
||||||
|
|
||||||
|
class AudioMetadataMangaer {
|
||||||
|
constructor(db, emitter, clientEmitter) {
|
||||||
|
this.db = db
|
||||||
|
this.emitter = emitter
|
||||||
|
this.clientEmitter = clientEmitter
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAudioFileMetadataForItem(user, libraryItem) {
|
||||||
|
var audioFiles = libraryItem.media.audioFiles
|
||||||
|
|
||||||
|
const itemAudioMetadataPayload = {
|
||||||
|
userId: user.id,
|
||||||
|
libraryItemId: libraryItem.id,
|
||||||
|
startedAt: Date.now(),
|
||||||
|
audioFiles: audioFiles.map(af => ({ index: af.index, ino: af.ino, filename: af.metadata.filename }))
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emitter('audio_metadata_started', itemAudioMetadataPayload)
|
||||||
|
|
||||||
|
var downloadsPath = Path.join(global.MetadataPath, 'downloads')
|
||||||
|
var outputDir = Path.join(downloadsPath, libraryItem.id)
|
||||||
|
await fs.ensureDir(outputDir)
|
||||||
|
|
||||||
|
var metadataFilePath = Path.join(outputDir, 'metadata.txt')
|
||||||
|
await writeMetadataFile(libraryItem, metadataFilePath)
|
||||||
|
|
||||||
|
// TODO: Split into batches
|
||||||
|
const proms = audioFiles.map(af => {
|
||||||
|
return this.updateAudioFileMetadata(libraryItem.id, af, outputDir, metadataFilePath)
|
||||||
|
})
|
||||||
|
|
||||||
|
const results = await Promise.all(proms)
|
||||||
|
|
||||||
|
Logger.debug(`[AudioMetadataManager] Finished`)
|
||||||
|
|
||||||
|
await fs.remove(outputDir)
|
||||||
|
|
||||||
|
const elapsed = Date.now() - itemAudioMetadataPayload.startedAt
|
||||||
|
Logger.debug(`[AudioMetadataManager] Elapsed ${secondsToTimestamp(elapsed)}`)
|
||||||
|
itemAudioMetadataPayload.results = results
|
||||||
|
itemAudioMetadataPayload.elapsed = elapsed
|
||||||
|
itemAudioMetadataPayload.finishedAt = Date.now()
|
||||||
|
this.emitter('audio_metadata_finished', itemAudioMetadataPayload)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAudioFileMetadata(libraryItemId, audioFile, outputDir, metadataFilePath) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const resultPayload = {
|
||||||
|
libraryItemId,
|
||||||
|
index: audioFile.index,
|
||||||
|
ino: audioFile.ino,
|
||||||
|
filename: audioFile.metadata.filename
|
||||||
|
}
|
||||||
|
this.emitter('audiofile_metadata_started', resultPayload)
|
||||||
|
|
||||||
|
Logger.debug(`[AudioFileMetadataManager] Starting audio file metadata encode for "${audioFile.metadata.filename}"`)
|
||||||
|
|
||||||
|
var outputPath = Path.join(outputDir, audioFile.metadata.filename)
|
||||||
|
var inputPath = audioFile.metadata.path
|
||||||
|
const isM4b = audioFile.metadata.format === 'm4b'
|
||||||
|
const ffmpegInputs = [
|
||||||
|
{
|
||||||
|
input: inputPath,
|
||||||
|
options: isM4b ? ['-f mp4'] : []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: metadataFilePath
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
/*
|
||||||
|
Mp4 doesnt support writing custom tags by default. Supported tags are itunes tags: https://git.videolan.org/?p=ffmpeg.git;a=blob;f=libavformat/movenc.c;h=b6821d447c92183101086cb67099b2f4804293de;hb=HEAD#l2905
|
||||||
|
|
||||||
|
Workaround -movflags use_metadata_tags found here: https://superuser.com/a/1208277
|
||||||
|
|
||||||
|
Ffmpeg premapped id3 tags: https://wiki.multimedia.cx/index.php/FFmpeg_Metadata
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ffmpegOptions = ['-c copy', '-map_metadata 1', `-metadata track=${audioFile.index}`, '-write_id3v2 1', '-movflags use_metadata_tags']
|
||||||
|
var workerData = {
|
||||||
|
inputs: ffmpegInputs,
|
||||||
|
options: ffmpegOptions,
|
||||||
|
outputOptions: isM4b ? ['-f mp4'] : [],
|
||||||
|
output: outputPath,
|
||||||
|
}
|
||||||
|
var workerPath = Path.join(global.appRoot, 'server/utils/downloadWorker.js')
|
||||||
|
var worker = new workerThreads.Worker(workerPath, { workerData })
|
||||||
|
|
||||||
|
worker.on('message', async (message) => {
|
||||||
|
if (message != null && typeof message === 'object') {
|
||||||
|
if (message.type === 'RESULT') {
|
||||||
|
Logger.debug(message)
|
||||||
|
|
||||||
|
if (message.success) {
|
||||||
|
Logger.debug(`[AudioFileMetadataManager] Metadata encode SUCCESS for "${audioFile.metadata.filename}"`)
|
||||||
|
|
||||||
|
await filePerms.setDefault(outputPath, true)
|
||||||
|
|
||||||
|
fs.move(outputPath, inputPath, { overwrite: true }).then(() => {
|
||||||
|
Logger.debug(`[AudioFileMetadataManager] Audio file replaced successfully "${inputPath}"`)
|
||||||
|
|
||||||
|
resultPayload.success = true
|
||||||
|
this.emitter('audiofile_metadata_finished', resultPayload)
|
||||||
|
resolve(resultPayload)
|
||||||
|
}).catch((error) => {
|
||||||
|
Logger.error(`[AudioFileMetadataManager] Audio file failed to move "${inputPath}"`, error)
|
||||||
|
resultPayload.success = false
|
||||||
|
this.emitter('audiofile_metadata_finished', resultPayload)
|
||||||
|
resolve(resultPayload)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Logger.debug(`[AudioFileMetadataManager] Metadata encode FAILED for "${audioFile.metadata.filename}"`)
|
||||||
|
|
||||||
|
resultPayload.success = false
|
||||||
|
this.emitter('audiofile_metadata_finished', resultPayload)
|
||||||
|
resolve(resultPayload)
|
||||||
|
}
|
||||||
|
} else if (message.type === 'FFMPEG') {
|
||||||
|
if (message.level === 'debug' && process.env.NODE_ENV === 'production') {
|
||||||
|
// stderr is not necessary in production
|
||||||
|
} else if (Logger[message.level]) {
|
||||||
|
Logger[message.level](message.log)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Logger.error('Invalid worker message', message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
module.exports = AudioMetadataMangaer
|
||||||
@@ -131,8 +131,21 @@ class BackupManager {
|
|||||||
var filename = filesInDir[i]
|
var filename = filesInDir[i]
|
||||||
if (filename.endsWith('.audiobookshelf')) {
|
if (filename.endsWith('.audiobookshelf')) {
|
||||||
var fullFilePath = Path.join(this.BackupPath, filename)
|
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 details = data.toString('utf8').split('\n')
|
||||||
|
|
||||||
var backup = new Backup({ details, fullPath: fullFilePath })
|
var backup = new Backup({ details, fullPath: fullFilePath })
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class PodcastManager {
|
|||||||
this.currentDownload = null
|
this.currentDownload = null
|
||||||
|
|
||||||
this.episodeScheduleTask = null
|
this.episodeScheduleTask = null
|
||||||
|
this.failedCheckMap = {}
|
||||||
}
|
}
|
||||||
|
|
||||||
get serverSettings() {
|
get serverSettings() {
|
||||||
@@ -154,7 +155,10 @@ class PodcastManager {
|
|||||||
schedulePodcastEpisodeCron() {
|
schedulePodcastEpisodeCron() {
|
||||||
try {
|
try {
|
||||||
Logger.debug(`[PodcastManager] Scheduled podcast episode check cron "${this.serverSettings.podcastEpisodeSchedule}"`)
|
Logger.debug(`[PodcastManager] Scheduled podcast episode check cron "${this.serverSettings.podcastEpisodeSchedule}"`)
|
||||||
this.episodeScheduleTask = cron.schedule(this.serverSettings.podcastEpisodeSchedule, this.checkForNewEpisodes.bind(this))
|
this.episodeScheduleTask = cron.schedule(this.serverSettings.podcastEpisodeSchedule, () => {
|
||||||
|
Logger.debug(`[PodcastManager] Running cron`)
|
||||||
|
this.checkForNewEpisodes()
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(`[PodcastManager] Failed to schedule podcast cron ${this.serverSettings.podcastEpisodeSchedule}`, error)
|
Logger.error(`[PodcastManager] Failed to schedule podcast cron ${this.serverSettings.podcastEpisodeSchedule}`, error)
|
||||||
}
|
}
|
||||||
@@ -171,21 +175,35 @@ class PodcastManager {
|
|||||||
async checkForNewEpisodes() {
|
async checkForNewEpisodes() {
|
||||||
var podcastsWithAutoDownload = this.db.libraryItems.filter(li => li.mediaType === 'podcast' && li.media.autoDownloadEpisodes)
|
var podcastsWithAutoDownload = this.db.libraryItems.filter(li => li.mediaType === 'podcast' && li.media.autoDownloadEpisodes)
|
||||||
if (!podcastsWithAutoDownload.length) {
|
if (!podcastsWithAutoDownload.length) {
|
||||||
|
Logger.info(`[PodcastManager] checkForNewEpisodes - No podcasts with auto download set`)
|
||||||
this.cancelCron()
|
this.cancelCron()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
Logger.debug(`[PodcastManager] checkForNewEpisodes - Checking ${podcastsWithAutoDownload.length} Podcasts`)
|
||||||
|
|
||||||
for (const libraryItem of podcastsWithAutoDownload) {
|
for (const libraryItem of podcastsWithAutoDownload) {
|
||||||
const lastEpisodeCheckDate = new Date(libraryItem.media.lastEpisodeCheck || 0)
|
const lastEpisodeCheckDate = new Date(libraryItem.media.lastEpisodeCheck || 0)
|
||||||
Logger.info(`[PodcastManager] checkForNewEpisodes Cron for "${libraryItem.media.metadata.title}" - Last episode check: ${lastEpisodeCheckDate}`)
|
Logger.info(`[PodcastManager] checkForNewEpisodes Cron for "${libraryItem.media.metadata.title}" - Last episode check: ${lastEpisodeCheckDate}`)
|
||||||
var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem)
|
var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem)
|
||||||
|
Logger.debug(`[PodcastManager] checkForNewEpisodes checked result ${newEpisodes ? newEpisodes.length : 'N/A'}`)
|
||||||
|
|
||||||
if (!newEpisodes) { // Failed
|
if (!newEpisodes) { // Failed
|
||||||
libraryItem.media.autoDownloadEpisodes = false
|
// Allow up to 3 failed attempts before disabling auto download
|
||||||
|
if (!this.failedCheckMap[libraryItem.id]) this.failedCheckMap[libraryItem.id] = 0
|
||||||
|
this.failedCheckMap[libraryItem.id]++
|
||||||
|
if (this.failedCheckMap[libraryItem.id] > 2) {
|
||||||
|
Logger.error(`[PodcastManager] checkForNewEpisodes 3 failed attempts at checking episodes for "${libraryItem.media.metadata.title}" - disabling auto download`)
|
||||||
|
libraryItem.media.autoDownloadEpisodes = false
|
||||||
|
delete this.failedCheckMap[libraryItem.id]
|
||||||
|
} else {
|
||||||
|
Logger.warn(`[PodcastManager] checkForNewEpisodes ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.metadata.title}"`)
|
||||||
|
}
|
||||||
} else if (newEpisodes.length) {
|
} else if (newEpisodes.length) {
|
||||||
|
delete this.failedCheckMap[libraryItem.id]
|
||||||
Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast "${libraryItem.media.metadata.title}" - starting download`)
|
Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast "${libraryItem.media.metadata.title}" - starting download`)
|
||||||
this.downloadPodcastEpisodes(libraryItem, newEpisodes)
|
this.downloadPodcastEpisodes(libraryItem, newEpisodes)
|
||||||
} else {
|
} else {
|
||||||
|
delete this.failedCheckMap[libraryItem.id]
|
||||||
Logger.debug(`[PodcastManager] No new episodes for "${libraryItem.media.metadata.title}"`)
|
Logger.debug(`[PodcastManager] No new episodes for "${libraryItem.media.metadata.title}"`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,14 +216,22 @@ class PodcastManager {
|
|||||||
|
|
||||||
async checkPodcastForNewEpisodes(podcastLibraryItem) {
|
async checkPodcastForNewEpisodes(podcastLibraryItem) {
|
||||||
if (!podcastLibraryItem.media.metadata.feedUrl) {
|
if (!podcastLibraryItem.media.metadata.feedUrl) {
|
||||||
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes no feed url for ${podcastLibraryItem.media.metadata.title} (ID: ${podcastLibraryItem.id}) - disabling auto download`)
|
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes no feed url for ${podcastLibraryItem.media.metadata.title} (ID: ${podcastLibraryItem.id})`)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
var feed = await this.getPodcastFeed(podcastLibraryItem.media.metadata.feedUrl)
|
var feed = await this.getPodcastFeed(podcastLibraryItem.media.metadata.feedUrl)
|
||||||
if (!feed || !feed.episodes) {
|
if (!feed || !feed.episodes) {
|
||||||
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes invalid feed payload for ${podcastLibraryItem.media.metadata.title} (ID: ${podcastLibraryItem.id}) - disabling auto download`)
|
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes invalid feed payload for ${podcastLibraryItem.media.metadata.title} (ID: ${podcastLibraryItem.id})`, feed)
|
||||||
return false
|
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
|
// 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 > podcastLibraryItem.media.lastEpisodeCheck && !podcastLibraryItem.media.checkHasEpisodeByFeedUrl(ep.enclosure.url))
|
||||||
// Max new episodes for safety = 3
|
// Max new episodes for safety = 3
|
||||||
@@ -233,11 +259,13 @@ class PodcastManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getPodcastFeed(feedUrl) {
|
getPodcastFeed(feedUrl) {
|
||||||
return axios.get(feedUrl).then(async (data) => {
|
Logger.debug(`[PodcastManager] getPodcastFeed for "${feedUrl}"`)
|
||||||
|
return axios.get(feedUrl, { timeout: 5000 }).then(async (data) => {
|
||||||
if (!data || !data.data) {
|
if (!data || !data.data) {
|
||||||
Logger.error('Invalid podcast feed request response')
|
Logger.error('Invalid podcast feed request response')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
Logger.debug(`[PodcastManager] getPodcastFeed for "${feedUrl}" success - parsing xml`)
|
||||||
var payload = await parsePodcastRssFeedXml(data.data)
|
var payload = await parsePodcastRssFeedXml(data.data)
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
const Path = require('path')
|
||||||
|
const fs = require('fs-extra')
|
||||||
|
const { Podcast } = require('podcast')
|
||||||
|
const { getId } = require('../utils/index')
|
||||||
|
const Logger = require('../Logger')
|
||||||
|
|
||||||
|
// Not functional at the moment
|
||||||
|
class RssFeedManager {
|
||||||
|
constructor(db, emitter) {
|
||||||
|
this.db = db
|
||||||
|
this.emitter = emitter
|
||||||
|
this.feeds = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
findFeedForItem(libraryItemId) {
|
||||||
|
return Object.values(this.feeds).find(feed => feed.libraryItemId === libraryItemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
getFeed(req, res) {
|
||||||
|
var feedData = this.feeds[req.params.id]
|
||||||
|
if (!feedData) {
|
||||||
|
Logger.error(`[RssFeedManager] Feed not found ${req.params.id}`)
|
||||||
|
res.sendStatus(404)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var xml = feedData.feed.buildXml()
|
||||||
|
res.set('Content-Type', 'text/xml')
|
||||||
|
res.send(xml)
|
||||||
|
}
|
||||||
|
|
||||||
|
getFeedItem(req, res) {
|
||||||
|
var feedData = this.feeds[req.params.id]
|
||||||
|
if (!feedData) {
|
||||||
|
Logger.error(`[RssFeedManager] Feed not found ${req.params.id}`)
|
||||||
|
res.sendStatus(404)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var remainingPath = req.params['0']
|
||||||
|
var fullPath = Path.join(feedData.libraryItemPath, remainingPath)
|
||||||
|
res.sendFile(fullPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
getFeedCover(req, res) {
|
||||||
|
var feedData = this.feeds[req.params.id]
|
||||||
|
if (!feedData) {
|
||||||
|
Logger.error(`[RssFeedManager] Feed not found ${req.params.id}`)
|
||||||
|
res.sendStatus(404)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!feedData.mediaCoverPath) {
|
||||||
|
res.sendStatus(404)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const extname = Path.extname(feedData.mediaCoverPath).toLowerCase().slice(1)
|
||||||
|
res.type(`image/${extname}`)
|
||||||
|
var readStream = fs.createReadStream(feedData.mediaCoverPath)
|
||||||
|
readStream.pipe(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
openFeed(userId, slug, libraryItem, serverAddress) {
|
||||||
|
const podcast = libraryItem.media
|
||||||
|
|
||||||
|
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/${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/${slug}/item`)
|
||||||
|
|
||||||
|
feed.addItem({
|
||||||
|
title: episode.title,
|
||||||
|
description: episode.description || '',
|
||||||
|
enclosure: {
|
||||||
|
url: `${serverAddress}${contentUrl}`,
|
||||||
|
type: episode.audioTrack.mimeType,
|
||||||
|
size: episode.size
|
||||||
|
},
|
||||||
|
date: episode.pubDate || '',
|
||||||
|
url: `${serverAddress}${contentUrl}`,
|
||||||
|
author: podcast.metadata.author || 'advplyr'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const feedData = {
|
||||||
|
id: slug,
|
||||||
|
slug,
|
||||||
|
userId,
|
||||||
|
libraryItemId: libraryItem.id,
|
||||||
|
libraryItemPath: libraryItem.path,
|
||||||
|
mediaCoverPath: podcast.coverPath,
|
||||||
|
serverAddress: serverAddress,
|
||||||
|
feedUrl,
|
||||||
|
feed
|
||||||
|
}
|
||||||
|
this.feeds[slug] = feedData
|
||||||
|
return feedData
|
||||||
|
}
|
||||||
|
|
||||||
|
openPodcastFeed(user, libraryItem, options) {
|
||||||
|
const serverAddress = options.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
|
||||||
|
}
|
||||||
|
|
||||||
|
closePodcastFeedForItem(libraryItemId) {
|
||||||
|
var feed = this.findFeedForItem(libraryItemId)
|
||||||
|
if (!feed) return
|
||||||
|
this.closeRssFeed(feed.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
closeRssFeed(id) {
|
||||||
|
if (!this.feeds[id]) return
|
||||||
|
var feedData = this.feeds[id]
|
||||||
|
this.emitter('rss_feed_closed', { libraryItemId: feedData.libraryItemId, feedUrl: feedData.feedUrl })
|
||||||
|
delete this.feeds[id]
|
||||||
|
Logger.info(`[RssFeedManager] Closed RSS feed "${feedData.feedUrl}"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
module.exports = RssFeedManager
|
||||||
@@ -288,7 +288,6 @@ class LibraryItem {
|
|||||||
// FileMetadata keys
|
// FileMetadata keys
|
||||||
['filename', 'ext', 'mtimeMs', 'ctimeMs', 'birthtimeMs', 'size'].forEach((key) => {
|
['filename', 'ext', 'mtimeMs', 'ctimeMs', 'birthtimeMs', 'size'].forEach((key) => {
|
||||||
if (existingFile.metadata[key] !== fileFound.metadata[key]) {
|
if (existingFile.metadata[key] !== fileFound.metadata[key]) {
|
||||||
|
|
||||||
// Add modified flag on file data object if exists and was changed
|
// Add modified flag on file data object if exists and was changed
|
||||||
if (key === 'mtimeMs' && existingFile.metadata[key]) {
|
if (key === 'mtimeMs' && existingFile.metadata[key]) {
|
||||||
fileFound.metadata.wasModified = true
|
fileFound.metadata.wasModified = true
|
||||||
@@ -348,7 +347,7 @@ class LibraryItem {
|
|||||||
var fileFoundCheck = this.checkFileFound(lf, true)
|
var fileFoundCheck = this.checkFileFound(lf, true)
|
||||||
if (fileFoundCheck === null) {
|
if (fileFoundCheck === null) {
|
||||||
newLibraryFiles.push(lf)
|
newLibraryFiles.push(lf)
|
||||||
} else if (fileFoundCheck) {
|
} else if (fileFoundCheck && lf.metadata.format !== 'abs') { // Ignore abs file updates
|
||||||
hasUpdated = true
|
hasUpdated = true
|
||||||
existingLibraryFiles.push(lf)
|
existingLibraryFiles.push(lf)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ class PodcastEpisode {
|
|||||||
this.id = null
|
this.id = null
|
||||||
this.index = null
|
this.index = null
|
||||||
|
|
||||||
|
this.season = null
|
||||||
this.episode = null
|
this.episode = null
|
||||||
this.episodeType = null
|
this.episodeType = null
|
||||||
this.title = null
|
this.title = null
|
||||||
@@ -31,6 +32,7 @@ class PodcastEpisode {
|
|||||||
this.libraryItemId = episode.libraryItemId
|
this.libraryItemId = episode.libraryItemId
|
||||||
this.id = episode.id
|
this.id = episode.id
|
||||||
this.index = episode.index
|
this.index = episode.index
|
||||||
|
this.season = episode.season
|
||||||
this.episode = episode.episode
|
this.episode = episode.episode
|
||||||
this.episodeType = episode.episodeType
|
this.episodeType = episode.episodeType
|
||||||
this.title = episode.title
|
this.title = episode.title
|
||||||
@@ -51,6 +53,7 @@ class PodcastEpisode {
|
|||||||
libraryItemId: this.libraryItemId,
|
libraryItemId: this.libraryItemId,
|
||||||
id: this.id,
|
id: this.id,
|
||||||
index: this.index,
|
index: this.index,
|
||||||
|
season: this.season,
|
||||||
episode: this.episode,
|
episode: this.episode,
|
||||||
episodeType: this.episodeType,
|
episodeType: this.episodeType,
|
||||||
title: this.title,
|
title: this.title,
|
||||||
@@ -70,6 +73,7 @@ class PodcastEpisode {
|
|||||||
libraryItemId: this.libraryItemId,
|
libraryItemId: this.libraryItemId,
|
||||||
id: this.id,
|
id: this.id,
|
||||||
index: this.index,
|
index: this.index,
|
||||||
|
season: this.season,
|
||||||
episode: this.episode,
|
episode: this.episode,
|
||||||
episodeType: this.episodeType,
|
episodeType: this.episodeType,
|
||||||
title: this.title,
|
title: this.title,
|
||||||
@@ -117,6 +121,7 @@ class PodcastEpisode {
|
|||||||
this.pubDate = data.pubDate || ''
|
this.pubDate = data.pubDate || ''
|
||||||
this.description = data.description || ''
|
this.description = data.description || ''
|
||||||
this.enclosure = data.enclosure ? { ...data.enclosure } : null
|
this.enclosure = data.enclosure ? { ...data.enclosure } : null
|
||||||
|
this.season = data.season || ''
|
||||||
this.episode = data.episode || ''
|
this.episode = data.episode || ''
|
||||||
this.episodeType = data.episodeType || ''
|
this.episodeType = data.episodeType || ''
|
||||||
this.publishedAt = data.publishedAt || 0
|
this.publishedAt = data.publishedAt || 0
|
||||||
|
|||||||
@@ -341,6 +341,11 @@ class User {
|
|||||||
return this.itemTagsAccessible.some(tag => tags.includes(tag))
|
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) {
|
findBookmark(libraryItemId, time) {
|
||||||
return this.bookmarks.find(bm => bm.libraryItemId === libraryItemId && bm.time == 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}`)
|
Logger.debug(`[Audnexus] Looking up author by name ${name}`)
|
||||||
var asins = await this.authorASINsRequest(name)
|
var asins = await this.authorASINsRequest(name)
|
||||||
var matchingAsin = asins.find(obj => levenshteinDistance(obj.name, name) <= maxLevenshtein)
|
var matchingAsin = asins.find(obj => levenshteinDistance(obj.name, name) <= maxLevenshtein)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const Series = require('../objects/entities/Series')
|
|||||||
const FileSystemController = require('../controllers/FileSystemController')
|
const FileSystemController = require('../controllers/FileSystemController')
|
||||||
|
|
||||||
class ApiRouter {
|
class ApiRouter {
|
||||||
constructor(db, auth, scanner, playbackSessionManager, abMergeManager, coverManager, backupManager, watcher, cacheManager, podcastManager, emitter, clientEmitter) {
|
constructor(db, auth, scanner, playbackSessionManager, abMergeManager, coverManager, backupManager, watcher, cacheManager, podcastManager, audioMetadataManager, rssFeedManager, emitter, clientEmitter) {
|
||||||
this.db = db
|
this.db = db
|
||||||
this.auth = auth
|
this.auth = auth
|
||||||
this.scanner = scanner
|
this.scanner = scanner
|
||||||
@@ -36,6 +36,8 @@ class ApiRouter {
|
|||||||
this.watcher = watcher
|
this.watcher = watcher
|
||||||
this.cacheManager = cacheManager
|
this.cacheManager = cacheManager
|
||||||
this.podcastManager = podcastManager
|
this.podcastManager = podcastManager
|
||||||
|
this.audioMetadataManager = audioMetadataManager
|
||||||
|
this.rssFeedManager = rssFeedManager
|
||||||
this.emitter = emitter
|
this.emitter = emitter
|
||||||
this.clientEmitter = clientEmitter
|
this.clientEmitter = clientEmitter
|
||||||
|
|
||||||
@@ -91,6 +93,7 @@ class ApiRouter {
|
|||||||
this.router.patch('/items/:id/episodes', LibraryItemController.middleware.bind(this), LibraryItemController.updateEpisodes.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.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/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.post('/items/batch/delete', LibraryItemController.batchDelete.bind(this))
|
this.router.post('/items/batch/delete', LibraryItemController.batchDelete.bind(this))
|
||||||
this.router.post('/items/batch/update', LibraryItemController.batchUpdate.bind(this))
|
this.router.post('/items/batch/update', LibraryItemController.batchUpdate.bind(this))
|
||||||
@@ -178,11 +181,13 @@ class ApiRouter {
|
|||||||
//
|
//
|
||||||
this.router.post('/podcasts', PodcastController.create.bind(this))
|
this.router.post('/podcasts', PodcastController.create.bind(this))
|
||||||
this.router.post('/podcasts/feed', PodcastController.getPodcastFeed.bind(this))
|
this.router.post('/podcasts/feed', PodcastController.getPodcastFeed.bind(this))
|
||||||
this.router.get('/podcasts/:id/checknew', PodcastController.checkNewEpisodes.bind(this))
|
this.router.get('/podcasts/:id/checknew', PodcastController.middleware.bind(this), PodcastController.checkNewEpisodes.bind(this))
|
||||||
this.router.get('/podcasts/:id/downloads', PodcastController.getEpisodeDownloads.bind(this))
|
this.router.get('/podcasts/:id/downloads', PodcastController.middleware.bind(this), PodcastController.getEpisodeDownloads.bind(this))
|
||||||
this.router.get('/podcasts/:id/clear-queue', PodcastController.clearEpisodeDownloadQueue.bind(this))
|
this.router.get('/podcasts/:id/clear-queue', PodcastController.middleware.bind(this), PodcastController.clearEpisodeDownloadQueue.bind(this))
|
||||||
this.router.post('/podcasts/:id/download-episodes', PodcastController.downloadEpisodes.bind(this))
|
this.router.post('/podcasts/:id/download-episodes', PodcastController.middleware.bind(this), PodcastController.downloadEpisodes.bind(this))
|
||||||
this.router.patch('/podcasts/:id/episode/:episodeId', PodcastController.updateEpisode.bind(this))
|
this.router.post('/podcasts/:id/open-feed', PodcastController.middleware.bind(this), PodcastController.openPodcastFeed.bind(this))
|
||||||
|
this.router.post('/podcasts/:id/close-feed', PodcastController.middleware.bind(this), PodcastController.closePodcastFeed.bind(this))
|
||||||
|
this.router.patch('/podcasts/:id/episode/:episodeId', PodcastController.middleware.bind(this), PodcastController.updateEpisode.bind(this))
|
||||||
|
|
||||||
//
|
//
|
||||||
// Misc Routes
|
// Misc Routes
|
||||||
@@ -234,8 +239,11 @@ class ApiRouter {
|
|||||||
//
|
//
|
||||||
// Helper Methods
|
// Helper Methods
|
||||||
//
|
//
|
||||||
userJsonWithItemProgressDetails(user) {
|
userJsonWithItemProgressDetails(user, hideRootToken = false) {
|
||||||
var json = user.toJSONForBrowser()
|
var json = user.toJSONForBrowser()
|
||||||
|
if (json.type === 'root' && hideRootToken) {
|
||||||
|
json.token = ''
|
||||||
|
}
|
||||||
|
|
||||||
json.mediaProgress = json.mediaProgress.map(lip => {
|
json.mediaProgress = json.mediaProgress.map(lip => {
|
||||||
var libraryItem = this.db.libraryItems.find(li => li.id === lip.id)
|
var libraryItem = this.db.libraryItems.find(li => li.id === lip.id)
|
||||||
|
|||||||
@@ -291,12 +291,13 @@ class Scanner {
|
|||||||
return this.rescanLibraryItem(lid, libraryScan)
|
return this.rescanLibraryItem(lid, libraryScan)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
itemsUpdated = itemsUpdated.filter(li => li) // Filter out nulls
|
||||||
|
|
||||||
for (const libraryItem of itemsUpdated) {
|
for (const libraryItem of itemsUpdated) {
|
||||||
// Temp authors & series are inserted - create them if found
|
// Temp authors & series are inserted - create them if found
|
||||||
await this.createNewAuthorsAndSeries(libraryItem)
|
await this.createNewAuthorsAndSeries(libraryItem)
|
||||||
}
|
}
|
||||||
|
|
||||||
itemsUpdated = itemsUpdated.filter(li => li) // Filter out nulls
|
|
||||||
if (itemsUpdated.length) {
|
if (itemsUpdated.length) {
|
||||||
libraryScan.resultsUpdated += itemsUpdated.length
|
libraryScan.resultsUpdated += itemsUpdated.length
|
||||||
await this.db.updateLibraryItems(itemsUpdated)
|
await this.db.updateLibraryItems(itemsUpdated)
|
||||||
@@ -728,16 +729,14 @@ class Scanner {
|
|||||||
var libraryItem = itemsInLibrary[i]
|
var libraryItem = itemsInLibrary[i]
|
||||||
|
|
||||||
if (libraryItem.media.metadata.asin && library.settings.skipMatchingMediaWithAsin) {
|
if (libraryItem.media.metadata.asin && library.settings.skipMatchingMediaWithAsin) {
|
||||||
Logger.debug(`[Scanner] matchLibraryItems: Skipping "${
|
Logger.debug(`[Scanner] matchLibraryItems: Skipping "${libraryItem.media.metadata.title
|
||||||
libraryItem.media.metadata.title
|
}" because it already has an ASIN (${i + 1} of ${itemsInLibrary.length})`)
|
||||||
}" because it already has an ASIN (${i + 1} of ${itemsInLibrary.length})`)
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (libraryItem.media.metadata.isbn && library.settings.skipMatchingMediaWithIsbn) {
|
if (libraryItem.media.metadata.isbn && library.settings.skipMatchingMediaWithIsbn) {
|
||||||
Logger.debug(`[Scanner] matchLibraryItems: Skipping "${
|
Logger.debug(`[Scanner] matchLibraryItems: Skipping "${libraryItem.media.metadata.title
|
||||||
libraryItem.media.metadata.title
|
}" because it already has an ISBN (${i + 1} of ${itemsInLibrary.length})`)
|
||||||
}" because it already has an ISBN (${i + 1} of ${itemsInLibrary.length})`)
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ async function runFfmpeg() {
|
|||||||
ffmpegCommand.on('stderr', (stdErrline) => {
|
ffmpegCommand.on('stderr', (stdErrline) => {
|
||||||
parentPort.postMessage({
|
parentPort.postMessage({
|
||||||
type: 'FFMPEG',
|
type: 'FFMPEG',
|
||||||
level: 'error',
|
level: 'debug',
|
||||||
log: '[DownloadWorker] Ffmpeg Stderr: ' + stdErrline
|
log: '[DownloadWorker] Ffmpeg Stderr: ' + stdErrline
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -48,10 +48,33 @@ async function writeMetadataFile(libraryItem, outputPath) {
|
|||||||
`artist=${libraryItem.media.metadata.authorName}`,
|
`artist=${libraryItem.media.metadata.authorName}`,
|
||||||
`album_artist=${libraryItem.media.metadata.authorName}`,
|
`album_artist=${libraryItem.media.metadata.authorName}`,
|
||||||
`date=${libraryItem.media.metadata.publishedYear || ''}`,
|
`date=${libraryItem.media.metadata.publishedYear || ''}`,
|
||||||
`description=${libraryItem.media.metadata.description}`,
|
`description=${libraryItem.media.metadata.description || ''}`,
|
||||||
`genre=${libraryItem.media.metadata.genres.join(';')}`
|
`genre=${libraryItem.media.metadata.genres.join(';')}`,
|
||||||
|
`performer=${libraryItem.media.metadata.narratorName || ''}`,
|
||||||
|
`encoded_by=audiobookshelf:${package.version}`
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if (libraryItem.media.metadata.asin) {
|
||||||
|
inputstrs.push(`ASIN=${libraryItem.media.metadata.asin}`)
|
||||||
|
}
|
||||||
|
if (libraryItem.media.metadata.isbn) {
|
||||||
|
inputstrs.push(`ISBN=${libraryItem.media.metadata.isbn}`)
|
||||||
|
}
|
||||||
|
if (libraryItem.media.metadata.language) {
|
||||||
|
inputstrs.push(`language=${libraryItem.media.metadata.language}`)
|
||||||
|
}
|
||||||
|
if (libraryItem.media.metadata.series.length) {
|
||||||
|
// Only uses first series
|
||||||
|
var firstSeries = libraryItem.media.metadata.series[0]
|
||||||
|
inputstrs.push(`series=${firstSeries.name}`)
|
||||||
|
if (firstSeries.sequence) {
|
||||||
|
inputstrs.push(`series-part=${firstSeries.sequence}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (libraryItem.media.metadata.subtitle) {
|
||||||
|
inputstrs.push(`subtitle=${libraryItem.media.metadata.subtitle}`)
|
||||||
|
}
|
||||||
|
|
||||||
if (libraryItem.media.chapters) {
|
if (libraryItem.media.chapters) {
|
||||||
libraryItem.media.chapters.forEach((chap) => {
|
libraryItem.media.chapters.forEach((chap) => {
|
||||||
const chapterstrs = [
|
const chapterstrs = [
|
||||||
|
|||||||
@@ -406,7 +406,7 @@ module.exports = {
|
|||||||
if (libraryItem.media.metadata.series.length) {
|
if (libraryItem.media.metadata.series.length) {
|
||||||
for (const librarySeries of libraryItem.media.metadata.series) {
|
for (const librarySeries of libraryItem.media.metadata.series) {
|
||||||
const mediaProgress = allItemProgress.length ? allItemProgress[0] : null
|
const mediaProgress = allItemProgress.length ? allItemProgress[0] : null
|
||||||
const bookInProgress = mediaProgress && mediaProgress.inProgress
|
const bookInProgress = mediaProgress && (mediaProgress.inProgress || mediaProgress.isFinished)
|
||||||
const libraryItemJson = libraryItem.toJSONMinified()
|
const libraryItemJson = libraryItem.toJSONMinified()
|
||||||
libraryItemJson.seriesSequence = librarySeries.sequence
|
libraryItemJson.seriesSequence = librarySeries.sequence
|
||||||
|
|
||||||
@@ -445,7 +445,7 @@ module.exports = {
|
|||||||
|
|
||||||
if (bookInProgress) { // Update if this series is in progress
|
if (bookInProgress) { // Update if this series is in progress
|
||||||
seriesMap[librarySeries.id].inProgress = true
|
seriesMap[librarySeries.id].inProgress = true
|
||||||
if (!seriesMap[librarySeries.id].sequenceInProgress) {
|
if (!seriesMap[librarySeries.id].sequenceInProgress || (librarySeries.sequence && String(librarySeries.sequence).localeCompare(String(seriesMap[librarySeries.id].sequenceInProgress), undefined, { sensitivity: 'base', numeric: true }) > 0)) {
|
||||||
seriesMap[librarySeries.id].sequenceInProgress = librarySeries.sequence
|
seriesMap[librarySeries.id].sequenceInProgress = librarySeries.sequence
|
||||||
seriesMap[librarySeries.id].bookInProgressLastUpdate = mediaProgress.lastUpdate
|
seriesMap[librarySeries.id].bookInProgressLastUpdate = mediaProgress.lastUpdate
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ function extractEpisodeData(item) {
|
|||||||
episode.descriptionPlain = stripHtml(episode.description || '').result
|
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) => {
|
arrayFields.forEach((key) => {
|
||||||
var cleanKey = key.split(':').pop()
|
var cleanKey = key.split(':').pop()
|
||||||
episode[cleanKey] = extractFirstArrayItem(item, key)
|
episode[cleanKey] = extractFirstArrayItem(item, key)
|
||||||
@@ -101,6 +101,7 @@ function cleanEpisodeData(data) {
|
|||||||
descriptionPlain: data.descriptionPlain || '',
|
descriptionPlain: data.descriptionPlain || '',
|
||||||
pubDate: data.pubDate || '',
|
pubDate: data.pubDate || '',
|
||||||
episodeType: data.episodeType || '',
|
episodeType: data.episodeType || '',
|
||||||
|
season: data.season || '',
|
||||||
episode: data.episode || '',
|
episode: data.episode || '',
|
||||||
author: data.author || '',
|
author: data.author || '',
|
||||||
duration: data.duration || '',
|
duration: data.duration || '',
|
||||||
|
|||||||
@@ -204,12 +204,12 @@ function parseTags(format, verbose) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var keysToLookOutFor = ['file_tag_genre1', 'file_tag_genre2', 'file_tag_series', 'file_tag_seriespart', 'file_tag_movement', 'file_tag_movementname', 'file_tag_wwwaudiofile', 'file_tag_contentgroup', 'file_tag_releasetime', 'file_tag_isbn']
|
// var keysToLookOutFor = ['file_tag_genre1', 'file_tag_genre2', 'file_tag_series', 'file_tag_seriespart', 'file_tag_movement', 'file_tag_movementname', 'file_tag_wwwaudiofile', 'file_tag_contentgroup', 'file_tag_releasetime', 'file_tag_isbn']
|
||||||
keysToLookOutFor.forEach((key) => {
|
// keysToLookOutFor.forEach((key) => {
|
||||||
if (tags[key]) {
|
// if (tags[key]) {
|
||||||
Logger.debug(`Notable! ${key} => ${tags[key]}`)
|
// Logger.debug(`Notable! ${key} => ${tags[key]}`)
|
||||||
}
|
// }
|
||||||
})
|
// })
|
||||||
return tags
|
return tags
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user