Compare commits

...

54 Commits

Author SHA1 Message Date
advplyr c8a743ccc1 Version bump 2.0.14 2022-05-15 12:53:41 -05:00
advplyr 09dc95f560 Fix:Create cache dirs on server init 2022-05-15 11:19:04 -05:00
advplyr 853858825b Fix:File permissions on cache dirs and cache images, Fix:Db delete read stream closing before write stream resulting in deletes sometimes not happening 2022-05-15 09:51:08 -05:00
advplyr c962090c3a Update:No longer creating initial root user and initial library, add init root user page, web app works with no libraries 2022-05-14 17:23:22 -05:00
advplyr 63a8e2433e Fix:Manage and chapters item page available on refresh 2022-05-14 13:08:56 -05:00
advplyr f78d287b59 Update:Matching authors uses the ASIN if set #572, Fix:Purge author image cache when updating author 2022-05-13 18:11:54 -05:00
advplyr eaa383b6d8 Update:Show siderail on all pages not just library pages 2022-05-13 17:40:43 -05:00
advplyr 113026ce13 Fix:Sanitize new podcast folder names and ensure feedUrl is in feed metadata #589 2022-05-13 17:13:58 -05:00
advplyr 578a946ca5 Fix:Update changes to filterdata (authors, narrators, genres, tags, languages, series) 2022-05-13 16:51:54 -05:00
advplyr f31306eda0 Fix:Realtime updates on book cards when changing series sequence #590 2022-05-13 16:26:34 -05:00
advplyr c62b716a2c Fix:Duplicate keys on episode slider 2022-05-13 10:39:33 -05:00
advplyr 97ed20c683 Merge pull request #586 from jflattery/main
Fix for unsorted feeds, update npm, add docker tag
2022-05-12 17:36:51 -05:00
jflattery d5c46dcbfb Dedupe packages 2022-05-12 21:14:36 +00:00
jflattery 30934edd57 Add tag
Addressing issues found on discord where users are not getting latest image
2022-05-12 21:01:47 +00:00
jflattery d06fd1a1b1 Update npm packages 2022-05-12 20:58:30 +00:00
jflattery 6bb36381f1 Fix for unsorted feeds
Fix for Podcasts such as Beers with Talos who don't publish their feed in a chronological order
2022-05-12 20:26:21 +00:00
advplyr a1331fb3f8 Version bump 2.0.13 2022-05-11 18:57:09 -05:00
advplyr 17d15144eb Update:Podcast new episode check cronjob to use last episode pub date if exists otherwise fallback to using last check date 2022-05-11 18:55:19 -05:00
advplyr 74d26eece4 Add:Re-broadcast podcast with RSS feed no longer experimental #553, Update:Podcast RSS feed modal warnings and note text 2022-05-11 18:34:17 -05:00
advplyr 474a7d08d0 Fix:Watcher & scanner on folder renames to check inode value and update existing library item paths 2022-05-11 18:18:54 -05:00
advplyr 639c930779 Fix:Remove all button when not viewing issues page #585 2022-05-11 17:39:15 -05:00
advplyr c6323f8ad9 Fix:Local playback session store date/dayOfWeek string to be used in stats 2022-05-11 17:35:04 -05:00
advplyr caea6c6371 Update:Show seconds in elapsedPretty 2022-05-11 17:20:32 -05:00
advplyr d285845e04 Fix:Crash when mobile sends invalid library item to sync with session 2022-05-11 17:07:41 -05:00
advplyr 5a6867e98a Add:Listening sessions calendar heat map 2022-05-11 16:27:40 -05:00
advplyr 621444114f Fix:Modal click outside srcElement null 2022-05-10 19:30:17 -05:00
advplyr 5591704aad Fix:Uploads page set folder path on load #581 2022-05-10 17:49:25 -05:00
advplyr cc1181b301 Add:Chapter editor, lookup chapters via audnexus, chapters table on audiobook landing page #435 2022-05-10 17:03:41 -05:00
advplyr 095f49824e Version bump 2.0.12 2022-05-09 18:45:33 -05:00
advplyr b330030f50 Fix:Ignore file metadata updates to metadata.abs files 2022-05-09 18:40:54 -05:00
advplyr a7d422e23f Add:Alternate view for home page, series and collections without wood texture #424 2022-05-09 18:23:23 -05:00
advplyr f51a31c8ca Update:Remove back arrow in appbar 2022-05-09 15:12:55 -05:00
advplyr 290340a385 Fix:Rescan filter out items not updated #577 2022-05-09 07:23:29 -05:00
advplyr 0137f6dfeb Update:Show publishedYear below book cards instead of author name 2022-05-08 18:54:41 -05:00
advplyr 7f27eabf3e Update:Authors page check user can access library items and can edit 2022-05-08 18:48:57 -05:00
advplyr 4f7588c87d Update:Author names to link to authors page 2022-05-08 18:43:24 -05:00
advplyr a19b6370c4 Fix:getBookCoverAspectRatio 2022-05-08 18:40:43 -05:00
advplyr fbd7ae10d1 Add:Authors landing page #187 2022-05-08 18:21:46 -05:00
advplyr f94c706fc8 Update:Item edit modal add Save and Save & Close buttons 2022-05-08 15:25:33 -05:00
advplyr 9de4b1069a Fix:Item edit modal scrollable and overflowing #574 2022-05-08 14:52:58 -05:00
advplyr 8fbe3c3884 Remove unnecessary background-image #569 2022-05-07 20:21:08 -05:00
advplyr abf9120363 Fix:Hide book library settings for podcast libraries #573 2022-05-07 20:10:23 -05:00
advplyr 69f250cba5 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-05-07 20:01:33 -05:00
advplyr 2103edfcdc Update:Max levenshtein distance for matching author names to 3 #572 2022-05-07 20:01:29 -05:00
advplyr 02ba147bd4 Merge pull request #571 from jflattery/main
fix repo
2022-05-07 11:50:27 -05:00
jflattery 230b548921 fix repo 2022-05-07 16:09:59 +00:00
advplyr f34ebdc016 Version bump 2.0.11 2022-05-05 18:50:15 -05:00
advplyr 69ad651671 Fix:Context menu on library page 2022-05-05 18:12:27 -05:00
advplyr edc919b3f5 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-05-05 18:07:50 -05:00
advplyr c8c7a9ece5 Merge pull request #561 from jflattery/main
Add support for seasonal podcasts
2022-05-05 18:06:52 -05:00
advplyr 8702ac1ccf Fix:Manage tracks page 2022-05-05 18:04:17 -05:00
advplyr 33833e0a36 Update:Host fonts locally #563 2022-05-05 18:02:42 -05:00
jflattery 6b98baafdf Resolve @advplyr's feedback
Add 'itunes' tag to 'season' and fix display formating
2022-05-05 13:38:00 +00:00
jflattery cc285bb685 Add support for seasonal podcasts
Podcasts such as [Command Line Heroes](https://podcasts.apple.com/us/podcast/command-line-heroes/id1319947289) have multiple seasons in which each has it's own , . This seaks to add support for such podcast series.
2022-05-04 14:14:09 +00:00
108 changed files with 4106 additions and 2615 deletions
+2 -13
View File
@@ -3,7 +3,6 @@ set -e
set -o pipefail set -o pipefail
FFMPEG_INSTALL_DIR="/usr/lib/audiobookshelf-ffmpeg/" FFMPEG_INSTALL_DIR="/usr/lib/audiobookshelf-ffmpeg/"
DEFAULT_AUDIOBOOK_PATH="/usr/share/audiobookshelf/audiobooks"
DEFAULT_DATA_PATH="/usr/share/audiobookshelf" DEFAULT_DATA_PATH="/usr/share/audiobookshelf"
DEFAULT_PORT=7331 DEFAULT_PORT=7331
DEFAULT_HOST="0.0.0.0" DEFAULT_HOST="0.0.0.0"
@@ -54,14 +53,6 @@ setup_config_interactive() {
if should_build_config; then if should_build_config; then
echo "Okay, let's setup a new config." echo "Okay, let's setup a new config."
AUDIOBOOK_PATH=""
read -p "
Enter path for your audiobooks [Default: $DEFAULT_AUDIOBOOK_PATH]:" AUDIOBOOK_PATH
if [[ -z "$AUDIOBOOK_PATH" ]]; then
AUDIOBOOK_PATH="$DEFAULT_AUDIOBOOK_PATH"
fi
DATA_PATH="" DATA_PATH=""
read -p " read -p "
Enter path for data files, i.e. streams, downloads, database [Default: $DEFAULT_DATA_PATH]:" DATA_PATH Enter path for data files, i.e. streams, downloads, database [Default: $DEFAULT_DATA_PATH]:" DATA_PATH
@@ -78,8 +69,7 @@ setup_config_interactive() {
PORT="$DEFAULT_PORT" PORT="$DEFAULT_PORT"
fi fi
config_text="AUDIOBOOK_PATH=$AUDIOBOOK_PATH config_text="METADATA_PATH=$DATA_PATH/metadata
METADATA_PATH=$DATA_PATH/metadata
CONFIG_PATH=$DATA_PATH/config CONFIG_PATH=$DATA_PATH/config
FFMPEG_PATH=/usr/lib/audiobookshelf-ffmpeg/ffmpeg FFMPEG_PATH=/usr/lib/audiobookshelf-ffmpeg/ffmpeg
FFPROBE_PATH=/usr/lib/audiobookshelf-ffmpeg/ffprobe FFPROBE_PATH=/usr/lib/audiobookshelf-ffmpeg/ffprobe
@@ -102,8 +92,7 @@ setup_config() {
else else
echo "Creating default config." echo "Creating default config."
config_text="AUDIOBOOK_PATH=$DEFAULT_AUDIOBOOK_PATH config_text="METADATA_PATH=$DEFAULT_DATA_PATH/metadata
METADATA_PATH=$DEFAULT_DATA_PATH/metadata
CONFIG_PATH=$DEFAULT_DATA_PATH/config CONFIG_PATH=$DEFAULT_DATA_PATH/config
FFMPEG_PATH=/usr/lib/audiobookshelf-ffmpeg/ffmpeg FFMPEG_PATH=/usr/lib/audiobookshelf-ffmpeg/ffmpeg
FFPROBE_PATH=/usr/lib/audiobookshelf-ffmpeg/ffprobe FFPROBE_PATH=/usr/lib/audiobookshelf-ffmpeg/ffprobe
+50 -24
View File
@@ -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,36 +46,25 @@
::-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;
} }
.no-scroll::-webkit-scrollbar { .no-scroll::-webkit-scrollbar {
@@ -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,22 @@ input[type=number] {
border-right: 6px solid transparent; border-right: 6px solid transparent;
border-top: 6px solid white; border-top: 6px solid white;
} }
.arrow-down-small {
width: 0;
height: 0;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 4px solid currentColor;
}
.triangle-right { .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 +171,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 +188,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 +217,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
View File
@@ -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';
@@ -64,4 +66,274 @@
font-display: swap; font-display: swap;
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;
} }
+10 -17
View File
@@ -2,15 +2,17 @@
<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 />
<controls-global-search class="hidden md:block" /> <controls-global-search v-if="currentLibrary" class="hidden md:block" />
<div class="flex-grow" /> <div class="flex-grow" />
<span v-if="showExperimentalFeatures" class="material-icons text-4xl text-warning pr-0 sm:pr-2 md:pr-4">logo_dev</span> <span v-if="showExperimentalFeatures" class="material-icons text-4xl text-warning pr-0 sm:pr-2 md:pr-4">logo_dev</span>
@@ -22,11 +24,11 @@
<google-cast-launcher></google-cast-launcher> <google-cast-launcher></google-cast-launcher>
</div> </div>
<nuxt-link to="/config/stats" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1"> <nuxt-link v-if="currentLibrary" to="/config/stats" 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="User Stats" role="button">equalizer</span> <span class="material-icons" aria-label="User Stats" role="button">equalizer</span>
</nuxt-link> </nuxt-link>
<nuxt-link v-if="userCanUpload" to="/upload" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1"> <nuxt-link v-if="userCanUpload && currentLibrary" to="/upload" 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="Upload Media" role="button">upload</span> <span class="material-icons" aria-label="Upload Media" role="button">upload</span>
</nuxt-link> </nuxt-link>
@@ -94,9 +96,6 @@ 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
}, },
@@ -151,12 +150,6 @@ export default {
toggleBookshelfTexture() { toggleBookshelfTexture() {
this.$store.dispatch('setBookshelfTexture', 'wood2.png') this.$store.dispatch('setBookshelfTexture', 'wood2.png')
}, },
async back() {
var popped = await this.$store.dispatch('popRoute')
if (popped) this.$store.commit('setIsRoutingBack', true)
var backTo = popped || '/'
this.$router.push(backTo)
},
cancelSelectionMode() { cancelSelectionMode() {
if (this.processingBatchDelete) return if (this.processingBatchDelete) return
this.$store.commit('setSelectedLibraryItems', []) this.$store.commit('setSelectedLibraryItems', [])
+27 -3
View File
@@ -1,9 +1,9 @@
<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>
@@ -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>
@@ -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
+4 -29
View File
@@ -1,6 +1,6 @@
<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">
@@ -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,8 +86,7 @@ export default {
this.updateSelectionMode(false) this.updateSelectionMode(false)
}, },
editAuthor(author) { editAuthor(author) {
this.selectedAuthor = author this.$store.commit('globals/showEditAuthorModal', author)
this.showAuthorModal = true
}, },
editItem(libraryItem) { editItem(libraryItem) {
var itemIds = this.shelf.entities.map((e) => e.id) var itemIds = this.shelf.entities.map((e) => e.id)
@@ -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;
} }
+1 -1
View File
@@ -143,7 +143,7 @@ export default {
return this.$store.getters['user/getUserSetting']('filterBy') return this.$store.getters['user/getUserSetting']('filterBy')
}, },
isIssuesFilter() { isIssuesFilter() {
return this.filterBy === 'issues' return this.filterBy === 'issues' && this.$route.query.filter === 'issues'
} }
}, },
methods: { methods: {
+15 -7
View File
@@ -25,6 +25,9 @@ export default {
return {} return {}
}, },
computed: { computed: {
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
userIsAdminOrUp() { userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp'] return this.$store.getters['user/getIsAdminOrUp']
}, },
@@ -38,7 +41,7 @@ export default {
} }
] ]
} }
return [ const configRoutes = [
{ {
id: 'config', id: 'config',
title: 'Settings', title: 'Settings',
@@ -63,18 +66,23 @@ export default {
id: 'config-log', id: 'config-log',
title: 'Log', title: 'Log',
path: '/config/log' path: '/config/log'
}, }
{ ]
if (this.currentLibraryId) {
configRoutes.push({
id: 'config-library-stats', id: 'config-library-stats',
title: 'Library Stats', title: 'Library Stats',
path: '/config/library-stats' path: '/config/library-stats'
}, })
{ configRoutes.push({
id: 'config-stats', id: 'config-stats',
title: 'Your Stats', title: 'Your Stats',
path: '/config/stats' path: '/config/stats'
} })
] }
return configRoutes
}, },
wrapperClass() { wrapperClass() {
var classes = [] var classes = []
+7 -3
View File
@@ -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>
@@ -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 -2
View File
@@ -1,6 +1,9 @@
<template> <template>
<div class="w-20 bg-bg h-full relative box-shadow-side z-40" style="min-width: 80px"> <!-- <div class="w-20 bg-bg h-full relative box-shadow-side z-40" style="min-width: 80px"> -->
<div class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" /> <div class="w-20 bg-bg h-full fixed left-0 box-shadow-side z-40" style="min-width: 80px" :style="{ top: offsetTop + 'px' }">
<!-- ugly little workaround to cover up the shadow overlapping the bookshelf toolbar -->
<div v-if="isShowingBookshelfToolbar" class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" />
<nuxt-link :to="`/library/${currentLibraryId}`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> <nuxt-link :to="`/library/${currentLibraryId}`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
@@ -79,6 +82,13 @@ export default {
return {} return {}
}, },
computed: { computed: {
isShowingBookshelfToolbar() {
if (!this.$route.name) return false
return this.$route.name.startsWith('library')
},
offsetTop() {
return 64
},
showExperimentalFeatures() { showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures return this.$store.state.showExperimentalFeatures
}, },
+1 -1
View File
@@ -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">,&nbsp;</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">,&nbsp;</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>
+34 -25
View File
@@ -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 && userCanUpdate" class="absolute top-0 left-0 p-2 cursor-pointer hover:text-white text-gray-200 transform transition-transform hover:scale-125" @click.prevent.stop="searchAuthor"> <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 && userCanUpdate" class="absolute top-0 right-0 p-2 cursor-pointer hover:text-white text-gray-200 transform transition-transform hover:scale-125" @click.prevent.stop="$emit('edit', author)"> <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>
@@ -63,6 +65,9 @@ export default {
name() { name() {
return this._author.name || '' return this._author.name || ''
}, },
asin() {
return this._author.asin || ''
},
numBooks() { numBooks() {
return this._author.numBooks || 0 return this._author.numBooks || 0
}, },
@@ -74,12 +79,16 @@ export default {
mouseover() { mouseover() {
this.isHovering = true this.isHovering = true
}, },
mouseout() { mouseleave() {
this.isHovering = false this.isHovering = false
}, },
async searchAuthor() { async searchAuthor() {
this.searching = true this.searching = true
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, { q: this.name }).catch((error) => { const payload = {}
if (this.asin) payload.asin = this.asin
else payload.q = this.name
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, payload).catch((error) => {
console.error('Failed', error) console.error('Failed', error)
return null return null
}) })
+42 -8
View File
@@ -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 || '&nbsp;' }}</p> <p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayLineTwo || '&nbsp;' }}</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>
@@ -61,7 +61,7 @@
</div> </div>
<!-- More Menu Icon --> <!-- 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-100" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickShowMore"> <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>
@@ -247,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
}, },
@@ -344,7 +347,7 @@ export default {
return this.store.getters['user/getUserCanDownload'] return this.store.getters['user/getUserCanDownload']
}, },
userIsAdminOrUp() { userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp'] return this.store.getters['user/getIsAdminOrUp']
}, },
moreMenuItems() { moreMenuItems() {
if (this.recentEpisode) { if (this.recentEpisode) {
@@ -424,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
} }
@@ -435,7 +442,34 @@ export default {
this.isSelectionMode = val this.isSelectionMode = val
if (!val) this.selected = false if (!val) this.selected = false
}, },
setEntity(libraryItem) { setEntity(_libraryItem) {
var libraryItem = _libraryItem
// this code block is only necessary when showing a selected series with sequence #
// it will update the selected series so we get realtime updates for series sequence changes
if (this.series) {
// i know.. but the libraryItem passed to this func cannot be modified so we need to create a copy
libraryItem = {
..._libraryItem,
media: {
..._libraryItem.media,
metadata: {
..._libraryItem.media.metadata
}
}
}
var mediaMetadata = libraryItem.media.metadata
if (mediaMetadata.series) {
var newSeries = mediaMetadata.series.find((se) => se.id === this.series.id)
if (newSeries) {
// update selected series
libraryItem.media.metadata.series = newSeries
this.libraryItem = libraryItem
return
}
}
}
this.libraryItem = libraryItem this.libraryItem = libraryItem
}, },
clickCard(e) { clickCard(e) {
+13 -7
View File
@@ -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: {
+12 -1
View File
@@ -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: {
@@ -1,73 +0,0 @@
<template>
<div>
<form @submit.prevent="submitSearch">
<div class="flex items-center justify-start -mx-1 h-20">
<!-- <div class="w-40 px-1">
<ui-dropdown v-model="provider" :items="providers" label="Provider" small />
</div> -->
<div class="flex-grow px-1">
<ui-text-input-with-label v-model="searchAuthor" label="Author" />
</div>
<ui-btn class="mt-5 ml-1" type="submit">Search</ui-btn>
</div>
</form>
</div>
</template>
<script>
export default {
props: {
authorName: String
},
data() {
return {
searchAuthor: null,
lastSearch: null,
isProcessing: false,
provider: 'audnexus',
providers: [
{
text: 'Audnexus',
value: 'audnexus'
}
]
}
},
watch: {
authorName: {
immediate: true,
handler(newVal) {
this.searchAuthor = newVal
}
}
},
computed: {},
methods: {
getSearchQuery() {
return `q=${this.searchAuthor}`
},
submitSearch() {
if (!this.searchAuthor) {
this.$toast.warning('Author name is required')
return
}
this.runSearch()
},
async runSearch() {
var searchQuery = this.getSearchQuery()
if (this.lastSearch === searchQuery) return
this.isProcessing = true
this.lastSearch = searchQuery
var result = await this.$axios.$get(`/api/authors/search?${searchQuery}`).catch((error) => {
console.error('Failed', error)
return []
})
this.isProcessing = false
if (result) {
this.$emit('match', result)
}
}
},
mounted() {}
}
</script>
@@ -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'
+2 -1
View File
@@ -93,12 +93,13 @@ export default {
this.show = false this.show = false
}, },
clickBg(ev) { clickBg(ev) {
if (!this.show) return
if (this.preventClickoutside) { if (this.preventClickoutside) {
this.preventClickoutside = false this.preventClickoutside = false
return return
} }
if (this.processing && this.persistent) return if (this.processing && this.persistent) return
if (ev.srcElement.classList.contains('modal-bg')) { if (ev.srcElement && ev.srcElement.classList.contains('modal-bg')) {
this.show = false this.show = false
} }
}, },
+19 -11
View File
@@ -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
@@ -136,12 +139,17 @@ export default {
this.processing = false this.processing = false
}, },
async searchAuthor() { async searchAuthor() {
if (!this.authorCopy.name) { if (!this.authorCopy.name && !this.authorCopy.asin) {
this.$toast.error('Must enter an author name') this.$toast.error('Must enter an author name')
return return
} }
this.processing = true this.processing = true
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, { q: this.authorCopy.name }).catch((error) => {
const payload = {}
if (this.authorCopy.asin) payload.asin = this.authorCopy.asin
else payload.q = this.authorCopy.name
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, payload).catch((error) => {
console.error('Failed', error) console.error('Failed', error)
return null return null
}) })
+1 -1
View File
@@ -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>
@@ -1,32 +1,11 @@
<template> <template>
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6"> <div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
<div class="w-full mb-4"> <div class="w-full mb-4">
<div v-if="chapters.length" class="w-full p-4 bg-primary"> <tables-chapters-table v-if="chapters.length" :library-item="libraryItem" keep-open />
<p>Audiobook Chapters</p> <div v-if="!chapters.length" class="py-4 text-center">
<p class="mb-8 text-xl">No Chapters</p>
<ui-btn v-if="userCanUpdate" :to="`/audiobook/${libraryItem.id}/chapters`">Add Chapters</ui-btn>
</div> </div>
<div v-if="!chapters.length" class="flex my-4 text-center justify-center text-xl">No Chapters</div>
<table v-else class="text-sm tracksTable">
<tr class="font-book">
<th class="text-left w-16"><span class="px-4">Id</span></th>
<th class="text-left">Title</th>
<th class="text-center">Start</th>
<th class="text-center">End</th>
</tr>
<tr v-for="chapter in chapters" :key="chapter.id">
<td class="text-left">
<p class="px-4">{{ chapter.id }}</p>
</td>
<td class="font-book">
{{ chapter.title }}
</td>
<td class="font-mono text-center">
{{ $secondsToTimestamp(chapter.start) }}
</td>
<td class="font-mono text-center">
{{ $secondsToTimestamp(chapter.end) }}
</td>
</tr>
</table>
</div> </div>
</div> </div>
</template> </template>
@@ -48,6 +27,9 @@ export default {
}, },
chapters() { chapters() {
return this.media.chapters || [] return this.media.chapters || []
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
} }
}, },
methods: {} methods: {}
+22 -13
View File
@@ -1,9 +1,11 @@
<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>
@@ -17,7 +19,9 @@
<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-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>
@@ -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>
@@ -95,7 +95,7 @@ export default {
settings: { settings: {
disableWatcher: false, disableWatcher: false,
skipMatchingMediaWithAsin: false, skipMatchingMediaWithAsin: false,
skipMatchingMediaWithIsbn: false, skipMatchingMediaWithIsbn: false
} }
} }
}, },
@@ -193,6 +193,11 @@ export default {
this.processing = false this.processing = false
this.show = false this.show = false
this.$toast.success(`Library "${res.name}" created successfully`) this.$toast.success(`Library "${res.name}" created successfully`)
if (!this.$store.state.libraries.currentLibraryId) {
console.log('Setting initially library id', res.id)
// First library added
this.$store.dispatch('libraries/fetch', res.id)
}
}) })
.catch((error) => { .catch((error) => {
console.error(error) console.error(error)
@@ -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 || ''
@@ -148,6 +148,7 @@ export default {
}) })
}, },
init() { init() {
this.episodes.sort((a, b) => (a.publishedAt < b.publishedAt) ? 1 : -1)
for (let i = 0; i < this.episodes.length; i++) { for (let i = 0; i < this.episodes.length; i++) {
var episode = this.episodes[i] var episode = this.episodes[i]
if (episode.enclosure && !this.itemEpisodeMap[episode.enclosure.url]) { if (episode.enclosure && !this.itemEpisodeMap[episode.enclosure.url]) {
@@ -151,7 +151,7 @@ export default {
this.fullPath = '' this.fullPath = ''
return return
} }
this.fullPath = Path.join(this.selectedFolderPath, this.podcast.title) this.fullPath = Path.join(this.selectedFolderPath, this.$sanitizeFilename(this.podcast.title))
}, },
submit() { submit() {
const podcastPayload = { const podcastPayload = {
+14 -1
View File
@@ -18,12 +18,16 @@
<div v-else class="w-full"> <div v-else class="w-full">
<p class="text-lg font-semibold mb-4">Open RSS Feed</p> <p class="text-lg font-semibold mb-4">Open RSS Feed</p>
<div class="w-full relative"> <div class="w-full relative mb-2">
<ui-text-input-with-label v-model="newFeedSlug" label="RSS Feed Slug" /> <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> <p class="text-xs text-gray-400 py-0.5 px-1">Feed will be {{ demoFeedUrl }}</p>
</div> </div>
<p v-if="isHttp" class="w-full pt-2 text-warning text-xs">Warning: Most podcast apps will require the RSS feed URL is using HTTPS</p>
<p v-if="hasEpisodesWithoutPubDate" class="w-full pt-2 text-warning text-xs">Warning: 1 or more of your episodes do not have a Pub Date. Some podcast apps require this.</p>
</div> </div>
<div v-show="userIsAdminOrUp" class="flex items-center pt-6"> <div v-show="userIsAdminOrUp" class="flex items-center pt-6">
<p class="text-xs text-gray-300">Note: RSS feed URLs are not authenticated</p>
<div class="flex-grow" /> <div class="flex-grow" />
<ui-btn v-if="currentFeedUrl" color="error" small @click="closeFeed">Close RSS Feed</ui-btn> <ui-btn v-if="currentFeedUrl" color="error" small @click="closeFeed">Close RSS Feed</ui-btn>
<ui-btn v-else color="success" small @click="openFeed">Open RSS Feed</ui-btn> <ui-btn v-else color="success" small @click="openFeed">Open RSS Feed</ui-btn>
@@ -85,6 +89,15 @@ export default {
}, },
demoFeedUrl() { demoFeedUrl() {
return `${window.origin}/feed/${this.newFeedSlug}` return `${window.origin}/feed/${this.newFeedSlug}`
},
isHttp() {
return window.origin.startsWith('http://')
},
episodes() {
return this.media.episodes || []
},
hasEpisodesWithoutPubDate() {
return this.episodes.some((ep) => !ep.pubDate)
} }
}, },
methods: { methods: {
+273
View File
@@ -0,0 +1,273 @@
<template>
<div id="heatmap" class="w-full">
<div class="mx-auto" :style="{ height: innerHeight + 160 + 'px', width: innerWidth + 52 + 'px' }" style="background-color: rgba(13, 17, 23, 0)">
<p class="mb-2 px-1 text-sm text-gray-200">{{ Object.values(daysListening).length }} listening sessions in the last year</p>
<div class="border border-opacity-25 rounded py-2 w-full" style="background-color: #232323" :style="{ height: innerHeight + 80 + 'px' }">
<div :style="{ width: innerWidth + 'px', height: innerHeight + 'px' }" class="ml-10 mt-5 absolute" @mouseover="mouseover" @mouseout="mouseout">
<div v-for="dayLabel in dayLabels" :key="dayLabel.label" :style="dayLabel.style" class="absolute top-0 left-0 text-gray-300">{{ dayLabel.label }}</div>
<div v-for="monthLabel in monthLabels" :key="monthLabel.id" :style="monthLabel.style" class="absolute top-0 left-0 text-gray-300">{{ monthLabel.label }}</div>
<div v-for="(block, index) in data" :key="block.dateString" :style="block.style" :data-index="index" class="absolute top-0 left-0 h-2.5 w-2.5 rounded-sm" />
<div class="flex py-2 px-4" :style="{ marginTop: innerHeight + 'px' }">
<div class="flex-grow" />
<p style="font-size: 10px; line-height: 10px" class="text-gray-400 px-1">Less</p>
<div v-for="block in legendBlocks" :key="block.id" :style="block.style" class="h-2.5 w-2.5 rounded-sm" style="margin-left: 1.5px; margin-right: 1.5px" />
<p style="font-size: 10px; line-height: 10px" class="text-gray-400 px-1">More</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
daysListening: {
type: Object,
default: () => {}
}
},
data() {
return {
contentWidth: 0,
maxInnerWidth: 0,
innerHeight: 13 * 7,
blockWidth: 13,
data: [],
monthLabels: [],
tooltipEl: null,
tooltipTextEl: null,
tooltipArrowEl: null,
showingTooltipIndex: -1,
outlineColors: ['rgba(27, 31, 35, 0.06)', 'rgba(255,255,255,0.03)'],
bgColors: ['rgb(45,45,45)', 'rgb(14, 68, 41)', 'rgb(0, 109, 50)', 'rgb(38, 166, 65)', 'rgb(57, 211, 83)']
// GH Colors
// outlineColors: ['rgba(27, 31, 35, 0.06)', 'rgba(255,255,255,0.05)'],
// bgColors: ['rgb(22, 27, 34)', 'rgb(14, 68, 41)', 'rgb(0, 109, 50)', 'rgb(38, 166, 65)', 'rgb(57, 211, 83)']
}
},
computed: {
weeksToShow() {
return Math.min(52, Math.floor(this.maxInnerWidth / this.blockWidth) - 1)
},
innerWidth() {
return (this.weeksToShow + 1) * 13
},
daysToShow() {
return this.weeksToShow * 7 + this.dayOfWeekToday
},
dayOfWeekToday() {
return new Date().getDay()
},
firstWeekStart() {
return this.$addDaysToToday(-this.daysToShow)
},
dayLabels() {
return [
{
label: 'Mon',
style: {
transform: `translate(${-25}px, ${13}px)`,
lineHeight: '10px',
fontSize: '10px'
}
},
{
label: 'Wed',
style: {
transform: `translate(${-25}px, ${13 * 3}px)`,
lineHeight: '10px',
fontSize: '10px'
}
},
{
label: 'Fri',
style: {
transform: `translate(${-25}px, ${13 * 5}px)`,
lineHeight: '10px',
fontSize: '10px'
}
}
]
},
legendBlocks() {
return [
{
id: 'legend-0',
style: `background-color:${this.bgColors[0]};outline:1px solid ${this.outlineColors[0]};outline-offset:-1px;`
},
{
id: 'legend-1',
style: `background-color:${this.bgColors[1]};outline:1px solid ${this.outlineColors[1]};outline-offset:-1px;`
},
{
id: 'legend-2',
style: `background-color:${this.bgColors[2]};outline:1px solid ${this.outlineColors[1]};outline-offset:-1px;`
},
{
id: 'legend-3',
style: `background-color:${this.bgColors[3]};outline:1px solid ${this.outlineColors[1]};outline-offset:-1px;`
},
{
id: 'legend-4',
style: `background-color:${this.bgColors[4]};outline:1px solid ${this.outlineColors[1]};outline-offset:-1px;`
}
]
}
},
methods: {
destroyTooltip() {
if (this.tooltipEl) this.tooltipEl.remove()
this.tooltipEl = null
this.showingTooltipIndex = -1
},
createTooltip() {
const tooltip = document.createElement('div')
tooltip.className = 'absolute top-0 left-0 rounded bg-gray-500 text-white p-2 text-white max-w-xs pointer-events-none'
tooltip.style.display = 'none'
tooltip.id = 'heatmap-tooltip'
const tooltipText = document.createElement('p')
tooltipText.innerText = 'Tooltip'
tooltipText.style.fontSize = '10px'
tooltipText.style.lineHeight = '10px'
tooltip.appendChild(tooltipText)
const tooltipArrow = document.createElement('div')
tooltipArrow.className = 'text-gray-500 arrow-down-small absolute -bottom-1 left-0 right-0 mx-auto'
tooltip.appendChild(tooltipArrow)
this.tooltipEl = tooltip
this.tooltipTextEl = tooltipText
this.tooltipArrowEl = tooltipArrow
document.body.appendChild(this.tooltipEl)
},
showTooltip(index, block, rect) {
if (this.tooltipEl && this.showingTooltipIndex === index) return
if (!this.tooltipEl) {
this.createTooltip()
}
this.showingTooltipIndex = index
this.tooltipEl.style.display = 'block'
this.tooltipTextEl.innerHTML = block.value ? `<strong>${this.$elapsedPretty(block.value, true)} listening</strong> on ${block.datePretty}` : `No listening sessions on ${block.datePretty}`
const calculateRect = this.tooltipEl.getBoundingClientRect()
const w = calculateRect.width / 2
var left = rect.x - w
var offsetX = 0
if (left < 0) {
offsetX = Math.abs(left)
left = 0
} else if (rect.x + w > window.innerWidth - 10) {
offsetX = window.innerWidth - 10 - (rect.x + w)
left += offsetX
}
this.tooltipEl.style.transform = `translate(${left}px, ${rect.y - 32}px)`
this.tooltipArrowEl.style.transform = `translate(${5 - offsetX}px, 0px)`
},
hideTooltip() {
if (this.showingTooltipIndex >= 0 && this.tooltipEl) {
this.tooltipEl.style.display = 'none'
this.showingTooltipIndex = -1
}
},
mouseover(e) {
if (isNaN(e.target.dataset.index)) {
this.hideTooltip()
return
}
var block = this.data[e.target.dataset.index]
var rect = e.target.getBoundingClientRect()
this.showTooltip(e.target.dataset.index, block, rect)
},
mouseout(e) {
this.hideTooltip()
},
buildData() {
this.data = []
var maxValue = 0
var minValue = 0
Object.values(this.daysListening).forEach((val) => {
if (val > maxValue) maxValue = val
if (!minValue || val < minValue) minValue = val
})
const range = maxValue - minValue + 0.01
for (let i = 0; i < this.daysToShow + 1; i++) {
const col = Math.floor(i / 7)
const row = i % 7
const date = i === 0 ? this.firstWeekStart : this.$addDaysToDate(this.firstWeekStart, i)
const dateString = this.$formatJsDate(date, 'yyyy-MM-dd')
const datePretty = this.$formatJsDate(date, 'MMM d, yyyy')
const monthString = this.$formatJsDate(date, 'MMM')
const value = this.daysListening[dateString] || 0
const x = col * 13
const y = row * 13
var bgColor = this.bgColors[0]
var outlineColor = this.outlineColors[0]
if (value) {
outlineColor = this.outlineColors[1]
var percentOfAvg = (value - minValue) / range
var bgIndex = Math.floor(percentOfAvg * 4) + 1
bgColor = this.bgColors[bgIndex] || 'red'
}
this.data.push({
date,
dateString,
datePretty,
monthString,
dayOfMonth: Number(dateString.split('-').pop()),
yearString: dateString.split('-').shift(),
value,
col,
row,
style: `transform:translate(${x}px,${y}px);background-color:${bgColor};outline:1px solid ${outlineColor};outline-offset:-1px;`
})
}
console.log('Data', this.data)
this.monthLabels = []
var lastMonth = null
for (let i = 0; i < this.data.length; i++) {
if (this.data[i].monthString !== lastMonth) {
const weekOfMonth = Math.floor(this.data[i].dayOfMonth / 7)
if (weekOfMonth <= 2) {
this.monthLabels.push({
id: this.data[i].dateString + '-ml',
label: this.data[i].monthString,
style: {
transform: `translate(${this.data[i].col * 13}px, -15px)`,
lineHeight: '10px',
fontSize: '10px'
}
})
lastMonth = this.data[i].monthString
}
}
}
},
init() {
const heatmapEl = document.getElementById('heatmap')
this.contentWidth = heatmapEl.clientWidth
this.maxInnerWidth = this.contentWidth - 52
this.buildData()
}
},
updated() {},
mounted() {
this.init()
},
beforeDestroy() {}
}
</script>
@@ -0,0 +1,74 @@
<template>
<div class="w-full my-2">
<div class="w-full bg-primary px-6 py-2 flex items-center cursor-pointer" @click.stop="clickBar">
<p class="pr-4">Chapters</p>
<span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ chapters.length }}</span>
<div class="flex-grow" />
<ui-btn v-if="userCanUpdate" small :to="`/audiobook/${libraryItemId}/chapters`" color="primary" class="mr-2">Edit Chapters</ui-btn>
<div v-if="!keepOpen" class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="expanded ? 'transform rotate-180' : ''">
<span class="material-icons text-4xl">expand_more</span>
</div>
</div>
<transition name="slide">
<table class="text-sm tracksTable" v-show="expanded || keepOpen">
<tr class="font-book">
<th class="text-left w-16"><span class="px-4">Id</span></th>
<th class="text-left">Title</th>
<th class="text-center">Start</th>
<th class="text-center">End</th>
</tr>
<tr v-for="chapter in chapters" :key="chapter.id">
<td class="text-left">
<p class="px-4">{{ chapter.id }}</p>
</td>
<td class="font-book">
{{ chapter.title }}
</td>
<td class="font-mono text-center">
{{ $secondsToTimestamp(chapter.start) }}
</td>
<td class="font-mono text-center">
{{ $secondsToTimestamp(chapter.end) }}
</td>
</tr>
</table>
</transition>
</div>
</template>
<script>
export default {
props: {
libraryItem: {
type: Object,
default: () => {}
},
keepOpen: Boolean
},
data() {
return {
expanded: false
}
},
computed: {
libraryItemId() {
return this.libraryItem.id
},
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
},
chapters() {
return this.media.chapters || []
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
}
},
methods: {
clickBar() {
this.expanded = !this.expanded
}
},
mounted() {}
}
</script>
@@ -6,18 +6,20 @@
<span class="material-icons" style="font-size: 1.4rem">add</span> <span class="material-icons" style="font-size: 1.4rem">add</span>
</div> </div>
</div> </div>
<draggable :list="libraryCopies" v-bind="dragOptions" class="list-group" draggable=".item" tag="div" @start="startDrag" @end="endDrag"> <draggable v-if="libraryCopies.length" :list="libraryCopies" v-bind="dragOptions" class="list-group" draggable=".item" tag="div" @start="startDrag" @end="endDrag">
<template v-for="library in libraryCopies"> <template v-for="library in libraryCopies">
<div :key="library.id" class="item"> <div :key="library.id" class="item">
<tables-library-item :library="library" :selected="currentLibraryId === library.id" :show-edit="true" :dragging="drag" @edit="editLibrary" @click="setLibrary" /> <tables-library-item :library="library" :selected="currentLibraryId === library.id" :show-edit="true" :dragging="drag" @edit="editLibrary" @click="setLibrary" />
</div> </div>
</template> </template>
</draggable> </draggable>
<modals-libraries-edit-modal v-model="showLibraryModal" :library="selectedLibrary" /> <div v-if="!libraries.length" class="pb-4">
<ui-btn @click="clickAddLibrary">Add your first library</ui-btn>
</div>
<p class="text-xs mt-4 text-gray-200">*<strong>Force Re-Scan</strong> will scan all files again like a fresh scan. Audio file ID3 tags, OPF files, and text files will be probed/parsed and used for book details.</p> <p v-if="libraries.length" class="text-xs mt-4 text-gray-200">*<strong>Force Re-Scan</strong> will scan all files again like a fresh scan. Audio file ID3 tags, OPF files, and text files will be probed/parsed and used for book details.</p>
<p class="text-xs mt-4 text-gray-200">**<strong>Match Books</strong> will attempt to match books in library with a book from the selected search provider and fill in empty details and cover art. Does not overwrite details.</p> <p v-if="libraries.length && libraries.some((li) => li.mediaType === 'book')" class="text-xs mt-4 text-gray-200">**<strong>Match Books</strong> will attempt to match books in library with a book from the selected search provider and fill in empty details and cover art. Does not overwrite details.</p>
</div> </div>
</template> </template>
@@ -32,8 +34,6 @@ export default {
return { return {
libraryCopies: [], libraryCopies: [],
currentOrder: [], currentOrder: [],
showLibraryModal: false,
selectedLibrary: null,
drag: false, drag: false,
dragOptions: { dragOptions: {
animation: 200, animation: 200,
@@ -97,12 +97,10 @@ export default {
this.$router.push(`/library/${library.id}`) this.$router.push(`/library/${library.id}`)
}, },
clickAddLibrary() { clickAddLibrary() {
this.selectedLibrary = null this.$emit('showLibraryModal', null)
this.showLibraryModal = true
}, },
editLibrary(library) { editLibrary(library) {
this.selectedLibrary = library this.$emit('showLibraryModal', library)
this.showLibraryModal = true
}, },
init() { init() {
this.libraryCopies = this.libraries.map((lib) => { this.libraryCopies = this.libraries.map((lib) => {
@@ -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>
+25 -4
View File
@@ -1,9 +1,12 @@
<template> <template>
<div class="relative"> <div ref="wrapper" class="relative">
<input ref="input" v-model="inputValue" :type="type" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="rounded bg-primary text-gray-200 focus:border-gray-300 focus:bg-bg focus:outline-none border border-gray-600 h-full w-full" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" /> <input ref="input" v-model="inputValue" :type="actualType" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="rounded bg-primary text-gray-200 focus:border-gray-300 focus:bg-bg focus:outline-none border border-gray-600 h-full w-full" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
<div v-if="clearable && inputValue" class="absolute top-0 right-0 h-full px-2 flex items-center justify-center"> <div v-if="clearable && inputValue" class="absolute top-0 right-0 h-full px-2 flex items-center justify-center">
<span class="material-icons text-gray-300 cursor-pointer" style="font-size: 1.1rem" @click.stop.prevent="clear">close</span> <span class="material-icons text-gray-300 cursor-pointer" style="font-size: 1.1rem" @click.stop.prevent="clear">close</span>
</div> </div>
<div v-if="type === 'password' && isHovering" class="absolute top-0 right-0 h-full px-4 flex items-center justify-center">
<span class="material-icons-outlined text-gray-400 cursor-pointer text-lg" @click.stop.prevent="showPassword = !showPassword">{{ !showPassword ? 'visibility' : 'visibility_off' }}</span>
</div>
</div> </div>
</template> </template>
@@ -31,7 +34,10 @@ export default {
clearable: Boolean clearable: Boolean
}, },
data() { data() {
return {} return {
showPassword: false,
isHovering: false
}
}, },
computed: { computed: {
inputValue: { inputValue: {
@@ -49,6 +55,10 @@ export default {
if (this.noSpinner) _list.push('no-spinner') if (this.noSpinner) _list.push('no-spinner')
if (this.textCenter) _list.push('text-center') if (this.textCenter) _list.push('text-center')
return _list.join(' ') return _list.join(' ')
},
actualType() {
if (this.type === 'password' && this.showPassword) return 'text'
return this.type
} }
}, },
methods: { methods: {
@@ -69,9 +79,20 @@ export default {
}, },
blur() { blur() {
if (this.$refs.input) this.$refs.input.blur() if (this.$refs.input) this.$refs.input.blur()
},
mouseover() {
this.isHovering = true
},
mouseleave() {
this.isHovering = false
} }
}, },
mounted() {} mounted() {
if (this.type === 'password' && this.$refs.wrapper) {
this.$refs.wrapper.addEventListener('mouseover', this.mouseover)
this.$refs.wrapper.addEventListener('mouseleave', this.mouseleave)
}
}
} }
</script> </script>
+1 -1
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="w-full"> <div class="w-full">
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''"> <p class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': disabled }">
{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em> {{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
</p> </p>
<ui-text-input ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" class="w-full" @blur="inputBlurred" /> <ui-text-input ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" class="w-full" @blur="inputBlurred" />
+112
View File
@@ -0,0 +1,112 @@
<template>
<div class="w-full">
<div class="flex items-center py-3">
<slot />
<div class="flex-grow" />
<button v-if="isScrollable" class="w-8 h-8 mx-1 flex items-center justify-center rounded-full" :class="canScrollLeft ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollLeft">
<span class="material-icons text-2xl">chevron_left</span>
</button>
<button v-if="isScrollable" class="w-8 h-8 mx-1 flex items-center justify-center rounded-full" :class="canScrollRight ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollRight">
<span class="material-icons text-2xl">chevron_right</span>
</button>
</div>
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll -mx-2" style="scroll-behavior: smooth" @scroll="scrolled">
<div class="flex" :style="{ height: height + 'px' }">
<template v-for="(item, index) in items">
<cards-author-card :key="item.id" :ref="`slider-item-${item.id}`" :index="index" :author="item" :height="cardHeight" :width="cardWidth" class="relative mx-2" @edit="editAuthor" @hook:updated="setScrollVars" />
</template>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
items: {
type: Array,
default: () => []
},
height: {
type: Number,
default: 192
},
bookshelfView: {
type: Number,
default: 1
}
},
data() {
return {
isScrollable: false,
canScrollLeft: false,
canScrollRight: false,
clientWidth: 0
}
},
computed: {
bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio']
},
cardScaleMulitiplier() {
return this.height / 192
},
cardHeight() {
return this.height
},
cardWidth() {
return this.cardHeight / this.bookCoverAspectRatio / 1.25
},
booksPerPage() {
return Math.floor(this.clientWidth / (this.cardWidth + 16))
},
isSelectionMode() {
return this.$store.getters['getNumLibraryItemsSelected'] > 0
}
},
methods: {
editAuthor(author) {
this.$store.commit('globals/showEditAuthorModal', author)
},
scrolled() {
this.setScrollVars()
},
scrollRight() {
if (!this.canScrollRight) return
const slider = this.$refs.slider
if (!slider) return
const scrollAmount = this.booksPerPage * this.cardWidth
const maxScrollLeft = slider.scrollWidth - slider.clientWidth
const newScrollLeft = Math.min(maxScrollLeft, slider.scrollLeft + scrollAmount)
slider.scrollLeft = newScrollLeft
},
scrollLeft() {
if (!this.canScrollLeft) return
const slider = this.$refs.slider
if (!slider) return
const scrollAmount = this.booksPerPage * this.cardWidth
const newScrollLeft = Math.max(0, slider.scrollLeft - scrollAmount)
slider.scrollLeft = newScrollLeft
},
setScrollVars() {
const slider = this.$refs.slider
if (!slider) return
const { scrollLeft, scrollWidth, clientWidth } = slider
const scrollPercent = (scrollLeft + clientWidth) / scrollWidth
this.clientWidth = clientWidth
this.isScrollable = scrollWidth > clientWidth
this.canScrollRight = scrollPercent < 1
this.canScrollLeft = scrollLeft > 0
}
},
updated() {
this.setScrollVars()
},
mounted() {},
beforeDestroy() {}
}
</script>
+51 -53
View File
@@ -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>
+148
View File
@@ -0,0 +1,148 @@
<template>
<div class="w-full">
<div class="flex items-center py-3">
<slot />
<div class="flex-grow" />
<button v-if="isScrollable" class="w-8 h-8 mx-1 flex items-center justify-center rounded-full" :class="canScrollLeft ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollLeft">
<span class="material-icons text-2xl">chevron_left</span>
</button>
<button v-if="isScrollable" class="w-8 h-8 mx-1 flex items-center justify-center rounded-full" :class="canScrollRight ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollRight">
<span class="material-icons text-2xl">chevron_right</span>
</button>
</div>
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll -mx-2" style="scroll-behavior: smooth" @scroll="scrolled">
<div class="flex" :style="{ height: height + 'px' }">
<template v-for="(item, index) in items">
<cards-lazy-book-card :key="item.recentEpisode.id" :ref="`slider-episode-${item.recentEpisode.id}`" :index="index" :book-mount="item" :height="cardHeight" :width="cardWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" :bookshelf-view="bookshelfView" class="relative mx-2" @edit="editEpisode" @editPodcast="editPodcast" @select="selectItem" @hook:updated="setScrollVars" />
</template>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
items: {
type: Array,
default: () => []
},
height: {
type: Number,
default: 192
},
bookshelfView: {
type: Number,
default: 1
}
},
data() {
return {
isScrollable: false,
canScrollLeft: false,
canScrollRight: false,
clientWidth: 0
}
},
computed: {
bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio']
},
cardScaleMulitiplier() {
return this.height / 192
},
cardHeight() {
return this.height - 40 * this.cardScaleMulitiplier
},
cardWidth() {
return this.cardHeight / this.bookCoverAspectRatio
},
booksPerPage() {
return Math.floor(this.clientWidth / (this.cardWidth + 16))
},
isSelectionMode() {
return this.$store.getters['getNumLibraryItemsSelected'] > 0
}
},
methods: {
clearSelectedEntities() {
this.updateSelectionMode(false)
},
editEpisode({ libraryItem, episode }) {
this.$store.commit('setSelectedLibraryItem', libraryItem)
this.$store.commit('globals/setSelectedEpisode', episode)
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
},
editPodcast(libraryItem) {
var itemIds = this.items.map((e) => e.id)
this.$store.commit('setBookshelfBookIds', itemIds)
this.$store.commit('showEditModal', libraryItem)
},
selectItem(libraryItem) {
this.$store.commit('toggleLibraryItemSelected', libraryItem.id)
this.$nextTick(() => {
this.$eventBus.$emit('item-selected', libraryItem)
})
},
itemSelectedEvt() {
this.updateSelectionMode(this.isSelectionMode)
},
updateSelectionMode(val) {
var selectedLibraryItems = this.$store.state.selectedLibraryItems
this.items.forEach((ent) => {
var component = this.$refs[`slider-episode-${ent.recentEpisode.id}`]
if (!component || !component.length) return
component = component[0]
component.setSelectionMode(val)
component.selected = selectedLibraryItems.includes(ent.id)
})
},
scrolled() {
this.setScrollVars()
},
scrollRight() {
if (!this.canScrollRight) return
const slider = this.$refs.slider
if (!slider) return
const scrollAmount = this.booksPerPage * this.cardWidth
const maxScrollLeft = slider.scrollWidth - slider.clientWidth
const newScrollLeft = Math.min(maxScrollLeft, slider.scrollLeft + scrollAmount)
slider.scrollLeft = newScrollLeft
},
scrollLeft() {
if (!this.canScrollLeft) return
const slider = this.$refs.slider
if (!slider) return
const scrollAmount = this.booksPerPage * this.cardWidth
const newScrollLeft = Math.max(0, slider.scrollLeft - scrollAmount)
slider.scrollLeft = newScrollLeft
},
setScrollVars() {
const slider = this.$refs.slider
if (!slider) return
const { scrollLeft, scrollWidth, clientWidth } = slider
const scrollPercent = (scrollLeft + clientWidth) / scrollWidth
this.clientWidth = clientWidth
this.isScrollable = scrollWidth > clientWidth
this.canScrollRight = scrollPercent < 1
this.canScrollLeft = scrollLeft > 0
}
},
updated() {
this.setScrollVars()
},
mounted() {
this.$eventBus.$on('bookshelf-clear-selection', this.clearSelectedEntities)
this.$eventBus.$on('item-selected', this.itemSelectedEvt)
},
beforeDestroy() {
this.$eventBus.$off('bookshelf-clear-selection', this.clearSelectedEntities)
this.$eventBus.$off('item-selected', this.itemSelectedEvt)
}
}
</script>
+143
View File
@@ -0,0 +1,143 @@
<template>
<div class="w-full">
<div class="flex items-center py-3">
<slot />
<div class="flex-grow" />
<button v-if="isScrollable" class="w-8 h-8 mx-1 flex items-center justify-center rounded-full" :class="canScrollLeft ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollLeft">
<span class="material-icons text-2xl">chevron_left</span>
</button>
<button v-if="isScrollable" class="w-8 h-8 mx-1 flex items-center justify-center rounded-full" :class="canScrollRight ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollRight">
<span class="material-icons text-2xl">chevron_right</span>
</button>
</div>
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll -mx-2" style="scroll-behavior: smooth" @scroll="scrolled">
<div class="flex" :style="{ height: height + 'px' }">
<template v-for="(item, index) in items">
<cards-lazy-book-card :key="item.id" :ref="`slider-item-${item.id}`" :index="index" :book-mount="item" :height="cardHeight" :width="cardWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" :bookshelf-view="bookshelfView" class="relative mx-2" @edit="editItem" @select="selectItem" @hook:updated="setScrollVars" />
</template>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
items: {
type: Array,
default: () => []
},
height: {
type: Number,
default: 192
},
bookshelfView: {
type: Number,
default: 1
}
},
data() {
return {
isScrollable: false,
canScrollLeft: false,
canScrollRight: false,
clientWidth: 0
}
},
computed: {
bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio']
},
cardScaleMulitiplier() {
return this.height / 192
},
cardHeight() {
return this.height - 40 * this.cardScaleMulitiplier
},
cardWidth() {
return this.cardHeight / this.bookCoverAspectRatio
},
booksPerPage() {
return Math.floor(this.clientWidth / (this.cardWidth + 16))
},
isSelectionMode() {
return this.$store.getters['getNumLibraryItemsSelected'] > 0
}
},
methods: {
clearSelectedEntities() {
this.updateSelectionMode(false)
},
editItem(libraryItem) {
var itemIds = this.items.map((e) => e.id)
this.$store.commit('setBookshelfBookIds', itemIds)
this.$store.commit('showEditModal', libraryItem)
},
selectItem(libraryItem) {
this.$store.commit('toggleLibraryItemSelected', libraryItem.id)
this.$nextTick(() => {
this.$eventBus.$emit('item-selected', libraryItem)
})
},
itemSelectedEvt() {
this.updateSelectionMode(this.isSelectionMode)
},
updateSelectionMode(val) {
var selectedLibraryItems = this.$store.state.selectedLibraryItems
this.items.forEach((item) => {
var component = this.$refs[`slider-item-${item.id}`]
if (!component || !component.length) return
component = component[0]
component.setSelectionMode(val)
component.selected = selectedLibraryItems.includes(item.id)
})
},
scrolled() {
this.setScrollVars()
},
scrollRight() {
if (!this.canScrollRight) return
const slider = this.$refs.slider
if (!slider) return
const scrollAmount = this.booksPerPage * this.cardWidth
const maxScrollLeft = slider.scrollWidth - slider.clientWidth
const newScrollLeft = Math.min(maxScrollLeft, slider.scrollLeft + scrollAmount)
slider.scrollLeft = newScrollLeft
},
scrollLeft() {
if (!this.canScrollLeft) return
const slider = this.$refs.slider
if (!slider) return
const scrollAmount = this.booksPerPage * this.cardWidth
const newScrollLeft = Math.max(0, slider.scrollLeft - scrollAmount)
slider.scrollLeft = newScrollLeft
},
setScrollVars() {
const slider = this.$refs.slider
if (!slider) return
const { scrollLeft, scrollWidth, clientWidth } = slider
const scrollPercent = (scrollLeft + clientWidth) / scrollWidth
this.clientWidth = clientWidth
this.isScrollable = scrollWidth > clientWidth
this.canScrollRight = scrollPercent < 1
this.canScrollLeft = scrollLeft > 0
}
},
updated() {
this.setScrollVars()
},
mounted() {
this.$eventBus.$on('bookshelf-clear-selection', this.clearSelectedEntities)
this.$eventBus.$on('item-selected', this.itemSelectedEvt)
},
beforeDestroy() {
this.$eventBus.$off('bookshelf-clear-selection', this.clearSelectedEntities)
this.$eventBus.$off('item-selected', this.itemSelectedEvt)
}
}
</script>
@@ -1,50 +1,48 @@
<template> <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>
+109
View File
@@ -0,0 +1,109 @@
<template>
<div class="w-full">
<div class="flex items-center py-3">
<slot />
<div class="flex-grow" />
<button v-if="isScrollable" class="w-8 h-8 mx-1 flex items-center justify-center rounded-full" :class="canScrollLeft ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollLeft">
<span class="material-icons text-2xl">chevron_left</span>
</button>
<button v-if="isScrollable" class="w-8 h-8 mx-1 flex items-center justify-center rounded-full" :class="canScrollRight ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollRight">
<span class="material-icons text-2xl">chevron_right</span>
</button>
</div>
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll -mx-2" style="scroll-behavior: smooth" @scroll="scrolled">
<div class="flex" :style="{ height: height + 'px' }">
<template v-for="(item, index) in items">
<cards-lazy-series-card :key="item.id" :ref="`slider-item-${item.id}`" :index="index" :series-mount="item" :height="cardHeight" :width="cardWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" :bookshelf-view="$constants.BookshelfView.TITLES" class="relative mx-2" @hook:updated="setScrollVars" />
</template>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
items: {
type: Array,
default: () => []
},
height: {
type: Number,
default: 192
},
bookshelfView: {
type: Number,
default: 1
}
},
data() {
return {
isScrollable: false,
canScrollLeft: false,
canScrollRight: false,
clientWidth: 0
}
},
computed: {
bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio']
},
cardScaleMulitiplier() {
return this.height / 192
},
cardHeight() {
return this.height - 40 * this.cardScaleMulitiplier
},
cardWidth() {
return 2 * (this.cardHeight / this.bookCoverAspectRatio)
},
booksPerPage() {
return Math.floor(this.clientWidth / (this.cardWidth + 16))
},
isSelectionMode() {
return this.$store.getters['getNumLibraryItemsSelected'] > 0
}
},
methods: {
scrolled() {
this.setScrollVars()
},
scrollRight() {
if (!this.canScrollRight) return
const slider = this.$refs.slider
if (!slider) return
const scrollAmount = this.booksPerPage * this.cardWidth
const maxScrollLeft = slider.scrollWidth - slider.clientWidth
const newScrollLeft = Math.min(maxScrollLeft, slider.scrollLeft + scrollAmount)
slider.scrollLeft = newScrollLeft
},
scrollLeft() {
if (!this.canScrollLeft) return
const slider = this.$refs.slider
if (!slider) return
const scrollAmount = this.booksPerPage * this.cardWidth
const newScrollLeft = Math.max(0, slider.scrollLeft - scrollAmount)
slider.scrollLeft = newScrollLeft
},
setScrollVars() {
const slider = this.$refs.slider
if (!slider) return
const { scrollLeft, scrollWidth, clientWidth } = slider
const scrollPercent = (scrollLeft + clientWidth) / scrollWidth
this.clientWidth = clientWidth
this.isScrollable = scrollWidth > clientWidth
this.canScrollRight = scrollPercent < 1
this.canScrollLeft = scrollLeft > 0
}
},
updated() {
this.setScrollVars()
},
mounted() {},
beforeDestroy() {}
}
</script>
+26 -3
View File
@@ -2,7 +2,10 @@
<div class="text-white max-h-screen h-screen overflow-hidden bg-bg"> <div class="text-white max-h-screen h-screen overflow-hidden bg-bg">
<app-appbar /> <app-appbar />
<Nuxt /> <app-side-rail v-if="isShowingSideRail" class="hidden md:block" />
<div id="app-content" class="h-full" :class="{ 'has-siderail': isShowingSideRail }">
<Nuxt />
</div>
<app-stream-container ref="streamContainer" /> <app-stream-container ref="streamContainer" />
@@ -11,6 +14,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>
@@ -44,6 +48,13 @@ export default {
}, },
isCasting() { isCasting() {
return this.$store.state.globals.isCasting return this.$store.state.globals.isCasting
},
isShowingSideRail() {
if (!this.$route.name) return false
return !this.$route.name.startsWith('config') && this.$store.state.libraries.currentLibraryId
},
appContentMarginLeft() {
return this.isShowingSideRail ? 80 : 0
} }
}, },
methods: { methods: {
@@ -162,6 +173,7 @@ export default {
this.$store.commit('libraries/addUpdate', library) this.$store.commit('libraries/addUpdate', library)
}, },
async libraryRemoved(library) { async libraryRemoved(library) {
console.log('Library removed', library)
this.$store.commit('libraries/remove', library) this.$store.commit('libraries/remove', library)
// When removed currently selected library then set next accessible library // When removed currently selected library then set next accessible library
@@ -180,18 +192,20 @@ export default {
this.$router.push(`/library/${nextLibrary.id}`) this.$router.push(`/library/${nextLibrary.id}`)
} }
} else { } else {
console.error('User has no accessible libraries') console.error('User has no more accessible libraries')
this.$store.commit('libraries/setCurrentLibrary', null)
} }
} }
}, },
libraryItemAdded(libraryItem) { libraryItemAdded(libraryItem) {
// this.$store.commit('libraries/updateFilterDataWithAudiobook', libraryItem) this.$store.commit('libraries/updateFilterDataWithItem', libraryItem)
}, },
libraryItemUpdated(libraryItem) { libraryItemUpdated(libraryItem) {
if (this.$store.state.selectedLibraryItem && this.$store.state.selectedLibraryItem.id === libraryItem.id) { if (this.$store.state.selectedLibraryItem && this.$store.state.selectedLibraryItem.id === libraryItem.id) {
this.$store.commit('setSelectedLibraryItem', libraryItem) this.$store.commit('setSelectedLibraryItem', libraryItem)
} }
this.$eventBus.$emit(`${libraryItem.id}_updated`, libraryItem) this.$eventBus.$emit(`${libraryItem.id}_updated`, libraryItem)
this.$store.commit('libraries/updateFilterDataWithItem', libraryItem)
}, },
libraryItemRemoved(item) { libraryItemRemoved(item) {
if (this.$route.name.startsWith('item')) { if (this.$route.name.startsWith('item')) {
@@ -549,4 +563,13 @@ export default {
.Vue-Toastification__toast-body.custom-class-1 { .Vue-Toastification__toast-body.custom-class-1 {
font-size: 14px; font-size: 14px;
} }
#app-content {
width: 100%;
}
#app-content.has-siderail {
width: calc(100% - 80px);
max-width: calc(100% - 80px);
margin-left: 80px;
}
</style> </style>
-30
View File
@@ -1,30 +0,0 @@
export default function (context) {
if (process.client) {
var route = context.route
var from = context.from
var store = context.store
if (route.name === 'login' || from.name === 'login') return
if (!route.name) {
console.warn('No Route name', route)
return
}
if (store.state.isRoutingBack) {
// pressing back button in appbar do not add to route history
store.commit('setIsRoutingBack', false)
return
}
if (route.name.startsWith('config') || route.name === 'upload' || route.name === 'account' || route.name.startsWith('audiobook-id') || route.name.startsWith('collection-id')) {
if (from.name !== route.name && from.name !== 'audiobook-id-edit' && !from.name.startsWith('config') && from.name !== 'upload' && from.name !== 'account') {
var _history = [...store.state.routeHistory]
if (!_history.length || _history[_history.length - 1] !== from.fullPath) {
_history.push(from.fullPath)
store.commit('setRouteHistory', _history)
}
}
}
}
}
+2 -6
View File
@@ -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,14 +32,11 @@ 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' },
] ]
}, },
router: { router: {},
middleware: ['routed']
},
// Global CSS: https://go.nuxtjs.dev/config-css // Global CSS: https://go.nuxtjs.dev/config-css
css: [ css: [
+701 -1604
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.0.10", "version": "2.0.14",
"description": "Audiobook manager and player", "description": "Self-hosted audiobook and podcast client",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"dev": "nuxt", "dev": "nuxt",
+422
View File
@@ -0,0 +1,422 @@
<template>
<div id="page-wrapper" class="bg-bg page overflow-y-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
<div class="flex items-center py-4 max-w-7xl mx-auto">
<nuxt-link :to="`/item/${libraryItem.id}`" class="hover:underline">
<h1 class="text-xl">{{ title }}</h1>
</nuxt-link>
<button class="w-7 h-7 flex items-center justify-center mx-4 hover:scale-110 duration-100 transform text-gray-200 hover:text-white" @click="editItem">
<span class="material-icons text-base">edit</span>
</button>
<div class="flex-grow" />
<p class="text-base">Duration:</p>
<p class="text-base font-mono ml-8">{{ mediaDuration }}</p>
</div>
<div class="flex flex-wrap-reverse justify-center py-4">
<div class="w-full max-w-3xl py-4">
<div class="flex items-center">
<p class="text-lg mb-4 font-semibold">Audiobook Chapters</p>
<div class="flex-grow" />
<ui-btn color="primary" small class="mx-2" @click="showFindChaptersModal = true">Lookup</ui-btn>
<ui-btn color="success" small @click="saveChapters">Save</ui-btn>
<div class="w-40" />
</div>
<div class="flex text-xs uppercase text-gray-300 font-semibold mb-2">
<div class="w-12"></div>
<div class="w-32 px-2">Start</div>
<div class="flex-grow px-2">Title</div>
<div class="w-40"></div>
</div>
<template v-for="chapter in newChapters">
<div :key="chapter.id" class="flex py-1">
<div class="w-12">#{{ chapter.id + 1 }}</div>
<div class="w-32 px-1">
<ui-text-input v-model="chapter.start" type="number" class="text-xs" @change="checkChapters" />
</div>
<div class="flex-grow px-1">
<ui-text-input v-model="chapter.title" class="text-xs" />
</div>
<div class="w-40 px-2 py-1">
<div class="flex items-center">
<button v-if="newChapters.length > 1" class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-error transform hover:scale-110 duration-150" @click="removeChapter(chapter)">
<span class="material-icons-outlined text-base">remove</span>
</button>
<ui-tooltip text="Insert chapter below" direction="bottom">
<button class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-success transform hover:scale-110 duration-150" @click="addChapter(chapter)">
<span class="material-icons text-lg">add</span>
</button>
</ui-tooltip>
<button class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-white transform hover:scale-110 duration-150" @click="playChapter(chapter)">
<widgets-loading-spinner v-if="selectedChapterId === chapter.id && isLoadingChapter" />
<span v-else-if="selectedChapterId === chapter.id && isPlayingChapter" class="material-icons-outlined text-base">pause</span>
<span v-else class="material-icons-outlined text-base">play_arrow</span>
</button>
<ui-tooltip v-if="chapter.error" :text="chapter.error" direction="left">
<button class="w-7 h-7 rounded-full flex items-center justify-center text-error">
<span class="material-icons-outlined text-lg">error_outline</span>
</button>
</ui-tooltip>
</div>
</div>
</div>
</template>
</div>
<div class="w-full max-w-xl py-4">
<p class="text-lg mb-4 font-semibold py-1">Audio Tracks</p>
<div class="flex text-xs uppercase text-gray-300 font-semibold mb-2">
<div class="flex-grow">Filename</div>
<div class="w-20">Duration</div>
<div class="w-20 text-center">Chapters</div>
</div>
<template v-for="track in audioTracks">
<div :key="track.ino" class="flex items-center py-2" :class="currentTrackIndex === track.index && isPlayingChapter ? 'bg-success bg-opacity-10' : ''">
<div class="flex-grow">
<p class="text-xs truncate max-w-sm">{{ track.metadata.filename }}</p>
</div>
<div class="w-20" style="min-width: 80px">
<p class="text-xs font-mono text-gray-200">{{ track.duration }}</p>
</div>
<div class="w-20 flex justify-center" style="min-width: 80px">
<span v-if="(track.chapters || []).length" class="material-icons text-success text-sm">check</span>
</div>
</div>
</template>
</div>
</div>
<div v-if="saving" class="w-full h-full absolute top-0 left-0 bottom-0 right-0 z-30 bg-black bg-opacity-25 flex items-center justify-center">
<ui-loading-indicator />
</div>
<modals-modal v-model="showFindChaptersModal" name="edit-book" :width="500" :processing="findingChapters">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none">
<p class="font-book text-3xl text-white truncate pointer-events-none">Find Chapters</p>
</div>
</template>
<div class="w-full h-full max-h-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative">
<div v-if="!chapterData" class="flex p-20">
<ui-text-input-with-label v-model="asinInput" label="ASIN" />
<ui-btn small color="primary" class="mt-5 ml-2" @click="findChapters">Find</ui-btn>
</div>
<div v-else class="w-full p-4">
<p class="mb-4">Duration found: {{ chapterData.runtimeLengthSec }}</p>
<div v-if="chapterData.runtimeLengthSec > mediaDuration" class="w-full bg-error bg-opacity-25 p-4 text-center mb-2 rounded border border-white border-opacity-10 text-gray-100 text-sm">
<p>Chapter data invalid duration<br />Your media duration is shorter than duration found</p>
</div>
<div class="flex py-0.5 text-xs font-semibold uppercase text-gray-300 mb-1">
<div class="w-24 px-2">Start</div>
<div class="flex-grow px-2">Title</div>
</div>
<div class="w-full max-h-80 overflow-y-auto my-2">
<div v-for="(chapter, index) in chapterData.chapters" :key="index" class="flex py-0.5 text-xs" :class="chapter.startOffsetSec > mediaDuration ? 'bg-error bg-opacity-20' : chapter.startOffsetSec + chapter.lengthMs / 1000 > mediaDuration ? 'bg-warning bg-opacity-20' : index % 2 === 0 ? 'bg-primary bg-opacity-30' : ''">
<div class="w-24 min-w-24 px-2">
<p class="font-mono">{{ $secondsToTimestamp(chapter.startOffsetSec) }}</p>
</div>
<div class="flex-grow px-2">
<p class="truncate max-w-sm">{{ chapter.title }}</p>
</div>
</div>
</div>
<div class="flex pt-2">
<div class="flex-grow" />
<ui-btn small color="success" @click="applyChapterData">Apply Chapters</ui-btn>
</div>
</div>
</div>
</modals-modal>
</div>
</template>
<script>
export default {
async asyncData({ store, params, app, redirect, route }) {
if (!store.getters['user/getUserCanUpdate']) {
return redirect('/?error=unauthorized')
}
var libraryItem = await app.$axios.$get(`/api/items/${params.id}?expanded=1`).catch((error) => {
console.error('Failed', error)
return false
})
if (!libraryItem) {
console.error('Not found...', params.id)
return redirect('/')
}
if (libraryItem.mediaType != 'book') {
console.error('Invalid media type')
return redirect('/')
}
return {
libraryItem
}
},
data() {
return {
newChapters: [],
selectedChapter: null,
audioEl: null,
isPlayingChapter: false,
isLoadingChapter: false,
currentTrackIndex: 0,
saving: false,
asinInput: null,
findingChapters: false,
showFindChaptersModal: false,
chapterData: null
}
},
computed: {
streamLibraryItem() {
return this.$store.state.streamLibraryItem
},
userToken() {
return this.$store.getters['user/getToken']
},
media() {
return this.libraryItem.media || {}
},
mediaMetadata() {
return this.media.metadata || {}
},
title() {
return this.mediaMetadata.title
},
mediaDuration() {
return this.media.duration
},
chapters() {
return this.media.chapters || []
},
tracks() {
return this.media.tracks || []
},
audioFiles() {
return this.media.audioFiles || []
},
audioTracks() {
return this.audioFiles.filter((af) => !af.exclude && !af.invalid)
},
selectedChapterId() {
return this.selectedChapter ? this.selectedChapter.id : null
}
},
methods: {
editItem() {
this.$store.commit('showEditModal', this.libraryItem)
},
addChapter(chapter) {
console.log('Add chapter', chapter)
const newChapter = {
id: chapter.id + 1,
start: chapter.start,
end: chapter.end,
title: ''
}
this.newChapters.splice(chapter.id + 1, 0, newChapter)
this.checkChapters()
},
removeChapter(chapter) {
this.newChapters = this.newChapters.filter((ch) => ch.id !== chapter.id)
this.checkChapters()
},
checkChapters() {
var previousStart = 0
for (let i = 0; i < this.newChapters.length; i++) {
this.newChapters[i].id = i
this.newChapters[i].start = Number(this.newChapters[i].start)
if (i === 0 && this.newChapters[i].start !== 0) {
this.newChapters[i].error = 'First chapter must start at 0'
} else if (this.newChapters[i].start <= previousStart && i > 0) {
this.newChapters[i].error = 'Invalid start time must be >= previous chapter start time'
} else if (this.newChapters[i].start >= this.mediaDuration) {
this.newChapters[i].error = 'Invalid start time must be < duration'
} else {
this.newChapters[i].error = null
}
previousStart = this.newChapters[i].start
}
},
playChapter(chapter) {
console.log('Play Chapter', chapter.id)
if (this.selectedChapterId === chapter.id) {
console.log('Chapter already playing', this.isLoadingChapter, this.isPlayingChapter)
if (this.isLoadingChapter) return
if (this.isPlayingChapter) {
console.log('Destroying chapter')
this.destroyAudioEl()
return
}
}
if (this.selectedChapterId) {
this.destroyAudioEl()
}
const audioTrack = this.tracks.find((at) => {
return chapter.start >= at.startOffset && chapter.start < at.startOffset + at.duration
})
console.log('audio track', audioTrack)
this.selectedChapter = chapter
this.isLoadingChapter = true
const trackOffset = chapter.start - audioTrack.startOffset
this.playTrackAtTime(audioTrack, trackOffset)
},
playTrackAtTime(audioTrack, trackOffset) {
this.currentTrackIndex = audioTrack.index
const audioEl = this.audioEl || document.createElement('audio')
var src = audioTrack.contentUrl + `?token=${this.userToken}`
if (this.$isDev) {
src = `http://localhost:3333${src}`
}
console.log('src', src)
audioEl.src = src
audioEl.id = 'chapter-audio'
document.body.appendChild(audioEl)
audioEl.addEventListener('loadeddata', () => {
console.log('Audio loaded data', audioEl.duration)
audioEl.currentTime = trackOffset
audioEl.play()
console.log('Playing audio at current time', trackOffset)
})
audioEl.addEventListener('play', () => {
console.log('Audio playing')
this.isLoadingChapter = false
this.isPlayingChapter = true
})
audioEl.addEventListener('ended', () => {
console.log('Audio ended')
const nextTrack = this.tracks.find((t) => t.index === this.currentTrackIndex + 1)
if (nextTrack) {
console.log('Playing next track', nextTrack.index)
this.currentTrackIndex = nextTrack.index
this.playTrackAtTime(nextTrack, 0)
} else {
console.log('No next track')
this.destroyAudioEl()
}
})
this.audioEl = audioEl
},
destroyAudioEl() {
if (!this.audioEl) return
this.audioEl.remove()
this.audioEl = null
this.selectedChapter = null
this.isPlayingChapter = false
this.isLoadingChapter = false
},
saveChapters() {
this.checkChapters()
for (let i = 0; i < this.newChapters.length; i++) {
if (this.newChapters[i].error) {
this.$toast.error('Chapters have errors')
return
}
if (!this.newChapters[i].title) {
this.$toast.error('Chapters must have titles')
return
}
const nextChapter = this.newChapters[i + 1]
if (nextChapter) {
this.newChapters[i].end = nextChapter.start
} else {
this.newChapters[i].end = this.mediaDuration
}
}
this.saving = true
console.log('udpated chapters', this.newChapters)
const payload = {
chapters: this.newChapters
}
this.$axios
.$post(`/api/items/${this.libraryItem.id}/chapters`, payload)
.then((data) => {
this.saving = false
if (data.updated) {
this.$toast.success('Chapters updated')
this.$router.push(`/item/${this.libraryItem.id}`)
} else {
this.$toast.info('No changes needed updating')
}
})
.catch((error) => {
this.saving = false
console.error('Failed to update chapters', error)
this.$toast.error('Failed to update chapters')
})
},
applyChapterData() {
var index = 0
this.newChapters = this.chapterData.chapters
.filter((chap) => chap.startOffsetSec < this.mediaDuration)
.map((chap) => {
var chapEnd = Math.min(this.mediaDuration, (chap.startOffsetMs + chap.lengthMs) / 1000)
return {
id: index++,
start: chap.startOffsetMs / 1000,
end: chapEnd,
title: chap.title
}
})
this.showFindChaptersModal = false
this.chapterData = null
},
findChapters() {
if (!this.asinInput) {
this.$toast.error('Must input an ASIN')
return
}
this.findingChapters = true
this.chapterData = null
this.$axios
.$get(`/api/search/chapters?asin=${this.asinInput}`)
.then((data) => {
this.findingChapters = false
if (data.error) {
this.$toast.error(data.error)
this.showFindChaptersModal = false
} else {
console.log('Chapter data', data)
this.chapterData = data
}
})
.catch((error) => {
this.findingChapters = false
console.error('Failed to get chapter data', error)
this.$toast.error('Failed to find chapters')
this.showFindChaptersModal = false
})
}
},
mounted() {
this.asinInput = this.mediaMetadata.asin || null
this.newChapters = this.chapters.map((c) => ({ ...c }))
if (!this.newChapters.length) {
this.newChapters = [
{
id: 0,
start: 0,
end: this.mediaDuration,
title: ''
}
]
}
}
}
</script>
-5
View File
@@ -39,8 +39,6 @@
<draggable v-model="files" v-bind="dragOptions" class="list-group border border-gray-600" draggable=".item" tag="ul" @start="drag = true" @end="drag = false" @update="draggableUpdate"> <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 relative"> <li v-for="(audio, index) in files" :key="audio.ino" :class="audio.include ? 'item' : 'exclude'" class="w-full list-group-item flex items-center relative">
<div v-if="audiofilesEncoding[audio.ino]" class="absolute top-0 left-0 w-full h-full bg-success bg-opacity-25" />
<div class="font-book text-center px-4 py-1 w-12"> <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>
@@ -91,9 +89,6 @@ export default {
draggable draggable
}, },
async asyncData({ store, params, app, redirect, route }) { async asyncData({ store, params, app, redirect, route }) {
if (!store.state.user.user) {
return redirect(`/login?redirect=${route.path}`)
}
if (!store.getters['user/getUserCanUpdate']) { if (!store.getters['user/getUserCanUpdate']) {
return redirect('/?error=unauthorized') return redirect('/?error=unauthorized')
} }
+108
View File
@@ -0,0 +1,108 @@
<template>
<div id="page-wrapper" class="bg-bg page overflow-y-auto p-8" :class="streamLibraryItem ? 'streaming' : ''">
<div class="max-w-6xl mx-auto">
<div class="flex mb-6">
<div class="w-48 min-w-48">
<div class="w-full h-52">
<covers-author-image :author="author" rounded="0" />
</div>
</div>
<div class="flex-grow px-8">
<div class="flex items-center mb-8">
<h1 class="text-2xl">{{ author.name }}</h1>
<button v-if="userCanUpdate" class="w-8 h-8 rounded-full flex items-center justify-center mx-4 cursor-pointer text-gray-300 hover:text-warning transform hover:scale-125 duration-100" @click="editAuthor">
<span class="material-icons text-base">edit</span>
</button>
</div>
<p v-if="author.description" class="text-white text-opacity-60 uppercase text-xs mb-2">Description</p>
<p class="text-white max-w-3xl text-sm leading-5">{{ author.description }}</p>
</div>
</div>
<div class="py-4">
<widgets-item-slider :items="libraryItems" :bookshelf-view="$constants.BookshelfView.AUTHOR">
<h2 class="text-lg">{{ libraryItems.length }} Books</h2>
</widgets-item-slider>
</div>
<div v-for="series in authorSeries" :key="series.id" class="py-4">
<widgets-item-slider :items="series.items" :bookshelf-view="$constants.BookshelfView.AUTHOR">
<h2 class="text-lg">{{ series.name }}</h2>
<p class="text-white text-opacity-40 text-base px-2">Series</p>
</widgets-item-slider>
</div>
</div>
</div>
</template>
<script>
export default {
async asyncData({ store, app, params, redirect }) {
const author = await app.$axios.$get(`/api/authors/${params.id}?include=items,series`).catch((error) => {
console.error('Failed to get author', error)
return null
})
if (!author) {
return redirect(`/library/${store.state.libraries.currentLibraryId}/authors`)
}
return {
author
}
},
data() {
return {}
},
computed: {
streamLibraryItem() {
return this.$store.state.streamLibraryItem
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
libraryItems() {
return this.author.libraryItems || []
},
authorSeries() {
return this.author.series || []
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
}
},
methods: {
editAuthor() {
this.$store.commit('globals/showEditAuthorModal', this.author)
},
authorUpdated(author) {
if (author.id === this.author.id) {
console.log('Author was updated', author)
this.author = {
...author,
series: this.authorSeries,
libraryItems: this.libraryItems
}
}
},
authorRemoved(author) {
if (author.id === this.author.id) {
console.warn('Author was removed')
this.$router.replace(`/library/${this.currentLibraryId}/authors`)
}
}
},
mounted() {
if (!this.author) this.$router.replace('/')
this.$root.socket.on('author_updated', this.authorUpdated)
this.$root.socket.on('author_removed', this.authorRemoved)
},
beforeDestroy() {
this.$root.socket.off('author_updated', this.authorUpdated)
this.$root.socket.off('author_removed', this.authorRemoved)
}
}
</script>
+1 -1
View File
@@ -1,5 +1,5 @@
<template> <template>
<div id="page-wrapper" class="page p-6 overflow-y-auto relative" :class="streamLibraryItem ? 'streaming' : ''"> <div id="page-wrapper" class="page p-2 md:p-6 overflow-y-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
<app-config-side-nav :is-open.sync="sideDrawerOpen" /> <app-config-side-nav :is-open.sync="sideDrawerOpen" />
<div class="configContent" :class="`page-${currentPage}`"> <div class="configContent" :class="`page-${currentPage}`">
<div v-show="isMobile" class="w-full pb-4 px-2 flex border-b border-white border-opacity-10 mb-2"> <div v-show="isMobile" class="w-full pb-4 px-2 flex border-b border-white border-opacity-10 mb-2">
+2 -2
View File
@@ -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'
+13 -3
View File
@@ -1,16 +1,26 @@
<template> <template>
<div> <div>
<tables-library-libraries-table /> <tables-library-libraries-table @showLibraryModal="setShowLibraryModal" />
<modals-libraries-edit-modal v-model="showLibraryModal" :library="selectedLibrary" />
</div> </div>
</template> </template>
<script> <script>
export default { export default {
data() { data() {
return {} return {
showLibraryModal: false,
selectedLibrary: null
}
}, },
computed: {}, computed: {},
methods: {}, methods: {
setShowLibraryModal(selectedLibrary) {
this.selectedLibrary = selectedLibrary
this.showLibraryModal = true
}
},
mounted() {} mounted() {}
} }
</script> </script>
+6
View File
@@ -67,6 +67,12 @@
<script> <script>
export default { export default {
asyncData({ redirect, store }) {
if (!store.state.libraries.currentLibraryId) {
return redirect('/config')
}
return {}
},
data() { data() {
return { return {
libraryStats: null libraryStats: null
+13 -6
View File
@@ -2,7 +2,7 @@
<div> <div>
<div class="flex justify-center"> <div class="flex justify-center">
<div class="flex p-2"> <div class="flex p-2">
<svg class="h-14 w-14 md:h-18 md:w-18" viewBox="0 0 24 24"> <svg class="hidden sm:block h-14 w-14 lg:h-18 lg:w-18" viewBox="0 0 24 24">
<path <path
fill="currentColor" fill="currentColor"
d="M19 1L14 6V17L19 12.5V1M21 5V18.5C19.9 18.15 18.7 18 17.5 18C15.8 18 13.35 18.65 12 19.5V6C10.55 4.9 8.45 4.5 6.5 4.5C4.55 4.5 2.45 4.9 1 6V20.65C1 20.9 1.25 21.15 1.5 21.15C1.6 21.15 1.65 21.1 1.75 21.1C3.1 20.45 5.05 20 6.5 20C8.45 20 10.55 20.4 12 21.5C13.35 20.65 15.8 20 17.5 20C19.15 20 20.85 20.3 22.25 21.05C22.35 21.1 22.4 21.1 22.5 21.1C22.75 21.1 23 20.85 23 20.6V6C22.4 5.55 21.75 5.25 21 5M10 18.41C8.75 18.09 7.5 18 6.5 18C5.44 18 4.18 18.19 3 18.5V7.13C3.91 6.73 5.14 6.5 6.5 6.5C7.86 6.5 9.09 6.73 10 7.13V18.41Z" d="M19 1L14 6V17L19 12.5V1M21 5V18.5C19.9 18.15 18.7 18 17.5 18C15.8 18 13.35 18.65 12 19.5V6C10.55 4.9 8.45 4.5 6.5 4.5C4.55 4.5 2.45 4.9 1 6V20.65C1 20.9 1.25 21.15 1.5 21.15C1.6 21.15 1.65 21.1 1.75 21.1C3.1 20.45 5.05 20 6.5 20C8.45 20 10.55 20.4 12 21.5C13.35 20.65 15.8 20 17.5 20C19.15 20 20.85 20.3 22.25 21.05C22.35 21.1 22.4 21.1 22.5 21.1C22.75 21.1 23 20.85 23 20.6V6C22.4 5.55 21.75 5.25 21 5M10 18.41C8.75 18.09 7.5 18 6.5 18C5.44 18 4.18 18.19 3 18.5V7.13C3.91 6.73 5.14 6.5 6.5 6.5C7.86 6.5 9.09 6.73 10 7.13V18.41Z"
@@ -15,7 +15,9 @@
</div> </div>
<div class="flex p-2"> <div class="flex p-2">
<span class="material-icons-outlined" style="font-size: 4.1rem">event</span> <div class="hidden sm:block">
<span class="hidden sm:block material-icons-outlined text-5xl lg:text-6xl">event</span>
</div>
<div class="px-1"> <div class="px-1">
<p class="text-4xl md:text-5xl font-bold">{{ totalDaysListened }}</p> <p class="text-4xl md:text-5xl font-bold">{{ totalDaysListened }}</p>
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Days Listened</p> <p class="font-book text-xs md:text-sm text-white text-opacity-80">Days Listened</p>
@@ -23,15 +25,17 @@
</div> </div>
<div class="flex p-2"> <div class="flex p-2">
<span class="material-icons-outlined" style="font-size: 4.1rem">watch_later</span> <div class="hidden sm:block">
<span class="material-icons-outlined text-5xl lg:text-6xl">watch_later</span>
</div>
<div class="px-1"> <div class="px-1">
<p class="text-4xl md:text-5xl font-bold">{{ totalMinutesListening }}</p> <p class="text-4xl md:text-5xl font-bold">{{ totalMinutesListening }}</p>
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Minutes Listening</p> <p class="font-book text-xs md:text-sm text-white text-opacity-80">Minutes Listening</p>
</div> </div>
</div> </div>
</div> </div>
<div class="flex flex-col md:flex-row"> <div class="flex flex-col md:flex-row overflow-hidden max-w-full">
<stats-daily-listening-chart :listening-stats="listeningStats" /> <stats-daily-listening-chart :listening-stats="listeningStats" class="origin-top-left transform scale-75 lg:scale-100" />
<div class="w-80 my-6 mx-auto"> <div class="w-80 my-6 mx-auto">
<h1 class="text-2xl mb-4 font-book">Recent Listening Sessions</h1> <h1 class="text-2xl mb-4 font-book">Recent Listening Sessions</h1>
<p v-if="!mostRecentListeningSessions.length">No Listening Sessions</p> <p v-if="!mostRecentListeningSessions.length">No Listening Sessions</p>
@@ -52,6 +56,8 @@
</template> </template>
</div> </div>
</div> </div>
<stats-heatmap v-if="listeningStats" :days-listening="listeningStats.days" class="my-2" />
</div> </div>
</template> </template>
@@ -59,7 +65,8 @@
export default { export default {
data() { data() {
return { return {
listeningStats: null listeningStats: null,
windowWidth: 0
} }
}, },
watch: { watch: {
+3
View File
@@ -5,6 +5,9 @@
<script> <script>
export default { export default {
asyncData({ redirect, store }) { asyncData({ redirect, store }) {
if (!store.state.libraries.currentLibraryId) {
return redirect('/oops?message=No libraries')
}
redirect(`/library/${store.state.libraries.currentLibraryId}`) redirect(`/library/${store.state.libraries.currentLibraryId}`)
}, },
data() { data() {
+8 -2
View File
@@ -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">,&nbsp;</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">,&nbsp;</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>
@@ -177,6 +177,8 @@
<tables-podcast-episodes-table v-if="isPodcast" :library-item="libraryItem" /> <tables-podcast-episodes-table v-if="isPodcast" :library-item="libraryItem" />
<tables-chapters-table v-if="chapters.length" :library-item="libraryItem" class="mt-6" />
<tables-library-files-table v-if="libraryFiles.length" :is-missing="isMissing" :library-item-id="libraryItemId" :files="libraryFiles" class="mt-6" /> <tables-library-files-table v-if="libraryFiles.length" :is-missing="isMissing" :library-item-id="libraryItemId" :files="libraryFiles" class="mt-6" />
</div> </div>
</div> </div>
@@ -275,6 +277,9 @@ export default {
mediaMetadata() { mediaMetadata() {
return this.media.metadata || {} return this.media.metadata || {}
}, },
chapters() {
return this.media.chapters || []
},
tracks() { tracks() {
return this.media.tracks || [] return this.media.tracks || []
}, },
@@ -378,7 +383,8 @@ export default {
return this.$store.getters['user/getUserCanDownload'] return this.$store.getters['user/getUserCanDownload']
}, },
showRssFeedBtn() { showRssFeedBtn() {
if (!this.showExperimentalFeatures) return false if (!this.rssFeedUrl && !this.podcastEpisodes.length) return false // Cannot open RSS feed with no episodes
// If rss feed is open then show feed url to users otherwise just show to admins // If rss feed is open then show feed url to users otherwise just show to admins
return this.isPodcast && (this.userIsAdminOrUp || this.rssFeedUrl) return this.isPodcast && (this.userIsAdminOrUp || this.rssFeedUrl)
} }
+12 -20
View File
@@ -1,21 +1,13 @@
<template> <template>
<div class="page" :class="streamLibraryItem ? 'streaming' : ''"> <div class="page" :class="streamLibraryItem ? 'streaming' : ''">
<div class="flex h-full"> <app-book-shelf-toolbar is-home />
<app-side-rail class="hidden md:block" /> <div id="bookshelf" class="w-full h-full p-8 overflow-y-auto">
<div class="flex-grow"> <div class="flex flex-wrap justify-center">
<app-book-shelf-toolbar is-home /> <template v-for="author in authors">
<div id="bookshelf" class="w-full h-full p-8 overflow-y-auto"> <cards-author-card :key="author.id" :author="author" :width="160" :height="200" class="p-3" @edit="editAuthor" />
<div class="flex flex-wrap justify-center"> </template>
<template v-for="author in authors">
<nuxt-link :key="author.id" :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(author.id)}`">
<cards-author-card :author="author" :width="160" :height="200" class="p-3" @edit="editAuthor" />
</nuxt-link>
</template>
</div>
</div>
</div> </div>
</div> </div>
<modals-authors-edit-modal v-model="showAuthorModal" :author="selectedAuthor" />
</div> </div>
</template> </template>
@@ -40,9 +32,7 @@ export default {
data() { data() {
return { return {
loading: true, loading: true,
authors: [], authors: []
showAuthorModal: false,
selectedAuthor: null
} }
}, },
computed: { computed: {
@@ -51,6 +41,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 +61,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 +74,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() {
@@ -1,12 +1,7 @@
<template> <template>
<div class="page" :class="streamLibraryItem ? 'streaming' : ''"> <div class="page" :class="streamLibraryItem ? 'streaming' : ''">
<div class="flex h-full"> <app-book-shelf-toolbar :page="id || ''" :view-mode.sync="viewMode" />
<app-side-rail class="hidden md:block" /> <app-lazy-bookshelf :page="id || ''" :view-mode="viewMode" />
<div class="flex-grow">
<app-book-shelf-toolbar :page="id || ''" :view-mode.sync="viewMode" />
<app-lazy-bookshelf :page="id || ''" :view-mode="viewMode" />
</div>
</div>
</div> </div>
</template> </template>
+2 -7
View File
@@ -1,12 +1,7 @@
<template> <template>
<div class="page" :class="streamLibraryItem ? 'streaming' : ''"> <div class="page" :class="streamLibraryItem ? 'streaming' : ''">
<div class="flex h-full"> <app-book-shelf-toolbar is-home />
<app-side-rail class="hidden md:block" /> <app-book-shelf-categorized />
<div class="flex-grow">
<app-book-shelf-toolbar is-home />
<app-book-shelf-categorized />
</div>
</div>
</div> </div>
</template> </template>
@@ -1,38 +1,33 @@
<template> <template>
<div class="page" :class="streamLibraryItem ? 'streaming' : ''"> <div class="page" :class="streamLibraryItem ? 'streaming' : ''">
<div class="flex h-full"> <app-book-shelf-toolbar page="podcast-search" />
<app-side-rail class="hidden md:block" /> <div class="w-full h-full overflow-y-auto p-12 relative">
<div class="flex-grow"> <div class="w-full max-w-3xl mx-auto">
<app-book-shelf-toolbar page="podcast-search" /> <form @submit.prevent="submit" class="flex">
<div class="w-full h-full overflow-y-auto p-12 relative"> <ui-text-input v-model="searchInput" :disabled="processing" placeholder="Enter search term or RSS feed URL" class="flex-grow mr-2" />
<div class="w-full max-w-3xl mx-auto"> <ui-btn type="submit" :disabled="processing">Submit</ui-btn>
<form @submit.prevent="submit" class="flex"> </form>
<ui-text-input v-model="searchInput" :disabled="processing" placeholder="Enter search term or RSS feed URL" class="flex-grow mr-2" /> </div>
<ui-btn type="submit" :disabled="processing">Submit</ui-btn>
</form>
</div>
<div class="w-full max-w-3xl mx-auto py-4"> <div class="w-full max-w-3xl mx-auto py-4">
<p v-if="termSearched && !results.length && !processing" class="text-center text-xl">No podcasts found</p> <p v-if="termSearched && !results.length && !processing" class="text-center text-xl">No podcasts found</p>
<template v-for="podcast in results"> <template v-for="podcast in results">
<div :key="podcast.id" class="flex p-1 hover:bg-primary hover:bg-opacity-25 cursor-pointer" @click="selectPodcast(podcast)"> <div :key="podcast.id" class="flex p-1 hover:bg-primary hover:bg-opacity-25 cursor-pointer" @click="selectPodcast(podcast)">
<div class="w-24 min-w-24 h-24 bg-primary"> <div class="w-24 min-w-24 h-24 bg-primary">
<img v-if="podcast.cover" :src="podcast.cover" class="h-full w-full" /> <img v-if="podcast.cover" :src="podcast.cover" class="h-full w-full" />
</div> </div>
<div class="flex-grow pl-4 max-w-2xl"> <div class="flex-grow pl-4 max-w-2xl">
<a :href="podcast.pageUrl" class="text-lg text-gray-200 hover:underline" target="_blank" @click.stop>{{ podcast.title }}</a> <a :href="podcast.pageUrl" class="text-lg text-gray-200 hover:underline" target="_blank" @click.stop>{{ podcast.title }}</a>
<p class="text-base text-gray-300 whitespace-nowrap truncate">by {{ podcast.artistName }}</p> <p class="text-base text-gray-300 whitespace-nowrap truncate">by {{ podcast.artistName }}</p>
<p class="text-xs text-gray-400 leading-5">{{ podcast.genres.join(', ') }}</p> <p class="text-xs text-gray-400 leading-5">{{ podcast.genres.join(', ') }}</p>
<p class="text-xs text-gray-400 leading-5">{{ podcast.trackCount }} Episodes</p> <p class="text-xs text-gray-400 leading-5">{{ podcast.trackCount }} Episodes</p>
</div> </div>
</div>
</template>
</div> </div>
</template>
</div>
<div v-show="processing" class="absolute top-0 left-0 w-full h-full flex items-center justify-center bg-black bg-opacity-25 z-40"> <div v-show="processing" class="absolute top-0 left-0 w-full h-full flex items-center justify-center bg-black bg-opacity-25 z-40">
<ui-loading-indicator /> <ui-loading-indicator />
</div>
</div>
</div> </div>
</div> </div>
+4 -18
View File
@@ -1,17 +1,9 @@
<template> <template>
<div class="page" :class="streamLibraryItem ? 'streaming' : ''"> <div class="page" :class="streamLibraryItem ? 'streaming' : ''">
<div class="flex h-full"> <app-book-shelf-toolbar is-home page="search" :search-query="query" />
<app-side-rail class="hidden md:block" /> <app-book-shelf-categorized v-if="hasResults" ref="bookshelf" search :results="results" />
<div class="flex-grow"> <div v-else class="w-full py-16">
<app-book-shelf-toolbar is-home page="search" :search-query="query" /> <p class="text-xl text-center">No Search results for "{{ query }}"</p>
<app-book-shelf-categorized v-if="hasResults" ref="bookshelf" search :results="results" />
<div v-else class="w-full py-16">
<p class="text-xl text-center">No Search results for "{{ query }}"</p>
<div class="flex justify-center">
<ui-btn class="w-52 my-4" @click="back">Back</ui-btn>
</div>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -79,12 +71,6 @@ export default {
this.$refs.bookshelf.setShelvesFromSearch() this.$refs.bookshelf.setShelvesFromSearch()
} }
}) })
},
async back() {
var popped = await this.$store.dispatch('popRoute')
if (popped) this.$store.commit('setIsRoutingBack', true)
var backTo = popped || '/'
this.$router.push(backTo)
} }
}, },
mounted() {}, mounted() {},
+2 -7
View File
@@ -1,12 +1,7 @@
<template> <template>
<div class="page" :class="streamLibraryItem ? 'streaming' : ''"> <div class="page" :class="streamLibraryItem ? 'streaming' : ''">
<div class="flex h-full"> <app-book-shelf-toolbar :selected-series="series" />
<app-side-rail class="hidden md:block" /> <app-lazy-bookshelf page="series-books" :series-id="seriesId" />
<div class="flex-grow">
<app-book-shelf-toolbar :selected-series="series" />
<app-lazy-bookshelf page="series-books" :series-id="seriesId" />
</div>
</div>
</div> </div>
</template> </template>
+126 -28
View File
@@ -1,7 +1,29 @@
<template> <template>
<div class="w-full h-screen bg-bg"> <div class="w-full h-screen bg-bg">
<div class="w-full flex h-1/2 items-center justify-center"> <div class="w-full flex h-full items-center justify-center">
<div class="w-full max-w-md border border-opacity-0 rounded-xl px-8 pb-8 pt-4"> <div v-if="criticalError" class="w-full max-w-md rounded border border-error border-opacity-25 bg-error bg-opacity-10 p-4">
<p class="text-center text-lg font-semibold">Server could not be reached</p>
</div>
<div v-else-if="showInitScreen" class="w-full max-w-lg px-4 md:px-8 pb-8 pt-4">
<p class="text-3xl text-white text-center mb-4">Initial Server Setup</p>
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
<form @submit.prevent="submitServerSetup">
<p class="text-lg font-semibold mb-2 pl-1 text-center">Create Root User</p>
<ui-text-input-with-label v-model="newRoot.username" label="Username" :disabled="processing" class="w-full mb-3 text-sm" />
<ui-text-input-with-label v-model="newRoot.password" label="Password" type="password" :disabled="processing" class="w-full mb-3 text-sm" />
<ui-text-input-with-label v-model="confirmPassword" label="Confirm Password" type="password" :disabled="processing" class="w-full mb-3 text-sm" />
<p class="text-lg font-semibold mt-6 mb-2 pl-1 text-center">Directory Paths</p>
<ui-text-input-with-label v-model="ConfigPath" label="Config Path" disabled class="w-full mb-3 text-sm" />
<ui-text-input-with-label v-model="MetadataPath" label="Metadata Path" disabled class="w-full mb-3 text-sm" />
<div class="w-full flex justify-end py-3">
<ui-btn type="submit" :disabled="processing" color="primary" class="leading-none">{{ processing ? 'Initializing...' : 'Submit' }}</ui-btn>
</div>
</form>
</div>
<div v-else-if="isInit" class="w-full max-w-md px-8 pb-8 pt-4 -mt-40">
<p class="text-3xl text-white text-center mb-4">Login</p> <p class="text-3xl text-white text-center mb-4">Login</p>
<div class="w-full h-px bg-white bg-opacity-10 my-4" /> <div class="w-full h-px bg-white bg-opacity-10 my-4" />
<p v-if="error" class="text-error text-center py-2">{{ error }}</p> <p v-if="error" class="text-error text-center py-2">{{ error }}</p>
@@ -11,8 +33,8 @@
<label class="text-xs text-gray-300 uppercase">Password</label> <label class="text-xs text-gray-300 uppercase">Password</label>
<ui-text-input v-model="password" type="password" :disabled="processing" class="w-full mb-3" /> <ui-text-input v-model="password" type="password" :disabled="processing" class="w-full mb-3" />
<div class="w-full flex justify-end"> <div class="w-full flex justify-end py-3">
<button type="submit" :disabled="processing" class="bg-blue-600 hover:bg-blue-800 px-8 py-1 mt-3 rounded-md text-white text-center transition duration-300 ease-in-out focus:outline-none">{{ processing ? 'Checking...' : 'Submit' }}</button> <ui-btn type="submit" :disabled="processing" color="primary" class="leading-none">{{ processing ? 'Checking...' : 'Submit' }}</ui-btn>
</div> </div>
</form> </form>
</div> </div>
@@ -26,15 +48,33 @@ export default {
data() { data() {
return { return {
error: null, error: null,
criticalError: null,
processing: false, processing: false,
username: '', username: '',
password: null password: null,
showInitScreen: false,
isInit: false,
newRoot: {
username: 'root',
password: ''
},
confirmPassword: '',
ConfigPath: '',
MetadataPath: ''
} }
}, },
watch: { watch: {
user(newVal) { user(newVal) {
if (newVal) { if (newVal) {
if (this.$route.query.redirect) { if (!this.$store.state.libraries.currentLibraryId) {
// No libraries available to this user
if (this.$store.getters['user/getIsRoot']) {
// If root user go to config/libraries
this.$router.replace('/config/libraries')
} else {
this.$router.replace('/oops?message=No libraries available')
}
} else if (this.$route.query.redirect) {
this.$router.replace(this.$route.query.redirect) this.$router.replace(this.$route.query.redirect)
} else { } else {
this.$router.replace(`/library/${this.$store.state.libraries.currentLibraryId}`) this.$router.replace(`/library/${this.$store.state.libraries.currentLibraryId}`)
@@ -48,6 +88,42 @@ export default {
} }
}, },
methods: { methods: {
async submitServerSetup() {
if (!this.newRoot.username || !this.newRoot.username.trim()) {
this.$toast.error('Must enter a root username')
return
}
if (this.newRoot.password !== this.confirmPassword) {
this.$toast.error('Password mismatch')
return
}
if (!this.newRoot.password) {
if (!confirm('Are you sure you want to create the root user with no password?')) {
return
}
}
this.processing = true
const payload = {
newRoot: { ...this.newRoot }
}
var success = await this.$axios
.$post('/init', payload)
.then(() => true)
.catch((error) => {
console.error('Failed', error.response)
const errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
this.$toast.error(errorMsg)
return false
})
if (!success) {
this.processing = false
return
}
location.reload()
},
setUser({ user, userDefaultLibraryId, serverSettings }) { setUser({ user, userDefaultLibraryId, serverSettings }) {
this.$store.commit('setServerSettings', serverSettings) this.$store.commit('setServerSettings', serverSettings)
@@ -81,32 +157,54 @@ export default {
this.processing = false this.processing = false
}, },
checkAuth() { checkAuth() {
if (localStorage.getItem('token')) { var token = localStorage.getItem('token')
var token = localStorage.getItem('token') if (!token) return false
if (token) { this.processing = true
this.processing = true
this.$axios return this.$axios
.$post('/api/authorize', null, { .$post('/api/authorize', null, {
headers: { headers: {
Authorization: `Bearer ${token}` Authorization: `Bearer ${token}`
} }
}) })
.then((res) => { .then((res) => {
this.setUser(res) this.setUser(res)
this.processing = false this.processing = false
}) return true
.catch((error) => { })
console.error('Authorize error', error) .catch((error) => {
this.processing = false console.error('Authorize error', error)
}) this.processing = false
} return false
} })
},
checkStatus() {
this.processing = true
this.$axios
.$get('/status')
.then((res) => {
this.processing = false
this.isInit = res.isInit
this.showInitScreen = !res.isInit
if (this.showInitScreen) {
this.ConfigPath = res.ConfigPath || ''
this.MetadataPath = res.MetadataPath || ''
}
})
.catch((error) => {
console.error('Status check failed', error)
this.processing = false
this.criticalError = 'Status check failed'
})
} }
}, },
mounted() { async mounted() {
this.checkAuth() if (localStorage.getItem('token')) {
var userfound = await this.checkAuth()
if (userfound) return // if valid user no need to check status
}
this.checkStatus()
} }
} }
</script> </script>
+7
View File
@@ -86,6 +86,13 @@ export default {
uploadFinished: false uploadFinished: false
} }
}, },
watch: {
selectedLibrary(newVal) {
if (newVal && !this.selectedFolderId) {
this.setDefaultFolder()
}
}
},
computed: { computed: {
inputAccept() { inputAccept() {
var extensions = [] var extensions = []
+2 -1
View File
@@ -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 = {
+15 -5
View File
@@ -23,6 +23,11 @@ Vue.prototype.$addDaysToToday = (daysToAdd) => {
if (!date || !isDate(date)) return null if (!date || !isDate(date)) return null
return date return date
} }
Vue.prototype.$addDaysToDate = (jsdate, daysToAdd) => {
var date = addDays(jsdate, daysToAdd)
if (!date || !isDate(date)) return null
return date
}
Vue.prototype.$bytesPretty = (bytes, decimals = 2) => { Vue.prototype.$bytesPretty = (bytes, decimals = 2) => {
if (isNaN(bytes) || bytes == 0) { if (isNaN(bytes) || bytes == 0) {
@@ -35,17 +40,20 @@ Vue.prototype.$bytesPretty = (bytes, decimals = 2) => {
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i] return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
} }
Vue.prototype.$elapsedPretty = (seconds) => { Vue.prototype.$elapsedPretty = (seconds, useFullNames = false) => {
if (seconds < 60) {
return `${Math.floor(seconds)} sec${useFullNames ? 'onds' : ''}`
}
var minutes = Math.floor(seconds / 60) var minutes = Math.floor(seconds / 60)
if (minutes < 70) { if (minutes < 70) {
return `${minutes} min` return `${minutes} min${useFullNames ? `ute${minutes === 1 ? '' : 's'}` : ''}`
} }
var hours = Math.floor(minutes / 60) var hours = Math.floor(minutes / 60)
minutes -= hours * 60 minutes -= hours * 60
if (!minutes) { if (!minutes) {
return `${hours} hr` return `${hours} ${useFullNames ? 'hours' : 'hr'}`
} }
return `${hours} hr ${minutes} min` return `${hours} ${useFullNames ? `hour${hours === 1 ? '' : 's'}` : 'hr'} ${minutes} ${useFullNames ? `minute${minutes === 1 ? '' : 's'}` : 'min'}`
} }
Vue.prototype.$secondsToTimestamp = (seconds) => { Vue.prototype.$secondsToTimestamp = (seconds) => {
@@ -106,10 +114,11 @@ Vue.prototype.$calculateTextSize = (text, styles = {}) => {
} }
} }
Vue.prototype.$sanitizeFilename = (input, replacement = '') => { Vue.prototype.$sanitizeFilename = (input, colonReplacement = ' - ') => {
if (typeof input !== 'string') { if (typeof input !== 'string') {
return false return false
} }
var replacement = ''
var illegalRe = /[\/\?<>\\:\*\|"]/g; var illegalRe = /[\/\?<>\\:\*\|"]/g;
var controlRe = /[\x00-\x1f\x80-\x9f]/g; var controlRe = /[\x00-\x1f\x80-\x9f]/g;
var reservedRe = /^\.+$/; var reservedRe = /^\.+$/;
@@ -117,6 +126,7 @@ Vue.prototype.$sanitizeFilename = (input, replacement = '') => {
var windowsTrailingRe = /[\. ]+$/; var windowsTrailingRe = /[\. ]+$/;
var sanitized = input var sanitized = input
.replace(':', colonReplacement) // Replace first occurrence of a colon
.replace(illegalRe, replacement) .replace(illegalRe, replacement)
.replace(controlRe, replacement) .replace(controlRe, replacement)
.replace(reservedRe, replacement) .replace(reservedRe, replacement)
@@ -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.
+96
View File
@@ -0,0 +1,96 @@
-------------------------------
UBUNTU FONT LICENCE Version 1.0
-------------------------------
PREAMBLE
This licence allows the licensed fonts to be used, studied, modified and
redistributed freely. The fonts, including any derivative works, can be
bundled, embedded, and redistributed provided the terms of this licence
are met. The fonts and derivatives, however, cannot be released under
any other licence. The requirement for fonts to remain under this
licence does not require any document created using the fonts or their
derivatives to be published under this licence, as long as the primary
purpose of the document is not to be a vehicle for the distribution of
the fonts.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this licence and clearly marked as such. This may
include source files, build scripts and documentation.
"Original Version" refers to the collection of Font Software components
as received under this licence.
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to
a new environment.
"Copyright Holder(s)" refers to all individuals and companies who have a
copyright ownership of the Font Software.
"Substantially Changed" refers to Modified Versions which can be easily
identified as dissimilar to the Font Software by users of the Font
Software comparing the Original Version with the Modified Version.
To "Propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification and with or without charging
a redistribution fee), making available to the public, and in some
countries other activities as well.
PERMISSION & CONDITIONS
This licence does not grant any rights under trademark law and all such
rights are reserved.
Permission is hereby granted, free of charge, to any person obtaining a
copy of the Font Software, to propagate the Font Software, subject to
the below conditions:
1) Each copy of the Font Software must contain the above copyright
notice and this licence. These can be included either as stand-alone
text files, human-readable headers or in the appropriate machine-
readable metadata fields within text or binary files as long as those
fields can be easily viewed by the user.
2) The font name complies with the following:
(a) The Original Version must retain its name, unmodified.
(b) Modified Versions which are Substantially Changed must be renamed to
avoid use of the name of the Original Version or similar names entirely.
(c) Modified Versions which are not Substantially Changed must be
renamed to both (i) retain the name of the Original Version and (ii) add
additional naming elements to distinguish the Modified Version from the
Original Version. The name of such Modified Versions must be the name of
the Original Version, with "derivative X" where X represents the name of
the new work, appended to that name.
3) The name(s) of the Copyright Holder(s) and any contributor to the
Font Software shall not be used to promote, endorse or advertise any
Modified Version, except (i) as required by this licence, (ii) to
acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with
their explicit written permission.
4) The Font Software, modified or unmodified, in part or in whole, must
be distributed entirely under this licence, and must not be distributed
under any other licence. The requirement for fonts to remain under this
licence does not affect any document created using the Font Software,
except any version of the Font Software extracted from a document
created using the Font Software may only be distributed under this
licence.
TERMINATION
This licence becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
DEALINGS IN THE FONT SOFTWARE.
+12
View File
@@ -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
}, },
+1 -18
View File
@@ -15,8 +15,6 @@ export const state = () => ({
selectedLibraryItems: [], selectedLibraryItems: [],
processingBatch: false, processingBatch: false,
previousPath: '/', previousPath: '/',
routeHistory: [],
isRoutingBack: false,
showExperimentalFeatures: false, showExperimentalFeatures: false,
backups: [], backups: [],
bookshelfBookIds: [], bookshelfBookIds: [],
@@ -33,7 +31,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,
@@ -74,15 +72,6 @@ export const actions = {
return false return false
}) })
}, },
popRoute({ commit, state }) {
if (!state.routeHistory.length) {
return null
}
var _history = [...state.routeHistory]
var last = _history.pop()
commit('setRouteHistory', _history)
return last
},
setBookshelfTexture({ commit, state }, img) { setBookshelfTexture({ commit, state }, img) {
let root = document.documentElement; let root = document.documentElement;
commit('setBookshelfTexture', img) commit('setBookshelfTexture', img)
@@ -94,12 +83,6 @@ export const mutations = {
setBookshelfBookIds(state, val) { setBookshelfBookIds(state, val) {
state.bookshelfBookIds = val || [] state.bookshelfBookIds = val || []
}, },
setRouteHistory(state, val) {
state.routeHistory = val
},
setIsRoutingBack(state, val) {
state.isRoutingBack = val
},
setPreviousPath(state, val) { setPreviousPath(state, val) {
state.previousPath = val state.previousPath = val
}, },
+60 -26
View File
@@ -2,7 +2,7 @@ export const state = () => ({
libraries: [], libraries: [],
lastLoad: 0, lastLoad: 0,
listeners: [], listeners: [],
currentLibraryId: 'main', currentLibraryId: null,
folders: [], folders: [],
issues: 0, issues: 0,
folderLastUpdate: 0, folderLastUpdate: 0,
@@ -206,11 +206,11 @@ export const mutations = {
setLibraryFilterData(state, filterData) { setLibraryFilterData(state, filterData) {
state.filterData = filterData state.filterData = filterData
}, },
updateFilterDataWithAudiobook(state, audiobook) { updateFilterDataWithItem(state, libraryItem) {
if (!audiobook || !audiobook.book || !state.filterData) return if (!libraryItem || !state.filterData) return
if (state.currentLibraryId !== audiobook.libraryId) return if (state.currentLibraryId !== libraryItem.libraryId) return
/* /*
var filterdata = { var data = {
authors: [], authors: [],
genres: [], genres: [],
tags: [], tags: [],
@@ -219,36 +219,70 @@ export const mutations = {
languages: [] languages: []
} }
*/ */
var mediaMetadata = libraryItem.media.metadata
if (audiobook.book.authorFL) { // Add/update book authors
audiobook.book.authorFL.split(', ').forEach((author) => { if (mediaMetadata.authors && mediaMetadata.authors.length) {
if (author && !state.filterData.authors.includes(author)) { mediaMetadata.authors.forEach((author) => {
var indexOf = state.filterData.authors.findIndex(au => au.id === author.id)
if (indexOf >= 0) {
state.filterData.authors.splice(indexOf, 1, author)
} else {
state.filterData.authors.push(author) state.filterData.authors.push(author)
state.filterData.authors.sort((a, b) => (a.name || '').localeCompare((b.name || '')))
} }
}) })
} }
if (audiobook.book.narratorFL) {
audiobook.book.narratorFL.split(', ').forEach((narrator) => { // Add/update series
if (narrator && !state.filterData.narrators.includes(narrator)) { if (mediaMetadata.series && mediaMetadata.series.length) {
mediaMetadata.series.forEach((series) => {
var indexOf = state.filterData.series.findIndex(se => se.id === series.id)
if (indexOf >= 0) {
state.filterData.series.splice(indexOf, 1, { id: series.id, name: series.name })
} else {
state.filterData.series.push({ id: series.id, name: series.name })
state.filterData.series.sort((a, b) => (a.name || '').localeCompare((b.name || '')))
}
})
}
// Add genres
if (mediaMetadata.genres && mediaMetadata.genres.length) {
mediaMetadata.genres.forEach((genre) => {
if (!state.filterData.genres.includes(genre)) {
state.filterData.genres.push(genre)
state.filterData.genres.sort((a, b) => a.localeCompare(b))
}
})
}
// Add tags
if (libraryItem.media.tags && libraryItem.media.tags.length) {
libraryItem.media.tags.forEach((tag) => {
if (!state.filterData.tags.includes(tag)) {
state.filterData.tags.push(tag)
state.filterData.tags.sort((a, b) => a.localeCompare(b))
}
})
}
// Add narrators
if (mediaMetadata.narrators && mediaMetadata.narrators.length) {
mediaMetadata.narrators.forEach((narrator) => {
if (!state.filterData.narrators.includes(narrator)) {
state.filterData.narrators.push(narrator) state.filterData.narrators.push(narrator)
state.filterData.narrators.sort((a, b) => a.localeCompare(b))
} }
}) })
} }
if (audiobook.book.series && !state.filterData.series.includes(audiobook.book.series)) {
state.filterData.series.push(audiobook.book.series) // Add language
} if (mediaMetadata.language) {
if (audiobook.tags && audiobook.tags.length) { if (!state.filterData.languages.includes(mediaMetadata.language)) {
audiobook.tags.forEach((tag) => { state.filterData.languages.push(mediaMetadata.language)
if (tag && !state.filterData.tags.includes(tag)) state.filterData.tags.push(tag) state.filterData.languages.sort((a, b) => a.localeCompare(b))
}) }
}
if (audiobook.book.genres && audiobook.book.genres.length) {
audiobook.book.genres.forEach((genre) => {
if (genre && !state.filterData.genres.includes(genre)) state.filterData.genres.push(genre)
})
}
if (audiobook.book.language && !state.filterData.languages.includes(audiobook.book.language)) {
state.filterData.languages.push(audiobook.book.language)
} }
} }
} }
+5 -5
View File
@@ -1,4 +1,4 @@
if(process.env.TOKEN_SECRET == null) process.env.TOKEN_SECRET = '09f26e402586e2faa8da4c98a35f1b20d6b033c6097befa8be3486a829587fe2f90a832bd3ff9d42710a4da095a2ce285b009f0c3730cd9b8e1af3eb84df6611' if (process.env.TOKEN_SECRET == null) process.env.TOKEN_SECRET = '09f26e402586e2faa8da4c98a35f1b20d6b033c6097befa8be3486a829587fe2f90a832bd3ff9d42710a4da095a2ce285b009f0c3730cd9b8e1af3eb84df6611'
const server = require('./server/Server') const server = require('./server/Server')
global.appRoot = __dirname global.appRoot = __dirname
@@ -9,20 +9,20 @@ if (isDev) {
process.env.PORT = devEnv.Port process.env.PORT = devEnv.Port
process.env.CONFIG_PATH = devEnv.ConfigPath process.env.CONFIG_PATH = devEnv.ConfigPath
process.env.METADATA_PATH = devEnv.MetadataPath process.env.METADATA_PATH = devEnv.MetadataPath
process.env.AUDIOBOOK_PATH = devEnv.AudiobookPath
process.env.FFMPEG_PATH = devEnv.FFmpegPath process.env.FFMPEG_PATH = devEnv.FFmpegPath
process.env.FFPROBE_PATH = devEnv.FFProbePath process.env.FFPROBE_PATH = devEnv.FFProbePath
process.env.SOURCE = 'local'
} }
const PORT = process.env.PORT || 80 const PORT = process.env.PORT || 80
const HOST = process.env.HOST || '0.0.0.0' const HOST = process.env.HOST || '0.0.0.0'
const CONFIG_PATH = process.env.CONFIG_PATH || '/config' const CONFIG_PATH = process.env.CONFIG_PATH || '/config'
const AUDIOBOOK_PATH = process.env.AUDIOBOOK_PATH || '/audiobooks'
const METADATA_PATH = process.env.METADATA_PATH || '/metadata' const METADATA_PATH = process.env.METADATA_PATH || '/metadata'
const UID = process.env.AUDIOBOOKSHELF_UID || 99 const UID = process.env.AUDIOBOOKSHELF_UID || 99
const GID = process.env.AUDIOBOOKSHELF_GID || 100 const GID = process.env.AUDIOBOOKSHELF_GID || 100
const SOURCE = process.env.SOURCE || 'docker'
console.log('Config', CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) console.log('Config', CONFIG_PATH, METADATA_PATH)
const Server = new server(PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) const Server = new server(SOURCE, PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH)
Server.start() Server.start()
+366 -229
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.0.10", "version": "2.0.14",
"description": "Self-hosted audiobook server for managing and playing audiobooks", "description": "Self-hosted audiobook and podcast server",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"dev": "node index.js", "dev": "node index.js",
+6 -7
View File
@@ -1,16 +1,16 @@
const optionDefinitions = [ const optionDefinitions = [
{ name: 'config', alias: 'c', type: String }, { name: 'config', alias: 'c', type: String },
{ name: 'audiobooks', alias: 'a', type: String },
{ name: 'metadata', alias: 'm', type: String }, { name: 'metadata', alias: 'm', type: String },
{ name: 'port', alias: 'p', type: String }, { name: 'port', alias: 'p', type: String },
{ name: 'host', alias: 'h', type: String } { name: 'host', alias: 'h', type: String },
{ name: 'source', alias: 's', type: String }
] ]
const commandLineArgs = require('command-line-args') const commandLineArgs = require('command-line-args')
const options = commandLineArgs(optionDefinitions) const options = commandLineArgs(optionDefinitions)
const Path = require('path') const Path = require('path')
if(process.env.TOKEN_SECRET == null) process.env.TOKEN_SECRET = '09f26e402586e2faa8da4c98a35f1b20d6b033c6097befa8be3486a829587fe2f90a832bd3ff9d42710a4da095a2ce285b009f0c3730cd9b8e1af3eb84df6611' if (process.env.TOKEN_SECRET == null) process.env.TOKEN_SECRET = '09f26e402586e2faa8da4c98a35f1b20d6b033c6097befa8be3486a829587fe2f90a832bd3ff9d42710a4da095a2ce285b009f0c3730cd9b8e1af3eb84df6611'
process.env.NODE_ENV = 'production' process.env.NODE_ENV = 'production'
const server = require('./server/Server') const server = require('./server/Server')
@@ -18,18 +18,17 @@ const server = require('./server/Server')
global.appRoot = __dirname global.appRoot = __dirname
var inputConfig = options.config ? Path.resolve(options.config) : null var inputConfig = options.config ? Path.resolve(options.config) : null
var inputAudiobook = options.audiobooks ? Path.resolve(options.audiobooks) : null
var inputMetadata = options.metadata ? Path.resolve(options.metadata) : null var inputMetadata = options.metadata ? Path.resolve(options.metadata) : null
const PORT = options.port || process.env.PORT || 3333 const PORT = options.port || process.env.PORT || 3333
const HOST = options.host || process.env.HOST || "0.0.0.0" const HOST = options.host || process.env.HOST || "0.0.0.0"
const CONFIG_PATH = inputConfig || process.env.CONFIG_PATH || Path.resolve('config') const CONFIG_PATH = inputConfig || process.env.CONFIG_PATH || Path.resolve('config')
const AUDIOBOOK_PATH = inputAudiobook || process.env.AUDIOBOOK_PATH || Path.resolve('audiobooks')
const METADATA_PATH = inputMetadata || process.env.METADATA_PATH || Path.resolve('metadata') const METADATA_PATH = inputMetadata || process.env.METADATA_PATH || Path.resolve('metadata')
const UID = 99 const UID = 99
const GID = 100 const GID = 100
const SOURCE = options.source || 'debian'
console.log(process.env.NODE_ENV, 'Config', CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) console.log(process.env.NODE_ENV, 'Config', CONFIG_PATH, METADATA_PATH)
const Server = new server(PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) const Server = new server(SOURCE, PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH)
Server.start() Server.start()
+4 -4
View File
@@ -64,7 +64,7 @@ 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:latest
docker run -d \ docker run -d \
-e AUDIOBOOKSHELF_UID=99 \ -e AUDIOBOOKSHELF_UID=99 \
@@ -75,14 +75,14 @@ docker run -d \
-v </path/to/config>:/config \ -v </path/to/config>:/config \
-v </path/to/metadata>:/metadata \ -v </path/to/metadata>:/metadata \
--name audiobookshelf \ --name audiobookshelf \
ghcr.io/advplyr/audiobookshelf ghcr.io/advplyr/audiobookshelf:latest
``` ```
### Docker Update ### Docker Update
```bash ```bash
docker stop audiobookshelf docker stop audiobookshelf
docker pull ghcr.io/advplyr/audiobookshelf docker pull ghcr.io/advplyr/audiobookshelf:latest
docker start audiobookshelf docker start audiobookshelf
``` ```
@@ -92,7 +92,7 @@ docker start audiobookshelf
### docker-compose.yml ### ### docker-compose.yml ###
services: services:
audiobookshelf: audiobookshelf:
image: ghcr.io/advplyr/audiobookshelf image: ghcr.io/advplyr/audiobookshelf:latest
environment: environment:
- AUDIOBOOKSHELF_UID=99 - AUDIOBOOKSHELF_UID=99
- AUDIOBOOKSHELF_GID=100 - AUDIOBOOKSHELF_GID=100
-8
View File
@@ -17,14 +17,6 @@ class Auth {
return this.db.users return this.db.users
} }
init() {
var root = this.users.find(u => u.type === 'root')
if (!root) {
Logger.fatal('No Root User', this.users)
throw new Error('No Root User')
}
}
cors(req, res, next) { cors(req, res, next) {
res.header('Access-Control-Allow-Origin', '*') res.header('Access-Control-Allow-Origin', '*')
res.header("Access-Control-Allow-Methods", 'GET, POST, PATCH, PUT, DELETE, OPTIONS') res.header("Access-Control-Allow-Methods", 'GET, POST, PATCH, PUT, DELETE, OPTIONS')
+29 -39
View File
@@ -46,6 +46,10 @@ class Db {
this.previousVersion = null this.previousVersion = null
} }
get hasRootUser() {
return this.users.some(u => u.id === 'root')
}
getEntityDb(entityName) { getEntityDb(entityName) {
if (entityName === 'user') return this.usersDb if (entityName === 'user') return this.usersDb
else if (entityName === 'session') return this.sessionsDb else if (entityName === 'session') return this.sessionsDb
@@ -70,33 +74,6 @@ class Db {
return null return null
} }
getDefaultUser(token) {
return new User({
id: 'root',
type: 'root',
username: 'root',
pash: '',
stream: null,
token,
isActive: true,
createdAt: Date.now()
})
}
getDefaultLibrary() {
var defaultLibrary = new Library()
defaultLibrary.setData({
id: 'main',
name: 'Main',
folder: { // Generates default folder
id: 'audiobooks',
fullPath: global.AudiobookPath,
libraryId: 'main'
}
})
return defaultLibrary
}
reinit() { reinit() {
this.libraryItemsDb = new njodb.Database(this.LibraryItemsPath) this.libraryItemsDb = new njodb.Database(this.LibraryItemsPath)
this.usersDb = new njodb.Database(this.UsersPath) this.usersDb = new njodb.Database(this.UsersPath)
@@ -123,23 +100,36 @@ class Db {
}) })
} }
createRootUser(username, pash, token) {
const newRoot = new User({
id: 'root',
type: 'root',
username,
pash,
token,
isActive: true,
createdAt: Date.now()
})
return this.insertEntity('user', newRoot)
}
async init() { async init() {
await this.load() await this.load()
// Insert Defaults // Insert Defaults
var rootUser = this.users.find(u => u.type === 'root') // var rootUser = this.users.find(u => u.type === 'root')
if (!rootUser) { // if (!rootUser) {
var token = await jwt.sign({ userId: 'root' }, process.env.TOKEN_SECRET) // var token = await jwt.sign({ userId: 'root' }, process.env.TOKEN_SECRET)
Logger.debug('Generated default token', token) // Logger.debug('Generated default token', token)
Logger.info('[Db] Root user created') // Logger.info('[Db] Root user created')
await this.insertEntity('user', this.getDefaultUser(token)) // await this.insertEntity('user', this.getDefaultUser(token))
} else { // } else {
Logger.info(`[Db] Root user exists, pw: ${rootUser.hasPw}`) // Logger.info(`[Db] Root user exists, pw: ${rootUser.hasPw}`)
} // }
if (!this.libraries.length) { // if (!this.libraries.length) {
await this.insertEntity('library', this.getDefaultLibrary()) // await this.insertEntity('library', this.getDefaultLibrary())
} // }
if (!this.serverSettings) { if (!this.serverSettings) {
this.serverSettings = new ServerSettings() this.serverSettings = new ServerSettings()
+48 -19
View File
@@ -34,18 +34,18 @@ const AudioMetadataMangaer = require('./managers/AudioMetadataManager')
const RssFeedManager = require('./managers/RssFeedManager') const RssFeedManager = require('./managers/RssFeedManager')
class Server { class Server {
constructor(PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) { constructor(SOURCE, PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH) {
this.Source = SOURCE
this.Port = PORT this.Port = PORT
this.Host = HOST this.Host = HOST
global.Uid = isNaN(UID) ? 0 : Number(UID) global.Uid = isNaN(UID) ? 0 : Number(UID)
global.Gid = isNaN(GID) ? 0 : Number(GID) global.Gid = isNaN(GID) ? 0 : Number(GID)
global.ConfigPath = Path.normalize(CONFIG_PATH) global.ConfigPath = Path.normalize(CONFIG_PATH)
global.AudiobookPath = Path.normalize(AUDIOBOOK_PATH)
global.MetadataPath = Path.normalize(METADATA_PATH) global.MetadataPath = Path.normalize(METADATA_PATH)
// Fix backslash if not on Windows // Fix backslash if not on Windows
if (process.platform !== 'win32') { if (process.platform !== 'win32') {
global.ConfigPath = global.ConfigPath.replace(/\\/g, '/') global.ConfigPath = global.ConfigPath.replace(/\\/g, '/')
global.AudiobookPath = global.AudiobookPath.replace(/\\/g, '/')
global.MetadataPath = global.MetadataPath.replace(/\\/g, '/') global.MetadataPath = global.MetadataPath.replace(/\\/g, '/')
} }
@@ -57,10 +57,6 @@ class Server {
fs.mkdirSync(global.MetadataPath) fs.mkdirSync(global.MetadataPath)
filePerms.setDefaultDirSync(global.MetadataPath, false) filePerms.setDefaultDirSync(global.MetadataPath, false)
} }
if (!fs.pathExistsSync(global.AudiobookPath)) {
fs.mkdirSync(global.AudiobookPath)
filePerms.setDefaultDirSync(global.AudiobookPath, false)
}
this.db = new Db() this.db = new Db()
this.watcher = new Watcher() this.watcher = new Watcher()
@@ -140,10 +136,9 @@ class Server {
await this.db.init() await this.db.init()
} }
this.auth.init()
await this.checkUserMediaProgress() // Remove invalid user item progress await this.checkUserMediaProgress() // Remove invalid user item progress
await this.purgeMetadata() // Remove metadata folders without library item await this.purgeMetadata() // Remove metadata folders without library item
await this.cacheManager.ensureCachePaths()
await this.backupManager.init() await this.backupManager.init()
await this.logManager.init() await this.logManager.init()
@@ -214,18 +209,42 @@ class Server {
}) })
// Client dynamic routes // Client dynamic routes
app.get('/item/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html'))) const dyanimicRoutes = [
app.get('/audiobook/:id/edit', (req, res) => res.sendFile(Path.join(distPath, 'index.html'))) '/item/:id',
app.get('/library/:library', (req, res) => res.sendFile(Path.join(distPath, 'index.html'))) '/item/:id/manage',
app.get('/library/:library/search', (req, res) => res.sendFile(Path.join(distPath, 'index.html'))) '/item/:id/chapters',
app.get('/library/:library/bookshelf/:id?', (req, res) => res.sendFile(Path.join(distPath, 'index.html'))) '/audiobook/:id/edit',
app.get('/library/:library/authors', (req, res) => res.sendFile(Path.join(distPath, 'index.html'))) '/library/:library',
app.get('/library/:library/series/:id?', (req, res) => res.sendFile(Path.join(distPath, 'index.html'))) '/library/:library/search',
app.get('/config/users/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html'))) '/library/:library/bookshelf/:id?',
app.get('/collection/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html'))) '/library/:library/authors',
'/library/:library/series/:id?',
'/config/users/:id',
'/collection/:id'
]
dyanimicRoutes.forEach((route) => app.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html'))))
app.post('/login', this.getLoginRateLimiter(), (req, res) => this.auth.login(req, res)) app.post('/login', this.getLoginRateLimiter(), (req, res) => this.auth.login(req, res))
app.post('/logout', this.authMiddleware.bind(this), this.logout.bind(this)) app.post('/logout', this.authMiddleware.bind(this), this.logout.bind(this))
app.post('/init', (req, res) => {
if (this.db.hasRootUser) {
Logger.error(`[Server] attempt to init server when server already has a root user`)
return res.sendStatus(500)
}
this.initializeServer(req, res)
})
app.get('/status', (req, res) => {
// status check for client to see if server has been initialized
// server has been initialized if a root user exists
const payload = {
isInit: this.db.hasRootUser
}
if (!payload.isInit) {
payload.ConfigPath = global.ConfigPath
payload.MetadataPath = global.MetadataPath
}
res.json(payload)
})
app.get('/ping', (req, res) => { app.get('/ping', (req, res) => {
Logger.info('Recieved ping') Logger.info('Recieved ping')
res.json({ success: true }) res.json({ success: true })
@@ -288,6 +307,17 @@ class Server {
}) })
} }
async initializeServer(req, res) {
Logger.info(`[Server] Initializing new server`)
const newRoot = req.body.newRoot
let rootPash = newRoot.password ? await this.auth.hashPass(newRoot.password) : ''
if (!rootPash) Logger.warn(`[Server] Creating root user with no password`)
let rootToken = await this.auth.generateAccessToken({ userId: 'root' })
await this.db.createRootUser(newRoot.username, rootPash, rootToken)
res.sendStatus(200)
}
async filesChanged(fileUpdates) { async filesChanged(fileUpdates) {
Logger.info('[Server]', fileUpdates.length, 'Files Changed') Logger.info('[Server]', fileUpdates.length, 'Files Changed')
await this.scanner.scanFilesChanged(fileUpdates) await this.scanner.scanFilesChanged(fileUpdates)
@@ -428,7 +458,6 @@ class Server {
const initialPayload = { const initialPayload = {
// TODO: this is sent with user auth now, update mobile app to use that then remove this // TODO: this is sent with user auth now, update mobile app to use that then remove this
serverSettings: this.db.serverSettings.toJSON(), serverSettings: this.db.serverSettings.toJSON(),
audiobookPath: global.AudiobookPath,
metadataPath: global.MetadataPath, metadataPath: global.MetadataPath,
configPath: global.ConfigPath, configPath: global.ConfigPath,
user: client.user.toJSONForBrowser(), user: client.user.toJSONForBrowser(),
-1
View File
@@ -140,7 +140,6 @@ class FolderWatcher extends EventEmitter {
return return
} }
Logger.debug(`[Watcher] Rename ${pathFrom} => ${pathTo}`) Logger.debug(`[Watcher] Rename ${pathFrom} => ${pathTo}`)
this.addFileUpdate(libraryId, pathFrom, 'renamed')
this.addFileUpdate(libraryId, pathTo, 'renamed') this.addFileUpdate(libraryId, pathTo, 'renamed')
} }
+60 -3
View File
@@ -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
@@ -57,11 +107,16 @@ class AuthorController {
} }
async match(req, res) { async match(req, res) {
var authorData = await this.authorFinder.findAuthorByName(req.body.q) var authorData = null
if (req.body.asin) {
authorData = await this.authorFinder.findAuthorByASIN(req.body.asin)
} else {
authorData = await this.authorFinder.findAuthorByName(req.body.q)
}
if (!authorData) { if (!authorData) {
return res.status(404).send('Author not found') return res.status(404).send('Author not found')
} }
Logger.debug(`[AuthorController] match author with "${req.body.q}"`, authorData) Logger.debug(`[AuthorController] match author with "${req.body.q || req.body.asin}"`, authorData)
var hasUpdates = false var hasUpdates = false
if (authorData.asin && req.author.asin !== authorData.asin) { if (authorData.asin && req.author.asin !== authorData.asin) {
@@ -71,6 +126,8 @@ class AuthorController {
// Only updates image if there was no image before or the author ASIN was updated // Only updates image if there was no image before or the author ASIN was updated
if (authorData.image && (!req.author.imagePath || hasUpdates)) { if (authorData.image && (!req.author.imagePath || hasUpdates)) {
this.cacheManager.purgeImageCache(req.author.id)
var imageData = await this.authorFinder.saveAuthorImage(req.author.id, authorData.image) var imageData = await this.authorFinder.saveAuthorImage(req.author.id, authorData.image)
if (imageData) { if (imageData) {
req.author.imagePath = imageData.path req.author.imagePath = imageData.path
+1
View File
@@ -42,6 +42,7 @@ class LibraryController {
newLibraryPayload.displayOrder = this.db.libraries.length + 1 newLibraryPayload.displayOrder = this.db.libraries.length + 1
library.setData(newLibraryPayload) library.setData(newLibraryPayload)
await this.db.insertEntity('library', library) await this.db.insertEntity('library', library)
// TODO: Only emit to users that have access
this.emitter('library_added', library.toJSON()) this.emitter('library_added', library.toJSON())
// Add library watcher // Add library watcher
+32 -7
View File
@@ -359,7 +359,7 @@ class LibraryItemController {
}) })
} }
// POST: api/items/:id/audio-metadata // GET: api/items/:id/audio-metadata
async updateAudioFileMetadata(req, res) { async updateAudioFileMetadata(req, res) {
if (!req.user.isAdminOrUp) { if (!req.user.isAdminOrUp) {
Logger.error(`[LibraryItemController] Non-root user attempted to update audio metadata`, req.user) Logger.error(`[LibraryItemController] Non-root user attempted to update audio metadata`, req.user)
@@ -375,17 +375,42 @@ class LibraryItemController {
res.sendStatus(200) res.sendStatus(200)
} }
// POST: api/items/:id/chapters
async updateMediaChapters(req, res) {
if (!req.user.canUpdate) {
Logger.error(`[LibraryItemController] User attempted to update chapters with invalid permissions`, req.user.username)
return res.sendStatus(403)
}
if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) {
Logger.error(`[LibraryItemController] Invalid library item`)
return res.sendStatus(500)
}
const chapters = req.body.chapters || []
if (!chapters.length) {
Logger.error(`[LibraryItemController] Invalid payload`)
return res.sendStatus(400)
}
const wasUpdated = req.libraryItem.media.updateChapters(chapters)
if (wasUpdated) {
await this.db.updateLibraryItem(req.libraryItem)
this.emitter('item_updated', req.libraryItem.toJSONExpanded())
}
res.json({
success: true,
updated: wasUpdated
})
}
middleware(req, res, next) { 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)
} }
+9
View File
@@ -225,6 +225,15 @@ class MiscController {
res.json(author) res.json(author)
} }
async findChapters(req, res) {
var asin = req.query.asin
var chapterData = await this.bookFinder.findChapters(asin)
if (!chapterData) {
return res.json({ error: 'Chapters not found' })
}
res.json(chapterData)
}
authorize(req, res) { authorize(req, res) {
if (!req.user) { if (!req.user) {
Logger.error('Invalid user in authorize') Logger.error('Invalid user in authorize')
+8 -1
View File
@@ -1,9 +1,10 @@
const axios = require('axios') const axios = require('axios')
const fs = require('fs-extra') const fs = require('fs-extra')
const Path = require('path')
const Logger = require('../Logger') const Logger = require('../Logger')
const { parsePodcastRssFeedXml } = require('../utils/podcastUtils') const { parsePodcastRssFeedXml } = require('../utils/podcastUtils')
const LibraryItem = require('../objects/LibraryItem') const LibraryItem = require('../objects/LibraryItem')
const { getFileTimestampsWithIno } = require('../utils/fileUtils') const { getFileTimestampsWithIno, sanitizeFilename } = require('../utils/fileUtils')
const filePerms = require('../utils/filePerms') const filePerms = require('../utils/filePerms')
class PodcastController { class PodcastController {
@@ -107,6 +108,12 @@ class PodcastController {
if (!payload) { if (!payload) {
return res.status(500).send('Invalid podcast RSS feed') return res.status(500).send('Invalid podcast RSS feed')
} }
if (!payload.podcast.metadata.feedUrl) {
// Not every RSS feed will put the feed url in their metadata
payload.podcast.metadata.feedUrl = url
}
res.json(payload) res.json(payload)
}).catch((error) => { }).catch((error) => {
console.error('Failed', error) console.error('Failed', error)
+6 -1
View File
@@ -19,9 +19,14 @@ class AuthorFinder {
}) })
} }
findAuthorByASIN(asin) {
if (!asin) return null
return this.audnexus.findAuthorByASIN(asin)
}
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) {
+6
View File
@@ -3,6 +3,7 @@ const LibGen = require('../providers/LibGen')
const GoogleBooks = require('../providers/GoogleBooks') const GoogleBooks = require('../providers/GoogleBooks')
const Audible = require('../providers/Audible') const Audible = require('../providers/Audible')
const iTunes = require('../providers/iTunes') const iTunes = require('../providers/iTunes')
const Audnexus = require('../providers/Audnexus')
const Logger = require('../Logger') const Logger = require('../Logger')
const { levenshteinDistance } = require('../utils/index') const { levenshteinDistance } = require('../utils/index')
@@ -13,6 +14,7 @@ class BookFinder {
this.googleBooks = new GoogleBooks() this.googleBooks = new GoogleBooks()
this.audible = new Audible() this.audible = new Audible()
this.iTunesApi = new iTunes() this.iTunesApi = new iTunes()
this.audnexus = new Audnexus()
this.verbose = false this.verbose = false
} }
@@ -226,5 +228,9 @@ class BookFinder {
}) })
return covers return covers
} }
findChapters(asin) {
return this.audnexus.getChaptersByASIN(asin)
}
} }
module.exports = BookFinder module.exports = BookFinder
+36 -8
View File
@@ -1,6 +1,7 @@
const Path = require('path') const Path = require('path')
const fs = require('fs-extra') const fs = require('fs-extra')
const stream = require('stream') const stream = require('stream')
const filePerms = require('../utils/filePerms')
const Logger = require('../Logger') const Logger = require('../Logger')
const { resizeImage } = require('../utils/ffmpegHelpers') const { resizeImage } = require('../utils/ffmpegHelpers')
@@ -9,6 +10,34 @@ class CacheManager {
this.CachePath = Path.join(global.MetadataPath, 'cache') this.CachePath = Path.join(global.MetadataPath, 'cache')
this.CoverCachePath = Path.join(this.CachePath, 'covers') this.CoverCachePath = Path.join(this.CachePath, 'covers')
this.ImageCachePath = Path.join(this.CachePath, 'images') this.ImageCachePath = Path.join(this.CachePath, 'images')
this.cachePathsExist = false
}
async ensureCachePaths() { // Creates cache paths if necessary and sets owner and permissions
if (this.cachePathsExist) return
var pathsCreated = false
if (!(await fs.pathExists(this.CachePath))) {
await fs.mkdir(this.CachePath)
pathsCreated = true
}
if (!(await fs.pathExists(this.CoverCachePath))) {
await fs.mkdir(this.CoverCachePath)
pathsCreated = true
}
if (!(await fs.pathExists(this.ImageCachePath))) {
await fs.mkdir(this.ImageCachePath)
pathsCreated = true
}
if (pathsCreated) {
await filePerms.setDefault(this.CachePath)
}
this.cachePathsExist = true
} }
async handleCoverCache(res, libraryItem, options = {}) { async handleCoverCache(res, libraryItem, options = {}) {
@@ -33,9 +62,6 @@ class CacheManager {
return ps.pipe(res) return ps.pipe(res)
} }
// Write cache
await fs.ensureDir(this.CoverCachePath)
if (!libraryItem.media.coverPath || !await fs.pathExists(libraryItem.media.coverPath)) { if (!libraryItem.media.coverPath || !await fs.pathExists(libraryItem.media.coverPath)) {
return res.sendStatus(404) return res.sendStatus(404)
} }
@@ -43,6 +69,9 @@ class CacheManager {
let writtenFile = await resizeImage(libraryItem.media.coverPath, path, width, height) let writtenFile = await resizeImage(libraryItem.media.coverPath, path, width, height)
if (!writtenFile) return res.sendStatus(400) if (!writtenFile) return res.sendStatus(400)
// Set owner and permissions of cache image
await filePerms.setDefault(path)
var readStream = fs.createReadStream(writtenFile) var readStream = fs.createReadStream(writtenFile)
readStream.pipe(res) readStream.pipe(res)
} }
@@ -56,8 +85,6 @@ class CacheManager {
} }
async purgeEntityCache(entityId, cachePath) { async purgeEntityCache(entityId, cachePath) {
// If purgeAll has been called... The cover cache directory no longer exists
await fs.ensureDir(cachePath)
return Promise.all((await fs.readdir(cachePath)).reduce((promises, file) => { return Promise.all((await fs.readdir(cachePath)).reduce((promises, file) => {
if (file.startsWith(entityId)) { if (file.startsWith(entityId)) {
Logger.debug(`[CacheManager] Going to purge ${file}`); Logger.debug(`[CacheManager] Going to purge ${file}`);
@@ -84,6 +111,7 @@ class CacheManager {
Logger.error(`[CacheManager] Failed to remove cache dir "${this.CachePath}"`, error) Logger.error(`[CacheManager] Failed to remove cache dir "${this.CachePath}"`, error)
}) })
} }
await this.ensureCachePaths()
} }
async handleAuthorCache(res, author, options = {}) { async handleAuthorCache(res, author, options = {}) {
@@ -108,12 +136,12 @@ class CacheManager {
return ps.pipe(res) return ps.pipe(res)
} }
// Write cache
await fs.ensureDir(this.ImageCachePath)
let writtenFile = await resizeImage(author.imagePath, path, width, height) let writtenFile = await resizeImage(author.imagePath, path, width, height)
if (!writtenFile) return res.sendStatus(400) if (!writtenFile) return res.sendStatus(400)
// Set owner and permissions of cache image
await filePerms.setDefault(path)
var readStream = fs.createReadStream(writtenFile) var readStream = fs.createReadStream(writtenFile)
readStream.pipe(res) readStream.pipe(res)
} }
-7
View File
@@ -242,13 +242,6 @@ class DownloadManager {
if (shouldIncludeCover) { if (shouldIncludeCover) {
var _cover = audiobook.book.coverFullPath.replace(/\\/g, '/') var _cover = audiobook.book.coverFullPath.replace(/\\/g, '/')
// Supporting old local file prefix
var bookCoverPath = audiobook.book.cover ? audiobook.book.cover.replace(/\\/g, '/') : null
if (!_cover && bookCoverPath && bookCoverPath.startsWith('/local')) {
_cover = Path.posix.join(global.AudiobookPath, _cover.replace('/local', ''))
Logger.debug('Local cover url', _cover)
}
ffmpegInputs.push({ ffmpegInputs.push({
input: _cover, input: _cover,
options: ['-f image2pipe'] options: ['-f image2pipe']
@@ -1,4 +1,5 @@
const Path = require('path') const Path = require('path')
const date = require('date-and-time')
const { PlayMethod } = require('../utils/constants') const { PlayMethod } = require('../utils/constants')
const PlaybackSession = require('../objects/PlaybackSession') const PlaybackSession = require('../objects/PlaybackSession')
const Stream = require('../objects/Stream') const Stream = require('../objects/Stream')
@@ -40,6 +41,10 @@ class PlaybackSessionManager {
async syncLocalSessionRequest(user, sessionJson, res) { async syncLocalSessionRequest(user, sessionJson, res) {
var libraryItem = this.db.getLibraryItem(sessionJson.libraryItemId) var libraryItem = this.db.getLibraryItem(sessionJson.libraryItemId)
if (!libraryItem) {
Logger.error(`[PlaybackSessionManager] syncLocalSessionRequest: Library item not found for session "${sessionJson.libraryItemId}"`)
return res.sendStatus(200)
}
var session = await this.db.getPlaybackSession(sessionJson.id) var session = await this.db.getPlaybackSession(sessionJson.id)
if (!session) { if (!session) {
@@ -49,6 +54,8 @@ class PlaybackSessionManager {
} else { } else {
session.timeListening = sessionJson.timeListening session.timeListening = sessionJson.timeListening
session.updatedAt = sessionJson.updatedAt session.updatedAt = sessionJson.updatedAt
session.date = date.format(new Date(), 'YYYY-MM-DD')
session.dayOfWeek = date.format(new Date(), 'dddd')
await this.db.updateEntity('session', session) await this.db.updateEntity('session', session)
} }
+12 -12
View File
@@ -183,8 +183,15 @@ class PodcastManager {
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}`) const latestEpisodePublishedAt = libraryItem.media.latestEpisodePublished
var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem) Logger.info(`[PodcastManager] checkForNewEpisodes: "${libraryItem.media.metadata.title}" | Last check: ${lastEpisodeCheckDate} | ${latestEpisodePublishedAt ? `Latest episode pubDate: ${new Date(latestEpisodePublishedAt)}` : 'No latest episode'}`)
// Use latest episode pubDate if exists OR fallback to using lastEpisodeCheckDate
// lastEpisodeCheckDate will be the current time when adding a new podcast
const dateToCheckForEpisodesAfter = latestEpisodePublishedAt || lastEpisodeCheckDate
Logger.debug(`[PodcastManager] checkForNewEpisodes: "${libraryItem.media.metadata.title}" checking for episodes after ${new Date(dateToCheckForEpisodesAfter)}`)
var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, dateToCheckForEpisodesAfter)
Logger.debug(`[PodcastManager] checkForNewEpisodes checked result ${newEpisodes ? newEpisodes.length : 'N/A'}`) Logger.debug(`[PodcastManager] checkForNewEpisodes checked result ${newEpisodes ? newEpisodes.length : 'N/A'}`)
if (!newEpisodes) { // Failed if (!newEpisodes) { // Failed
@@ -214,7 +221,7 @@ class PodcastManager {
} }
} }
async checkPodcastForNewEpisodes(podcastLibraryItem) { async checkPodcastForNewEpisodes(podcastLibraryItem, dateToCheckForEpisodesAfter) {
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})`) Logger.error(`[PodcastManager] checkPodcastForNewEpisodes no feed url for ${podcastLibraryItem.media.metadata.title} (ID: ${podcastLibraryItem.id})`)
return false return false
@@ -225,15 +232,8 @@ class PodcastManager {
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 > dateToCheckForEpisodesAfter && !podcastLibraryItem.media.checkHasEpisodeByFeedUrl(ep.enclosure.url))
// Max new episodes for safety = 3 // Max new episodes for safety = 3
newEpisodes = newEpisodes.slice(0, 3) newEpisodes = newEpisodes.slice(0, 3)
return newEpisodes return newEpisodes
@@ -242,7 +242,7 @@ class PodcastManager {
async checkAndDownloadNewEpisodes(libraryItem) { async checkAndDownloadNewEpisodes(libraryItem) {
const lastEpisodeCheckDate = new Date(libraryItem.media.lastEpisodeCheck || 0) const lastEpisodeCheckDate = new Date(libraryItem.media.lastEpisodeCheck || 0)
Logger.info(`[PodcastManager] checkAndDownloadNewEpisodes for "${libraryItem.media.metadata.title}" - Last episode check: ${lastEpisodeCheckDate}`) Logger.info(`[PodcastManager] checkAndDownloadNewEpisodes for "${libraryItem.media.metadata.title}" - Last episode check: ${lastEpisodeCheckDate}`)
var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem) var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, libraryItem.media.lastEpisodeCheck)
if (newEpisodes.length) { if (newEpisodes.length) {
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)
+10 -18
View File
@@ -480,7 +480,6 @@ const updateStoreData = async (store, match, update, tempstore, lockoptions) =>
release = await lock(store, lockoptions); release = await lock(store, lockoptions);
// console.log('Start updateStoreData for tempstore', tempstore, 'real store', store)
const handlerResults = await new Promise((resolve, reject) => { const handlerResults = await new Promise((resolve, reject) => {
const writer = createWriteStream(tempstore); const writer = createWriteStream(tempstore);
@@ -490,14 +489,11 @@ const updateStoreData = async (store, match, update, tempstore, lockoptions) =>
// Reader was opening and closing before writer ever opened // Reader was opening and closing before writer ever opened
const reader = createInterface({ input: createReadStream(store), crlfDelay: Infinity }); const reader = createInterface({ input: createReadStream(store), crlfDelay: Infinity });
// console.log('Writer opened for tempstore', tempstore)
reader.on("line", record => { reader.on("line", record => {
handler.next(record, writer) handler.next(record, writer)
}); });
reader.on("close", () => { reader.on("close", () => {
// console.log('Closing reader for store', store)
writer.end(); writer.end();
resolve(handler.return()); resolve(handler.return());
}); });
@@ -505,12 +501,7 @@ const updateStoreData = async (store, match, update, tempstore, lockoptions) =>
reader.on("error", error => reject(error)); reader.on("error", error => reject(error));
}); });
// writer.on('close', () => {
// console.log('Writer closed for tempstore', tempstore)
// })
writer.on("error", error => reject(error)); writer.on("error", error => reject(error));
}); });
results = Object.assign({ store: store, tempstore: tempstore }, handlerResults); results = Object.assign({ store: store, tempstore: tempstore }, handlerResults);
@@ -579,26 +570,27 @@ const updateStoreDataSync = (store, match, update, tempstore) => {
const deleteStoreData = async (store, match, tempstore, lockoptions) => { const deleteStoreData = async (store, match, tempstore, lockoptions) => {
let release, results; let release, results;
release = await lock(store, lockoptions); release = await lock(store, lockoptions);
const handlerResults = await new Promise((resolve, reject) => { const handlerResults = await new Promise((resolve, reject) => {
const reader = createInterface({ input: createReadStream(store), crlfDelay: Infinity });
const writer = createWriteStream(tempstore); const writer = createWriteStream(tempstore);
const handler = Handler("delete", match); const handler = Handler("delete", match);
writer.on("open", () => { writer.on("open", () => {
// Create reader after writer opens otherwise the reader can sometimes close before the writer opens
const reader = createInterface({ input: createReadStream(store), crlfDelay: Infinity });
reader.on("line", record => handler.next(record, writer)); reader.on("line", record => handler.next(record, writer));
reader.on("close", () => {
writer.end();
resolve(handler.return());
});
reader.on("error", error => reject(error));
}); });
writer.on("error", error => reject(error)); writer.on("error", error => reject(error));
reader.on("close", () => {
writer.end();
resolve(handler.return());
});
reader.on("error", error => reject(error));
}); });
results = Object.assign({ store: store, tempstore: tempstore }, handlerResults); results = Object.assign({ store: store, tempstore: tempstore }, handlerResults);
+1 -2
View File
@@ -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 {
+1 -1
View File
@@ -37,7 +37,7 @@ class Author {
imagePath: this.imagePath, imagePath: this.imagePath,
relImagePath: this.relImagePath, relImagePath: this.relImagePath,
addedAt: this.addedAt, addedAt: this.addedAt,
lastUpdate: this.updatedAt updatedAt: this.updatedAt
} }
} }
@@ -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
+44 -12
View File
@@ -153,6 +153,30 @@ class Book {
return hasUpdates return hasUpdates
} }
updateChapters(chapters) {
var hasUpdates = this.chapters.length !== chapters.length
if (hasUpdates) {
this.chapters = chapters.map(ch => ({
id: ch.id,
start: ch.start,
end: ch.end,
title: ch.title
}))
} else {
for (let i = 0; i < this.chapters.length; i++) {
const currChapter = this.chapters[i]
const newChapter = chapters[i]
if (!hasUpdates && (currChapter.title !== newChapter.title || currChapter.start !== newChapter.start || currChapter.end !== newChapter.end)) {
hasUpdates = true
}
this.chapters[i].title = newChapter.title
this.chapters[i].start = newChapter.start
this.chapters[i].end = newChapter.end
}
}
return hasUpdates
}
updateCover(coverPath) { updateCover(coverPath) {
coverPath = coverPath.replace(/\\/g, '/') coverPath = coverPath.replace(/\\/g, '/')
if (this.coverPath === coverPath) return false if (this.coverPath === coverPath) return false
@@ -381,19 +405,27 @@ class Book {
// If audio file has chapters use chapters // If audio file has chapters use chapters
if (file.chapters && file.chapters.length) { if (file.chapters && file.chapters.length) {
file.chapters.forEach((chapter) => { file.chapters.forEach((chapter) => {
var chapterDuration = chapter.end - chapter.start if (chapter.start > this.duration) {
if (chapterDuration > 0) { Logger.warn(`[Book] Invalid chapter start time > duration`)
var title = `Chapter ${currChapterId}` } else {
if (chapter.title) { var chapterAlreadyExists = this.chapters.find(ch => ch.start === chapter.start)
title += ` (${chapter.title})` if (!chapterAlreadyExists) {
var chapterDuration = chapter.end - chapter.start
if (chapterDuration > 0) {
var title = `Chapter ${currChapterId}`
if (chapter.title) {
title += ` (${chapter.title})`
}
var endTime = Math.min(this.duration, currStartTime + chapterDuration)
this.chapters.push({
id: currChapterId++,
start: currStartTime,
end: endTime,
title
})
currStartTime += chapterDuration
}
} }
this.chapters.push({
id: currChapterId++,
start: currStartTime,
end: currStartTime + chapterDuration,
title
})
currStartTime += chapterDuration
} }
}) })
} else if (file.duration) { } else if (file.duration) {
+9
View File
@@ -104,6 +104,15 @@ class Podcast {
get numTracks() { get numTracks() {
return this.episodes.length return this.episodes.length
} }
get latestEpisodePublished() {
var largestPublishedAt = 0
this.episodes.forEach((ep) => {
if (ep.publishedAt && ep.publishedAt > largestPublishedAt) {
largestPublishedAt = ep.publishedAt
}
})
return largestPublishedAt
}
update(payload) { update(payload) {
var json = this.toJSON() var json = this.toJSON()

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