mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-06-05 02:02:44 +02:00
Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d2e012d7b1 | |||
| d4fe0be386 | |||
| 6d947bbc29 | |||
| 5187d0e55f | |||
| c6253e4fd4 | |||
| 1ab933c8b0 | |||
| e2e5dd372a | |||
| 3e98b6f749 | |||
| 3c465994fe | |||
| 6cfe583535 | |||
| 0ad7a98fc7 | |||
| a8d5b543d7 | |||
| f2e16017f6 | |||
| 4d227cbade | |||
| 15a85299b9 | |||
| d22e9e32ed | |||
| 8beac53f5f | |||
| cbad435690 | |||
| 169b637720 | |||
| f083d4b5f6 | |||
| 3451a312e9 | |||
| 927c1a3514 | |||
| dabcad5ebd | |||
| 796602d1b2 | |||
| 302870a101 | |||
| 3954aa1963 | |||
| 2d8c840ad6 | |||
| f1f02b185e | |||
| 13d21e90f8 | |||
| dd664da871 | |||
| 6ff66370fe | |||
| 23904d57ad | |||
| efdb43e2d2 | |||
| 67523095d6 | |||
| e2d869bb19 | |||
| d38e9499db | |||
| c7429efe95 | |||
| b925dbcc95 | |||
| 2a235b8324 | |||
| 06cc2a1b21 | |||
| 4bcca97b1f | |||
| 313b9026f1 | |||
| 139ee013a7 | |||
| 7e5ab477b2 | |||
| eba37c46cb | |||
| 228d9cc301 | |||
| 85946dd1d5 | |||
| b40598593d | |||
| e918a46d09 | |||
| 8061ee29d5 | |||
| e15e04f085 | |||
| 958d68ffa9 | |||
| c8a743ccc1 | |||
| 09dc95f560 | |||
| 853858825b | |||
| c962090c3a | |||
| 63a8e2433e | |||
| f78d287b59 | |||
| eaa383b6d8 | |||
| 113026ce13 | |||
| 578a946ca5 | |||
| f31306eda0 | |||
| c62b716a2c | |||
| 97ed20c683 | |||
| d5c46dcbfb | |||
| 30934edd57 | |||
| d06fd1a1b1 | |||
| 6bb36381f1 |
@@ -0,0 +1,4 @@
|
|||||||
|
FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:16
|
||||||
|
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||||
|
&& apt-get install ffmpeg gnupg2 -y
|
||||||
|
ENV NODE_ENV=development
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"build": { "dockerfile": "Dockerfile" },
|
||||||
|
"mounts": [
|
||||||
|
"source=abs-node-node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume"
|
||||||
|
],
|
||||||
|
"features": {
|
||||||
|
"fish": "latest"
|
||||||
|
},
|
||||||
|
"extensions": [
|
||||||
|
"eamodio.gitlens"
|
||||||
|
]
|
||||||
|
}
|
||||||
+1
-1
@@ -14,6 +14,6 @@ COPY index.js index.js
|
|||||||
COPY package-lock.json package-lock.json
|
COPY package-lock.json package-lock.json
|
||||||
COPY package.json package.json
|
COPY package.json package.json
|
||||||
COPY server server
|
COPY server server
|
||||||
RUN npm ci --production
|
RUN npm ci --only=production
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
CMD ["npm", "start"]
|
CMD ["npm", "start"]
|
||||||
|
|||||||
@@ -2,49 +2,11 @@
|
|||||||
set -e
|
set -e
|
||||||
set -o pipefail
|
set -o pipefail
|
||||||
|
|
||||||
FFMPEG_INSTALL_DIR="/usr/lib/audiobookshelf-ffmpeg/"
|
ABS_LOG_DIR="/var/log/audiobookshelf"
|
||||||
|
|
||||||
declare -r init_type='auto'
|
declare -r init_type='auto'
|
||||||
declare -ri no_rebuild='0'
|
declare -ri no_rebuild='0'
|
||||||
|
|
||||||
add_user() {
|
|
||||||
: "${1:?'User was not defined'}"
|
|
||||||
declare -r user="$1"
|
|
||||||
declare -r uid="$2"
|
|
||||||
|
|
||||||
if [ -z "$uid" ]; then
|
|
||||||
declare -r uid_flags=""
|
|
||||||
else
|
|
||||||
declare -r uid_flags="--uid $uid"
|
|
||||||
fi
|
|
||||||
|
|
||||||
declare -r group="${3:-$user}"
|
|
||||||
declare -r descr="${4:-No description}"
|
|
||||||
declare -r shell="${5:-/bin/false}"
|
|
||||||
|
|
||||||
if ! getent passwd | grep -q "^$user:"; then
|
|
||||||
echo "Creating system user: $user in $group with $descr and shell $shell"
|
|
||||||
useradd $uid_flags --gid $group --no-create-home --system --shell $shell -c "$descr" $user
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
add_group() {
|
|
||||||
: "${1:?'Group was not defined'}"
|
|
||||||
declare -r group="$1"
|
|
||||||
declare -r gid="$2"
|
|
||||||
|
|
||||||
if [ -z "$gid" ]; then
|
|
||||||
declare -r gid_flags=""
|
|
||||||
else
|
|
||||||
declare -r gid_flags="--gid $gid"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! getent group | grep -q "^$group:" ; then
|
|
||||||
echo "Creating system group: $group"
|
|
||||||
groupadd $gid_flags --system $group
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
start_service () {
|
start_service () {
|
||||||
: "${1:?'Service name was not defined'}"
|
: "${1:?'Service name was not defined'}"
|
||||||
declare -r service_name="$1"
|
declare -r service_name="$1"
|
||||||
@@ -76,13 +38,10 @@ start_service () {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Create log directory if not there and set ownership
|
||||||
add_group 'audiobookshelf' ''
|
if [ ! -d "$ABS_LOG_DIR" ]; then
|
||||||
add_user 'audiobookshelf' '' 'audiobookshelf' 'audiobookshelf user-daemon' '/bin/false'
|
mkdir -p "$ABS_LOG_DIR"
|
||||||
|
chown -R 'audiobookshelf:audiobookshelf' "$ABS_LOG_DIR"
|
||||||
mkdir -p '/var/log/audiobookshelf'
|
fi
|
||||||
chown -R 'audiobookshelf:audiobookshelf' '/var/log/audiobookshelf'
|
|
||||||
chown -R 'audiobookshelf:audiobookshelf' '/usr/share/audiobookshelf'
|
|
||||||
chown -R 'audiobookshelf:audiobookshelf' "$FFMPEG_INSTALL_DIR"
|
|
||||||
|
|
||||||
start_service 'audiobookshelf'
|
start_service 'audiobookshelf'
|
||||||
|
|||||||
+58
-75
@@ -2,13 +2,51 @@
|
|||||||
set -e
|
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_DIR="/usr/share/audiobookshelf"
|
||||||
DEFAULT_DATA_PATH="/usr/share/audiobookshelf"
|
CONFIG_PATH="/etc/default/audiobookshelf"
|
||||||
DEFAULT_PORT=7331
|
DEFAULT_PORT=7331
|
||||||
DEFAULT_HOST="0.0.0.0"
|
DEFAULT_HOST="0.0.0.0"
|
||||||
|
|
||||||
CONFIG_PATH="/etc/default/audiobookshelf"
|
|
||||||
|
|
||||||
|
add_user() {
|
||||||
|
: "${1:?'User was not defined'}"
|
||||||
|
declare -r user="$1"
|
||||||
|
declare -r uid="$2"
|
||||||
|
|
||||||
|
if [ -z "$uid" ]; then
|
||||||
|
declare -r uid_flags=""
|
||||||
|
else
|
||||||
|
declare -r uid_flags="--uid $uid"
|
||||||
|
fi
|
||||||
|
|
||||||
|
declare -r group="${3:-$user}"
|
||||||
|
declare -r descr="${4:-No description}"
|
||||||
|
declare -r shell="${5:-/bin/false}"
|
||||||
|
|
||||||
|
if ! getent passwd | grep -q "^$user:"; then
|
||||||
|
echo "Creating system user: $user in $group with $descr and shell $shell"
|
||||||
|
useradd $uid_flags --gid $group --no-create-home --system --shell $shell -c "$descr" $user
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
add_group() {
|
||||||
|
: "${1:?'Group was not defined'}"
|
||||||
|
declare -r group="$1"
|
||||||
|
declare -r gid="$2"
|
||||||
|
|
||||||
|
if [ -z "$gid" ]; then
|
||||||
|
declare -r gid_flags=""
|
||||||
|
else
|
||||||
|
declare -r gid_flags="--gid $gid"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! getent group | grep -q "^$group:" ; then
|
||||||
|
echo "Creating system group: $group"
|
||||||
|
groupadd $gid_flags --system $group
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
install_ffmpeg() {
|
install_ffmpeg() {
|
||||||
echo "Starting FFMPEG Install"
|
echo "Starting FFMPEG Install"
|
||||||
@@ -16,8 +54,9 @@ install_ffmpeg() {
|
|||||||
WGET="wget https://johnvansickle.com/ffmpeg/builds/ffmpeg-git-amd64-static.tar.xz"
|
WGET="wget https://johnvansickle.com/ffmpeg/builds/ffmpeg-git-amd64-static.tar.xz"
|
||||||
|
|
||||||
if ! cd "$FFMPEG_INSTALL_DIR"; then
|
if ! cd "$FFMPEG_INSTALL_DIR"; then
|
||||||
echo "WARNING: can't access working directory ($FFMPEG_INSTALL_DIR) creating it" >&2
|
echo "Creating ffmpeg install dir at $FFMPEG_INSTALL_DIR"
|
||||||
mkdir "$FFMPEG_INSTALL_DIR"
|
mkdir "$FFMPEG_INSTALL_DIR"
|
||||||
|
chown -R 'audiobookshelf:audiobookshelf' "$FFMPEG_INSTALL_DIR"
|
||||||
cd "$FFMPEG_INSTALL_DIR"
|
cd "$FFMPEG_INSTALL_DIR"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -28,83 +67,23 @@ install_ffmpeg() {
|
|||||||
echo "Good to go on Ffmpeg... hopefully"
|
echo "Good to go on Ffmpeg... hopefully"
|
||||||
}
|
}
|
||||||
|
|
||||||
should_build_config() {
|
|
||||||
if [ -f "$CONFIG_PATH" ]; then
|
|
||||||
echo "You already have a config file. Do you want to use it?"
|
|
||||||
|
|
||||||
options=("Yes" "No")
|
|
||||||
select yn in "${options[@]}"
|
|
||||||
do
|
|
||||||
case $yn in
|
|
||||||
"Yes")
|
|
||||||
false; return
|
|
||||||
;;
|
|
||||||
"No")
|
|
||||||
true; return
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
else
|
|
||||||
echo "No existing config found in $CONFIG_PATH"
|
|
||||||
true; return
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
setup_config_interactive() {
|
|
||||||
if should_build_config; then
|
|
||||||
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=""
|
|
||||||
read -p "
|
|
||||||
Enter path for data files, i.e. streams, downloads, database [Default: $DEFAULT_DATA_PATH]:" DATA_PATH
|
|
||||||
|
|
||||||
if [[ -z "$DATA_PATH" ]]; then
|
|
||||||
DATA_PATH="$DEFAULT_DATA_PATH"
|
|
||||||
fi
|
|
||||||
|
|
||||||
PORT=""
|
|
||||||
read -p "
|
|
||||||
Port for the web ui [Default: $DEFAULT_PORT]:" PORT
|
|
||||||
|
|
||||||
if [[ -z "$PORT" ]]; then
|
|
||||||
PORT="$DEFAULT_PORT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
config_text="AUDIOBOOK_PATH=$AUDIOBOOK_PATH
|
|
||||||
METADATA_PATH=$DATA_PATH/metadata
|
|
||||||
CONFIG_PATH=$DATA_PATH/config
|
|
||||||
FFMPEG_PATH=/usr/lib/audiobookshelf-ffmpeg/ffmpeg
|
|
||||||
FFPROBE_PATH=/usr/lib/audiobookshelf-ffmpeg/ffprobe
|
|
||||||
PORT=$PORT
|
|
||||||
HOST=$DEFAULT_HOST"
|
|
||||||
|
|
||||||
echo "$config_text"
|
|
||||||
|
|
||||||
echo "$config_text" > /etc/default/audiobookshelf;
|
|
||||||
|
|
||||||
echo "Config created"
|
|
||||||
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
setup_config() {
|
setup_config() {
|
||||||
if [ -f "$CONFIG_PATH" ]; then
|
if [ -f "$CONFIG_PATH" ]; then
|
||||||
echo "Existing config found."
|
echo "Existing config found."
|
||||||
cat $CONFIG_PATH
|
cat $CONFIG_PATH
|
||||||
else
|
else
|
||||||
|
|
||||||
|
if [ ! -d "$DEFAULT_DATA_DIR" ]; then
|
||||||
|
# Create directory and set permissions
|
||||||
|
echo "Creating default data dir at $DEFAULT_DATA_DIR"
|
||||||
|
mkdir "$DEFAULT_DATA_DIR"
|
||||||
|
chown -R 'audiobookshelf:audiobookshelf' "$DEFAULT_DATA_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Creating default config."
|
echo "Creating default config."
|
||||||
|
|
||||||
config_text="AUDIOBOOK_PATH=$DEFAULT_AUDIOBOOK_PATH
|
config_text="METADATA_PATH=$DEFAULT_DATA_DIR/metadata
|
||||||
METADATA_PATH=$DEFAULT_DATA_PATH/metadata
|
CONFIG_PATH=$DEFAULT_DATA_DIR/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
|
||||||
PORT=$DEFAULT_PORT
|
PORT=$DEFAULT_PORT
|
||||||
@@ -118,6 +97,10 @@ setup_config() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
add_group 'audiobookshelf' ''
|
||||||
|
|
||||||
|
add_user 'audiobookshelf' '' 'audiobookshelf' 'audiobookshelf user-daemon' '/bin/false'
|
||||||
|
|
||||||
setup_config
|
setup_config
|
||||||
|
|
||||||
install_ffmpeg
|
install_ffmpeg
|
||||||
|
|||||||
@@ -153,9 +153,6 @@ export default {
|
|||||||
},
|
},
|
||||||
currentChapterName() {
|
currentChapterName() {
|
||||||
return this.currentChapter ? this.currentChapter.title : ''
|
return this.currentChapter ? this.currentChapter.title : ''
|
||||||
},
|
|
||||||
showExperimentalFeatures() {
|
|
||||||
return this.$store.state.showExperimentalFeatures
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
<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>
|
||||||
@@ -24,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>
|
||||||
|
|
||||||
|
|||||||
@@ -190,7 +190,15 @@ export default {
|
|||||||
},
|
},
|
||||||
settingsUpdated(settings) {},
|
settingsUpdated(settings) {},
|
||||||
scan() {
|
scan() {
|
||||||
this.$store.dispatch('libraries/requestLibraryScan', { libraryId: this.$store.state.libraries.currentLibraryId })
|
this.$store
|
||||||
|
.dispatch('libraries/requestLibraryScan', { libraryId: this.$store.state.libraries.currentLibraryId })
|
||||||
|
.then(() => {
|
||||||
|
this.$toast.success('Library scan started')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to start scan', error)
|
||||||
|
this.$toast.error('Failed to start scan')
|
||||||
|
})
|
||||||
},
|
},
|
||||||
libraryItemAdded(libraryItem) {
|
libraryItemAdded(libraryItem) {
|
||||||
console.log('libraryItem added', libraryItem)
|
console.log('libraryItem added', libraryItem)
|
||||||
|
|||||||
@@ -9,9 +9,13 @@
|
|||||||
<div v-show="routeName === route.iod" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
<div v-show="routeName === route.iod" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<div class="w-full h-10 px-4 border-t border-black border-opacity-20 absolute left-0 flex flex-col justify-center" :style="{ bottom: streamLibraryItem && isMobileLandscape ? '300px' : '65px' }">
|
<div class="w-full h-12 px-4 border-t border-black border-opacity-20 absolute left-0 flex flex-col justify-center" :style="{ bottom: streamLibraryItem && isMobileLandscape ? '300px' : '65px' }">
|
||||||
<p class="font-mono text-sm">v{{ $config.version }}</p>
|
<div class="flex justify-between">
|
||||||
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-sm">Update available: {{ latestVersion }}</a>
|
<p class="font-mono text-sm">v{{ $config.version }}</p>
|
||||||
|
|
||||||
|
<p class="font-mono text-xs text-gray-300 italic">{{ Source }}</p>
|
||||||
|
</div>
|
||||||
|
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xs">Latest: {{ latestVersion }}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -25,6 +29,12 @@ export default {
|
|||||||
return {}
|
return {}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
Source() {
|
||||||
|
return this.$store.state.Source
|
||||||
|
},
|
||||||
|
currentLibraryId() {
|
||||||
|
return this.$store.state.libraries.currentLibraryId
|
||||||
|
},
|
||||||
userIsAdminOrUp() {
|
userIsAdminOrUp() {
|
||||||
return this.$store.getters['user/getIsAdminOrUp']
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
},
|
},
|
||||||
@@ -38,7 +48,7 @@ export default {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
return [
|
const configRoutes = [
|
||||||
{
|
{
|
||||||
id: 'config',
|
id: 'config',
|
||||||
title: 'Settings',
|
title: 'Settings',
|
||||||
@@ -63,18 +73,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 = []
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export default {
|
|||||||
mixins: [bookshelfCardsHelpers],
|
mixins: [bookshelfCardsHelpers],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
routeFullPath: null,
|
||||||
initialized: false,
|
initialized: false,
|
||||||
bookshelfHeight: 0,
|
bookshelfHeight: 0,
|
||||||
bookshelfWidth: 0,
|
bookshelfWidth: 0,
|
||||||
@@ -413,6 +414,8 @@ export default {
|
|||||||
if (newSearchParams !== this.currentSFQueryString || newSearchParams !== currentQueryString) {
|
if (newSearchParams !== this.currentSFQueryString || newSearchParams !== currentQueryString) {
|
||||||
let newurl = window.location.protocol + '//' + window.location.host + window.location.pathname + '?' + newSearchParams
|
let newurl = window.location.protocol + '//' + window.location.host + window.location.pathname + '?' + newSearchParams
|
||||||
window.history.replaceState({ path: newurl }, '', newurl)
|
window.history.replaceState({ path: newurl }, '', newurl)
|
||||||
|
|
||||||
|
this.routeFullPath = window.location.pathname + (window.location.search || '') // Update for saving scroll position
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -530,6 +533,15 @@ export default {
|
|||||||
await this.fetchEntites(0)
|
await this.fetchEntites(0)
|
||||||
var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf)
|
var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf)
|
||||||
this.mountEntites(0, lastBookIndex)
|
this.mountEntites(0, lastBookIndex)
|
||||||
|
|
||||||
|
// Set last scroll position for this bookshelf page
|
||||||
|
if (this.$store.state.lastBookshelfScrollData[this.page] && window.bookshelf) {
|
||||||
|
const { path, scrollTop } = this.$store.state.lastBookshelfScrollData[this.page]
|
||||||
|
if (path === this.routeFullPath) {
|
||||||
|
// Exact path match with query so use scroll position
|
||||||
|
window.bookshelf.scrollTop = scrollTop
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
executeRebuild() {
|
executeRebuild() {
|
||||||
clearTimeout(this.resizeTimeout)
|
clearTimeout(this.resizeTimeout)
|
||||||
@@ -605,13 +617,25 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
scan() {
|
scan() {
|
||||||
this.$store.dispatch('libraries/requestLibraryScan', { libraryId: this.currentLibraryId })
|
this.$store
|
||||||
|
.dispatch('libraries/requestLibraryScan', { libraryId: this.currentLibraryId })
|
||||||
|
.then(() => {
|
||||||
|
this.$toast.success('Library scan started')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to start scan', error)
|
||||||
|
this.$toast.error('Failed to start scan')
|
||||||
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.initListeners()
|
this.initListeners()
|
||||||
|
|
||||||
|
this.routeFullPath = window.location.pathname + (window.location.search || '')
|
||||||
},
|
},
|
||||||
updated() {
|
updated() {
|
||||||
|
this.routeFullPath = window.location.pathname + (window.location.search || '')
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (window.innerWidth > 0 && window.innerWidth !== this.mountWindowWidth) {
|
if (window.innerWidth > 0 && window.innerWidth !== this.mountWindowWidth) {
|
||||||
console.log('Updated window width', window.innerWidth, 'from', this.mountWindowWidth)
|
console.log('Updated window width', window.innerWidth, 'from', this.mountWindowWidth)
|
||||||
@@ -622,6 +646,11 @@ export default {
|
|||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
this.destroyEntityComponents()
|
this.destroyEntityComponents()
|
||||||
this.removeListeners()
|
this.removeListeners()
|
||||||
|
|
||||||
|
// Set bookshelf scroll position for specific bookshelf page and query
|
||||||
|
if (window.bookshelf) {
|
||||||
|
this.$store.commit('setLastBookshelfScrollData', { scrollTop: window.bookshelf.scrollTop || 0, path: this.routeFullPath, name: this.page })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -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,8 +82,12 @@ export default {
|
|||||||
return {}
|
return {}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
showExperimentalFeatures() {
|
isShowingBookshelfToolbar() {
|
||||||
return this.$store.state.showExperimentalFeatures
|
if (!this.$route.name) return false
|
||||||
|
return this.$route.name.startsWith('library')
|
||||||
|
},
|
||||||
|
offsetTop() {
|
||||||
|
return 64
|
||||||
},
|
},
|
||||||
userIsAdminOrUp() {
|
userIsAdminOrUp() {
|
||||||
return this.$store.getters['user/getIsAdminOrUp']
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
|
|||||||
@@ -74,9 +74,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
showExperimentalFeatures() {
|
|
||||||
return this.$store.state.showExperimentalFeatures
|
|
||||||
},
|
|
||||||
coverAspectRatio() {
|
coverAspectRatio() {
|
||||||
return this.$store.getters['getServerSetting']('coverAspectRatio')
|
return this.$store.getters['getServerSetting']('coverAspectRatio')
|
||||||
},
|
},
|
||||||
@@ -148,6 +145,7 @@ export default {
|
|||||||
setPlaying(isPlaying) {
|
setPlaying(isPlaying) {
|
||||||
this.isPlaying = isPlaying
|
this.isPlaying = isPlaying
|
||||||
this.$store.commit('setIsPlaying', isPlaying)
|
this.$store.commit('setIsPlaying', isPlaying)
|
||||||
|
this.updateMediaSessionPlaybackState()
|
||||||
},
|
},
|
||||||
setSleepTimer(seconds) {
|
setSleepTimer(seconds) {
|
||||||
this.sleepTimerSet = true
|
this.sleepTimerSet = true
|
||||||
@@ -240,6 +238,71 @@ export default {
|
|||||||
this.playerHandler.closePlayer()
|
this.playerHandler.closePlayer()
|
||||||
this.$store.commit('setMediaPlaying', null)
|
this.$store.commit('setMediaPlaying', null)
|
||||||
},
|
},
|
||||||
|
mediaSessionPlay() {
|
||||||
|
console.log('Media session play')
|
||||||
|
this.playerHandler.play()
|
||||||
|
},
|
||||||
|
mediaSessionPause() {
|
||||||
|
console.log('Media session pause')
|
||||||
|
this.playerHandler.pause()
|
||||||
|
},
|
||||||
|
mediaSessionStop() {
|
||||||
|
console.log('Media session stop')
|
||||||
|
this.playerHandler.pause()
|
||||||
|
},
|
||||||
|
mediaSessionSeekBackward() {
|
||||||
|
console.log('Media session seek backward')
|
||||||
|
this.playerHandler.jumpBackward()
|
||||||
|
},
|
||||||
|
mediaSessionSeekForward() {
|
||||||
|
console.log('Media session seek forward')
|
||||||
|
this.playerHandler.jumpForward()
|
||||||
|
},
|
||||||
|
mediaSessionSeekTo(e) {
|
||||||
|
console.log('Media session seek to', e)
|
||||||
|
if (e.seekTime !== null && !isNaN(e.seekTime)) {
|
||||||
|
this.playerHandler.seek(e.seekTime)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateMediaSessionPlaybackState() {
|
||||||
|
if ('mediaSession' in navigator) {
|
||||||
|
navigator.mediaSession.playbackState = this.isPlaying ? 'playing' : 'paused'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setMediaSession() {
|
||||||
|
if (!this.streamLibraryItem) {
|
||||||
|
console.error('setMediaSession: No library item set')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('mediaSession' in navigator) {
|
||||||
|
var coverImageSrc = this.$store.getters['globals/getLibraryItemCoverSrc'](this.streamLibraryItem, '/Logo.png')
|
||||||
|
const artwork = [
|
||||||
|
{
|
||||||
|
src: coverImageSrc
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
navigator.mediaSession.metadata = new MediaMetadata({
|
||||||
|
title: this.title,
|
||||||
|
artist: this.playerHandler.displayAuthor || this.mediaMetadata.authorName || 'Unknown',
|
||||||
|
album: this.mediaMetadata.seriesName || '',
|
||||||
|
artwork
|
||||||
|
})
|
||||||
|
console.log('Set media session metadata', navigator.mediaSession.metadata)
|
||||||
|
|
||||||
|
navigator.mediaSession.setActionHandler('play', this.mediaSessionPlay)
|
||||||
|
navigator.mediaSession.setActionHandler('pause', this.mediaSessionPause)
|
||||||
|
navigator.mediaSession.setActionHandler('stop', this.mediaSessionStop)
|
||||||
|
navigator.mediaSession.setActionHandler('seekbackward', this.mediaSessionSeekBackward)
|
||||||
|
navigator.mediaSession.setActionHandler('seekforward', this.mediaSessionSeekForward)
|
||||||
|
navigator.mediaSession.setActionHandler('seekto', this.mediaSessionSeekTo)
|
||||||
|
// navigator.mediaSession.setActionHandler('previoustrack')
|
||||||
|
// navigator.mediaSession.setActionHandler('nexttrack')
|
||||||
|
} else {
|
||||||
|
console.warn('Media session not available')
|
||||||
|
}
|
||||||
|
},
|
||||||
streamProgress(data) {
|
streamProgress(data) {
|
||||||
if (!data.numSegments) return
|
if (!data.numSegments) return
|
||||||
var chunks = data.chunks
|
var chunks = data.chunks
|
||||||
@@ -312,7 +375,6 @@ export default {
|
|||||||
libraryItem,
|
libraryItem,
|
||||||
episodeId
|
episodeId
|
||||||
})
|
})
|
||||||
|
|
||||||
this.playerHandler.load(libraryItem, episodeId, true, this.initialPlaybackRate)
|
this.playerHandler.load(libraryItem, episodeId, true, this.initialPlaybackRate)
|
||||||
},
|
},
|
||||||
pauseItem() {
|
pauseItem() {
|
||||||
|
|||||||
@@ -65,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
|
||||||
},
|
},
|
||||||
@@ -81,7 +84,11 @@ export default {
|
|||||||
},
|
},
|
||||||
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
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -109,19 +109,14 @@ export default {
|
|||||||
hasValidCovers() {
|
hasValidCovers() {
|
||||||
var validCovers = this.bookItems.map((bookItem) => bookItem.media.coverPath)
|
var validCovers = this.bookItems.map((bookItem) => bookItem.media.coverPath)
|
||||||
return !!validCovers.length
|
return !!validCovers.length
|
||||||
},
|
|
||||||
showExperimentalFeatures() {
|
|
||||||
return this.$store.state.showExperimentalFeatures
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
mouseoverCard() {
|
mouseoverCard() {
|
||||||
this.isHovering = true
|
this.isHovering = true
|
||||||
// if (this.$refs.groupcover) this.$refs.groupcover.setHover(true)
|
|
||||||
},
|
},
|
||||||
mouseleaveCard() {
|
mouseleaveCard() {
|
||||||
this.isHovering = false
|
this.isHovering = false
|
||||||
// if (this.$refs.groupcover) this.$refs.groupcover.setHover(false)
|
|
||||||
},
|
},
|
||||||
clickCard() {
|
clickCard() {
|
||||||
this.$emit('click', this.group)
|
this.$emit('click', this.group)
|
||||||
|
|||||||
@@ -147,6 +147,9 @@ export default {
|
|||||||
showExperimentalFeatures() {
|
showExperimentalFeatures() {
|
||||||
return this.store.state.showExperimentalFeatures
|
return this.store.state.showExperimentalFeatures
|
||||||
},
|
},
|
||||||
|
enableEReader() {
|
||||||
|
return this.store.getters['getServerSetting']('enableEReader')
|
||||||
|
},
|
||||||
_libraryItem() {
|
_libraryItem() {
|
||||||
return this.libraryItem || {}
|
return this.libraryItem || {}
|
||||||
},
|
},
|
||||||
@@ -287,13 +290,13 @@ export default {
|
|||||||
return this.store.getters['getlibraryItemIdStreaming'] === this.libraryItemId
|
return this.store.getters['getlibraryItemIdStreaming'] === this.libraryItemId
|
||||||
},
|
},
|
||||||
showReadButton() {
|
showReadButton() {
|
||||||
return !this.isSelectionMode && this.showExperimentalFeatures && !this.showPlayButton && this.hasEbook
|
return !this.isSelectionMode && !this.showPlayButton && this.hasEbook && (this.showExperimentalFeatures || this.enableEReader)
|
||||||
},
|
},
|
||||||
showPlayButton() {
|
showPlayButton() {
|
||||||
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode)
|
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode)
|
||||||
},
|
},
|
||||||
showSmallEBookIcon() {
|
showSmallEBookIcon() {
|
||||||
return !this.isSelectionMode && this.showExperimentalFeatures && this.hasEbook
|
return !this.isSelectionMode && this.hasEbook && (this.showExperimentalFeatures || this.enableEReader)
|
||||||
},
|
},
|
||||||
isMissing() {
|
isMissing() {
|
||||||
return this._libraryItem.isMissing
|
return this._libraryItem.isMissing
|
||||||
@@ -442,7 +445,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) {
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -33,8 +33,8 @@ export default {
|
|||||||
showMenu: false,
|
showMenu: false,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
text: 'Current',
|
text: 'Pub Date',
|
||||||
value: 'index'
|
value: 'publishedAt'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Title',
|
text: 'Title',
|
||||||
@@ -47,10 +47,6 @@ export default {
|
|||||||
{
|
{
|
||||||
text: 'Episode',
|
text: 'Episode',
|
||||||
value: 'episode'
|
value: 'episode'
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Pub Date',
|
|
||||||
value: 'publishedAt'
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,9 +59,6 @@ export default {
|
|||||||
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
|
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
|
||||||
return this.width / 240
|
return this.width / 240
|
||||||
},
|
},
|
||||||
showExperimentalFeatures() {
|
|
||||||
return this.store.state.showExperimentalFeatures
|
|
||||||
},
|
|
||||||
store() {
|
store() {
|
||||||
return this.$store || this.$nuxt.$store
|
return this.$store || this.$nuxt.$store
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,12 +6,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
|
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
|
||||||
<form @submit.prevent="submitForm">
|
<form v-if="author" @submit.prevent="submitForm">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<div class="w-40 p-2">
|
<div class="w-40 p-2">
|
||||||
<div class="w-full h-45 relative">
|
<div class="w-full h-45 relative">
|
||||||
<covers-author-image :author="author" />
|
<covers-author-image :author="author" />
|
||||||
<div v-show="!processing" class="absolute top-0 left-0 w-full h-full opacity-0 hover:opacity-100">
|
<div v-show="!processing && author.imagePath" class="absolute top-0 left-0 w-full h-full opacity-0 hover:opacity-100">
|
||||||
<span class="absolute top-2 right-2 material-icons text-error transform hover:scale-125 transition-transform cursor-pointer text-lg" @click="removeCover">delete</span>
|
<span class="absolute top-2 right-2 material-icons text-error transform hover:scale-125 transition-transform cursor-pointer text-lg" @click="removeCover">delete</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -139,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
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -64,8 +64,7 @@ export default {
|
|||||||
{
|
{
|
||||||
id: 'manage',
|
id: 'manage',
|
||||||
title: 'Manage',
|
title: 'Manage',
|
||||||
component: 'modals-item-tabs-manage',
|
component: 'modals-item-tabs-manage'
|
||||||
experimental: true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Split to mp3 -->
|
<!-- Split to mp3 -->
|
||||||
<div v-if="showMp3Split" class="w-full border border-black-200 p-4 my-8">
|
<div v-if="showMp3Split && showExperimentalFeatures" class="w-full border border-black-200 p-4 my-8">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-lg">Split M4B to MP3's</p>
|
<p class="text-lg">Split M4B to MP3's</p>
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Embed Metadata -->
|
<!-- Embed Metadata -->
|
||||||
<div v-if="mediaTracks.length" class="w-full border border-black-200 p-4 my-8">
|
<div v-if="mediaTracks.length && showExperimentalFeatures" class="w-full border border-black-200 p-4 my-8">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-lg">Embed Metadata</p>
|
<p class="text-lg">Embed Metadata</p>
|
||||||
@@ -113,6 +113,9 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
showExperimentalFeatures() {
|
||||||
|
return this.$store.state.showExperimentalFeatures
|
||||||
|
},
|
||||||
libraryItemId() {
|
libraryItemId() {
|
||||||
return this.libraryItem ? this.libraryItem.id : null
|
return this.libraryItem ? this.libraryItem.id : null
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -28,10 +28,9 @@
|
|||||||
<ui-editable-text v-model="newFolderPath" placeholder="New folder path" type="text" class="w-full" @blur="newFolderInputBlurred" />
|
<ui-editable-text v-model="newFolderPath" placeholder="New folder path" type="text" class="w-full" @blur="newFolderInputBlurred" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ui-btn class="w-full mt-2" color="primary" @click="showDirectoryPicker = true">Browse for Folder</ui-btn>
|
<ui-btn class="w-full mt-2" color="primary" @click="browseForFolder">Browse for Folder</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<modals-libraries-folder-chooser v-else :paths="folderPaths" @back="showDirectoryPicker = false" @select="selectFolder" />
|
<modals-libraries-folder-chooser v-else :paths="folderPaths" @back="showDirectoryPicker = false" @select="selectFolder" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -77,6 +76,9 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
browseForFolder() {
|
||||||
|
this.showDirectoryPicker = true
|
||||||
|
},
|
||||||
getLibraryData() {
|
getLibraryData() {
|
||||||
return {
|
return {
|
||||||
name: this.name,
|
name: this.name,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<template>
|
||||||
|
<modals-modal v-model="show" name="podcast-episode-remove-modal" :width="500" :height="'unset'" :processing="processing">
|
||||||
|
<template #outer>
|
||||||
|
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||||
|
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div ref="wrapper" class="px-8 py-6 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
|
||||||
|
<div class="mb-4">
|
||||||
|
<p class="text-lg text-gray-200 mb-4">
|
||||||
|
Are you sure you want to remove episode<br /><span class="text-base">{{ episodeTitle }}</span
|
||||||
|
>?
|
||||||
|
</p>
|
||||||
|
<p class="text-xs font-semibold text-warning text-opacity-90">Note: This does not delete the audio file unless toggling "Hard delete file"</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center pt-4">
|
||||||
|
<ui-checkbox v-model="hardDeleteFile" label="Hard delete file" check-color="error" checkbox-bg="bg" small label-class="text-base text-gray-200 pl-3" />
|
||||||
|
|
||||||
|
<ui-btn @click="submit">{{ hardDeleteFile ? 'Delete episode' : 'Remove episode' }}</ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</modals-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: Boolean,
|
||||||
|
libraryItem: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
},
|
||||||
|
episode: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
hardDeleteFile: false,
|
||||||
|
processing: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
value(newVal) {
|
||||||
|
if (newVal) this.hardDeleteFile = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
return 'Remove Episode'
|
||||||
|
},
|
||||||
|
episodeId() {
|
||||||
|
return this.episode ? this.episode.id : null
|
||||||
|
},
|
||||||
|
episodeTitle() {
|
||||||
|
return this.episode ? this.episode.title : null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
submit() {
|
||||||
|
this.processing = true
|
||||||
|
|
||||||
|
var queryString = this.hardDeleteFile ? '?hard=1' : ''
|
||||||
|
this.$axios
|
||||||
|
.$delete(`/api/podcasts/${this.libraryItem.id}/episode/${this.episodeId}${queryString}`)
|
||||||
|
.then(() => {
|
||||||
|
this.processing = false
|
||||||
|
this.$toast.success('Podcast episode removed')
|
||||||
|
this.show = false
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed remove episode'
|
||||||
|
console.error('Failed update episode', error)
|
||||||
|
this.processing = false
|
||||||
|
this.$toast.error(errorMsg)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -122,7 +122,7 @@ export default {
|
|||||||
|
|
||||||
console.log('Payload', payload)
|
console.log('Payload', payload)
|
||||||
this.$axios
|
this.$axios
|
||||||
.$post(`/api/podcasts/${this.libraryItemId}/open-feed`, payload)
|
.$post(`/api/items/${this.libraryItemId}/open-feed`, payload)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
console.log('Opened RSS Feed', data)
|
console.log('Opened RSS Feed', data)
|
||||||
@@ -143,7 +143,7 @@ export default {
|
|||||||
closeFeed() {
|
closeFeed() {
|
||||||
this.processing = true
|
this.processing = true
|
||||||
this.$axios
|
this.$axios
|
||||||
.$post(`/api/podcasts/${this.libraryItem.id}/close-feed`)
|
.$post(`/api/items/${this.libraryItem.id}/close-feed`)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.$toast.success('RSS Feed Closed')
|
this.$toast.success('RSS Feed Closed')
|
||||||
this.show = false
|
this.show = false
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -73,10 +73,28 @@ export default {
|
|||||||
this.$emit('edit', this.library)
|
this.$emit('edit', this.library)
|
||||||
},
|
},
|
||||||
scan() {
|
scan() {
|
||||||
this.$store.dispatch('libraries/requestLibraryScan', { libraryId: this.library.id })
|
this.$store
|
||||||
|
.dispatch('libraries/requestLibraryScan', { libraryId: this.library.id })
|
||||||
|
.then(() => {
|
||||||
|
this.$toast.success('Library scan started')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to start scan', error)
|
||||||
|
this.$toast.error('Failed to start scan')
|
||||||
|
})
|
||||||
},
|
},
|
||||||
forceScan() {
|
forceScan() {
|
||||||
this.$store.dispatch('libraries/requestLibraryScan', { libraryId: this.library.id, force: 1 })
|
if (confirm(`Force Re-Scan will scan all files again like a fresh scan. Audio file ID3 tags, OPF files, and text files will be probed/parsed to be used for the library item.\n\nAre you sure you want to force re-scan?`)) {
|
||||||
|
this.$store
|
||||||
|
.dispatch('libraries/requestLibraryScan', { libraryId: this.library.id, force: 1 })
|
||||||
|
.then(() => {
|
||||||
|
this.$toast.success('Library scan started')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to start scan', error)
|
||||||
|
this.$toast.error('Failed to start scan')
|
||||||
|
})
|
||||||
|
}
|
||||||
},
|
},
|
||||||
deleteClick() {
|
deleteClick() {
|
||||||
if (this.isMain) return
|
if (this.isMain) return
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full px-2 py-3 overflow-hidden relative border-b border-white border-opacity-10" @mouseover="mouseover" @mouseleave="mouseleave">
|
<div class="w-full px-2 py-3 overflow-hidden relative border-b border-white border-opacity-10" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||||
<div v-if="episode" class="flex items-center h-24">
|
<div v-if="episode" class="flex items-center h-24">
|
||||||
<div v-show="userCanUpdate" class="w-12 min-w-12 max-w-16 h-full">
|
|
||||||
<div class="flex h-full items-center justify-center">
|
|
||||||
<span class="material-icons drag-handle text-lg text-white text-opacity-50 hover:text-opacity-100">menu</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow px-2">
|
<div class="flex-grow px-2">
|
||||||
<p class="text-sm font-semibold">
|
<p class="text-sm font-semibold">
|
||||||
{{ title }}
|
{{ title }}
|
||||||
@@ -49,8 +44,7 @@ export default {
|
|||||||
episode: {
|
episode: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => {}
|
default: () => {}
|
||||||
},
|
}
|
||||||
isDragging: Boolean
|
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -59,15 +53,6 @@ export default {
|
|||||||
isHovering: false
|
isHovering: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
|
||||||
isDragging: {
|
|
||||||
handler(newVal) {
|
|
||||||
if (newVal) {
|
|
||||||
this.isHovering = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
computed: {
|
||||||
userCanUpdate() {
|
userCanUpdate() {
|
||||||
return this.$store.getters['user/getUserCanUpdate']
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
@@ -117,7 +102,7 @@ export default {
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
mouseover() {
|
mouseover() {
|
||||||
if (this.isDragging) return
|
// if (this.isDragging) return
|
||||||
this.isHovering = true
|
this.isHovering = true
|
||||||
},
|
},
|
||||||
mouseleave() {
|
mouseleave() {
|
||||||
@@ -154,22 +139,7 @@ export default {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
removeClick() {
|
removeClick() {
|
||||||
if (confirm(`Are you sure you want to remove episode ${this.title}?\nNote: Does not delete from file system`)) {
|
this.$emit('remove', this.episode)
|
||||||
this.processingRemove = true
|
|
||||||
|
|
||||||
this.$axios
|
|
||||||
.$delete(`/api/items/${this.libraryItemId}/episode/${this.episode.id}`)
|
|
||||||
.then((updatedPodcast) => {
|
|
||||||
console.log(`Episode removed from podcast`, updatedPodcast)
|
|
||||||
this.$toast.success('Episode removed from podcast')
|
|
||||||
this.processingRemove = false
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed to remove episode from podcast', error)
|
|
||||||
this.$toast.error('Failed to remove episode from podcast')
|
|
||||||
this.processingRemove = false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,29 +3,19 @@
|
|||||||
<div class="flex items-center mb-4">
|
<div class="flex items-center mb-4">
|
||||||
<p class="text-lg mb-0 font-semibold">Episodes</p>
|
<p class="text-lg mb-0 font-semibold">Episodes</p>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<controls-episode-sort-select v-model="sortKey" :descending.sync="sortDesc" class="w-36 sm:w-44 md:w-48 h-9 ml-1 sm:ml-4" @change="changeSort" />
|
<controls-episode-sort-select v-model="sortKey" :descending.sync="sortDesc" class="w-36 sm:w-44 md:w-48 h-9 ml-1 sm:ml-4" />
|
||||||
<div v-if="userCanUpdate" class="w-12">
|
|
||||||
<ui-icon-btn v-if="orderChanged" :loading="savingOrder" icon="save" bg-color="primary" class="ml-auto" @click="saveOrder" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<p v-if="!episodes.length" class="py-4 text-center text-lg">No Episodes</p>
|
<p v-if="!episodes.length" class="py-4 text-center text-lg">No Episodes</p>
|
||||||
<draggable v-model="episodesCopy" v-bind="dragOptions" class="list-group" handle=".drag-handle" draggable=".item" tag="div" @start="drag = true" @end="drag = false" @update="draggableUpdate">
|
<template v-for="episode in episodesSorted">
|
||||||
<transition-group type="transition" :name="!drag ? 'episode' : null">
|
<tables-podcast-episode-table-row :key="episode.id" :episode="episode" :library-item-id="libraryItem.id" class="item" @remove="removeEpisode" @edit="editEpisode" />
|
||||||
<template v-for="episode in episodesCopy">
|
</template>
|
||||||
<tables-podcast-episode-table-row :key="episode.id" :is-dragging="drag" :episode="episode" :library-item-id="libraryItem.id" class="item" :class="drag ? '' : 'episode'" @edit="editEpisode" />
|
|
||||||
</template>
|
<modals-podcast-remove-episode v-model="showPodcastRemoveModal" :library-item="libraryItem" :episode="selectedEpisode" />
|
||||||
</transition-group>
|
|
||||||
</draggable>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import draggable from 'vuedraggable'
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
|
||||||
draggable
|
|
||||||
},
|
|
||||||
props: {
|
props: {
|
||||||
libraryItem: {
|
libraryItem: {
|
||||||
type: Object,
|
type: Object,
|
||||||
@@ -34,30 +24,19 @@ export default {
|
|||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
sortKey: 'index',
|
|
||||||
sortDesc: true,
|
|
||||||
drag: false,
|
|
||||||
episodesCopy: [],
|
episodesCopy: [],
|
||||||
orderChanged: false,
|
sortKey: 'publishedAt',
|
||||||
savingOrder: false
|
sortDesc: true,
|
||||||
|
selectedEpisode: null,
|
||||||
|
showPodcastRemoveModal: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
libraryItem: {
|
libraryItem() {
|
||||||
handler(newVal) {
|
this.init()
|
||||||
this.init()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
dragOptions() {
|
|
||||||
return {
|
|
||||||
animation: 200,
|
|
||||||
group: 'description',
|
|
||||||
ghostClass: 'ghost',
|
|
||||||
disabled: !this.userCanUpdate
|
|
||||||
}
|
|
||||||
},
|
|
||||||
userCanUpdate() {
|
userCanUpdate() {
|
||||||
return this.$store.getters['user/getUserCanUpdate']
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
},
|
},
|
||||||
@@ -69,64 +48,28 @@ export default {
|
|||||||
},
|
},
|
||||||
episodes() {
|
episodes() {
|
||||||
return this.media.episodes || []
|
return this.media.episodes || []
|
||||||
}
|
},
|
||||||
},
|
episodesSorted() {
|
||||||
methods: {
|
return this.episodesCopy.sort((a, b) => {
|
||||||
changeSort() {
|
|
||||||
this.episodesCopy.sort((a, b) => {
|
|
||||||
if (this.sortDesc) {
|
if (this.sortDesc) {
|
||||||
return String(b[this.sortKey]).localeCompare(String(a[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' })
|
return String(b[this.sortKey]).localeCompare(String(a[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' })
|
||||||
}
|
}
|
||||||
return String(a[this.sortKey]).localeCompare(String(b[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' })
|
return String(a[this.sortKey]).localeCompare(String(b[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' })
|
||||||
})
|
})
|
||||||
|
}
|
||||||
this.orderChanged = this.checkHasOrderChanged()
|
},
|
||||||
},
|
methods: {
|
||||||
checkHasOrderChanged() {
|
removeEpisode(episode) {
|
||||||
for (let i = 0; i < this.episodesCopy.length; i++) {
|
this.selectedEpisode = episode
|
||||||
var epc = this.episodesCopy[i]
|
this.showPodcastRemoveModal = true
|
||||||
var ep = this.episodes[i]
|
|
||||||
if (epc.index != ep.index) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
},
|
},
|
||||||
editEpisode(episode) {
|
editEpisode(episode) {
|
||||||
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
|
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
|
||||||
this.$store.commit('globals/setSelectedEpisode', episode)
|
this.$store.commit('globals/setSelectedEpisode', episode)
|
||||||
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
|
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
|
||||||
},
|
},
|
||||||
draggableUpdate() {
|
|
||||||
this.orderChanged = this.checkHasOrderChanged()
|
|
||||||
},
|
|
||||||
async saveOrder() {
|
|
||||||
if (!this.userCanUpdate) return
|
|
||||||
|
|
||||||
this.savingOrder = true
|
|
||||||
|
|
||||||
var episodesUpdate = {
|
|
||||||
episodes: this.episodesCopy.map((b) => b.id)
|
|
||||||
}
|
|
||||||
await this.$axios
|
|
||||||
.$patch(`/api/items/${this.libraryItem.id}/episodes`, episodesUpdate)
|
|
||||||
.then((podcast) => {
|
|
||||||
console.log('Podcast updated', podcast)
|
|
||||||
this.$toast.success('Saved episode order')
|
|
||||||
this.orderChanged = false
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed to update podcast', error)
|
|
||||||
this.$toast.error('Failed to save podcast episode order')
|
|
||||||
})
|
|
||||||
this.savingOrder = false
|
|
||||||
},
|
|
||||||
init() {
|
init() {
|
||||||
this.episodesCopy = this.episodes.map((ep) => {
|
this.episodesCopy = this.episodes.map((ep) => ({ ...ep }))
|
||||||
return {
|
|
||||||
...ep
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|||||||
@@ -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,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" />
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll -mx-2" style="scroll-behavior: smooth" @scroll="scrolled">
|
<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' }">
|
<div class="flex" :style="{ height: height + 'px' }">
|
||||||
<template v-for="(item, index) in items">
|
<template v-for="(item, index) in items">
|
||||||
<cards-lazy-book-card :key="item.id" :ref="`slider-episode-${item.recentEpisode.id}`" :index="index" :book-mount="item" :height="cardHeight" :width="cardWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" :bookshelf-view="bookshelfView" class="relative mx-2" @edit="editEpisode" @editPodcast="editPodcast" @select="selectItem" @hook:updated="setScrollVars" />
|
<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>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="absolute w-32 bg-bg rounded-md border border-black-200 shadow-lg z-50" v-click-outside="clickOutsideObj" style="top: 0; left: 0">
|
<div class="absolute w-36 bg-bg rounded-md border border-black-200 shadow-lg z-50" v-click-outside="clickOutsideObj" style="top: 0; left: 0">
|
||||||
<template v-for="(item, index) in items">
|
<template v-for="(item, index) in items">
|
||||||
<div :key="index" class="flex h-7 items-center px-2 hover:bg-white hover:bg-opacity-5 text-white text-xs cursor-pointer" @click="clickAction(item.func)">
|
<div :key="index" class="flex h-7 items-center px-2 hover:bg-white hover:bg-opacity-5 text-white text-xs cursor-pointer" @click="clickAction(item.func)">
|
||||||
<p>{{ item.text }}</p>
|
<p>{{ item.text }}</p>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|
||||||
@@ -45,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: {
|
||||||
@@ -163,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
|
||||||
@@ -181,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')) {
|
||||||
@@ -550,4 +563,20 @@ 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;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
#app-content.has-siderail {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
margin-left: 0px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
Generated
+701
-1604
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.0.13",
|
"version": "2.0.16",
|
||||||
"description": "Audiobook manager and player",
|
"description": "Self-hosted audiobook and podcast client",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "nuxt",
|
"dev": "nuxt",
|
||||||
|
|||||||
@@ -126,9 +126,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
showExperimentalFeatures() {
|
|
||||||
return this.$store.state.showExperimentalFeatures
|
|
||||||
},
|
|
||||||
media() {
|
media() {
|
||||||
return this.libraryItem.media || {}
|
return this.libraryItem.media || {}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,13 +23,17 @@
|
|||||||
|
|
||||||
<div class="py-4">
|
<div class="py-4">
|
||||||
<widgets-item-slider :items="libraryItems" :bookshelf-view="$constants.BookshelfView.AUTHOR">
|
<widgets-item-slider :items="libraryItems" :bookshelf-view="$constants.BookshelfView.AUTHOR">
|
||||||
<h2 class="text-lg">{{ libraryItems.length }} Books</h2>
|
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(author.id)}`" class="hover:underline">
|
||||||
|
<h2 class="text-lg">{{ libraryItems.length }} Books</h2>
|
||||||
|
</nuxt-link>
|
||||||
</widgets-item-slider>
|
</widgets-item-slider>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-for="series in authorSeries" :key="series.id" class="py-4">
|
<div v-for="series in authorSeries" :key="series.id" class="py-4">
|
||||||
<widgets-item-slider :items="series.items" :bookshelf-view="$constants.BookshelfView.AUTHOR">
|
<widgets-item-slider :items="series.items" :bookshelf-view="$constants.BookshelfView.AUTHOR">
|
||||||
<h2 class="text-lg">{{ series.name }}</h2>
|
<nuxt-link :to="`/library/${currentLibraryId}/series/${series.id}`" class="hover:underline">
|
||||||
|
<h2 class="text-lg">{{ series.name }}</h2>
|
||||||
|
</nuxt-link>
|
||||||
<p class="text-white text-opacity-40 text-base px-2">Series</p>
|
<p class="text-white text-opacity-40 text-base px-2">Series</p>
|
||||||
</widgets-item-slider>
|
</widgets-item-slider>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -122,6 +122,20 @@
|
|||||||
</p>
|
</p>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center mb-2 mt-8">
|
||||||
|
<h1 class="text-xl">Experimental Feature Settings</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center py-2">
|
||||||
|
<ui-toggle-switch v-model="newServerSettings.enableEReader" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('enableEReader', val)" />
|
||||||
|
<ui-tooltip :text="tooltips.enableEReader">
|
||||||
|
<p class="pl-4 text-lg">
|
||||||
|
Enable e-reader for all users
|
||||||
|
<span class="material-icons icon-text">info_outlined</span>
|
||||||
|
</p>
|
||||||
|
</ui-tooltip>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="h-0.5 bg-primary bg-opacity-30 w-full" />
|
<div class="h-0.5 bg-primary bg-opacity-30 w-full" />
|
||||||
@@ -169,10 +183,12 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<ui-toggle-switch v-model="showExperimentalFeatures" />
|
<ui-toggle-switch v-model="showExperimentalFeatures" />
|
||||||
<ui-tooltip :text="experimentalFeaturesTooltip">
|
<ui-tooltip :text="tooltips.experimentalFeatures">
|
||||||
<p class="pl-4 text-lg">
|
<p class="pl-4 text-lg">
|
||||||
Experimental Features
|
Experimental Features
|
||||||
<span class="material-icons icon-text">info_outlined</span>
|
<a href="https://github.com/advplyr/audiobookshelf/discussions/75" target="_blank">
|
||||||
|
<span class="material-icons icon-text">info_outlined</span>
|
||||||
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -207,6 +223,7 @@ export default {
|
|||||||
isPurgingCache: false,
|
isPurgingCache: false,
|
||||||
newServerSettings: {},
|
newServerSettings: {},
|
||||||
tooltips: {
|
tooltips: {
|
||||||
|
experimentalFeatures: 'Features in development that could use your feedback and help testing. Click to open github discussion.',
|
||||||
scannerDisableWatcher: 'Disables the automatic adding/updating of items when file changes are detected. *Requires server restart',
|
scannerDisableWatcher: 'Disables the automatic adding/updating of items when file changes are detected. *Requires server restart',
|
||||||
scannerPreferOpfMetadata: 'OPF file metadata will be used for book details over folder names',
|
scannerPreferOpfMetadata: 'OPF file metadata will be used for book details over folder names',
|
||||||
scannerPreferAudioMetadata: 'Audio file ID3 meta tags will be used for book details over folder names',
|
scannerPreferAudioMetadata: 'Audio file ID3 meta tags will be used for book details over folder names',
|
||||||
@@ -216,7 +233,8 @@ export default {
|
|||||||
bookshelfView: 'Alternative view without wooden bookshelf',
|
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',
|
||||||
|
enableEReader: 'E-reader is still a work in progress, but use this setting to open it up to all your users (or use the "Experimental Features" toggle below just for you)'
|
||||||
},
|
},
|
||||||
showConfirmPurgeCache: false
|
showConfirmPurgeCache: false
|
||||||
}
|
}
|
||||||
@@ -229,9 +247,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
experimentalFeaturesTooltip() {
|
|
||||||
return 'Features in development that could use your feedback and help testing.'
|
|
||||||
},
|
|
||||||
serverSettings() {
|
serverSettings() {
|
||||||
return this.$store.state.serverSettings
|
return this.$store.state.serverSettings
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -104,9 +104,6 @@ export default {
|
|||||||
bookCoverAspectRatio() {
|
bookCoverAspectRatio() {
|
||||||
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6
|
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6
|
||||||
},
|
},
|
||||||
showExperimentalFeatures() {
|
|
||||||
return this.$store.state.showExperimentalFeatures
|
|
||||||
},
|
|
||||||
username() {
|
username() {
|
||||||
return this.user.username
|
return this.user.username
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -92,7 +92,8 @@
|
|||||||
<!-- Alerts -->
|
<!-- Alerts -->
|
||||||
<div v-show="showExperimentalReadAlert" class="bg-error p-4 rounded-xl flex items-center">
|
<div v-show="showExperimentalReadAlert" class="bg-error p-4 rounded-xl flex items-center">
|
||||||
<span class="material-icons text-2xl">warning_amber</span>
|
<span class="material-icons text-2xl">warning_amber</span>
|
||||||
<p class="ml-4">Book has no audio tracks but has valid ebook files. The e-reader is experimental and can be turned on in config.</p>
|
<p v-if="userIsAdminOrUp" class="ml-4">Book has no audio tracks but has an ebook. The experimental e-reader can be enabled in config.</p>
|
||||||
|
<p v-else class="ml-4">Book has no audio tracks but has an ebook. The experimental e-reader must be enabled by a server admin.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Podcast episode downloads queue -->
|
<!-- Podcast episode downloads queue -->
|
||||||
@@ -135,7 +136,7 @@
|
|||||||
{{ isMissing ? 'Missing' : 'Incomplete' }}
|
{{ isMissing ? 'Missing' : 'Incomplete' }}
|
||||||
</ui-btn>
|
</ui-btn>
|
||||||
|
|
||||||
<ui-btn v-if="showExperimentalFeatures && ebookFile" color="info" :padding-x="4" small class="flex items-center h-9 mr-2" @click="openEbook">
|
<ui-btn v-if="showReadButton" color="info" :padding-x="4" small class="flex items-center h-9 mr-2" @click="openEbook">
|
||||||
<span class="material-icons -ml-2 pr-2 text-white">auto_stories</span>
|
<span class="material-icons -ml-2 pr-2 text-white">auto_stories</span>
|
||||||
Read
|
Read
|
||||||
</ui-btn>
|
</ui-btn>
|
||||||
@@ -223,6 +224,12 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
showExperimentalFeatures() {
|
||||||
|
return this.$store.state.showExperimentalFeatures
|
||||||
|
},
|
||||||
|
enableEReader() {
|
||||||
|
return this.$store.getters['getServerSetting']('enableEReader')
|
||||||
|
},
|
||||||
userIsAdminOrUp() {
|
userIsAdminOrUp() {
|
||||||
return this.$store.getters['user/getIsAdminOrUp']
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
},
|
},
|
||||||
@@ -241,9 +248,6 @@ export default {
|
|||||||
isDeveloperMode() {
|
isDeveloperMode() {
|
||||||
return this.$store.state.developerMode
|
return this.$store.state.developerMode
|
||||||
},
|
},
|
||||||
showExperimentalFeatures() {
|
|
||||||
return this.$store.state.showExperimentalFeatures
|
|
||||||
},
|
|
||||||
isPodcast() {
|
isPodcast() {
|
||||||
return this.libraryItem.mediaType === 'podcast'
|
return this.libraryItem.mediaType === 'podcast'
|
||||||
},
|
},
|
||||||
@@ -262,6 +266,9 @@ export default {
|
|||||||
if (this.isPodcast) return this.podcastEpisodes.length
|
if (this.isPodcast) return this.podcastEpisodes.length
|
||||||
return this.tracks.length
|
return this.tracks.length
|
||||||
},
|
},
|
||||||
|
showReadButton() {
|
||||||
|
return this.ebookFile && (this.showExperimentalFeatures || this.enableEReader)
|
||||||
|
},
|
||||||
libraryId() {
|
libraryId() {
|
||||||
return this.libraryItem.libraryId
|
return this.libraryItem.libraryId
|
||||||
},
|
},
|
||||||
@@ -342,7 +349,7 @@ export default {
|
|||||||
return this.media.ebookFile
|
return this.media.ebookFile
|
||||||
},
|
},
|
||||||
showExperimentalReadAlert() {
|
showExperimentalReadAlert() {
|
||||||
return !this.tracks.length && this.ebookFile && !this.showExperimentalFeatures
|
return !this.tracks.length && this.ebookFile && !this.showExperimentalFeatures && !this.enableEReader
|
||||||
},
|
},
|
||||||
description() {
|
description() {
|
||||||
return this.mediaMetadata.description || ''
|
return this.mediaMetadata.description || ''
|
||||||
@@ -383,10 +390,10 @@ export default {
|
|||||||
return this.$store.getters['user/getUserCanDownload']
|
return this.$store.getters['user/getUserCanDownload']
|
||||||
},
|
},
|
||||||
showRssFeedBtn() {
|
showRssFeedBtn() {
|
||||||
if (!this.rssFeedUrl && !this.podcastEpisodes.length) return false // Cannot open RSS feed with no episodes
|
if (!this.rssFeedUrl && !this.podcastEpisodes.length && !this.tracks.length) return false // Cannot open RSS feed with no episodes/tracks
|
||||||
|
|
||||||
// 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.userIsAdminOrUp || this.rssFeedUrl
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -1,16 +1,11 @@
|
|||||||
<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">
|
|
||||||
<cards-author-card :key="author.id" :author="author" :width="160" :height="200" class="p-3" @edit="editAuthor" />
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +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>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
+128
-29
@@ -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,8 +88,45 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
setUser({ user, userDefaultLibraryId, serverSettings }) {
|
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, Source }) {
|
||||||
this.$store.commit('setServerSettings', serverSettings)
|
this.$store.commit('setServerSettings', serverSettings)
|
||||||
|
this.$store.commit('setSource', Source)
|
||||||
|
|
||||||
if (serverSettings.chromecastEnabled) {
|
if (serverSettings.chromecastEnabled) {
|
||||||
console.log('Chromecast enabled import script')
|
console.log('Chromecast enabled import script')
|
||||||
@@ -81,32 +158,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>
|
||||||
@@ -179,6 +179,9 @@ export default class PlayerHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.player.set(this.libraryItem, audioTracks, this.isHlsTranscode, this.startTime, this.playWhenReady)
|
this.player.set(this.libraryItem, audioTracks, this.isHlsTranscode, this.startTime, this.playWhenReady)
|
||||||
|
|
||||||
|
// browser media session api
|
||||||
|
this.ctx.setMediaSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
closePlayer() {
|
closePlayer() {
|
||||||
|
|||||||
@@ -114,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 = /^\.+$/;
|
||||||
@@ -125,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)
|
||||||
@@ -161,17 +163,26 @@ Vue.prototype.$sanitizeSlug = (str) => {
|
|||||||
Vue.prototype.$copyToClipboard = (str, ctx) => {
|
Vue.prototype.$copyToClipboard = (str, ctx) => {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
if (!navigator.clipboard) {
|
if (!navigator.clipboard) {
|
||||||
console.warn('Clipboard not supported')
|
navigator.clipboard.writeText(str).then(() => {
|
||||||
return resolve(false)
|
if (ctx) ctx.$toast.success('Copied to clipboard')
|
||||||
|
resolve(true)
|
||||||
|
}, (err) => {
|
||||||
|
console.error('Clipboard copy failed', str, err)
|
||||||
|
resolve(false)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const el = document.createElement('textarea')
|
||||||
|
el.value = str
|
||||||
|
el.setAttribute('readonly', '')
|
||||||
|
el.style.position = 'absolute'
|
||||||
|
el.style.left = '-9999px'
|
||||||
|
document.body.appendChild(el)
|
||||||
|
el.select()
|
||||||
|
document.execCommand('copy')
|
||||||
|
document.body.removeChild(el)
|
||||||
|
|
||||||
|
if (ctx) ctx.$toast.success('Copied to clipboard')
|
||||||
}
|
}
|
||||||
navigator.clipboard.writeText(str).then(() => {
|
|
||||||
console.log('Clipboard copy success', str)
|
|
||||||
ctx.$toast.success('Copied to clipboard')
|
|
||||||
resolve(true)
|
|
||||||
}, (err) => {
|
|
||||||
console.error('Clipboard copy failed', str, err)
|
|
||||||
resolve(false)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { checkForUpdate } from '@/plugins/version'
|
|||||||
import Vue from 'vue'
|
import Vue from 'vue'
|
||||||
|
|
||||||
export const state = () => ({
|
export const state = () => ({
|
||||||
|
Source: null,
|
||||||
versionData: null,
|
versionData: null,
|
||||||
serverSettings: null,
|
serverSettings: null,
|
||||||
streamLibraryItem: null,
|
streamLibraryItem: null,
|
||||||
@@ -19,7 +20,8 @@ export const state = () => ({
|
|||||||
backups: [],
|
backups: [],
|
||||||
bookshelfBookIds: [],
|
bookshelfBookIds: [],
|
||||||
openModal: null,
|
openModal: null,
|
||||||
selectedBookshelfTexture: '/textures/wood_default.jpg'
|
selectedBookshelfTexture: '/textures/wood_default.jpg',
|
||||||
|
lastBookshelfScrollData: {}
|
||||||
})
|
})
|
||||||
|
|
||||||
export const getters = {
|
export const getters = {
|
||||||
@@ -80,6 +82,12 @@ export const actions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const mutations = {
|
export const mutations = {
|
||||||
|
setSource(state, source) {
|
||||||
|
state.Source = source
|
||||||
|
},
|
||||||
|
setLastBookshelfScrollData(state, { scrollTop, path, name }) {
|
||||||
|
state.lastBookshelfScrollData[name] = { scrollTop, path }
|
||||||
|
},
|
||||||
setBookshelfBookIds(state, val) {
|
setBookshelfBookIds(state, val) {
|
||||||
state.bookshelfBookIds = val || []
|
state.bookshelfBookIds = val || []
|
||||||
},
|
},
|
||||||
|
|||||||
+61
-32
@@ -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,
|
||||||
@@ -47,12 +47,7 @@ export const getters = {
|
|||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
requestLibraryScan({ state, commit }, { libraryId, force }) {
|
requestLibraryScan({ state, commit }, { libraryId, force }) {
|
||||||
this.$axios.$get(`/api/libraries/${libraryId}/scan`, { params: { force } }).then(() => {
|
return this.$axios.$get(`/api/libraries/${libraryId}/scan`, { params: { force } })
|
||||||
this.$toast.success('Library scan started')
|
|
||||||
}).catch((error) => {
|
|
||||||
console.error('Failed to start scan', error)
|
|
||||||
this.$toast.error('Failed to start scan')
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
loadFolders({ state, commit }) {
|
loadFolders({ state, commit }) {
|
||||||
if (state.folders.length) {
|
if (state.folders.length) {
|
||||||
@@ -206,11 +201,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 +214,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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
Generated
+366
-229
File diff suppressed because it is too large
Load Diff
+6
-7
@@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.0.13",
|
"version": "2.0.16",
|
||||||
"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",
|
||||||
"start": "node index.js",
|
"start": "node index.js",
|
||||||
"client": "cd client && npm install && npm run generate",
|
"client": "cd client && npm ci && npm run generate",
|
||||||
"prod": "npm run client && npm install && node prod.js",
|
"prod": "npm run client && npm ci && node prod.js",
|
||||||
"build-win": "pkg -t node16-win-x64 -o ./dist/win/audiobookshelf .",
|
"build-win": "npm run client && pkg -t node16-win-x64 -o ./dist/win/audiobookshelf -C GZip .",
|
||||||
"build-linux": "build/linuxpackager",
|
"build-linux": "build/linuxpackager",
|
||||||
"docker": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 --push . -t advplyr/audiobookshelf",
|
"docker": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 --push . -t advplyr/audiobookshelf",
|
||||||
"deploy": "node dist/autodeploy"
|
"deploy": "node dist/autodeploy"
|
||||||
@@ -52,6 +52,5 @@
|
|||||||
"string-strip-html": "^8.3.0",
|
"string-strip-html": "^8.3.0",
|
||||||
"watcher": "^1.2.0",
|
"watcher": "^1.2.0",
|
||||||
"xml2js": "^0.4.23"
|
"xml2js": "^0.4.23"
|
||||||
},
|
}
|
||||||
"devDependencies": {}
|
|
||||||
}
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -58,13 +58,11 @@ Available using Test Flight: https://testflight.apple.com/join/wiic7QIW - [Join
|
|||||||
|
|
||||||
# Installation
|
# Installation
|
||||||
|
|
||||||
** Default username is "root" with no password
|
|
||||||
|
|
||||||
### Docker Install
|
### Docker Install
|
||||||
Available in Unraid Community Apps
|
Available in Unraid Community Apps
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull ghcr.io/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 +73,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 +90,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
|
||||||
|
|||||||
+2
-9
@@ -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')
|
||||||
@@ -104,7 +96,8 @@ class Auth {
|
|||||||
return {
|
return {
|
||||||
user: user.toJSONForBrowser(),
|
user: user.toJSONForBrowser(),
|
||||||
userDefaultLibraryId: user.getDefaultLibraryId(this.db.libraries),
|
userDefaultLibraryId: user.getDefaultLibraryId(this.db.libraries),
|
||||||
serverSettings: this.db.serverSettings.toJSON()
|
serverSettings: this.db.serverSettings.toJSON(),
|
||||||
|
Source: global.Source
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+29
-39
@@ -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
@@ -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.Port = PORT
|
this.Port = PORT
|
||||||
this.Host = HOST
|
this.Host = HOST
|
||||||
|
global.Source = SOURCE
|
||||||
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(),
|
||||||
|
|||||||
@@ -162,13 +162,6 @@ class FolderWatcher extends EventEmitter {
|
|||||||
}
|
}
|
||||||
var folderFullPath = folder.fullPath.replace(/\\/g, '/')
|
var folderFullPath = folder.fullPath.replace(/\\/g, '/')
|
||||||
|
|
||||||
// Check if file was added to root directory
|
|
||||||
var dir = Path.dirname(path)
|
|
||||||
if (dir === folderFullPath) {
|
|
||||||
Logger.warn(`[Watcher] New file "${Path.basename(path)}" added to folder root - ignoring it`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var relPath = path.replace(folderFullPath, '')
|
var relPath = path.replace(folderFullPath, '')
|
||||||
|
|
||||||
var hasDotPath = relPath.split('/').find(p => p.startsWith('.'))
|
var hasDotPath = relPath.split('/').find(p => p.startsWith('.'))
|
||||||
|
|||||||
@@ -107,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) {
|
||||||
@@ -121,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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -85,13 +86,17 @@ class LibraryController {
|
|||||||
return f
|
return f
|
||||||
})
|
})
|
||||||
for (var path of newFolderPaths) {
|
for (var path of newFolderPaths) {
|
||||||
var success = await fs.ensureDir(path).then(() => true).catch((error) => {
|
var pathExists = await fs.pathExists(path)
|
||||||
Logger.error(`[LibraryController] Failed to ensure folder dir "${path}"`, error)
|
if (!pathExists) {
|
||||||
return false
|
// Ensure dir will recursively create directories which might be preferred over mkdir
|
||||||
})
|
var success = await fs.ensureDir(path).then(() => true).catch((error) => {
|
||||||
if (!success) {
|
Logger.error(`[LibraryController] Failed to ensure folder dir "${path}"`, error)
|
||||||
return res.status(400).send(`Invalid folder directory "${path}"`)
|
return false
|
||||||
} else {
|
})
|
||||||
|
if (!success) {
|
||||||
|
return res.status(400).send(`Invalid folder directory "${path}"`)
|
||||||
|
}
|
||||||
|
// Set permissions on newly created path
|
||||||
await filePerms.setDefault(path)
|
await filePerms.setDefault(path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -224,38 +224,6 @@ class LibraryItemController {
|
|||||||
res.json(libraryItem.toJSON())
|
res.json(libraryItem.toJSON())
|
||||||
}
|
}
|
||||||
|
|
||||||
// PATCH: api/items/:id/episodes
|
|
||||||
async updateEpisodes(req, res) { // For updating podcast episode order
|
|
||||||
var libraryItem = req.libraryItem
|
|
||||||
var orderedFileData = req.body.episodes
|
|
||||||
if (!libraryItem.media.setEpisodeOrder) {
|
|
||||||
Logger.error(`[LibraryItemController] updateEpisodes invalid media type ${libraryItem.id}`)
|
|
||||||
return res.sendStatus(500)
|
|
||||||
}
|
|
||||||
libraryItem.media.setEpisodeOrder(orderedFileData)
|
|
||||||
await this.db.updateLibraryItem(libraryItem)
|
|
||||||
this.emitter('item_updated', libraryItem.toJSONExpanded())
|
|
||||||
res.json(libraryItem.toJSON())
|
|
||||||
}
|
|
||||||
|
|
||||||
// DELETE: api/items/:id/episode/:episodeId
|
|
||||||
async removeEpisode(req, res) {
|
|
||||||
var episodeId = req.params.episodeId
|
|
||||||
var libraryItem = req.libraryItem
|
|
||||||
if (libraryItem.mediaType !== 'podcast') {
|
|
||||||
Logger.error(`[LibraryItemController] removeEpisode invalid media type ${libraryItem.id}`)
|
|
||||||
return res.sendStatus(500)
|
|
||||||
}
|
|
||||||
if (!libraryItem.media.episodes.find(ep => ep.id === episodeId)) {
|
|
||||||
Logger.error(`[LibraryItemController] removeEpisode episode ${episodeId} not found for item ${libraryItem.id}`)
|
|
||||||
return res.sendStatus(404)
|
|
||||||
}
|
|
||||||
libraryItem.media.removeEpisode(episodeId)
|
|
||||||
await this.db.updateLibraryItem(libraryItem)
|
|
||||||
this.emitter('item_updated', libraryItem.toJSONExpanded())
|
|
||||||
res.json(libraryItem.toJSON())
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST api/items/:id/match
|
// POST api/items/:id/match
|
||||||
async match(req, res) {
|
async match(req, res) {
|
||||||
var libraryItem = req.libraryItem
|
var libraryItem = req.libraryItem
|
||||||
@@ -405,6 +373,38 @@ class LibraryItemController {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST: api/items/:id/open-feed
|
||||||
|
async openRSSFeed(req, res) {
|
||||||
|
if (!req.user.isAdminOrUp) {
|
||||||
|
Logger.error(`[LibraryItemController] Non-admin user attempted to open RSS feed`, req.user.username)
|
||||||
|
return res.sendStatus(500)
|
||||||
|
}
|
||||||
|
|
||||||
|
const feedData = this.rssFeedManager.openFeedForItem(req.user, req.libraryItem, req.body)
|
||||||
|
if (feedData.error) {
|
||||||
|
return res.json({
|
||||||
|
success: false,
|
||||||
|
error: feedData.error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
feedUrl: feedData.feedUrl
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async closeRSSFeed(req, res) {
|
||||||
|
if (!req.user.isAdminOrUp) {
|
||||||
|
Logger.error(`[LibraryItemController] Non-admin user attempted to close RSS feed`, req.user.username)
|
||||||
|
return res.sendStatus(500)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.rssFeedManager.closeFeedForItem(req.params.id)
|
||||||
|
|
||||||
|
res.sendStatus(200)
|
||||||
|
}
|
||||||
|
|
||||||
middleware(req, res, next) {
|
middleware(req, res, next) {
|
||||||
var item = this.db.libraryItems.find(li => li.id === req.params.id)
|
var item = this.db.libraryItems.find(li => li.id === req.params.id)
|
||||||
if (!item || !item.media) return res.sendStatus(404)
|
if (!item || !item.media) return res.sendStatus(404)
|
||||||
|
|||||||
@@ -242,7 +242,8 @@ class MiscController {
|
|||||||
const userResponse = {
|
const userResponse = {
|
||||||
user: req.user,
|
user: req.user,
|
||||||
userDefaultLibraryId: req.user.getDefaultLibraryId(this.db.libraries),
|
userDefaultLibraryId: req.user.getDefaultLibraryId(this.db.libraries),
|
||||||
serverSettings: this.db.serverSettings.toJSON()
|
serverSettings: this.db.serverSettings.toJSON(),
|
||||||
|
Source: global.Source
|
||||||
}
|
}
|
||||||
res.json(userResponse)
|
res.json(userResponse)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -166,37 +173,6 @@ class PodcastController {
|
|||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
async openPodcastFeed(req, res) {
|
|
||||||
if (!req.user.isAdminOrUp) {
|
|
||||||
Logger.error(`[PodcastController] Non-admin user attempted to open podcast feed`, req.user.username)
|
|
||||||
return res.sendStatus(500)
|
|
||||||
}
|
|
||||||
|
|
||||||
const feedData = this.rssFeedManager.openPodcastFeed(req.user, req.libraryItem, req.body)
|
|
||||||
if (feedData.error) {
|
|
||||||
return res.json({
|
|
||||||
success: false,
|
|
||||||
error: feedData.error
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
feedUrl: feedData.feedUrl
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async closePodcastFeed(req, res) {
|
|
||||||
if (!req.user.isAdminOrUp) {
|
|
||||||
Logger.error(`[PodcastController] Non-admin user attempted to close podcast feed`, req.user.username)
|
|
||||||
return res.sendStatus(500)
|
|
||||||
}
|
|
||||||
|
|
||||||
this.rssFeedManager.closePodcastFeedForItem(req.params.id)
|
|
||||||
|
|
||||||
res.sendStatus(200)
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateEpisode(req, res) {
|
async updateEpisode(req, res) {
|
||||||
var libraryItem = req.libraryItem
|
var libraryItem = req.libraryItem
|
||||||
|
|
||||||
@@ -214,6 +190,35 @@ class PodcastController {
|
|||||||
res.json(libraryItem.toJSONExpanded())
|
res.json(libraryItem.toJSONExpanded())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DELETE: api/podcasts/:id/episode/:episodeId
|
||||||
|
async removeEpisode(req, res) {
|
||||||
|
var episodeId = req.params.episodeId
|
||||||
|
var libraryItem = req.libraryItem
|
||||||
|
var hardDelete = req.query.hard === '1'
|
||||||
|
|
||||||
|
var episode = libraryItem.media.episodes.find(ep => ep.id === episodeId)
|
||||||
|
if (!episode) {
|
||||||
|
Logger.error(`[PodcastController] removeEpisode episode ${episodeId} not found for item ${libraryItem.id}`)
|
||||||
|
return res.sendStatus(404)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hardDelete) {
|
||||||
|
var audioFile = episode.audioFile
|
||||||
|
// TODO: this will trigger the watcher. should maybe handle this gracefully
|
||||||
|
await fs.remove(audioFile.metadata.path).then(() => {
|
||||||
|
Logger.info(`[PodcastController] Hard deleted episode file at "${audioFile.metadata.path}"`)
|
||||||
|
}).catch((error) => {
|
||||||
|
Logger.error(`[PodcastController] Failed to hard delete episode file at "${audioFile.metadata.path}"`, error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
libraryItem.media.removeEpisode(episodeId)
|
||||||
|
|
||||||
|
await this.db.updateLibraryItem(libraryItem)
|
||||||
|
this.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||||
|
res.json(libraryItem.toJSON())
|
||||||
|
}
|
||||||
|
|
||||||
middleware(req, res, next) {
|
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)
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ 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) : 3
|
const maxLevenshtein = !isNaN(options.maxLevenshtein) ? Number(options.maxLevenshtein) : 3
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,7 +1,7 @@
|
|||||||
const Path = require('path')
|
const Path = require('path')
|
||||||
const fs = require('fs-extra')
|
const fs = require('fs-extra')
|
||||||
|
const date = require('date-and-time')
|
||||||
const { Podcast } = require('podcast')
|
const { Podcast } = require('podcast')
|
||||||
const { getId } = require('../utils/index')
|
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
|
|
||||||
// Not functional at the moment
|
// Not functional at the moment
|
||||||
@@ -60,36 +60,72 @@ class RssFeedManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
openFeed(userId, slug, libraryItem, serverAddress) {
|
openFeed(userId, slug, libraryItem, serverAddress) {
|
||||||
const podcast = libraryItem.media
|
const media = libraryItem.media
|
||||||
|
const mediaMetadata = media.metadata
|
||||||
|
const isPodcast = libraryItem.mediaType === 'podcast'
|
||||||
|
|
||||||
const feedUrl = `${serverAddress}/feed/${slug}`
|
const feedUrl = `${serverAddress}/feed/${slug}`
|
||||||
// Removed Podcast npm package and ip package
|
const author = isPodcast ? mediaMetadata.author : mediaMetadata.authorName
|
||||||
|
|
||||||
const feed = new Podcast({
|
const feed = new Podcast({
|
||||||
title: podcast.metadata.title,
|
title: mediaMetadata.title,
|
||||||
description: podcast.metadata.description,
|
description: mediaMetadata.description,
|
||||||
feedUrl,
|
feedUrl,
|
||||||
siteUrl: serverAddress,
|
siteUrl: `${serverAddress}/items/${libraryItem.id}`,
|
||||||
imageUrl: podcast.coverPath ? `${serverAddress}/feed/${slug}/cover` : `${serverAddress}/Logo.png`,
|
imageUrl: media.coverPath ? `${serverAddress}/feed/${slug}/cover` : `${serverAddress}/Logo.png`,
|
||||||
author: podcast.metadata.author || 'advplyr',
|
author: author || 'advplyr',
|
||||||
language: 'en'
|
language: 'en'
|
||||||
})
|
})
|
||||||
podcast.episodes.forEach((episode) => {
|
|
||||||
var contentUrl = episode.audioTrack.contentUrl.replace(/\\/g, '/')
|
|
||||||
contentUrl = contentUrl.replace(`/s/item/${libraryItem.id}`, `/feed/${slug}/item`)
|
|
||||||
|
|
||||||
feed.addItem({
|
if (isPodcast) { // PODCAST EPISODES
|
||||||
title: episode.title,
|
media.episodes.forEach((episode) => {
|
||||||
description: episode.description || '',
|
var contentUrl = episode.audioTrack.contentUrl.replace(/\\/g, '/')
|
||||||
enclosure: {
|
contentUrl = contentUrl.replace(`/s/item/${libraryItem.id}`, `/feed/${slug}/item`)
|
||||||
|
|
||||||
|
feed.addItem({
|
||||||
|
title: episode.title,
|
||||||
|
description: episode.description || '',
|
||||||
|
enclosure: {
|
||||||
|
url: `${serverAddress}${contentUrl}`,
|
||||||
|
type: episode.audioTrack.mimeType,
|
||||||
|
size: episode.size
|
||||||
|
},
|
||||||
|
date: episode.pubDate || '',
|
||||||
url: `${serverAddress}${contentUrl}`,
|
url: `${serverAddress}${contentUrl}`,
|
||||||
type: episode.audioTrack.mimeType,
|
author: author || 'advplyr'
|
||||||
size: episode.size
|
})
|
||||||
},
|
|
||||||
date: episode.pubDate || '',
|
|
||||||
url: `${serverAddress}${contentUrl}`,
|
|
||||||
author: podcast.metadata.author || 'advplyr'
|
|
||||||
})
|
})
|
||||||
})
|
} else { // AUDIOBOOK EPISODES
|
||||||
|
|
||||||
|
// Example: <pubDate>Fri, 04 Feb 2015 00:00:00 GMT</pubDate>
|
||||||
|
const audiobookPubDate = date.format(new Date(libraryItem.addedAt), 'ddd, DD MMM YYYY HH:mm:ss [GMT]')
|
||||||
|
|
||||||
|
media.tracks.forEach((audioTrack) => {
|
||||||
|
var contentUrl = audioTrack.contentUrl.replace(/\\/g, '/')
|
||||||
|
contentUrl = contentUrl.replace(`/s/item/${libraryItem.id}`, `/feed/${slug}/item`)
|
||||||
|
|
||||||
|
var title = audioTrack.title
|
||||||
|
if (media.chapters.length) {
|
||||||
|
// If audio track start and chapter start are within 1 seconds of eachother then use the chapter title
|
||||||
|
var matchingChapter = media.chapters.find(ch => Math.abs(ch.start - audioTrack.startOffset) < 1)
|
||||||
|
if (matchingChapter && matchingChapter.title) title = matchingChapter.title
|
||||||
|
}
|
||||||
|
|
||||||
|
feed.addItem({
|
||||||
|
title,
|
||||||
|
description: '',
|
||||||
|
enclosure: {
|
||||||
|
url: `${serverAddress}${contentUrl}`,
|
||||||
|
type: audioTrack.mimeType,
|
||||||
|
size: audioTrack.metadata.size
|
||||||
|
},
|
||||||
|
date: audiobookPubDate,
|
||||||
|
url: `${serverAddress}${contentUrl}`,
|
||||||
|
author: author || 'advplyr'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const feedData = {
|
const feedData = {
|
||||||
id: slug,
|
id: slug,
|
||||||
@@ -97,7 +133,7 @@ class RssFeedManager {
|
|||||||
userId,
|
userId,
|
||||||
libraryItemId: libraryItem.id,
|
libraryItemId: libraryItem.id,
|
||||||
libraryItemPath: libraryItem.path,
|
libraryItemPath: libraryItem.path,
|
||||||
mediaCoverPath: podcast.coverPath,
|
mediaCoverPath: media.coverPath,
|
||||||
serverAddress: serverAddress,
|
serverAddress: serverAddress,
|
||||||
feedUrl,
|
feedUrl,
|
||||||
feed
|
feed
|
||||||
@@ -106,7 +142,7 @@ class RssFeedManager {
|
|||||||
return feedData
|
return feedData
|
||||||
}
|
}
|
||||||
|
|
||||||
openPodcastFeed(user, libraryItem, options) {
|
openFeedForItem(user, libraryItem, options) {
|
||||||
const serverAddress = options.serverAddress
|
const serverAddress = options.serverAddress
|
||||||
const slug = options.slug
|
const slug = options.slug
|
||||||
|
|
||||||
@@ -118,12 +154,12 @@ class RssFeedManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const feedData = this.openFeed(user.id, slug, libraryItem, serverAddress)
|
const feedData = this.openFeed(user.id, slug, libraryItem, serverAddress)
|
||||||
Logger.debug(`[RssFeedManager] Opened podcast feed ${feedData.feedUrl}`)
|
Logger.debug(`[RssFeedManager] Opened RSS feed ${feedData.feedUrl}`)
|
||||||
this.emitter('rss_feed_open', { libraryItemId: libraryItem.id, feedUrl: feedData.feedUrl })
|
this.emitter('rss_feed_open', { libraryItemId: libraryItem.id, feedUrl: feedData.feedUrl })
|
||||||
return feedData
|
return feedData
|
||||||
}
|
}
|
||||||
|
|
||||||
closePodcastFeedForItem(libraryItemId) {
|
closeFeedForItem(libraryItemId) {
|
||||||
var feed = this.findFeedForItem(libraryItemId)
|
var feed = this.findFeedForItem(libraryItemId)
|
||||||
if (!feed) return
|
if (!feed) return
|
||||||
this.closeRssFeed(feed.id)
|
this.closeRssFeed(feed.id)
|
||||||
|
|||||||
+10
-18
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -225,6 +225,7 @@ class Book {
|
|||||||
// Look for desc.txt, reader.txt, metadata.abs and opf file then update details if found
|
// Look for desc.txt, reader.txt, metadata.abs and opf file then update details if found
|
||||||
async syncMetadataFiles(textMetadataFiles, opfMetadataOverrideDetails) {
|
async syncMetadataFiles(textMetadataFiles, opfMetadataOverrideDetails) {
|
||||||
var metadataUpdatePayload = {}
|
var metadataUpdatePayload = {}
|
||||||
|
var tagsUpdated = false
|
||||||
|
|
||||||
var descTxt = textMetadataFiles.find(lf => lf.metadata.filename === 'desc.txt')
|
var descTxt = textMetadataFiles.find(lf => lf.metadata.filename === 'desc.txt')
|
||||||
if (descTxt) {
|
if (descTxt) {
|
||||||
@@ -264,8 +265,13 @@ class Book {
|
|||||||
var opfMetadata = await parseOpfMetadataXML(xmlText)
|
var opfMetadata = await parseOpfMetadataXML(xmlText)
|
||||||
if (opfMetadata) {
|
if (opfMetadata) {
|
||||||
for (const key in opfMetadata) {
|
for (const key in opfMetadata) {
|
||||||
// Add genres only if genres are empty
|
|
||||||
if (key === 'genres') {
|
if (key === 'tags') { // Add tags only if tags are empty
|
||||||
|
if (opfMetadata.tags.length && (!this.tags.length || opfMetadataOverrideDetails)) {
|
||||||
|
this.tags = opfMetadata.tags
|
||||||
|
tagsUpdated = true
|
||||||
|
}
|
||||||
|
} else if (key === 'genres') { // Add genres only if genres are empty
|
||||||
if (opfMetadata.genres.length && (!this.metadata.genres.length || opfMetadataOverrideDetails)) {
|
if (opfMetadata.genres.length && (!this.metadata.genres.length || opfMetadataOverrideDetails)) {
|
||||||
metadataUpdatePayload[key] = opfMetadata.genres
|
metadataUpdatePayload[key] = opfMetadata.genres
|
||||||
}
|
}
|
||||||
@@ -290,9 +296,9 @@ class Book {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(metadataUpdatePayload).length) {
|
if (Object.keys(metadataUpdatePayload).length) {
|
||||||
return this.metadata.update(metadataUpdatePayload)
|
return this.metadata.update(metadataUpdatePayload) || tagsUpdated
|
||||||
}
|
}
|
||||||
return false
|
return tagsUpdated
|
||||||
}
|
}
|
||||||
|
|
||||||
searchQuery(query) {
|
searchQuery(query) {
|
||||||
|
|||||||
@@ -224,18 +224,10 @@ class Podcast {
|
|||||||
this.episodes.push(pe)
|
this.episodes.push(pe)
|
||||||
}
|
}
|
||||||
|
|
||||||
setEpisodeOrder(episodeIds) {
|
|
||||||
episodeIds.reverse() // episode Ids will already be in descending order
|
|
||||||
this.episodes = this.episodes.map(ep => {
|
|
||||||
var indexOf = episodeIds.findIndex(id => id === ep.id)
|
|
||||||
ep.index = indexOf + 1
|
|
||||||
return ep
|
|
||||||
})
|
|
||||||
this.episodes.sort((a, b) => b.index - a.index)
|
|
||||||
}
|
|
||||||
|
|
||||||
reorderEpisodes() {
|
reorderEpisodes() {
|
||||||
var hasUpdates = false
|
var hasUpdates = false
|
||||||
|
|
||||||
|
// TODO: Sort by published date
|
||||||
this.episodes = naturalSort(this.episodes).asc((ep) => ep.bestFilename)
|
this.episodes = naturalSort(this.episodes).asc((ep) => ep.bestFilename)
|
||||||
for (let i = 0; i < this.episodes.length; i++) {
|
for (let i = 0; i < this.episodes.length; i++) {
|
||||||
if (this.episodes[i].index !== (i + 1)) {
|
if (this.episodes[i].index !== (i + 1)) {
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ class BookMetadata {
|
|||||||
setData(scanMediaData = {}) {
|
setData(scanMediaData = {}) {
|
||||||
this.title = scanMediaData.title || null
|
this.title = scanMediaData.title || null
|
||||||
this.subtitle = scanMediaData.subtitle || null
|
this.subtitle = scanMediaData.subtitle || null
|
||||||
this.narrators = []
|
this.narrators = this.parseNarratorsTag(scanMediaData.narrators)
|
||||||
this.publishedYear = scanMediaData.publishedYear || null
|
this.publishedYear = scanMediaData.publishedYear || null
|
||||||
this.description = scanMediaData.description || null
|
this.description = scanMediaData.description || null
|
||||||
this.isbn = scanMediaData.isbn || null
|
this.isbn = scanMediaData.isbn || null
|
||||||
@@ -356,4 +356,4 @@ class BookMetadata {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
module.exports = BookMetadata
|
module.exports = BookMetadata
|
||||||
|
|||||||
@@ -5,10 +5,6 @@ class ServerSettings {
|
|||||||
constructor(settings) {
|
constructor(settings) {
|
||||||
this.id = 'server-settings'
|
this.id = 'server-settings'
|
||||||
|
|
||||||
// Misc/Unused
|
|
||||||
this.autoTagNew = false
|
|
||||||
this.newTagExpireDays = 15
|
|
||||||
|
|
||||||
// Scanner
|
// Scanner
|
||||||
this.scannerParseSubtitle = false
|
this.scannerParseSubtitle = false
|
||||||
this.scannerFindCovers = false
|
this.scannerFindCovers = false
|
||||||
@@ -43,11 +39,16 @@ class ServerSettings {
|
|||||||
// Podcasts
|
// Podcasts
|
||||||
this.podcastEpisodeSchedule = '0 * * * *' // Every hour
|
this.podcastEpisodeSchedule = '0 * * * *' // Every hour
|
||||||
|
|
||||||
|
// Sorting
|
||||||
this.sortingIgnorePrefix = false
|
this.sortingIgnorePrefix = false
|
||||||
this.sortingPrefixes = ['the', 'a']
|
this.sortingPrefixes = ['the', 'a']
|
||||||
|
|
||||||
|
// Misc Flags
|
||||||
this.chromecastEnabled = false
|
this.chromecastEnabled = false
|
||||||
|
this.enableEReader = false
|
||||||
|
|
||||||
this.logLevel = Logger.logLevel
|
this.logLevel = Logger.logLevel
|
||||||
|
|
||||||
this.version = null
|
this.version = null
|
||||||
|
|
||||||
if (settings) {
|
if (settings) {
|
||||||
@@ -56,8 +57,6 @@ class ServerSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
construct(settings) {
|
construct(settings) {
|
||||||
this.autoTagNew = settings.autoTagNew
|
|
||||||
this.newTagExpireDays = settings.newTagExpireDays
|
|
||||||
this.scannerFindCovers = !!settings.scannerFindCovers
|
this.scannerFindCovers = !!settings.scannerFindCovers
|
||||||
this.scannerCoverProvider = settings.scannerCoverProvider || 'google'
|
this.scannerCoverProvider = settings.scannerCoverProvider || 'google'
|
||||||
this.scannerParseSubtitle = settings.scannerParseSubtitle
|
this.scannerParseSubtitle = settings.scannerParseSubtitle
|
||||||
@@ -91,6 +90,7 @@ class ServerSettings {
|
|||||||
this.sortingIgnorePrefix = !!settings.sortingIgnorePrefix
|
this.sortingIgnorePrefix = !!settings.sortingIgnorePrefix
|
||||||
this.sortingPrefixes = settings.sortingPrefixes || ['the', 'a']
|
this.sortingPrefixes = settings.sortingPrefixes || ['the', 'a']
|
||||||
this.chromecastEnabled = !!settings.chromecastEnabled
|
this.chromecastEnabled = !!settings.chromecastEnabled
|
||||||
|
this.enableEReader = !!settings.enableEReader
|
||||||
this.logLevel = settings.logLevel || Logger.logLevel
|
this.logLevel = settings.logLevel || Logger.logLevel
|
||||||
this.version = settings.version || null
|
this.version = settings.version || null
|
||||||
|
|
||||||
@@ -102,8 +102,6 @@ class ServerSettings {
|
|||||||
toJSON() {
|
toJSON() {
|
||||||
return {
|
return {
|
||||||
id: this.id,
|
id: this.id,
|
||||||
autoTagNew: this.autoTagNew,
|
|
||||||
newTagExpireDays: this.newTagExpireDays,
|
|
||||||
scannerFindCovers: this.scannerFindCovers,
|
scannerFindCovers: this.scannerFindCovers,
|
||||||
scannerCoverProvider: this.scannerCoverProvider,
|
scannerCoverProvider: this.scannerCoverProvider,
|
||||||
scannerParseSubtitle: this.scannerParseSubtitle,
|
scannerParseSubtitle: this.scannerParseSubtitle,
|
||||||
@@ -125,6 +123,7 @@ class ServerSettings {
|
|||||||
sortingIgnorePrefix: this.sortingIgnorePrefix,
|
sortingIgnorePrefix: this.sortingIgnorePrefix,
|
||||||
sortingPrefixes: [...this.sortingPrefixes],
|
sortingPrefixes: [...this.sortingPrefixes],
|
||||||
chromecastEnabled: this.chromecastEnabled,
|
chromecastEnabled: this.chromecastEnabled,
|
||||||
|
enableEReader: this.enableEReader,
|
||||||
logLevel: this.logLevel,
|
logLevel: this.logLevel,
|
||||||
version: this.version
|
version: this.version
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,19 @@ class Audnexus {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findAuthorByASIN(asin) {
|
||||||
|
var author = await this.authorRequest(asin)
|
||||||
|
if (!author) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
asin: author.asin,
|
||||||
|
description: author.description,
|
||||||
|
image: author.image,
|
||||||
|
name: author.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async findAuthorByName(name, maxLevenshtein = 3) {
|
async findAuthorByName(name, maxLevenshtein = 3) {
|
||||||
Logger.debug(`[Audnexus] Looking up author by name ${name}`)
|
Logger.debug(`[Audnexus] Looking up author by name ${name}`)
|
||||||
var asins = await this.authorASINsRequest(name)
|
var asins = await this.authorASINsRequest(name)
|
||||||
|
|||||||
@@ -90,11 +90,11 @@ class ApiRouter {
|
|||||||
this.router.post('/items/:id/play', LibraryItemController.middleware.bind(this), LibraryItemController.startPlaybackSession.bind(this))
|
this.router.post('/items/:id/play', LibraryItemController.middleware.bind(this), LibraryItemController.startPlaybackSession.bind(this))
|
||||||
this.router.post('/items/:id/play/:episodeId', LibraryItemController.middleware.bind(this), LibraryItemController.startEpisodePlaybackSession.bind(this))
|
this.router.post('/items/:id/play/:episodeId', LibraryItemController.middleware.bind(this), LibraryItemController.startEpisodePlaybackSession.bind(this))
|
||||||
this.router.patch('/items/:id/tracks', LibraryItemController.middleware.bind(this), LibraryItemController.updateTracks.bind(this))
|
this.router.patch('/items/:id/tracks', LibraryItemController.middleware.bind(this), LibraryItemController.updateTracks.bind(this))
|
||||||
this.router.patch('/items/:id/episodes', LibraryItemController.middleware.bind(this), LibraryItemController.updateEpisodes.bind(this))
|
|
||||||
this.router.delete('/items/:id/episode/:episodeId', LibraryItemController.middleware.bind(this), LibraryItemController.removeEpisode.bind(this))
|
|
||||||
this.router.get('/items/:id/scan', LibraryItemController.middleware.bind(this), LibraryItemController.scan.bind(this))
|
this.router.get('/items/:id/scan', LibraryItemController.middleware.bind(this), LibraryItemController.scan.bind(this))
|
||||||
this.router.get('/items/:id/audio-metadata', LibraryItemController.middleware.bind(this), LibraryItemController.updateAudioFileMetadata.bind(this))
|
this.router.get('/items/:id/audio-metadata', LibraryItemController.middleware.bind(this), LibraryItemController.updateAudioFileMetadata.bind(this))
|
||||||
this.router.post('/items/:id/chapters', LibraryItemController.middleware.bind(this), LibraryItemController.updateMediaChapters.bind(this))
|
this.router.post('/items/:id/chapters', LibraryItemController.middleware.bind(this), LibraryItemController.updateMediaChapters.bind(this))
|
||||||
|
this.router.post('/items/:id/open-feed', LibraryItemController.middleware.bind(this), LibraryItemController.openRSSFeed.bind(this))
|
||||||
|
this.router.post('/items/:id/close-feed', LibraryItemController.middleware.bind(this), LibraryItemController.closeRSSFeed.bind(this))
|
||||||
|
|
||||||
this.router.post('/items/batch/delete', LibraryItemController.batchDelete.bind(this))
|
this.router.post('/items/batch/delete', LibraryItemController.batchDelete.bind(this))
|
||||||
this.router.post('/items/batch/update', LibraryItemController.batchUpdate.bind(this))
|
this.router.post('/items/batch/update', LibraryItemController.batchUpdate.bind(this))
|
||||||
@@ -186,9 +186,8 @@ class ApiRouter {
|
|||||||
this.router.get('/podcasts/:id/downloads', PodcastController.middleware.bind(this), PodcastController.getEpisodeDownloads.bind(this))
|
this.router.get('/podcasts/:id/downloads', PodcastController.middleware.bind(this), PodcastController.getEpisodeDownloads.bind(this))
|
||||||
this.router.get('/podcasts/:id/clear-queue', PodcastController.middleware.bind(this), PodcastController.clearEpisodeDownloadQueue.bind(this))
|
this.router.get('/podcasts/:id/clear-queue', PodcastController.middleware.bind(this), PodcastController.clearEpisodeDownloadQueue.bind(this))
|
||||||
this.router.post('/podcasts/:id/download-episodes', PodcastController.middleware.bind(this), PodcastController.downloadEpisodes.bind(this))
|
this.router.post('/podcasts/:id/download-episodes', PodcastController.middleware.bind(this), PodcastController.downloadEpisodes.bind(this))
|
||||||
this.router.post('/podcasts/:id/open-feed', PodcastController.middleware.bind(this), PodcastController.openPodcastFeed.bind(this))
|
|
||||||
this.router.post('/podcasts/:id/close-feed', PodcastController.middleware.bind(this), PodcastController.closePodcastFeed.bind(this))
|
|
||||||
this.router.patch('/podcasts/:id/episode/:episodeId', PodcastController.middleware.bind(this), PodcastController.updateEpisode.bind(this))
|
this.router.patch('/podcasts/:id/episode/:episodeId', PodcastController.middleware.bind(this), PodcastController.updateEpisode.bind(this))
|
||||||
|
this.router.delete('/podcasts/:id/episode/:episodeId', PodcastController.middleware.bind(this), PodcastController.removeEpisode.bind(this))
|
||||||
|
|
||||||
//
|
//
|
||||||
// Misc Routes
|
// Misc Routes
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ class StaticRouter {
|
|||||||
if (!item) return res.status(404).send('Item not found with id ' + req.params.id)
|
if (!item) return res.status(404).send('Item not found with id ' + req.params.id)
|
||||||
|
|
||||||
var remainingPath = req.params['0']
|
var remainingPath = req.params['0']
|
||||||
var fullPath = Path.join(item.path, remainingPath)
|
var fullPath = null
|
||||||
|
if (item.isFile) fullPath = item.path
|
||||||
|
else fullPath = Path.join(item.path, remainingPath)
|
||||||
res.sendFile(fullPath)
|
res.sendFile(fullPath)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,8 @@ class Scanner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async scanLibraryItem(libraryMediaType, folder, libraryItem) {
|
async scanLibraryItem(libraryMediaType, folder, libraryItem) {
|
||||||
var libraryItemData = await getLibraryItemFileData(libraryMediaType, folder, libraryItem.path, this.db.serverSettings)
|
// TODO: Support for single media item
|
||||||
|
var libraryItemData = await getLibraryItemFileData(libraryMediaType, folder, libraryItem.path, false, this.db.serverSettings)
|
||||||
if (!libraryItemData) {
|
if (!libraryItemData) {
|
||||||
return ScanResult.NOTHING
|
return ScanResult.NOTHING
|
||||||
}
|
}
|
||||||
@@ -499,7 +500,11 @@ class Scanner {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
var relFilePaths = folderGroups[folderId].fileUpdates.map(fileUpdate => fileUpdate.relPath)
|
var relFilePaths = folderGroups[folderId].fileUpdates.map(fileUpdate => fileUpdate.relPath)
|
||||||
var fileUpdateGroup = groupFilesIntoLibraryItemPaths(relFilePaths, true)
|
var fileUpdateGroup = groupFilesIntoLibraryItemPaths(library.mediaType, relFilePaths)
|
||||||
|
if (!Object.keys(fileUpdateGroup).length) {
|
||||||
|
Logger.info(`[Scanner] No important changes to scan for in folder "${folderId}"`)
|
||||||
|
continue;
|
||||||
|
}
|
||||||
var folderScanResults = await this.scanFolderUpdates(library, folder, fileUpdateGroup)
|
var folderScanResults = await this.scanFolderUpdates(library, folder, fileUpdateGroup)
|
||||||
Logger.debug(`[Scanner] Folder scan results`, folderScanResults)
|
Logger.debug(`[Scanner] Folder scan results`, folderScanResults)
|
||||||
}
|
}
|
||||||
@@ -513,6 +518,8 @@ class Scanner {
|
|||||||
// Test Case: Moving audio files from library item folder to author folder should trigger a re-scan of the item
|
// Test Case: Moving audio files from library item folder to author folder should trigger a re-scan of the item
|
||||||
var updateGroup = { ...fileUpdateGroup }
|
var updateGroup = { ...fileUpdateGroup }
|
||||||
for (const itemDir in updateGroup) {
|
for (const itemDir in updateGroup) {
|
||||||
|
if (itemDir == fileUpdateGroup[itemDir]) continue; // Media in root path
|
||||||
|
|
||||||
var itemDirNestedFiles = fileUpdateGroup[itemDir].filter(b => b.includes('/'))
|
var itemDirNestedFiles = fileUpdateGroup[itemDir].filter(b => b.includes('/'))
|
||||||
if (!itemDirNestedFiles.length) continue;
|
if (!itemDirNestedFiles.length) continue;
|
||||||
|
|
||||||
@@ -582,7 +589,8 @@ class Scanner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Logger.debug(`[Scanner] Folder update group must be a new item "${itemDir}" in library "${library.name}"`)
|
Logger.debug(`[Scanner] Folder update group must be a new item "${itemDir}" in library "${library.name}"`)
|
||||||
var newLibraryItem = await this.scanPotentialNewLibraryItem(library.mediaType, folder, fullPath)
|
var isSingleMediaItem = itemDir === fileUpdateGroup[itemDir]
|
||||||
|
var newLibraryItem = await this.scanPotentialNewLibraryItem(library.mediaType, folder, fullPath, isSingleMediaItem)
|
||||||
if (newLibraryItem) {
|
if (newLibraryItem) {
|
||||||
await this.createNewAuthorsAndSeries(newLibraryItem)
|
await this.createNewAuthorsAndSeries(newLibraryItem)
|
||||||
await this.db.insertLibraryItem(newLibraryItem)
|
await this.db.insertLibraryItem(newLibraryItem)
|
||||||
@@ -594,8 +602,8 @@ class Scanner {
|
|||||||
return itemGroupingResults
|
return itemGroupingResults
|
||||||
}
|
}
|
||||||
|
|
||||||
async scanPotentialNewLibraryItem(libraryMediaType, folder, fullPath) {
|
async scanPotentialNewLibraryItem(libraryMediaType, folder, fullPath, isSingleMediaItem = false) {
|
||||||
var libraryItemData = await getLibraryItemFileData(libraryMediaType, folder, fullPath, this.db.serverSettings)
|
var libraryItemData = await getLibraryItemFileData(libraryMediaType, folder, fullPath, isSingleMediaItem, this.db.serverSettings)
|
||||||
if (!libraryItemData) return null
|
if (!libraryItemData) return null
|
||||||
var serverSettings = this.db.serverSettings
|
var serverSettings = this.db.serverSettings
|
||||||
return this.scanNewLibraryItem(libraryItemData, libraryMediaType, serverSettings.scannerPreferAudioMetadata, serverSettings.scannerPreferOpfMetadata, serverSettings.scannerFindCovers)
|
return this.scanNewLibraryItem(libraryItemData, libraryMediaType, serverSettings.scannerPreferAudioMetadata, serverSettings.scannerPreferOpfMetadata, serverSettings.scannerFindCovers)
|
||||||
|
|||||||
@@ -120,9 +120,6 @@ module.exports.extractCoverArt = extractCoverArt
|
|||||||
|
|
||||||
//This should convert based on the output file extension as well
|
//This should convert based on the output file extension as well
|
||||||
async function resizeImage(filePath, outputPath, width, height) {
|
async function resizeImage(filePath, outputPath, width, height) {
|
||||||
var dirname = Path.dirname(outputPath);
|
|
||||||
await fs.ensureDir(dirname);
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
var ffmpeg = Ffmpeg(filePath)
|
var ffmpeg = Ffmpeg(filePath)
|
||||||
ffmpeg.addOption(['-vf', `scale=${width || -1}:${height || -1}`])
|
ffmpeg.addOption(['-vf', `scale=${width || -1}:${height || -1}`])
|
||||||
|
|||||||
@@ -176,17 +176,20 @@ module.exports.downloadFile = async (url, filepath) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports.sanitizeFilename = (filename, replacement = '') => {
|
module.exports.sanitizeFilename = (filename, colonReplacement = ' - ') => {
|
||||||
if (typeof filename !== 'string') {
|
if (typeof filename !== '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 = /^\.+$/;
|
||||||
var windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i;
|
var windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i;
|
||||||
var windowsTrailingRe = /[\. ]+$/;
|
var windowsTrailingRe = /[\. ]+$/;
|
||||||
|
|
||||||
var sanitized = filename
|
sanitized = filename
|
||||||
|
.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)
|
||||||
|
|||||||
@@ -418,7 +418,7 @@ module.exports = {
|
|||||||
books: [libraryItemJson],
|
books: [libraryItemJson],
|
||||||
inProgress: bookInProgress,
|
inProgress: bookInProgress,
|
||||||
bookInProgressLastUpdate: bookInProgress ? mediaProgress.lastUpdate : null,
|
bookInProgressLastUpdate: bookInProgress ? mediaProgress.lastUpdate : null,
|
||||||
sequenceInProgress: bookInProgress ? libraryItemJson.seriesSequence : null
|
firstBookUnread: bookInProgress ? null : libraryItemJson
|
||||||
}
|
}
|
||||||
seriesMap[librarySeries.id] = series
|
seriesMap[librarySeries.id] = series
|
||||||
|
|
||||||
@@ -445,10 +445,18 @@ module.exports = {
|
|||||||
|
|
||||||
if (bookInProgress) { // Update if this series is in progress
|
if (bookInProgress) { // Update if this series is in progress
|
||||||
seriesMap[librarySeries.id].inProgress = true
|
seriesMap[librarySeries.id].inProgress = true
|
||||||
if (!seriesMap[librarySeries.id].sequenceInProgress || (librarySeries.sequence && String(librarySeries.sequence).localeCompare(String(seriesMap[librarySeries.id].sequenceInProgress), undefined, { sensitivity: 'base', numeric: true }) > 0)) {
|
|
||||||
seriesMap[librarySeries.id].sequenceInProgress = librarySeries.sequence
|
if (seriesMap[librarySeries.id].bookInProgressLastUpdate > mediaProgress.lastUpdate) {
|
||||||
seriesMap[librarySeries.id].bookInProgressLastUpdate = mediaProgress.lastUpdate
|
seriesMap[librarySeries.id].bookInProgressLastUpdate = mediaProgress.lastUpdate
|
||||||
}
|
}
|
||||||
|
} else if (!seriesMap[librarySeries.id].firstBookUnread) {
|
||||||
|
seriesMap[librarySeries.id].firstBookUnread = libraryItemJson
|
||||||
|
} else if (libraryItemJson.seriesSequence) {
|
||||||
|
// If current firstBookUnread has a series sequence greater than this series sequence, then update firstBookUnread
|
||||||
|
const firstBookUnreadSequence = seriesMap[librarySeries.id].firstBookUnread.seriesSequence
|
||||||
|
if (!firstBookUnreadSequence || String(firstBookUnreadSequence).localeCompare(String(librarySeries.sequence), undefined, { sensitivity: 'base', numeric: true }) > 0) {
|
||||||
|
seriesMap[librarySeries.id].firstBookUnread = libraryItemJson
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -545,11 +553,8 @@ module.exports = {
|
|||||||
if (seriesMap[seriesId].inProgress) {
|
if (seriesMap[seriesId].inProgress) {
|
||||||
seriesMap[seriesId].books = naturalSort(seriesMap[seriesId].books).asc(li => li.seriesSequence)
|
seriesMap[seriesId].books = naturalSort(seriesMap[seriesId].books).asc(li => li.seriesSequence)
|
||||||
|
|
||||||
const nextBookInSeries = seriesMap[seriesId].books.find(li => {
|
// NEW implementation takes the first book unread with the smallest series sequence
|
||||||
if (!seriesMap[seriesId].sequenceInProgress) return true
|
const nextBookInSeries = seriesMap[seriesId].firstBookUnread
|
||||||
// True if book series sequence is greater than the current book sequence in progress
|
|
||||||
return String(li.seriesSequence).localeCompare(String(seriesMap[seriesId].sequenceInProgress), undefined, { sensitivity: 'base', numeric: true }) > 0
|
|
||||||
})
|
|
||||||
|
|
||||||
if (nextBookInSeries) {
|
if (nextBookInSeries) {
|
||||||
const bookForContinueSeries = {
|
const bookForContinueSeries = {
|
||||||
|
|||||||
@@ -70,14 +70,14 @@ function fetchLanguage(metadata) {
|
|||||||
return fetchTagString(metadata, 'dc:language')
|
return fetchTagString(metadata, 'dc:language')
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchSeries(metadata) {
|
function fetchSeries(metadataMeta) {
|
||||||
if (typeof metadata.meta == "undefined") return null
|
if (!metadataMeta) return null
|
||||||
return fetchTagString(metadata.meta, "calibre:series")
|
return fetchTagString(metadataMeta, "calibre:series")
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchVolumeNumber(metadata) {
|
function fetchVolumeNumber(metadataMeta) {
|
||||||
if (typeof metadata.meta == "undefined") return null
|
if (!metadataMeta) return null
|
||||||
return fetchTagString(metadata.meta, "calibre:series_index")
|
return fetchTagString(metadataMeta, "calibre:series_index")
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchNarrators(creators, metadata) {
|
function fetchNarrators(creators, metadata) {
|
||||||
@@ -91,21 +91,42 @@ function fetchNarrators(creators, metadata) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fetchTags(metadata) {
|
||||||
|
if (!metadata['dc:tag'] || !metadata['dc:tag'].length) return []
|
||||||
|
return metadata['dc:tag'].filter(tag => (typeof tag === 'string'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripPrefix(str) {
|
||||||
|
if (!str) return ''
|
||||||
|
return str.split(':').pop()
|
||||||
|
}
|
||||||
|
|
||||||
module.exports.parseOpfMetadataXML = async (xml) => {
|
module.exports.parseOpfMetadataXML = async (xml) => {
|
||||||
var json = await xmlToJSON(xml)
|
var json = await xmlToJSON(xml)
|
||||||
if (!json || !json.package || !json.package.metadata) return null
|
|
||||||
var metadata = json.package.metadata
|
if (!json) return null
|
||||||
|
|
||||||
|
// Handle <package ...> or with prefix <ns0:package ...>
|
||||||
|
const packageKey = Object.keys(json).find(key => stripPrefix(key) === 'package')
|
||||||
|
if (!packageKey) return null
|
||||||
|
const prefix = packageKey.split(':').shift()
|
||||||
|
var metadata = prefix ? json[packageKey][`${prefix}:metadata`] || json[packageKey].metadata : json[packageKey].metadata
|
||||||
|
if (!metadata) return null
|
||||||
|
|
||||||
if (Array.isArray(metadata)) {
|
if (Array.isArray(metadata)) {
|
||||||
if (!metadata.length) return null
|
if (!metadata.length) return null
|
||||||
metadata = metadata[0]
|
metadata = metadata[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof metadata.meta != "undefined") {
|
const metadataMeta = prefix ? metadata[`${prefix}:meta`] || metadata.meta : metadata.meta
|
||||||
metadata.meta = {}
|
|
||||||
for (var match of xml.matchAll(/<meta name="(?<name>.+)" content="(?<content>.+)"\/>/g)) {
|
metadata.meta = {}
|
||||||
metadata.meta[match.groups['name']] = [match.groups['content']]
|
if (metadataMeta && metadataMeta.length) {
|
||||||
}
|
metadataMeta.forEach((meta) => {
|
||||||
|
if (meta && meta['$'] && meta['$'].name) {
|
||||||
|
metadata.meta[meta['$'].name] = [meta['$'].content || '']
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
var creators = parseCreators(metadata)
|
var creators = parseCreators(metadata)
|
||||||
@@ -119,8 +140,9 @@ module.exports.parseOpfMetadataXML = async (xml) => {
|
|||||||
description: fetchDescription(metadata),
|
description: fetchDescription(metadata),
|
||||||
genres: fetchGenres(metadata),
|
genres: fetchGenres(metadata),
|
||||||
language: fetchLanguage(metadata),
|
language: fetchLanguage(metadata),
|
||||||
series: fetchSeries(metadata),
|
series: fetchSeries(metadata.meta),
|
||||||
sequence: fetchVolumeNumber(metadata)
|
sequence: fetchVolumeNumber(metadata.meta),
|
||||||
|
tags: fetchTags(metadata)
|
||||||
}
|
}
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
+126
-110
@@ -17,11 +17,14 @@ function isMediaFile(mediaType, ext) {
|
|||||||
// TODO: Function needs to be re-done
|
// TODO: Function needs to be re-done
|
||||||
// Input: array of relative file paths
|
// Input: array of relative file paths
|
||||||
// Output: map of files grouped into potential item dirs
|
// Output: map of files grouped into potential item dirs
|
||||||
function groupFilesIntoLibraryItemPaths(paths) {
|
function groupFilesIntoLibraryItemPaths(mediaType, paths) {
|
||||||
// Step 1: Clean path, Remove leading "/", Filter out files in root dir
|
// Step 1: Clean path, Remove leading "/", Filter out non-media files in root dir
|
||||||
var pathsFiltered = paths.map(path => {
|
var pathsFiltered = paths.map(path => {
|
||||||
return path.startsWith('/') ? path.slice(1) : path
|
return path.startsWith('/') ? path.slice(1) : path
|
||||||
}).filter(path => Path.parse(path).dir)
|
}).filter(path => {
|
||||||
|
let parsedPath = Path.parse(path)
|
||||||
|
return parsedPath.dir || (mediaType === 'book' && isMediaFile(mediaType, parsedPath.ext))
|
||||||
|
})
|
||||||
|
|
||||||
// Step 2: Sort by least number of directories
|
// Step 2: Sort by least number of directories
|
||||||
pathsFiltered.sort((a, b) => {
|
pathsFiltered.sort((a, b) => {
|
||||||
@@ -33,25 +36,30 @@ function groupFilesIntoLibraryItemPaths(paths) {
|
|||||||
// Step 3: Group files in dirs
|
// Step 3: Group files in dirs
|
||||||
var itemGroup = {}
|
var itemGroup = {}
|
||||||
pathsFiltered.forEach((path) => {
|
pathsFiltered.forEach((path) => {
|
||||||
var dirparts = Path.dirname(path).split('/')
|
var dirparts = Path.dirname(path).split('/').filter(p => !!p && p !== '.') // dirname returns . if no directory
|
||||||
var numparts = dirparts.length
|
var numparts = dirparts.length
|
||||||
var _path = ''
|
var _path = ''
|
||||||
|
|
||||||
// Iterate over directories in path
|
if (!numparts) {
|
||||||
for (let i = 0; i < numparts; i++) {
|
// Media file in root
|
||||||
var dirpart = dirparts.shift()
|
itemGroup[path] = path
|
||||||
_path = Path.posix.join(_path, dirpart)
|
} else {
|
||||||
|
// Iterate over directories in path
|
||||||
|
for (let i = 0; i < numparts; i++) {
|
||||||
|
var dirpart = dirparts.shift()
|
||||||
|
_path = Path.posix.join(_path, dirpart)
|
||||||
|
|
||||||
if (itemGroup[_path]) { // Directory already has files, add file
|
if (itemGroup[_path]) { // Directory already has files, add file
|
||||||
var relpath = Path.posix.join(dirparts.join('/'), Path.basename(path))
|
var relpath = Path.posix.join(dirparts.join('/'), Path.basename(path))
|
||||||
itemGroup[_path].push(relpath)
|
itemGroup[_path].push(relpath)
|
||||||
return
|
return
|
||||||
} else if (!dirparts.length) { // This is the last directory, create group
|
} else if (!dirparts.length) { // This is the last directory, create group
|
||||||
itemGroup[_path] = [Path.basename(path)]
|
itemGroup[_path] = [Path.basename(path)]
|
||||||
return
|
return
|
||||||
} else if (dirparts.length === 1 && /^cd\d{1,3}$/i.test(dirparts[0])) { // Next directory is the last and is a CD dir, create group
|
} else if (dirparts.length === 1 && /^cd\d{1,3}$/i.test(dirparts[0])) { // Next directory is the last and is a CD dir, create group
|
||||||
itemGroup[_path] = [Path.posix.join(dirparts[0], Path.basename(path))]
|
itemGroup[_path] = [Path.posix.join(dirparts[0], Path.basename(path))]
|
||||||
return
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -62,9 +70,9 @@ module.exports.groupFilesIntoLibraryItemPaths = groupFilesIntoLibraryItemPaths
|
|||||||
// Input: array of relative file items (see recurseFiles)
|
// Input: array of relative file items (see recurseFiles)
|
||||||
// Output: map of files grouped into potential libarary item dirs
|
// Output: map of files grouped into potential libarary item dirs
|
||||||
function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems) {
|
function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems) {
|
||||||
// Step 1: Filter out non-media files in root dir (with depth of 0)
|
// Step 1: Filter out non-book-media files in root dir (with depth of 0)
|
||||||
var itemsFiltered = fileItems.filter(i => {
|
var itemsFiltered = fileItems.filter(i => {
|
||||||
return i.deep > 0 || isMediaFile(mediaType, i.extension)
|
return i.deep > 0 || (mediaType === 'book' && isMediaFile(mediaType, i.extension))
|
||||||
})
|
})
|
||||||
|
|
||||||
// Step 2: Seperate media files and other files
|
// Step 2: Seperate media files and other files
|
||||||
@@ -128,7 +136,7 @@ function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cleanFileObjects(libraryItemPath, files) {
|
function cleanFileObjects(libraryItemPath, files) {
|
||||||
return Promise.all(files.map(async (file) => {
|
return Promise.all(files.map(async(file) => {
|
||||||
var filePath = Path.posix.join(libraryItemPath, file)
|
var filePath = Path.posix.join(libraryItemPath, file)
|
||||||
var newLibraryFile = new LibraryFile()
|
var newLibraryFile = new LibraryFile()
|
||||||
await newLibraryFile.setDataFromPath(filePath, file)
|
await newLibraryFile.setDataFromPath(filePath, file)
|
||||||
@@ -147,16 +155,6 @@ async function scanFolder(libraryMediaType, folder, serverSettings = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var fileItems = await recurseFiles(folderPath)
|
var fileItems = await recurseFiles(folderPath)
|
||||||
var basePath = folderPath
|
|
||||||
|
|
||||||
const isOpenAudibleFolder = fileItems.find(fi => fi.deep === 0 && fi.name === 'books.json')
|
|
||||||
if (isOpenAudibleFolder) {
|
|
||||||
Logger.info(`[scandir] Detected Open Audible Folder, looking in books folder`)
|
|
||||||
basePath = Path.posix.join(folderPath, 'books')
|
|
||||||
fileItems = await recurseFiles(basePath)
|
|
||||||
Logger.debug(`[scandir] ${fileItems.length} files found in books folder`)
|
|
||||||
}
|
|
||||||
|
|
||||||
var libraryItemGrouping = groupFileItemsIntoLibraryItemDirs(libraryMediaType, fileItems)
|
var libraryItemGrouping = groupFileItemsIntoLibraryItemDirs(libraryMediaType, fileItems)
|
||||||
|
|
||||||
if (!Object.keys(libraryItemGrouping).length) {
|
if (!Object.keys(libraryItemGrouping).length) {
|
||||||
@@ -175,10 +173,10 @@ async function scanFolder(libraryMediaType, folder, serverSettings = {}) {
|
|||||||
mediaMetadata: {
|
mediaMetadata: {
|
||||||
title: Path.basename(libraryItemPath, Path.extname(libraryItemPath))
|
title: Path.basename(libraryItemPath, Path.extname(libraryItemPath))
|
||||||
},
|
},
|
||||||
path: Path.posix.join(basePath, libraryItemPath),
|
path: Path.posix.join(folderPath, libraryItemPath),
|
||||||
relPath: libraryItemPath
|
relPath: libraryItemPath
|
||||||
}
|
}
|
||||||
fileObjs = await cleanFileObjects(basePath, [libraryItemPath])
|
fileObjs = await cleanFileObjects(folderPath, [libraryItemPath])
|
||||||
isFile = true
|
isFile = true
|
||||||
} else {
|
} else {
|
||||||
libraryItemData = getDataFromMediaDir(libraryMediaType, folderPath, libraryItemPath, serverSettings)
|
libraryItemData = getDataFromMediaDir(libraryMediaType, folderPath, libraryItemPath, serverSettings)
|
||||||
@@ -211,78 +209,15 @@ function getBookDataFromDir(folderPath, relPath, parseSubtitle = false) {
|
|||||||
relPath = relPath.replace(/\\/g, '/')
|
relPath = relPath.replace(/\\/g, '/')
|
||||||
var splitDir = relPath.split('/')
|
var splitDir = relPath.split('/')
|
||||||
|
|
||||||
// Audio files will always be in the directory named for the title
|
var folder = splitDir.pop() // Audio files will always be in the directory named for the title
|
||||||
var title = splitDir.pop()
|
series = (splitDir.length > 1) ? splitDir.pop() : null // If there are at least 2 more directories, next furthest will be the series
|
||||||
var series = null
|
author = (splitDir.length > 0) ? splitDir.pop() : null // There could be many more directories, but only the top 3 are used for naming /author/series/title/
|
||||||
var author = null
|
|
||||||
// If there are at least 2 more directories, next furthest will be the series
|
|
||||||
if (splitDir.length > 1) series = splitDir.pop()
|
|
||||||
if (splitDir.length > 0) author = splitDir.pop()
|
|
||||||
// There could be many more directories, but only the top 3 are used for naming /author/series/title/
|
|
||||||
|
|
||||||
|
// The may contain various other pieces of metadata, these functions extract it.
|
||||||
// If in a series directory check for volume number match
|
var [folder, narrators] = getNarrator(folder)
|
||||||
/* ACCEPTS
|
if (series) { var [folder, sequence] = getSequence(folder) }
|
||||||
Book 2 - Title Here - Subtitle Here
|
var [folder, publishedYear] = getPublishedYear(folder)
|
||||||
Title Here - Subtitle Here - Vol 12
|
if (parseSubtitle) { var [title, subtitle] = getSubtitle(folder) } // Subtitle can be parsed from the title if user enabled
|
||||||
Title Here - volume 9 - Subtitle Here
|
|
||||||
Vol. 3 Title Here - Subtitle Here
|
|
||||||
1980 - Book 2-Title Here
|
|
||||||
Title Here-Volume 999-Subtitle Here
|
|
||||||
2 - Book Title
|
|
||||||
100 - Book Title
|
|
||||||
0.5 - Book Title
|
|
||||||
*/
|
|
||||||
var volumeNumber = null
|
|
||||||
if (series) {
|
|
||||||
// Added 1.7.1: If title starts with a # that is 3 digits or less (or w/ 2 decimal), then use as volume number
|
|
||||||
var volumeMatch = title.match(/^(\d{1,3}(?:\.\d{1,2})?) - ./)
|
|
||||||
if (volumeMatch && volumeMatch.length > 1) {
|
|
||||||
volumeNumber = volumeMatch[1]
|
|
||||||
title = title.replace(`${volumeNumber} - `, '')
|
|
||||||
} else {
|
|
||||||
// Match volumes with decimal (OLD: /(-? ?)\b((?:Book|Vol.?|Volume) (\d{1,3}))\b( ?-?)/i)
|
|
||||||
var volumeMatch = title.match(/(-? ?)\b((?:Book|Vol.?|Volume) (\d{0,3}(?:\.\d{1,2})?))\b( ?-?)/i)
|
|
||||||
if (volumeMatch && volumeMatch.length > 3 && volumeMatch[2] && volumeMatch[3]) {
|
|
||||||
volumeNumber = volumeMatch[3]
|
|
||||||
var replaceChunk = volumeMatch[2]
|
|
||||||
|
|
||||||
// "1980 - Book 2-Title Here"
|
|
||||||
// Group 1 would be "- "
|
|
||||||
// Group 3 would be "-"
|
|
||||||
// Only remove the first group
|
|
||||||
if (volumeMatch[1]) {
|
|
||||||
replaceChunk = volumeMatch[1] + replaceChunk
|
|
||||||
} else if (volumeMatch[4]) {
|
|
||||||
replaceChunk += volumeMatch[4]
|
|
||||||
}
|
|
||||||
title = title.replace(replaceChunk, '').trim()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var publishedYear = null
|
|
||||||
// If Title is of format 1999 OR (1999) - Title, then use 1999 as publish year
|
|
||||||
var publishYearMatch = title.match(/^(\(?[0-9]{4}\)?) - (.+)/)
|
|
||||||
if (publishYearMatch && publishYearMatch.length > 2 && publishYearMatch[1]) {
|
|
||||||
// Strip parentheses
|
|
||||||
if (publishYearMatch[1].startsWith('(') && publishYearMatch[1].endsWith(')')) {
|
|
||||||
publishYearMatch[1] = publishYearMatch[1].slice(1, -1)
|
|
||||||
}
|
|
||||||
if (!isNaN(publishYearMatch[1])) {
|
|
||||||
publishedYear = publishYearMatch[1]
|
|
||||||
title = publishYearMatch[2]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subtitle can be parsed from the title if user enabled
|
|
||||||
// Subtitle is everything after " - "
|
|
||||||
var subtitle = null
|
|
||||||
if (parseSubtitle && title.includes(' - ')) {
|
|
||||||
var splitOnSubtitle = title.split(' - ')
|
|
||||||
title = splitOnSubtitle.shift()
|
|
||||||
subtitle = splitOnSubtitle.join(' - ')
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mediaMetadata: {
|
mediaMetadata: {
|
||||||
@@ -290,14 +225,76 @@ function getBookDataFromDir(folderPath, relPath, parseSubtitle = false) {
|
|||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
series,
|
series,
|
||||||
sequence: volumeNumber,
|
sequence,
|
||||||
publishedYear,
|
publishedYear,
|
||||||
|
narrators,
|
||||||
},
|
},
|
||||||
relPath: relPath, // relative audiobook path i.e. /Author Name/Book Name/..
|
relPath: relPath, // relative audiobook path i.e. /Author Name/Book Name/..
|
||||||
path: Path.posix.join(folderPath, relPath) // i.e. /audiobook/Author Name/Book Name/..
|
path: Path.posix.join(folderPath, relPath) // i.e. /audiobook/Author Name/Book Name/..
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getNarrator(folder) {
|
||||||
|
let pattern = /^(?<title>.*) \{(?<narrators>.*)\}$/
|
||||||
|
let match = folder.match(pattern)
|
||||||
|
return match ? [match.groups.title, match.groups.narrators] : [folder, null]
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSequence(folder) {
|
||||||
|
// Valid ways of including a volume number:
|
||||||
|
// [
|
||||||
|
// 'Book 2 - Title - Subtitle',
|
||||||
|
// 'Title - Subtitle - Vol 12',
|
||||||
|
// 'Title - volume 9 - Subtitle',
|
||||||
|
// 'Vol. 3 Title Here - Subtitle',
|
||||||
|
// '1980 - Book 2 - Title',
|
||||||
|
// 'Volume 12. Title - Subtitle',
|
||||||
|
// '100 - Book Title',
|
||||||
|
// '2 - Book Title',
|
||||||
|
// '6. Title',
|
||||||
|
// '0.5 - Book Title'
|
||||||
|
// ]
|
||||||
|
|
||||||
|
// Matches a valid volume string. Also matches a book whose title starts with a 1 to 3 digit number. Will handle that later.
|
||||||
|
let pattern = /^(?<volumeLabel>vol\.? |volume |book )?(?<sequence>\d{1,3}(?:\.\d{1,2})?)(?<trailingDot>\.?)(?: (?<suffix>.*))?/i
|
||||||
|
|
||||||
|
let volumeNumber = null
|
||||||
|
let parts = folder.split(' - ')
|
||||||
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
let match = parts[i].match(pattern)
|
||||||
|
|
||||||
|
// This excludes '101 Dalmations' but includes '101. Dalmations'
|
||||||
|
if (match && !(match.groups.suffix && !(match.groups.volumeLabel || match.groups.trailingDot))) {
|
||||||
|
volumeNumber = match.groups.sequence
|
||||||
|
parts[i] = match.groups.suffix
|
||||||
|
if (!parts[i]) { parts.splice(i, 1) }
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
folder = parts.join(' - ')
|
||||||
|
return [folder, volumeNumber]
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPublishedYear(folder) {
|
||||||
|
var publishedYear = null
|
||||||
|
|
||||||
|
pattern = /^ *\(?([0-9]{4})\)? * - *(.+)/ //Matches #### - title or (####) - title
|
||||||
|
var match = folder.match(pattern)
|
||||||
|
if (match) {
|
||||||
|
publishedYear = match[1]
|
||||||
|
folder = match[2]
|
||||||
|
}
|
||||||
|
|
||||||
|
return [folder, publishedYear]
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSubtitle(folder) {
|
||||||
|
// Subtitle is everything after " - "
|
||||||
|
var splitTitle = folder.split(' - ')
|
||||||
|
return [splitTitle.shift(), splitTitle.join(' - ')]
|
||||||
|
}
|
||||||
|
|
||||||
function getPodcastDataFromDir(folderPath, relPath) {
|
function getPodcastDataFromDir(folderPath, relPath) {
|
||||||
relPath = relPath.replace(/\\/g, '/')
|
relPath = relPath.replace(/\\/g, '/')
|
||||||
var splitDir = relPath.split('/')
|
var splitDir = relPath.split('/')
|
||||||
@@ -323,14 +320,32 @@ function getDataFromMediaDir(libraryMediaType, folderPath, relPath, serverSettin
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Called from Scanner.js
|
// Called from Scanner.js
|
||||||
async function getLibraryItemFileData(libraryMediaType, folder, libraryItemPath, serverSettings = {}) {
|
async function getLibraryItemFileData(libraryMediaType, folder, libraryItemPath, isSingleMediaItem, serverSettings = {}) {
|
||||||
var fileItems = await recurseFiles(libraryItemPath)
|
|
||||||
|
|
||||||
libraryItemPath = libraryItemPath.replace(/\\/g, '/')
|
libraryItemPath = libraryItemPath.replace(/\\/g, '/')
|
||||||
var folderFullPath = folder.fullPath.replace(/\\/g, '/')
|
var folderFullPath = folder.fullPath.replace(/\\/g, '/')
|
||||||
|
|
||||||
var libraryItemDir = libraryItemPath.replace(folderFullPath, '').slice(1)
|
var libraryItemDir = libraryItemPath.replace(folderFullPath, '').slice(1)
|
||||||
var libraryItemData = getDataFromMediaDir(libraryMediaType, folderFullPath, libraryItemDir, serverSettings)
|
var libraryItemData = {}
|
||||||
|
|
||||||
|
var fileItems = []
|
||||||
|
|
||||||
|
if (isSingleMediaItem) { // Single media item in root of folder
|
||||||
|
fileItems = [{
|
||||||
|
fullpath: libraryItemPath,
|
||||||
|
path: libraryItemDir // actually the relPath (only filename here)
|
||||||
|
}]
|
||||||
|
libraryItemData = {
|
||||||
|
path: libraryItemPath, // full path
|
||||||
|
relPath: libraryItemDir, // only filename
|
||||||
|
mediaMetadata: {
|
||||||
|
title: Path.basename(libraryItemDir, Path.extname(libraryItemDir))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fileItems = await recurseFiles(libraryItemPath)
|
||||||
|
libraryItemData = getDataFromMediaDir(libraryMediaType, folderFullPath, libraryItemDir, serverSettings)
|
||||||
|
}
|
||||||
|
|
||||||
var libraryItemDirStats = await getFileTimestampsWithIno(libraryItemData.path)
|
var libraryItemDirStats = await getFileTimestampsWithIno(libraryItemData.path)
|
||||||
var libraryItem = {
|
var libraryItem = {
|
||||||
ino: libraryItemDirStats.ino,
|
ino: libraryItemDirStats.ino,
|
||||||
@@ -341,6 +356,7 @@ async function getLibraryItemFileData(libraryMediaType, folder, libraryItemPath,
|
|||||||
libraryId: folder.libraryId,
|
libraryId: folder.libraryId,
|
||||||
path: libraryItemData.path,
|
path: libraryItemData.path,
|
||||||
relPath: libraryItemData.relPath,
|
relPath: libraryItemData.relPath,
|
||||||
|
isFile: isSingleMediaItem,
|
||||||
media: {
|
media: {
|
||||||
metadata: libraryItemData.mediaMetadata || null
|
metadata: libraryItemData.mediaMetadata || null
|
||||||
},
|
},
|
||||||
@@ -350,7 +366,7 @@ async function getLibraryItemFileData(libraryMediaType, folder, libraryItemPath,
|
|||||||
for (let i = 0; i < fileItems.length; i++) {
|
for (let i = 0; i < fileItems.length; i++) {
|
||||||
var fileItem = fileItems[i]
|
var fileItem = fileItems[i]
|
||||||
var newLibraryFile = new LibraryFile()
|
var newLibraryFile = new LibraryFile()
|
||||||
// fileItem.path is the relative path
|
// fileItem.path is the relative path
|
||||||
await newLibraryFile.setDataFromPath(fileItem.fullpath, fileItem.path)
|
await newLibraryFile.setDataFromPath(fileItem.fullpath, fileItem.path)
|
||||||
libraryItem.libraryFiles.push(newLibraryFile)
|
libraryItem.libraryFiles.push(newLibraryFile)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user