mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-06-05 10:12:44 +02:00
Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 68efd30a54 | |||
| 27407d49dd | |||
| 97d4330cda | |||
| 3153bdc5bb | |||
| 31fd75a895 | |||
| b22173a631 | |||
| 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 |
@@ -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
-64
@@ -2,12 +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_DATA_PATH="/usr/share/audiobookshelf"
|
DEFAULT_DATA_DIR="/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"
|
||||||
@@ -15,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
|
||||||
|
|
||||||
@@ -27,73 +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."
|
|
||||||
|
|
||||||
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="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="METADATA_PATH=$DEFAULT_DATA_PATH/metadata
|
config_text="METADATA_PATH=$DEFAULT_DATA_DIR/metadata
|
||||||
CONFIG_PATH=$DEFAULT_DATA_PATH/config
|
CONFIG_PATH=$DEFAULT_DATA_DIR/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
|
||||||
@@ -107,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: {
|
||||||
|
|||||||
@@ -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,9 @@ export default {
|
|||||||
return {}
|
return {}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
Source() {
|
||||||
|
return this.$store.state.Source
|
||||||
|
},
|
||||||
currentLibraryId() {
|
currentLibraryId() {
|
||||||
return this.$store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -89,9 +89,6 @@ export default {
|
|||||||
offsetTop() {
|
offsetTop() {
|
||||||
return 64
|
return 64
|
||||||
},
|
},
|
||||||
showExperimentalFeatures() {
|
|
||||||
return this.$store.state.showExperimentalFeatures
|
|
||||||
},
|
|
||||||
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() {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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>
|
||||||
|
|||||||
@@ -572,4 +572,11 @@ export default {
|
|||||||
max-width: calc(100% - 80px);
|
max-width: calc(100% - 80px);
|
||||||
margin-left: 80px;
|
margin-left: 80px;
|
||||||
}
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
#app-content.has-siderail {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
margin-left: 0px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.0.13",
|
"version": "2.0.14",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.0.13",
|
"version": "2.0.14",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/axios": "^5.13.6",
|
"@nuxtjs/axios": "^5.13.6",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.0.14",
|
"version": "2.0.17",
|
||||||
"description": "Self-hosted audiobook and podcast client",
|
"description": "Self-hosted audiobook and podcast client",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -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
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -124,8 +124,9 @@ export default {
|
|||||||
|
|
||||||
location.reload()
|
location.reload()
|
||||||
},
|
},
|
||||||
setUser({ user, userDefaultLibraryId, serverSettings }) {
|
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')
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -163,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 || []
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.0.13",
|
"version": "2.0.14",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.0.13",
|
"version": "2.0.14",
|
||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"archiver": "^5.3.0",
|
"archiver": "^5.3.0",
|
||||||
|
|||||||
+5
-6
@@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.0.14",
|
"version": "2.0.17",
|
||||||
"description": "Self-hosted audiobook and podcast server",
|
"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": {}
|
|
||||||
}
|
}
|
||||||
@@ -58,8 +58,6 @@ 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
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -96,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -35,9 +35,9 @@ const RssFeedManager = require('./managers/RssFeedManager')
|
|||||||
|
|
||||||
class Server {
|
class Server {
|
||||||
constructor(SOURCE, PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH) {
|
constructor(SOURCE, PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH) {
|
||||||
this.Source = SOURCE
|
|
||||||
this.Port = PORT
|
this.Port = PORT
|
||||||
this.Host = HOST
|
this.Host = HOST
|
||||||
|
global.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)
|
||||||
|
|||||||
@@ -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('.'))
|
||||||
|
|||||||
@@ -86,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,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
|
||||||
|
|
||||||
@@ -221,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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
+129
-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,16 @@ 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, sequence] = series ? getSequence(folder) : [folder, null]
|
||||||
Title Here - Subtitle Here - Vol 12
|
var [folder, publishedYear] = getPublishedYear(folder)
|
||||||
Title Here - volume 9 - Subtitle Here
|
var [title, subtitle] = parseSubtitle ? getSubtitle(folder) : [folder, null]
|
||||||
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 +226,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 +321,34 @@ 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 +359,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
|
||||||
},
|
},
|
||||||
@@ -356,4 +375,4 @@ async function getLibraryItemFileData(libraryMediaType, folder, libraryItemPath,
|
|||||||
}
|
}
|
||||||
return libraryItem
|
return libraryItem
|
||||||
}
|
}
|
||||||
module.exports.getLibraryItemFileData = getLibraryItemFileData
|
module.exports.getLibraryItemFileData = getLibraryItemFileData
|
||||||
|
|||||||
Reference in New Issue
Block a user