mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-06-04 18:00:45 +02:00
Compare commits
396 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d2e012d7b1 | |||
| d4fe0be386 | |||
| 6d947bbc29 | |||
| 5187d0e55f | |||
| c6253e4fd4 | |||
| 1ab933c8b0 | |||
| e2e5dd372a | |||
| 3e98b6f749 | |||
| 3c465994fe | |||
| 6cfe583535 | |||
| 0ad7a98fc7 | |||
| a8d5b543d7 | |||
| f2e16017f6 | |||
| 4d227cbade | |||
| 15a85299b9 | |||
| d22e9e32ed | |||
| 8beac53f5f | |||
| cbad435690 | |||
| 169b637720 | |||
| f083d4b5f6 | |||
| 3451a312e9 | |||
| 927c1a3514 | |||
| dabcad5ebd | |||
| 796602d1b2 | |||
| 302870a101 | |||
| 3954aa1963 | |||
| 2d8c840ad6 | |||
| f1f02b185e | |||
| 13d21e90f8 | |||
| dd664da871 | |||
| 6ff66370fe | |||
| 23904d57ad | |||
| efdb43e2d2 | |||
| 67523095d6 | |||
| e2d869bb19 | |||
| d38e9499db | |||
| c7429efe95 | |||
| b925dbcc95 | |||
| 2a235b8324 | |||
| 06cc2a1b21 | |||
| 4bcca97b1f | |||
| 313b9026f1 | |||
| 139ee013a7 | |||
| 7e5ab477b2 | |||
| eba37c46cb | |||
| 228d9cc301 | |||
| 85946dd1d5 | |||
| b40598593d | |||
| e918a46d09 | |||
| 8061ee29d5 | |||
| e15e04f085 | |||
| 958d68ffa9 | |||
| c8a743ccc1 | |||
| 09dc95f560 | |||
| 853858825b | |||
| c962090c3a | |||
| 63a8e2433e | |||
| f78d287b59 | |||
| eaa383b6d8 | |||
| 113026ce13 | |||
| 578a946ca5 | |||
| f31306eda0 | |||
| c62b716a2c | |||
| 97ed20c683 | |||
| d5c46dcbfb | |||
| 30934edd57 | |||
| d06fd1a1b1 | |||
| 6bb36381f1 | |||
| a1331fb3f8 | |||
| 17d15144eb | |||
| 74d26eece4 | |||
| 474a7d08d0 | |||
| 639c930779 | |||
| c6323f8ad9 | |||
| caea6c6371 | |||
| d285845e04 | |||
| 5a6867e98a | |||
| 621444114f | |||
| 5591704aad | |||
| cc1181b301 | |||
| 095f49824e | |||
| b330030f50 | |||
| a7d422e23f | |||
| f51a31c8ca | |||
| 290340a385 | |||
| 0137f6dfeb | |||
| 7f27eabf3e | |||
| 4f7588c87d | |||
| a19b6370c4 | |||
| fbd7ae10d1 | |||
| f94c706fc8 | |||
| 9de4b1069a | |||
| 8fbe3c3884 | |||
| abf9120363 | |||
| 69f250cba5 | |||
| 2103edfcdc | |||
| 02ba147bd4 | |||
| 230b548921 | |||
| f34ebdc016 | |||
| 69ad651671 | |||
| edc919b3f5 | |||
| c8c7a9ece5 | |||
| 8702ac1ccf | |||
| 33833e0a36 | |||
| 6b98baafdf | |||
| cc285bb685 | |||
| ef0243f1d7 | |||
| 7a7d53f92e | |||
| 2e070227ab | |||
| 195a30096f | |||
| 55c40658f2 | |||
| db48a486e5 | |||
| d869a9836e | |||
| 55680cbc98 | |||
| 9b7e6a6058 | |||
| a482e5d316 | |||
| 5ac342defd | |||
| 944a5b3e92 | |||
| 9b9de84740 | |||
| 2746e61cb3 | |||
| 7f1d797fb2 | |||
| 2059c9f14a | |||
| 0e16a9c8de | |||
| b6a33bf7bb | |||
| ce88ac9f33 | |||
| 678dceefed | |||
| 8b38dda229 | |||
| 7373c7159b | |||
| e34a39dde4 | |||
| d4cd8c6db9 | |||
| 9e93a3c7e6 | |||
| 4a8bcc90ea | |||
| 84c12a6e7e | |||
| 2a513ac8b8 | |||
| 97687c96cd | |||
| a42c13aec2 | |||
| 5f0f8b92d1 | |||
| 78ca6aa679 | |||
| 22e3d4a150 | |||
| e3fba1fb2b | |||
| 4d95250990 | |||
| 4776368501 | |||
| 8b0ed2bf29 | |||
| 54389e3c25 | |||
| bf0da1c6ec | |||
| 591a866f8c | |||
| fc8473ed84 | |||
| b19442e440 | |||
| 7a51e0693d | |||
| 21785c8e72 | |||
| bdf6ccbd2d | |||
| ceb163570f | |||
| 049ae73d74 | |||
| 729fdd5c9f | |||
| 4dac8ac16c | |||
| 220bbc3d2d | |||
| c2a4b32192 | |||
| 09d0d47549 | |||
| 4185807da4 | |||
| 8abda14e0f | |||
| 619e5c0895 | |||
| 3a2594cde9 | |||
| 5cca2d0155 | |||
| a467637cb5 | |||
| 1a23001955 | |||
| 8942dca31d | |||
| 2a919012b6 | |||
| 40b342498f | |||
| e220b2818a | |||
| 620bf7990f | |||
| 0df36d2609 | |||
| adfe50a841 | |||
| 35925ddc1b | |||
| 33dfb764fa | |||
| 49bef2c641 | |||
| ac58536501 | |||
| c344555be3 | |||
| 645bcc53c6 | |||
| 84dd06dfc4 | |||
| 0a73dd6437 | |||
| 2cc055a1ad | |||
| d8ec3bd218 | |||
| d189ec74c9 | |||
| 4291769b93 | |||
| 22900a3f67 | |||
| 7fa08449de | |||
| 4f7203fccb | |||
| 0eea766931 | |||
| 5c054aef90 | |||
| a1674d5da1 | |||
| 91597a5454 | |||
| 11354a3e3f | |||
| dcd4f69383 | |||
| e253939c1e | |||
| f25ce1c0e7 | |||
| 7717e57c16 | |||
| 2e28c9b06d | |||
| 4bc7cd2045 | |||
| 5389115120 | |||
| 6e99cf6570 | |||
| 21bdd9f9ec | |||
| e3ae3f7e6a | |||
| 74bf917150 | |||
| 5666b263f5 | |||
| fc8fec62a0 | |||
| 034d858f18 | |||
| ebc9e1a888 | |||
| c5a9c2bf5a | |||
| 3dbce8fd71 | |||
| b2d299dba6 | |||
| cb5d9a8287 | |||
| f9530897c0 | |||
| 7c7e8285a4 | |||
| 7b3f9a1e0c | |||
| 399e0ea0bc | |||
| a47b0bce57 | |||
| 4b60b4f73e | |||
| d88b20addd | |||
| 5d12cc3f23 | |||
| 84fb7ce8b3 | |||
| 243cc672f7 | |||
| 663546dd77 | |||
| 1b79b3f42d | |||
| d4525ad5ca | |||
| dc9c307663 | |||
| 554e9ec238 | |||
| 2276228531 | |||
| 6f7d2ef4cd | |||
| ad3fbe7abf | |||
| c58110c7b7 | |||
| f781fa9e6b | |||
| 7f3543400a | |||
| 1ff5637c1b | |||
| f2d9de5a5f | |||
| 8be3bebee8 | |||
| ef88972b25 | |||
| 35f3b5863f | |||
| ff294867f8 | |||
| 1c6cd7499b | |||
| ce35ae6b03 | |||
| 28c99cf17f | |||
| 584e754eae | |||
| 68cf748e77 | |||
| 9b8f53caf6 | |||
| fdf332937f | |||
| 182545a729 | |||
| e83df2bf4b | |||
| 10299e3037 | |||
| 6a43672973 | |||
| 02bf55b401 | |||
| f0615c2971 | |||
| 7ef44eb75b | |||
| 044804115b | |||
| 3b941d59a3 | |||
| d69f6020c6 | |||
| 2fc60e4e9c | |||
| cdcfd01da2 | |||
| d6c5b6e8c6 | |||
| 5d305c96ad | |||
| 6d823f4e42 | |||
| bd5e865a11 | |||
| cd274e0844 | |||
| e9249430c3 | |||
| cd5e5099f2 | |||
| 09dd90e3fc | |||
| a62f7a4861 | |||
| 5a26b01ffb | |||
| cbde451120 | |||
| 8bbeae4873 | |||
| 05dff2583a | |||
| 79a82df914 | |||
| 3f6ed6dbf9 | |||
| 4edba20e9e | |||
| 2c6e1cc2b5 | |||
| e1af25d9d8 | |||
| 9b30a8ff4b | |||
| b1a9de819e | |||
| 68da974c12 | |||
| 8c47ccb651 | |||
| d544ecc657 | |||
| 9f69a8ace3 | |||
| a90cfc4d04 | |||
| 88354de495 | |||
| 5b02c5185f | |||
| 1152e5513e | |||
| 8ce9b55969 | |||
| ccf08e9e80 | |||
| b0b1d2707d | |||
| 469278cd1e | |||
| 10d9e11387 | |||
| 5328f4cddb | |||
| 4154022ad1 | |||
| 642e9787c0 | |||
| da2e65c042 | |||
| ab895fa8ed | |||
| f5e892b862 | |||
| ac097862fc | |||
| 23cc6bb210 | |||
| c60807f998 | |||
| 99e2ea228d | |||
| 8df05896b5 | |||
| 174dac8fd4 | |||
| 2a386ca2a9 | |||
| fc228013d3 | |||
| 64b824ef6b | |||
| 96cd91a385 | |||
| 5c91c1e2c7 | |||
| 2df5ab0dde | |||
| baf738f5ba | |||
| 3a7cafbb95 | |||
| 3276b04256 | |||
| ac3fa31d1e | |||
| 6e5e638076 | |||
| 609bf4309f | |||
| 66b5c14c6b | |||
| e4936ed522 | |||
| c201e2aa98 | |||
| 3d3f20296c | |||
| 9ae71615bc | |||
| 292840a0e3 | |||
| 84e6e6fdbe | |||
| cfe27dff80 | |||
| c75895d711 | |||
| c0ff28ffff | |||
| 58dfa65660 | |||
| 3f8e685d64 | |||
| 08e1782253 | |||
| 0dd219f303 | |||
| d5e96a3422 | |||
| 03bfecefee | |||
| 12027b9a76 | |||
| 0e665e2091 | |||
| e32d05ea27 | |||
| 5446aea910 | |||
| 86e7c7fc33 | |||
| 173b72c3b5 | |||
| 3150822117 | |||
| 9a96d17a30 | |||
| c98409b9ae | |||
| 0e3640c246 | |||
| e030b59bae | |||
| 920ca683b9 | |||
| 28d76d21f1 | |||
| e1e6b46456 | |||
| 122f2a2556 | |||
| 27f1bd90f9 | |||
| f8d0384155 | |||
| 43bbfbfee3 | |||
| deadc63dbb | |||
| a9b9e23f46 | |||
| 6a06ba4327 | |||
| 3d2bbc7719 | |||
| c9ea5dd2d7 | |||
| eea3e2583c | |||
| 57399bb79e | |||
| 69fcb103e4 | |||
| f00b120e96 | |||
| 14a8f84446 | |||
| 099ae7c776 | |||
| 1cf9e85272 | |||
| c4eeb1cfb7 | |||
| 1dde02b170 | |||
| 08e648a3bc | |||
| 755e70b4a9 | |||
| 5ff4cd2c0b | |||
| e36c31c5e7 | |||
| d561a48229 | |||
| 5243a225e8 | |||
| 4fe60465e5 | |||
| 0af6ad63c1 | |||
| 68b13ae45f | |||
| 4c2ad3ede5 | |||
| deea6702f0 | |||
| 7348432594 | |||
| 7d66f1eec9 | |||
| be1e1e7ba0 | |||
| 4bdef893af | |||
| 6597fca576 | |||
| ea9ec13845 | |||
| 30f15d3575 | |||
| dad12537b6 | |||
| 65df377a49 | |||
| 2d19208340 | |||
| 73257188f6 | |||
| 5f4e5cd3d8 | |||
| f2be3bc95e | |||
| 2a30cc428f | |||
| b97ed953f7 | |||
| 65793f7109 | |||
| 2b7f53b0a7 | |||
| c6eb1096e8 | |||
| a907c88f66 | |||
| 43f48b65f8 | |||
| 2a4cbd48b8 | |||
| b6e4f3a8c5 | |||
| 83976b5549 |
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
---
|
||||||
|
|
||||||
|
name: Build and Push Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
tags:
|
||||||
|
- 'v*.*.*'
|
||||||
|
# Only build when files in these directories have been changed
|
||||||
|
paths:
|
||||||
|
- client/**
|
||||||
|
- server/**
|
||||||
|
- index.js
|
||||||
|
- package.json
|
||||||
|
# Allows you to run workflow manually from Actions tab
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
if: "!contains(github.event.head_commit.message, 'skip ci')"
|
||||||
|
runs-on: ubuntu-20.04
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
|
||||||
|
- name: Docker meta
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v3
|
||||||
|
with:
|
||||||
|
images: advplyr/audiobookshelf,ghcr.io/${{ github.repository_owner }}/audiobookshelf
|
||||||
|
tags: |
|
||||||
|
type=edge,branch=master
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
- name: Setup QEMU
|
||||||
|
uses: docker/setup-qemu-action@v1
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v1
|
||||||
|
|
||||||
|
- name: Cache Docker layers
|
||||||
|
uses: actions/cache@v2
|
||||||
|
with:
|
||||||
|
path: /tmp/.buildx-cache
|
||||||
|
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-buildx-
|
||||||
|
|
||||||
|
- name: Login to Dockerhub
|
||||||
|
uses: docker/login-action@v1
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Login to ghcr
|
||||||
|
uses: docker/login-action@v1
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GHCR_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Build image
|
||||||
|
uses: docker/build-push-action@v2
|
||||||
|
with:
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||||
|
push: true
|
||||||
|
cache-from: type=local,src=/tmp/.buildx-cache
|
||||||
|
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
|
||||||
|
|
||||||
|
- name: Move cache
|
||||||
|
run: |
|
||||||
|
rm -rf /tmp/.buildx-cache
|
||||||
|
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
|
||||||
@@ -4,6 +4,7 @@ node_modules/
|
|||||||
/config/
|
/config/
|
||||||
/audiobooks/
|
/audiobooks/
|
||||||
/audiobooks2/
|
/audiobooks2/
|
||||||
|
/podcasts/
|
||||||
/media/
|
/media/
|
||||||
/metadata/
|
/metadata/
|
||||||
test/
|
test/
|
||||||
@@ -12,3 +13,4 @@ test/
|
|||||||
/dist/
|
/dist/
|
||||||
|
|
||||||
sw.*
|
sw.*
|
||||||
|
.DS_STORE
|
||||||
+3
-3
@@ -1,12 +1,12 @@
|
|||||||
### STAGE 0: Build client ###
|
### STAGE 0: Build client ###
|
||||||
FROM node:12-alpine AS build
|
FROM node:16-alpine AS build
|
||||||
WORKDIR /client
|
WORKDIR /client
|
||||||
COPY /client /client
|
COPY /client /client
|
||||||
RUN npm install
|
RUN npm install
|
||||||
RUN npm run generate
|
RUN npm run generate
|
||||||
|
|
||||||
### STAGE 1: Build server ###
|
### STAGE 1: Build server ###
|
||||||
FROM node:12-alpine
|
FROM node:16-alpine
|
||||||
RUN apk update && apk add --no-cache --update ffmpeg
|
RUN apk update && apk add --no-cache --update ffmpeg
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
COPY --from=build /client/dist /client/dist
|
COPY --from=build /client/dist /client/dist
|
||||||
@@ -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'
|
||||||
|
|||||||
+62
-76
@@ -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_AUDIOBOOK_PATH="/usr/share/audiobookshelf/audiobooks"
|
DEFAULT_DATA_DIR="/usr/share/audiobookshelf"
|
||||||
DEFAULT_DATA_PATH="/usr/share/audiobookshelf"
|
|
||||||
DEFAULT_PORT=7331
|
|
||||||
|
|
||||||
CONFIG_PATH="/etc/default/audiobookshelf"
|
CONFIG_PATH="/etc/default/audiobookshelf"
|
||||||
|
DEFAULT_PORT=7331
|
||||||
|
DEFAULT_HOST="0.0.0.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
|
||||||
|
}
|
||||||
|
|
||||||
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,85 +67,27 @@ install_ffmpeg() {
|
|||||||
echo "Good to go on Ffmpeg... hopefully"
|
echo "Good to go on Ffmpeg... hopefully"
|
||||||
}
|
}
|
||||||
|
|
||||||
should_build_config() {
|
|
||||||
if [ -f "$CONFIG_PATH" ]; then
|
|
||||||
echo "You already have a config file. Do you want to use it?"
|
|
||||||
|
|
||||||
options=("Yes" "No")
|
|
||||||
select yn in "${options[@]}"
|
|
||||||
do
|
|
||||||
case $yn in
|
|
||||||
"Yes")
|
|
||||||
false; return
|
|
||||||
;;
|
|
||||||
"No")
|
|
||||||
true; return
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
else
|
|
||||||
echo "No existing config found in $CONFIG_PATH"
|
|
||||||
true; return
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
setup_config_interactive() {
|
|
||||||
if should_build_config; then
|
|
||||||
echo "Okay, let's setup a new config."
|
|
||||||
|
|
||||||
AUDIOBOOK_PATH=""
|
|
||||||
read -p "
|
|
||||||
Enter path for your audiobooks [Default: $DEFAULT_AUDIOBOOK_PATH]:" AUDIOBOOK_PATH
|
|
||||||
|
|
||||||
if [[ -z "$AUDIOBOOK_PATH" ]]; then
|
|
||||||
AUDIOBOOK_PATH="$DEFAULT_AUDIOBOOK_PATH"
|
|
||||||
fi
|
|
||||||
|
|
||||||
DATA_PATH=""
|
|
||||||
read -p "
|
|
||||||
Enter path for data files, i.e. streams, downloads, database [Default: $DEFAULT_DATA_PATH]:" DATA_PATH
|
|
||||||
|
|
||||||
if [[ -z "$DATA_PATH" ]]; then
|
|
||||||
DATA_PATH="$DEFAULT_DATA_PATH"
|
|
||||||
fi
|
|
||||||
|
|
||||||
PORT=""
|
|
||||||
read -p "
|
|
||||||
Port for the web ui [Default: $DEFAULT_PORT]:" PORT
|
|
||||||
|
|
||||||
if [[ -z "$PORT" ]]; then
|
|
||||||
PORT="$DEFAULT_PORT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
config_text="AUDIOBOOK_PATH=$AUDIOBOOK_PATH
|
|
||||||
METADATA_PATH=$DATA_PATH/metadata
|
|
||||||
CONFIG_PATH=$DATA_PATH/config
|
|
||||||
FFMPEG_PATH=/usr/lib/audiobookshelf-ffmpeg/ffmpeg
|
|
||||||
FFPROBE_PATH=/usr/lib/audiobookshelf-ffmpeg/ffprobe
|
|
||||||
PORT=$PORT"
|
|
||||||
|
|
||||||
echo "$config_text"
|
|
||||||
|
|
||||||
echo "$config_text" > /etc/default/audiobookshelf;
|
|
||||||
|
|
||||||
echo "Config created"
|
|
||||||
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
setup_config() {
|
setup_config() {
|
||||||
if [ -f "$CONFIG_PATH" ]; then
|
if [ -f "$CONFIG_PATH" ]; then
|
||||||
echo "Existing config found."
|
echo "Existing config found."
|
||||||
cat $CONFIG_PATH
|
cat $CONFIG_PATH
|
||||||
else
|
else
|
||||||
|
|
||||||
|
if [ ! -d "$DEFAULT_DATA_DIR" ]; then
|
||||||
|
# Create directory and set permissions
|
||||||
|
echo "Creating default data dir at $DEFAULT_DATA_DIR"
|
||||||
|
mkdir "$DEFAULT_DATA_DIR"
|
||||||
|
chown -R 'audiobookshelf:audiobookshelf' "$DEFAULT_DATA_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Creating default config."
|
echo "Creating default config."
|
||||||
|
|
||||||
config_text="AUDIOBOOK_PATH=$DEFAULT_AUDIOBOOK_PATH
|
config_text="METADATA_PATH=$DEFAULT_DATA_DIR/metadata
|
||||||
METADATA_PATH=$DEFAULT_DATA_PATH/metadata
|
CONFIG_PATH=$DEFAULT_DATA_DIR/config
|
||||||
CONFIG_PATH=$DEFAULT_DATA_PATH/config
|
|
||||||
FFMPEG_PATH=/usr/lib/audiobookshelf-ffmpeg/ffmpeg
|
FFMPEG_PATH=/usr/lib/audiobookshelf-ffmpeg/ffmpeg
|
||||||
FFPROBE_PATH=/usr/lib/audiobookshelf-ffmpeg/ffprobe
|
FFPROBE_PATH=/usr/lib/audiobookshelf-ffmpeg/ffprobe
|
||||||
PORT=$DEFAULT_PORT"
|
PORT=$DEFAULT_PORT
|
||||||
|
HOST=$DEFAULT_HOST"
|
||||||
|
|
||||||
echo "$config_text"
|
echo "$config_text"
|
||||||
|
|
||||||
@@ -115,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
|
||||||
|
|||||||
+1
-1
@@ -48,7 +48,7 @@ Description: $DESCRIPTION"
|
|||||||
echo "$controlfile" > dist/debian/DEBIAN/control;
|
echo "$controlfile" > dist/debian/DEBIAN/control;
|
||||||
|
|
||||||
# Package debian
|
# Package debian
|
||||||
pkg -t node12-linux-x64 -o dist/debian/usr/share/audiobookshelf/audiobookshelf .
|
pkg -t node16-linux-x64 -o dist/debian/usr/share/audiobookshelf/audiobookshelf .
|
||||||
|
|
||||||
fakeroot dpkg-deb --build dist/debian
|
fakeroot dpkg-deb --build dist/debian
|
||||||
|
|
||||||
|
|||||||
+57
-19
@@ -12,18 +12,30 @@
|
|||||||
height: calc(100% - 64px);
|
height: calc(100% - 64px);
|
||||||
max-height: calc(100% - 64px);
|
max-height: calc(100% - 64px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page.streaming {
|
.page.streaming {
|
||||||
height: calc(100% - 64px - 165px);
|
height: calc(100% - 64px - 165px);
|
||||||
max-height: calc(100% - 64px - 165px);
|
max-height: calc(100% - 64px - 165px);
|
||||||
}
|
}
|
||||||
|
|
||||||
#bookshelf {
|
#bookshelf {
|
||||||
height: calc(100% - 40px);
|
height: calc(100% - 40px);
|
||||||
background-image: linear-gradient(to right bottom, #2e2e2e, #303030, #313131, #333333, #353535, #343434, #323232, #313131, #2c2c2c, #282828, #232323, #1f1f1f);
|
background-image: linear-gradient(to right bottom, #2e2e2e, #303030, #313131, #333333, #353535, #343434, #323232, #313131, #2c2c2c, #282828, #232323, #1f1f1f);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bookshelf-row {
|
||||||
|
/* Sidebar width + scrollbar width */
|
||||||
|
width: calc(100vw - 88px);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
#bookshelf {
|
#bookshelf {
|
||||||
height: calc(100% - 80px);
|
height: calc(100% - 80px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bookshelf-row {
|
||||||
|
width: 100vw;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#page-wrapper {
|
#page-wrapper {
|
||||||
@@ -34,33 +46,22 @@
|
|||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar:horizontal {
|
::-webkit-scrollbar:horizontal {
|
||||||
height: 8px;
|
height: 8px;
|
||||||
}
|
}
|
||||||
/* ::-webkit-scrollbar:horizontal { */
|
|
||||||
/* height: 16px; */
|
|
||||||
/* height: 24px;
|
|
||||||
} */
|
|
||||||
/* Track */
|
/* Track */
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background-color: rgba(0,0,0,0);
|
background-color: rgba(0, 0, 0, 0);
|
||||||
}
|
}
|
||||||
/* ::-webkit-scrollbar-track:horizontal { */
|
|
||||||
/* background: rgb(149, 119, 90); */
|
|
||||||
/* background: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%); */
|
|
||||||
/* background: linear-gradient(180deg, rgb(117, 88, 60) 0%, rgb(65, 41, 17) 17%, rgb(71, 43, 15) 88%, rgb(3, 2, 1) 100%);
|
|
||||||
box-shadow: 2px 14px 8px #111111aa;
|
|
||||||
} */
|
|
||||||
/* Handle */
|
/* Handle */
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: #855620;
|
background: #855620;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
/* ::-webkit-scrollbar-thumb:horizontal { */
|
|
||||||
/* background: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%); */
|
|
||||||
/* box-shadow: 2px 14px 8px #111111aa;
|
|
||||||
border-radius: 4px;
|
|
||||||
} */
|
|
||||||
/* Handle on hover */
|
/* Handle on hover */
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: #704922;
|
background: #704922;
|
||||||
@@ -71,6 +72,13 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.no-scroll {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
/* IE and Edge */
|
||||||
|
scrollbar-width: none;
|
||||||
|
/* Firefox */
|
||||||
|
}
|
||||||
|
|
||||||
/* Chrome, Safari, Edge, Opera */
|
/* Chrome, Safari, Edge, Opera */
|
||||||
.no-spinner::-webkit-outer-spin-button,
|
.no-spinner::-webkit-outer-spin-button,
|
||||||
.no-spinner::-webkit-inner-spin-button {
|
.no-spinner::-webkit-inner-spin-button {
|
||||||
@@ -89,18 +97,23 @@ input[type=number] {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px solid #474747;
|
border: 1px solid #474747;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tracksTable tr:nth-child(even) {
|
.tracksTable tr:nth-child(even) {
|
||||||
background-color: #2e2e2e;
|
background-color: #2e2e2e;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tracksTable tr {
|
.tracksTable tr {
|
||||||
background-color: #373838;
|
background-color: #373838;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tracksTable tr:hover {
|
.tracksTable tr:hover {
|
||||||
background-color: #474747;
|
background-color: #474747;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tracksTable td {
|
.tracksTable td {
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tracksTable th {
|
.tracksTable th {
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
@@ -113,13 +126,22 @@ input[type=number] {
|
|||||||
border-right: 6px solid transparent;
|
border-right: 6px solid transparent;
|
||||||
border-top: 6px solid white;
|
border-top: 6px solid white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.arrow-down-small {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-left: 4px solid transparent;
|
||||||
|
border-right: 4px solid transparent;
|
||||||
|
border-top: 4px solid currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
.triangle-right {
|
.triangle-right {
|
||||||
width: 0;
|
width: 0;
|
||||||
height: 0;
|
height: 0;
|
||||||
border-left: 8px solid transparent;
|
border-left: 8px solid transparent;
|
||||||
border-bottom: 8px solid transparent;
|
border-bottom: 8px solid transparent;
|
||||||
border-top: 8px solid rgb(34,127,35);
|
border-top: 8px solid rgb(34, 127, 35);
|
||||||
border-right: 8px solid rgb(34,127,35);
|
border-right: 8px solid rgb(34, 127, 35);
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-text {
|
.icon-text {
|
||||||
@@ -149,6 +171,7 @@ input[type=number] {
|
|||||||
.box-shadow-book {
|
.box-shadow-book {
|
||||||
box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166;
|
box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shadow-height {
|
.shadow-height {
|
||||||
height: calc(100% - 4px);
|
height: calc(100% - 4px);
|
||||||
}
|
}
|
||||||
@@ -165,9 +188,9 @@ input[type=number] {
|
|||||||
Bookshelf Label
|
Bookshelf Label
|
||||||
*/
|
*/
|
||||||
.categoryPlacard {
|
.categoryPlacard {
|
||||||
background-image: url(https://image.freepik.com/free-photo/brown-wooden-textured-flooring-background_53876-128537.jpg);
|
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shinyBlack {
|
.shinyBlack {
|
||||||
background-color: #2d3436;
|
background-color: #2d3436;
|
||||||
background-image: linear-gradient(315deg, #19191a 0%, rgb(15, 15, 15) 74%);
|
background-image: linear-gradient(315deg, #19191a 0%, rgb(15, 15, 15) 74%);
|
||||||
@@ -187,3 +210,18 @@ Bookshelf Label
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
filter: blur(20px);
|
filter: blur(20px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.episode-subtitle {
|
||||||
|
word-break: break-word;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
line-height: 16px;
|
||||||
|
/* fallback */
|
||||||
|
max-height: 32px;
|
||||||
|
/* fallback */
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
/* number of lines to show */
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
+275
-3
@@ -1,10 +1,10 @@
|
|||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Material Icons';
|
font-family: 'Material Icons';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
src: url(/fonts/MaterialIcons.woff2) format('woff2');
|
src: url(/fonts/MaterialIcons.woff2) format('woff2');
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Material Icons Outlined';
|
font-family: 'Material Icons Outlined';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
@@ -23,12 +23,13 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
word-wrap: normal;
|
word-wrap: normal;
|
||||||
direction: ltr;
|
direction: ltr;
|
||||||
-webkit-font-feature-settings: 'liga';
|
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
.material-icons:not(.text-xs):not(.text-sm):not(.text-md):not(.text-base):not(.text-lg):not(.text-xl):not(.text-2xl):not(.text-3xl):not(.text-4xl):not(.text-5xl):not(.text-6xl):not(.text-7xl):not(.text-8xl) {
|
.material-icons:not(.text-xs):not(.text-sm):not(.text-md):not(.text-base):not(.text-lg):not(.text-xl):not(.text-2xl):not(.text-3xl):not(.text-4xl):not(.text-5xl):not(.text-6xl):not(.text-7xl):not(.text-8xl) {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.material-icons-outlined {
|
.material-icons-outlined {
|
||||||
font-family: 'Material Icons Outlined';
|
font-family: 'Material Icons Outlined';
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
@@ -40,9 +41,9 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
word-wrap: normal;
|
word-wrap: normal;
|
||||||
direction: ltr;
|
direction: ltr;
|
||||||
-webkit-font-feature-settings: 'liga';
|
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
.material-icons-outlined:not(.text-xs):not(.text-sm):not(.text-md):not(.text-base):not(.text-lg):not(.text-xl):not(.text-2xl):not(.text-3xl):not(.text-4xl):not(.text-5xl):not(.text-6xl):not(.text-7xl):not(.text-8xl) {
|
.material-icons-outlined:not(.text-xs):not(.text-sm):not(.text-md):not(.text-base):not(.text-lg):not(.text-xl):not(.text-2xl):not(.text-3xl):not(.text-4xl):not(.text-5xl):not(.text-6xl):not(.text-7xl):not(.text-8xl) {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
@@ -56,6 +57,7 @@
|
|||||||
src: url(/fonts/GentiumBookBasic.woff2) format('woff2');
|
src: url(/fonts/GentiumBookBasic.woff2) format('woff2');
|
||||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* latin */
|
/* latin */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Gentium Book Basic';
|
font-family: 'Gentium Book Basic';
|
||||||
@@ -65,3 +67,273 @@
|
|||||||
src: url(/fonts/GentiumBookBasic.woff2) format('woff2');
|
src: url(/fonts/GentiumBookBasic.woff2) format('woff2');
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* cyrillic-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 300;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
|
||||||
|
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* cyrillic */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 300;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
|
||||||
|
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* greek-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 300;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
|
||||||
|
unicode-range: U+1F00-1FFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* greek */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 300;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
|
||||||
|
unicode-range: U+0370-03FF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* vietnamese */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 300;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
|
||||||
|
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 300;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
|
||||||
|
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 300;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* cyrillic-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
|
||||||
|
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* cyrillic */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
|
||||||
|
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* greek-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
|
||||||
|
unicode-range: U+1F00-1FFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* greek */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
|
||||||
|
unicode-range: U+0370-03FF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* vietnamese */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
|
||||||
|
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
|
||||||
|
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* cyrillic-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
|
||||||
|
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* cyrillic */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
|
||||||
|
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* greek-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
|
||||||
|
unicode-range: U+1F00-1FFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* greek */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
|
||||||
|
unicode-range: U+0370-03FF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* vietnamese */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
|
||||||
|
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
|
||||||
|
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* cyrillic-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Ubuntu Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
|
||||||
|
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* cyrillic */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Ubuntu Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
|
||||||
|
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* greek-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Ubuntu Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
|
||||||
|
unicode-range: U+1F00-1FFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* greek */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Ubuntu Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
|
||||||
|
unicode-range: U+0370-03FF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Ubuntu Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
|
||||||
|
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Ubuntu Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="cursor-pointer text-gray-300 mx-1 md:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showBookmarks')">
|
<div v-if="!isPodcast" class="cursor-pointer text-gray-300 mx-1 md:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showBookmarks')">
|
||||||
<span class="material-icons" style="font-size: 1.7rem">{{ bookmarks.length ? 'bookmarks' : 'bookmark_border' }}</span>
|
<span class="material-icons" style="font-size: 1.7rem">{{ bookmarks.length ? 'bookmarks' : 'bookmark_border' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -99,7 +99,8 @@ export default {
|
|||||||
default: () => []
|
default: () => []
|
||||||
},
|
},
|
||||||
sleepTimerSet: Boolean,
|
sleepTimerSet: Boolean,
|
||||||
sleepTimerRemaining: Number
|
sleepTimerRemaining: Number,
|
||||||
|
isPodcast: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -152,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: {
|
||||||
|
|||||||
@@ -2,15 +2,17 @@
|
|||||||
<div class="w-full h-16 bg-primary relative">
|
<div class="w-full h-16 bg-primary relative">
|
||||||
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-50">
|
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-50">
|
||||||
<div class="flex h-full items-center">
|
<div class="flex h-full items-center">
|
||||||
<img v-if="!showBack" src="/icon48.png" class="w-10 h-10 md:w-12 md:h-12 mr-4" />
|
<nuxt-link to="/">
|
||||||
<a v-if="showBack" @click="back" class="rounded-full h-12 w-12 flex items-center justify-center hover:bg-white hover:bg-opacity-10 mr-4 cursor-pointer">
|
<img src="/icon48.png" class="w-10 h-10 md:w-12 md:h-12 mr-4" />
|
||||||
<span class="material-icons text-4xl text-white">arrow_back</span>
|
</nuxt-link>
|
||||||
</a>
|
|
||||||
<h1 class="text-2xl font-book mr-6 hidden lg:block">audiobookshelf</h1>
|
<nuxt-link to="/">
|
||||||
|
<h1 class="text-2xl font-book mr-6 hidden lg:block hover:underline">audiobookshelf</h1>
|
||||||
|
</nuxt-link>
|
||||||
|
|
||||||
<ui-libraries-dropdown />
|
<ui-libraries-dropdown />
|
||||||
|
|
||||||
<controls-global-search class="hidden md:block" />
|
<controls-global-search v-if="currentLibrary" class="hidden md:block" />
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
|
|
||||||
<span v-if="showExperimentalFeatures" class="material-icons text-4xl text-warning pr-0 sm:pr-2 md:pr-4">logo_dev</span>
|
<span v-if="showExperimentalFeatures" class="material-icons text-4xl text-warning pr-0 sm:pr-2 md:pr-4">logo_dev</span>
|
||||||
@@ -22,16 +24,16 @@
|
|||||||
<google-cast-launcher></google-cast-launcher>
|
<google-cast-launcher></google-cast-launcher>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nuxt-link to="/config/stats" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
<nuxt-link v-if="currentLibrary" to="/config/stats" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
||||||
<span class="material-icons">equalizer</span>
|
<span class="material-icons" aria-label="User Stats" role="button">equalizer</span>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link v-if="userCanUpload" to="/upload" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
<nuxt-link v-if="userCanUpload && currentLibrary" to="/upload" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
||||||
<span class="material-icons">upload</span>
|
<span class="material-icons" aria-label="Upload Media" role="button">upload</span>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link v-if="isRootUser" to="/config" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
<nuxt-link v-if="userIsAdminOrUp" to="/config" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
||||||
<span class="material-icons">settings</span>
|
<span class="material-icons" aria-label="System Settings" role="button">settings</span>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link to="/account" class="relative w-9 h-9 md:w-32 bg-fg border border-gray-500 rounded shadow-sm ml-1.5 sm:ml-3 md:ml-5 md:pl-3 md:pr-10 py-2 text-left focus:outline-none sm:text-sm cursor-pointer hover:bg-bg hover:bg-opacity-40" aria-haspopup="listbox" aria-expanded="true">
|
<nuxt-link to="/account" class="relative w-9 h-9 md:w-32 bg-fg border border-gray-500 rounded shadow-sm ml-1.5 sm:ml-3 md:ml-5 md:pl-3 md:pr-10 py-2 text-left focus:outline-none sm:text-sm cursor-pointer hover:bg-bg hover:bg-opacity-40" aria-haspopup="listbox" aria-expanded="true">
|
||||||
@@ -44,20 +46,26 @@
|
|||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-show="numAudiobooksSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center">
|
<div v-show="numLibraryItemsSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center">
|
||||||
<h1 class="text-2xl px-4">{{ numAudiobooksSelected }} Selected</h1>
|
<h1 class="text-2xl px-4">{{ numLibraryItemsSelected }} Selected</h1>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<ui-tooltip :text="`Mark as ${selectedIsRead ? 'Not Read' : 'Read'}`" direction="bottom">
|
<ui-tooltip v-if="!isPodcastLibrary" :text="`Mark as ${selectedIsFinished ? 'Not Finished' : 'Finished'}`" direction="bottom">
|
||||||
<ui-read-icon-btn :disabled="processingBatch" :is-read="selectedIsRead" @click="toggleBatchRead" class="mx-1.5" />
|
<ui-read-icon-btn :disabled="processingBatch" :is-read="selectedIsFinished" @click="toggleBatchRead" class="mx-1.5" />
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
<ui-tooltip v-if="userCanUpdate" text="Add to Collection" direction="bottom">
|
<ui-tooltip v-if="userCanUpdate && !isPodcastLibrary" text="Add to Collection" direction="bottom">
|
||||||
<ui-icon-btn :disabled="processingBatch" icon="collections_bookmark" @click="batchAddToCollectionClick" class="mx-1.5" />
|
<ui-icon-btn :disabled="processingBatch" icon="collections_bookmark" @click="batchAddToCollectionClick" class="mx-1.5" />
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
<template v-if="userCanUpdate && numAudiobooksSelected < 50">
|
<template v-if="userCanUpdate && numLibraryItemsSelected < 50">
|
||||||
<ui-icon-btn v-show="!processingBatchDelete" icon="edit" bg-color="warning" class="mx-1.5" @click="batchEditClick" />
|
<ui-tooltip text="Edit" direction="bottom">
|
||||||
|
<ui-icon-btn v-show="!processingBatchDelete" icon="edit" bg-color="warning" class="mx-1.5" @click="batchEditClick" />
|
||||||
|
</ui-tooltip>
|
||||||
</template>
|
</template>
|
||||||
<ui-icon-btn v-show="userCanDelete" :disabled="processingBatchDelete" icon="delete" bg-color="error" class="mx-1.5" @click="batchDeleteClick" />
|
<ui-tooltip v-if="userCanDelete" text="Delete" direction="bottom">
|
||||||
<span class="material-icons text-4xl px-4 hover:text-gray-100 cursor-pointer" :class="processingBatchDelete ? 'text-gray-400' : ''" @click="cancelSelectionMode">close</span>
|
<ui-icon-btn :disabled="processingBatchDelete" icon="delete" bg-color="error" class="mx-1.5" @click="batchDeleteClick" />
|
||||||
|
</ui-tooltip>
|
||||||
|
<ui-tooltip text="Deselect All" direction="bottom">
|
||||||
|
<span class="material-icons text-4xl px-4 hover:text-gray-100 cursor-pointer" :class="processingBatchDelete ? 'text-gray-400' : ''" @click="cancelSelectionMode">close</span>
|
||||||
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -79,32 +87,32 @@ export default {
|
|||||||
libraryName() {
|
libraryName() {
|
||||||
return this.currentLibrary ? this.currentLibrary.name : 'unknown'
|
return this.currentLibrary ? this.currentLibrary.name : 'unknown'
|
||||||
},
|
},
|
||||||
|
libraryMediaType() {
|
||||||
|
return this.currentLibrary ? this.currentLibrary.mediaType : null
|
||||||
|
},
|
||||||
|
isPodcastLibrary() {
|
||||||
|
return this.libraryMediaType === 'podcast'
|
||||||
|
},
|
||||||
isHome() {
|
isHome() {
|
||||||
return this.$route.name === 'library-library'
|
return this.$route.name === 'library-library'
|
||||||
},
|
},
|
||||||
showBack() {
|
|
||||||
return this.$route.name !== 'library-library-bookshelf-id' && !this.isHome
|
|
||||||
},
|
|
||||||
user() {
|
user() {
|
||||||
return this.$store.state.user.user
|
return this.$store.state.user.user
|
||||||
},
|
},
|
||||||
isRootUser() {
|
userIsAdminOrUp() {
|
||||||
return this.$store.getters['user/getIsRoot']
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
},
|
},
|
||||||
username() {
|
username() {
|
||||||
return this.user ? this.user.username : 'err'
|
return this.user ? this.user.username : 'err'
|
||||||
},
|
},
|
||||||
numAudiobooksSelected() {
|
numLibraryItemsSelected() {
|
||||||
return this.selectedAudiobooks.length
|
return this.selectedLibraryItems.length
|
||||||
},
|
},
|
||||||
selectedAudiobooks() {
|
selectedLibraryItems() {
|
||||||
return this.$store.state.selectedAudiobooks
|
return this.$store.state.selectedLibraryItems
|
||||||
},
|
},
|
||||||
userAudiobooks() {
|
userMediaProgress() {
|
||||||
return this.$store.state.user.user.audiobooks || {}
|
return this.$store.state.user.user.mediaProgress || []
|
||||||
},
|
|
||||||
selectedSeries() {
|
|
||||||
return this.$store.state.audiobooks.selectedSeries
|
|
||||||
},
|
},
|
||||||
userCanUpdate() {
|
userCanUpdate() {
|
||||||
return this.$store.getters['user/getUserCanUpdate']
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
@@ -115,11 +123,11 @@ export default {
|
|||||||
userCanUpload() {
|
userCanUpload() {
|
||||||
return this.$store.getters['user/getUserCanUpload']
|
return this.$store.getters['user/getUserCanUpload']
|
||||||
},
|
},
|
||||||
selectedIsRead() {
|
selectedIsFinished() {
|
||||||
// Find an audiobook that is not read, if none then all audiobooks read
|
// Find an item that is not finished, if none then all items finished
|
||||||
return !this.selectedAudiobooks.find((ab) => {
|
return !this.selectedLibraryItems.find((libraryItemId) => {
|
||||||
var userAb = this.userAudiobooks[ab]
|
var itemProgress = this.userMediaProgress.find((lip) => lip.libraryItemId === libraryItemId)
|
||||||
return !userAb || !userAb.isRead
|
return !itemProgress || !itemProgress.isFinished
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
processingBatch() {
|
processingBatch() {
|
||||||
@@ -142,33 +150,28 @@ export default {
|
|||||||
toggleBookshelfTexture() {
|
toggleBookshelfTexture() {
|
||||||
this.$store.dispatch('setBookshelfTexture', 'wood2.png')
|
this.$store.dispatch('setBookshelfTexture', 'wood2.png')
|
||||||
},
|
},
|
||||||
async back() {
|
|
||||||
var popped = await this.$store.dispatch('popRoute')
|
|
||||||
if (popped) this.$store.commit('setIsRoutingBack', true)
|
|
||||||
var backTo = popped || '/'
|
|
||||||
this.$router.push(backTo)
|
|
||||||
},
|
|
||||||
cancelSelectionMode() {
|
cancelSelectionMode() {
|
||||||
if (this.processingBatchDelete) return
|
if (this.processingBatchDelete) return
|
||||||
this.$store.commit('setSelectedAudiobooks', [])
|
this.$store.commit('setSelectedLibraryItems', [])
|
||||||
this.$eventBus.$emit('bookshelf-clear-selection')
|
this.$eventBus.$emit('bookshelf-clear-selection')
|
||||||
this.isAllSelected = false
|
this.isAllSelected = false
|
||||||
},
|
},
|
||||||
toggleBatchRead() {
|
toggleBatchRead() {
|
||||||
this.$store.commit('setProcessingBatch', true)
|
this.$store.commit('setProcessingBatch', true)
|
||||||
var newIsRead = !this.selectedIsRead
|
var newIsFinished = !this.selectedIsFinished
|
||||||
var updateProgressPayloads = this.selectedAudiobooks.map((ab) => {
|
var updateProgressPayloads = this.selectedLibraryItems.map((lid) => {
|
||||||
return {
|
return {
|
||||||
audiobookId: ab,
|
id: lid,
|
||||||
isRead: newIsRead
|
isFinished: newIsFinished
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
console.log('Progress payloads', updateProgressPayloads)
|
||||||
this.$axios
|
this.$axios
|
||||||
.patch(`/api/me/audiobook/batch/update`, updateProgressPayloads)
|
.patch(`/api/me/progress/batch/update`, updateProgressPayloads)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.$toast.success('Batch update success!')
|
this.$toast.success('Batch update success!')
|
||||||
this.$store.commit('setProcessingBatch', false)
|
this.$store.commit('setProcessingBatch', false)
|
||||||
this.$store.commit('setSelectedAudiobooks', [])
|
this.$store.commit('setSelectedLibraryItems', [])
|
||||||
this.$eventBus.$emit('bookshelf-clear-selection')
|
this.$eventBus.$emit('bookshelf-clear-selection')
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -178,20 +181,20 @@ export default {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
batchDeleteClick() {
|
batchDeleteClick() {
|
||||||
var audiobookText = this.numAudiobooksSelected > 1 ? `these ${this.numAudiobooksSelected} audiobooks` : 'this audiobook'
|
var audiobookText = this.numLibraryItemsSelected > 1 ? `these ${this.numLibraryItemsSelected} items` : 'this item'
|
||||||
var confirmMsg = `Are you sure you want to remove ${audiobookText}?\n\n*Does not delete your files, only removes the audiobooks from AudioBookshelf`
|
var confirmMsg = `Are you sure you want to remove ${audiobookText}?\n\n*Does not delete your files, only removes the items from Audiobookshelf`
|
||||||
if (confirm(confirmMsg)) {
|
if (confirm(confirmMsg)) {
|
||||||
this.processingBatchDelete = true
|
this.processingBatchDelete = true
|
||||||
this.$store.commit('setProcessingBatch', true)
|
this.$store.commit('setProcessingBatch', true)
|
||||||
this.$axios
|
this.$axios
|
||||||
.$post(`/api/books/batch/delete`, {
|
.$post(`/api/items/batch/delete`, {
|
||||||
audiobookIds: this.selectedAudiobooks
|
libraryItemIds: this.selectedLibraryItems
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.$toast.success('Batch delete success!')
|
this.$toast.success('Batch delete success!')
|
||||||
this.processingBatchDelete = false
|
this.processingBatchDelete = false
|
||||||
this.$store.commit('setProcessingBatch', false)
|
this.$store.commit('setProcessingBatch', false)
|
||||||
this.$store.commit('setSelectedAudiobooks', [])
|
this.$store.commit('setSelectedLibraryItems', [])
|
||||||
this.$eventBus.$emit('bookshelf-clear-selection')
|
this.$eventBus.$emit('bookshelf-clear-selection')
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="outer-container">
|
|
||||||
<!-- absolute positioned container -->
|
|
||||||
<div class="inner-container">
|
|
||||||
<div class="relative h-10">
|
|
||||||
<div class="table-header" id="headerdiv">
|
|
||||||
<table id="headertable" width="100%" cellpadding="0" cellspacing="0">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="header-cell min-w-12 max-w-12"></th>
|
|
||||||
<th class="header-cell min-w-6 max-w-6"></th>
|
|
||||||
<th class="header-cell min-w-64 max-w-64 px-2">Title</th>
|
|
||||||
<th class="header-cell min-w-48 max-w-48 px-2">Author</th>
|
|
||||||
<th class="header-cell min-w-48 max-w-48 px-2">Series</th>
|
|
||||||
<th class="header-cell min-w-24 max-w-24 px-2">Year</th>
|
|
||||||
<th class="header-cell min-w-80 max-w-80 px-2">Description</th>
|
|
||||||
<th class="header-cell min-w-48 max-w-48 px-2">Narrator</th>
|
|
||||||
<th class="header-cell min-w-48 max-w-48 px-2">Genres</th>
|
|
||||||
<th class="header-cell min-w-48 max-w-48 px-2">Tags</th>
|
|
||||||
<th class="header-cell min-w-24 max-w-24 px-2"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="absolute top-0 left-0 w-full h-full pointer-events-none" :class="isScrollable ? 'header-shadow' : ''" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div ref="tableBody" class="table-body" onscroll="document.getElementById('headerdiv').scrollLeft = this.scrollLeft;" @scroll="tableScrolled">
|
|
||||||
<table id="bodytable" width="100%" cellpadding="0" cellspacing="0">
|
|
||||||
<tbody>
|
|
||||||
<template v-for="book in books">
|
|
||||||
<app-book-list-row :key="book.id" :book="book" @edit="editBook" />
|
|
||||||
</template>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
books: {
|
|
||||||
type: Array,
|
|
||||||
default: () => []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
isScrollable: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {},
|
|
||||||
methods: {
|
|
||||||
checkIsScrolled() {
|
|
||||||
if (!this.$refs.tableBody) return
|
|
||||||
this.isScrollable = this.$refs.tableBody.scrollTop > 0
|
|
||||||
},
|
|
||||||
tableScrolled() {
|
|
||||||
this.checkIsScrolled()
|
|
||||||
},
|
|
||||||
editBook(book) {
|
|
||||||
var bookIds = this.books.map((e) => e.id)
|
|
||||||
this.$store.commit('setBookshelfBookIds', bookIds)
|
|
||||||
this.$store.commit('showEditModal', book)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
this.checkIsScrolled()
|
|
||||||
},
|
|
||||||
beforeDestroy() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.outer-container {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
overflow: visible;
|
|
||||||
height: calc(100% - 50px);
|
|
||||||
width: calc(100% - 10px);
|
|
||||||
margin: 10px;
|
|
||||||
}
|
|
||||||
.inner-container {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.table-header {
|
|
||||||
float: left;
|
|
||||||
overflow: hidden;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.header-shadow {
|
|
||||||
box-shadow: 3px 8px 3px #11111155;
|
|
||||||
}
|
|
||||||
.table-body {
|
|
||||||
float: left;
|
|
||||||
height: 100%;
|
|
||||||
width: inherit;
|
|
||||||
overflow-y: scroll;
|
|
||||||
padding-right: 0px;
|
|
||||||
}
|
|
||||||
.header-cell {
|
|
||||||
background-color: #22222288;
|
|
||||||
padding: 0px 4px;
|
|
||||||
text-align: left;
|
|
||||||
height: 40px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: semi-bold;
|
|
||||||
}
|
|
||||||
.body-cell {
|
|
||||||
text-align: left;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
.book-row {
|
|
||||||
background-color: #22222288;
|
|
||||||
}
|
|
||||||
.book-row:nth-child(odd) {
|
|
||||||
background-color: #333;
|
|
||||||
}
|
|
||||||
.book-row.selected {
|
|
||||||
background-color: rgba(0, 255, 0, 0.05);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
<template>
|
|
||||||
<tr class="book-row" :class="selected ? 'selected' : ''">
|
|
||||||
<td class="body-cell min-w-12 max-w-12">
|
|
||||||
<div class="flex justify-center">
|
|
||||||
<div class="bg-white border-2 rounded border-gray-400 flex flex-shrink-0 justify-center items-center focus-within:border-blue-500 w-4 h-4" @click="selectBtnClick">
|
|
||||||
<svg v-if="selected" class="fill-current text-green-500 pointer-events-none w-2.5 h-2.5" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="body-cell min-w-6 max-w-6">
|
|
||||||
<covers-hover-book-cover :audiobook="book" />
|
|
||||||
</td>
|
|
||||||
<td class="body-cell min-w-64 max-w-64 px-2">
|
|
||||||
<nuxt-link :to="`/audiobook/${book.id}`" class="hover:underline">
|
|
||||||
<p class="truncate">
|
|
||||||
{{ book.book.title }}<span v-if="book.book.subtitle">: {{ book.book.subtitle }}</span>
|
|
||||||
</p>
|
|
||||||
</nuxt-link>
|
|
||||||
</td>
|
|
||||||
<td class="body-cell min-w-48 max-w-48 px-2">
|
|
||||||
<p class="truncate">{{ book.book.authorFL }}</p>
|
|
||||||
</td>
|
|
||||||
<td class="body-cell min-w-48 max-w-48 px-2">
|
|
||||||
<p class="truncate">{{ seriesText }}</p>
|
|
||||||
</td>
|
|
||||||
<td class="body-cell min-w-24 max-w-24 px-2">
|
|
||||||
<p class="truncate">{{ book.book.publishYear }}</p>
|
|
||||||
</td>
|
|
||||||
<td class="body-cell min-w-80 max-w-80 px-2">
|
|
||||||
<p class="truncate">{{ book.book.description }}</p>
|
|
||||||
</td>
|
|
||||||
<td class="body-cell min-w-48 max-w-48 px-2">
|
|
||||||
<p class="truncate">{{ book.book.narrator }}</p>
|
|
||||||
</td>
|
|
||||||
<td class="body-cell min-w-48 max-w-48 px-2">
|
|
||||||
<p class="truncate">{{ genresText }}</p>
|
|
||||||
</td>
|
|
||||||
<td class="body-cell min-w-48 max-w-48 px-2">
|
|
||||||
<p class="truncate">{{ tagsText }}</p>
|
|
||||||
</td>
|
|
||||||
<td class="body-cell min-w-24 max-w-24 px-2">
|
|
||||||
<div class="flex">
|
|
||||||
<span v-if="userCanUpdate" class="material-icons cursor-pointer text-white text-opacity-60 hover:text-opacity-100 text-xl" @click="editClick">edit</span>
|
|
||||||
<span v-if="showPlayButton" class="material-icons cursor-pointer text-white text-opacity-60 hover:text-opacity-100 text-2xl mx-1" @click="startStream">play_arrow</span>
|
|
||||||
<span v-if="showReadButton" class="material-icons cursor-pointer text-white text-opacity-60 hover:text-opacity-100 text-xl" @click="openEbook">auto_stories</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
book: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {}
|
|
||||||
},
|
|
||||||
userAudiobook: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
isProcessingReadUpdate: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
showExperimentalFeatures() {
|
|
||||||
return this.$store.state.showExperimentalFeatures
|
|
||||||
},
|
|
||||||
audiobookId() {
|
|
||||||
return this.book.id
|
|
||||||
},
|
|
||||||
selected: {
|
|
||||||
get() {
|
|
||||||
return this.$store.getters['getIsAudiobookSelected'](this.audiobookId)
|
|
||||||
},
|
|
||||||
set(val) {
|
|
||||||
if (this.processingBatch) return
|
|
||||||
this.$store.commit('setAudiobookSelected', { audiobookId: this.audiobookId, selected: val })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
processingBatch() {
|
|
||||||
return this.$store.state.processingBatch
|
|
||||||
},
|
|
||||||
bookObj() {
|
|
||||||
return this.book.book || {}
|
|
||||||
},
|
|
||||||
series() {
|
|
||||||
return this.bookObj.series || null
|
|
||||||
},
|
|
||||||
volumeNumber() {
|
|
||||||
return this.bookObj.volumeNumber || null
|
|
||||||
},
|
|
||||||
seriesText() {
|
|
||||||
if (!this.series) return ''
|
|
||||||
if (!this.volumeNumber) return this.series
|
|
||||||
return `${this.series} #${this.volumeNumber}`
|
|
||||||
},
|
|
||||||
genresText() {
|
|
||||||
if (!this.bookObj.genres) return ''
|
|
||||||
return this.bookObj.genres.join(', ')
|
|
||||||
},
|
|
||||||
tagsText() {
|
|
||||||
return (this.book.tags || []).join(', ')
|
|
||||||
},
|
|
||||||
isMissing() {
|
|
||||||
return this.book.isMissing
|
|
||||||
},
|
|
||||||
isInvalid() {
|
|
||||||
return this.book.isInvalid
|
|
||||||
},
|
|
||||||
numEbooks() {
|
|
||||||
return this.book.numEbooks
|
|
||||||
},
|
|
||||||
numTracks() {
|
|
||||||
return this.book.numTracks
|
|
||||||
},
|
|
||||||
isStreaming() {
|
|
||||||
return this.$store.getters['getAudiobookIdStreaming'] === this.audiobookId
|
|
||||||
},
|
|
||||||
showReadButton() {
|
|
||||||
return this.showExperimentalFeatures && this.numEbooks
|
|
||||||
},
|
|
||||||
showPlayButton() {
|
|
||||||
return !this.isMissing && !this.isInvalid && this.numTracks && !this.isStreaming
|
|
||||||
},
|
|
||||||
userIsRead() {
|
|
||||||
return this.userAudiobook ? !!this.userAudiobook.isRead : false
|
|
||||||
},
|
|
||||||
userCanUpdate() {
|
|
||||||
return this.$store.getters['user/getUserCanUpdate']
|
|
||||||
},
|
|
||||||
userCanDelete() {
|
|
||||||
return this.$store.getters['user/getUserCanDelete']
|
|
||||||
},
|
|
||||||
userCanDownload() {
|
|
||||||
return this.$store.getters['user/getUserCanDownload']
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
selectBtnClick() {
|
|
||||||
if (this.processingBatch) return
|
|
||||||
this.$store.commit('toggleAudiobookSelected', this.audiobookId)
|
|
||||||
},
|
|
||||||
openEbook() {
|
|
||||||
this.$store.commit('showEReader', this.book)
|
|
||||||
},
|
|
||||||
downloadClick() {
|
|
||||||
this.$store.commit('showEditModalOnTab', { audiobook: this.book, tab: 'download' })
|
|
||||||
},
|
|
||||||
toggleRead() {
|
|
||||||
var updatePayload = {
|
|
||||||
isRead: !this.userIsRead
|
|
||||||
}
|
|
||||||
this.isProcessingReadUpdate = true
|
|
||||||
this.$axios
|
|
||||||
.$patch(`/api/me/audiobook/${this.audiobookId}`, updatePayload)
|
|
||||||
.then(() => {
|
|
||||||
this.isProcessingReadUpdate = false
|
|
||||||
this.$toast.success(`"${this.title}" Marked as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed', error)
|
|
||||||
this.isProcessingReadUpdate = false
|
|
||||||
this.$toast.error(`Failed to mark as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
startStream() {
|
|
||||||
this.$eventBus.$emit('play-audiobook', this.book.id)
|
|
||||||
},
|
|
||||||
editClick() {
|
|
||||||
this.$emit('edit', this.book)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,20 +1,41 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="bookshelf" ref="wrapper" class="w-full h-full overflow-y-scroll relative">
|
<div id="bookshelf" ref="wrapper" class="w-full max-w-full h-full overflow-y-scroll relative">
|
||||||
<!-- Cover size widget -->
|
<!-- Cover size widget -->
|
||||||
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
|
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
|
||||||
<!-- Experimental Bookshelf Texture -->
|
<!-- Experimental Bookshelf Texture -->
|
||||||
<div v-show="showExperimentalFeatures" class="fixed bottom-4 right-28 z-40">
|
<div v-show="showExperimentalFeatures && !isAlternativeBookshelfView" class="fixed bottom-4 right-28 z-40">
|
||||||
<div class="rounded-full py-1 bg-primary hover:bg-bg cursor-pointer px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent @click="showBookshelfTextureModal"><p class="text-sm py-0.5">Texture</p></div>
|
<div class="rounded-full py-1 bg-primary hover:bg-bg cursor-pointer px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent @click="showBookshelfTextureModal"><p class="text-sm py-0.5">Texture</p></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="loaded && !shelves.length && isRootUser" class="w-full flex flex-col items-center justify-center py-12">
|
<div v-if="loaded && !shelves.length && !search" class="w-full flex flex-col items-center justify-center py-12">
|
||||||
<p class="text-center text-2xl font-book mb-4 py-4">Audiobookshelf is empty!</p>
|
<p class="text-center text-2xl font-book mb-4 py-4">{{ libraryName }} Library is empty!</p>
|
||||||
<div class="flex">
|
<div v-if="userIsAdminOrUp" class="flex">
|
||||||
<ui-btn to="/config" color="primary" class="w-52 mr-2">Configure Scanner</ui-btn>
|
<ui-btn to="/config" color="primary" class="w-52 mr-2">Configure Scanner</ui-btn>
|
||||||
<ui-btn color="success" class="w-52" @click="scan">Scan Audiobooks</ui-btn>
|
<ui-btn color="success" class="w-52" @click="scan">Scan Library</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="w-full flex flex-col items-center">
|
<div v-else-if="loaded && !shelves.length && search" class="w-full h-40 flex items-center justify-center">
|
||||||
|
<p class="text-center text-xl font-book py-4">No results for query</p>
|
||||||
|
</div>
|
||||||
|
<!-- Alternate plain view -->
|
||||||
|
<div v-else-if="isAlternativeBookshelfView" class="w-full mb-24">
|
||||||
|
<template v-for="(shelf, index) in shelves">
|
||||||
|
<widgets-item-slider v-if="shelf.type === 'book' || shelf.type === 'podcast'" :key="index + '.'" :items="shelf.entities" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
|
||||||
|
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ shelf.label }}</p>
|
||||||
|
</widgets-item-slider>
|
||||||
|
<widgets-episode-slider v-else-if="shelf.type === 'episode'" :key="index + '.'" :items="shelf.entities" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
|
||||||
|
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ shelf.label }}</p>
|
||||||
|
</widgets-episode-slider>
|
||||||
|
<widgets-series-slider v-else-if="shelf.type === 'series'" :key="index + '.'" :items="shelf.entities" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
|
||||||
|
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ shelf.label }}</p>
|
||||||
|
</widgets-series-slider>
|
||||||
|
<widgets-authors-slider v-else-if="shelf.type === 'authors'" :key="index + '.'" :items="shelf.entities" :height="192 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
|
||||||
|
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ shelf.label }}</p>
|
||||||
|
</widgets-authors-slider>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<!-- Regular bookshelf view -->
|
||||||
|
<div v-else class="w-full">
|
||||||
<template v-for="(shelf, index) in shelves">
|
<template v-for="(shelf, index) in shelves">
|
||||||
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
</template>
|
</template>
|
||||||
@@ -41,8 +62,8 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
isRootUser() {
|
userIsAdminOrUp() {
|
||||||
return this.$store.getters['user/getIsRoot']
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
},
|
},
|
||||||
showExperimentalFeatures() {
|
showExperimentalFeatures() {
|
||||||
return this.$store.state.showExperimentalFeatures
|
return this.$store.state.showExperimentalFeatures
|
||||||
@@ -50,6 +71,15 @@ export default {
|
|||||||
currentLibraryId() {
|
currentLibraryId() {
|
||||||
return this.$store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
},
|
},
|
||||||
|
libraryName() {
|
||||||
|
return this.$store.getters['libraries/getCurrentLibraryName']
|
||||||
|
},
|
||||||
|
bookshelfView() {
|
||||||
|
return this.$store.getters['getServerSetting']('bookshelfView')
|
||||||
|
},
|
||||||
|
isAlternativeBookshelfView() {
|
||||||
|
return this.bookshelfView === this.$constants.BookshelfView.TITLES
|
||||||
|
},
|
||||||
bookCoverWidth() {
|
bookCoverWidth() {
|
||||||
var coverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
|
var coverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
|
||||||
if (this.isCoverSquareAspectRatio) return coverSize * 1.6
|
if (this.isCoverSquareAspectRatio) return coverSize * 1.6
|
||||||
@@ -85,7 +115,7 @@ export default {
|
|||||||
},
|
},
|
||||||
async fetchCategories() {
|
async fetchCategories() {
|
||||||
var categories = await this.$axios
|
var categories = await this.$axios
|
||||||
.$get(`/api/libraries/${this.currentLibraryId}/categories?minified=1`)
|
.$get(`/api/libraries/${this.currentLibraryId}/personalized`)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
return data
|
return data
|
||||||
})
|
})
|
||||||
@@ -97,53 +127,60 @@ export default {
|
|||||||
},
|
},
|
||||||
async setShelvesFromSearch() {
|
async setShelvesFromSearch() {
|
||||||
var shelves = []
|
var shelves = []
|
||||||
if (this.results.audiobooks) {
|
if (this.results.books && this.results.books.length) {
|
||||||
shelves.push({
|
shelves.push({
|
||||||
id: 'audiobooks',
|
id: 'books',
|
||||||
label: 'Books',
|
label: 'Books',
|
||||||
type: 'books',
|
type: 'book',
|
||||||
entities: this.results.audiobooks.map((ab) => ab.audiobook)
|
entities: this.results.books.map((res) => res.libraryItem)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.results.series) {
|
if (this.results.podcasts && this.results.podcasts.length) {
|
||||||
|
shelves.push({
|
||||||
|
id: 'podcasts',
|
||||||
|
label: 'Podcasts',
|
||||||
|
type: 'podcast',
|
||||||
|
entities: this.results.podcasts.map((res) => res.libraryItem)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.results.series && this.results.series.length) {
|
||||||
shelves.push({
|
shelves.push({
|
||||||
id: 'series',
|
id: 'series',
|
||||||
label: 'Series',
|
label: 'Series',
|
||||||
type: 'series',
|
type: 'series',
|
||||||
entities: this.results.series.map((seriesObj) => {
|
entities: this.results.series.map((seriesObj) => {
|
||||||
return {
|
return {
|
||||||
name: seriesObj.series,
|
...seriesObj.series,
|
||||||
books: seriesObj.audiobooks,
|
books: seriesObj.books,
|
||||||
type: 'series'
|
type: 'series'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (this.results.tags) {
|
if (this.results.tags && this.results.tags.length) {
|
||||||
shelves.push({
|
shelves.push({
|
||||||
id: 'tags',
|
id: 'tags',
|
||||||
label: 'Tags',
|
label: 'Tags',
|
||||||
type: 'tags',
|
type: 'tags',
|
||||||
entities: this.results.tags.map((tagObj) => {
|
entities: this.results.tags.map((tagObj) => {
|
||||||
return {
|
return {
|
||||||
name: tagObj.tag,
|
name: tagObj.name,
|
||||||
books: tagObj.audiobooks,
|
books: tagObj.books || [],
|
||||||
type: 'tags'
|
type: 'tags'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (this.results.authors) {
|
if (this.results.authors && this.results.authors.length) {
|
||||||
shelves.push({
|
shelves.push({
|
||||||
id: 'authors',
|
id: 'authors',
|
||||||
label: 'Authors',
|
label: 'Authors',
|
||||||
type: 'authors',
|
type: 'authors',
|
||||||
entities: this.results.authors.map((a) => {
|
entities: this.results.authors.map((a) => {
|
||||||
return {
|
return {
|
||||||
id: a.author,
|
...a,
|
||||||
name: a.author,
|
|
||||||
numBooks: a.numBooks,
|
|
||||||
type: 'author'
|
type: 'author'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -153,74 +190,106 @@ export default {
|
|||||||
},
|
},
|
||||||
settingsUpdated(settings) {},
|
settingsUpdated(settings) {},
|
||||||
scan() {
|
scan() {
|
||||||
this.$root.socket.emit('scan', 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')
|
||||||
|
})
|
||||||
},
|
},
|
||||||
audiobookAdded(audiobook) {
|
libraryItemAdded(libraryItem) {
|
||||||
console.log('Audiobook added', audiobook)
|
console.log('libraryItem added', libraryItem)
|
||||||
// TODO: Check if audiobook would be on this shelf
|
// TODO: Check if libraryItem would be on this shelf
|
||||||
if (!this.search) {
|
if (!this.search) {
|
||||||
this.fetchCategories()
|
this.fetchCategories()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
audiobookUpdated(audiobook) {
|
libraryItemUpdated(libraryItem) {
|
||||||
console.log('Audiobook updated', audiobook)
|
console.log('libraryItem updated', libraryItem)
|
||||||
this.shelves.forEach((shelf) => {
|
this.shelves.forEach((shelf) => {
|
||||||
if (shelf.type === 'books') {
|
if (shelf.type == 'book' || shelf.type == 'podcast') {
|
||||||
shelf.entities = shelf.entities.map((ent) => {
|
shelf.entities = shelf.entities.map((ent) => {
|
||||||
if (ent.id === audiobook.id) {
|
if (ent.id === libraryItem.id) {
|
||||||
return audiobook
|
return libraryItem
|
||||||
}
|
}
|
||||||
return ent
|
return ent
|
||||||
})
|
})
|
||||||
} else if (shelf.type === 'series') {
|
} else if (shelf.type === 'series') {
|
||||||
shelf.entities.forEach((ent) => {
|
shelf.entities.forEach((ent) => {
|
||||||
ent.books = ent.books.map((book) => {
|
ent.books = ent.books.map((book) => {
|
||||||
if (book.id === audiobook.id) return audiobook
|
if (book.id === libraryItem.id) return libraryItem
|
||||||
return book
|
return book
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
removeBookFromShelf(audiobook) {
|
removeBookFromShelf(libraryItem) {
|
||||||
this.shelves.forEach((shelf) => {
|
this.shelves.forEach((shelf) => {
|
||||||
if (shelf.type === 'books') {
|
if (shelf.type == 'book' || shelf.type == 'podcast') {
|
||||||
shelf.entities = shelf.entities.filter((ent) => {
|
shelf.entities = shelf.entities.filter((ent) => {
|
||||||
return ent.id !== audiobook.id
|
return ent.id !== libraryItem.id
|
||||||
})
|
})
|
||||||
} else if (shelf.type === 'series') {
|
} else if (shelf.type === 'series') {
|
||||||
shelf.entities.forEach((ent) => {
|
shelf.entities.forEach((ent) => {
|
||||||
ent.books = ent.books.filter((book) => {
|
ent.books = ent.books.filter((book) => {
|
||||||
return book.id !== audiobook.id
|
return book.id !== libraryItem.id
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
audiobookRemoved(audiobook) {
|
libraryItemRemoved(libraryItem) {
|
||||||
this.removeBookFromShelf(audiobook)
|
this.removeBookFromShelf(libraryItem)
|
||||||
},
|
},
|
||||||
audiobooksAdded(audiobooks) {
|
libraryItemsAdded(libraryItems) {
|
||||||
console.log('audiobooks added', audiobooks)
|
console.log('libraryItems added', libraryItems)
|
||||||
// TODO: Check if audiobook would be on this shelf
|
// TODO: Check if audiobook would be on this shelf
|
||||||
if (!this.search) {
|
if (!this.search) {
|
||||||
this.fetchCategories()
|
this.fetchCategories()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
audiobooksUpdated(audiobooks) {
|
libraryItemsUpdated(items) {
|
||||||
audiobooks.forEach((ab) => {
|
items.forEach((li) => {
|
||||||
this.audiobookUpdated(ab)
|
this.libraryItemUpdated(li)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
authorUpdated(author) {
|
||||||
|
this.shelves.forEach((shelf) => {
|
||||||
|
if (shelf.type == 'authors') {
|
||||||
|
shelf.entities = shelf.entities.map((ent) => {
|
||||||
|
if (ent.id === author.id) {
|
||||||
|
return {
|
||||||
|
...ent,
|
||||||
|
...author
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ent
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
authorRemoved(author) {
|
||||||
|
this.shelves.forEach((shelf) => {
|
||||||
|
if (shelf.type == 'authors') {
|
||||||
|
shelf.entities = shelf.entities.filter((ent) => ent.id != author.id)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
initListeners() {
|
initListeners() {
|
||||||
this.$store.commit('user/addSettingsListener', { id: 'bookshelf', meth: this.settingsUpdated })
|
this.$store.commit('user/addSettingsListener', { id: 'bookshelf', meth: this.settingsUpdated })
|
||||||
|
|
||||||
if (this.$root.socket) {
|
if (this.$root.socket) {
|
||||||
this.$root.socket.on('audiobook_updated', this.audiobookUpdated)
|
this.$root.socket.on('author_updated', this.authorUpdated)
|
||||||
this.$root.socket.on('audiobook_added', this.audiobookAdded)
|
this.$root.socket.on('author_removed', this.authorRemoved)
|
||||||
this.$root.socket.on('audiobook_removed', this.audiobookRemoved)
|
this.$root.socket.on('item_updated', this.libraryItemUpdated)
|
||||||
this.$root.socket.on('audiobooks_updated', this.audiobooksUpdated)
|
this.$root.socket.on('item_added', this.libraryItemAdded)
|
||||||
this.$root.socket.on('audiobooks_added', this.audiobooksAdded)
|
this.$root.socket.on('item_removed', this.libraryItemRemoved)
|
||||||
|
this.$root.socket.on('items_updated', this.libraryItemsUpdated)
|
||||||
|
this.$root.socket.on('items_added', this.libraryItemsAdded)
|
||||||
} else {
|
} else {
|
||||||
console.error('Error socket not initialized')
|
console.error('Error socket not initialized')
|
||||||
}
|
}
|
||||||
@@ -229,11 +298,13 @@ export default {
|
|||||||
this.$store.commit('user/removeSettingsListener', 'bookshelf')
|
this.$store.commit('user/removeSettingsListener', 'bookshelf')
|
||||||
|
|
||||||
if (this.$root.socket) {
|
if (this.$root.socket) {
|
||||||
this.$root.socket.off('audiobook_updated', this.audiobookUpdated)
|
this.$root.socket.off('author_updated', this.authorUpdated)
|
||||||
this.$root.socket.off('audiobook_added', this.audiobookAdded)
|
this.$root.socket.off('author_removed', this.authorRemoved)
|
||||||
this.$root.socket.off('audiobook_removed', this.audiobookRemoved)
|
this.$root.socket.off('item_updated', this.libraryItemUpdated)
|
||||||
this.$root.socket.off('audiobooks_updated', this.audiobooksUpdated)
|
this.$root.socket.off('item_added', this.libraryItemAdded)
|
||||||
this.$root.socket.off('audiobooks_added', this.audiobooksAdded)
|
this.$root.socket.off('item_removed', this.libraryItemRemoved)
|
||||||
|
this.$root.socket.off('items_updated', this.libraryItemsUpdated)
|
||||||
|
this.$root.socket.off('items_added', this.libraryItemsAdded)
|
||||||
} else {
|
} else {
|
||||||
console.error('Error socket not initialized')
|
console.error('Error socket not initialized')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div ref="shelf" class="w-full max-w-full categorizedBookshelfRow relative overflow-x-scroll overflow-y-hidden z-10" :style="{ paddingLeft: paddingLeft * sizeMultiplier + 'rem', height: shelfHeight + 'px' }" @scroll="scrolled">
|
<div ref="shelf" class="w-full max-w-full bookshelf-row categorizedBookshelfRow relative overflow-x-scroll overflow-y-hidden z-10" :style="{ paddingLeft: paddingLeft * sizeMultiplier + 'rem', height: shelfHeight + 'px' }" @scroll="scrolled">
|
||||||
<div class="w-full h-full pt-6">
|
<div class="w-full h-full pt-6">
|
||||||
<div v-if="shelf.type === 'books'" class="flex items-center">
|
<div v-if="shelf.type === 'book' || shelf.type === 'podcast'" class="flex items-center">
|
||||||
<template v-for="(entity, index) in shelf.entities">
|
<template v-for="(entity, index) in shelf.entities">
|
||||||
<cards-lazy-book-card :key="entity.id" :ref="`shelf-book-${entity.id}`" :index="index" :width="bookCoverWidth" :height="bookCoverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :book-mount="entity" class="relative mx-2" @hook:updated="updatedBookCard" @select="selectBook" @edit="editBook" />
|
<cards-lazy-book-card :key="entity.id" :ref="`shelf-book-${entity.id}`" :index="index" :width="bookCoverWidth" :height="bookCoverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :book-mount="entity" class="relative mx-2" @hook:updated="updatedBookCard" @select="selectItem" @edit="editItem" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div v-if="shelf.type === 'episode'" class="flex items-center">
|
||||||
|
<template v-for="(entity, index) in shelf.entities">
|
||||||
|
<cards-lazy-book-card :key="entity.recentEpisode.id" :ref="`shelf-episode-${entity.recentEpisode.id}`" :index="index" :width="bookCoverWidth" :height="bookCoverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :book-mount="entity" class="relative mx-2" @hook:updated="updatedBookCard" @select="selectItem" @editPodcast="editItem" @edit="editEpisode" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="shelf.type === 'series'" class="flex items-center">
|
<div v-if="shelf.type === 'series'" class="flex items-center">
|
||||||
@@ -12,18 +17,9 @@
|
|||||||
<cards-lazy-series-card :key="entity.name" :series-mount="entity" :height="bookCoverHeight" :width="bookCoverWidth * 2" :book-cover-aspect-ratio="bookCoverAspectRatio" class="relative mx-2" @hook:updated="updatedBookCard" />
|
<cards-lazy-series-card :key="entity.name" :series-mount="entity" :height="bookCoverHeight" :width="bookCoverWidth * 2" :book-cover-aspect-ratio="bookCoverAspectRatio" class="relative mx-2" @hook:updated="updatedBookCard" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="shelf.type === 'tags'" class="flex items-center">
|
|
||||||
<template v-for="entity in shelf.entities">
|
|
||||||
<nuxt-link :key="entity.name" :to="`/library/${currentLibraryId}/bookshelf?filter=tags.${$encode(entity.name)}`">
|
|
||||||
<cards-group-card is-categorized :width="bookCoverWidth" :group="entity" :book-cover-aspect-ratio="bookCoverAspectRatio" @hook:updated="updatedBookCard" />
|
|
||||||
</nuxt-link>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
<div v-if="shelf.type === 'authors'" class="flex items-center">
|
<div v-if="shelf.type === 'authors'" class="flex items-center">
|
||||||
<template v-for="entity in shelf.entities">
|
<template v-for="entity in shelf.entities">
|
||||||
<nuxt-link :key="entity.id" :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(entity.name)}`">
|
<cards-author-card :key="entity.id" :width="bookCoverWidth / 1.25" :height="bookCoverWidth" :author="entity" :size-multiplier="sizeMultiplier" @hook:updated="updatedBookCard" class="pb-6 mx-2" @edit="editAuthor" />
|
||||||
<cards-author-card :width="bookCoverWidth" :height="bookCoverWidth" :author="entity" :size-multiplier="sizeMultiplier" @hook:updated="updatedBookCard" class="pb-6 mx-2" />
|
|
||||||
</nuxt-link>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -67,11 +63,6 @@ export default {
|
|||||||
updateTimer: null
|
updateTimer: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
|
||||||
isSelectionMode(newVal) {
|
|
||||||
this.updateSelectionMode(newVal)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
computed: {
|
||||||
bookCoverHeight() {
|
bookCoverHeight() {
|
||||||
return this.bookCoverWidth * this.bookCoverAspectRatio
|
return this.bookCoverWidth * this.bookCoverAspectRatio
|
||||||
@@ -79,9 +70,6 @@ export default {
|
|||||||
shelfHeight() {
|
shelfHeight() {
|
||||||
return this.bookCoverHeight + 48
|
return this.bookCoverHeight + 48
|
||||||
},
|
},
|
||||||
userAudiobooks() {
|
|
||||||
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
|
|
||||||
},
|
|
||||||
paddingLeft() {
|
paddingLeft() {
|
||||||
if (window.innerWidth < 768) return 1
|
if (window.innerWidth < 768) return 1
|
||||||
return 2.5
|
return 2.5
|
||||||
@@ -90,29 +78,54 @@ export default {
|
|||||||
return this.$store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
},
|
},
|
||||||
isSelectionMode() {
|
isSelectionMode() {
|
||||||
return this.$store.getters['getNumAudiobooksSelected'] > 0
|
return this.$store.getters['getNumLibraryItemsSelected'] > 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
editBook(audiobook) {
|
clearSelectedEntities() {
|
||||||
var bookIds = this.shelf.entities.map((e) => e.id)
|
this.updateSelectionMode(false)
|
||||||
this.$store.commit('setBookshelfBookIds', bookIds)
|
},
|
||||||
this.$store.commit('showEditModal', audiobook)
|
editAuthor(author) {
|
||||||
|
this.$store.commit('globals/showEditAuthorModal', author)
|
||||||
|
},
|
||||||
|
editItem(libraryItem) {
|
||||||
|
var itemIds = this.shelf.entities.map((e) => e.id)
|
||||||
|
this.$store.commit('setBookshelfBookIds', itemIds)
|
||||||
|
this.$store.commit('showEditModal', libraryItem)
|
||||||
|
},
|
||||||
|
editEpisode({ libraryItem, episode }) {
|
||||||
|
this.$store.commit('setSelectedLibraryItem', libraryItem)
|
||||||
|
this.$store.commit('globals/setSelectedEpisode', episode)
|
||||||
|
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
|
||||||
},
|
},
|
||||||
updateSelectionMode(val) {
|
updateSelectionMode(val) {
|
||||||
var selectedAudiobooks = this.$store.state.selectedAudiobooks
|
var selectedLibraryItems = this.$store.state.selectedLibraryItems
|
||||||
if (this.shelf.type === 'books') {
|
if (this.shelf.type === 'book' || this.shelf.type === 'podcast') {
|
||||||
this.shelf.entities.forEach((ent) => {
|
this.shelf.entities.forEach((ent) => {
|
||||||
var component = this.$refs[`shelf-book-${ent.id}`]
|
var component = this.$refs[`shelf-book-${ent.id}`]
|
||||||
if (!component || !component.length) return
|
if (!component || !component.length) return
|
||||||
component = component[0]
|
component = component[0]
|
||||||
component.setSelectionMode(val)
|
component.setSelectionMode(val)
|
||||||
component.selected = selectedAudiobooks.includes(ent.id)
|
component.selected = selectedLibraryItems.includes(ent.id)
|
||||||
|
})
|
||||||
|
} else if (this.shelf.type === 'episode') {
|
||||||
|
this.shelf.entities.forEach((ent) => {
|
||||||
|
var component = this.$refs[`shelf-episode-${ent.recentEpisode.id}`]
|
||||||
|
if (!component || !component.length) return
|
||||||
|
component = component[0]
|
||||||
|
component.setSelectionMode(val)
|
||||||
|
component.selected = selectedLibraryItems.includes(ent.id)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
selectBook(audiobook) {
|
selectItem(libraryItem) {
|
||||||
this.$store.commit('toggleAudiobookSelected', audiobook.id)
|
this.$store.commit('toggleLibraryItemSelected', libraryItem.id)
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.$eventBus.$emit('item-selected', libraryItem)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
itemSelectedEvt() {
|
||||||
|
this.updateSelectionMode(this.isSelectionMode)
|
||||||
},
|
},
|
||||||
scrolled() {
|
scrolled() {
|
||||||
clearTimeout(this.scrollTimer)
|
clearTimeout(this.scrollTimer)
|
||||||
@@ -156,6 +169,14 @@ export default {
|
|||||||
this.canScrollLeft = false
|
this.canScrollLeft = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.$eventBus.$on('bookshelf-clear-selection', this.clearSelectedEntities)
|
||||||
|
this.$eventBus.$on('item-selected', this.itemSelectedEvt)
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
this.$eventBus.$off('bookshelf-clear-selection', this.clearSelectedEntities)
|
||||||
|
this.$eventBus.$off('item-selected', this.itemSelectedEvt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -163,25 +184,13 @@ export default {
|
|||||||
<style>
|
<style>
|
||||||
.categorizedBookshelfRow {
|
.categorizedBookshelfRow {
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
width: calc(100vw - 80px);
|
|
||||||
|
|
||||||
/* background-color: rgb(214, 116, 36); */
|
|
||||||
background-image: var(--bookshelf-texture-img);
|
background-image: var(--bookshelf-texture-img);
|
||||||
/* background-position: center; */
|
|
||||||
/* background-size: contain; */
|
|
||||||
background-repeat: repeat-x;
|
background-repeat: repeat-x;
|
||||||
}
|
}
|
||||||
@media (max-width: 768px) {
|
|
||||||
.categorizedBookshelfRow {
|
|
||||||
width: 100vw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.bookshelfDividerCategorized {
|
.bookshelfDividerCategorized {
|
||||||
background: rgb(149, 119, 90);
|
background: rgb(149, 119, 90);
|
||||||
/* background: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%); */
|
|
||||||
background: linear-gradient(180deg, rgb(122, 94, 68) 0%, rgb(92, 62, 31) 17%, rgb(82, 54, 26) 88%, rgba(71, 48, 25, 1) 100%);
|
background: linear-gradient(180deg, rgb(122, 94, 68) 0%, rgb(92, 62, 31) 17%, rgb(82, 54, 26) 88%, rgba(71, 48, 25, 1) 100%);
|
||||||
/* background: linear-gradient(180deg, rgb(114, 85, 59) 0%, rgb(73, 48, 22) 17%, rgb(71, 43, 15) 88%, rgb(61, 41, 20) 100%); */
|
|
||||||
box-shadow: 2px 14px 8px #111111aa;
|
box-shadow: 2px 14px 8px #111111aa;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,22 +12,34 @@
|
|||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</div>
|
</div>
|
||||||
<div id="toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-30 flex items-center justify-end md:justify-start px-2 md:px-8">
|
<div id="toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-30 flex items-center justify-end md:justify-start px-2 md:px-8">
|
||||||
<template v-if="page !== 'search' && !isHome">
|
<template v-if="page !== 'search' && page !== 'podcast-search' && !isHome">
|
||||||
<p v-if="!selectedSeries" class="font-book hidden md:block">{{ numShowing }} {{ entityName }}</p>
|
<p v-if="!selectedSeries" class="font-book hidden md:block">{{ numShowing }} {{ entityName }}</p>
|
||||||
<div v-else class="items-center hidden md:flex">
|
<div v-else class="items-center hidden md:flex w-full">
|
||||||
<div @click="seriesBackArrow" class="rounded-full h-9 w-9 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
|
<div @click="seriesBackArrow" class="rounded-full h-9 w-9 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
|
||||||
<span class="material-icons text-2xl text-white">west</span>
|
<span class="material-icons text-2xl text-white">west</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="pl-4 font-book text-lg">
|
<p class="pl-4 font-book text-lg">
|
||||||
{{ selectedSeries }}
|
{{ seriesName }}
|
||||||
</p>
|
</p>
|
||||||
<div class="w-6 h-6 rounded-full bg-black bg-opacity-30 flex items-center justify-center ml-3">
|
<div class="w-6 h-6 rounded-full bg-black bg-opacity-30 flex items-center justify-center ml-3">
|
||||||
<span class="font-mono">{{ numShowing }}</span>
|
<span class="font-mono">{{ numShowing }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<ui-btn color="primary" small :loading="processingSeries" class="flex items-center" @click="markSeriesFinished">
|
||||||
|
<div class="h-5 w-5">
|
||||||
|
<svg v-if="isSeriesFinished" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="rgb(63, 181, 68)">
|
||||||
|
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z" />
|
||||||
|
</svg>
|
||||||
|
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-7 19.6l-7-4.66V3h14v12.93l-7 4.67zm-2.01-7.42l-2.58-2.59L6 12l4 4 8-8-1.42-1.42z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="pl-2"> Mark Series {{ isSeriesFinished ? 'Not Finished' : 'Finished' }}</span></ui-btn
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow hidden sm:inline-block" />
|
<div class="flex-grow hidden sm:inline-block" />
|
||||||
|
|
||||||
<ui-checkbox v-show="showSortFilters" v-model="settings.collapseSeries" label="Collapse Series" checkbox-bg="bg" check-color="white" small class="mr-2" @input="updateCollapseSeries" />
|
<ui-checkbox v-show="showSortFilters && !isPodcast" v-model="settings.collapseSeries" label="Collapse Series" checkbox-bg="bg" check-color="white" small class="mr-2" @input="updateCollapseSeries" />
|
||||||
<controls-filter-select v-show="showSortFilters" v-model="settings.filterBy" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateFilter" />
|
<controls-filter-select v-show="showSortFilters" v-model="settings.filterBy" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateFilter" />
|
||||||
<controls-order-select v-show="showSortFilters" v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateOrder" />
|
<controls-order-select v-show="showSortFilters" v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateOrder" />
|
||||||
<!-- <div v-show="showSortFilters" class="h-7 ml-4 flex border border-white border-opacity-25 rounded-md">
|
<!-- <div v-show="showSortFilters" class="h-7 ml-4 flex border border-white border-opacity-25 rounded-md">
|
||||||
@@ -38,6 +50,8 @@
|
|||||||
<span class="material-icons" style="font-size: 1.4rem">view_list</span>
|
<span class="material-icons" style="font-size: 1.4rem">view_list</span>
|
||||||
</div>
|
</div>
|
||||||
</div> -->
|
</div> -->
|
||||||
|
|
||||||
|
<ui-btn v-if="isIssuesFilter && userCanDelete" :loading="processingIssues" color="error" small class="ml-4" @click="removeAllIssues">Remove All {{ numShowing }} {{ entityName }}</ui-btn>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="page === 'search'">
|
<template v-else-if="page === 'search'">
|
||||||
<div @click="searchBackArrow" class="rounded-full h-10 w-10 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
|
<div @click="searchBackArrow" class="rounded-full h-10 w-10 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
|
||||||
@@ -56,7 +70,10 @@ export default {
|
|||||||
props: {
|
props: {
|
||||||
page: String,
|
page: String,
|
||||||
isHome: Boolean,
|
isHome: Boolean,
|
||||||
selectedSeries: String,
|
selectedSeries: {
|
||||||
|
type: Object,
|
||||||
|
default: () => null
|
||||||
|
},
|
||||||
searchQuery: String,
|
searchQuery: String,
|
||||||
viewMode: String
|
viewMode: String
|
||||||
},
|
},
|
||||||
@@ -66,10 +83,18 @@ export default {
|
|||||||
hasInit: false,
|
hasInit: false,
|
||||||
totalEntities: 0,
|
totalEntities: 0,
|
||||||
keywordFilter: null,
|
keywordFilter: null,
|
||||||
keywordTimeout: null
|
keywordTimeout: null,
|
||||||
|
processingSeries: false,
|
||||||
|
processingIssues: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
userCanDelete() {
|
||||||
|
return this.$store.getters['user/getUserCanDelete']
|
||||||
|
},
|
||||||
|
isPodcast() {
|
||||||
|
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast'
|
||||||
|
},
|
||||||
isGridMode() {
|
isGridMode() {
|
||||||
return this.viewMode === 'grid'
|
return this.viewMode === 'grid'
|
||||||
},
|
},
|
||||||
@@ -80,6 +105,7 @@ export default {
|
|||||||
return this.totalEntities
|
return this.totalEntities
|
||||||
},
|
},
|
||||||
entityName() {
|
entityName() {
|
||||||
|
if (this.isPodcast) return 'Podcasts'
|
||||||
if (!this.page) return 'Books'
|
if (!this.page) return 'Books'
|
||||||
if (this.page === 'series') return 'Series'
|
if (this.page === 'series') return 'Series'
|
||||||
if (this.page === 'collections') return 'Collections'
|
if (this.page === 'collections') return 'Collections'
|
||||||
@@ -99,9 +125,68 @@ export default {
|
|||||||
},
|
},
|
||||||
showLibrary() {
|
showLibrary() {
|
||||||
return this.libraryBookshelfPage && this.paramId === '' && !this.showingIssues
|
return this.libraryBookshelfPage && this.paramId === '' && !this.showingIssues
|
||||||
|
},
|
||||||
|
seriesName() {
|
||||||
|
return this.selectedSeries ? this.selectedSeries.name : null
|
||||||
|
},
|
||||||
|
seriesProgress() {
|
||||||
|
return this.selectedSeries ? this.selectedSeries.progress : null
|
||||||
|
},
|
||||||
|
seriesLibraryItemIds() {
|
||||||
|
if (!this.seriesProgress) return []
|
||||||
|
return this.seriesProgress.libraryItemIds || []
|
||||||
|
},
|
||||||
|
isSeriesFinished() {
|
||||||
|
return this.seriesProgress && !!this.seriesProgress.isFinished
|
||||||
|
},
|
||||||
|
filterBy() {
|
||||||
|
return this.$store.getters['user/getUserSetting']('filterBy')
|
||||||
|
},
|
||||||
|
isIssuesFilter() {
|
||||||
|
return this.filterBy === 'issues' && this.$route.query.filter === 'issues'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
removeAllIssues() {
|
||||||
|
if (confirm(`Are you sure you want to remove all library items with issues?\n\nNote: This will not delete any files`)) {
|
||||||
|
this.processingIssues = true
|
||||||
|
this.$axios
|
||||||
|
.$delete(`/api/libraries/${this.currentLibraryId}/issues`)
|
||||||
|
.then(() => {
|
||||||
|
this.$toast.success('Removed library items with issues')
|
||||||
|
this.$router.push(`/library/${this.currentLibraryId}/bookshelf`)
|
||||||
|
this.processingIssues = false
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to remove library items with issues', error)
|
||||||
|
this.$toast.error('Failed to remove library items with issues')
|
||||||
|
this.processingIssues = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
markSeriesFinished() {
|
||||||
|
var newIsFinished = !this.isSeriesFinished
|
||||||
|
this.processingSeries = true
|
||||||
|
var updateProgressPayloads = this.seriesLibraryItemIds.map((lid) => {
|
||||||
|
return {
|
||||||
|
id: lid,
|
||||||
|
isFinished: newIsFinished
|
||||||
|
}
|
||||||
|
})
|
||||||
|
console.log('Progress payloads', updateProgressPayloads)
|
||||||
|
this.$axios
|
||||||
|
.patch(`/api/me/progress/batch/update`, updateProgressPayloads)
|
||||||
|
.then(() => {
|
||||||
|
this.$toast.success('Series update success')
|
||||||
|
this.selectedSeries.progress.isFinished = newIsFinished
|
||||||
|
this.processingSeries = false
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.$toast.error('Series update failed')
|
||||||
|
console.error('Failed to batch update read/not read', error)
|
||||||
|
this.processingSeries = false
|
||||||
|
})
|
||||||
|
},
|
||||||
searchBackArrow() {
|
searchBackArrow() {
|
||||||
this.$router.replace(`/library/${this.currentLibraryId}/bookshelf`)
|
this.$router.replace(`/library/${this.currentLibraryId}/bookshelf`)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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: streamAudiobook && 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,11 +29,17 @@ export default {
|
|||||||
return {}
|
return {}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
userIsRoot() {
|
Source() {
|
||||||
return this.$store.getters['user/getIsRoot']
|
return this.$store.state.Source
|
||||||
|
},
|
||||||
|
currentLibraryId() {
|
||||||
|
return this.$store.state.libraries.currentLibraryId
|
||||||
|
},
|
||||||
|
userIsAdminOrUp() {
|
||||||
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
},
|
},
|
||||||
configRoutes() {
|
configRoutes() {
|
||||||
if (!this.userIsRoot) {
|
if (!this.userIsAdminOrUp) {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: 'config-stats',
|
id: 'config-stats',
|
||||||
@@ -38,7 +48,7 @@ export default {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
return [
|
const configRoutes = [
|
||||||
{
|
{
|
||||||
id: 'config',
|
id: 'config',
|
||||||
title: 'Settings',
|
title: 'Settings',
|
||||||
@@ -63,18 +73,23 @@ export default {
|
|||||||
id: 'config-log',
|
id: 'config-log',
|
||||||
title: 'Log',
|
title: 'Log',
|
||||||
path: '/config/log'
|
path: '/config/log'
|
||||||
},
|
}
|
||||||
{
|
]
|
||||||
|
|
||||||
|
if (this.currentLibraryId) {
|
||||||
|
configRoutes.push({
|
||||||
id: 'config-library-stats',
|
id: 'config-library-stats',
|
||||||
title: 'Library Stats',
|
title: 'Library Stats',
|
||||||
path: '/config/library-stats'
|
path: '/config/library-stats'
|
||||||
},
|
})
|
||||||
{
|
configRoutes.push({
|
||||||
id: 'config-stats',
|
id: 'config-stats',
|
||||||
title: 'Your Stats',
|
title: 'Your Stats',
|
||||||
path: '/config/stats'
|
path: '/config/stats'
|
||||||
}
|
})
|
||||||
]
|
}
|
||||||
|
|
||||||
|
return configRoutes
|
||||||
},
|
},
|
||||||
wrapperClass() {
|
wrapperClass() {
|
||||||
var classes = []
|
var classes = []
|
||||||
@@ -109,8 +124,8 @@ export default {
|
|||||||
githubTagUrl() {
|
githubTagUrl() {
|
||||||
return this.versionData.githubTagUrl
|
return this.versionData.githubTagUrl
|
||||||
},
|
},
|
||||||
streamAudiobook() {
|
streamLibraryItem() {
|
||||||
return this.$store.state.streamAudiobook
|
return this.$store.state.streamLibraryItem
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -6,23 +6,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div v-if="initialized && !totalShelves && !hasFilter && isRootUser && entityName === 'books'" class="w-full flex flex-col items-center justify-center py-12">
|
<div v-if="initialized && !totalShelves && !hasFilter && entityName === 'books'" class="w-full flex flex-col items-center justify-center py-12">
|
||||||
<p class="text-center text-2xl font-book mb-4 py-4">Audiobookshelf is empty!</p>
|
<p class="text-center text-2xl font-book mb-4 py-4">{{ libraryName }} Library is empty!</p>
|
||||||
<div class="flex">
|
<div v-if="userIsAdminOrUp" class="flex">
|
||||||
<ui-btn to="/config" color="primary" class="w-52 mr-2">Configure Scanner</ui-btn>
|
<ui-btn to="/config" color="primary" class="w-52 mr-2">Configure Scanner</ui-btn>
|
||||||
<ui-btn color="success" class="w-52" @click="scan">Scan Audiobooks</ui-btn>
|
<ui-btn color="success" class="w-52" @click="scan">Scan Library</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="!totalShelves && initialized" class="w-full py-16">
|
<div v-else-if="!totalShelves && initialized" class="w-full py-16">
|
||||||
<p class="text-xl text-center">{{ emptyMessage }}</p>
|
<p class="text-xl text-center">{{ emptyMessage }}</p>
|
||||||
<div class="flex justify-center mt-2">
|
<!-- Clear filter only available on Library bookshelf -->
|
||||||
|
<div v-if="entityName === 'books'" class="flex justify-center mt-2">
|
||||||
<ui-btn v-if="hasFilter" color="primary" @click="clearFilter">Clear Filter</ui-btn>
|
<ui-btn v-if="hasFilter" color="primary" @click="clearFilter">Clear Filter</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
|
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
|
||||||
|
|
||||||
<!-- Experimental Bookshelf Texture -->
|
<!-- Experimental Bookshelf Texture -->
|
||||||
<div v-show="showExperimentalFeatures" class="fixed bottom-4 right-28 z-40">
|
<div v-show="showExperimentalFeatures && !isAlternativeBookshelfView" class="fixed bottom-4 right-28 z-40">
|
||||||
<div class="rounded-full py-1 bg-primary hover:bg-bg cursor-pointer px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent @click="showBookshelfTextureModal">
|
<div class="rounded-full py-1 bg-primary hover:bg-bg cursor-pointer px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent @click="showBookshelfTextureModal">
|
||||||
<p class="text-sm py-0.5">Texture</p>
|
<p class="text-sm py-0.5">Texture</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -41,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,
|
||||||
@@ -60,7 +63,6 @@ export default {
|
|||||||
totalShelves: 0,
|
totalShelves: 0,
|
||||||
bookshelfMarginLeft: 0,
|
bookshelfMarginLeft: 0,
|
||||||
isSelectionMode: false,
|
isSelectionMode: false,
|
||||||
isSelectAll: false,
|
|
||||||
currentSFQueryString: null,
|
currentSFQueryString: null,
|
||||||
pendingReset: false,
|
pendingReset: false,
|
||||||
keywordFilter: null,
|
keywordFilter: null,
|
||||||
@@ -79,16 +81,22 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
isRootUser() {
|
userIsAdminOrUp() {
|
||||||
return this.$store.getters['user/getIsRoot']
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
},
|
},
|
||||||
showExperimentalFeatures() {
|
showExperimentalFeatures() {
|
||||||
return this.$store.state.showExperimentalFeatures
|
return this.$store.state.showExperimentalFeatures
|
||||||
},
|
},
|
||||||
|
isPodcast() {
|
||||||
|
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast'
|
||||||
|
},
|
||||||
emptyMessage() {
|
emptyMessage() {
|
||||||
if (this.page === 'series') return `You have no series`
|
if (this.page === 'series') return 'You have no series'
|
||||||
if (this.page === 'collections') return "You haven't made any collections yet"
|
if (this.page === 'collections') return "You haven't made any collections yet"
|
||||||
if (this.hasFilter) return `No Results for filter "${this.filterValue}"`
|
if (this.hasFilter) {
|
||||||
|
if (this.filterName === 'Issues') return 'No Issues'
|
||||||
|
return `No Results for filter "${this.filterName}: ${this.filterValue}"`
|
||||||
|
}
|
||||||
return 'No results'
|
return 'No results'
|
||||||
},
|
},
|
||||||
entityName() {
|
entityName() {
|
||||||
@@ -120,7 +128,7 @@ export default {
|
|||||||
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE
|
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE
|
||||||
},
|
},
|
||||||
isAlternativeBookshelfView() {
|
isAlternativeBookshelfView() {
|
||||||
if (!this.isEntityBook) return false // Only used for bookshelf showing books
|
// if (!this.isEntityBook) return false // Only used for bookshelf showing books
|
||||||
return this.bookshelfView === this.$constants.BookshelfView.TITLES
|
return this.bookshelfView === this.$constants.BookshelfView.TITLES
|
||||||
},
|
},
|
||||||
bookCoverAspectRatio() {
|
bookCoverAspectRatio() {
|
||||||
@@ -143,6 +151,9 @@ export default {
|
|||||||
currentLibraryId() {
|
currentLibraryId() {
|
||||||
return this.$store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
},
|
},
|
||||||
|
libraryName() {
|
||||||
|
return this.$store.getters['libraries/getCurrentLibraryName']
|
||||||
|
},
|
||||||
isEntityBook() {
|
isEntityBook() {
|
||||||
return this.entityName === 'series-books' || this.entityName === 'books'
|
return this.entityName === 'series-books' || this.entityName === 'books'
|
||||||
},
|
},
|
||||||
@@ -176,15 +187,18 @@ export default {
|
|||||||
return 6
|
return 6
|
||||||
},
|
},
|
||||||
shelfHeight() {
|
shelfHeight() {
|
||||||
if (this.isAlternativeBookshelfView) return this.entityHeight + 80 * this.sizeMultiplier
|
if (this.isAlternativeBookshelfView) {
|
||||||
|
var extraTitleSpace = this.isEntityBook ? 80 : 40
|
||||||
|
return this.entityHeight + extraTitleSpace * this.sizeMultiplier
|
||||||
|
}
|
||||||
return this.entityHeight + 40
|
return this.entityHeight + 40
|
||||||
},
|
},
|
||||||
totalEntityCardWidth() {
|
totalEntityCardWidth() {
|
||||||
// Includes margin
|
// Includes margin
|
||||||
return this.entityWidth + 24
|
return this.entityWidth + 24
|
||||||
},
|
},
|
||||||
selectedAudiobooks() {
|
selectedLibraryItems() {
|
||||||
return this.$store.state.selectedAudiobooks || []
|
return this.$store.state.selectedLibraryItems || []
|
||||||
},
|
},
|
||||||
sizeMultiplier() {
|
sizeMultiplier() {
|
||||||
var baseSize = this.isCoverSquareAspectRatio ? 192 : 120
|
var baseSize = this.isCoverSquareAspectRatio ? 192 : 120
|
||||||
@@ -210,13 +224,12 @@ export default {
|
|||||||
clearSelectedEntities() {
|
clearSelectedEntities() {
|
||||||
this.updateBookSelectionMode(false)
|
this.updateBookSelectionMode(false)
|
||||||
this.isSelectionMode = false
|
this.isSelectionMode = false
|
||||||
this.isSelectAll = false
|
|
||||||
},
|
},
|
||||||
selectEntity(entity) {
|
selectEntity(entity) {
|
||||||
if (this.entityName === 'books' || this.entityName === 'series-books') {
|
if (this.entityName === 'books' || this.entityName === 'series-books') {
|
||||||
this.$store.commit('toggleAudiobookSelected', entity.id)
|
this.$store.commit('toggleLibraryItemSelected', entity.id)
|
||||||
|
|
||||||
var newIsSelectionMode = !!this.selectedAudiobooks.length
|
var newIsSelectionMode = !!this.selectedLibraryItems.length
|
||||||
if (this.isSelectionMode !== newIsSelectionMode) {
|
if (this.isSelectionMode !== newIsSelectionMode) {
|
||||||
this.isSelectionMode = newIsSelectionMode
|
this.isSelectionMode = newIsSelectionMode
|
||||||
this.updateBookSelectionMode(newIsSelectionMode)
|
this.updateBookSelectionMode(newIsSelectionMode)
|
||||||
@@ -239,7 +252,7 @@ export default {
|
|||||||
this.currentSFQueryString = this.buildSearchParams()
|
this.currentSFQueryString = this.buildSearchParams()
|
||||||
}
|
}
|
||||||
|
|
||||||
var entityPath = this.entityName === 'books' || this.entityName === 'series-books' ? `books/all` : this.entityName
|
var entityPath = this.entityName === 'books' || this.entityName === 'series-books' ? `items` : this.entityName
|
||||||
var sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
|
var sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
|
||||||
var fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1`
|
var fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1`
|
||||||
|
|
||||||
@@ -300,11 +313,11 @@ export default {
|
|||||||
var firstBookPage = Math.floor(firstBookIndex / this.booksPerFetch)
|
var firstBookPage = Math.floor(firstBookIndex / this.booksPerFetch)
|
||||||
var lastBookPage = Math.floor(lastBookIndex / this.booksPerFetch)
|
var lastBookPage = Math.floor(lastBookIndex / this.booksPerFetch)
|
||||||
if (!this.pagesLoaded[firstBookPage]) {
|
if (!this.pagesLoaded[firstBookPage]) {
|
||||||
console.log('Must load next batch', firstBookPage, 'book index', firstBookIndex)
|
// console.log('Must load next batch', firstBookPage, 'book index', firstBookIndex)
|
||||||
this.loadPage(firstBookPage)
|
this.loadPage(firstBookPage)
|
||||||
}
|
}
|
||||||
if (!this.pagesLoaded[lastBookPage]) {
|
if (!this.pagesLoaded[lastBookPage]) {
|
||||||
console.log('Must load last next batch', lastBookPage, 'book index', lastBookIndex)
|
// console.log('Must load last next batch', lastBookPage, 'book index', lastBookIndex)
|
||||||
this.loadPage(lastBookPage)
|
this.loadPage(lastBookPage)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,7 +345,6 @@ export default {
|
|||||||
this.totalEntities = 0
|
this.totalEntities = 0
|
||||||
this.currentPage = 0
|
this.currentPage = 0
|
||||||
this.isSelectionMode = false
|
this.isSelectionMode = false
|
||||||
this.isSelectAll = false
|
|
||||||
this.initialized = false
|
this.initialized = false
|
||||||
|
|
||||||
this.initSizeData()
|
this.initSizeData()
|
||||||
@@ -374,9 +386,7 @@ export default {
|
|||||||
|
|
||||||
let searchParams = new URLSearchParams()
|
let searchParams = new URLSearchParams()
|
||||||
if (this.page === 'series-books') {
|
if (this.page === 'series-books') {
|
||||||
searchParams.set('filter', `series.${this.seriesId}`)
|
searchParams.set('filter', `series.${this.$encode(this.seriesId)}`)
|
||||||
searchParams.set('sort', 'book.volumeNumber')
|
|
||||||
searchParams.set('desc', 0)
|
|
||||||
} else {
|
} else {
|
||||||
if (this.filterBy && this.filterBy !== 'all') {
|
if (this.filterBy && this.filterBy !== 'all') {
|
||||||
searchParams.set('filter', this.filterBy)
|
searchParams.set('filter', this.filterBy)
|
||||||
@@ -385,7 +395,7 @@ export default {
|
|||||||
searchParams.set('sort', this.orderBy)
|
searchParams.set('sort', this.orderBy)
|
||||||
searchParams.set('desc', this.orderDesc ? 1 : 0)
|
searchParams.set('desc', this.orderDesc ? 1 : 0)
|
||||||
}
|
}
|
||||||
if (this.collapseSeries) {
|
if (this.collapseSeries && !this.isPodcast) {
|
||||||
searchParams.set('collapseseries', 1)
|
searchParams.set('collapseseries', 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -404,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,44 +437,71 @@ export default {
|
|||||||
this.handleScroll(scrollTop)
|
this.handleScroll(scrollTop)
|
||||||
// }, 250)
|
// }, 250)
|
||||||
},
|
},
|
||||||
audiobookAdded(audiobook) {
|
libraryItemAdded(libraryItem) {
|
||||||
console.log('Audiobook added', audiobook)
|
console.log('libraryItem added', libraryItem)
|
||||||
// TODO: Check if audiobook would be on this shelf
|
// TODO: Check if audiobook would be on this shelf
|
||||||
this.resetEntities()
|
this.resetEntities()
|
||||||
},
|
},
|
||||||
audiobookUpdated(audiobook) {
|
libraryItemUpdated(libraryItem) {
|
||||||
console.log('Audiobook updated', audiobook)
|
console.log('Item updated', libraryItem)
|
||||||
if (this.entityName === 'books' || this.entityName === 'series-books') {
|
if (this.entityName === 'books' || this.entityName === 'series-books') {
|
||||||
var indexOf = this.entities.findIndex((ent) => ent && ent.id === audiobook.id)
|
var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id)
|
||||||
if (indexOf >= 0) {
|
if (indexOf >= 0) {
|
||||||
this.entities[indexOf] = audiobook
|
this.entities[indexOf] = libraryItem
|
||||||
if (this.entityComponentRefs[indexOf]) {
|
if (this.entityComponentRefs[indexOf]) {
|
||||||
this.entityComponentRefs[indexOf].setEntity(audiobook)
|
this.entityComponentRefs[indexOf].setEntity(libraryItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
audiobookRemoved(audiobook) {
|
libraryItemRemoved(libraryItem) {
|
||||||
if (this.entityName === 'books' || this.entityName === 'series-books') {
|
if (this.entityName === 'books' || this.entityName === 'series-books') {
|
||||||
var indexOf = this.entities.findIndex((ent) => ent && ent.id === audiobook.id)
|
var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id)
|
||||||
if (indexOf >= 0) {
|
if (indexOf >= 0) {
|
||||||
this.entities = this.entities.filter((ent) => ent.id !== audiobook.id)
|
this.entities = this.entities.filter((ent) => ent.id !== libraryItem.id)
|
||||||
this.totalEntities = this.entities.length
|
this.totalEntities = this.entities.length
|
||||||
this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities)
|
this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities)
|
||||||
this.remountEntities()
|
this.executeRebuild()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
audiobooksAdded(audiobooks) {
|
libraryItemsAdded(libraryItems) {
|
||||||
console.log('audiobooks added', audiobooks)
|
console.log('items added', libraryItems)
|
||||||
// TODO: Check if audiobook would be on this shelf
|
// TODO: Check if audiobook would be on this shelf
|
||||||
this.resetEntities()
|
this.resetEntities()
|
||||||
},
|
},
|
||||||
audiobooksUpdated(audiobooks) {
|
libraryItemsUpdated(libraryItems) {
|
||||||
audiobooks.forEach((ab) => {
|
libraryItems.forEach((ab) => {
|
||||||
this.audiobookUpdated(ab)
|
this.libraryItemUpdated(ab)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
collectionAdded(collection) {
|
||||||
|
if (this.entityName !== 'collections') return
|
||||||
|
console.log(`[LazyBookshelf] collectionAdded ${collection.id}`, collection)
|
||||||
|
this.resetEntities()
|
||||||
|
},
|
||||||
|
collectionUpdated(collection) {
|
||||||
|
if (this.entityName !== 'collections') return
|
||||||
|
console.log(`[LazyBookshelf] collectionUpdated ${collection.id}`, collection)
|
||||||
|
var indexOf = this.entities.findIndex((ent) => ent && ent.id === collection.id)
|
||||||
|
if (indexOf >= 0) {
|
||||||
|
this.entities[indexOf] = collection
|
||||||
|
if (this.entityComponentRefs[indexOf]) {
|
||||||
|
this.entityComponentRefs[indexOf].setEntity(collection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
collectionRemoved(collection) {
|
||||||
|
if (this.entityName !== 'collections') return
|
||||||
|
console.log(`[LazyBookshelf] collectionRemoved ${collection.id}`, collection)
|
||||||
|
var indexOf = this.entities.findIndex((ent) => ent && ent.id === collection.id)
|
||||||
|
if (indexOf >= 0) {
|
||||||
|
this.entities = this.entities.filter((ent) => ent.id !== collection.id)
|
||||||
|
this.totalEntities = this.entities.length
|
||||||
|
this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities)
|
||||||
|
this.executeRebuild()
|
||||||
|
}
|
||||||
|
},
|
||||||
initSizeData(_bookshelf) {
|
initSizeData(_bookshelf) {
|
||||||
var bookshelf = _bookshelf || document.getElementById('bookshelf')
|
var bookshelf = _bookshelf || document.getElementById('bookshelf')
|
||||||
if (!bookshelf) {
|
if (!bookshelf) {
|
||||||
@@ -494,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)
|
||||||
@@ -525,11 +573,14 @@ export default {
|
|||||||
this.$store.commit('user/addSettingsListener', { id: 'lazy-bookshelf', meth: this.settingsUpdated })
|
this.$store.commit('user/addSettingsListener', { id: 'lazy-bookshelf', meth: this.settingsUpdated })
|
||||||
|
|
||||||
if (this.$root.socket) {
|
if (this.$root.socket) {
|
||||||
this.$root.socket.on('audiobook_updated', this.audiobookUpdated)
|
this.$root.socket.on('item_updated', this.libraryItemUpdated)
|
||||||
this.$root.socket.on('audiobook_added', this.audiobookAdded)
|
this.$root.socket.on('item_added', this.libraryItemAdded)
|
||||||
this.$root.socket.on('audiobook_removed', this.audiobookRemoved)
|
this.$root.socket.on('item_removed', this.libraryItemRemoved)
|
||||||
this.$root.socket.on('audiobooks_updated', this.audiobooksUpdated)
|
this.$root.socket.on('items_updated', this.libraryItemsUpdated)
|
||||||
this.$root.socket.on('audiobooks_added', this.audiobooksAdded)
|
this.$root.socket.on('items_added', this.libraryItemsAdded)
|
||||||
|
this.$root.socket.on('collection_added', this.collectionAdded)
|
||||||
|
this.$root.socket.on('collection_updated', this.collectionUpdated)
|
||||||
|
this.$root.socket.on('collection_removed', this.collectionRemoved)
|
||||||
} else {
|
} else {
|
||||||
console.error('Bookshelf - Socket not initialized')
|
console.error('Bookshelf - Socket not initialized')
|
||||||
}
|
}
|
||||||
@@ -546,11 +597,14 @@ export default {
|
|||||||
this.$store.commit('user/removeSettingsListener', 'lazy-bookshelf')
|
this.$store.commit('user/removeSettingsListener', 'lazy-bookshelf')
|
||||||
|
|
||||||
if (this.$root.socket) {
|
if (this.$root.socket) {
|
||||||
this.$root.socket.off('audiobook_updated', this.audiobookUpdated)
|
this.$root.socket.off('item_updated', this.libraryItemUpdated)
|
||||||
this.$root.socket.off('audiobook_added', this.audiobookAdded)
|
this.$root.socket.off('item_added', this.libraryItemAdded)
|
||||||
this.$root.socket.off('audiobook_removed', this.audiobookRemoved)
|
this.$root.socket.off('item_removed', this.libraryItemRemoved)
|
||||||
this.$root.socket.off('audiobooks_updated', this.audiobooksUpdated)
|
this.$root.socket.off('items_updated', this.libraryItemsUpdated)
|
||||||
this.$root.socket.off('audiobooks_added', this.audiobooksAdded)
|
this.$root.socket.off('items_added', this.libraryItemsAdded)
|
||||||
|
this.$root.socket.off('collection_added', this.collectionAdded)
|
||||||
|
this.$root.socket.off('collection_updated', this.collectionUpdated)
|
||||||
|
this.$root.socket.off('collection_removed', this.collectionRemoved)
|
||||||
} else {
|
} else {
|
||||||
console.error('Bookshelf - Socket not initialized')
|
console.error('Bookshelf - Socket not initialized')
|
||||||
}
|
}
|
||||||
@@ -563,13 +617,25 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
scan() {
|
scan() {
|
||||||
this.$root.socket.emit('scan', 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)
|
||||||
@@ -580,6 +646,11 @@ export default {
|
|||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
this.destroyEntityComponents()
|
this.destroyEntityComponents()
|
||||||
this.removeListeners()
|
this.removeListeners()
|
||||||
|
|
||||||
|
// Set bookshelf scroll position for specific bookshelf page and query
|
||||||
|
if (window.bookshelf) {
|
||||||
|
this.$store.commit('setLastBookshelfScrollData', { scrollTop: window.bookshelf.scrollTop || 0, path: this.routeFullPath, name: this.page })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-20 bg-bg h-full relative box-shadow-side z-40" style="min-width: 80px">
|
<!-- <div class="w-20 bg-bg h-full relative box-shadow-side z-40" style="min-width: 80px"> -->
|
||||||
<div class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" />
|
<div class="w-20 bg-bg h-full fixed left-0 box-shadow-side z-40" style="min-width: 80px" :style="{ top: offsetTop + 'px' }">
|
||||||
|
<!-- ugly little workaround to cover up the shadow overlapping the bookshelf toolbar -->
|
||||||
|
<div v-if="isShowingBookshelfToolbar" class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" />
|
||||||
|
|
||||||
<nuxt-link :to="`/library/${currentLibraryId}`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
<nuxt-link :to="`/library/${currentLibraryId}`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||||
@@ -21,7 +24,7 @@
|
|||||||
<div v-show="showLibrary" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
<div v-show="showLibrary" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf/series`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isSeriesPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
<nuxt-link v-if="!isPodcastLibrary" :to="`/library/${currentLibraryId}/bookshelf/series`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isSeriesPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -31,7 +34,7 @@
|
|||||||
<div v-show="isSeriesPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
<div v-show="isSeriesPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf/collections`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'collections' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
<nuxt-link v-if="!isPodcastLibrary" :to="`/library/${currentLibraryId}/bookshelf/collections`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'collections' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||||
<span class="material-icons-outlined">collections_bookmark</span>
|
<span class="material-icons-outlined">collections_bookmark</span>
|
||||||
|
|
||||||
<p class="font-book pt-1.5" style="font-size: 0.9rem">Collections</p>
|
<p class="font-book pt-1.5" style="font-size: 0.9rem">Collections</p>
|
||||||
@@ -39,7 +42,7 @@
|
|||||||
<div v-show="paramId === 'collections'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
<div v-show="paramId === 'collections'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link v-if="showExperimentalFeatures" :to="`/library/${currentLibraryId}/authors`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
<nuxt-link v-if="!isPodcastLibrary" :to="`/library/${currentLibraryId}/authors`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||||
<svg class="w-6 h-6" viewBox="0 0 24 24">
|
<svg class="w-6 h-6" viewBox="0 0 24 24">
|
||||||
<path
|
<path
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
@@ -52,6 +55,14 @@
|
|||||||
<div v-show="isAuthorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
<div v-show="isAuthorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
|
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||||
|
<icons-podcast-svg class="w-6 h-6" />
|
||||||
|
|
||||||
|
<p class="font-book pt-1.5" style="font-size: 0.9rem">Search</p>
|
||||||
|
|
||||||
|
<div v-show="isPodcastSearchPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link v-if="numIssues" :to="`/library/${currentLibraryId}/bookshelf?filter=issues`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-opacity-40 cursor-pointer relative" :class="showingIssues ? 'bg-error bg-opacity-40' : ' bg-error bg-opacity-20'">
|
<nuxt-link v-if="numIssues" :to="`/library/${currentLibraryId}/bookshelf?filter=issues`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-opacity-40 cursor-pointer relative" :class="showingIssues ? 'bg-error bg-opacity-40' : ' bg-error bg-opacity-20'">
|
||||||
<span class="material-icons text-2xl">warning</span>
|
<span class="material-icons text-2xl">warning</span>
|
||||||
|
|
||||||
@@ -62,36 +73,6 @@
|
|||||||
<p class="text-xs font-mono pb-0.5">{{ numIssues }}</p>
|
<p class="text-xs font-mono pb-0.5">{{ numIssues }}</p>
|
||||||
</div>
|
</div>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<!-- <nuxt-link to="/library/collections" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'collections' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<p class="font-book pt-1.5" style="font-size: 0.8rem">Collections</p>
|
|
||||||
|
|
||||||
<div v-show="paramId === 'collections'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
|
||||||
</nuxt-link> -->
|
|
||||||
|
|
||||||
<!-- <nuxt-link to="/library/tags" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'tags' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<p class="font-book pt-1.5" style="font-size: 0.8rem">Tags</p>
|
|
||||||
|
|
||||||
<div v-show="paramId === 'tags'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
|
||||||
</nuxt-link> -->
|
|
||||||
|
|
||||||
<!-- <nuxt-link to="/library/authors" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'authors' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<p class="font-book pt-1.5" style="font-size: 0.8rem">Authors</p>
|
|
||||||
|
|
||||||
<div v-show="paramId === 'authors'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
|
||||||
</nuxt-link> -->
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -101,8 +82,15 @@ export default {
|
|||||||
return {}
|
return {}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
showExperimentalFeatures() {
|
isShowingBookshelfToolbar() {
|
||||||
return this.$store.state.showExperimentalFeatures
|
if (!this.$route.name) return false
|
||||||
|
return this.$route.name.startsWith('library')
|
||||||
|
},
|
||||||
|
offsetTop() {
|
||||||
|
return 64
|
||||||
|
},
|
||||||
|
userIsAdminOrUp() {
|
||||||
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
},
|
},
|
||||||
paramId() {
|
paramId() {
|
||||||
return this.$route.params ? this.$route.params.id || '' : ''
|
return this.$route.params ? this.$route.params.id || '' : ''
|
||||||
@@ -110,6 +98,15 @@ export default {
|
|||||||
currentLibraryId() {
|
currentLibraryId() {
|
||||||
return this.$store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
},
|
},
|
||||||
|
currentLibraryMediaType() {
|
||||||
|
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||||
|
},
|
||||||
|
isPodcastLibrary() {
|
||||||
|
return this.currentLibraryMediaType === 'podcast'
|
||||||
|
},
|
||||||
|
isPodcastSearchPage() {
|
||||||
|
return this.$route.name === 'library-library-podcast-search'
|
||||||
|
},
|
||||||
homePage() {
|
homePage() {
|
||||||
return this.$route.name === 'library-library'
|
return this.$route.name === 'library-library'
|
||||||
},
|
},
|
||||||
@@ -125,6 +122,9 @@ export default {
|
|||||||
showLibrary() {
|
showLibrary() {
|
||||||
return this.libraryBookshelfPage && this.paramId === '' && !this.showingIssues
|
return this.libraryBookshelfPage && this.paramId === '' && !this.showingIssues
|
||||||
},
|
},
|
||||||
|
filterBy() {
|
||||||
|
return this.$store.getters['user/getUserSetting']('filterBy')
|
||||||
|
},
|
||||||
showingIssues() {
|
showingIssues() {
|
||||||
if (!this.$route.query) return false
|
if (!this.$route.query) return false
|
||||||
return this.libraryBookshelfPage && this.$route.query.filter === 'issues'
|
return this.libraryBookshelfPage && this.$route.query.filter === 'issues'
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="streamAudiobook" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 sm:h-44 md:h-40 z-40 bg-primary px-4 pb-1 md:pb-4 pt-2">
|
<div v-if="streamLibraryItem" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 sm:h-44 md:h-40 z-40 bg-primary px-4 pb-1 md:pb-4 pt-2">
|
||||||
<nuxt-link :to="`/audiobook/${streamAudiobook.id}`" class="absolute left-4 cursor-pointer" :style="{ top: bookCoverPosTop + 'px' }">
|
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="absolute left-4 cursor-pointer" :style="{ top: bookCoverPosTop + 'px' }">
|
||||||
<covers-book-cover :audiobook="streamAudiobook" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-book-cover :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
<div class="flex items-start pl-24 mb-6 md:mb-0">
|
<div class="flex items-start pl-24 mb-6 md:mb-0">
|
||||||
<div>
|
<div>
|
||||||
<nuxt-link :to="`/audiobook/${streamAudiobook.id}`" class="hover:underline cursor-pointer text-base sm:text-lg">
|
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-base sm:text-lg">
|
||||||
{{ title }}
|
{{ title }}
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
<div class="text-gray-400 flex items-center">
|
<div class="text-gray-400 flex items-center">
|
||||||
<span class="material-icons text-sm">person</span>
|
<span class="material-icons text-sm">person</span>
|
||||||
<p v-if="authorFL" class="pl-1.5 text-sm sm:text-base">
|
<p v-if="podcastAuthor">{{ podcastAuthor }}</p>
|
||||||
<nuxt-link v-for="(author, index) in authorsList" :key="index" :to="`/library/${libraryId}/bookshelf?filter=authors.${$encode(author)}`" class="hover:underline">{{ author }}<span v-if="index < authorsList.length - 1">, </span></nuxt-link>
|
<p v-else-if="authors.length" class="pl-1.5 text-sm sm:text-base">
|
||||||
|
<nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link>
|
||||||
</p>
|
</p>
|
||||||
<p v-else class="text-sm sm:text-base cursor-pointer pl-2">Unknown</p>
|
<p v-else class="text-sm sm:text-base cursor-pointer pl-2">Unknown</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -24,7 +25,6 @@
|
|||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<span class="material-icons px-2 py-1 md:p-4 cursor-pointer" @click="closePlayer">close</span>
|
<span class="material-icons px-2 py-1 md:p-4 cursor-pointer" @click="closePlayer">close</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<audio-player
|
<audio-player
|
||||||
ref="audioPlayer"
|
ref="audioPlayer"
|
||||||
:chapters="chapters"
|
:chapters="chapters"
|
||||||
@@ -33,6 +33,7 @@
|
|||||||
:bookmarks="bookmarks"
|
:bookmarks="bookmarks"
|
||||||
:sleep-timer-set="sleepTimerSet"
|
:sleep-timer-set="sleepTimerSet"
|
||||||
:sleep-timer-remaining="sleepTimerRemaining"
|
:sleep-timer-remaining="sleepTimerRemaining"
|
||||||
|
:is-podcast="isPodcast"
|
||||||
@playPause="playPause"
|
@playPause="playPause"
|
||||||
@jumpForward="jumpForward"
|
@jumpForward="jumpForward"
|
||||||
@jumpBackward="jumpBackward"
|
@jumpBackward="jumpBackward"
|
||||||
@@ -44,7 +45,7 @@
|
|||||||
@showSleepTimer="showSleepTimerModal = true"
|
@showSleepTimer="showSleepTimerModal = true"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :audiobook-id="bookmarkAudiobookId" :current-time="bookmarkCurrentTime" @select="selectBookmark" />
|
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :current-time="bookmarkCurrentTime" :library-item-id="libraryItemId" @select="selectBookmark" />
|
||||||
|
|
||||||
<modals-sleep-timer-modal v-model="showSleepTimerModal" :timer-set="sleepTimerSet" :timer-time="sleepTimerTime" :remaining="sleepTimerRemaining" @set="setSleepTimer" @cancel="cancelSleepTimer" @increment="incrementSleepTimer" @decrement="decrementSleepTimer" />
|
<modals-sleep-timer-modal v-model="showSleepTimerModal" :timer-set="sleepTimerSet" :timer-time="sleepTimerTime" :remaining="sleepTimerRemaining" @set="setSleepTimer" @cancel="cancelSleepTimer" @increment="incrementSleepTimer" @decrement="decrementSleepTimer" />
|
||||||
</div>
|
</div>
|
||||||
@@ -60,7 +61,6 @@ export default {
|
|||||||
totalDuration: 0,
|
totalDuration: 0,
|
||||||
showBookmarksModal: false,
|
showBookmarksModal: false,
|
||||||
bookmarkCurrentTime: 0,
|
bookmarkCurrentTime: 0,
|
||||||
bookmarkAudiobookId: null,
|
|
||||||
playerLoading: false,
|
playerLoading: false,
|
||||||
isPlaying: false,
|
isPlaying: false,
|
||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
@@ -68,13 +68,12 @@ export default {
|
|||||||
sleepTimerSet: false,
|
sleepTimerSet: false,
|
||||||
sleepTimerTime: 0,
|
sleepTimerTime: 0,
|
||||||
sleepTimerRemaining: 0,
|
sleepTimerRemaining: 0,
|
||||||
sleepTimer: null
|
sleepTimer: null,
|
||||||
|
displayTitle: null,
|
||||||
|
initialPlaybackRate: 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
showExperimentalFeatures() {
|
|
||||||
return this.$store.state.showExperimentalFeatures
|
|
||||||
},
|
|
||||||
coverAspectRatio() {
|
coverAspectRatio() {
|
||||||
return this.$store.getters['getServerSetting']('coverAspectRatio')
|
return this.$store.getters['getServerSetting']('coverAspectRatio')
|
||||||
},
|
},
|
||||||
@@ -89,55 +88,65 @@ export default {
|
|||||||
return -64
|
return -64
|
||||||
},
|
},
|
||||||
cover() {
|
cover() {
|
||||||
if (this.streamAudiobook && this.streamAudiobook.cover) return this.streamAudiobook.cover
|
if (this.media.coverPath) return this.media.coverPath
|
||||||
return 'Logo.png'
|
return 'Logo.png'
|
||||||
},
|
},
|
||||||
user() {
|
user() {
|
||||||
return this.$store.state.user.user
|
return this.$store.state.user.user
|
||||||
},
|
},
|
||||||
userAudiobook() {
|
userMediaProgress() {
|
||||||
if (!this.audiobookId) return
|
if (!this.libraryItemId) return
|
||||||
return this.$store.getters['user/getUserAudiobook'](this.audiobookId)
|
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
|
||||||
},
|
},
|
||||||
userAudiobookCurrentTime() {
|
userItemCurrentTime() {
|
||||||
return this.userAudiobook ? this.userAudiobook.currentTime || 0 : 0
|
return this.userMediaProgress ? this.userMediaProgress.currentTime || 0 : 0
|
||||||
},
|
},
|
||||||
bookmarks() {
|
bookmarks() {
|
||||||
if (!this.userAudiobook) return []
|
if (!this.libraryItemId) return []
|
||||||
return (this.userAudiobook.bookmarks || []).map((bm) => ({ ...bm })).sort((a, b) => a.time - b.time)
|
return this.$store.getters['user/getUserBookmarksForItem'](this.libraryItemId)
|
||||||
},
|
},
|
||||||
streamAudiobook() {
|
streamLibraryItem() {
|
||||||
return this.$store.state.streamAudiobook
|
return this.$store.state.streamLibraryItem
|
||||||
},
|
},
|
||||||
audiobookId() {
|
libraryItemId() {
|
||||||
return this.streamAudiobook ? this.streamAudiobook.id : null
|
return this.streamLibraryItem ? this.streamLibraryItem.id : null
|
||||||
},
|
},
|
||||||
book() {
|
media() {
|
||||||
return this.streamAudiobook ? this.streamAudiobook.book || {} : {}
|
return this.streamLibraryItem ? this.streamLibraryItem.media || {} : {}
|
||||||
|
},
|
||||||
|
isPodcast() {
|
||||||
|
return this.streamLibraryItem ? this.streamLibraryItem.mediaType === 'podcast' : false
|
||||||
|
},
|
||||||
|
mediaMetadata() {
|
||||||
|
return this.media.metadata || {}
|
||||||
},
|
},
|
||||||
chapters() {
|
chapters() {
|
||||||
return this.streamAudiobook ? this.streamAudiobook.chapters || [] : []
|
return this.media.chapters || []
|
||||||
},
|
},
|
||||||
title() {
|
title() {
|
||||||
return this.book.title || 'No Title'
|
if (this.playerHandler.displayTitle) return this.playerHandler.displayTitle
|
||||||
|
return this.mediaMetadata.title || 'No Title'
|
||||||
},
|
},
|
||||||
author() {
|
authors() {
|
||||||
return this.book.author || 'Unknown'
|
return this.mediaMetadata.authors || []
|
||||||
},
|
|
||||||
authorFL() {
|
|
||||||
return this.book.authorFL
|
|
||||||
},
|
|
||||||
authorsList() {
|
|
||||||
return this.authorFL ? this.authorFL.split(', ') : []
|
|
||||||
},
|
},
|
||||||
libraryId() {
|
libraryId() {
|
||||||
return this.streamAudiobook ? this.streamAudiobook.libraryId : null
|
return this.streamLibraryItem ? this.streamLibraryItem.libraryId : null
|
||||||
},
|
},
|
||||||
totalDurationPretty() {
|
totalDurationPretty() {
|
||||||
return this.$secondsToTimestamp(this.totalDuration)
|
return this.$secondsToTimestamp(this.totalDuration)
|
||||||
|
},
|
||||||
|
podcastAuthor() {
|
||||||
|
if (!this.isPodcast) return null
|
||||||
|
return this.mediaMetadata.author || 'Unknown'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
setPlaying(isPlaying) {
|
||||||
|
this.isPlaying = isPlaying
|
||||||
|
this.$store.commit('setIsPlaying', isPlaying)
|
||||||
|
this.updateMediaSessionPlaybackState()
|
||||||
|
},
|
||||||
setSleepTimer(seconds) {
|
setSleepTimer(seconds) {
|
||||||
this.sleepTimerSet = true
|
this.sleepTimerSet = true
|
||||||
this.sleepTimerTime = seconds
|
this.sleepTimerTime = seconds
|
||||||
@@ -194,6 +203,7 @@ export default {
|
|||||||
this.playerHandler.setVolume(volume)
|
this.playerHandler.setVolume(volume)
|
||||||
},
|
},
|
||||||
setPlaybackRate(playbackRate) {
|
setPlaybackRate(playbackRate) {
|
||||||
|
this.initialPlaybackRate = playbackRate
|
||||||
this.playerHandler.setPlaybackRate(playbackRate)
|
this.playerHandler.setPlaybackRate(playbackRate)
|
||||||
},
|
},
|
||||||
seek(time) {
|
seek(time) {
|
||||||
@@ -217,7 +227,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
showBookmarks() {
|
showBookmarks() {
|
||||||
this.bookmarkAudiobookId = this.audiobookId
|
|
||||||
this.bookmarkCurrentTime = this.currentTime
|
this.bookmarkCurrentTime = this.currentTime
|
||||||
this.showBookmarksModal = true
|
this.showBookmarksModal = true
|
||||||
},
|
},
|
||||||
@@ -227,7 +236,72 @@ export default {
|
|||||||
},
|
},
|
||||||
closePlayer() {
|
closePlayer() {
|
||||||
this.playerHandler.closePlayer()
|
this.playerHandler.closePlayer()
|
||||||
this.$store.commit('setStreamAudiobook', 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
|
||||||
@@ -239,13 +313,19 @@ export default {
|
|||||||
console.error('No Audio Ref')
|
console.error('No Audio Ref')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
streamOpen(stream) {
|
sessionOpen(session) {
|
||||||
this.$store.commit('setStreamAudiobook', stream.audiobook)
|
this.$store.commit('setMediaPlaying', {
|
||||||
this.playerHandler.prepareStream(stream)
|
libraryItem: session.libraryItem,
|
||||||
|
episodeId: session.episodeId
|
||||||
|
})
|
||||||
|
this.playerHandler.prepareOpenSession(session, this.initialPlaybackRate)
|
||||||
|
},
|
||||||
|
streamOpen(session) {
|
||||||
|
console.log(`[StreamContainer] Stream session open`, session)
|
||||||
},
|
},
|
||||||
streamClosed(streamId) {
|
streamClosed(streamId) {
|
||||||
// Stream was closed from the server
|
// Stream was closed from the server
|
||||||
if (this.playerHandler.isPlayingLocalAudiobook && this.playerHandler.currentStreamId === streamId) {
|
if (this.playerHandler.isPlayingLocalItem && this.playerHandler.currentStreamId === streamId) {
|
||||||
console.warn('[StreamContainer] Closing stream due to request from server')
|
console.warn('[StreamContainer] Closing stream due to request from server')
|
||||||
this.playerHandler.closePlayer()
|
this.playerHandler.closePlayer()
|
||||||
}
|
}
|
||||||
@@ -260,7 +340,7 @@ export default {
|
|||||||
},
|
},
|
||||||
streamError(streamId) {
|
streamError(streamId) {
|
||||||
// Stream had critical error from the server
|
// Stream had critical error from the server
|
||||||
if (this.playerHandler.isPlayingLocalAudiobook && this.playerHandler.currentStreamId === streamId) {
|
if (this.playerHandler.isPlayingLocalItem && this.playerHandler.currentStreamId === streamId) {
|
||||||
console.warn('[StreamContainer] Closing stream due to stream error from server')
|
console.warn('[StreamContainer] Closing stream due to stream error from server')
|
||||||
this.playerHandler.closePlayer()
|
this.playerHandler.closePlayer()
|
||||||
}
|
}
|
||||||
@@ -269,32 +349,47 @@ export default {
|
|||||||
this.playerHandler.resetStream(startTime, streamId)
|
this.playerHandler.resetStream(startTime, streamId)
|
||||||
},
|
},
|
||||||
castSessionActive(isActive) {
|
castSessionActive(isActive) {
|
||||||
if (isActive && this.playerHandler.isPlayingLocalAudiobook) {
|
if (isActive && this.playerHandler.isPlayingLocalItem) {
|
||||||
// Cast session started switch to cast player
|
// Cast session started switch to cast player
|
||||||
this.playerHandler.switchPlayer()
|
this.playerHandler.switchPlayer()
|
||||||
} else if (!isActive && this.playerHandler.isPlayingCastedAudiobook) {
|
} else if (!isActive && this.playerHandler.isPlayingCastedItem) {
|
||||||
// Cast session ended switch to local player
|
// Cast session ended switch to local player
|
||||||
this.playerHandler.switchPlayer()
|
this.playerHandler.switchPlayer()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async playAudiobook(audiobookId) {
|
async playLibraryItem(payload) {
|
||||||
var audiobook = await this.$axios.$get(`/api/books/${audiobookId}`).catch((error) => {
|
var libraryItemId = payload.libraryItemId
|
||||||
console.error('Failed to fetch full audiobook', error)
|
var episodeId = payload.episodeId || null
|
||||||
|
|
||||||
|
if (this.playerHandler.libraryItemId == libraryItemId && this.playerHandler.episodeId == episodeId) {
|
||||||
|
this.playerHandler.play()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var libraryItem = await this.$axios.$get(`/api/items/${libraryItemId}?expanded=1`).catch((error) => {
|
||||||
|
console.error('Failed to fetch full item', error)
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
if (!audiobook) return
|
if (!libraryItem) return
|
||||||
this.$store.commit('setStreamAudiobook', audiobook)
|
this.$store.commit('setMediaPlaying', {
|
||||||
|
libraryItem,
|
||||||
this.playerHandler.load(audiobook, true, this.userAudiobookCurrentTime)
|
episodeId
|
||||||
|
})
|
||||||
|
this.playerHandler.load(libraryItem, episodeId, true, this.initialPlaybackRate)
|
||||||
|
},
|
||||||
|
pauseItem() {
|
||||||
|
this.playerHandler.pause()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.$eventBus.$on('cast-session-active', this.castSessionActive)
|
this.$eventBus.$on('cast-session-active', this.castSessionActive)
|
||||||
this.$eventBus.$on('play-audiobook', this.playAudiobook)
|
this.$eventBus.$on('play-item', this.playLibraryItem)
|
||||||
|
this.$eventBus.$on('pause-item', this.pauseItem)
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
this.$eventBus.$off('cast-session-active', this.castSessionActive)
|
this.$eventBus.$off('cast-session-active', this.castSessionActive)
|
||||||
this.$eventBus.$off('play-audiobook', this.playAudiobook)
|
this.$eventBus.$off('play-item', this.playLibraryItem)
|
||||||
|
this.$eventBus.$off('pause-item', this.pauseItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,28 +1,34 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<nuxt-link :to="`/author/${author.id}`">
|
||||||
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-lg relative">
|
<div @mouseover="mouseover" @mouseleave="mouseleave">
|
||||||
<div class="w-full h-full overflow-hidden max-w-full max-h-full relative">
|
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
|
||||||
<svg width="140%" height="140%" style="margin-left: -20%; margin-top: -20%; opacity: 0.6" viewBox="0 0 177 266" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<!-- Image or placeholder -->
|
||||||
<path fill="white" d="M40.7156 165.47C10.2694 150.865 -31.5407 148.629 -38.0532 155.529L63.3191 204.159L76.9443 190.899C66.828 181.394 54.006 171.846 40.7156 165.47Z" stroke="white" stroke-width="4" transform="translate(-2 -1)" />
|
<covers-author-image :author="author" />
|
||||||
<path d="M-38.0532 155.529C-31.5407 148.629 10.2694 150.865 40.7156 165.47C54.006 171.846 66.828 181.394 76.9443 190.899L95.0391 173.37C80.6681 159.403 64.7526 149.155 51.5747 142.834C21.3549 128.337 -46.2471 114.563 -60.6897 144.67L-71.5489 167.307L44.5864 223.019L63.3191 204.159L-38.0532 155.529Z" fill="white" />
|
|
||||||
<path
|
|
||||||
d="M105.87 29.6508C80.857 17.6515 50.8784 28.1923 38.879 53.2056C26.8797 78.219 37.4205 108.198 62.4338 120.197C87.4472 132.196 117.426 121.656 129.425 96.6422C141.425 71.6288 130.884 41.6502 105.87 29.6508ZM106.789 85.783C112.761 73.3329 107.461 58.2599 95.0112 52.2874C82.5611 46.3148 67.4881 51.6147 61.5156 64.0648C55.543 76.5149 60.8429 91.5879 73.293 97.5604C85.7431 103.533 100.816 98.2331 106.789 85.783Z"
|
|
||||||
fill="white"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M151.336 159.01L159.048 166.762L82.7048 242.703L74.973 242.683L74.9934 234.951L151.336 159.01ZM181.725 108.497C179.624 108.491 177.436 109.326 175.835 110.918L160.415 126.257L191.848 157.856L207.268 142.517C210.554 139.248 210.568 133.954 207.299 130.667L187.685 110.95C186.009 109.264 183.91 108.502 181.725 108.497ZM151.399 135.226L58.2034 227.931L58.1203 259.447L89.6359 259.53L182.831 166.825L151.399 135.226Z"
|
|
||||||
fill="white"
|
|
||||||
/>
|
|
||||||
<path d="M151.336 159.01L159.048 166.762L82.7048 242.703L74.973 242.683L74.9934 234.951L151.336 159.01Z" fill="white" stroke="white" stroke-width="10px" />
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<div class="absolute bottom-0 left-0 w-full py-2 bg-black bg-opacity-25 px-2">
|
<!-- Author name & num books overlay -->
|
||||||
<p class="text-center font-semibold truncate" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ name }}</p>
|
<div v-show="!searching && !nameBelow" class="absolute bottom-0 left-0 w-full py-1 bg-black bg-opacity-60 px-2">
|
||||||
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.85 + 'rem' }">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p>
|
<p class="text-center font-semibold truncate" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
|
||||||
|
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.65 + 'rem' }">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search icon btn -->
|
||||||
|
<div v-show="!searching && isHovering && userCanUpdate" class="absolute top-0 left-0 p-2 cursor-pointer hover:text-white text-gray-200 transform hover:scale-125 duration-150" @click.prevent.stop="searchAuthor">
|
||||||
|
<span class="material-icons text-lg">search</span>
|
||||||
|
</div>
|
||||||
|
<div v-show="isHovering && !searching && userCanUpdate" class="absolute top-0 right-0 p-2 cursor-pointer hover:text-white text-gray-200 transform hover:scale-125 duration-150" @click.prevent.stop="$emit('edit', author)">
|
||||||
|
<span class="material-icons text-lg">edit</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading spinner -->
|
||||||
|
<div v-show="searching" class="absolute top-0 left-0 z-10 w-full h-full bg-black bg-opacity-50 flex items-center justify-center">
|
||||||
|
<widgets-loading-spinner size="" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-show="nameBelow" class="w-full py-1 px-2">
|
||||||
|
<p class="text-center font-semibold truncate text-gray-200" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</nuxt-link>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -34,11 +40,16 @@ export default {
|
|||||||
},
|
},
|
||||||
width: Number,
|
width: Number,
|
||||||
height: Number,
|
height: Number,
|
||||||
sizeMultiplier: Number
|
sizeMultiplier: {
|
||||||
|
type: Number,
|
||||||
|
default: 1
|
||||||
|
},
|
||||||
|
nameBelow: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
placeholder: '/Logo.png'
|
searching: false,
|
||||||
|
isHovering: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -48,30 +59,50 @@ export default {
|
|||||||
_author() {
|
_author() {
|
||||||
return this.author || {}
|
return this.author || {}
|
||||||
},
|
},
|
||||||
|
authorId() {
|
||||||
|
return this._author.id
|
||||||
|
},
|
||||||
name() {
|
name() {
|
||||||
return this._author.name || ''
|
return this._author.name || ''
|
||||||
},
|
},
|
||||||
image() {
|
asin() {
|
||||||
return this._author.image || null
|
return this._author.asin || ''
|
||||||
},
|
|
||||||
description() {
|
|
||||||
return this._author.description
|
|
||||||
},
|
|
||||||
lastUpdate() {
|
|
||||||
return this._author.lastUpdate
|
|
||||||
},
|
},
|
||||||
numBooks() {
|
numBooks() {
|
||||||
return this._author.numBooks || 0
|
return this._author.numBooks || 0
|
||||||
},
|
},
|
||||||
imgSrc() {
|
userCanUpdate() {
|
||||||
if (!this.image) return this.placeholder
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
var encodedImg = this.image.replace(/%/g, '%25').replace(/#/g, '%23')
|
}
|
||||||
|
},
|
||||||
var url = new URL(encodedImg, document.baseURI)
|
methods: {
|
||||||
return url.href + `?token=${this.userToken}&ts=${this.lastUpdate}`
|
mouseover() {
|
||||||
|
this.isHovering = true
|
||||||
|
},
|
||||||
|
mouseleave() {
|
||||||
|
this.isHovering = false
|
||||||
|
},
|
||||||
|
async searchAuthor() {
|
||||||
|
this.searching = true
|
||||||
|
const payload = {}
|
||||||
|
if (this.asin) payload.asin = this.asin
|
||||||
|
else payload.q = this.name
|
||||||
|
|
||||||
|
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, payload).catch((error) => {
|
||||||
|
console.error('Failed', error)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
if (!response) {
|
||||||
|
this.$toast.error('Author not found')
|
||||||
|
} else if (response.updated) {
|
||||||
|
if (response.author.imagePath) this.$toast.success('Author was updated')
|
||||||
|
else this.$toast.success('Author was updated (no image found)')
|
||||||
|
} else {
|
||||||
|
this.$toast.info('No updates were made for Author')
|
||||||
|
}
|
||||||
|
this.searching = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {},
|
|
||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full px-1 overflow-hidden">
|
<div class="flex h-full px-1 overflow-hidden">
|
||||||
<img src="/icons/NoUserPhoto.png" class="w-40 h-40 max-h-40 object-contain" style="max-height: 40px; max-width: 40px" />
|
<div class="overflow-hidden bg-primary rounded" style="height: 50px; width: 40px">
|
||||||
|
<covers-author-image :author="author" />
|
||||||
|
</div>
|
||||||
<div class="flex-grow px-2 authorSearchCardContent h-full">
|
<div class="flex-grow px-2 authorSearchCardContent h-full">
|
||||||
<p class="truncate text-sm">{{ author }}</p>
|
<p class="truncate text-sm">{{ name }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -10,12 +12,19 @@
|
|||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
author: String
|
author: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
},
|
},
|
||||||
computed: {},
|
computed: {
|
||||||
|
name() {
|
||||||
|
return this.author.name
|
||||||
|
}
|
||||||
|
},
|
||||||
methods: {},
|
methods: {},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,26 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full border-b border-gray-700 pb-2">
|
<div class="w-full border-b border-gray-700 pb-2">
|
||||||
<div class="flex py-1 hover:bg-gray-300 hover:bg-opacity-10 cursor-pointer" @click="selectMatch">
|
<div class="flex py-1 hover:bg-gray-300 hover:bg-opacity-10 cursor-pointer" @click="selectMatch">
|
||||||
<img :src="selectedCover || '/book_placeholder.jpg'" class="h-24 object-cover" :style="{ width: 96 / bookCoverAspectRatio + 'px' }" />
|
<div class="h-24 bg-primary" :style="{ minWidth: 96 / bookCoverAspectRatio + 'px' }">
|
||||||
<div class="px-4 flex-grow">
|
<img v-if="selectedCover" :src="selectedCover" class="h-full w-full object-contain" />
|
||||||
|
</div>
|
||||||
|
<div v-if="!isPodcast" class="px-4 flex-grow">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<h1>{{ book.title }}</h1>
|
<h1>{{ book.title }}</h1>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<p>{{ book.publishYear }}</p>
|
<p>{{ book.publishedYear }}</p>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-gray-400">{{ book.author }}</p>
|
<p class="text-gray-400">{{ book.author }}</p>
|
||||||
<div class="w-full max-h-12 overflow-hidden">
|
<div class="w-full max-h-12 overflow-hidden">
|
||||||
<p class="text-gray-500 text-xs">{{ book.description }}</p>
|
<p class="text-gray-500 text-xs">{{ book.description }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else class="px-4 flex-grow">
|
||||||
|
<h1>{{ book.title }}</h1>
|
||||||
|
<p class="text-base text-gray-300 whitespace-nowrap truncate">by {{ book.author }}</p>
|
||||||
|
<p class="text-xs text-gray-400 leading-5">{{ book.genres.join(', ') }}</p>
|
||||||
|
<p class="text-xs text-gray-400 leading-5">{{ book.trackCount }} Episodes</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="bookCovers.length > 1" class="flex">
|
<div v-if="bookCovers.length > 1" class="flex">
|
||||||
<template v-for="cover in bookCovers">
|
<template v-for="cover in bookCovers">
|
||||||
@@ -31,6 +39,7 @@ export default {
|
|||||||
type: Object,
|
type: Object,
|
||||||
default: () => {}
|
default: () => {}
|
||||||
},
|
},
|
||||||
|
isPodcast: Boolean,
|
||||||
bookCoverAspectRatio: Number
|
bookCoverAspectRatio: Number
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="relative">
|
|
||||||
<div class="rounded-sm h-full relative" :style="{ padding: `${paddingY}px ${paddingX}px` }" @mouseover="mouseoverCard" @mouseleave="mouseleaveCard" @click="clickCard">
|
|
||||||
<nuxt-link :to="groupTo" class="cursor-pointer">
|
|
||||||
<div class="w-full relative" :class="isHovering ? 'bg-black-400' : 'bg-primary'" :style="{ height: coverHeight + 'px', width: coverWidth + 'px' }">
|
|
||||||
<covers-collection-cover ref="groupcover" :book-items="bookItems" :width="coverWidth" :height="coverHeight" />
|
|
||||||
|
|
||||||
<div v-show="isHovering" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 pointer-events-none">
|
|
||||||
<!-- <div class="absolute pointer-events-auto" :style="{ top: 0.5 * sizeMultiplier + 'rem', left: 0.5 * sizeMultiplier + 'rem' }" @click.stop.prevent="toggleSelected">
|
|
||||||
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">radio_button_unchecked</span>
|
|
||||||
</div> -->
|
|
||||||
<div class="absolute pointer-events-auto" :style="{ top: 0.5 * sizeMultiplier + 'rem', right: 0.5 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickEdit">
|
|
||||||
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">edit</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nuxt-link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="categoryPlacard absolute z-30 left-0 right-0 mx-auto bottom-0 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, coverWidth) + 'px' }">
|
|
||||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${1 * sizeMultiplier}rem` }">
|
|
||||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ collectionName }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
collection: {
|
|
||||||
type: Object,
|
|
||||||
default: () => null
|
|
||||||
},
|
|
||||||
width: {
|
|
||||||
type: Number,
|
|
||||||
default: 120
|
|
||||||
},
|
|
||||||
paddingY: {
|
|
||||||
type: Number,
|
|
||||||
default: 24
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
isHovering: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
width(newVal) {
|
|
||||||
this.$nextTick(() => {
|
|
||||||
if (this.$refs.groupcover && this.$refs.groupcover.init) {
|
|
||||||
this.$refs.groupcover.init()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
labelFontSize() {
|
|
||||||
if (this.coverWidth < 160) return 0.75
|
|
||||||
return 0.875
|
|
||||||
},
|
|
||||||
currentLibraryId() {
|
|
||||||
return this.$store.state.libraries.currentLibraryId
|
|
||||||
},
|
|
||||||
_collection() {
|
|
||||||
return this.collection || {}
|
|
||||||
},
|
|
||||||
groupTo() {
|
|
||||||
return `/collection/${this._collection.id}`
|
|
||||||
},
|
|
||||||
coverWidth() {
|
|
||||||
return this.width * 2
|
|
||||||
},
|
|
||||||
coverHeight() {
|
|
||||||
return this.width * 1.6
|
|
||||||
},
|
|
||||||
sizeMultiplier() {
|
|
||||||
return this.width / 120
|
|
||||||
},
|
|
||||||
paddingX() {
|
|
||||||
return 16 * this.sizeMultiplier
|
|
||||||
},
|
|
||||||
bookItems() {
|
|
||||||
return this._collection.books || []
|
|
||||||
},
|
|
||||||
collectionName() {
|
|
||||||
return this._collection.name || 'No Name'
|
|
||||||
},
|
|
||||||
showExperimentalFeatures() {
|
|
||||||
return this.$store.state.showExperimentalFeatures
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
toggleSelected() {
|
|
||||||
// Selected
|
|
||||||
},
|
|
||||||
clickEdit() {
|
|
||||||
this.$store.commit('globals/setEditCollection', this.collection)
|
|
||||||
},
|
|
||||||
mouseoverCard() {
|
|
||||||
this.isHovering = true
|
|
||||||
},
|
|
||||||
mouseleaveCard() {
|
|
||||||
this.isHovering = false
|
|
||||||
},
|
|
||||||
clickCard() {
|
|
||||||
this.$emit('click', this.collection)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -12,9 +12,6 @@
|
|||||||
<div class="absolute top-2 right-2 w-7 h-7 rounded-lg bg-black bg-opacity-90 text-gray-300 box-shadow-book flex items-center justify-center border border-white border-opacity-25 pointer-events-none z-40">
|
<div class="absolute top-2 right-2 w-7 h-7 rounded-lg bg-black bg-opacity-90 text-gray-300 box-shadow-book flex items-center justify-center border border-white border-opacity-25 pointer-events-none z-40">
|
||||||
<p class="font-book text-xl">{{ bookItems.length }}</p>
|
<p class="font-book text-xl">{{ bookItems.length }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute bottom-0 left-0 w-full h-1 flex flex-nowrap z-40">
|
|
||||||
<div v-for="userProgress in userProgressItems" :key="userProgress.audiobookId" class="h-full w-full" :class="userProgress.isRead ? 'bg-success' : userProgress.progress > 0 ? 'bg-yellow-400' : ''" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</div>
|
</div>
|
||||||
@@ -74,7 +71,7 @@ export default {
|
|||||||
},
|
},
|
||||||
groupTo() {
|
groupTo() {
|
||||||
if (this.groupType === 'series') {
|
if (this.groupType === 'series') {
|
||||||
return `/library/${this.currentLibraryId}/series/${this.groupEncode}`
|
return `/library/${this.currentLibraryId}/series/${this._group.id}`
|
||||||
} else if (this.groupType === 'collection') {
|
} else if (this.groupType === 'collection') {
|
||||||
return `/collection/${this._group.id}`
|
return `/collection/${this._group.id}`
|
||||||
} else {
|
} else {
|
||||||
@@ -100,15 +97,6 @@ export default {
|
|||||||
bookItems() {
|
bookItems() {
|
||||||
return this._group.books || []
|
return this._group.books || []
|
||||||
},
|
},
|
||||||
userAudiobooks() {
|
|
||||||
return Object.values(this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {})
|
|
||||||
},
|
|
||||||
userProgressItems() {
|
|
||||||
return this.bookItems.map((item) => {
|
|
||||||
var userAudiobook = this.userAudiobooks.find((ab) => ab.audiobookId === item.id)
|
|
||||||
return userAudiobook || {}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
groupName() {
|
groupName() {
|
||||||
return this._group.name || 'No Name'
|
return this._group.name || 'No Name'
|
||||||
},
|
},
|
||||||
@@ -119,21 +107,16 @@ export default {
|
|||||||
return `${this.groupType}.${this.$encode(this.groupName)}`
|
return `${this.groupType}.${this.$encode(this.groupName)}`
|
||||||
},
|
},
|
||||||
hasValidCovers() {
|
hasValidCovers() {
|
||||||
var validCovers = this.bookItems.map((bookItem) => bookItem.book.cover)
|
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)
|
||||||
|
|||||||
+20
-10
@@ -1,13 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-center h-full px-1 overflow-hidden">
|
<div class="flex items-center h-full px-1 overflow-hidden">
|
||||||
<covers-book-cover :audiobook="audiobook" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
<div class="flex-grow px-2 audiobookSearchCardContent">
|
<div class="flex-grow px-2 audiobookSearchCardContent">
|
||||||
<p v-if="matchKey !== 'title'" class="truncate text-sm">{{ title }}</p>
|
<p v-if="matchKey !== 'title'" class="truncate text-sm">{{ title }}</p>
|
||||||
<p v-else class="truncate text-sm" v-html="matchHtml" />
|
<p v-else class="truncate text-sm" v-html="matchHtml" />
|
||||||
|
|
||||||
<p v-if="matchKey === 'subtitle'" class="truncate text-xs text-gray-300">{{ matchHtml }}</p>
|
<p v-if="matchKey === 'subtitle'" class="truncate text-xs text-gray-300">{{ matchHtml }}</p>
|
||||||
|
|
||||||
<p v-if="matchKey !== 'authorFL'" class="text-xs text-gray-200 truncate">by {{ authorFL }}</p>
|
<p v-if="matchKey !== 'authors'" class="text-xs text-gray-200 truncate">by {{ authorName }}</p>
|
||||||
<p v-else class="truncate text-xs text-gray-200" v-html="matchHtml" />
|
<p v-else class="truncate text-xs text-gray-200" v-html="matchHtml" />
|
||||||
|
|
||||||
<div v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" />
|
<div v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" />
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
audiobook: {
|
libraryItem: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => {}
|
default: () => {}
|
||||||
},
|
},
|
||||||
@@ -37,17 +37,27 @@ export default {
|
|||||||
if (this.bookCoverAspectRatio === 1) return 50 * 1.2
|
if (this.bookCoverAspectRatio === 1) return 50 * 1.2
|
||||||
return 50
|
return 50
|
||||||
},
|
},
|
||||||
book() {
|
media() {
|
||||||
return this.audiobook ? this.audiobook.book || {} : {}
|
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||||
|
},
|
||||||
|
mediaType() {
|
||||||
|
return this.libraryItem ? this.libraryItem.mediaType : null
|
||||||
|
},
|
||||||
|
isPodcast() {
|
||||||
|
return this.mediaType == 'podcast'
|
||||||
|
},
|
||||||
|
mediaMetadata() {
|
||||||
|
return this.media.metadata || {}
|
||||||
},
|
},
|
||||||
title() {
|
title() {
|
||||||
return this.book ? this.book.title : 'No Title'
|
return this.mediaMetadata.title || 'No Title'
|
||||||
},
|
},
|
||||||
subtitle() {
|
subtitle() {
|
||||||
return this.book ? this.book.subtitle : ''
|
return this.mediaMetadata.subtitle || ''
|
||||||
},
|
},
|
||||||
authorFL() {
|
authorName() {
|
||||||
return this.book ? this.book.authorFL : 'Unknown'
|
if (this.isPodcast) return this.mediaMetadata.author || 'Unknown'
|
||||||
|
return this.mediaMetadata.authorName || 'Unknown'
|
||||||
},
|
},
|
||||||
matchHtml() {
|
matchHtml() {
|
||||||
if (!this.matchText || !this.search) return ''
|
if (!this.matchText || !this.search) return ''
|
||||||
@@ -69,7 +79,7 @@ export default {
|
|||||||
html += lastPart
|
html += lastPart
|
||||||
|
|
||||||
if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>`
|
if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>`
|
||||||
if (this.matchKey === 'authorFL') return `by ${html}`
|
if (this.matchKey === 'authors') return `by ${html}`
|
||||||
if (this.matchKey === 'isbn') return `<p class="truncate">ISBN: ${html}</p>`
|
if (this.matchKey === 'isbn') return `<p class="truncate">ISBN: ${html}</p>`
|
||||||
if (this.matchKey === 'asin') return `<p class="truncate">ASIN: ${html}</p>`
|
if (this.matchKey === 'asin') return `<p class="truncate">ASIN: ${html}</p>`
|
||||||
if (this.matchKey === 'series') return `<p class="truncate">Series: ${html}</p>`
|
if (this.matchKey === 'series') return `<p class="truncate">Series: ${html}</p>`
|
||||||
+34
-24
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative w-full py-4 px-6 border border-white border-opacity-10 shadow-lg rounded-md my-6">
|
<div class="relative w-full py-4 px-6 border border-white border-opacity-10 shadow-lg rounded-md my-6">
|
||||||
<div class="absolute -top-3 -left-3 w-8 h-8 bg-bg border border-white border-opacity-10 flex items-center justify-center rounded-full">
|
<div class="absolute -top-3 -left-3 w-8 h-8 bg-bg border border-white border-opacity-10 flex items-center justify-center rounded-full">
|
||||||
<p class="text-base text-white text-opacity-80 font-mono">#{{ book.index }}</p>
|
<p class="text-base text-white text-opacity-80 font-mono">#{{ item.index }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!processing && !uploadFailed && !uploadSuccess" class="absolute -top-3 -right-3 w-8 h-8 bg-bg border border-white border-opacity-10 flex items-center justify-center rounded-full hover:bg-error cursor-pointer" @click="$emit('remove')">
|
<div v-if="!processing && !uploadFailed && !uploadSuccess" class="absolute -top-3 -right-3 w-8 h-8 bg-bg border border-white border-opacity-10 flex items-center justify-center rounded-full hover:bg-error cursor-pointer" @click="$emit('remove')">
|
||||||
@@ -15,15 +15,19 @@
|
|||||||
|
|
||||||
<div class="flex my-2 -mx-2">
|
<div class="flex my-2 -mx-2">
|
||||||
<div class="w-1/2 px-2">
|
<div class="w-1/2 px-2">
|
||||||
<ui-text-input-with-label v-model="bookData.title" :disabled="processing" label="Title" @input="titleUpdated" />
|
<ui-text-input-with-label v-model="itemData.title" :disabled="processing" label="Title" @input="titleUpdated" />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-1/2 px-2">
|
<div class="w-1/2 px-2">
|
||||||
<ui-text-input-with-label v-model="bookData.author" :disabled="processing" label="Author" />
|
<ui-text-input-with-label v-if="!isPodcast" v-model="itemData.author" :disabled="processing" label="Author" />
|
||||||
|
<div v-else class="w-full">
|
||||||
|
<p class="px-1 text-sm font-semibold">Directory <em class="font-normal text-xs pl-2">(auto)</em></p>
|
||||||
|
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs" style="height: 38px" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex my-2 -mx-2">
|
<div v-if="!isPodcast" class="flex my-2 -mx-2">
|
||||||
<div class="w-1/2 px-2">
|
<div class="w-1/2 px-2">
|
||||||
<ui-text-input-with-label v-model="bookData.series" :disabled="processing" label="Series" note="(optional)" />
|
<ui-text-input-with-label v-model="itemData.series" :disabled="processing" label="Series" note="(optional)" />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-1/2 px-2">
|
<div class="w-1/2 px-2">
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
@@ -33,9 +37,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<tables-uploaded-files-table :files="book.bookFiles" title="Book Files" class="mt-8" />
|
<tables-uploaded-files-table :files="item.itemFiles" title="Item Files" class="mt-8" />
|
||||||
<tables-uploaded-files-table v-if="book.otherFiles.length" title="Other Files" :files="book.otherFiles" />
|
<tables-uploaded-files-table v-if="item.otherFiles.length" title="Other Files" :files="item.otherFiles" />
|
||||||
<tables-uploaded-files-table v-if="book.ignoredFiles.length" title="Ignored Files" :files="book.ignoredFiles" />
|
<tables-uploaded-files-table v-if="item.ignoredFiles.length" title="Ignored Files" :files="item.ignoredFiles" />
|
||||||
</template>
|
</template>
|
||||||
<widgets-alert v-if="uploadSuccess" type="success">
|
<widgets-alert v-if="uploadSuccess" type="success">
|
||||||
<p class="text-base">Successfully Uploaded!</p>
|
<p class="text-base">Successfully Uploaded!</p>
|
||||||
@@ -55,15 +59,16 @@ import Path from 'path'
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
book: {
|
item: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => {}
|
default: () => {}
|
||||||
},
|
},
|
||||||
|
mediaType: String,
|
||||||
processing: Boolean
|
processing: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
bookData: {
|
itemData: {
|
||||||
title: '',
|
title: '',
|
||||||
author: '',
|
author: '',
|
||||||
series: ''
|
series: ''
|
||||||
@@ -75,14 +80,19 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
isPodcast() {
|
||||||
|
return this.mediaType === 'podcast'
|
||||||
|
},
|
||||||
directory() {
|
directory() {
|
||||||
if (!this.bookData.title) return ''
|
if (!this.itemData.title) return ''
|
||||||
if (this.bookData.series && this.bookData.author) {
|
if (this.isPodcast) return this.itemData.title
|
||||||
return Path.join(this.bookData.author, this.bookData.series, this.bookData.title)
|
|
||||||
} else if (this.bookData.author) {
|
if (this.itemData.series && this.itemData.author) {
|
||||||
return Path.join(this.bookData.author, this.bookData.title)
|
return Path.join(this.itemData.author, this.itemData.series, this.itemData.title)
|
||||||
|
} else if (this.itemData.author) {
|
||||||
|
return Path.join(this.itemData.author, this.itemData.title)
|
||||||
} else {
|
} else {
|
||||||
return this.bookData.title
|
return this.itemData.title
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -96,24 +106,24 @@ export default {
|
|||||||
this.error = ''
|
this.error = ''
|
||||||
},
|
},
|
||||||
getData() {
|
getData() {
|
||||||
if (!this.bookData.title) {
|
if (!this.itemData.title) {
|
||||||
this.error = 'Must have a title'
|
this.error = 'Must have a title'
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
this.error = ''
|
this.error = ''
|
||||||
var files = this.book.bookFiles.concat(this.book.otherFiles)
|
var files = this.item.itemFiles.concat(this.item.otherFiles)
|
||||||
return {
|
return {
|
||||||
index: this.book.index,
|
index: this.item.index,
|
||||||
...this.bookData,
|
...this.itemData,
|
||||||
files
|
files
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
if (this.book) {
|
if (this.item) {
|
||||||
this.bookData.title = this.book.title
|
this.itemData.title = this.item.title
|
||||||
this.bookData.author = this.book.author
|
this.itemData.author = this.item.author
|
||||||
this.bookData.series = this.book.series
|
this.itemData.series = this.item.series
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,22 +6,23 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Alternative bookshelf title/author/sort -->
|
<!-- Alternative bookshelf title/author/sort -->
|
||||||
<div v-if="isAlternativeBookshelfView" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }">
|
<div v-if="isAlternativeBookshelfView || isAuthorBookshelfView" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }">
|
||||||
<p class="truncate" :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
|
<p class="truncate" :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
|
||||||
<span v-if="volumeNumber">#{{ volumeNumber }} </span>{{ displayTitle }}
|
{{ displayTitle }}
|
||||||
</p>
|
</p>
|
||||||
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayAuthor }}</p>
|
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayLineTwo || ' ' }}</p>
|
||||||
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
|
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="booksInSeries" class="absolute z-20 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ booksInSeries }}</div>
|
<div v-if="booksInSeries" class="absolute z-20 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ booksInSeries }}</div>
|
||||||
|
|
||||||
<div class="w-full h-full absolute top-0 left-0 rounded overflow-hidden z-10">
|
<div class="w-full h-full absolute top-0 left-0 rounded overflow-hidden z-10">
|
||||||
<div v-show="audiobook && !imageReady" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: sizeMultiplier * 0.5 + 'rem' }">
|
<div v-show="libraryItem && !imageReady" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: sizeMultiplier * 0.5 + 'rem' }">
|
||||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }" class="font-book text-gray-300 text-center">{{ title }}</p>
|
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }" class="font-book text-gray-300 text-center">{{ title }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<img v-show="audiobook" ref="cover" :src="bookCoverSrc" class="w-full h-full object-contain transition-opacity duration-300" :class="showCoverBg ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" />
|
<!-- Cover Image -->
|
||||||
|
<img v-show="libraryItem" ref="cover" :src="bookCoverSrc" class="w-full h-full transition-opacity duration-300" :class="showCoverBg ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" />
|
||||||
|
|
||||||
<!-- Placeholder Cover Title & Author -->
|
<!-- Placeholder Cover Title & Author -->
|
||||||
<div v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem' }">
|
<div v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem' }">
|
||||||
@@ -34,11 +35,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- No progress shown for collapsed series in library -->
|
<!-- No progress shown for collapsed series in library and podcasts (unless showing podcast episode) -->
|
||||||
<div v-if="!booksInSeries" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="userIsRead ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
<div v-if="!booksInSeries && (!isPodcast || episodeProgress)" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
||||||
|
|
||||||
<!-- Overlay is not shown if collapsing series in library -->
|
<!-- Overlay is not shown if collapsing series in library -->
|
||||||
<div v-show="!booksInSeries && audiobook && (isHovering || isSelectionMode || isMoreMenuOpen)" class="w-full h-full absolute top-0 left-0 z-10 bg-black rounded hidden md:block" :class="overlayWrapperClasslist">
|
<div v-show="!booksInSeries && libraryItem && (isHovering || isSelectionMode || isMoreMenuOpen)" class="w-full h-full absolute top-0 left-0 z-10 bg-black rounded hidden md:block" :class="overlayWrapperClasslist">
|
||||||
<div v-show="showPlayButton" class="h-full flex items-center justify-center pointer-events-none">
|
<div v-show="showPlayButton" class="h-full flex items-center justify-center pointer-events-none">
|
||||||
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto" @click.stop.prevent="play">
|
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto" @click.stop.prevent="play">
|
||||||
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">play_circle_filled</span>
|
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">play_circle_filled</span>
|
||||||
@@ -51,7 +52,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="userCanUpdate" v-show="!isSelectionMode" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-50" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="editClick">
|
<div v-if="userCanUpdate" v-show="!isSelectionMode" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-150 top-0 right-0" :style="{ padding: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="editClick">
|
||||||
<span class="material-icons" :style="{ fontSize: sizeMultiplier + 'rem' }">edit</span>
|
<span class="material-icons" :style="{ fontSize: sizeMultiplier + 'rem' }">edit</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -59,13 +60,14 @@
|
|||||||
<span class="material-icons" :class="selected ? 'text-yellow-400' : ''" :style="{ fontSize: 1.25 * sizeMultiplier + 'rem' }">{{ selected ? 'radio_button_checked' : 'radio_button_unchecked' }}</span>
|
<span class="material-icons" :class="selected ? 'text-yellow-400' : ''" :style="{ fontSize: 1.25 * sizeMultiplier + 'rem' }">{{ selected ? 'radio_button_checked' : 'radio_button_unchecked' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref="moreIcon" v-show="!isSelectionMode" class="hidden md:block absolute cursor-pointer hover:text-yellow-300" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickShowMore">
|
<!-- More Menu Icon -->
|
||||||
|
<div ref="moreIcon" v-show="!isSelectionMode" class="hidden md:block absolute cursor-pointer hover:text-yellow-300 300 hover:scale-125 transform duration-150" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickShowMore">
|
||||||
<span class="material-icons" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">more_vert</span>
|
<span class="material-icons" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">more_vert</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Series name overlay -->
|
<!-- Series name overlay -->
|
||||||
<div v-if="booksInSeries && audiobook && isHovering" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-60 rounded flex items-center justify-center" :style="{ padding: sizeMultiplier + 'rem' }">
|
<div v-if="booksInSeries && libraryItem && isHovering" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-60 rounded flex items-center justify-center" :style="{ padding: sizeMultiplier + 'rem' }">
|
||||||
<p class="text-gray-200 text-center" :style="{ fontSize: 1.1 * sizeMultiplier + 'rem' }">{{ series }}</p>
|
<p class="text-gray-200 text-center" :style="{ fontSize: 1.1 * sizeMultiplier + 'rem' }">{{ series }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -76,9 +78,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|
||||||
<!-- Volume number -->
|
<!-- Series sequence -->
|
||||||
<div v-if="volumeNumber && showVolumeNumber && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
<div v-if="seriesSequence && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ volumeNumber }}</p>
|
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ seriesSequence }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Podcast Episode # -->
|
||||||
|
<div v-if="recentEpisodeNumber && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||||
|
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">Episode #{{ recentEpisodeNumber }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Podcast Num Episodes -->
|
||||||
|
<div v-else-if="numEpisodes && !isHovering && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
|
||||||
|
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodes }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -99,7 +111,6 @@ export default {
|
|||||||
default: 192
|
default: 192
|
||||||
},
|
},
|
||||||
bookCoverAspectRatio: Number,
|
bookCoverAspectRatio: Number,
|
||||||
showVolumeNumber: Boolean,
|
|
||||||
bookshelfView: Number,
|
bookshelfView: Number,
|
||||||
bookMount: {
|
bookMount: {
|
||||||
// Book can be passed as prop or set with setEntity()
|
// Book can be passed as prop or set with setEntity()
|
||||||
@@ -115,7 +126,7 @@ export default {
|
|||||||
isHovering: false,
|
isHovering: false,
|
||||||
isMoreMenuOpen: false,
|
isMoreMenuOpen: false,
|
||||||
isProcessingReadUpdate: false,
|
isProcessingReadUpdate: false,
|
||||||
audiobook: null,
|
libraryItem: null,
|
||||||
imageReady: false,
|
imageReady: false,
|
||||||
rescanning: false,
|
rescanning: false,
|
||||||
selected: false,
|
selected: false,
|
||||||
@@ -127,7 +138,7 @@ export default {
|
|||||||
bookMount: {
|
bookMount: {
|
||||||
handler(newVal) {
|
handler(newVal) {
|
||||||
if (newVal) {
|
if (newVal) {
|
||||||
this.audiobook = newVal
|
this.libraryItem = newVal
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -136,42 +147,82 @@ export default {
|
|||||||
showExperimentalFeatures() {
|
showExperimentalFeatures() {
|
||||||
return this.store.state.showExperimentalFeatures
|
return this.store.state.showExperimentalFeatures
|
||||||
},
|
},
|
||||||
_audiobook() {
|
enableEReader() {
|
||||||
return this.audiobook || {}
|
return this.store.getters['getServerSetting']('enableEReader')
|
||||||
},
|
},
|
||||||
book() {
|
_libraryItem() {
|
||||||
return this._audiobook.book || {}
|
return this.libraryItem || {}
|
||||||
|
},
|
||||||
|
isFile() {
|
||||||
|
// Library item is not in a folder
|
||||||
|
return this._libraryItem.isFile
|
||||||
|
},
|
||||||
|
media() {
|
||||||
|
return this._libraryItem.media || {}
|
||||||
|
},
|
||||||
|
mediaMetadata() {
|
||||||
|
return this.media.metadata || {}
|
||||||
|
},
|
||||||
|
mediaType() {
|
||||||
|
return this._libraryItem.mediaType
|
||||||
|
},
|
||||||
|
isPodcast() {
|
||||||
|
return this.mediaType === 'podcast'
|
||||||
},
|
},
|
||||||
placeholderUrl() {
|
placeholderUrl() {
|
||||||
return '/book_placeholder.jpg'
|
return '/book_placeholder.jpg'
|
||||||
},
|
},
|
||||||
bookCoverSrc() {
|
bookCoverSrc() {
|
||||||
return this.store.getters['audiobooks/getBookCoverSrc'](this._audiobook, this.placeholderUrl)
|
return this.store.getters['globals/getLibraryItemCoverSrc'](this._libraryItem, this.placeholderUrl)
|
||||||
},
|
},
|
||||||
audiobookId() {
|
libraryItemId() {
|
||||||
return this._audiobook.id
|
return this._libraryItem.id
|
||||||
},
|
},
|
||||||
series() {
|
series() {
|
||||||
return this.book.series
|
// Only included when filtering by series or collapse series or Continue Series shelf on home page
|
||||||
|
return this.mediaMetadata.series
|
||||||
|
},
|
||||||
|
seriesSequence() {
|
||||||
|
return this.series ? this.series.sequence : null
|
||||||
},
|
},
|
||||||
libraryId() {
|
libraryId() {
|
||||||
return this._audiobook.libraryId
|
return this._libraryItem.libraryId
|
||||||
},
|
},
|
||||||
hasEbook() {
|
hasEbook() {
|
||||||
return this._audiobook.numEbooks
|
return this.media.ebookFormat
|
||||||
},
|
},
|
||||||
hasTracks() {
|
numTracks() {
|
||||||
return this._audiobook.numTracks
|
if (this.media.tracks) return this.media.tracks.length
|
||||||
|
return this.media.numTracks || 0 // toJSONMinified
|
||||||
|
},
|
||||||
|
numEpisodes() {
|
||||||
|
if (!this.isPodcast) return 0
|
||||||
|
return this.media.numEpisodes || 0
|
||||||
},
|
},
|
||||||
processingBatch() {
|
processingBatch() {
|
||||||
return this.store.state.processingBatch
|
return this.store.state.processingBatch
|
||||||
},
|
},
|
||||||
|
recentEpisode() {
|
||||||
|
// Only added to item when getting currently listening podcasts
|
||||||
|
return this._libraryItem.recentEpisode
|
||||||
|
},
|
||||||
|
recentEpisodeNumber() {
|
||||||
|
if (!this.recentEpisode) return null
|
||||||
|
if (this.recentEpisode.episode) {
|
||||||
|
return this.recentEpisode.episode.replace(/^#/, '')
|
||||||
|
}
|
||||||
|
return this.recentEpisode.index
|
||||||
|
},
|
||||||
|
collapsedSeries() {
|
||||||
|
// Only added to item object when collapseSeries is enabled
|
||||||
|
return this._libraryItem.collapsedSeries
|
||||||
|
},
|
||||||
booksInSeries() {
|
booksInSeries() {
|
||||||
// Only added to audiobook object when collapseSeries is enabled
|
// Only added to item object when collapseSeries is enabled
|
||||||
return this._audiobook.booksInSeries
|
return this.collapsedSeries ? this.collapsedSeries.numBooks : 0
|
||||||
},
|
},
|
||||||
hasCover() {
|
hasCover() {
|
||||||
return !!this.book.cover
|
return !!this.media.coverPath
|
||||||
},
|
},
|
||||||
squareAspectRatio() {
|
squareAspectRatio() {
|
||||||
return this.bookCoverAspectRatio === 1
|
return this.bookCoverAspectRatio === 1
|
||||||
@@ -181,87 +232,99 @@ export default {
|
|||||||
return this.width / baseSize
|
return this.width / baseSize
|
||||||
},
|
},
|
||||||
title() {
|
title() {
|
||||||
return this.book.title || ''
|
return this.mediaMetadata.title || ''
|
||||||
},
|
},
|
||||||
playIconFontSize() {
|
playIconFontSize() {
|
||||||
return Math.max(2, 3 * this.sizeMultiplier)
|
return Math.max(2, 3 * this.sizeMultiplier)
|
||||||
},
|
},
|
||||||
author() {
|
author() {
|
||||||
return this.book.author
|
if (this.isPodcast) return this.mediaMetadata.author
|
||||||
},
|
return this.mediaMetadata.authorName
|
||||||
authorFL() {
|
|
||||||
return this.book.authorFL || this.author
|
|
||||||
},
|
},
|
||||||
authorLF() {
|
authorLF() {
|
||||||
return this.book.authorLF || this.author
|
return this.mediaMetadata.authorNameLF
|
||||||
},
|
|
||||||
volumeNumber() {
|
|
||||||
return this.book.volumeNumber || null
|
|
||||||
},
|
},
|
||||||
displayTitle() {
|
displayTitle() {
|
||||||
if (this.orderBy === 'book.title' && this.sortingIgnorePrefix && this.title.toLowerCase().startsWith('the ')) {
|
if (this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix) {
|
||||||
return this.title.substr(4) + ', The'
|
return this.mediaMetadata.titleIgnorePrefix
|
||||||
}
|
}
|
||||||
return this.title
|
return this.title
|
||||||
},
|
},
|
||||||
displayAuthor() {
|
displayLineTwo() {
|
||||||
if (this.orderBy === 'book.authorLF') return this.authorLF
|
if (this.isPodcast) return this.author
|
||||||
return this.authorFL
|
if (this.isAuthorBookshelfView) {
|
||||||
|
return this.mediaMetadata.publishedYear || ''
|
||||||
|
}
|
||||||
|
if (this.orderBy === 'media.metadata.authorNameLF') return this.authorLF
|
||||||
|
return this.author
|
||||||
},
|
},
|
||||||
displaySortLine() {
|
displaySortLine() {
|
||||||
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._audiobook.mtimeMs)
|
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs)
|
||||||
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._audiobook.birthtimeMs)
|
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs)
|
||||||
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._audiobook.addedAt)
|
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt)
|
||||||
if (this.orderBy === 'duration') return 'Duration: ' + this.$elapsedPrettyExtended(this._audiobook.duration, false)
|
if (this.orderBy === 'media.duration') return 'Duration: ' + this.$elapsedPrettyExtended(this.media.duration, false)
|
||||||
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._audiobook.size)
|
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size)
|
||||||
|
if (this.orderBy === 'media.numTracks') return `${this.numEpisodes} Episodes`
|
||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
|
episodeProgress() {
|
||||||
|
// Only used on home page currently listening podcast shelf
|
||||||
|
if (!this.recentEpisode) return null
|
||||||
|
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId, this.recentEpisode.id)
|
||||||
|
},
|
||||||
userProgress() {
|
userProgress() {
|
||||||
return this.store.getters['user/getUserAudiobook'](this.audiobookId)
|
if (this.episodeProgress) return this.episodeProgress
|
||||||
|
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
|
||||||
},
|
},
|
||||||
userProgressPercent() {
|
userProgressPercent() {
|
||||||
return this.userProgress ? this.userProgress.progress || 0 : 0
|
return this.userProgress ? this.userProgress.progress || 0 : 0
|
||||||
},
|
},
|
||||||
userIsRead() {
|
itemIsFinished() {
|
||||||
return this.userProgress ? !!this.userProgress.isRead : false
|
return this.userProgress ? !!this.userProgress.isFinished : false
|
||||||
},
|
},
|
||||||
showError() {
|
showError() {
|
||||||
return this.hasMissingParts || this.hasInvalidParts || this.isMissing || this.isInvalid
|
if (this.recentEpisode) return false // Dont show podcast error on episode card
|
||||||
|
return this.numInvalidAudioFiles || this.numMissingParts || this.isMissing || this.isInvalid
|
||||||
},
|
},
|
||||||
isStreaming() {
|
isStreaming() {
|
||||||
return this.store.getters['getAudiobookIdStreaming'] === this.audiobookId
|
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.hasTracks && !this.isStreaming
|
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._audiobook.isMissing
|
return this._libraryItem.isMissing
|
||||||
},
|
},
|
||||||
isInvalid() {
|
isInvalid() {
|
||||||
return this._audiobook.isInvalid
|
return this._libraryItem.isInvalid
|
||||||
},
|
},
|
||||||
hasMissingParts() {
|
numMissingParts() {
|
||||||
return this._audiobook.hasMissingParts
|
if (this.isPodcast) return 0
|
||||||
|
return this.media.numMissingParts
|
||||||
},
|
},
|
||||||
hasInvalidParts() {
|
numInvalidAudioFiles() {
|
||||||
return this._audiobook.hasInvalidParts
|
if (this.isPodcast) return 0
|
||||||
|
return this.media.numInvalidAudioFiles
|
||||||
},
|
},
|
||||||
errorText() {
|
errorText() {
|
||||||
if (this.isMissing) return 'Audiobook directory is missing!'
|
if (this.isMissing) return 'Item directory is missing!'
|
||||||
else if (this.isInvalid) return 'Audiobook has no audio tracks & ebook'
|
else if (this.isInvalid) {
|
||||||
var txt = ''
|
if (this.isPodcast) return 'Podcast has no episodes'
|
||||||
if (this.hasMissingParts) {
|
return 'Item has no audio tracks & ebook'
|
||||||
txt = `${this.hasMissingParts} missing parts.`
|
|
||||||
}
|
}
|
||||||
if (this.hasInvalidParts) {
|
var txt = ''
|
||||||
if (this.hasMissingParts) txt += ' '
|
if (this.numMissingParts) {
|
||||||
txt += `${this.hasInvalidParts} invalid parts.`
|
txt += `${this.numMissingParts} missing parts.`
|
||||||
|
}
|
||||||
|
if (this.numInvalidAudioFiles) {
|
||||||
|
if (txt) txt += ' '
|
||||||
|
txt += `${this.numInvalidAudioFiles} invalid audio files.`
|
||||||
}
|
}
|
||||||
return txt || 'Unknown Error'
|
return txt || 'Unknown Error'
|
||||||
},
|
},
|
||||||
@@ -286,39 +349,47 @@ export default {
|
|||||||
userCanDownload() {
|
userCanDownload() {
|
||||||
return this.store.getters['user/getUserCanDownload']
|
return this.store.getters['user/getUserCanDownload']
|
||||||
},
|
},
|
||||||
userIsRoot() {
|
userIsAdminOrUp() {
|
||||||
return this.store.getters['user/getIsRoot']
|
return this.store.getters['user/getIsAdminOrUp']
|
||||||
},
|
},
|
||||||
moreMenuItems() {
|
moreMenuItems() {
|
||||||
var items = [
|
if (this.recentEpisode) {
|
||||||
{
|
return [
|
||||||
func: 'toggleRead',
|
{
|
||||||
text: `Mark as ${this.userIsRead ? 'Not Read' : 'Read'}`
|
func: 'editPodcast',
|
||||||
},
|
text: 'Edit Podcast'
|
||||||
{
|
},
|
||||||
func: 'openCollections',
|
{
|
||||||
text: 'Add to Collection'
|
func: 'toggleFinished',
|
||||||
}
|
text: `Mark as ${this.itemIsFinished ? 'Not Finished' : 'Finished'}`
|
||||||
]
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
var items = []
|
||||||
|
if (!this.isPodcast) {
|
||||||
|
items = [
|
||||||
|
{
|
||||||
|
func: 'toggleFinished',
|
||||||
|
text: `Mark as ${this.itemIsFinished ? 'Not Finished' : 'Finished'}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
func: 'openCollections',
|
||||||
|
text: 'Add to Collection'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
if (this.userCanUpdate) {
|
if (this.userCanUpdate) {
|
||||||
if (this.hasTracks) {
|
items.push({
|
||||||
items.push({
|
func: 'showEditModalFiles',
|
||||||
func: 'showEditModalTracks',
|
text: 'Files'
|
||||||
text: 'Tracks'
|
})
|
||||||
})
|
|
||||||
}
|
|
||||||
items.push({
|
items.push({
|
||||||
func: 'showEditModalMatch',
|
func: 'showEditModalMatch',
|
||||||
text: 'Match'
|
text: 'Match'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (this.userCanDownload) {
|
if (this.userIsAdminOrUp && !this.isFile) {
|
||||||
items.push({
|
|
||||||
func: 'showEditModalDownload',
|
|
||||||
text: 'Download'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (this.userIsRoot) {
|
|
||||||
items.push({
|
items.push({
|
||||||
func: 'rescan',
|
func: 'rescan',
|
||||||
text: 'Re-Scan'
|
text: 'Re-Scan'
|
||||||
@@ -349,18 +420,22 @@ export default {
|
|||||||
return this.title
|
return this.title
|
||||||
},
|
},
|
||||||
authorCleaned() {
|
authorCleaned() {
|
||||||
if (!this.authorFL) return ''
|
if (!this.author) return ''
|
||||||
if (this.authorFL.length > 30) {
|
if (this.author.length > 30) {
|
||||||
return this.authorFL.slice(0, 27) + '...'
|
return this.author.slice(0, 27) + '...'
|
||||||
}
|
}
|
||||||
return this.authorFL
|
return this.author
|
||||||
},
|
},
|
||||||
isAlternativeBookshelfView() {
|
isAlternativeBookshelfView() {
|
||||||
var constants = this.$constants || this.$nuxt.$constants
|
var constants = this.$constants || this.$nuxt.$constants
|
||||||
return this.bookshelfView === constants.BookshelfView.TITLES
|
return this.bookshelfView === constants.BookshelfView.TITLES
|
||||||
},
|
},
|
||||||
|
isAuthorBookshelfView() {
|
||||||
|
var constants = this.$constants || this.$nuxt.$constants
|
||||||
|
return this.bookshelfView === constants.BookshelfView.AUTHOR
|
||||||
|
},
|
||||||
titleDisplayBottomOffset() {
|
titleDisplayBottomOffset() {
|
||||||
if (!this.isAlternativeBookshelfView) return 0
|
if (!this.isAlternativeBookshelfView && !this.isAuthorBookshelfView) return 0
|
||||||
else if (!this.displaySortLine) return 3 * this.sizeMultiplier
|
else if (!this.displaySortLine) return 3 * this.sizeMultiplier
|
||||||
return 4.25 * this.sizeMultiplier
|
return 4.25 * this.sizeMultiplier
|
||||||
}
|
}
|
||||||
@@ -370,8 +445,35 @@ export default {
|
|||||||
this.isSelectionMode = val
|
this.isSelectionMode = val
|
||||||
if (!val) this.selected = false
|
if (!val) this.selected = false
|
||||||
},
|
},
|
||||||
setEntity(audiobook) {
|
setEntity(_libraryItem) {
|
||||||
this.audiobook = audiobook
|
var libraryItem = _libraryItem
|
||||||
|
|
||||||
|
// this code block is only necessary when showing a selected series with sequence #
|
||||||
|
// it will update the selected series so we get realtime updates for series sequence changes
|
||||||
|
if (this.series) {
|
||||||
|
// i know.. but the libraryItem passed to this func cannot be modified so we need to create a copy
|
||||||
|
libraryItem = {
|
||||||
|
..._libraryItem,
|
||||||
|
media: {
|
||||||
|
..._libraryItem.media,
|
||||||
|
metadata: {
|
||||||
|
..._libraryItem.media.metadata
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var mediaMetadata = libraryItem.media.metadata
|
||||||
|
if (mediaMetadata.series) {
|
||||||
|
var newSeries = mediaMetadata.series.find((se) => se.id === this.series.id)
|
||||||
|
if (newSeries) {
|
||||||
|
// update selected series
|
||||||
|
libraryItem.media.metadata.series = newSeries
|
||||||
|
this.libraryItem = libraryItem
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.libraryItem = libraryItem
|
||||||
},
|
},
|
||||||
clickCard(e) {
|
clickCard(e) {
|
||||||
if (this.isSelectionMode) {
|
if (this.isSelectionMode) {
|
||||||
@@ -381,66 +483,76 @@ export default {
|
|||||||
} else {
|
} else {
|
||||||
var router = this.$router || this.$nuxt.$router
|
var router = this.$router || this.$nuxt.$router
|
||||||
if (router) {
|
if (router) {
|
||||||
if (this.booksInSeries) router.push(`/library/${this.libraryId}/series/${this.$encode(this.series)}`)
|
if (this.collapsedSeries) router.push(`/library/${this.libraryId}/series/${this.collapsedSeries.id}`)
|
||||||
else router.push(`/audiobook/${this.audiobookId}`)
|
else router.push(`/item/${this.libraryItemId}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
editClick() {
|
editClick() {
|
||||||
this.$emit('edit', this.audiobook)
|
if (this.recentEpisode) {
|
||||||
|
return this.$emit('edit', { libraryItem: this.libraryItem, episode: this.recentEpisode })
|
||||||
|
}
|
||||||
|
this.$emit('edit', this.libraryItem)
|
||||||
},
|
},
|
||||||
toggleRead() {
|
toggleFinished() {
|
||||||
// More menu func
|
|
||||||
var updatePayload = {
|
var updatePayload = {
|
||||||
isRead: !this.userIsRead
|
isFinished: !this.itemIsFinished
|
||||||
}
|
}
|
||||||
this.isProcessingReadUpdate = true
|
this.isProcessingReadUpdate = true
|
||||||
|
|
||||||
|
var apiEndpoint = `/api/me/progress/${this.libraryItemId}`
|
||||||
|
if (this.recentEpisode) apiEndpoint += `/${this.recentEpisode.id}`
|
||||||
|
|
||||||
var toast = this.$toast || this.$nuxt.$toast
|
var toast = this.$toast || this.$nuxt.$toast
|
||||||
var axios = this.$axios || this.$nuxt.$axios
|
var axios = this.$axios || this.$nuxt.$axios
|
||||||
axios
|
axios
|
||||||
.$patch(`/api/me/audiobook/${this.audiobookId}`, updatePayload)
|
.$patch(apiEndpoint, updatePayload)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.isProcessingReadUpdate = false
|
this.isProcessingReadUpdate = false
|
||||||
toast.success(`"${this.title}" Marked as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
|
toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
this.isProcessingReadUpdate = false
|
this.isProcessingReadUpdate = false
|
||||||
toast.error(`Failed to mark as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
|
toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
audiobookScanComplete(result) {
|
editPodcast() {
|
||||||
this.rescanning = false
|
this.$emit('editPodcast', this.libraryItem)
|
||||||
var toast = this.$toast || this.$nuxt.$toast
|
|
||||||
if (!result) {
|
|
||||||
toast.error(`Re-Scan Failed for "${this.title}"`)
|
|
||||||
} else if (result === 'UPDATED') {
|
|
||||||
toast.success(`Re-Scan complete audiobook was updated`)
|
|
||||||
} else if (result === 'UPTODATE') {
|
|
||||||
toast.success(`Re-Scan complete audiobook was up to date`)
|
|
||||||
} else if (result === 'REMOVED') {
|
|
||||||
toast.error(`Re-Scan complete audiobook was removed`)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
rescan() {
|
rescan() {
|
||||||
this.rescanning = true
|
this.rescanning = true
|
||||||
this._socket.once('audiobook_scan_complete', this.audiobookScanComplete)
|
this.$axios
|
||||||
this._socket.emit('scan_audiobook', this.audiobookId)
|
.$get(`/api/items/${this.libraryItemId}/scan`)
|
||||||
|
.then((data) => {
|
||||||
|
this.rescanning = false
|
||||||
|
var result = data.result
|
||||||
|
if (!result) {
|
||||||
|
this.$toast.error(`Re-Scan Failed for "${this.title}"`)
|
||||||
|
} else if (result === 'UPDATED') {
|
||||||
|
this.$toast.success(`Re-Scan complete item was updated`)
|
||||||
|
} else if (result === 'UPTODATE') {
|
||||||
|
this.$toast.success(`Re-Scan complete item was up to date`)
|
||||||
|
} else if (result === 'REMOVED') {
|
||||||
|
this.$toast.error(`Re-Scan complete item was removed`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to scan library item', error)
|
||||||
|
this.$toast.error('Failed to scan library item')
|
||||||
|
this.rescanning = false
|
||||||
|
})
|
||||||
},
|
},
|
||||||
showEditModalTracks() {
|
showEditModalFiles() {
|
||||||
// More menu func
|
// More menu func
|
||||||
this.store.commit('showEditModalOnTab', { audiobook: this.audiobook, tab: 'tracks' })
|
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'files' })
|
||||||
},
|
},
|
||||||
showEditModalMatch() {
|
showEditModalMatch() {
|
||||||
// More menu func
|
// More menu func
|
||||||
this.store.commit('showEditModalOnTab', { audiobook: this.audiobook, tab: 'match' })
|
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'match' })
|
||||||
},
|
|
||||||
showEditModalDownload() {
|
|
||||||
// More menu func
|
|
||||||
this.store.commit('showEditModalOnTab', { audiobook: this.audiobook, tab: 'download' })
|
|
||||||
},
|
},
|
||||||
openCollections() {
|
openCollections() {
|
||||||
this.store.commit('setSelectedAudiobook', this.audiobook)
|
this.store.commit('setSelectedLibraryItem', this.libraryItem)
|
||||||
this.store.commit('globals/setShowUserCollectionsModal', true)
|
this.store.commit('globals/setShowUserCollectionsModal', true)
|
||||||
},
|
},
|
||||||
createMoreMenu() {
|
createMoreMenu() {
|
||||||
@@ -493,17 +605,26 @@ export default {
|
|||||||
clickShowMore() {
|
clickShowMore() {
|
||||||
this.createMoreMenu()
|
this.createMoreMenu()
|
||||||
},
|
},
|
||||||
clickReadEBook() {
|
async clickReadEBook() {
|
||||||
this.store.commit('showEReader', this.audiobook)
|
var libraryItem = await this.$axios.$get(`/api/items/${this.libraryItemId}?expanded=1`).catch((error) => {
|
||||||
|
console.error('Failed to get lirbary item', this.libraryItemId)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
if (!libraryItem) return
|
||||||
|
console.log('Got library itemn', libraryItem)
|
||||||
|
this.store.commit('showEReader', libraryItem)
|
||||||
},
|
},
|
||||||
selectBtnClick() {
|
selectBtnClick() {
|
||||||
if (this.processingBatch) return
|
if (this.processingBatch) return
|
||||||
this.selected = !this.selected
|
this.selected = !this.selected
|
||||||
this.$emit('select', this.audiobook)
|
this.$emit('select', this.libraryItem)
|
||||||
},
|
},
|
||||||
play() {
|
play() {
|
||||||
var eventBus = this.$eventBus || this.$nuxt.$eventBus
|
var eventBus = this.$eventBus || this.$nuxt.$eventBus
|
||||||
eventBus.$emit('play-audiobook', this.audiobookId)
|
eventBus.$emit('play-item', {
|
||||||
|
libraryItemId: this.libraryItemId,
|
||||||
|
episodeId: this.recentEpisode ? this.recentEpisode.id : null
|
||||||
|
})
|
||||||
},
|
},
|
||||||
mouseover() {
|
mouseover() {
|
||||||
this.isHovering = true
|
this.isHovering = true
|
||||||
|
|||||||
@@ -5,20 +5,18 @@
|
|||||||
<covers-collection-cover ref="cover" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-collection-cover ref="cover" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
</div>
|
</div>
|
||||||
<div v-show="isHovering" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 pointer-events-none">
|
<div v-show="isHovering" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 pointer-events-none">
|
||||||
<!-- <div class="absolute pointer-events-auto" :style="{ top: 0.5 * sizeMultiplier + 'rem', left: 0.5 * sizeMultiplier + 'rem' }" @click.stop.prevent="toggleSelected">
|
|
||||||
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">radio_button_unchecked</span>
|
|
||||||
</div> -->
|
|
||||||
<div class="absolute pointer-events-auto" :style="{ top: 0.5 * sizeMultiplier + 'rem', right: 0.5 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickEdit">
|
<div class="absolute pointer-events-auto" :style="{ top: 0.5 * sizeMultiplier + 'rem', right: 0.5 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickEdit">
|
||||||
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">edit</span>
|
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">edit</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- <div v-if="isHovering || isSelectionMode" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-40">
|
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
|
||||||
</div> -->
|
|
||||||
<div class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
|
|
||||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
|
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
|
||||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
|
||||||
|
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -28,7 +26,11 @@ export default {
|
|||||||
index: Number,
|
index: Number,
|
||||||
width: Number,
|
width: Number,
|
||||||
height: Number,
|
height: Number,
|
||||||
bookCoverAspectRatio: Number
|
bookCoverAspectRatio: Number,
|
||||||
|
bookshelfView: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -58,6 +60,10 @@ export default {
|
|||||||
},
|
},
|
||||||
currentLibraryId() {
|
currentLibraryId() {
|
||||||
return this.store.state.libraries.currentLibraryId
|
return this.store.state.libraries.currentLibraryId
|
||||||
|
},
|
||||||
|
isAlternativeBookshelfView() {
|
||||||
|
const constants = this.$constants || this.$nuxt.$constants
|
||||||
|
return this.bookshelfView == constants.BookshelfView.TITLES
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -7,16 +7,20 @@
|
|||||||
|
|
||||||
<div class="absolute z-10 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ books.length }}</div>
|
<div class="absolute z-10 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ books.length }}</div>
|
||||||
|
|
||||||
|
<div v-if="isSeriesFinished" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b bg-success w-full" />
|
||||||
|
|
||||||
<div v-if="hasValidCovers" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: `${sizeMultiplier}rem` }">
|
<div v-if="hasValidCovers" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: `${sizeMultiplier}rem` }">
|
||||||
<p class="font-book" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ title }}</p>
|
<p class="font-book" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ title }}</p>
|
||||||
</div>
|
</div>
|
||||||
<!-- <div v-if="isHovering || isSelectionMode" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-40">
|
|
||||||
</div> -->
|
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
|
||||||
<div v-if="!isCategorized" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
|
|
||||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
|
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
|
||||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
|
||||||
|
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -27,6 +31,10 @@ export default {
|
|||||||
width: Number,
|
width: Number,
|
||||||
height: Number,
|
height: Number,
|
||||||
bookCoverAspectRatio: Number,
|
bookCoverAspectRatio: Number,
|
||||||
|
bookshelfView: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
isCategorized: Boolean,
|
isCategorized: Boolean,
|
||||||
seriesMount: {
|
seriesMount: {
|
||||||
type: Object,
|
type: Object,
|
||||||
@@ -51,12 +59,31 @@ 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
|
||||||
},
|
},
|
||||||
|
seriesId() {
|
||||||
|
return this.series ? this.series.id : ''
|
||||||
|
},
|
||||||
title() {
|
title() {
|
||||||
return this.series ? this.series.name : ''
|
return this.series ? this.series.name : ''
|
||||||
},
|
},
|
||||||
books() {
|
books() {
|
||||||
return this.series ? this.series.books || [] : []
|
return this.series ? this.series.books || [] : []
|
||||||
},
|
},
|
||||||
|
addedAt() {
|
||||||
|
return this.series ? this.series.addedAt : 0
|
||||||
|
},
|
||||||
|
seriesBookProgress() {
|
||||||
|
return this.books
|
||||||
|
.map((libraryItem) => {
|
||||||
|
return this.store.getters['user/getUserMediaProgress'](libraryItem.id)
|
||||||
|
})
|
||||||
|
.filter((p) => !!p)
|
||||||
|
},
|
||||||
|
seriesBooksFinished() {
|
||||||
|
return this.seriesBookProgress.filter((p) => p.isFinished)
|
||||||
|
},
|
||||||
|
isSeriesFinished() {
|
||||||
|
return this.books.length === this.seriesBooksFinished.length
|
||||||
|
},
|
||||||
store() {
|
store() {
|
||||||
return this.$store || this.$nuxt.$store
|
return this.$store || this.$nuxt.$store
|
||||||
},
|
},
|
||||||
@@ -64,14 +91,15 @@ export default {
|
|||||||
return this.store.state.libraries.currentLibraryId
|
return this.store.state.libraries.currentLibraryId
|
||||||
},
|
},
|
||||||
seriesBooksRoute() {
|
seriesBooksRoute() {
|
||||||
return `/library/${this.currentLibraryId}/series/${this.$encode(this.title)}`
|
return `/library/${this.currentLibraryId}/series/${this.seriesId}`
|
||||||
},
|
|
||||||
seriesId() {
|
|
||||||
return this.series ? this.$encode(this.title) : null
|
|
||||||
},
|
},
|
||||||
hasValidCovers() {
|
hasValidCovers() {
|
||||||
var validCovers = this.books.map((bookItem) => bookItem.book.cover)
|
var validCovers = this.books.map((bookItem) => bookItem.media.coverPath)
|
||||||
return !!validCovers.length
|
return !!validCovers.length
|
||||||
|
},
|
||||||
|
isAlternativeBookshelfView() {
|
||||||
|
const constants = this.$constants || this.$nuxt.$constants
|
||||||
|
return this.bookshelfView == constants.BookshelfView.TITLES
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<form @submit.prevent="submitSearch">
|
|
||||||
<div class="flex items-center justify-start -mx-1 h-20">
|
|
||||||
<!-- <div class="w-40 px-1">
|
|
||||||
<ui-dropdown v-model="provider" :items="providers" label="Provider" small />
|
|
||||||
</div> -->
|
|
||||||
<div class="flex-grow px-1">
|
|
||||||
<ui-text-input-with-label v-model="searchAuthor" label="Author" />
|
|
||||||
</div>
|
|
||||||
<ui-btn class="mt-5 ml-1" type="submit">Search</ui-btn>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
authorName: String
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
searchAuthor: null,
|
|
||||||
lastSearch: null,
|
|
||||||
isProcessing: false,
|
|
||||||
provider: 'audnexus',
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
text: 'Audnexus',
|
|
||||||
value: 'audnexus'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
authorName: {
|
|
||||||
immediate: true,
|
|
||||||
handler(newVal) {
|
|
||||||
this.searchAuthor = newVal
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {},
|
|
||||||
methods: {
|
|
||||||
getSearchQuery() {
|
|
||||||
return `q=${this.searchAuthor}`
|
|
||||||
},
|
|
||||||
submitSearch() {
|
|
||||||
if (!this.searchAuthor) {
|
|
||||||
this.$toast.warning('Author name is required')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.runSearch()
|
|
||||||
},
|
|
||||||
async runSearch() {
|
|
||||||
var searchQuery = this.getSearchQuery()
|
|
||||||
if (this.lastSearch === searchQuery) return
|
|
||||||
this.isProcessing = true
|
|
||||||
this.lastSearch = searchQuery
|
|
||||||
var result = await this.$axios.$get(`/api/authors/search?${searchQuery}`).catch((error) => {
|
|
||||||
console.error('Failed', error)
|
|
||||||
return []
|
|
||||||
})
|
|
||||||
this.isProcessing = false
|
|
||||||
if (result) {
|
|
||||||
this.$emit('match', result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full px-1 overflow-hidden">
|
<div class="flex h-full px-1 overflow-hidden">
|
||||||
<covers-group-cover :name="series" :book-items="bookItems" :width="60" :height="60" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-group-cover :name="name" :book-items="bookItems" :width="60" :height="60" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
<div class="flex-grow px-2 seriesSearchCardContent h-full">
|
<div class="flex-grow px-2 seriesSearchCardContent h-full">
|
||||||
<p class="truncate text-sm">{{ series }}</p>
|
<p class="truncate text-sm">{{ name }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -10,7 +10,10 @@
|
|||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
series: String,
|
series: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
},
|
||||||
bookItems: {
|
bookItems: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => []
|
default: () => []
|
||||||
@@ -22,6 +25,9 @@ export default {
|
|||||||
computed: {
|
computed: {
|
||||||
bookCoverAspectRatio() {
|
bookCoverAspectRatio() {
|
||||||
return this.$store.getters['getBookCoverAspectRatio']
|
return this.$store.getters['getBookCoverAspectRatio']
|
||||||
|
},
|
||||||
|
name() {
|
||||||
|
return this.series.name
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {},
|
methods: {},
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
|
||||||
|
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
||||||
|
<span class="flex items-center justify-between">
|
||||||
|
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
|
||||||
|
<span class="material-icons text-lg text-yellow-400">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
|
||||||
|
<template v-for="item in items">
|
||||||
|
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span class="font-normal ml-3 block truncate text-xs">{{ item.text }}</span>
|
||||||
|
</div>
|
||||||
|
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
|
||||||
|
<span class="material-icons text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: String,
|
||||||
|
descending: Boolean
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
showMenu: false,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
text: 'Pub Date',
|
||||||
|
value: 'publishedAt'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Title',
|
||||||
|
value: 'title'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Season',
|
||||||
|
value: 'season'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Episode',
|
||||||
|
value: 'episode'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
selected: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selectedDesc: {
|
||||||
|
get() {
|
||||||
|
return this.descending
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('update:descending', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selectedText() {
|
||||||
|
var _selected = this.selected
|
||||||
|
if (!_selected) return ''
|
||||||
|
var _sel = this.items.find((i) => i.value === _selected)
|
||||||
|
if (!_sel) return ''
|
||||||
|
return _sel.text
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
clickOutside() {
|
||||||
|
this.showMenu = false
|
||||||
|
},
|
||||||
|
clickedOption(val) {
|
||||||
|
if (this.selected === val) {
|
||||||
|
this.selectedDesc = !this.selectedDesc
|
||||||
|
} else {
|
||||||
|
this.selected = val
|
||||||
|
}
|
||||||
|
this.showMenu = false
|
||||||
|
this.$nextTick(() => this.$emit('change', val))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
|
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
|
||||||
<ul v-show="!sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
<ul v-show="!sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||||
<template v-for="item in items">
|
<template v-for="item in selectItems">
|
||||||
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item)">
|
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item)">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="font-normal ml-3 block truncate text-sm md:text-base">{{ item.text }}</span>
|
<span class="font-normal ml-3 block truncate text-sm md:text-base">{{ item.text }}</span>
|
||||||
@@ -67,7 +67,7 @@ export default {
|
|||||||
return {
|
return {
|
||||||
showMenu: false,
|
showMenu: false,
|
||||||
sublist: null,
|
sublist: null,
|
||||||
items: [
|
bookItems: [
|
||||||
{
|
{
|
||||||
text: 'All',
|
text: 'All',
|
||||||
value: 'all'
|
value: 'all'
|
||||||
@@ -107,6 +107,32 @@ export default {
|
|||||||
value: 'progress',
|
value: 'progress',
|
||||||
sublist: true
|
sublist: true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
text: 'Missing',
|
||||||
|
value: 'missing',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Issues',
|
||||||
|
value: 'issues',
|
||||||
|
sublist: false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
podcastItems: [
|
||||||
|
{
|
||||||
|
text: 'All',
|
||||||
|
value: 'all'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Genre',
|
||||||
|
value: 'genres',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Tag',
|
||||||
|
value: 'tags',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
text: 'Issues',
|
text: 'Issues',
|
||||||
value: 'issues',
|
value: 'issues',
|
||||||
@@ -132,18 +158,42 @@ export default {
|
|||||||
this.$emit('input', val)
|
this.$emit('input', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
isPodcast() {
|
||||||
|
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast'
|
||||||
|
},
|
||||||
|
selectItems() {
|
||||||
|
if (this.isPodcast) return this.podcastItems
|
||||||
|
return this.bookItems
|
||||||
|
},
|
||||||
selectedItemSublist() {
|
selectedItemSublist() {
|
||||||
return this.selected && this.selected.includes('.') ? this.selected.split('.')[0] : false
|
return this.selected && this.selected.includes('.') ? this.selected.split('.')[0] : false
|
||||||
},
|
},
|
||||||
selectedText() {
|
selectedText() {
|
||||||
if (!this.selected) return ''
|
if (!this.selected) return ''
|
||||||
var parts = this.selected.split('.')
|
var parts = this.selected.split('.')
|
||||||
|
var filterName = this.selectItems.find((i) => i.value === parts[0])
|
||||||
|
var filterValue = null
|
||||||
if (parts.length > 1) {
|
if (parts.length > 1) {
|
||||||
return this.$decode(parts[1])
|
var decoded = this.$decode(parts[1])
|
||||||
|
if (decoded.startsWith('aut_')) {
|
||||||
|
var author = this.authors.find((au) => au.id == decoded)
|
||||||
|
if (author) filterValue = author.name
|
||||||
|
} else if (decoded.startsWith('ser_')) {
|
||||||
|
var series = this.series.find((se) => se.id == decoded)
|
||||||
|
if (series) filterValue = series.name
|
||||||
|
} else {
|
||||||
|
filterValue = decoded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (filterName && filterValue) {
|
||||||
|
return `${filterName.text}: ${filterValue}`
|
||||||
|
} else if (filterName) {
|
||||||
|
return filterName.text
|
||||||
|
} else if (filterValue) {
|
||||||
|
return filterValue
|
||||||
|
} else {
|
||||||
|
return ''
|
||||||
}
|
}
|
||||||
var _sel = this.items.find((i) => i.value === this.selected)
|
|
||||||
if (!_sel) return ''
|
|
||||||
return _sel.text
|
|
||||||
},
|
},
|
||||||
genres() {
|
genres() {
|
||||||
return this.filterData.genres || []
|
return this.filterData.genres || []
|
||||||
@@ -164,13 +214,23 @@ export default {
|
|||||||
return this.filterData.languages || []
|
return this.filterData.languages || []
|
||||||
},
|
},
|
||||||
progress() {
|
progress() {
|
||||||
return ['Read', 'Unread', 'In Progress']
|
return ['Finished', 'In Progress', 'Not Started']
|
||||||
|
},
|
||||||
|
missing() {
|
||||||
|
return ['ASIN', 'ISBN', 'Subtitle', 'Author', 'Publish Year', 'Series', 'Description', 'Genres', 'Tags', 'Narrator', 'Publisher', 'Language']
|
||||||
},
|
},
|
||||||
sublistItems() {
|
sublistItems() {
|
||||||
return (this[this.sublist] || []).map((item) => {
|
return (this[this.sublist] || []).map((item) => {
|
||||||
return {
|
if (typeof item === 'string') {
|
||||||
text: item,
|
return {
|
||||||
value: this.$encode(item)
|
text: item,
|
||||||
|
value: this.$encode(item)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
text: item.name,
|
||||||
|
value: this.$encode(item.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,38 +19,47 @@
|
|||||||
<p>No Results</p>
|
<p>No Results</p>
|
||||||
</li>
|
</li>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<p class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">Books</p>
|
<p v-if="bookResults.length" class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">Books</p>
|
||||||
<template v-for="item in audiobookResults">
|
<template v-for="item in bookResults">
|
||||||
<li :key="item.audiobook.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
<li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
||||||
<nuxt-link :to="`/audiobook/${item.audiobook.id}`">
|
<nuxt-link :to="`/item/${item.libraryItem.id}`">
|
||||||
<cards-audiobook-search-card :audiobook="item.audiobook" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
|
<cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
|
||||||
|
</nuxt-link>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<p v-if="podcastResults.length" class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">Podcasts</p>
|
||||||
|
<template v-for="item in podcastResults">
|
||||||
|
<li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
||||||
|
<nuxt-link :to="`/item/${item.libraryItem.id}`">
|
||||||
|
<cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<p v-if="authorResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Authors</p>
|
<p v-if="authorResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Authors</p>
|
||||||
<template v-for="item in authorResults">
|
<template v-for="item in authorResults">
|
||||||
<li :key="item.author" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
<li :key="item.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
||||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(item.author)}`">
|
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(item.id)}`">
|
||||||
<cards-author-search-card :author="item.author" />
|
<cards-author-search-card :author="item" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<p v-if="seriesResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Series</p>
|
<p v-if="seriesResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Series</p>
|
||||||
<template v-for="item in seriesResults">
|
<template v-for="item in seriesResults">
|
||||||
<li :key="item.series" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
<li :key="item.series.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
||||||
<nuxt-link :to="`/library/${currentLibraryId}/series/${$encode(item.series)}`">
|
<nuxt-link :to="`/library/${currentLibraryId}/series/${item.series.id}`">
|
||||||
<cards-series-search-card :series="item.series" :book-items="item.audiobooks" />
|
<cards-series-search-card :series="item.series" :book-items="item.books" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<p v-if="tagResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Tags</p>
|
<p v-if="tagResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Tags</p>
|
||||||
<template v-for="item in tagResults">
|
<template v-for="item in tagResults">
|
||||||
<li :key="item.tag" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
<li :key="item.name" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
||||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=tags.${$encode(item.tag)}`">
|
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=tags.${$encode(item.name)}`">
|
||||||
<cards-tag-search-card :tag="item.tag" />
|
<cards-tag-search-card :tag="item.name" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
@@ -70,7 +79,8 @@ export default {
|
|||||||
isTyping: false,
|
isTyping: false,
|
||||||
isFetching: false,
|
isFetching: false,
|
||||||
search: null,
|
search: null,
|
||||||
audiobookResults: [],
|
podcastResults: [],
|
||||||
|
bookResults: [],
|
||||||
authorResults: [],
|
authorResults: [],
|
||||||
seriesResults: [],
|
seriesResults: [],
|
||||||
tagResults: [],
|
tagResults: [],
|
||||||
@@ -83,7 +93,7 @@ export default {
|
|||||||
return this.$store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
},
|
},
|
||||||
totalResults() {
|
totalResults() {
|
||||||
return this.audiobookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length
|
return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length + this.podcastResults.length
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -96,7 +106,8 @@ export default {
|
|||||||
clearResults() {
|
clearResults() {
|
||||||
this.search = null
|
this.search = null
|
||||||
this.lastSearch = null
|
this.lastSearch = null
|
||||||
this.audiobookResults = []
|
this.podcastResults = []
|
||||||
|
this.bookResults = []
|
||||||
this.authorResults = []
|
this.authorResults = []
|
||||||
this.seriesResults = []
|
this.seriesResults = []
|
||||||
this.tagResults = []
|
this.tagResults = []
|
||||||
@@ -136,7 +147,8 @@ export default {
|
|||||||
// Search was canceled
|
// Search was canceled
|
||||||
if (!this.isFetching) return
|
if (!this.isFetching) return
|
||||||
|
|
||||||
this.audiobookResults = searchResults.audiobooks || []
|
this.podcastResults = searchResults.podcast || []
|
||||||
|
this.bookResults = searchResults.book || []
|
||||||
this.authorResults = searchResults.authors || []
|
this.authorResults = searchResults.authors || []
|
||||||
this.seriesResults = searchResults.series || []
|
this.seriesResults = searchResults.series || []
|
||||||
this.tagResults = searchResults.tags || []
|
this.tagResults = searchResults.tags || []
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
|
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
|
||||||
<template v-for="item in items">
|
<template v-for="item in selectItems">
|
||||||
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)">
|
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span class="font-normal ml-3 block truncate text-xs">{{ item.text }}</span>
|
<span class="font-normal ml-3 block truncate text-xs">{{ item.text }}</span>
|
||||||
@@ -31,35 +31,61 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
showMenu: false,
|
showMenu: false,
|
||||||
items: [
|
bookItems: [
|
||||||
{
|
{
|
||||||
text: 'Title',
|
text: 'Title',
|
||||||
value: 'book.title'
|
value: 'media.metadata.title'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Author (First Last)',
|
text: 'Author (First Last)',
|
||||||
value: 'book.authorFL'
|
value: 'media.metadata.authorName'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Author (Last, First)',
|
text: 'Author (Last, First)',
|
||||||
value: 'book.authorLF'
|
value: 'media.metadata.authorNameLF'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Added At',
|
text: 'Added At',
|
||||||
value: 'addedAt'
|
value: 'addedAt'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Volume #',
|
text: 'Size',
|
||||||
value: 'book.volumeNumber'
|
value: 'size'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Duration',
|
text: 'Duration',
|
||||||
value: 'duration'
|
value: 'media.duration'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'File Birthtime',
|
||||||
|
value: 'birthtimeMs'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'File Modified',
|
||||||
|
value: 'mtimeMs'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
podcastItems: [
|
||||||
|
{
|
||||||
|
text: 'Title',
|
||||||
|
value: 'media.metadata.title'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Author',
|
||||||
|
value: 'media.metadata.author'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Added At',
|
||||||
|
value: 'addedAt'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Size',
|
text: 'Size',
|
||||||
value: 'size'
|
value: 'size'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
text: '# of Episodes',
|
||||||
|
value: 'media.numTracks'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
text: 'File Birthtime',
|
text: 'File Birthtime',
|
||||||
value: 'birthtimeMs'
|
value: 'birthtimeMs'
|
||||||
@@ -88,9 +114,18 @@ export default {
|
|||||||
this.$emit('update:descending', val)
|
this.$emit('update:descending', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
isPodcast() {
|
||||||
|
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast'
|
||||||
|
},
|
||||||
|
selectItems() {
|
||||||
|
if (this.isPodcast) return this.podcastItems
|
||||||
|
return this.bookItems
|
||||||
|
},
|
||||||
selectedText() {
|
selectedText() {
|
||||||
var _selected = this.selected === 'book.author' ? 'book.authorFL' : this.selected
|
var _selected = this.selected
|
||||||
var _sel = this.items.find((i) => i.value === _selected)
|
if (!_selected) return ''
|
||||||
|
if (this.selected.startsWith('book.')) _selected = _selected.replace('book.', 'media.metadata.')
|
||||||
|
var _sel = this.selectItems.find((i) => i.value === _selected)
|
||||||
if (!_sel) return ''
|
if (!_sel) return ''
|
||||||
return _sel.text
|
return _sel.text
|
||||||
}
|
}
|
||||||
@@ -104,6 +139,9 @@ export default {
|
|||||||
this.selectedDesc = !this.selectedDesc
|
this.selectedDesc = !this.selectedDesc
|
||||||
} else {
|
} else {
|
||||||
this.selected = val
|
this.selected = val
|
||||||
|
if (val == 'media.metadata.title' || val == 'media.metadata.author' || val == 'media.metadata.authorName' || val == 'media.metadata.authorNameLF') {
|
||||||
|
this.selectedDesc = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.showMenu = false
|
this.showMenu = false
|
||||||
this.$nextTick(() => this.$emit('change', val))
|
this.$nextTick(() => this.$emit('change', val))
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="wrapper" :class="`rounded-${rounded}`" class="w-full h-full bg-primary overflow-hidden">
|
||||||
|
<svg v-if="!imagePath" width="140%" height="140%" style="margin-left: -20%; margin-top: -20%; opacity: 0.6" viewBox="0 0 177 266" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill="white" d="M40.7156 165.47C10.2694 150.865 -31.5407 148.629 -38.0532 155.529L63.3191 204.159L76.9443 190.899C66.828 181.394 54.006 171.846 40.7156 165.47Z" stroke="white" stroke-width="4" transform="translate(-2 -1)" />
|
||||||
|
<path d="M-38.0532 155.529C-31.5407 148.629 10.2694 150.865 40.7156 165.47C54.006 171.846 66.828 181.394 76.9443 190.899L95.0391 173.37C80.6681 159.403 64.7526 149.155 51.5747 142.834C21.3549 128.337 -46.2471 114.563 -60.6897 144.67L-71.5489 167.307L44.5864 223.019L63.3191 204.159L-38.0532 155.529Z" fill="white" />
|
||||||
|
<path
|
||||||
|
d="M105.87 29.6508C80.857 17.6515 50.8784 28.1923 38.879 53.2056C26.8797 78.219 37.4205 108.198 62.4338 120.197C87.4472 132.196 117.426 121.656 129.425 96.6422C141.425 71.6288 130.884 41.6502 105.87 29.6508ZM106.789 85.783C112.761 73.3329 107.461 58.2599 95.0112 52.2874C82.5611 46.3148 67.4881 51.6147 61.5156 64.0648C55.543 76.5149 60.8429 91.5879 73.293 97.5604C85.7431 103.533 100.816 98.2331 106.789 85.783Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M151.336 159.01L159.048 166.762L82.7048 242.703L74.973 242.683L74.9934 234.951L151.336 159.01ZM181.725 108.497C179.624 108.491 177.436 109.326 175.835 110.918L160.415 126.257L191.848 157.856L207.268 142.517C210.554 139.248 210.568 133.954 207.299 130.667L187.685 110.95C186.009 109.264 183.91 108.502 181.725 108.497ZM151.399 135.226L58.2034 227.931L58.1203 259.447L89.6359 259.53L182.831 166.825L151.399 135.226Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path d="M151.336 159.01L159.048 166.762L82.7048 242.703L74.973 242.683L74.9934 234.951L151.336 159.01Z" fill="white" stroke="white" stroke-width="10px" />
|
||||||
|
</svg>
|
||||||
|
<div v-else class="w-full h-full relative">
|
||||||
|
<div v-if="showCoverBg" class="cover-bg absolute" :style="{ backgroundImage: `url(${imgSrc})` }" />
|
||||||
|
<img ref="img" :src="imgSrc" @load="imageLoaded" class="absolute top-0 left-0 h-full w-full" :class="coverContain ? 'object-contain' : 'object-cover'" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
author: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
},
|
||||||
|
rounded: {
|
||||||
|
type: String,
|
||||||
|
default: 'lg'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
showCoverBg: false,
|
||||||
|
coverContain: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
userToken() {
|
||||||
|
return this.$store.getters['user/getToken']
|
||||||
|
},
|
||||||
|
_author() {
|
||||||
|
return this.author || {}
|
||||||
|
},
|
||||||
|
authorId() {
|
||||||
|
return this._author.id
|
||||||
|
},
|
||||||
|
imagePath() {
|
||||||
|
return this._author.imagePath
|
||||||
|
},
|
||||||
|
updatedAt() {
|
||||||
|
return this._author.updatedAt
|
||||||
|
},
|
||||||
|
imgSrc() {
|
||||||
|
if (!this.imagePath) return null
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
// Testing
|
||||||
|
return `http://localhost:3333/api/authors/${this.authorId}/image?token=${this.userToken}&ts=${this.updatedAt}`
|
||||||
|
}
|
||||||
|
return `/api/authors/${this.authorId}/image?token=${this.userToken}&ts=${this.updatedAt}`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
imageLoaded() {
|
||||||
|
var aspectRatio = 1.25
|
||||||
|
if (this.$refs.wrapper) {
|
||||||
|
aspectRatio = this.$refs.wrapper.clientHeight / this.$refs.wrapper.clientWidth
|
||||||
|
}
|
||||||
|
if (this.$refs.img) {
|
||||||
|
var { naturalWidth, naturalHeight } = this.$refs.img
|
||||||
|
var imgAr = naturalHeight / naturalWidth
|
||||||
|
var arDiff = Math.abs(imgAr - aspectRatio)
|
||||||
|
if (arDiff > 0.15) {
|
||||||
|
this.showCoverBg = true
|
||||||
|
} else {
|
||||||
|
this.showCoverBg = false
|
||||||
|
this.coverContain = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -5,20 +5,11 @@
|
|||||||
<div class="absolute cover-bg" ref="coverBg" />
|
<div class="absolute cover-bg" ref="coverBg" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<img v-if="audiobook" ref="cover" :src="fullCoverUrl" loading="lazy" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10 duration-300 transition-opacity" :style="{ opacity: imageReady ? '1' : '0' }" :class="showCoverBg ? 'object-contain' : 'object-fill'" />
|
<img v-if="libraryItem" ref="cover" :src="fullCoverUrl" loading="lazy" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10 duration-300 transition-opacity" :style="{ opacity: imageReady ? '1' : '0' }" :class="showCoverBg ? 'object-contain' : 'object-fill'" />
|
||||||
<div v-show="loading && audiobook" class="absolute top-0 left-0 h-full w-full flex items-center justify-center">
|
<div v-show="loading && libraryItem" class="absolute top-0 left-0 h-full w-full flex items-center justify-center">
|
||||||
<p class="font-book text-center" :style="{ fontSize: 0.75 * sizeMultiplier + 'rem' }">{{ title }}</p>
|
<p class="font-book text-center" :style="{ fontSize: 0.75 * sizeMultiplier + 'rem' }">{{ title }}</p>
|
||||||
<div class="absolute top-2 right-2">
|
<div class="absolute top-2 right-2">
|
||||||
<div class="la-ball-spin-clockwise la-sm">
|
<widgets-loading-spinner />
|
||||||
<div></div>
|
|
||||||
<div></div>
|
|
||||||
<div></div>
|
|
||||||
<div></div>
|
|
||||||
<div></div>
|
|
||||||
<div></div>
|
|
||||||
<div></div>
|
|
||||||
<div></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -44,11 +35,10 @@
|
|||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
audiobook: {
|
libraryItem: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => {}
|
default: () => {}
|
||||||
},
|
},
|
||||||
authorOverride: String,
|
|
||||||
width: {
|
width: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 120
|
default: 120
|
||||||
@@ -75,12 +65,15 @@ export default {
|
|||||||
height() {
|
height() {
|
||||||
return this.width * this.bookCoverAspectRatio
|
return this.width * this.bookCoverAspectRatio
|
||||||
},
|
},
|
||||||
book() {
|
media() {
|
||||||
if (!this.audiobook) return {}
|
if (!this.libraryItem) return {}
|
||||||
return this.audiobook.book || {}
|
return this.libraryItem.media || {}
|
||||||
|
},
|
||||||
|
mediaMetadata() {
|
||||||
|
return this.media.metadata || {}
|
||||||
},
|
},
|
||||||
title() {
|
title() {
|
||||||
return this.book.title || 'No Title'
|
return this.mediaMetadata.title || 'No Title'
|
||||||
},
|
},
|
||||||
titleCleaned() {
|
titleCleaned() {
|
||||||
if (this.title.length > 60) {
|
if (this.title.length > 60) {
|
||||||
@@ -88,9 +81,11 @@ export default {
|
|||||||
}
|
}
|
||||||
return this.title
|
return this.title
|
||||||
},
|
},
|
||||||
|
authors() {
|
||||||
|
return this.mediaMetadata.authors || []
|
||||||
|
},
|
||||||
author() {
|
author() {
|
||||||
if (this.authorOverride) return this.authorOverride
|
return this.authors.map((au) => au.name).join(', ')
|
||||||
return this.book.author || 'Unknown'
|
|
||||||
},
|
},
|
||||||
authorCleaned() {
|
authorCleaned() {
|
||||||
if (this.author.length > 30) {
|
if (this.author.length > 30) {
|
||||||
@@ -102,15 +97,15 @@ export default {
|
|||||||
return '/book_placeholder.jpg'
|
return '/book_placeholder.jpg'
|
||||||
},
|
},
|
||||||
fullCoverUrl() {
|
fullCoverUrl() {
|
||||||
if (!this.audiobook) return null
|
if (!this.libraryItem) return null
|
||||||
var store = this.$store || this.$nuxt.$store
|
var store = this.$store || this.$nuxt.$store
|
||||||
return store.getters['audiobooks/getBookCoverSrc'](this.audiobook, this.placeholderUrl)
|
return store.getters['globals/getLibraryItemCoverSrc'](this.libraryItem, this.placeholderUrl)
|
||||||
},
|
},
|
||||||
cover() {
|
cover() {
|
||||||
return this.book.cover || this.placeholderUrl
|
return this.media.coverPath || this.placeholderUrl
|
||||||
},
|
},
|
||||||
hasCover() {
|
hasCover() {
|
||||||
return !!this.book.cover
|
return !!this.media.coverPath
|
||||||
},
|
},
|
||||||
sizeMultiplier() {
|
sizeMultiplier() {
|
||||||
var baseSize = this.squareAspectRatio ? 192 : 120
|
var baseSize = this.squareAspectRatio ? 192 : 120
|
||||||
@@ -138,12 +133,12 @@ export default {
|
|||||||
this.$refs.coverBg.style.backgroundImage = `url("${this.fullCoverUrl}")`
|
this.$refs.coverBg.style.backgroundImage = `url("${this.fullCoverUrl}")`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
hideCoverBg() {},
|
|
||||||
imageLoaded() {
|
imageLoaded() {
|
||||||
this.loading = false
|
this.loading = false
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.imageReady = true
|
this.imageReady = true
|
||||||
})
|
})
|
||||||
|
|
||||||
if (this.$refs.cover && this.cover !== this.placeholderUrl) {
|
if (this.$refs.cover && this.cover !== this.placeholderUrl) {
|
||||||
var { naturalWidth, naturalHeight } = this.$refs.cover
|
var { naturalWidth, naturalHeight } = this.$refs.cover
|
||||||
var aspectRatio = naturalHeight / naturalWidth
|
var aspectRatio = naturalHeight / naturalWidth
|
||||||
@@ -168,214 +163,3 @@ export default {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
|
||||||
/*!
|
|
||||||
* Load Awesome v1.1.0 (http://github.danielcardoso.net/load-awesome/)
|
|
||||||
* Copyright 2015 Daniel Cardoso <@DanielCardoso>
|
|
||||||
* Licensed under MIT
|
|
||||||
*/
|
|
||||||
.la-ball-spin-clockwise,
|
|
||||||
.la-ball-spin-clockwise > div {
|
|
||||||
position: relative;
|
|
||||||
-webkit-box-sizing: border-box;
|
|
||||||
-moz-box-sizing: border-box;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise {
|
|
||||||
display: block;
|
|
||||||
font-size: 0;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise.la-dark {
|
|
||||||
color: #262626;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise > div {
|
|
||||||
display: inline-block;
|
|
||||||
float: none;
|
|
||||||
background-color: currentColor;
|
|
||||||
border: 0 solid currentColor;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise > div {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
margin-top: -4px;
|
|
||||||
margin-left: -4px;
|
|
||||||
border-radius: 100%;
|
|
||||||
-webkit-animation: ball-spin-clockwise 1s infinite ease-in-out;
|
|
||||||
-moz-animation: ball-spin-clockwise 1s infinite ease-in-out;
|
|
||||||
-o-animation: ball-spin-clockwise 1s infinite ease-in-out;
|
|
||||||
animation: ball-spin-clockwise 1s infinite ease-in-out;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise > div:nth-child(1) {
|
|
||||||
top: 5%;
|
|
||||||
left: 50%;
|
|
||||||
-webkit-animation-delay: -0.875s;
|
|
||||||
-moz-animation-delay: -0.875s;
|
|
||||||
-o-animation-delay: -0.875s;
|
|
||||||
animation-delay: -0.875s;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise > div:nth-child(2) {
|
|
||||||
top: 18.1801948466%;
|
|
||||||
left: 81.8198051534%;
|
|
||||||
-webkit-animation-delay: -0.75s;
|
|
||||||
-moz-animation-delay: -0.75s;
|
|
||||||
-o-animation-delay: -0.75s;
|
|
||||||
animation-delay: -0.75s;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise > div:nth-child(3) {
|
|
||||||
top: 50%;
|
|
||||||
left: 95%;
|
|
||||||
-webkit-animation-delay: -0.625s;
|
|
||||||
-moz-animation-delay: -0.625s;
|
|
||||||
-o-animation-delay: -0.625s;
|
|
||||||
animation-delay: -0.625s;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise > div:nth-child(4) {
|
|
||||||
top: 81.8198051534%;
|
|
||||||
left: 81.8198051534%;
|
|
||||||
-webkit-animation-delay: -0.5s;
|
|
||||||
-moz-animation-delay: -0.5s;
|
|
||||||
-o-animation-delay: -0.5s;
|
|
||||||
animation-delay: -0.5s;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise > div:nth-child(5) {
|
|
||||||
top: 94.9999999966%;
|
|
||||||
left: 50.0000000005%;
|
|
||||||
-webkit-animation-delay: -0.375s;
|
|
||||||
-moz-animation-delay: -0.375s;
|
|
||||||
-o-animation-delay: -0.375s;
|
|
||||||
animation-delay: -0.375s;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise > div:nth-child(6) {
|
|
||||||
top: 81.8198046966%;
|
|
||||||
left: 18.1801949248%;
|
|
||||||
-webkit-animation-delay: -0.25s;
|
|
||||||
-moz-animation-delay: -0.25s;
|
|
||||||
-o-animation-delay: -0.25s;
|
|
||||||
animation-delay: -0.25s;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise > div:nth-child(7) {
|
|
||||||
top: 49.9999750815%;
|
|
||||||
left: 5.0000051215%;
|
|
||||||
-webkit-animation-delay: -0.125s;
|
|
||||||
-moz-animation-delay: -0.125s;
|
|
||||||
-o-animation-delay: -0.125s;
|
|
||||||
animation-delay: -0.125s;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise > div:nth-child(8) {
|
|
||||||
top: 18.179464974%;
|
|
||||||
left: 18.1803700518%;
|
|
||||||
-webkit-animation-delay: 0s;
|
|
||||||
-moz-animation-delay: 0s;
|
|
||||||
-o-animation-delay: 0s;
|
|
||||||
animation-delay: 0s;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise.la-sm {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise.la-sm > div {
|
|
||||||
width: 4px;
|
|
||||||
height: 4px;
|
|
||||||
margin-top: -2px;
|
|
||||||
margin-left: -2px;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise.la-2x {
|
|
||||||
width: 64px;
|
|
||||||
height: 64px;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise.la-2x > div {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
margin-top: -8px;
|
|
||||||
margin-left: -8px;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise.la-3x {
|
|
||||||
width: 96px;
|
|
||||||
height: 96px;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise.la-3x > div {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
margin-top: -12px;
|
|
||||||
margin-left: -12px;
|
|
||||||
}
|
|
||||||
/*
|
|
||||||
* Animation
|
|
||||||
*/
|
|
||||||
@-webkit-keyframes ball-spin-clockwise {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
-webkit-transform: scale(1);
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
20% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
80% {
|
|
||||||
opacity: 0;
|
|
||||||
-webkit-transform: scale(0);
|
|
||||||
transform: scale(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@-moz-keyframes ball-spin-clockwise {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
-moz-transform: scale(1);
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
20% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
80% {
|
|
||||||
opacity: 0;
|
|
||||||
-moz-transform: scale(0);
|
|
||||||
transform: scale(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@-o-keyframes ball-spin-clockwise {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
-o-transform: scale(1);
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
20% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
80% {
|
|
||||||
opacity: 0;
|
|
||||||
-o-transform: scale(0);
|
|
||||||
transform: scale(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@keyframes ball-spin-clockwise {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
-webkit-transform: scale(1);
|
|
||||||
-moz-transform: scale(1);
|
|
||||||
-o-transform: scale(1);
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
20% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
80% {
|
|
||||||
opacity: 0;
|
|
||||||
-webkit-transform: scale(0);
|
|
||||||
-moz-transform: scale(0);
|
|
||||||
-o-transform: scale(0);
|
|
||||||
transform: scale(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -13,8 +13,8 @@
|
|||||||
<div v-else-if="books.length" class="flex justify-center h-full relative bg-primary bg-opacity-95 rounded-sm">
|
<div v-else-if="books.length" class="flex justify-center h-full relative bg-primary bg-opacity-95 rounded-sm">
|
||||||
<div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" />
|
<div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" />
|
||||||
|
|
||||||
<covers-book-cover :audiobook="books[0]" :width="width / 2" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-book-cover :library-item="books[0]" :width="width / 2" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
<covers-book-cover v-if="books.length > 1" :audiobook="books[1]" :width="width / 2" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-book-cover v-if="books.length > 1" :library-item="books[1]" :width="width / 2" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="relative w-full h-full flex items-center justify-center p-2 bg-primary rounded-sm">
|
<div v-else class="relative w-full h-full flex items-center justify-center p-2 bg-primary rounded-sm">
|
||||||
<div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" />
|
<div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" />
|
||||||
|
|||||||
@@ -44,6 +44,14 @@ export default {
|
|||||||
this.$nextTick(this.init)
|
this.$nextTick(this.init)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
width: {
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.isInit = false
|
||||||
|
this.$nextTick(this.init)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -51,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
|
||||||
},
|
},
|
||||||
@@ -63,7 +68,7 @@ export default {
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
getCoverUrl(book) {
|
getCoverUrl(book) {
|
||||||
return this.store.getters['audiobooks/getBookCoverSrc'](book, '')
|
return this.store.getters['globals/getLibraryItemCoverSrc'](book, '')
|
||||||
},
|
},
|
||||||
async buildCoverImg(coverData, bgCoverWidth, offsetLeft, zIndex, forceCoverBg = false) {
|
async buildCoverImg(coverData, bgCoverWidth, offsetLeft, zIndex, forceCoverBg = false) {
|
||||||
var src = coverData.coverUrl
|
var src = coverData.coverUrl
|
||||||
@@ -151,7 +156,6 @@ export default {
|
|||||||
.map((bookItem) => {
|
.map((bookItem) => {
|
||||||
return {
|
return {
|
||||||
id: bookItem.id,
|
id: bookItem.id,
|
||||||
volumeNumber: bookItem.book ? bookItem.book.volumeNumber : null,
|
|
||||||
coverUrl: this.getCoverUrl(bookItem)
|
coverUrl: this.getCoverUrl(bookItem)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export default {
|
|||||||
return '/book_placeholder.jpg'
|
return '/book_placeholder.jpg'
|
||||||
},
|
},
|
||||||
fullCoverUrl() {
|
fullCoverUrl() {
|
||||||
return this.$store.getters['audiobooks/getBookCoverSrc'](this.audiobook, this.placeholderUrl)
|
return this.$store.getters['globals/getLibraryItemCoverSrc'](this.audiobook, this.placeholderUrl)
|
||||||
},
|
},
|
||||||
hasCover() {
|
hasCover() {
|
||||||
return !!this.audiobook.book.cover
|
return !!this.audiobook.book.cover
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<modals-modal v-model="show" name="account" :width="800" :height="'unset'" :processing="processing">
|
<modals-modal ref="modal" v-model="show" name="account" :width="800" :height="'unset'" :processing="processing">
|
||||||
<template #outer>
|
<template #outer>
|
||||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
<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>
|
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||||
@@ -8,20 +8,20 @@
|
|||||||
<form @submit.prevent="submitForm">
|
<form @submit.prevent="submitForm">
|
||||||
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
|
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
|
||||||
<div class="w-full p-8">
|
<div class="w-full p-8">
|
||||||
<div class="flex py-2 -mx-2">
|
<div class="flex py-2">
|
||||||
<div class="w-1/2 px-2">
|
<div class="w-1/2 px-2">
|
||||||
<ui-text-input-with-label v-model="newUser.username" label="Username" class="mx-2" />
|
<ui-text-input-with-label v-model="newUser.username" label="Username" />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-1/2 px-2">
|
<div class="w-1/2 px-2">
|
||||||
<ui-text-input-with-label v-if="!isEditingRoot" v-model="newUser.password" :label="isNew ? 'Password' : 'Change Password'" type="password" class="mx-2" />
|
<ui-text-input-with-label v-if="!isEditingRoot" v-model="newUser.password" :label="isNew ? 'Password' : 'Change Password'" type="password" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex py-2">
|
<div v-show="!isEditingRoot" class="flex py-2">
|
||||||
<div class="px-2">
|
<div class="px-2 w-52">
|
||||||
<ui-input-dropdown v-model="newUser.type" label="Account Type" :disabled="isEditingRoot" :editable="false" :items="accountTypes" @input="userTypeUpdated" />
|
<ui-dropdown v-model="newUser.type" label="Account Type" :disabled="isEditingRoot" :items="accountTypes" @input="userTypeUpdated" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<div v-show="!isEditingRoot" class="flex items-center pt-4 px-2">
|
<div class="flex items-center pt-4 px-2">
|
||||||
<p class="px-3 font-semibold" :class="isEditingRoot ? 'text-gray-300' : ''">Is Active</p>
|
<p class="px-3 font-semibold" :class="isEditingRoot ? 'text-gray-300' : ''">Is Active</p>
|
||||||
<ui-toggle-switch v-model="newUser.isActive" :disabled="isEditingRoot" />
|
<ui-toggle-switch v-model="newUser.isActive" :disabled="isEditingRoot" />
|
||||||
</div>
|
</div>
|
||||||
@@ -77,9 +77,22 @@
|
|||||||
<div v-if="!newUser.permissions.accessAllLibraries" class="my-4">
|
<div v-if="!newUser.permissions.accessAllLibraries" class="my-4">
|
||||||
<ui-multi-select-dropdown v-model="newUser.librariesAccessible" :items="libraryItems" label="Libraries Accessible to User" />
|
<ui-multi-select-dropdown v-model="newUser.librariesAccessible" :items="libraryItems" label="Libraries Accessible to User" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-cen~ter my-2 max-w-md">
|
||||||
|
<div class="w-1/2">
|
||||||
|
<p>Can Access All Tags</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-1/2">
|
||||||
|
<ui-toggle-switch v-model="newUser.permissions.accessAllTags" @input="accessAllTagsToggled" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="!newUser.permissions.accessAllTags" class="my-4">
|
||||||
|
<ui-multi-select-dropdown v-model="newUser.itemTagsAccessible" :items="itemTags" label="Tags Accessible to User" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex pt-4">
|
<div class="flex pt-4 px-2">
|
||||||
|
<ui-btn v-if="isEditingRoot" to="/account">Change Root Password</ui-btn>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<ui-btn color="success" type="submit">Submit</ui-btn>
|
<ui-btn color="success" type="submit">Submit</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,12 +116,28 @@ export default {
|
|||||||
processing: false,
|
processing: false,
|
||||||
newUser: {},
|
newUser: {},
|
||||||
isNew: true,
|
isNew: true,
|
||||||
accountTypes: ['guest', 'user', 'admin']
|
accountTypes: [
|
||||||
|
{
|
||||||
|
text: 'Guest',
|
||||||
|
value: 'guest'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'User',
|
||||||
|
value: 'user'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Admin',
|
||||||
|
value: 'admin'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
tags: [],
|
||||||
|
loadingTags: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
show: {
|
show: {
|
||||||
handler(newVal) {
|
handler(newVal) {
|
||||||
|
console.log('accoutn modal show change', newVal)
|
||||||
if (newVal) {
|
if (newVal) {
|
||||||
this.init()
|
this.init()
|
||||||
}
|
}
|
||||||
@@ -125,7 +154,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
title() {
|
title() {
|
||||||
return this.isNew ? 'Add New Account' : `Update Account: ${(this.account || {}).username}`
|
return this.isNew ? 'Add New Account' : `Update ${(this.account || {}).username}`
|
||||||
},
|
},
|
||||||
isEditingRoot() {
|
isEditingRoot() {
|
||||||
return this.account && this.account.type === 'root'
|
return this.account && this.account.type === 'root'
|
||||||
@@ -135,9 +164,39 @@ export default {
|
|||||||
},
|
},
|
||||||
libraryItems() {
|
libraryItems() {
|
||||||
return this.libraries.map((lib) => ({ text: lib.name, value: lib.id }))
|
return this.libraries.map((lib) => ({ text: lib.name, value: lib.id }))
|
||||||
|
},
|
||||||
|
itemTags() {
|
||||||
|
return this.tags.map((t) => {
|
||||||
|
return {
|
||||||
|
text: t,
|
||||||
|
value: t
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
close() {
|
||||||
|
// Force close when navigating - used in UsersTable
|
||||||
|
if (this.$refs.modal) this.$refs.modal.setHide()
|
||||||
|
},
|
||||||
|
accessAllTagsToggled(val) {
|
||||||
|
if (val && this.newUser.itemTagsAccessible.length) {
|
||||||
|
this.newUser.itemTagsAccessible = []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fetchAllTags() {
|
||||||
|
this.loadingTags = true
|
||||||
|
this.$axios
|
||||||
|
.$get(`/api/tags`)
|
||||||
|
.then((tags) => {
|
||||||
|
this.tags = tags
|
||||||
|
this.loadingTags = false
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to load tags', error)
|
||||||
|
this.loadingTags = false
|
||||||
|
})
|
||||||
|
},
|
||||||
accessAllLibrariesToggled(val) {
|
accessAllLibrariesToggled(val) {
|
||||||
if (!val && !this.newUser.librariesAccessible.length) {
|
if (!val && !this.newUser.librariesAccessible.length) {
|
||||||
this.newUser.librariesAccessible = this.libraries.map((l) => l.id)
|
this.newUser.librariesAccessible = this.libraries.map((l) => l.id)
|
||||||
@@ -154,6 +213,10 @@ export default {
|
|||||||
this.$toast.error('Must select at least one library')
|
this.$toast.error('Must select at least one library')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!this.newUser.permissions.accessAllTags && !this.newUser.itemTagsAccessible.length) {
|
||||||
|
this.$toast.error('Must select at least one tag')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (this.isNew) {
|
if (this.isNew) {
|
||||||
this.submitCreateAccount()
|
this.submitCreateAccount()
|
||||||
@@ -223,20 +286,25 @@ export default {
|
|||||||
download: type !== 'guest',
|
download: type !== 'guest',
|
||||||
update: type === 'admin',
|
update: type === 'admin',
|
||||||
delete: type === 'admin',
|
delete: type === 'admin',
|
||||||
upload: type === 'admin'
|
upload: type === 'admin',
|
||||||
|
accessAllLibraries: true,
|
||||||
|
accessAllTags: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
init() {
|
init() {
|
||||||
|
this.fetchAllTags()
|
||||||
|
|
||||||
this.isNew = !this.account
|
this.isNew = !this.account
|
||||||
if (this.account) {
|
if (this.account) {
|
||||||
var librariesAccessible = this.account.librariesAccessible || []
|
console.log(this.account)
|
||||||
this.newUser = {
|
this.newUser = {
|
||||||
username: this.account.username,
|
username: this.account.username,
|
||||||
password: this.account.password,
|
password: this.account.password,
|
||||||
type: this.account.type,
|
type: this.account.type,
|
||||||
isActive: this.account.isActive,
|
isActive: this.account.isActive,
|
||||||
permissions: { ...this.account.permissions },
|
permissions: { ...this.account.permissions },
|
||||||
librariesAccessible: [...librariesAccessible]
|
librariesAccessible: [...(this.account.librariesAccessible || [])],
|
||||||
|
itemTagsAccessible: [...(this.account.itemTagsAccessible || [])]
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.newUser = {
|
this.newUser = {
|
||||||
@@ -249,7 +317,8 @@ export default {
|
|||||||
update: false,
|
update: false,
|
||||||
delete: false,
|
delete: false,
|
||||||
upload: false,
|
upload: false,
|
||||||
accessAllLibraries: true
|
accessAllLibraries: true,
|
||||||
|
accessAllTags: true
|
||||||
},
|
},
|
||||||
librariesAccessible: []
|
librariesAccessible: []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export default {
|
|||||||
type: Number,
|
type: Number,
|
||||||
default: 0
|
default: 0
|
||||||
},
|
},
|
||||||
audiobookId: String
|
libraryItemId: String
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -76,8 +76,15 @@ export default {
|
|||||||
this.showBookmarkTitleInput = true
|
this.showBookmarkTitleInput = true
|
||||||
},
|
},
|
||||||
deleteBookmark(bm) {
|
deleteBookmark(bm) {
|
||||||
var bookmark = { ...bm, audiobookId: this.audiobookId }
|
this.$axios
|
||||||
this.$root.socket.emit('delete_bookmark', bookmark)
|
.$delete(`/api/me/item/${this.libraryItemId}/bookmark/${bm.time}`)
|
||||||
|
.then(() => {
|
||||||
|
this.$toast.success('Bookmark removed')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.$toast.error(`Failed to remove bookmark`)
|
||||||
|
console.error(error)
|
||||||
|
})
|
||||||
this.show = false
|
this.show = false
|
||||||
},
|
},
|
||||||
clickBookmark(bm) {
|
clickBookmark(bm) {
|
||||||
@@ -85,9 +92,15 @@ export default {
|
|||||||
},
|
},
|
||||||
submitUpdateBookmark(updatedBookmark) {
|
submitUpdateBookmark(updatedBookmark) {
|
||||||
var bookmark = { ...updatedBookmark }
|
var bookmark = { ...updatedBookmark }
|
||||||
bookmark.audiobookId = this.audiobookId
|
this.$axios
|
||||||
|
.$patch(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark)
|
||||||
this.$root.socket.emit('update_bookmark', bookmark)
|
.then(() => {
|
||||||
|
this.$toast.success('Bookmark updated')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.$toast.error(`Failed to update bookmark`)
|
||||||
|
console.error(error)
|
||||||
|
})
|
||||||
this.show = false
|
this.show = false
|
||||||
},
|
},
|
||||||
submitCreateBookmark() {
|
submitCreateBookmark() {
|
||||||
@@ -95,11 +108,18 @@ export default {
|
|||||||
this.newBookmarkTitle = this.$formatDate(Date.now(), 'MMM dd, yyyy HH:mm')
|
this.newBookmarkTitle = this.$formatDate(Date.now(), 'MMM dd, yyyy HH:mm')
|
||||||
}
|
}
|
||||||
var bookmark = {
|
var bookmark = {
|
||||||
audiobookId: this.audiobookId,
|
|
||||||
title: this.newBookmarkTitle,
|
title: this.newBookmarkTitle,
|
||||||
time: this.currentTime
|
time: Math.floor(this.currentTime)
|
||||||
}
|
}
|
||||||
this.$root.socket.emit('create_bookmark', bookmark)
|
this.$axios
|
||||||
|
.$post(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark)
|
||||||
|
.then(() => {
|
||||||
|
this.$toast.success('Bookmark added')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.$toast.error(`Failed to create bookmark`)
|
||||||
|
console.error(error)
|
||||||
|
})
|
||||||
|
|
||||||
this.newBookmarkTitle = ''
|
this.newBookmarkTitle = ''
|
||||||
this.showBookmarkTitleInput = false
|
this.showBookmarkTitleInput = false
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
<template>
|
|
||||||
<modals-modal v-model="show" name="edit-library" :width="700" :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 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">
|
|
||||||
<modals-libraries-edit-library v-if="show" :library="library" :processing.sync="processing" @close="show = false" />
|
|
||||||
</div>
|
|
||||||
</modals-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
value: Boolean,
|
|
||||||
library: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
processing: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
show: {
|
|
||||||
get() {
|
|
||||||
return this.value
|
|
||||||
},
|
|
||||||
set(val) {
|
|
||||||
this.$emit('input', val)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
title() {
|
|
||||||
return this.library ? 'Update Library' : 'New Library'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {},
|
|
||||||
mounted() {},
|
|
||||||
beforeDestroy() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -93,12 +93,13 @@ export default {
|
|||||||
this.show = false
|
this.show = false
|
||||||
},
|
},
|
||||||
clickBg(ev) {
|
clickBg(ev) {
|
||||||
|
if (!this.show) return
|
||||||
if (this.preventClickoutside) {
|
if (this.preventClickoutside) {
|
||||||
this.preventClickoutside = false
|
this.preventClickoutside = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (this.processing && this.persistent) return
|
if (this.processing && this.persistent) return
|
||||||
if (ev.srcElement.classList.contains('modal-bg')) {
|
if (ev.srcElement && ev.srcElement.classList.contains('modal-bg')) {
|
||||||
this.show = false
|
this.show = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<div class="w-full overflow-y-auto overflow-x-hidden max-h-96">
|
<div class="w-full overflow-y-auto overflow-x-hidden max-h-96">
|
||||||
<transition-group name="list-complete" tag="div">
|
<transition-group name="list-complete" tag="div">
|
||||||
<template v-for="collection in sortedCollections">
|
<template v-for="collection in sortedCollections">
|
||||||
<modals-collections-user-collection-item :key="collection.id" :collection="collection" class="list-complete-item" @add="addToCollection" @remove="removeFromCollection" @close="show = false" />
|
<modals-collections-user-collection-item :key="collection.id" :collection="collection" :book-cover-aspect-ratio="bookCoverAspectRatio" class="list-complete-item" @add="addToCollection" @remove="removeFromCollection" @close="show = false" />
|
||||||
</template>
|
</template>
|
||||||
</transition-group>
|
</transition-group>
|
||||||
</div>
|
</div>
|
||||||
@@ -50,7 +50,7 @@ export default {
|
|||||||
this.loadCollections()
|
this.loadCollections()
|
||||||
this.newCollectionName = ''
|
this.newCollectionName = ''
|
||||||
} else {
|
} else {
|
||||||
this.$store.commit('setSelectedAudiobook', null)
|
this.$store.commit('setSelectedLibraryItem', null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -65,15 +65,18 @@ export default {
|
|||||||
},
|
},
|
||||||
title() {
|
title() {
|
||||||
if (this.showBatchUserCollectionModal) {
|
if (this.showBatchUserCollectionModal) {
|
||||||
return `${this.selectedBookIds.length} Books Selected`
|
return `${this.selectedBookIds.length} Items Selected`
|
||||||
}
|
}
|
||||||
return this.selectedAudiobook ? this.selectedAudiobook.book.title : ''
|
return this.selectedLibraryItem ? this.selectedLibraryItem.media.metadata.title : ''
|
||||||
},
|
},
|
||||||
selectedAudiobook() {
|
bookCoverAspectRatio() {
|
||||||
return this.$store.state.selectedAudiobook
|
return this.$store.getters['getBookCoverAspectRatio']
|
||||||
},
|
},
|
||||||
selectedAudiobookId() {
|
selectedLibraryItem() {
|
||||||
return this.selectedAudiobook ? this.selectedAudiobook.id : null
|
return this.$store.state.selectedLibraryItem
|
||||||
|
},
|
||||||
|
selectedLibraryItemId() {
|
||||||
|
return this.selectedLibraryItem ? this.selectedLibraryItem.id : null
|
||||||
},
|
},
|
||||||
collections() {
|
collections() {
|
||||||
return this.$store.state.user.collections || []
|
return this.$store.state.user.collections || []
|
||||||
@@ -87,7 +90,7 @@ export default {
|
|||||||
var collectionBookIds = c.books.map((b) => b.id)
|
var collectionBookIds = c.books.map((b) => b.id)
|
||||||
includesBook = !this.selectedBookIds.find((id) => !collectionBookIds.includes(id))
|
includesBook = !this.selectedBookIds.find((id) => !collectionBookIds.includes(id))
|
||||||
} else {
|
} else {
|
||||||
includesBook = !!c.books.find((b) => b.id === this.selectedAudiobookId)
|
includesBook = !!c.books.find((b) => b.id === this.selectedLibraryItemId)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -101,7 +104,7 @@ export default {
|
|||||||
return this.$store.state.globals.showBatchUserCollectionModal
|
return this.$store.state.globals.showBatchUserCollectionModal
|
||||||
},
|
},
|
||||||
selectedBookIds() {
|
selectedBookIds() {
|
||||||
return this.$store.state.selectedAudiobooks || []
|
return this.$store.state.selectedLibraryItems || []
|
||||||
},
|
},
|
||||||
currentLibraryId() {
|
currentLibraryId() {
|
||||||
return this.$store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
@@ -112,7 +115,7 @@ export default {
|
|||||||
this.$store.dispatch('user/loadUserCollections')
|
this.$store.dispatch('user/loadUserCollections')
|
||||||
},
|
},
|
||||||
removeFromCollection(collection) {
|
removeFromCollection(collection) {
|
||||||
if (!this.selectedAudiobookId && !this.selectedBookIds.length) return
|
if (!this.selectedLibraryItemId && !this.selectedBookIds.length) return
|
||||||
this.processing = true
|
this.processing = true
|
||||||
|
|
||||||
if (this.showBatchUserCollectionModal) {
|
if (this.showBatchUserCollectionModal) {
|
||||||
@@ -132,7 +135,7 @@ export default {
|
|||||||
} else {
|
} else {
|
||||||
// Remove single book
|
// Remove single book
|
||||||
this.$axios
|
this.$axios
|
||||||
.$delete(`/api/collections/${collection.id}/book/${this.selectedAudiobookId}`)
|
.$delete(`/api/collections/${collection.id}/book/${this.selectedLibraryItemId}`)
|
||||||
.then((updatedCollection) => {
|
.then((updatedCollection) => {
|
||||||
console.log(`Book removed from collection`, updatedCollection)
|
console.log(`Book removed from collection`, updatedCollection)
|
||||||
this.$toast.success('Book removed from collection')
|
this.$toast.success('Book removed from collection')
|
||||||
@@ -146,7 +149,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
addToCollection(collection) {
|
addToCollection(collection) {
|
||||||
if (!this.selectedAudiobookId && !this.selectedBookIds.length) return
|
if (!this.selectedLibraryItemId && !this.selectedBookIds.length) return
|
||||||
this.processing = true
|
this.processing = true
|
||||||
|
|
||||||
if (this.showBatchUserCollectionModal) {
|
if (this.showBatchUserCollectionModal) {
|
||||||
@@ -164,10 +167,10 @@ export default {
|
|||||||
this.processing = false
|
this.processing = false
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
if (!this.selectedAudiobookId) return
|
if (!this.selectedLibraryItemId) return
|
||||||
|
|
||||||
this.$axios
|
this.$axios
|
||||||
.$post(`/api/collections/${collection.id}/book`, { id: this.selectedAudiobookId })
|
.$post(`/api/collections/${collection.id}/book`, { id: this.selectedLibraryItemId })
|
||||||
.then((updatedCollection) => {
|
.then((updatedCollection) => {
|
||||||
console.log(`Book added to collection`, updatedCollection)
|
console.log(`Book added to collection`, updatedCollection)
|
||||||
this.$toast.success('Book added to collection')
|
this.$toast.success('Book added to collection')
|
||||||
@@ -181,12 +184,12 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
submitCreateCollection() {
|
submitCreateCollection() {
|
||||||
if (!this.newCollectionName || (!this.selectedAudiobookId && !this.selectedBookIds.length)) {
|
if (!this.newCollectionName || (!this.selectedLibraryItemId && !this.selectedBookIds.length)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.processing = true
|
this.processing = true
|
||||||
|
|
||||||
var books = this.showBatchUserCollectionModal ? this.selectedBookIds : [this.selectedAudiobookId]
|
var books = this.showBatchUserCollectionModal ? this.selectedBookIds : [this.selectedLibraryItemId]
|
||||||
var newCollection = {
|
var newCollection = {
|
||||||
books: books,
|
books: books,
|
||||||
libraryId: this.currentLibraryId,
|
libraryId: this.currentLibraryId,
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<template>
|
||||||
|
<modals-modal v-model="show" name="edit-author" :width="800" :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 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 v-if="author" @submit.prevent="submitForm">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="w-40 p-2">
|
||||||
|
<div class="w-full h-45 relative">
|
||||||
|
<covers-author-image :author="author" />
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="w-3/4 p-2">
|
||||||
|
<ui-text-input-with-label v-model="authorCopy.name" :disabled="processing" label="Name" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow p-2">
|
||||||
|
<ui-text-input-with-label v-model="authorCopy.asin" :disabled="processing" label="ASIN" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-2">
|
||||||
|
<ui-textarea-with-label v-model="authorCopy.description" :disabled="processing" label="Description" :rows="8" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex pt-2 px-2">
|
||||||
|
<ui-btn type="button" @click="searchAuthor">Quick Match</ui-btn>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<ui-btn type="submit">Submit</ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</modals-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
// props: {
|
||||||
|
// value: Boolean,
|
||||||
|
// author: {
|
||||||
|
// type: Object,
|
||||||
|
// default: () => {}
|
||||||
|
// }
|
||||||
|
// },
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
authorCopy: {
|
||||||
|
name: '',
|
||||||
|
asin: '',
|
||||||
|
description: ''
|
||||||
|
},
|
||||||
|
processing: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
author: {
|
||||||
|
immediate: true,
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.$store.state.globals.showEditAuthorModal
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$store.commit('globals/setShowEditAuthorModal', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
author() {
|
||||||
|
return this.$store.state.globals.selectedAuthor
|
||||||
|
},
|
||||||
|
authorId() {
|
||||||
|
if (!this.author) return ''
|
||||||
|
return this.author.id
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
return 'Edit Author'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
init() {
|
||||||
|
this.authorCopy.name = this.author.name
|
||||||
|
this.authorCopy.asin = this.author.asin
|
||||||
|
this.authorCopy.description = this.author.description
|
||||||
|
},
|
||||||
|
async submitForm() {
|
||||||
|
var keysToCheck = ['name', 'asin', 'description']
|
||||||
|
var updatePayload = {}
|
||||||
|
keysToCheck.forEach((key) => {
|
||||||
|
if (this.authorCopy[key] !== this.author[key]) {
|
||||||
|
updatePayload[key] = this.authorCopy[key]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (!Object.keys(updatePayload).length) {
|
||||||
|
this.$toast.info('No updates are necessary')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.processing = true
|
||||||
|
var result = await this.$axios.$patch(`/api/authors/${this.authorId}`, updatePayload).catch((error) => {
|
||||||
|
console.error('Failed', error)
|
||||||
|
this.$toast.error('Failed to update author')
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
if (result) {
|
||||||
|
if (result.updated) {
|
||||||
|
this.$toast.success('Author updated')
|
||||||
|
this.show = false
|
||||||
|
} else this.$toast.info('No updates were needed')
|
||||||
|
}
|
||||||
|
this.processing = false
|
||||||
|
},
|
||||||
|
async removeCover() {
|
||||||
|
var updatePayload = {
|
||||||
|
imagePath: null,
|
||||||
|
relImagePath: null
|
||||||
|
}
|
||||||
|
this.processing = true
|
||||||
|
var result = await this.$axios.$patch(`/api/authors/${this.authorId}`, updatePayload).catch((error) => {
|
||||||
|
console.error('Failed', error)
|
||||||
|
this.$toast.error('Failed to remove image')
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
if (result && result.updated) {
|
||||||
|
this.$toast.success('Author image removed')
|
||||||
|
}
|
||||||
|
this.processing = false
|
||||||
|
},
|
||||||
|
async searchAuthor() {
|
||||||
|
if (!this.authorCopy.name && !this.authorCopy.asin) {
|
||||||
|
this.$toast.error('Must enter an author name')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.processing = true
|
||||||
|
|
||||||
|
const payload = {}
|
||||||
|
if (this.authorCopy.asin) payload.asin = this.authorCopy.asin
|
||||||
|
else payload.q = this.authorCopy.name
|
||||||
|
|
||||||
|
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, payload).catch((error) => {
|
||||||
|
console.error('Failed', error)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
if (!response) {
|
||||||
|
this.$toast.error('Author not found')
|
||||||
|
} else if (response.updated) {
|
||||||
|
if (response.author.imagePath) this.$toast.success('Author was updated')
|
||||||
|
else this.$toast.success('Author was updated (no image found)')
|
||||||
|
} else {
|
||||||
|
this.$toast.info('No updates were made for Author')
|
||||||
|
}
|
||||||
|
this.processing = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {},
|
||||||
|
beforeDestroy() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<!-- <span class="material-icons" :class="highlight ? 'text-success' : 'text-white text-opacity-80'">{{ highlight ? 'bookmark' : 'bookmark_border' }}</span> -->
|
<!-- <span class="material-icons" :class="highlight ? 'text-success' : 'text-white text-opacity-80'">{{ highlight ? 'bookmark' : 'bookmark_border' }}</span> -->
|
||||||
<div class="w-20 max-w-20 text-center">
|
<div class="w-20 max-w-20 text-center">
|
||||||
<!-- <img src="/Logo.png" /> -->
|
<!-- <img src="/Logo.png" /> -->
|
||||||
<covers-collection-cover :book-items="books" :width="80" :height="40 * 1.6" />
|
<covers-collection-cover :book-items="books" :width="80" :height="40 * bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow overflow-hidden px-2">
|
<div class="flex-grow overflow-hidden px-2">
|
||||||
<!-- <template v-if="isEditing">
|
<!-- <template v-if="isEditing">
|
||||||
@@ -38,7 +38,8 @@ export default {
|
|||||||
type: Object,
|
type: Object,
|
||||||
default: () => {}
|
default: () => {}
|
||||||
},
|
},
|
||||||
highlight: Boolean
|
highlight: Boolean,
|
||||||
|
bookCoverAspectRatio: Number
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,201 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="w-full h-full overflow-hidden px-4 py-6 relative">
|
|
||||||
<template v-for="(authorName, index) in searchAuthors">
|
|
||||||
<cards-search-author-card :key="index" :author-name="authorName" @match="setSelectedMatch" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div v-show="processing" class="flex h-full items-center justify-center">
|
|
||||||
<p>Loading...</p>
|
|
||||||
</div>
|
|
||||||
<div v-if="selectedMatch" class="absolute top-0 left-0 w-full bg-bg h-full p-8 max-h-full overflow-y-auto overflow-x-hidden">
|
|
||||||
<div class="flex mb-2">
|
|
||||||
<div class="w-8 h-8 rounded-full hover:bg-white hover:bg-opacity-10 flex items-center justify-center cursor-pointer" @click="selectedMatch = null">
|
|
||||||
<span class="material-icons text-3xl">arrow_back</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-xl pl-3">Update Author Details</p>
|
|
||||||
</div>
|
|
||||||
<form @submit.prevent="submitMatchUpdate">
|
|
||||||
<div v-if="selectedMatch.image" class="flex items-center py-2">
|
|
||||||
<ui-checkbox v-model="selectedMatchUsage.image" />
|
|
||||||
<img :src="selectedMatch.image" class="w-24 object-contain ml-4" />
|
|
||||||
<ui-text-input-with-label v-model="selectedMatch.image" :disabled="!selectedMatchUsage.image" label="Image" class="flex-grow ml-4" />
|
|
||||||
</div>
|
|
||||||
<div v-if="selectedMatch.name" class="flex items-center py-2">
|
|
||||||
<ui-checkbox v-model="selectedMatchUsage.name" />
|
|
||||||
<ui-text-input-with-label v-model="selectedMatch.name" :disabled="!selectedMatchUsage.name" label="Name" class="flex-grow ml-4" />
|
|
||||||
</div>
|
|
||||||
<div v-if="selectedMatch.description" class="flex items-center py-2">
|
|
||||||
<ui-checkbox v-model="selectedMatchUsage.description" />
|
|
||||||
<ui-textarea-with-label v-model="selectedMatch.description" :rows="3" :disabled="!selectedMatchUsage.description" label="Description" class="flex-grow ml-4" />
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-end py-2">
|
|
||||||
<ui-btn color="success" type="submit">Update</ui-btn>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
processing: Boolean,
|
|
||||||
audiobook: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
searchAuthors: [],
|
|
||||||
audiobookId: null,
|
|
||||||
searchAuthor: null,
|
|
||||||
lastSearch: null,
|
|
||||||
hasSearched: false,
|
|
||||||
selectedMatch: null,
|
|
||||||
|
|
||||||
selectedMatchUsage: {
|
|
||||||
image: true,
|
|
||||||
name: true,
|
|
||||||
description: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
audiobook: {
|
|
||||||
immediate: true,
|
|
||||||
handler(newVal) {
|
|
||||||
if (newVal) this.init()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
isProcessing: {
|
|
||||||
get() {
|
|
||||||
return this.processing
|
|
||||||
},
|
|
||||||
set(val) {
|
|
||||||
this.$emit('update:processing', val)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
// getSearchQuery() {
|
|
||||||
// return `q=${this.searchAuthor}`
|
|
||||||
// },
|
|
||||||
// submitSearch() {
|
|
||||||
// if (!this.searchTitle) {
|
|
||||||
// this.$toast.warning('Search title is required')
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
// this.runSearch()
|
|
||||||
// },
|
|
||||||
// async runSearch() {
|
|
||||||
// var searchQuery = this.getSearchQuery()
|
|
||||||
// if (this.lastSearch === searchQuery) return
|
|
||||||
// this.selectedMatch = null
|
|
||||||
// this.isProcessing = true
|
|
||||||
// this.lastSearch = searchQuery
|
|
||||||
// var result = await this.$axios.$get(`/api/authors/search?${searchQuery}`).catch((error) => {
|
|
||||||
// console.error('Failed', error)
|
|
||||||
// return []
|
|
||||||
// })
|
|
||||||
// if (result) {
|
|
||||||
// this.selectedMatch = result
|
|
||||||
// }
|
|
||||||
// this.isProcessing = false
|
|
||||||
// this.hasSearched = true
|
|
||||||
// },
|
|
||||||
init() {
|
|
||||||
this.selectedMatch = null
|
|
||||||
// this.selectedMatchUsage = {
|
|
||||||
// title: true,
|
|
||||||
// subtitle: true,
|
|
||||||
// cover: true,
|
|
||||||
// author: true,
|
|
||||||
// description: true,
|
|
||||||
// isbn: true,
|
|
||||||
// publisher: true,
|
|
||||||
// publishYear: true
|
|
||||||
// }
|
|
||||||
|
|
||||||
if (this.audiobook.id !== this.audiobookId) {
|
|
||||||
this.selectedMatch = null
|
|
||||||
this.hasSearched = false
|
|
||||||
this.audiobookId = this.audiobook.id
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.audiobook.book || !this.audiobook.book.authorFL) {
|
|
||||||
this.searchAuthors = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.searchAuthors = (this.audiobook.book.authorFL || '').split(', ')
|
|
||||||
},
|
|
||||||
selectMatch(match) {
|
|
||||||
this.selectedMatch = match
|
|
||||||
},
|
|
||||||
buildMatchUpdatePayload() {
|
|
||||||
var updatePayload = {}
|
|
||||||
for (const key in this.selectedMatchUsage) {
|
|
||||||
if (this.selectedMatchUsage[key] && this.selectedMatch[key]) {
|
|
||||||
updatePayload[key] = this.selectedMatch[key]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return updatePayload
|
|
||||||
},
|
|
||||||
async submitMatchUpdate() {
|
|
||||||
var updatePayload = this.buildMatchUpdatePayload()
|
|
||||||
if (!Object.keys(updatePayload).length) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.isProcessing = true
|
|
||||||
|
|
||||||
if (updatePayload.cover) {
|
|
||||||
var coverPayload = {
|
|
||||||
url: updatePayload.cover
|
|
||||||
}
|
|
||||||
var success = await this.$axios.$post(`/api/books/${this.audiobook.id}/cover`, coverPayload).catch((error) => {
|
|
||||||
console.error('Failed to update', error)
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
if (success) {
|
|
||||||
this.$toast.success('Book Cover Updated')
|
|
||||||
} else {
|
|
||||||
this.$toast.error('Book Cover Failed to Update')
|
|
||||||
}
|
|
||||||
console.log('Updated cover')
|
|
||||||
delete updatePayload.cover
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(updatePayload).length) {
|
|
||||||
var bookUpdatePayload = {
|
|
||||||
book: updatePayload
|
|
||||||
}
|
|
||||||
var success = await this.$axios.$patch(`/api/books/${this.audiobook.id}`, bookUpdatePayload).catch((error) => {
|
|
||||||
console.error('Failed to update', error)
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
if (success) {
|
|
||||||
this.$toast.success('Book Details Updated')
|
|
||||||
this.selectedMatch = null
|
|
||||||
this.$emit('selectTab', 'details')
|
|
||||||
} else {
|
|
||||||
this.$toast.error('Book Details Failed to Update')
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.selectedMatch = null
|
|
||||||
}
|
|
||||||
this.isProcessing = false
|
|
||||||
},
|
|
||||||
setSelectedMatch(authorMatchObj) {
|
|
||||||
this.selectedMatch = authorMatchObj
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.matchListWrapper {
|
|
||||||
height: calc(100% - 80px);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
|
||||||
<div v-if="!chapters.length" class="flex my-4 text-center justify-center text-xl">No Chapters</div>
|
|
||||||
<table v-else class="text-sm tracksTable">
|
|
||||||
<tr class="font-book">
|
|
||||||
<th class="text-left w-16"><span class="px-4">Id</span></th>
|
|
||||||
<th class="text-left">Title</th>
|
|
||||||
<th class="text-center">Start</th>
|
|
||||||
<th class="text-center">End</th>
|
|
||||||
</tr>
|
|
||||||
<template v-for="chapter in chapters">
|
|
||||||
<tr :key="chapter.id">
|
|
||||||
<td class="text-left">
|
|
||||||
<p class="px-4">{{ chapter.id }}</p>
|
|
||||||
</td>
|
|
||||||
<td class="font-book">
|
|
||||||
{{ chapter.title }}
|
|
||||||
</td>
|
|
||||||
<td class="font-mono text-center">
|
|
||||||
{{ $secondsToTimestamp(chapter.start) }}
|
|
||||||
</td>
|
|
||||||
<td class="font-mono text-center">
|
|
||||||
{{ $secondsToTimestamp(chapter.end) }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
audiobook: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
chapters: []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
audiobook: {
|
|
||||||
immediate: true,
|
|
||||||
handler(newVal) {
|
|
||||||
if (newVal) this.init()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {},
|
|
||||||
methods: {
|
|
||||||
init() {
|
|
||||||
this.chapters = this.audiobook.chapters || []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,342 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="w-full h-full relative">
|
|
||||||
<form class="w-full h-full" @submit.prevent="submitForm">
|
|
||||||
<div ref="formWrapper" class="px-4 py-6 details-form-wrapper w-full overflow-hidden overflow-y-auto">
|
|
||||||
<div class="flex -mx-1">
|
|
||||||
<div class="w-1/2 px-1">
|
|
||||||
<ui-text-input-with-label v-model="details.title" label="Title" />
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow px-1">
|
|
||||||
<ui-text-input-with-label v-model="details.subtitle" label="Subtitle" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex mt-2 -mx-1">
|
|
||||||
<div class="w-3/4 px-1">
|
|
||||||
<ui-text-input-with-label v-model="details.author" label="Author" />
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow px-1">
|
|
||||||
<ui-text-input-with-label v-model="details.publishYear" type="number" label="Publish Year" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex mt-2 -mx-1">
|
|
||||||
<div class="w-3/4 px-1">
|
|
||||||
<ui-input-dropdown ref="seriesDropdown" v-model="details.series" label="Series" :items="series" />
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow px-1">
|
|
||||||
<ui-text-input-with-label v-model="details.volumeNumber" label="Volume #" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ui-textarea-with-label v-model="details.description" :rows="3" label="Description" class="mt-2" />
|
|
||||||
|
|
||||||
<div class="flex mt-2 -mx-1">
|
|
||||||
<div class="w-1/2 px-1">
|
|
||||||
<ui-multi-select ref="genresSelect" v-model="details.genres" label="Genres" :items="genres" />
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow px-1">
|
|
||||||
<ui-multi-select ref="tagsSelect" v-model="newTags" label="Tags" :items="tags" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex mt-2 -mx-1">
|
|
||||||
<div class="w-1/3 px-1">
|
|
||||||
<ui-text-input-with-label v-model="details.narrator" label="Narrator" />
|
|
||||||
</div>
|
|
||||||
<div class="w-1/3 px-1">
|
|
||||||
<ui-text-input-with-label v-model="details.publisher" label="Publisher" />
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow px-1">
|
|
||||||
<ui-text-input-with-label v-model="details.language" label="Language" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex mt-2 -mx-1">
|
|
||||||
<div class="w-1/3 px-1">
|
|
||||||
<ui-text-input-with-label v-model="details.isbn" label="ISBN" />
|
|
||||||
</div>
|
|
||||||
<div class="w-1/3 px-1">
|
|
||||||
<ui-text-input-with-label v-model="details.asin" label="ASIN" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="absolute bottom-0 left-0 w-full py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'box-shadow-sm-up border-t border-primary border-opacity-50'">
|
|
||||||
<div class="flex items-center px-4">
|
|
||||||
<ui-btn v-if="userCanDelete" color="error" type="button" class="h-8" :padding-x="3" small @click.stop.prevent="deleteAudiobook">Remove</ui-btn>
|
|
||||||
|
|
||||||
<div class="flex-grow" />
|
|
||||||
|
|
||||||
<ui-tooltip v-if="!isMissing" text="(Root User Only) Save a NFO metadata file in your audiobooks directory" direction="bottom" class="mr-4 hidden sm:block">
|
|
||||||
<ui-btn v-if="isRootUser" :loading="savingMetadata" color="bg" type="button" class="h-full" small @click.stop.prevent="saveMetadata">Save Metadata</ui-btn>
|
|
||||||
</ui-tooltip>
|
|
||||||
|
|
||||||
<ui-tooltip :disabled="!!quickMatching" :text="`(Root User Only) Populate empty book details & cover with first book result from '${libraryProvider}'. Does not overwrite details.`" direction="bottom" class="mr-4">
|
|
||||||
<ui-btn v-if="isRootUser" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">Quick Match</ui-btn>
|
|
||||||
</ui-tooltip>
|
|
||||||
|
|
||||||
<ui-tooltip :disabled="!!libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="mr-4">
|
|
||||||
<ui-btn v-if="isRootUser" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn>
|
|
||||||
</ui-tooltip>
|
|
||||||
|
|
||||||
<ui-btn type="submit">Submit</ui-btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
processing: Boolean,
|
|
||||||
audiobook: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
details: {
|
|
||||||
title: null,
|
|
||||||
subtitle: null,
|
|
||||||
description: null,
|
|
||||||
author: null,
|
|
||||||
narrator: null,
|
|
||||||
series: null,
|
|
||||||
volumeNumber: null,
|
|
||||||
publishYear: null,
|
|
||||||
publisher: null,
|
|
||||||
language: null,
|
|
||||||
isbn: null,
|
|
||||||
asin: null,
|
|
||||||
genres: []
|
|
||||||
},
|
|
||||||
newTags: [],
|
|
||||||
resettingProgress: false,
|
|
||||||
isScrollable: false,
|
|
||||||
savingMetadata: false,
|
|
||||||
rescanning: false,
|
|
||||||
quickMatching: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
audiobook: {
|
|
||||||
immediate: true,
|
|
||||||
handler(newVal) {
|
|
||||||
if (newVal) this.init()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
isProcessing: {
|
|
||||||
get() {
|
|
||||||
return this.processing
|
|
||||||
},
|
|
||||||
set(val) {
|
|
||||||
this.$emit('update:processing', val)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
isRootUser() {
|
|
||||||
return this.$store.getters['user/getIsRoot']
|
|
||||||
},
|
|
||||||
isMissing() {
|
|
||||||
return !!this.audiobook && !!this.audiobook.isMissing
|
|
||||||
},
|
|
||||||
audiobookId() {
|
|
||||||
return this.audiobook ? this.audiobook.id : null
|
|
||||||
},
|
|
||||||
book() {
|
|
||||||
return this.audiobook ? this.audiobook.book || {} : {}
|
|
||||||
},
|
|
||||||
userCanDelete() {
|
|
||||||
return this.$store.getters['user/getUserCanDelete']
|
|
||||||
},
|
|
||||||
genres() {
|
|
||||||
return this.filterData.genres || []
|
|
||||||
},
|
|
||||||
tags() {
|
|
||||||
return this.filterData.tags || []
|
|
||||||
},
|
|
||||||
series() {
|
|
||||||
return this.filterData.series || []
|
|
||||||
},
|
|
||||||
filterData() {
|
|
||||||
return this.$store.state.libraries.filterData || {}
|
|
||||||
},
|
|
||||||
libraryId() {
|
|
||||||
return this.audiobook ? this.audiobook.libraryId : null
|
|
||||||
},
|
|
||||||
libraryProvider() {
|
|
||||||
return this.$store.getters['libraries/getLibraryProvider'](this.libraryId) || 'google'
|
|
||||||
},
|
|
||||||
libraryScan() {
|
|
||||||
if (!this.libraryId) return null
|
|
||||||
return this.$store.getters['scanners/getLibraryScan'](this.libraryId)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
quickMatch() {
|
|
||||||
this.quickMatching = true
|
|
||||||
var matchOptions = {
|
|
||||||
provider: this.libraryProvider,
|
|
||||||
title: this.details.title,
|
|
||||||
author: this.details.author !== this.book.author ? this.details.author : null
|
|
||||||
}
|
|
||||||
this.$axios
|
|
||||||
.$post(`/api/books/${this.audiobookId}/match`, matchOptions)
|
|
||||||
.then((res) => {
|
|
||||||
this.quickMatching = false
|
|
||||||
if (res.warning) {
|
|
||||||
this.$toast.warning(res.warning)
|
|
||||||
} else if (res.updated) {
|
|
||||||
this.$toast.success('Audiobook details updated')
|
|
||||||
} else {
|
|
||||||
this.$toast.info('No updates were made')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
var errMsg = error.response ? error.response.data || '' : ''
|
|
||||||
console.error('Failed to match', error)
|
|
||||||
this.$toast.error(errMsg || 'Failed to match')
|
|
||||||
this.quickMatching = false
|
|
||||||
})
|
|
||||||
},
|
|
||||||
audiobookScanComplete(result) {
|
|
||||||
this.rescanning = false
|
|
||||||
if (!result) {
|
|
||||||
this.$toast.error(`Re-Scan Failed for "${this.title}"`)
|
|
||||||
} else if (result === 'UPDATED') {
|
|
||||||
this.$toast.success(`Re-Scan complete audiobook was updated`)
|
|
||||||
} else if (result === 'UPTODATE') {
|
|
||||||
this.$toast.success(`Re-Scan complete audiobook was up to date`)
|
|
||||||
} else if (result === 'REMOVED') {
|
|
||||||
this.$toast.error(`Re-Scan complete audiobook was removed`)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
rescan() {
|
|
||||||
this.rescanning = true
|
|
||||||
this.$root.socket.once('audiobook_scan_complete', this.audiobookScanComplete)
|
|
||||||
this.$root.socket.emit('scan_audiobook', this.audiobookId)
|
|
||||||
},
|
|
||||||
saveMetadataComplete(result) {
|
|
||||||
this.savingMetadata = false
|
|
||||||
if (result.error) {
|
|
||||||
this.$toast.error(result.error)
|
|
||||||
} else if (result.audiobookId) {
|
|
||||||
var { savedPath } = result
|
|
||||||
if (!savedPath) {
|
|
||||||
this.$toast.error(`Failed to save metadata file (${result.audiobookId})`)
|
|
||||||
} else {
|
|
||||||
this.$toast.success(`Metadata file saved "${result.audiobookTitle}"`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
saveMetadata() {
|
|
||||||
this.savingMetadata = true
|
|
||||||
this.$root.socket.once('save_metadata_complete', this.saveMetadataComplete)
|
|
||||||
this.$root.socket.emit('save_metadata', this.audiobookId)
|
|
||||||
},
|
|
||||||
submitForm() {
|
|
||||||
if (this.isProcessing) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.isProcessing = true
|
|
||||||
if (this.$refs.seriesDropdown && this.$refs.seriesDropdown.isFocused) {
|
|
||||||
this.$refs.seriesDropdown.blur()
|
|
||||||
}
|
|
||||||
if (this.$refs.genresSelect && this.$refs.genresSelect.isFocused) {
|
|
||||||
this.$refs.genresSelect.forceBlur()
|
|
||||||
}
|
|
||||||
if (this.$refs.tagsSelect && this.$refs.tagsSelect.isFocused) {
|
|
||||||
this.$refs.tagsSelect.forceBlur()
|
|
||||||
}
|
|
||||||
this.$nextTick(this.handleForm)
|
|
||||||
},
|
|
||||||
async handleForm() {
|
|
||||||
const updatePayload = {
|
|
||||||
book: this.details,
|
|
||||||
tags: this.newTags
|
|
||||||
}
|
|
||||||
|
|
||||||
var updatedAudiobook = await this.$axios.$patch(`/api/books/${this.audiobook.id}`, updatePayload).catch((error) => {
|
|
||||||
console.error('Failed to update', error)
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
this.isProcessing = false
|
|
||||||
if (updatedAudiobook) {
|
|
||||||
this.$toast.success('Update Successful')
|
|
||||||
this.$emit('close')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
init() {
|
|
||||||
this.details.title = this.book.title
|
|
||||||
this.details.subtitle = this.book.subtitle
|
|
||||||
this.details.description = this.book.description
|
|
||||||
this.details.author = this.book.author
|
|
||||||
this.details.narrator = this.book.narrator
|
|
||||||
this.details.genres = this.book.genres || []
|
|
||||||
this.details.series = this.book.series
|
|
||||||
this.details.volumeNumber = this.book.volumeNumber
|
|
||||||
this.details.publishYear = this.book.publishYear
|
|
||||||
this.details.publisher = this.book.publisher || null
|
|
||||||
this.details.language = this.book.language || null
|
|
||||||
this.details.isbn = this.book.isbn || null
|
|
||||||
this.details.asin = this.book.asin || null
|
|
||||||
|
|
||||||
this.newTags = this.audiobook.tags || []
|
|
||||||
},
|
|
||||||
deleteAudiobook() {
|
|
||||||
if (confirm(`Are you sure you want to remove this audiobook?\n\n*Does not delete your files, only removes the audiobook from AudioBookshelf`)) {
|
|
||||||
this.isProcessing = true
|
|
||||||
this.$axios
|
|
||||||
.$delete(`/api/books/${this.audiobookId}`)
|
|
||||||
.then(() => {
|
|
||||||
console.log('Audiobook removed')
|
|
||||||
this.$toast.success('Audiobook Removed')
|
|
||||||
this.$emit('close')
|
|
||||||
this.isProcessing = false
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Remove Audiobook failed', error)
|
|
||||||
this.isProcessing = false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
checkIsScrollable() {
|
|
||||||
this.$nextTick(() => {
|
|
||||||
if (this.$refs.formWrapper) {
|
|
||||||
if (this.$refs.formWrapper.scrollHeight > this.$refs.formWrapper.clientHeight) {
|
|
||||||
this.isScrollable = true
|
|
||||||
} else {
|
|
||||||
this.isScrollable = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
setResizeObserver() {
|
|
||||||
try {
|
|
||||||
this.$nextTick(() => {
|
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
|
||||||
this.checkIsScrollable()
|
|
||||||
})
|
|
||||||
resizeObserver.observe(this.$refs.formWrapper)
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to set resize observer')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
this.setResizeObserver()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.details-form-wrapper {
|
|
||||||
height: calc(100% - 70px);
|
|
||||||
max-height: calc(100% - 70px);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
|
|
||||||
<p class="text-center text-lg mb-4 py-8">Preparing downloads can take several minutes and will be stored in <span class="bg-primary bg-opacity-75 font-mono p-1 text-base">/metadata/downloads</span>. After the download is ready, it will remain available for 60 minutes, then be deleted.<br />Download will timeout after 15 minutes.</p>
|
|
||||||
<div v-if="showM4bDownload" class="w-full border border-black-200 p-4 my-4">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div>
|
|
||||||
<p class="text-lg">M4B Audiobook File <span class="text-error">*</span></p>
|
|
||||||
<p class="max-w-xs text-sm pt-2 text-gray-300">Generate a .M4B audiobook file with embedded cover image and chapters.</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow" />
|
|
||||||
<div>
|
|
||||||
<p v-if="singleDownloadStatus === $constants.DownloadStatus.FAILED" class="text-error mb-2">Download Failed</p>
|
|
||||||
<p v-if="singleDownloadStatus === $constants.DownloadStatus.READY" class="text-success mb-2">Download Ready!</p>
|
|
||||||
<p v-if="singleDownloadStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
|
|
||||||
|
|
||||||
<ui-btn v-if="singleDownloadStatus !== $constants.DownloadStatus.READY" :loading="singleDownloadStatus === $constants.DownloadStatus.PENDING" :disabled="tempDisable" @click="startSingleAudioDownload">Start Download</ui-btn>
|
|
||||||
<div v-else>
|
|
||||||
<ui-btn @click="downloadWithProgress(singleAudioDownload)">Download</ui-btn>
|
|
||||||
<p class="px-0.5 py-1 text-sm font-mono text-center">Size: {{ $bytesPretty(singleAudioDownload.size) }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="w-full border border-black-200 p-4 my-4">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div>
|
|
||||||
<p v-if="totalFiles > 1" class="text-lg">Zip {{ totalFiles }} Files</p>
|
|
||||||
<p v-else>Zip 1 File</p>
|
|
||||||
<p class="max-w-xs text-sm pt-2 text-gray-300">Generate a .ZIP file from the contents of the audiobook directory.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-grow" />
|
|
||||||
<div>
|
|
||||||
<p v-if="zipDownloadStatus === $constants.DownloadStatus.FAILED" class="text-error mb-2">Download Failed</p>
|
|
||||||
<p v-if="zipDownloadStatus === $constants.DownloadStatus.READY" class="text-success mb-2">Download Ready!</p>
|
|
||||||
<p v-if="zipDownloadStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
|
|
||||||
|
|
||||||
<ui-btn v-if="zipDownloadStatus !== $constants.DownloadStatus.READY" :loading="zipDownloadStatus === $constants.DownloadStatus.PENDING" :disabled="tempDisable" @click="startZipDownload">Start Download</ui-btn>
|
|
||||||
<div v-else>
|
|
||||||
<ui-btn @click="downloadWithProgress(zipDownload)">Download</ui-btn>
|
|
||||||
<p class="px-0.5 py-1 text-sm font-mono text-center">Size: {{ $bytesPretty(zipDownload.size) }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="showM4bDownload" class="w-full flex items-center justify-center absolute bottom-4 left-0 right-0 text-center">
|
|
||||||
<p class="text-error text-lg">* <strong>Experimental:</strong> Merging multiple .m4b files may have issues. <a href="https://github.com/advplyr/audiobookshelf/issues" class="underline text-blue-600" target="_blank">Report issues here.</a></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="isDownloading" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
|
||||||
<div class="w-80 border border-black-400 bg-bg rounded-xl h-20">
|
|
||||||
<div class="w-full h-full flex items-center justify-center">
|
|
||||||
<p class="text-lg">Download.... {{ downloadPercent }}%</p>
|
|
||||||
<p class="w-24 font-mono pl-8 text-right">
|
|
||||||
{{ downloadAmount }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
processing: Boolean,
|
|
||||||
audiobook: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
tempDisable: false,
|
|
||||||
isDownloading: false,
|
|
||||||
downloadPercent: '0',
|
|
||||||
downloadAmount: '0 KB'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
singleDownloadStatus(newVal) {
|
|
||||||
if (newVal) {
|
|
||||||
this.tempDisable = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
audiobookId() {
|
|
||||||
return this.audiobook ? this.audiobook.id : null
|
|
||||||
},
|
|
||||||
_audiobook() {
|
|
||||||
return this.audiobook || {}
|
|
||||||
},
|
|
||||||
downloads() {
|
|
||||||
return this.$store.getters['downloads/getDownloads'](this.audiobookId)
|
|
||||||
},
|
|
||||||
singleAudioDownload() {
|
|
||||||
return this.downloads.find((d) => d.type === 'singleAudio')
|
|
||||||
},
|
|
||||||
singleDownloadStatus() {
|
|
||||||
return this.singleAudioDownload ? this.singleAudioDownload.status : false
|
|
||||||
},
|
|
||||||
zipDownload() {
|
|
||||||
return this.downloads.find((d) => d.type === 'zip')
|
|
||||||
},
|
|
||||||
zipDownloadStatus() {
|
|
||||||
return this.zipDownload ? this.zipDownload.status : false
|
|
||||||
},
|
|
||||||
isSingleTrack() {
|
|
||||||
if (!this.audiobook.tracks) return false
|
|
||||||
return this.audiobook.tracks.length === 1
|
|
||||||
},
|
|
||||||
singleTrackPath() {
|
|
||||||
if (!this.isSingleTrack) return null
|
|
||||||
return this.audiobook.tracks[0].path
|
|
||||||
},
|
|
||||||
audioFiles() {
|
|
||||||
return this.audiobook ? this.audiobook.audioFiles || [] : []
|
|
||||||
},
|
|
||||||
otherFiles() {
|
|
||||||
return this.audiobook ? this.audiobook.otherFiles || [] : []
|
|
||||||
},
|
|
||||||
totalFiles() {
|
|
||||||
return this.audioFiles.length + this.otherFiles.length
|
|
||||||
},
|
|
||||||
showM4bDownload() {
|
|
||||||
return !this._audiobook.isMissing && !this._audiobook.isInvalid && this._audiobook.tracks.length
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
startZipDownload() {
|
|
||||||
// console.log('Download request received', this.audiobook)
|
|
||||||
|
|
||||||
this.tempDisable = true
|
|
||||||
setTimeout(() => {
|
|
||||||
this.tempDisable = false
|
|
||||||
}, 1000)
|
|
||||||
|
|
||||||
var downloadPayload = {
|
|
||||||
audiobookId: this.audiobook.id,
|
|
||||||
type: 'zip'
|
|
||||||
}
|
|
||||||
this.$root.socket.emit('download', downloadPayload)
|
|
||||||
},
|
|
||||||
startSingleAudioDownload() {
|
|
||||||
// console.log('Download request received', this.audiobook)
|
|
||||||
|
|
||||||
this.tempDisable = true
|
|
||||||
setTimeout(() => {
|
|
||||||
this.tempDisable = false
|
|
||||||
}, 1000)
|
|
||||||
|
|
||||||
var downloadPayload = {
|
|
||||||
audiobookId: this.audiobook.id,
|
|
||||||
type: 'singleAudio',
|
|
||||||
includeMetadata: true,
|
|
||||||
includeCover: true
|
|
||||||
}
|
|
||||||
this.$root.socket.emit('download', downloadPayload)
|
|
||||||
},
|
|
||||||
downloadWithProgress(download) {
|
|
||||||
var downloadId = download.id
|
|
||||||
var downloadUrl = `${process.env.serverUrl}/api/download/${downloadId}`
|
|
||||||
var filename = download.filename
|
|
||||||
|
|
||||||
this.isDownloading = true
|
|
||||||
|
|
||||||
var request = new XMLHttpRequest()
|
|
||||||
request.responseType = 'blob'
|
|
||||||
request.open('get', downloadUrl, true)
|
|
||||||
request.setRequestHeader('Authorization', `Bearer ${this.$store.getters['user/getToken']}`)
|
|
||||||
request.send()
|
|
||||||
|
|
||||||
request.onreadystatechange = () => {
|
|
||||||
if (request.readyState === 4) {
|
|
||||||
this.isDownloading = false
|
|
||||||
}
|
|
||||||
if (request.readyState == 4 && request.status == 200) {
|
|
||||||
const url = window.URL.createObjectURL(request.response)
|
|
||||||
|
|
||||||
const anchor = document.createElement('a')
|
|
||||||
anchor.href = url
|
|
||||||
anchor.download = filename
|
|
||||||
document.body.appendChild(anchor)
|
|
||||||
anchor.click()
|
|
||||||
setTimeout(() => {
|
|
||||||
if (anchor) anchor.remove()
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
request.onerror = (err) => {
|
|
||||||
console.error('Download error', err)
|
|
||||||
this.isDownloading = false
|
|
||||||
}
|
|
||||||
|
|
||||||
request.onprogress = (e) => {
|
|
||||||
const percent_complete = Math.floor((e.loaded / e.total) * 100)
|
|
||||||
this.downloadAmount = this.$bytesPretty(e.loaded)
|
|
||||||
this.downloadPercent = percent_complete
|
|
||||||
|
|
||||||
// const duration = (new Date().getTime() - startTime) / 1000
|
|
||||||
// const bps = e.loaded / duration
|
|
||||||
// const kbps = Math.floor(bps / 1024)
|
|
||||||
// const time = (e.total - e.loaded) / bps
|
|
||||||
// const seconds = Math.floor(time % 60)
|
|
||||||
// const minutes = Math.floor(time / 60)
|
|
||||||
// console.log(`${percent_complete}% - ${kbps} Kbps - ${minutes} min ${seconds} sec remaining`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
|
||||||
<div class="mb-4">
|
|
||||||
<template v-if="hasTracks">
|
|
||||||
<div class="w-full bg-primary px-4 py-2 flex items-center">
|
|
||||||
<p class="pr-4">Audio Tracks</p>
|
|
||||||
<div class="h-7 w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
|
|
||||||
<span class="text-sm font-mono">{{ tracks.length }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow" />
|
|
||||||
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
|
|
||||||
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobook.id}/edit`">
|
|
||||||
<ui-btn small color="primary">Manage Tracks</ui-btn>
|
|
||||||
</nuxt-link>
|
|
||||||
</div>
|
|
||||||
<table class="text-sm tracksTable">
|
|
||||||
<tr class="font-book">
|
|
||||||
<th>#</th>
|
|
||||||
<th class="text-left">Filename</th>
|
|
||||||
<th class="text-left">Size</th>
|
|
||||||
<th class="text-left">Duration</th>
|
|
||||||
<th v-if="showDownload" class="text-center">Download</th>
|
|
||||||
</tr>
|
|
||||||
<template v-for="track in tracksCleaned">
|
|
||||||
<tr :key="track.index">
|
|
||||||
<td class="text-center">
|
|
||||||
<p>{{ track.index }}</p>
|
|
||||||
</td>
|
|
||||||
<td class="font-sans">{{ showFullPath ? track.fullPath : track.filename }}</td>
|
|
||||||
<td class="font-mono">
|
|
||||||
{{ $bytesPretty(track.size) }}
|
|
||||||
</td>
|
|
||||||
<td class="font-mono">
|
|
||||||
{{ $secondsToTimestamp(track.duration) }}
|
|
||||||
</td>
|
|
||||||
<td v-if="showDownload" class="font-mono text-center">
|
|
||||||
<a :href="`/s/book/${audiobook.id}/${track.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
</table>
|
|
||||||
</template>
|
|
||||||
<div v-else class="flex my-4 text-center justify-center text-xl">No Audio Tracks</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<tables-all-files-table :audiobook="audiobook" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
audiobook: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
tracks: null,
|
|
||||||
showFullPath: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
audiobook: {
|
|
||||||
immediate: true,
|
|
||||||
handler(newVal) {
|
|
||||||
if (newVal) this.init()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
audiobookPath() {
|
|
||||||
return this.audiobook.path
|
|
||||||
},
|
|
||||||
tracksCleaned() {
|
|
||||||
return this.tracks.map((track) => {
|
|
||||||
var trackPath = track.path.replace(/\\/g, '/')
|
|
||||||
var audiobookPath = this.audiobookPath.replace(/\\/g, '/')
|
|
||||||
|
|
||||||
return {
|
|
||||||
...track,
|
|
||||||
relativePath: trackPath
|
|
||||||
.replace(audiobookPath + '/', '')
|
|
||||||
.replace(/%/g, '%25')
|
|
||||||
.replace(/#/g, '%23')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
userToken() {
|
|
||||||
return this.$store.getters['user/getToken']
|
|
||||||
},
|
|
||||||
userCanUpdate() {
|
|
||||||
return this.$store.getters['user/getUserCanUpdate']
|
|
||||||
},
|
|
||||||
userCanDownload() {
|
|
||||||
return this.$store.getters['user/getUserCanDownload']
|
|
||||||
},
|
|
||||||
isMissing() {
|
|
||||||
return this.audiobook.isMissing
|
|
||||||
},
|
|
||||||
showDownload() {
|
|
||||||
return this.userCanDownload && !this.isMissing
|
|
||||||
},
|
|
||||||
hasTracks() {
|
|
||||||
return this.audiobook.tracks.length
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
init() {
|
|
||||||
this.tracks = this.audiobook.tracks
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,286 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="w-full h-full overflow-hidden px-4 py-6 relative">
|
|
||||||
<form @submit.prevent="submitSearch">
|
|
||||||
<div class="flex items-center justify-start -mx-1 h-20">
|
|
||||||
<div class="w-40 px-1">
|
|
||||||
<ui-dropdown v-model="provider" :items="providers" label="Provider" small />
|
|
||||||
</div>
|
|
||||||
<div class="w-72 px-1">
|
|
||||||
<ui-text-input-with-label v-model="searchTitle" :label="provider == 'audible' ? 'Search Title or ASIN' : 'Search Title'" placeholder="Search" />
|
|
||||||
</div>
|
|
||||||
<div class="w-72 px-1">
|
|
||||||
<ui-text-input-with-label v-model="searchAuthor" label="Author" />
|
|
||||||
</div>
|
|
||||||
<ui-btn class="mt-5 ml-1" type="submit">Search</ui-btn>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<div v-show="processing" class="flex h-full items-center justify-center">
|
|
||||||
<p>Loading...</p>
|
|
||||||
</div>
|
|
||||||
<div v-show="!processing && !searchResults.length && hasSearched" class="flex h-full items-center justify-center">
|
|
||||||
<p>No Results</p>
|
|
||||||
</div>
|
|
||||||
<div v-show="!processing" class="w-full max-h-full overflow-y-auto overflow-x-hidden matchListWrapper">
|
|
||||||
<template v-for="(res, index) in searchResults">
|
|
||||||
<cards-book-match-card :key="index" :book="res" :book-cover-aspect-ratio="bookCoverAspectRatio" @select="selectMatch" />
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
<div v-if="selectedMatch" class="absolute top-0 left-0 w-full bg-bg h-full p-8 max-h-full overflow-y-auto overflow-x-hidden">
|
|
||||||
<div class="flex mb-2">
|
|
||||||
<div class="w-8 h-8 rounded-full hover:bg-white hover:bg-opacity-10 flex items-center justify-center cursor-pointer" @click="selectedMatch = null">
|
|
||||||
<span class="material-icons text-3xl">arrow_back</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-xl pl-3">Update Book Details</p>
|
|
||||||
</div>
|
|
||||||
<form @submit.prevent="submitMatchUpdate">
|
|
||||||
<div v-if="selectedMatch.cover" class="flex items-center py-2">
|
|
||||||
<ui-checkbox v-model="selectedMatchUsage.cover" />
|
|
||||||
<ui-text-input-with-label v-model="selectedMatch.cover" :disabled="!selectedMatchUsage.cover" label="Cover" class="flex-grow ml-4" />
|
|
||||||
</div>
|
|
||||||
<div v-if="selectedMatch.title" class="flex items-center py-2">
|
|
||||||
<ui-checkbox v-model="selectedMatchUsage.title" />
|
|
||||||
<ui-text-input-with-label v-model="selectedMatch.title" :disabled="!selectedMatchUsage.title" label="Title" class="flex-grow ml-4" />
|
|
||||||
</div>
|
|
||||||
<div v-if="selectedMatch.subtitle" class="flex items-center py-2">
|
|
||||||
<ui-checkbox v-model="selectedMatchUsage.subtitle" />
|
|
||||||
<ui-text-input-with-label v-model="selectedMatch.subtitle" :disabled="!selectedMatchUsage.subtitle" label="Subtitle" class="flex-grow ml-4" />
|
|
||||||
</div>
|
|
||||||
<div v-if="selectedMatch.author" class="flex items-center py-2">
|
|
||||||
<ui-checkbox v-model="selectedMatchUsage.author" />
|
|
||||||
<ui-text-input-with-label v-model="selectedMatch.author" :disabled="!selectedMatchUsage.author" label="Author" class="flex-grow ml-4" />
|
|
||||||
</div>
|
|
||||||
<div v-if="selectedMatch.narrator" class="flex items-center py-2">
|
|
||||||
<ui-checkbox v-model="selectedMatchUsage.narrator" />
|
|
||||||
<ui-text-input-with-label v-model="selectedMatch.narrator" :disabled="!selectedMatchUsage.narrator" label="Narrator" class="flex-grow ml-4" />
|
|
||||||
</div>
|
|
||||||
<div v-if="selectedMatch.description" class="flex items-center py-2">
|
|
||||||
<ui-checkbox v-model="selectedMatchUsage.description" />
|
|
||||||
<ui-textarea-with-label v-model="selectedMatch.description" :rows="3" :disabled="!selectedMatchUsage.description" label="Description" class="flex-grow ml-4" />
|
|
||||||
</div>
|
|
||||||
<div v-if="selectedMatch.publisher" class="flex items-center py-2">
|
|
||||||
<ui-checkbox v-model="selectedMatchUsage.publisher" />
|
|
||||||
<ui-text-input-with-label v-model="selectedMatch.publisher" :disabled="!selectedMatchUsage.publisher" label="Publisher" class="flex-grow ml-4" />
|
|
||||||
</div>
|
|
||||||
<div v-if="selectedMatch.publishYear" class="flex items-center py-2">
|
|
||||||
<ui-checkbox v-model="selectedMatchUsage.publishYear" />
|
|
||||||
<ui-text-input-with-label v-model="selectedMatch.publishYear" :disabled="!selectedMatchUsage.publishYear" label="Publish Year" class="flex-grow ml-4" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="selectedMatch.series" class="flex items-center py-2">
|
|
||||||
<ui-checkbox v-model="selectedMatchUsage.series" />
|
|
||||||
<ui-text-input-with-label v-model="selectedMatch.series" :disabled="!selectedMatchUsage.series" label="Series" class="flex-grow ml-4" />
|
|
||||||
</div>
|
|
||||||
<div v-if="selectedMatch.volumeNumber" class="flex items-center py-2">
|
|
||||||
<ui-checkbox v-model="selectedMatchUsage.volumeNumber" />
|
|
||||||
<ui-text-input-with-label v-model="selectedMatch.volumeNumber" :disabled="!selectedMatchUsage.volumeNumber" label="Volume Number" class="flex-grow ml-4" />
|
|
||||||
</div>
|
|
||||||
<div v-if="selectedMatch.isbn" class="flex items-center py-2">
|
|
||||||
<ui-checkbox v-model="selectedMatchUsage.isbn" />
|
|
||||||
<ui-text-input-with-label v-model="selectedMatch.isbn" :disabled="!selectedMatchUsage.isbn" label="ISBN" class="flex-grow ml-4" />
|
|
||||||
</div>
|
|
||||||
<div v-if="selectedMatch.asin" class="flex items-center py-2">
|
|
||||||
<ui-checkbox v-model="selectedMatchUsage.asin" />
|
|
||||||
<ui-text-input-with-label v-model="selectedMatch.asin" :disabled="!selectedMatchUsage.asin" label="ASIN" class="flex-grow ml-4" />
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-end py-2">
|
|
||||||
<ui-btn color="success" type="submit">Update</ui-btn>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
processing: Boolean,
|
|
||||||
audiobook: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
audiobookId: null,
|
|
||||||
searchTitle: null,
|
|
||||||
searchAuthor: null,
|
|
||||||
lastSearch: null,
|
|
||||||
provider: 'google',
|
|
||||||
searchResults: [],
|
|
||||||
hasSearched: false,
|
|
||||||
selectedMatch: null,
|
|
||||||
selectedMatchUsage: {
|
|
||||||
title: true,
|
|
||||||
subtitle: true,
|
|
||||||
cover: true,
|
|
||||||
author: true,
|
|
||||||
narrator: true,
|
|
||||||
description: true,
|
|
||||||
publisher: true,
|
|
||||||
publishYear: true,
|
|
||||||
series: true,
|
|
||||||
volumeNumber: true,
|
|
||||||
asin: true,
|
|
||||||
isbn: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
audiobook: {
|
|
||||||
immediate: true,
|
|
||||||
handler(newVal) {
|
|
||||||
if (newVal) this.init()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
isProcessing: {
|
|
||||||
get() {
|
|
||||||
return this.processing
|
|
||||||
},
|
|
||||||
set(val) {
|
|
||||||
this.$emit('update:processing', val)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
bookCoverAspectRatio() {
|
|
||||||
return this.$store.getters['getBookCoverAspectRatio']
|
|
||||||
},
|
|
||||||
providers() {
|
|
||||||
return this.$store.state.scanners.providers
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
persistProvider() {
|
|
||||||
try {
|
|
||||||
localStorage.setItem('book-provider', this.provider)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('PersistProvider', error)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
getSearchQuery() {
|
|
||||||
var searchQuery = `provider=${this.provider}&fallbackTitleOnly=1&title=${this.searchTitle}`
|
|
||||||
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor}`
|
|
||||||
return searchQuery
|
|
||||||
},
|
|
||||||
submitSearch() {
|
|
||||||
if (!this.searchTitle) {
|
|
||||||
this.$toast.warning('Search title is required')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.persistProvider()
|
|
||||||
this.runSearch()
|
|
||||||
},
|
|
||||||
async runSearch() {
|
|
||||||
var searchQuery = this.getSearchQuery()
|
|
||||||
if (this.lastSearch === searchQuery) return
|
|
||||||
this.searchResults = []
|
|
||||||
this.isProcessing = true
|
|
||||||
this.lastSearch = searchQuery
|
|
||||||
var results = await this.$axios.$get(`/api/search/books?${searchQuery}`).catch((error) => {
|
|
||||||
console.error('Failed', error)
|
|
||||||
return []
|
|
||||||
})
|
|
||||||
results = results.filter((res) => {
|
|
||||||
return !!res.title
|
|
||||||
})
|
|
||||||
this.searchResults = results
|
|
||||||
this.isProcessing = false
|
|
||||||
this.hasSearched = true
|
|
||||||
},
|
|
||||||
init() {
|
|
||||||
this.selectedMatch = null
|
|
||||||
this.selectedMatchUsage = {
|
|
||||||
title: true,
|
|
||||||
subtitle: true,
|
|
||||||
cover: true,
|
|
||||||
author: true,
|
|
||||||
narrator: true,
|
|
||||||
description: true,
|
|
||||||
publisher: true,
|
|
||||||
publishYear: true,
|
|
||||||
series: true,
|
|
||||||
volumeNumber: true,
|
|
||||||
asin: true,
|
|
||||||
isbn: true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.audiobook.id !== this.audiobookId) {
|
|
||||||
this.searchResults = []
|
|
||||||
this.hasSearched = false
|
|
||||||
this.audiobookId = this.audiobook.id
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.audiobook.book || !this.audiobook.book.title) {
|
|
||||||
this.searchTitle = null
|
|
||||||
this.searchAuthor = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.searchTitle = this.audiobook.book.title
|
|
||||||
this.searchAuthor = this.audiobook.book.authorFL || ''
|
|
||||||
this.provider = localStorage.getItem('book-provider') || 'google'
|
|
||||||
},
|
|
||||||
selectMatch(match) {
|
|
||||||
this.selectedMatch = match
|
|
||||||
},
|
|
||||||
buildMatchUpdatePayload() {
|
|
||||||
var updatePayload = {}
|
|
||||||
for (const key in this.selectedMatchUsage) {
|
|
||||||
if (this.selectedMatchUsage[key] && this.selectedMatch[key]) {
|
|
||||||
updatePayload[key] = this.selectedMatch[key]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return updatePayload
|
|
||||||
},
|
|
||||||
async submitMatchUpdate() {
|
|
||||||
var updatePayload = this.buildMatchUpdatePayload()
|
|
||||||
if (!Object.keys(updatePayload).length) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.isProcessing = true
|
|
||||||
|
|
||||||
if (updatePayload.cover) {
|
|
||||||
var coverPayload = {
|
|
||||||
url: updatePayload.cover
|
|
||||||
}
|
|
||||||
var success = await this.$axios.$post(`/api/books/${this.audiobook.id}/cover`, coverPayload).catch((error) => {
|
|
||||||
console.error('Failed to update', error)
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
if (success) {
|
|
||||||
this.$toast.success('Book Cover Updated')
|
|
||||||
} else {
|
|
||||||
this.$toast.error('Book Cover Failed to Update')
|
|
||||||
}
|
|
||||||
console.log('Updated cover')
|
|
||||||
delete updatePayload.cover
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(updatePayload).length) {
|
|
||||||
var bookUpdatePayload = {
|
|
||||||
book: updatePayload
|
|
||||||
}
|
|
||||||
var success = await this.$axios.$patch(`/api/books/${this.audiobook.id}`, bookUpdatePayload).catch((error) => {
|
|
||||||
console.error('Failed to update', error)
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
if (success) {
|
|
||||||
this.$toast.success('Book Details Updated')
|
|
||||||
this.selectedMatch = null
|
|
||||||
this.$emit('selectTab', 'details')
|
|
||||||
} else {
|
|
||||||
this.$toast.error('Book Details Failed to Update')
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.selectedMatch = null
|
|
||||||
}
|
|
||||||
this.isProcessing = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.matchListWrapper {
|
|
||||||
height: calc(100% - 80px);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
|
||||||
<template v-if="hasTracks">
|
|
||||||
<div class="w-full bg-primary px-4 py-2 flex items-center">
|
|
||||||
<div class="h-7 w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
|
|
||||||
<span class="text-sm font-mono">{{ tracks.length }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow" />
|
|
||||||
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
|
|
||||||
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobook.id}/edit`" class="mr-4">
|
|
||||||
<ui-btn small color="primary">Manage Tracks</ui-btn>
|
|
||||||
</nuxt-link>
|
|
||||||
</div>
|
|
||||||
<table class="text-sm tracksTable">
|
|
||||||
<tr class="font-book">
|
|
||||||
<th>#</th>
|
|
||||||
<th class="text-left">Filename</th>
|
|
||||||
<th class="text-left">Size</th>
|
|
||||||
<th class="text-left">Duration</th>
|
|
||||||
<th v-if="showDownload" class="text-center">Download</th>
|
|
||||||
</tr>
|
|
||||||
<template v-for="track in tracksCleaned">
|
|
||||||
<tr :key="track.index">
|
|
||||||
<td class="text-center">
|
|
||||||
<p>{{ track.index }}</p>
|
|
||||||
</td>
|
|
||||||
<td class="font-sans">{{ showFullPath ? track.fullPath : track.filename }}</td>
|
|
||||||
<td class="font-mono">
|
|
||||||
{{ $bytesPretty(track.size) }}
|
|
||||||
</td>
|
|
||||||
<td class="font-mono">
|
|
||||||
{{ $secondsToTimestamp(track.duration) }}
|
|
||||||
</td>
|
|
||||||
<td v-if="showDownload" class="font-mono text-center">
|
|
||||||
<a :href="`/s/book/${audiobook.id}/${track.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
</table>
|
|
||||||
</template>
|
|
||||||
<div v-else class="flex my-4 text-center justify-center text-xl">No Audio Tracks</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
audiobook: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
tracks: null,
|
|
||||||
showFullPath: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
audiobook: {
|
|
||||||
immediate: true,
|
|
||||||
handler(newVal) {
|
|
||||||
if (newVal) this.init()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
audiobookPath() {
|
|
||||||
return this.audiobook.path
|
|
||||||
},
|
|
||||||
tracksCleaned() {
|
|
||||||
return this.tracks.map((track) => {
|
|
||||||
var trackPath = track.path.replace(/\\/g, '/')
|
|
||||||
var audiobookPath = this.audiobookPath.replace(/\\/g, '/')
|
|
||||||
|
|
||||||
return {
|
|
||||||
...track,
|
|
||||||
relativePath: trackPath
|
|
||||||
.replace(audiobookPath + '/', '')
|
|
||||||
.replace(/%/g, '%25')
|
|
||||||
.replace(/#/g, '%23')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
userToken() {
|
|
||||||
return this.$store.getters['user/getToken']
|
|
||||||
},
|
|
||||||
userCanUpdate() {
|
|
||||||
return this.$store.getters['user/getUserCanUpdate']
|
|
||||||
},
|
|
||||||
userCanDownload() {
|
|
||||||
return this.$store.getters['user/getUserCanDownload']
|
|
||||||
},
|
|
||||||
isMissing() {
|
|
||||||
return this.audiobook.isMissing
|
|
||||||
},
|
|
||||||
showDownload() {
|
|
||||||
return this.userCanDownload && !this.isMissing
|
|
||||||
},
|
|
||||||
hasTracks() {
|
|
||||||
return this.audiobook.tracks.length
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
init() {
|
|
||||||
this.tracks = this.audiobook.tracks
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
+65
-62
@@ -5,7 +5,7 @@
|
|||||||
<p class="font-book text-3xl text-white truncate pointer-events-none">{{ title }}</p>
|
<p class="font-book text-3xl text-white truncate pointer-events-none">{{ title }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div class="absolute -top-10 left-0 w-full flex">
|
<div class="absolute -top-10 left-0 z-10 w-full flex">
|
||||||
<template v-for="tab in availableTabs">
|
<template v-for="tab in availableTabs">
|
||||||
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
|
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -18,8 +18,8 @@
|
|||||||
<div class="material-icons text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goNextBook" @mousedown.prevent>arrow_forward_ios</div>
|
<div class="material-icons text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goNextBook" @mousedown.prevent>arrow_forward_ios</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-full h-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300">
|
<div class="w-full h-full max-h-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative">
|
||||||
<component v-if="audiobook && show" :is="tabName" :audiobook="audiobook" :processing.sync="processing" @close="show = false" @selectTab="selectTab" />
|
<component v-if="libraryItem && show" :is="tabName" :library-item="libraryItem" :processing.sync="processing" @close="show = false" @selectTab="selectTab" />
|
||||||
</div>
|
</div>
|
||||||
</modals-modal>
|
</modals-modal>
|
||||||
</template>
|
</template>
|
||||||
@@ -29,49 +29,43 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
processing: false,
|
processing: false,
|
||||||
audiobook: null,
|
libraryItem: null,
|
||||||
fetchOnShow: false,
|
|
||||||
tabs: [
|
tabs: [
|
||||||
{
|
{
|
||||||
id: 'details',
|
id: 'details',
|
||||||
title: 'Details',
|
title: 'Details',
|
||||||
component: 'modals-edit-tabs-details'
|
component: 'modals-item-tabs-details'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'cover',
|
id: 'cover',
|
||||||
title: 'Cover',
|
title: 'Cover',
|
||||||
component: 'modals-edit-tabs-cover'
|
component: 'modals-item-tabs-cover'
|
||||||
},
|
},
|
||||||
// {
|
|
||||||
// id: 'tracks',
|
|
||||||
// title: 'Tracks',
|
|
||||||
// component: 'modals-edit-tabs-tracks'
|
|
||||||
// },
|
|
||||||
{
|
{
|
||||||
id: 'chapters',
|
id: 'chapters',
|
||||||
title: 'Chapters',
|
title: 'Chapters',
|
||||||
component: 'modals-edit-tabs-chapters'
|
component: 'modals-item-tabs-chapters'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'episodes',
|
||||||
|
title: 'Episodes',
|
||||||
|
component: 'modals-item-tabs-episodes'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'files',
|
id: 'files',
|
||||||
title: 'Files',
|
title: 'Files',
|
||||||
component: 'modals-edit-tabs-files'
|
component: 'modals-item-tabs-files'
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'download',
|
|
||||||
title: 'Download',
|
|
||||||
component: 'modals-edit-tabs-download'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'match',
|
id: 'match',
|
||||||
title: 'Match',
|
title: 'Match',
|
||||||
component: 'modals-edit-tabs-match'
|
component: 'modals-item-tabs-match'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'manage',
|
||||||
|
title: 'Manage',
|
||||||
|
component: 'modals-item-tabs-manage'
|
||||||
}
|
}
|
||||||
// {
|
|
||||||
// id: 'authors',
|
|
||||||
// title: 'Authors',
|
|
||||||
// component: 'modals-edit-tabs-authors'
|
|
||||||
// }
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -89,12 +83,7 @@ export default {
|
|||||||
this.selectedTab = availableTabIds[0]
|
this.selectedTab = availableTabIds[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.audiobook && this.audiobook.id === this.selectedAudiobookId) {
|
this.libraryItem = null
|
||||||
if (this.fetchOnShow) this.fetchFull()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.fetchOnShow = false
|
|
||||||
this.audiobook = null
|
|
||||||
this.init()
|
this.init()
|
||||||
this.registerListeners()
|
this.registerListeners()
|
||||||
} else {
|
} else {
|
||||||
@@ -120,22 +109,26 @@ export default {
|
|||||||
this.$store.commit('setEditModalTab', val)
|
this.$store.commit('setEditModalTab', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
showExperimentalFeatures() {
|
||||||
|
return this.$store.state.showExperimentalFeatures
|
||||||
|
},
|
||||||
userCanUpdate() {
|
userCanUpdate() {
|
||||||
return this.$store.getters['user/getUserCanUpdate']
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
},
|
},
|
||||||
userCanDownload() {
|
userCanDownload() {
|
||||||
return this.$store.getters['user/getUserCanDownload']
|
return this.$store.getters['user/getUserCanDownload']
|
||||||
},
|
},
|
||||||
showExperimentalFeatures() {
|
|
||||||
return this.$store.state.showExperimentalFeatures
|
|
||||||
},
|
|
||||||
availableTabs() {
|
availableTabs() {
|
||||||
if (!this.userCanUpdate && !this.userCanDownload) return []
|
if (!this.userCanUpdate && !this.userCanDownload) return []
|
||||||
return this.tabs.filter((tab) => {
|
return this.tabs.filter((tab) => {
|
||||||
if (tab.id === 'download' && this.isMissing) return false
|
if (tab.experimental && !this.showExperimentalFeatures) return false
|
||||||
if ((tab.id === 'download' || tab.id === 'files' || tab.id === 'authors') && this.userCanDownload) return true
|
if (tab.id === 'manage' && (this.isMissing || this.mediaType !== 'book')) return false
|
||||||
if (tab.id !== 'download' && tab.id !== 'files' && tab.id !== 'authors' && this.userCanUpdate) return true
|
if (this.mediaType == 'podcast' && tab.id == 'chapters') return false
|
||||||
if (tab.id === 'match' && this.userCanUpdate && this.showExperimentalFeatures) return true
|
if (this.mediaType == 'book' && tab.id == 'episodes') return false
|
||||||
|
|
||||||
|
if ((tab.id === 'manage' || tab.id === 'files') && this.userCanDownload) return true
|
||||||
|
if (tab.id !== 'manage' && tab.id !== 'files' && this.userCanUpdate) return true
|
||||||
|
if (tab.id === 'match' && this.userCanUpdate) return true
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -148,26 +141,32 @@ export default {
|
|||||||
return _tab ? _tab.component : ''
|
return _tab ? _tab.component : ''
|
||||||
},
|
},
|
||||||
isMissing() {
|
isMissing() {
|
||||||
return this.selectedAudiobook.isMissing
|
return this.selectedLibraryItem.isMissing
|
||||||
},
|
},
|
||||||
selectedAudiobook() {
|
selectedLibraryItem() {
|
||||||
return this.$store.state.selectedAudiobook || {}
|
return this.$store.state.selectedLibraryItem || {}
|
||||||
},
|
},
|
||||||
selectedAudiobookId() {
|
selectedLibraryItemId() {
|
||||||
return this.selectedAudiobook.id
|
return this.selectedLibraryItem.id
|
||||||
},
|
},
|
||||||
book() {
|
media() {
|
||||||
return this.audiobook ? this.audiobook.book || {} : {}
|
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||||
|
},
|
||||||
|
mediaMetadata() {
|
||||||
|
return this.media.metadata || {}
|
||||||
|
},
|
||||||
|
mediaType() {
|
||||||
|
return this.libraryItem ? this.libraryItem.mediaType : null
|
||||||
},
|
},
|
||||||
title() {
|
title() {
|
||||||
return this.book.title || 'No Title'
|
return this.mediaMetadata.title || 'No Title'
|
||||||
},
|
},
|
||||||
bookshelfBookIds() {
|
bookshelfBookIds() {
|
||||||
return this.$store.state.bookshelfBookIds || []
|
return this.$store.state.bookshelfBookIds || []
|
||||||
},
|
},
|
||||||
currentBookshelfIndex() {
|
currentBookshelfIndex() {
|
||||||
if (!this.bookshelfBookIds.length) return 0
|
if (!this.bookshelfBookIds.length) return 0
|
||||||
return this.bookshelfBookIds.findIndex((bid) => bid === this.selectedAudiobookId)
|
return this.bookshelfBookIds.findIndex((bid) => bid === this.selectedLibraryItemId)
|
||||||
},
|
},
|
||||||
canGoPrev() {
|
canGoPrev() {
|
||||||
return this.bookshelfBookIds.length && this.currentBookshelfIndex > 0
|
return this.bookshelfBookIds.length && this.currentBookshelfIndex > 0
|
||||||
@@ -181,15 +180,18 @@ export default {
|
|||||||
if (this.currentBookshelfIndex - 1 < 0) return
|
if (this.currentBookshelfIndex - 1 < 0) return
|
||||||
var prevBookId = this.bookshelfBookIds[this.currentBookshelfIndex - 1]
|
var prevBookId = this.bookshelfBookIds[this.currentBookshelfIndex - 1]
|
||||||
this.processing = true
|
this.processing = true
|
||||||
var prevBook = await this.$axios.$get(`/api/books/${prevBookId}`).catch((error) => {
|
var prevBook = await this.$axios.$get(`/api/items/${prevBookId}?expanded=1`).catch((error) => {
|
||||||
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to fetch book'
|
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to fetch book'
|
||||||
this.$toast.error(errorMsg)
|
this.$toast.error(errorMsg)
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
this.processing = false
|
this.processing = false
|
||||||
if (prevBook) {
|
if (prevBook) {
|
||||||
this.$store.commit('showEditModalOnTab', { audiobook: prevBook, tab: this.selectedTab })
|
this.unregisterListeners()
|
||||||
this.$nextTick(this.init)
|
this.libraryItem = prevBook
|
||||||
|
this.selectedTab = 'details'
|
||||||
|
this.$store.commit('setSelectedLibraryItem', prevBook)
|
||||||
|
this.$nextTick(this.registerListeners)
|
||||||
} else {
|
} else {
|
||||||
console.error('Book not found', prevBookId)
|
console.error('Book not found', prevBookId)
|
||||||
}
|
}
|
||||||
@@ -198,15 +200,18 @@ export default {
|
|||||||
if (this.currentBookshelfIndex >= this.bookshelfBookIds.length - 1) return
|
if (this.currentBookshelfIndex >= this.bookshelfBookIds.length - 1) return
|
||||||
this.processing = true
|
this.processing = true
|
||||||
var nextBookId = this.bookshelfBookIds[this.currentBookshelfIndex + 1]
|
var nextBookId = this.bookshelfBookIds[this.currentBookshelfIndex + 1]
|
||||||
var nextBook = await this.$axios.$get(`/api/books/${nextBookId}`).catch((error) => {
|
var nextBook = await this.$axios.$get(`/api/items/${nextBookId}?expanded=1`).catch((error) => {
|
||||||
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to fetch book'
|
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to fetch book'
|
||||||
this.$toast.error(errorMsg)
|
this.$toast.error(errorMsg)
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
this.processing = false
|
this.processing = false
|
||||||
if (nextBook) {
|
if (nextBook) {
|
||||||
this.$store.commit('showEditModalOnTab', { audiobook: nextBook, tab: this.selectedTab })
|
this.unregisterListeners()
|
||||||
this.$nextTick(this.init)
|
this.libraryItem = nextBook
|
||||||
|
this.selectedTab = 'details'
|
||||||
|
this.$store.commit('setSelectedLibraryItem', nextBook)
|
||||||
|
this.$nextTick(this.registerListeners)
|
||||||
} else {
|
} else {
|
||||||
console.error('Book not found', nextBookId)
|
console.error('Book not found', nextBookId)
|
||||||
}
|
}
|
||||||
@@ -216,23 +221,19 @@ export default {
|
|||||||
this.selectedTab = tab
|
this.selectedTab = tab
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
audiobookUpdated() {
|
libraryItemUpdated(expandedLibraryItem) {
|
||||||
if (!this.show) this.fetchOnShow = true
|
this.libraryItem = expandedLibraryItem
|
||||||
else {
|
|
||||||
this.fetchFull()
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
init() {
|
init() {
|
||||||
this.$store.commit('audiobooks/addListener', { meth: this.audiobookUpdated, id: 'edit-modal', audiobookId: this.selectedAudiobookId })
|
|
||||||
this.fetchFull()
|
this.fetchFull()
|
||||||
},
|
},
|
||||||
async fetchFull() {
|
async fetchFull() {
|
||||||
try {
|
try {
|
||||||
this.processing = true
|
this.processing = true
|
||||||
this.audiobook = await this.$axios.$get(`/api/books/${this.selectedAudiobookId}`)
|
this.libraryItem = await this.$axios.$get(`/api/items/${this.selectedLibraryItemId}?expanded=1`)
|
||||||
this.processing = false
|
this.processing = false
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch audiobook', this.selectedAudiobookId, error)
|
console.error('Failed to fetch audiobook', this.selectedLibraryItemId, error)
|
||||||
this.processing = false
|
this.processing = false
|
||||||
this.show = false
|
this.show = false
|
||||||
}
|
}
|
||||||
@@ -246,9 +247,11 @@ export default {
|
|||||||
},
|
},
|
||||||
registerListeners() {
|
registerListeners() {
|
||||||
this.$eventBus.$on('modal-hotkey', this.hotkey)
|
this.$eventBus.$on('modal-hotkey', this.hotkey)
|
||||||
|
this.$eventBus.$on(`${this.selectedLibraryItemId}_updated`, this.libraryItemUpdated)
|
||||||
},
|
},
|
||||||
unregisterListeners() {
|
unregisterListeners() {
|
||||||
this.$eventBus.$off('modal-hotkey', this.hotkey)
|
this.$eventBus.$off('modal-hotkey', this.hotkey)
|
||||||
|
this.$eventBus.$off(`${this.selectedLibraryItemId}_updated`, this.libraryItemUpdated)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {},
|
mounted() {},
|
||||||
@@ -258,7 +261,7 @@ export default {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style scoped>
|
||||||
.tab {
|
.tab {
|
||||||
height: 40px;
|
height: 40px;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
||||||
|
<div class="w-full mb-4">
|
||||||
|
<tables-chapters-table v-if="chapters.length" :library-item="libraryItem" keep-open />
|
||||||
|
<div v-if="!chapters.length" class="py-4 text-center">
|
||||||
|
<p class="mb-8 text-xl">No Chapters</p>
|
||||||
|
<ui-btn v-if="userCanUpdate" :to="`/audiobook/${libraryItem.id}/chapters`">Add Chapters</ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
libraryItem: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
media() {
|
||||||
|
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||||
|
},
|
||||||
|
chapters() {
|
||||||
|
return this.media.chapters || []
|
||||||
|
},
|
||||||
|
userCanUpdate() {
|
||||||
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
+66
-67
@@ -2,9 +2,9 @@
|
|||||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6 relative">
|
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6 relative">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<covers-book-cover :audiobook="audiobook" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-book-cover :library-item="libraryItem" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
<!-- book cover overlay -->
|
<!-- book cover overlay -->
|
||||||
<div v-if="book.cover" class="absolute top-0 left-0 w-full h-full z-10 opacity-0 hover:opacity-100 transition-opacity duration-100">
|
<div v-if="media.coverPath" class="absolute top-0 left-0 w-full h-full z-10 opacity-0 hover:opacity-100 transition-opacity duration-100">
|
||||||
<div class="absolute top-0 left-0 w-full h-16 bg-gradient-to-b from-black-600 to-transparent" />
|
<div class="absolute top-0 left-0 w-full h-16 bg-gradient-to-b from-black-600 to-transparent" />
|
||||||
<div class="p-1 absolute top-1 right-1 text-red-500 rounded-full w-8 h-8 cursor-pointer hover:text-red-400 shadow-sm" @click="removeCover">
|
<div class="p-1 absolute top-1 right-1 text-red-500 rounded-full w-8 h-8 cursor-pointer hover:text-red-400 shadow-sm" @click="removeCover">
|
||||||
<span class="material-icons">delete</span>
|
<span class="material-icons">delete</span>
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
|
|
||||||
<div v-if="showLocalCovers" class="flex items-center justify-center">
|
<div v-if="showLocalCovers" class="flex items-center justify-center">
|
||||||
<template v-for="cover in localCovers">
|
<template v-for="cover in localCovers">
|
||||||
<div :key="cover.path" class="m-0.5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover.localPath === imageUrl ? 'border-yellow-300' : ''" @click="setCover(cover)">
|
<div :key="cover.path" class="m-0.5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover.metadata.path === coverPath ? 'border-yellow-300' : ''" @click="setCover(cover)">
|
||||||
<div class="h-24 bg-primary" :style="{ width: 96 / bookCoverAspectRatio + 'px' }">
|
<div class="h-24 bg-primary" :style="{ width: 96 / bookCoverAspectRatio + 'px' }">
|
||||||
<img :src="`${cover.localPath}?token=${userToken}`" class="h-full w-full object-contain" />
|
<img :src="`${cover.localPath}?token=${userToken}`" class="h-full w-full object-contain" />
|
||||||
</div>
|
</div>
|
||||||
@@ -47,9 +47,9 @@
|
|||||||
<ui-dropdown v-model="provider" :items="providers" label="Provider" small />
|
<ui-dropdown v-model="provider" :items="providers" label="Provider" small />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-72 px-1">
|
<div class="w-72 px-1">
|
||||||
<ui-text-input-with-label v-model="searchTitle" :label="provider == 'audible' ? 'Search Title or ASIN' : 'Search Title'" placeholder="Search" />
|
<ui-text-input-with-label v-model="searchTitle" :label="searchTitleLabel" placeholder="Search" />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-72 px-1">
|
<div v-show="provider != 'itunes'" class="w-72 px-1">
|
||||||
<ui-text-input-with-label v-model="searchAuthor" label="Author" />
|
<ui-text-input-with-label v-model="searchAuthor" label="Author" />
|
||||||
</div>
|
</div>
|
||||||
<ui-btn class="mt-5 ml-1" type="submit">Search</ui-btn>
|
<ui-btn class="mt-5 ml-1" type="submit">Search</ui-btn>
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
processing: Boolean,
|
processing: Boolean,
|
||||||
audiobook: {
|
libraryItem: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => {}
|
default: () => {}
|
||||||
}
|
}
|
||||||
@@ -98,25 +98,11 @@ export default {
|
|||||||
showLocalCovers: false,
|
showLocalCovers: false,
|
||||||
previewUpload: null,
|
previewUpload: null,
|
||||||
selectedFile: null,
|
selectedFile: null,
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
text: 'Google Books',
|
|
||||||
value: 'google'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Open Library',
|
|
||||||
value: 'openlibrary'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Audible',
|
|
||||||
value: 'audible'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
provider: 'google'
|
provider: 'google'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
audiobook: {
|
libraryItem: {
|
||||||
immediate: true,
|
immediate: true,
|
||||||
handler(newVal) {
|
handler(newVal) {
|
||||||
if (newVal) {
|
if (newVal) {
|
||||||
@@ -134,23 +120,41 @@ export default {
|
|||||||
this.$emit('update:processing', val)
|
this.$emit('update:processing', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
providers() {
|
||||||
|
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
|
||||||
|
return this.$store.state.scanners.providers
|
||||||
|
},
|
||||||
|
searchTitleLabel() {
|
||||||
|
if (this.provider == 'audible') return 'Search Title or ASIN'
|
||||||
|
else if (this.provider == 'itunes') return 'Search Term'
|
||||||
|
return 'Search Title'
|
||||||
|
},
|
||||||
coverAspectRatio() {
|
coverAspectRatio() {
|
||||||
return this.$store.getters['getServerSetting']('coverAspectRatio')
|
return this.$store.getters['getServerSetting']('coverAspectRatio')
|
||||||
},
|
},
|
||||||
bookCoverAspectRatio() {
|
bookCoverAspectRatio() {
|
||||||
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6
|
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6
|
||||||
},
|
},
|
||||||
audiobookId() {
|
libraryItemId() {
|
||||||
return this.audiobook ? this.audiobook.id : null
|
return this.libraryItem ? this.libraryItem.id : null
|
||||||
},
|
},
|
||||||
book() {
|
mediaType() {
|
||||||
return this.audiobook ? this.audiobook.book || {} : {}
|
return this.libraryItem ? this.libraryItem.mediaType : null
|
||||||
},
|
},
|
||||||
audiobookPath() {
|
isPodcast() {
|
||||||
return this.audiobook ? this.audiobook.path : null
|
return this.mediaType == 'podcast'
|
||||||
},
|
},
|
||||||
otherFiles() {
|
media() {
|
||||||
return this.audiobook ? this.audiobook.otherFiles || [] : []
|
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||||
|
},
|
||||||
|
coverPath() {
|
||||||
|
return this.media.coverPath
|
||||||
|
},
|
||||||
|
mediaMetadata() {
|
||||||
|
return this.media.metadata || {}
|
||||||
|
},
|
||||||
|
libraryFiles() {
|
||||||
|
return this.libraryItem ? this.libraryItem.libraryFiles || [] : []
|
||||||
},
|
},
|
||||||
userCanUpload() {
|
userCanUpload() {
|
||||||
return this.$store.getters['user/getUserCanUpload']
|
return this.$store.getters['user/getUserCanUpload']
|
||||||
@@ -159,12 +163,11 @@ export default {
|
|||||||
return this.$store.getters['user/getToken']
|
return this.$store.getters['user/getToken']
|
||||||
},
|
},
|
||||||
localCovers() {
|
localCovers() {
|
||||||
return this.otherFiles
|
return this.libraryFiles
|
||||||
.filter((f) => f.filetype === 'image')
|
.filter((f) => f.fileType === 'image')
|
||||||
.map((file) => {
|
.map((file) => {
|
||||||
var _file = { ...file }
|
var _file = { ...file }
|
||||||
var imgRelPath = _file.path.replace(this.audiobookPath, '')
|
_file.localPath = `/s/item/${this.libraryItemId}/${this.$encodeUriPath(file.metadata.relPath).replace(/^\//, '')}`
|
||||||
_file.localPath = `/s/book/${this.audiobookId}/${imgRelPath}`
|
|
||||||
return _file
|
return _file
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -176,7 +179,7 @@ export default {
|
|||||||
form.set('cover', this.selectedFile)
|
form.set('cover', this.selectedFile)
|
||||||
|
|
||||||
this.$axios
|
this.$axios
|
||||||
.$post(`/api/books/${this.audiobook.id}/cover`, form)
|
.$post(`/api/items/${this.libraryItemId}/cover`, form)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
this.$toast.error(data.error)
|
this.$toast.error(data.error)
|
||||||
@@ -209,17 +212,18 @@ export default {
|
|||||||
},
|
},
|
||||||
init() {
|
init() {
|
||||||
this.showLocalCovers = false
|
this.showLocalCovers = false
|
||||||
if (this.coversFound.length && (this.searchTitle !== this.book.title || this.searchAuthor !== this.book.authorFL)) {
|
if (this.coversFound.length && (this.searchTitle !== this.mediaMetadata.title || this.searchAuthor !== this.mediaMetadata.authorName)) {
|
||||||
this.coversFound = []
|
this.coversFound = []
|
||||||
this.hasSearched = false
|
this.hasSearched = false
|
||||||
}
|
}
|
||||||
this.imageUrl = this.book.cover || ''
|
this.imageUrl = this.media.coverPath || ''
|
||||||
this.searchTitle = this.book.title || ''
|
this.searchTitle = this.mediaMetadata.title || ''
|
||||||
this.searchAuthor = this.book.authorFL || ''
|
this.searchAuthor = this.mediaMetadata.authorName || ''
|
||||||
this.provider = localStorage.getItem('book-provider') || 'openlibrary'
|
if (this.isPodcast) this.provider = 'itunes'
|
||||||
|
else this.provider = localStorage.getItem('book-provider') || 'google'
|
||||||
},
|
},
|
||||||
removeCover() {
|
removeCover() {
|
||||||
if (!this.book.cover) {
|
if (!this.media.coverPath) {
|
||||||
this.imageUrl = ''
|
this.imageUrl = ''
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -229,7 +233,7 @@ export default {
|
|||||||
this.updateCover(this.imageUrl)
|
this.updateCover(this.imageUrl)
|
||||||
},
|
},
|
||||||
async updateCover(cover) {
|
async updateCover(cover) {
|
||||||
if (cover === this.book.cover) {
|
if (cover === this.coverPath) {
|
||||||
console.warn('Cover has not changed..', cover)
|
console.warn('Cover has not changed..', cover)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -237,9 +241,21 @@ export default {
|
|||||||
this.isProcessing = true
|
this.isProcessing = true
|
||||||
var success = false
|
var success = false
|
||||||
|
|
||||||
// Download cover from url and use
|
if (!cover) {
|
||||||
if (cover.startsWith('http:') || cover.startsWith('https:')) {
|
// Remove cover
|
||||||
success = await this.$axios.$post(`/api/books/${this.audiobook.id}/cover`, { url: cover }).catch((error) => {
|
success = await this.$axios
|
||||||
|
.$delete(`/api/items/${this.libraryItemId}/cover`)
|
||||||
|
.then(() => true)
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to remove cover', error)
|
||||||
|
if (error.response && error.response.data) {
|
||||||
|
this.$toast.error(error.response.data)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
} else if (cover.startsWith('http:') || cover.startsWith('https:')) {
|
||||||
|
// Download cover from url and use
|
||||||
|
success = await this.$axios.$post(`/api/items/${this.libraryItemId}/cover`, { url: cover }).catch((error) => {
|
||||||
console.error('Failed to download cover from url', error)
|
console.error('Failed to download cover from url', error)
|
||||||
if (error.response && error.response.data) {
|
if (error.response && error.response.data) {
|
||||||
this.$toast.error(error.response.data)
|
this.$toast.error(error.response.data)
|
||||||
@@ -249,11 +265,9 @@ export default {
|
|||||||
} else {
|
} else {
|
||||||
// Update local cover url
|
// Update local cover url
|
||||||
const updatePayload = {
|
const updatePayload = {
|
||||||
book: {
|
cover
|
||||||
cover: cover
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
success = await this.$axios.$patch(`/api/books/${this.audiobook.id}`, updatePayload).catch((error) => {
|
success = await this.$axios.$patch(`/api/items/${this.libraryItemId}/cover`, updatePayload).catch((error) => {
|
||||||
console.error('Failed to update', error)
|
console.error('Failed to update', error)
|
||||||
if (error.response && error.response.data) {
|
if (error.response && error.response.data) {
|
||||||
this.$toast.error(error.response.data)
|
this.$toast.error(error.response.data)
|
||||||
@@ -263,15 +277,16 @@ export default {
|
|||||||
}
|
}
|
||||||
if (success) {
|
if (success) {
|
||||||
this.$toast.success('Update Successful')
|
this.$toast.success('Update Successful')
|
||||||
this.$emit('close')
|
// this.$emit('close')
|
||||||
} else {
|
} else {
|
||||||
this.imageUrl = this.book.cover || ''
|
this.imageUrl = this.media.coverPath || ''
|
||||||
}
|
}
|
||||||
this.isProcessing = false
|
this.isProcessing = false
|
||||||
},
|
},
|
||||||
getSearchQuery() {
|
getSearchQuery() {
|
||||||
var searchQuery = `provider=${this.provider}&title=${this.searchTitle}`
|
var searchQuery = `provider=${this.provider}&title=${this.searchTitle}`
|
||||||
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor}`
|
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor}`
|
||||||
|
if (this.isPodcast) searchQuery += '&podcast=1'
|
||||||
return searchQuery
|
return searchQuery
|
||||||
},
|
},
|
||||||
persistProvider() {
|
persistProvider() {
|
||||||
@@ -296,23 +311,7 @@ export default {
|
|||||||
this.hasSearched = true
|
this.hasSearched = true
|
||||||
},
|
},
|
||||||
setCover(coverFile) {
|
setCover(coverFile) {
|
||||||
this.isProcessing = true
|
this.updateCover(coverFile.metadata.path)
|
||||||
this.$axios
|
|
||||||
.$patch(`/api/books/${this.audiobook.id}/coverfile`, coverFile)
|
|
||||||
.then((data) => {
|
|
||||||
console.log('response data', data)
|
|
||||||
if (data && typeof data === 'string') {
|
|
||||||
this.$toast.success(data)
|
|
||||||
}
|
|
||||||
this.isProcessing = false
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed to update', error)
|
|
||||||
if (error.response && error.response.data) {
|
|
||||||
this.$toast.error(error.response.data)
|
|
||||||
}
|
|
||||||
this.isProcessing = false
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full h-full relative">
|
||||||
|
<div id="formWrapper" class="w-full overflow-y-auto">
|
||||||
|
<widgets-book-details-edit v-if="mediaType == 'book'" ref="itemDetailsEdit" :library-item="libraryItem" @submit="saveAndClose" />
|
||||||
|
<widgets-podcast-details-edit v-else ref="itemDetailsEdit" :library-item="libraryItem" @submit="saveAndClose" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="absolute bottom-0 left-0 w-full py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'border-t border-white border-opacity-5'">
|
||||||
|
<div class="flex items-center px-4">
|
||||||
|
<ui-btn v-if="userCanDelete" color="error" type="button" class="h-8" :padding-x="3" small @click.stop.prevent="removeItem">Remove</ui-btn>
|
||||||
|
|
||||||
|
<div class="flex-grow" />
|
||||||
|
|
||||||
|
<ui-tooltip v-if="mediaType == 'book'" :disabled="!!quickMatching" :text="`(Root User Only) Populate empty book details & cover with first book result from '${libraryProvider}'. Does not overwrite details.`" direction="bottom" class="mr-4">
|
||||||
|
<ui-btn v-if="userIsAdminOrUp" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">Quick Match</ui-btn>
|
||||||
|
</ui-tooltip>
|
||||||
|
|
||||||
|
<ui-tooltip :disabled="!!libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="mr-4">
|
||||||
|
<ui-btn v-if="userIsAdminOrUp && !isFile" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn>
|
||||||
|
</ui-tooltip>
|
||||||
|
|
||||||
|
<ui-btn @click="save" class="mx-2">Save</ui-btn>
|
||||||
|
|
||||||
|
<ui-btn @click="saveAndClose">Save & Close</ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
processing: Boolean,
|
||||||
|
libraryItem: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
resettingProgress: false,
|
||||||
|
isScrollable: false,
|
||||||
|
rescanning: false,
|
||||||
|
quickMatching: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
isProcessing: {
|
||||||
|
get() {
|
||||||
|
return this.processing
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('update:processing', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isFile() {
|
||||||
|
return !!this.libraryItem && this.libraryItem.isFile
|
||||||
|
},
|
||||||
|
userIsAdminOrUp() {
|
||||||
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
|
},
|
||||||
|
isMissing() {
|
||||||
|
return !!this.libraryItem && !!this.libraryItem.isMissing
|
||||||
|
},
|
||||||
|
libraryItemId() {
|
||||||
|
return this.libraryItem ? this.libraryItem.id : null
|
||||||
|
},
|
||||||
|
media() {
|
||||||
|
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||||
|
},
|
||||||
|
mediaType() {
|
||||||
|
return this.libraryItem ? this.libraryItem.mediaType : null
|
||||||
|
},
|
||||||
|
mediaMetadata() {
|
||||||
|
return this.media.metadata || {}
|
||||||
|
},
|
||||||
|
userCanDelete() {
|
||||||
|
return this.$store.getters['user/getUserCanDelete']
|
||||||
|
},
|
||||||
|
libraryId() {
|
||||||
|
return this.libraryItem ? this.libraryItem.libraryId : null
|
||||||
|
},
|
||||||
|
libraryProvider() {
|
||||||
|
return this.$store.getters['libraries/getLibraryProvider'](this.libraryId) || 'google'
|
||||||
|
},
|
||||||
|
libraryScan() {
|
||||||
|
if (!this.libraryId) return null
|
||||||
|
return this.$store.getters['scanners/getLibraryScan'](this.libraryId)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
quickMatch() {
|
||||||
|
if (this.quickMatching) return
|
||||||
|
if (!this.$refs.itemDetailsEdit) return
|
||||||
|
|
||||||
|
var { title, author } = this.$refs.itemDetailsEdit.getTitleAndAuthorName()
|
||||||
|
if (!title) {
|
||||||
|
this.$toast.error('Must have a title for quick match')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.quickMatching = true
|
||||||
|
var matchOptions = {
|
||||||
|
provider: this.libraryProvider,
|
||||||
|
title: title || null,
|
||||||
|
author: author || null
|
||||||
|
}
|
||||||
|
this.$axios
|
||||||
|
.$post(`/api/items/${this.libraryItemId}/match`, matchOptions)
|
||||||
|
.then((res) => {
|
||||||
|
this.quickMatching = false
|
||||||
|
if (res.warning) {
|
||||||
|
this.$toast.warning(res.warning)
|
||||||
|
} else if (res.updated) {
|
||||||
|
this.$toast.success('Item details updated')
|
||||||
|
} else {
|
||||||
|
this.$toast.info('No updates were made')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
var errMsg = error.response ? error.response.data || '' : ''
|
||||||
|
console.error('Failed to match', error)
|
||||||
|
this.$toast.error(errMsg || 'Failed to match')
|
||||||
|
this.quickMatching = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
rescan() {
|
||||||
|
this.rescanning = true
|
||||||
|
this.$axios
|
||||||
|
.$get(`/api/items/${this.libraryItemId}/scan`)
|
||||||
|
.then((data) => {
|
||||||
|
this.rescanning = false
|
||||||
|
var result = data.result
|
||||||
|
if (!result) {
|
||||||
|
this.$toast.error(`Re-Scan Failed for "${this.title}"`)
|
||||||
|
} else if (result === 'UPDATED') {
|
||||||
|
this.$toast.success(`Re-Scan complete item was updated`)
|
||||||
|
} else if (result === 'UPTODATE') {
|
||||||
|
this.$toast.success(`Re-Scan complete item was up to date`)
|
||||||
|
} else if (result === 'REMOVED') {
|
||||||
|
this.$toast.error(`Re-Scan complete item was removed`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to scan library item', error)
|
||||||
|
this.$toast.error('Failed to scan library item')
|
||||||
|
this.rescanning = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async saveAndClose() {
|
||||||
|
const wasUpdated = await this.save()
|
||||||
|
if (wasUpdated !== null) this.$emit('close')
|
||||||
|
},
|
||||||
|
async save() {
|
||||||
|
if (this.isProcessing) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (!this.$refs.itemDetailsEdit) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
var updatedDetails = this.$refs.itemDetailsEdit.getDetails()
|
||||||
|
if (!updatedDetails.hasChanges) {
|
||||||
|
this.$toast.info('No changes were made')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return this.updateDetails(updatedDetails)
|
||||||
|
},
|
||||||
|
async updateDetails(updatedDetails) {
|
||||||
|
this.isProcessing = true
|
||||||
|
var updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, updatedDetails.updatePayload).catch((error) => {
|
||||||
|
console.error('Failed to update', error)
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
this.isProcessing = false
|
||||||
|
if (updateResult) {
|
||||||
|
if (updateResult.updated) {
|
||||||
|
this.$toast.success('Item details updated')
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
this.$toast.info('No updates were necessary')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
removeItem() {
|
||||||
|
if (confirm(`Are you sure you want to remove this item?\n\n*Does not delete your files, only removes the item from audiobookshelf`)) {
|
||||||
|
this.isProcessing = true
|
||||||
|
this.$axios
|
||||||
|
.$delete(`/api/items/${this.libraryItemId}`)
|
||||||
|
.then(() => {
|
||||||
|
console.log('Item removed')
|
||||||
|
this.$toast.success('Item Removed')
|
||||||
|
this.$emit('close')
|
||||||
|
this.isProcessing = false
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Remove item failed', error)
|
||||||
|
this.isProcessing = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
checkIsScrollable() {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
var formWrapper = document.getElementById('formWrapper')
|
||||||
|
if (formWrapper) {
|
||||||
|
if (formWrapper.scrollHeight > formWrapper.clientHeight) {
|
||||||
|
this.isScrollable = true
|
||||||
|
} else {
|
||||||
|
this.isScrollable = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
setResizeObserver() {
|
||||||
|
try {
|
||||||
|
var formWrapper = document.getElementById('formWrapper')
|
||||||
|
if (formWrapper) {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
this.checkIsScrollable()
|
||||||
|
})
|
||||||
|
resizeObserver.observe(formWrapper)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to set resize observer')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.setResizeObserver()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
#formWrapper {
|
||||||
|
height: calc(100% - 80px);
|
||||||
|
max-height: calc(100% - 80px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
||||||
|
<div class="w-full mb-4">
|
||||||
|
<div v-if="userIsAdminOrUp" class="flex items-end justify-end mb-4">
|
||||||
|
<!-- <p v-if="autoDownloadEpisodes">Last new episode check {{ $formatDate(lastEpisodeCheck) }}</p> -->
|
||||||
|
<ui-text-input-with-label ref="lastCheckInput" v-model="lastEpisodeCheckInput" :disabled="checkingNewEpisodes" type="datetime-local" label="Look for new episodes after this date" class="max-w-xs mr-2" />
|
||||||
|
<ui-btn :loading="checkingNewEpisodes" @click="checkForNewEpisodes">Check & Download New Episodes</ui-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="episodes.length" class="w-full p-4 bg-primary">
|
||||||
|
<p>Podcast Episodes</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="!episodes.length" class="flex my-4 text-center justify-center text-xl">No Episodes</div>
|
||||||
|
<table v-else class="text-sm tracksTable">
|
||||||
|
<tr class="font-book">
|
||||||
|
<th class="text-left">Sort #</th>
|
||||||
|
<th class="text-left whitespace-nowrap">Episode #</th>
|
||||||
|
<th class="text-left">Title</th>
|
||||||
|
<th class="text-center w-28">Duration</th>
|
||||||
|
<th class="text-center w-28">Size</th>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="episode in episodes" :key="episode.id">
|
||||||
|
<td class="text-left">
|
||||||
|
<p class="px-4">{{ episode.index }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="text-left">
|
||||||
|
<p class="px-4">{{ episode.episode }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="font-book">
|
||||||
|
{{ episode.title }}
|
||||||
|
</td>
|
||||||
|
<td class="font-mono text-center">
|
||||||
|
{{ $secondsToTimestamp(episode.duration) }}
|
||||||
|
</td>
|
||||||
|
<td class="font-mono text-center">
|
||||||
|
{{ $bytesPretty(episode.size) }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
libraryItem: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
checkingNewEpisodes: false,
|
||||||
|
lastEpisodeCheckInput: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
lastEpisodeCheck: {
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.setLastEpisodeCheckInput()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
userIsAdminOrUp() {
|
||||||
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
|
},
|
||||||
|
autoDownloadEpisodes() {
|
||||||
|
return !!this.media.autoDownloadEpisodes
|
||||||
|
},
|
||||||
|
lastEpisodeCheck() {
|
||||||
|
return this.media.lastEpisodeCheck
|
||||||
|
},
|
||||||
|
media() {
|
||||||
|
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||||
|
},
|
||||||
|
libraryItemId() {
|
||||||
|
return this.libraryItem ? this.libraryItem.id : null
|
||||||
|
},
|
||||||
|
episodes() {
|
||||||
|
return this.media.episodes || []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async checkForNewEpisodes() {
|
||||||
|
if (this.$refs.lastCheckInput) {
|
||||||
|
this.$refs.lastCheckInput.blur()
|
||||||
|
}
|
||||||
|
this.checkingNewEpisodes = true
|
||||||
|
const lastEpisodeCheck = new Date(this.lastEpisodeCheckInput).valueOf()
|
||||||
|
|
||||||
|
// If last episode check changed then update it first
|
||||||
|
if (lastEpisodeCheck && lastEpisodeCheck !== this.lastEpisodeCheck) {
|
||||||
|
var updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, { lastEpisodeCheck }).catch((error) => {
|
||||||
|
console.error('Failed to update', error)
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
console.log('updateResult', updateResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$axios
|
||||||
|
.$get(`/api/podcasts/${this.libraryItemId}/checknew`)
|
||||||
|
.then((response) => {
|
||||||
|
if (response.episodes && response.episodes.length) {
|
||||||
|
console.log('New episodes', response.episodes.length)
|
||||||
|
this.$toast.success(`${response.episodes.length} new episodes found!`)
|
||||||
|
} else {
|
||||||
|
this.$toast.info('No new episodes found')
|
||||||
|
}
|
||||||
|
this.checkingNewEpisodes = false
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed', error)
|
||||||
|
var errorMsg = error.response && error.response.data ? error.response.data : 'Unknown Error'
|
||||||
|
this.$toast.error(errorMsg)
|
||||||
|
this.checkingNewEpisodes = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
setLastEpisodeCheckInput() {
|
||||||
|
this.lastEpisodeCheckInput = this.lastEpisodeCheck ? this.$formatDate(this.lastEpisodeCheck, "yyyy-MM-dd'T'HH:mm") : null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.setLastEpisodeCheckInput()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
||||||
|
<tables-library-files-table expanded :files="libraryFiles" :library-item-id="libraryItem.id" :is-missing="isMissing" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
libraryItem: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
tracks: [],
|
||||||
|
showFullPath: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
libraryItem: {
|
||||||
|
immediate: true,
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
media() {
|
||||||
|
return this.libraryItem.media || {}
|
||||||
|
},
|
||||||
|
libraryFiles() {
|
||||||
|
return this.libraryItem.libraryFiles || []
|
||||||
|
},
|
||||||
|
userToken() {
|
||||||
|
return this.$store.getters['user/getToken']
|
||||||
|
},
|
||||||
|
userCanUpdate() {
|
||||||
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
|
},
|
||||||
|
userCanDownload() {
|
||||||
|
return this.$store.getters['user/getUserCanDownload']
|
||||||
|
},
|
||||||
|
isMissing() {
|
||||||
|
return this.libraryItem.isMissing
|
||||||
|
},
|
||||||
|
showDownload() {
|
||||||
|
return this.userCanDownload && !this.isMissing
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
init() {
|
||||||
|
this.tracks = this.media.tracks || []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
|
||||||
|
<!-- Merge to m4b -->
|
||||||
|
<div v-if="showM4bDownload" class="w-full border border-black-200 p-4 my-8">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div>
|
||||||
|
<p class="text-lg">Make M4B Audiobook File <span class="text-error">*</span></p>
|
||||||
|
<p class="max-w-sm text-sm pt-2 text-gray-300">Generate a .M4B audiobook file with embedded metadata, cover image, and chapters. <br /><span class="text-warning">*</span> Does not delete existing audio files.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<div>
|
||||||
|
<p v-if="abmergeStatus === $constants.DownloadStatus.FAILED" class="text-error mb-2">Download Failed</p>
|
||||||
|
<p v-if="abmergeStatus === $constants.DownloadStatus.READY" class="text-success mb-2">Download Ready!</p>
|
||||||
|
<p v-if="abmergeStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
|
||||||
|
|
||||||
|
<ui-btn v-if="abmergeStatus !== $constants.DownloadStatus.READY" :loading="abmergeStatus === $constants.DownloadStatus.PENDING" :disabled="tempDisable" @click="startAudiobookMerge">Start Merge</ui-btn>
|
||||||
|
<div v-else>
|
||||||
|
<div class="flex">
|
||||||
|
<ui-btn @click="downloadWithProgress(abmergeDownload)">Download</ui-btn>
|
||||||
|
<ui-icon-btn small icon="delete" bg-color="error" class="ml-2" @click="removeDownload" />
|
||||||
|
</div>
|
||||||
|
<p class="px-0.5 py-1 text-sm font-mono text-center">Size: {{ $bytesPretty(abmergeDownload.size) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Split to mp3 -->
|
||||||
|
<div v-if="showMp3Split && showExperimentalFeatures" class="w-full border border-black-200 p-4 my-8">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div>
|
||||||
|
<p class="text-lg">Split M4B to MP3's</p>
|
||||||
|
<p class="max-w-sm text-sm pt-2 text-gray-300">Generate multiple MP3's split by chapters with embedded metadata, cover image, and chapters. <br /><span class="text-warning">*</span> Does not delete existing audio files.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<div>
|
||||||
|
<p v-if="abmergeStatus === $constants.DownloadStatus.FAILED" class="text-error mb-2">Download Failed</p>
|
||||||
|
<p v-if="abmergeStatus === $constants.DownloadStatus.READY" class="text-success mb-2">Download Ready!</p>
|
||||||
|
<p v-if="abmergeStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
|
||||||
|
|
||||||
|
<ui-btn v-if="abmergeStatus !== $constants.DownloadStatus.READY" :loading="abmergeStatus === $constants.DownloadStatus.PENDING" :disabled="true" @click="startAudiobookMerge">Not yet implemented</ui-btn>
|
||||||
|
<div v-else>
|
||||||
|
<div class="flex">
|
||||||
|
<ui-btn @click="downloadWithProgress(abmergeDownload)">Download</ui-btn>
|
||||||
|
<ui-icon-btn small icon="delete" bg-color="error" class="ml-2" @click="removeDownload" />
|
||||||
|
</div>
|
||||||
|
<p class="px-0.5 py-1 text-sm font-mono text-center">Size: {{ $bytesPretty(abmergeDownload.size) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Embed Metadata -->
|
||||||
|
<div v-if="mediaTracks.length && showExperimentalFeatures" class="w-full border border-black-200 p-4 my-8">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div>
|
||||||
|
<p class="text-lg">Embed Metadata</p>
|
||||||
|
<p class="max-w-sm text-sm pt-2 text-gray-300">Embed metadata into audio files including cover image and chapters. <br /><span class="text-warning">*</span> Modifies audio files.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<div>
|
||||||
|
<ui-btn :to="`/item/${libraryItemId}/manage`" class="flex items-center"
|
||||||
|
>Open Manager
|
||||||
|
<span class="material-icons text-lg ml-2">launch</span>
|
||||||
|
</ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="showM4bDownload" class="text-left text-base mb-4 py-4">
|
||||||
|
<span class="text-error">* <strong>Experimental</strong></span
|
||||||
|
> - M4b merge can take several minutes and will be stored in <span class="bg-primary bg-opacity-75 font-mono p-1 text-base">/metadata/downloads</span>. After the download is ready, it will remain available for 60 minutes, then be deleted. Download will timeout after 20 minutes.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- <p v-if="isSingleM4b" class="text-lg text-center my-8">Audiobook is already a single m4b!</p> -->
|
||||||
|
<p v-if="!mediaTracks.length" class="text-lg text-center my-8">No audio tracks to merge</p>
|
||||||
|
|
||||||
|
<div v-if="isDownloading" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||||
|
<div class="w-80 border border-black-400 bg-bg rounded-xl h-20">
|
||||||
|
<div class="w-full h-full flex items-center justify-center">
|
||||||
|
<p class="text-lg">Download.... {{ downloadPercent }}%</p>
|
||||||
|
<p class="w-24 font-mono pl-8 text-right">
|
||||||
|
{{ downloadAmount }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
processing: Boolean,
|
||||||
|
libraryItem: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
tempDisable: false,
|
||||||
|
isDownloading: false,
|
||||||
|
downloadPercent: '0',
|
||||||
|
downloadAmount: '0 KB'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
abmergeStatus(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.tempDisable = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
showExperimentalFeatures() {
|
||||||
|
return this.$store.state.showExperimentalFeatures
|
||||||
|
},
|
||||||
|
libraryItemId() {
|
||||||
|
return this.libraryItem ? this.libraryItem.id : null
|
||||||
|
},
|
||||||
|
media() {
|
||||||
|
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||||
|
},
|
||||||
|
downloads() {
|
||||||
|
return this.$store.getters['downloads/getDownloads'](this.libraryItemId)
|
||||||
|
},
|
||||||
|
abmergeDownload() {
|
||||||
|
return this.downloads.find((d) => d.type === 'abmerge')
|
||||||
|
},
|
||||||
|
abmergeStatus() {
|
||||||
|
return this.abmergeDownload ? this.abmergeDownload.status : false
|
||||||
|
},
|
||||||
|
libraryFiles() {
|
||||||
|
return this.libraryItem.libraryFiles
|
||||||
|
},
|
||||||
|
totalFiles() {
|
||||||
|
return this.libraryFiles.length
|
||||||
|
},
|
||||||
|
mediaTracks() {
|
||||||
|
return this.media.tracks || []
|
||||||
|
},
|
||||||
|
isSingleM4b() {
|
||||||
|
return this.mediaTracks.length === 1 && this.mediaTracks[0].metadata.ext.toLowerCase() === '.m4b'
|
||||||
|
},
|
||||||
|
chapters() {
|
||||||
|
return this.media.chapters || []
|
||||||
|
},
|
||||||
|
showM4bDownload() {
|
||||||
|
if (!this.mediaTracks.length) return false
|
||||||
|
return !this.isSingleM4b
|
||||||
|
},
|
||||||
|
showMp3Split() {
|
||||||
|
if (!this.mediaTracks.length) return false
|
||||||
|
return this.isSingleM4b && this.chapters.length
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
removeDownload() {
|
||||||
|
if (!this.abmergeDownload) return
|
||||||
|
if (!confirm(`Are you sure you want to remove this merge download?`)) return
|
||||||
|
|
||||||
|
var downloadId = this.abmergeDownload.id
|
||||||
|
|
||||||
|
this.tempDisable = true
|
||||||
|
this.$axios
|
||||||
|
.$delete(`/api/download/${downloadId}`)
|
||||||
|
.then(() => {
|
||||||
|
this.tempDisable = false
|
||||||
|
this.$toast.success('Merge download deleted')
|
||||||
|
this.$store.commit('downloads/removeDownload', { id: downloadId })
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
|
||||||
|
this.$toast.error(errorMsg)
|
||||||
|
this.tempDisable = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
startAudiobookMerge() {
|
||||||
|
this.tempDisable = true
|
||||||
|
|
||||||
|
this.$axios
|
||||||
|
.$get(`/api/audiobook-merge/${this.libraryItemId}`)
|
||||||
|
.then(() => {
|
||||||
|
this.tempDisable = false
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
|
||||||
|
this.$toast.error(errorMsg)
|
||||||
|
this.tempDisable = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
downloadWithProgress(download) {
|
||||||
|
var downloadId = download.id
|
||||||
|
var downloadUrl = `${process.env.serverUrl}/api/download/${downloadId}`
|
||||||
|
var filename = download.filename
|
||||||
|
|
||||||
|
this.isDownloading = true
|
||||||
|
|
||||||
|
var request = new XMLHttpRequest()
|
||||||
|
request.responseType = 'blob'
|
||||||
|
request.open('get', downloadUrl, true)
|
||||||
|
request.setRequestHeader('Authorization', `Bearer ${this.$store.getters['user/getToken']}`)
|
||||||
|
request.send()
|
||||||
|
|
||||||
|
request.onreadystatechange = () => {
|
||||||
|
if (request.readyState === 4) {
|
||||||
|
this.isDownloading = false
|
||||||
|
}
|
||||||
|
if (request.readyState == 4 && request.status == 200) {
|
||||||
|
const url = window.URL.createObjectURL(request.response)
|
||||||
|
|
||||||
|
const anchor = document.createElement('a')
|
||||||
|
anchor.href = url
|
||||||
|
anchor.download = filename
|
||||||
|
document.body.appendChild(anchor)
|
||||||
|
anchor.click()
|
||||||
|
setTimeout(() => {
|
||||||
|
if (anchor) anchor.remove()
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
request.onerror = (err) => {
|
||||||
|
console.error('Download error', err)
|
||||||
|
this.isDownloading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
request.onprogress = (e) => {
|
||||||
|
const percent_complete = Math.floor((e.loaded / e.total) * 100)
|
||||||
|
this.downloadAmount = this.$bytesPretty(e.loaded)
|
||||||
|
this.downloadPercent = percent_complete
|
||||||
|
|
||||||
|
// const duration = (new Date().getTime() - startTime) / 1000
|
||||||
|
// const bps = e.loaded / duration
|
||||||
|
// const kbps = Math.floor(bps / 1024)
|
||||||
|
// const time = (e.total - e.loaded) / bps
|
||||||
|
// const seconds = Math.floor(time % 60)
|
||||||
|
// const minutes = Math.floor(time / 60)
|
||||||
|
// console.log(`${percent_complete}% - ${kbps} Kbps - ${minutes} min ${seconds} sec remaining`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
loadDownloads() {
|
||||||
|
this.$axios
|
||||||
|
.$get(`/api/downloads`)
|
||||||
|
.then((data) => {
|
||||||
|
var pendingDownloads = data.pendingDownloads.map((pd) => {
|
||||||
|
pd.download.status = this.$constants.DownloadStatus.PENDING
|
||||||
|
return pd.download
|
||||||
|
})
|
||||||
|
var downloads = data.downloads.map((d) => {
|
||||||
|
d.status = this.$constants.DownloadStatus.READY
|
||||||
|
return d
|
||||||
|
})
|
||||||
|
var allDownloads = downloads.concat(pendingDownloads)
|
||||||
|
this.$store.commit('downloads/setDownloads', allDownloads)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to load downloads', error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.loadDownloads()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,411 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full h-full overflow-hidden px-4 py-6 relative">
|
||||||
|
<form @submit.prevent="submitSearch">
|
||||||
|
<div class="flex items-center justify-start -mx-1 h-20">
|
||||||
|
<div class="w-40 px-1">
|
||||||
|
<ui-dropdown v-model="provider" :items="providers" label="Provider" small />
|
||||||
|
</div>
|
||||||
|
<div class="w-72 px-1">
|
||||||
|
<ui-text-input-with-label v-model="searchTitle" :label="searchTitleLabel" placeholder="Search" />
|
||||||
|
</div>
|
||||||
|
<div v-show="provider != 'itunes'" class="w-72 px-1">
|
||||||
|
<ui-text-input-with-label v-model="searchAuthor" label="Author" />
|
||||||
|
</div>
|
||||||
|
<ui-btn class="mt-5 ml-1" type="submit">Search</ui-btn>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div v-show="processing" class="flex h-full items-center justify-center">
|
||||||
|
<p>Loading...</p>
|
||||||
|
</div>
|
||||||
|
<div v-show="!processing && !searchResults.length && hasSearched" class="flex h-full items-center justify-center">
|
||||||
|
<p>No Results</p>
|
||||||
|
</div>
|
||||||
|
<div v-show="!processing" class="w-full max-h-full overflow-y-auto overflow-x-hidden matchListWrapper">
|
||||||
|
<template v-for="(res, index) in searchResults">
|
||||||
|
<cards-book-match-card :key="index" :book="res" :is-podcast="isPodcast" :book-cover-aspect-ratio="bookCoverAspectRatio" @select="selectMatch" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedMatch" class="absolute top-0 left-0 w-full bg-bg h-full p-8 max-h-full overflow-y-auto overflow-x-hidden">
|
||||||
|
<div class="flex mb-2">
|
||||||
|
<div class="w-8 h-8 rounded-full hover:bg-white hover:bg-opacity-10 flex items-center justify-center cursor-pointer" @click="selectedMatch = null">
|
||||||
|
<span class="material-icons text-3xl">arrow_back</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xl pl-3">Update Book Details</p>
|
||||||
|
</div>
|
||||||
|
<form @submit.prevent="submitMatchUpdate">
|
||||||
|
<div v-if="selectedMatch.cover" class="flex items-center py-2">
|
||||||
|
<ui-checkbox v-model="selectedMatchUsage.cover" />
|
||||||
|
<ui-text-input-with-label v-model="selectedMatch.cover" :disabled="!selectedMatchUsage.cover" label="Cover" class="flex-grow ml-4" />
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedMatch.title" class="flex items-center py-2">
|
||||||
|
<ui-checkbox v-model="selectedMatchUsage.title" />
|
||||||
|
<div class="flex-grow ml-4">
|
||||||
|
<ui-text-input-with-label v-model="selectedMatch.title" :disabled="!selectedMatchUsage.title" label="Title" />
|
||||||
|
<p v-if="mediaMetadata.title" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.title || '' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedMatch.subtitle" class="flex items-center py-2">
|
||||||
|
<ui-checkbox v-model="selectedMatchUsage.subtitle" />
|
||||||
|
<div class="flex-grow ml-4">
|
||||||
|
<ui-text-input-with-label v-model="selectedMatch.subtitle" :disabled="!selectedMatchUsage.subtitle" label="Subtitle" />
|
||||||
|
<p v-if="mediaMetadata.subtitle" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.subtitle || '' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedMatch.author" class="flex items-center py-2">
|
||||||
|
<ui-checkbox v-model="selectedMatchUsage.author" />
|
||||||
|
<div class="flex-grow ml-4">
|
||||||
|
<ui-text-input-with-label v-model="selectedMatch.author" :disabled="!selectedMatchUsage.author" label="Author" />
|
||||||
|
<p v-if="mediaMetadata.authorName" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.authorName || '' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedMatch.narrator" class="flex items-center py-2">
|
||||||
|
<ui-checkbox v-model="selectedMatchUsage.narrator" />
|
||||||
|
<div class="flex-grow ml-4">
|
||||||
|
<ui-text-input-with-label v-model="selectedMatch.narrator" :disabled="!selectedMatchUsage.narrator" label="Narrator" />
|
||||||
|
<p v-if="mediaMetadata.narratorName" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.narratorName || '' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedMatch.description" class="flex items-center py-2">
|
||||||
|
<ui-checkbox v-model="selectedMatchUsage.description" />
|
||||||
|
<ui-textarea-with-label v-model="selectedMatch.description" :rows="3" :disabled="!selectedMatchUsage.description" label="Description" class="flex-grow ml-4" />
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedMatch.publisher" class="flex items-center py-2">
|
||||||
|
<ui-checkbox v-model="selectedMatchUsage.publisher" />
|
||||||
|
<div class="flex-grow ml-4">
|
||||||
|
<ui-text-input-with-label v-model="selectedMatch.publisher" :disabled="!selectedMatchUsage.publisher" label="Publisher" />
|
||||||
|
<p v-if="mediaMetadata.publisher" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.publisher || '' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedMatch.publishedYear" class="flex items-center py-2">
|
||||||
|
<ui-checkbox v-model="selectedMatchUsage.publishedYear" />
|
||||||
|
<div class="flex-grow ml-4">
|
||||||
|
<ui-text-input-with-label v-model="selectedMatch.publishedYear" :disabled="!selectedMatchUsage.publishedYear" label="Published Year" />
|
||||||
|
<p v-if="mediaMetadata.publishedYear" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.publishedYear || '' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedMatch.series" class="flex items-center py-2">
|
||||||
|
<ui-checkbox v-model="selectedMatchUsage.series" />
|
||||||
|
<div class="flex-grow ml-4">
|
||||||
|
<ui-text-input-with-label v-model="selectedMatch.series" :disabled="!selectedMatchUsage.series" label="Series" />
|
||||||
|
<p v-if="mediaMetadata.seriesName" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.seriesName || '' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedMatch.volumeNumber" class="flex items-center py-2">
|
||||||
|
<ui-checkbox v-model="selectedMatchUsage.volumeNumber" />
|
||||||
|
<ui-text-input-with-label v-model="selectedMatch.volumeNumber" :disabled="!selectedMatchUsage.volumeNumber" label="Volume Number" class="flex-grow ml-4" />
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedMatch.isbn" class="flex items-center py-2">
|
||||||
|
<ui-checkbox v-model="selectedMatchUsage.isbn" />
|
||||||
|
<div class="flex-grow ml-4">
|
||||||
|
<ui-text-input-with-label v-model="selectedMatch.isbn" :disabled="!selectedMatchUsage.isbn" label="ISBN" />
|
||||||
|
<p v-if="mediaMetadata.isbn" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.isbn || '' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedMatch.asin" class="flex items-center py-2">
|
||||||
|
<ui-checkbox v-model="selectedMatchUsage.asin" />
|
||||||
|
<div class="flex-grow ml-4">
|
||||||
|
<ui-text-input-with-label v-model="selectedMatch.asin" :disabled="!selectedMatchUsage.asin" label="ASIN" />
|
||||||
|
<p v-if="mediaMetadata.asin" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.asin || '' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedMatch.itunesId" class="flex items-center py-2">
|
||||||
|
<ui-checkbox v-model="selectedMatchUsage.itunesId" />
|
||||||
|
<div class="flex-grow ml-4">
|
||||||
|
<ui-text-input-with-label v-model="selectedMatch.itunesId" type="number" :disabled="!selectedMatchUsage.itunesId" label="iTunes ID" />
|
||||||
|
<p v-if="mediaMetadata.itunesId" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.itunesId || '' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedMatch.feedUrl" class="flex items-center py-2">
|
||||||
|
<ui-checkbox v-model="selectedMatchUsage.feedUrl" />
|
||||||
|
<div class="flex-grow ml-4">
|
||||||
|
<ui-text-input-with-label v-model="selectedMatch.feedUrl" :disabled="!selectedMatchUsage.feedUrl" label="RSS Feed URL" />
|
||||||
|
<p v-if="mediaMetadata.feedUrl" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.feedUrl || '' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedMatch.itunesPageUrl" class="flex items-center py-2">
|
||||||
|
<ui-checkbox v-model="selectedMatchUsage.itunesPageUrl" />
|
||||||
|
<div class="flex-grow ml-4">
|
||||||
|
<ui-text-input-with-label v-model="selectedMatch.itunesPageUrl" :disabled="!selectedMatchUsage.itunesPageUrl" label="iTunes Page URL" />
|
||||||
|
<p v-if="mediaMetadata.itunesPageUrl" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.itunesPageUrl || '' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedMatch.releaseDate" class="flex items-center py-2">
|
||||||
|
<ui-checkbox v-model="selectedMatchUsage.releaseDate" />
|
||||||
|
<div class="flex-grow ml-4">
|
||||||
|
<ui-text-input-with-label v-model="selectedMatch.releaseDate" :disabled="!selectedMatchUsage.releaseDate" label="Release Date" />
|
||||||
|
<p v-if="mediaMetadata.releaseDate" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.releaseDate || '' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-end py-2">
|
||||||
|
<ui-btn color="success" type="submit">Update</ui-btn>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
processing: Boolean,
|
||||||
|
libraryItem: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
libraryItemId: null,
|
||||||
|
searchTitle: null,
|
||||||
|
searchAuthor: null,
|
||||||
|
lastSearch: null,
|
||||||
|
provider: 'google',
|
||||||
|
searchResults: [],
|
||||||
|
hasSearched: false,
|
||||||
|
selectedMatch: null,
|
||||||
|
selectedMatchUsage: {
|
||||||
|
title: true,
|
||||||
|
subtitle: true,
|
||||||
|
cover: true,
|
||||||
|
author: true,
|
||||||
|
narrator: true,
|
||||||
|
description: true,
|
||||||
|
publisher: true,
|
||||||
|
publishedYear: true,
|
||||||
|
series: true,
|
||||||
|
volumeNumber: true,
|
||||||
|
asin: true,
|
||||||
|
isbn: true,
|
||||||
|
// Podcast specific
|
||||||
|
itunesPageUrl: true,
|
||||||
|
itunesId: true,
|
||||||
|
feedUrl: true,
|
||||||
|
releaseDate: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
libraryItem: {
|
||||||
|
immediate: true,
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
isProcessing: {
|
||||||
|
get() {
|
||||||
|
return this.processing
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('update:processing', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
bookCoverAspectRatio() {
|
||||||
|
return this.$store.getters['getBookCoverAspectRatio']
|
||||||
|
},
|
||||||
|
providers() {
|
||||||
|
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
|
||||||
|
return this.$store.state.scanners.providers
|
||||||
|
},
|
||||||
|
searchTitleLabel() {
|
||||||
|
if (this.provider == 'audible') return 'Search Title or ASIN'
|
||||||
|
else if (this.provider == 'itunes') return 'Search Term'
|
||||||
|
return 'Search Title'
|
||||||
|
},
|
||||||
|
media() {
|
||||||
|
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||||
|
},
|
||||||
|
mediaMetadata() {
|
||||||
|
return this.media.metadata || {}
|
||||||
|
},
|
||||||
|
mediaType() {
|
||||||
|
return this.libraryItem ? this.libraryItem.mediaType : null
|
||||||
|
},
|
||||||
|
isPodcast() {
|
||||||
|
return this.mediaType == 'podcast'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
persistProvider() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('book-provider', this.provider)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('PersistProvider', error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getSearchQuery() {
|
||||||
|
if (this.isPodcast) return `term=${this.searchTitle}`
|
||||||
|
var searchQuery = `provider=${this.provider}&fallbackTitleOnly=1&title=${this.searchTitle}`
|
||||||
|
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor}`
|
||||||
|
return searchQuery
|
||||||
|
},
|
||||||
|
submitSearch() {
|
||||||
|
if (!this.searchTitle) {
|
||||||
|
this.$toast.warning('Search title is required')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.persistProvider()
|
||||||
|
this.runSearch()
|
||||||
|
},
|
||||||
|
async runSearch() {
|
||||||
|
var searchQuery = this.getSearchQuery()
|
||||||
|
if (this.lastSearch === searchQuery) return
|
||||||
|
this.searchResults = []
|
||||||
|
this.isProcessing = true
|
||||||
|
this.lastSearch = searchQuery
|
||||||
|
var searchEntity = this.isPodcast ? 'podcast' : 'books'
|
||||||
|
var results = await this.$axios.$get(`/api/search/${searchEntity}?${searchQuery}`).catch((error) => {
|
||||||
|
console.error('Failed', error)
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
// console.log('Got search results', results)
|
||||||
|
results = (results || []).filter((res) => {
|
||||||
|
return !!res.title
|
||||||
|
})
|
||||||
|
|
||||||
|
if (this.isPodcast) {
|
||||||
|
// Map to match PodcastMetadata keys
|
||||||
|
results = results.map((res) => {
|
||||||
|
res.itunesPageUrl = res.pageUrl || null
|
||||||
|
res.itunesId = res.id || null
|
||||||
|
res.author = res.artistName || null
|
||||||
|
return res
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
this.searchResults = results || []
|
||||||
|
this.isProcessing = false
|
||||||
|
this.hasSearched = true
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
this.selectedMatch = null
|
||||||
|
this.selectedMatchUsage = {
|
||||||
|
title: true,
|
||||||
|
subtitle: true,
|
||||||
|
cover: true,
|
||||||
|
author: true,
|
||||||
|
narrator: true,
|
||||||
|
description: true,
|
||||||
|
publisher: true,
|
||||||
|
publishedYear: true,
|
||||||
|
series: true,
|
||||||
|
volumeNumber: true,
|
||||||
|
asin: true,
|
||||||
|
isbn: true,
|
||||||
|
// Podcast specific
|
||||||
|
itunesPageUrl: true,
|
||||||
|
itunesId: true,
|
||||||
|
feedUrl: true,
|
||||||
|
releaseDate: true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.libraryItem.id !== this.libraryItemId) {
|
||||||
|
this.searchResults = []
|
||||||
|
this.hasSearched = false
|
||||||
|
this.libraryItemId = this.libraryItem.id
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.libraryItem.media || !this.libraryItem.media.metadata.title) {
|
||||||
|
this.searchTitle = null
|
||||||
|
this.searchAuthor = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.searchTitle = this.libraryItem.media.metadata.title
|
||||||
|
this.searchAuthor = this.libraryItem.media.metadata.authorName || ''
|
||||||
|
if (this.isPodcast) this.provider = 'itunes'
|
||||||
|
else this.provider = localStorage.getItem('book-provider') || 'google'
|
||||||
|
},
|
||||||
|
selectMatch(match) {
|
||||||
|
this.selectedMatch = match
|
||||||
|
},
|
||||||
|
buildMatchUpdatePayload() {
|
||||||
|
var updatePayload = {}
|
||||||
|
|
||||||
|
var volumeNumber = this.selectedMatchUsage.volumeNumber ? this.selectedMatch.volumeNumber || null : null
|
||||||
|
for (const key in this.selectedMatchUsage) {
|
||||||
|
if (this.selectedMatchUsage[key] && this.selectedMatch[key]) {
|
||||||
|
if (key === 'series') {
|
||||||
|
var seriesItem = {
|
||||||
|
id: `new-${Math.floor(Math.random() * 10000)}`,
|
||||||
|
name: this.selectedMatch[key],
|
||||||
|
sequence: volumeNumber
|
||||||
|
}
|
||||||
|
updatePayload.series = [seriesItem]
|
||||||
|
} else if (key === 'author' && !this.isPodcast) {
|
||||||
|
var authorItem = {
|
||||||
|
id: `new-${Math.floor(Math.random() * 10000)}`,
|
||||||
|
name: this.selectedMatch[key]
|
||||||
|
}
|
||||||
|
updatePayload.authors = [authorItem]
|
||||||
|
} else if (key === 'narrator') {
|
||||||
|
updatePayload.narrators = [this.selectedMatch[key]]
|
||||||
|
} else if (key === 'itunesId') {
|
||||||
|
updatePayload.itunesId = Number(this.selectedMatch[key])
|
||||||
|
} else if (key !== 'volumeNumber') {
|
||||||
|
updatePayload[key] = this.selectedMatch[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return updatePayload
|
||||||
|
},
|
||||||
|
async submitMatchUpdate() {
|
||||||
|
var updatePayload = this.buildMatchUpdatePayload()
|
||||||
|
if (!Object.keys(updatePayload).length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.isProcessing = true
|
||||||
|
|
||||||
|
if (updatePayload.cover) {
|
||||||
|
var coverPayload = {
|
||||||
|
url: updatePayload.cover
|
||||||
|
}
|
||||||
|
var success = await this.$axios.$post(`/api/items/${this.libraryItemId}/cover`, coverPayload).catch((error) => {
|
||||||
|
console.error('Failed to update', error)
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
if (success) {
|
||||||
|
this.$toast.success('Item Cover Updated')
|
||||||
|
} else {
|
||||||
|
this.$toast.error('Item Cover Failed to Update')
|
||||||
|
}
|
||||||
|
console.log('Updated cover')
|
||||||
|
delete updatePayload.cover
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(updatePayload).length) {
|
||||||
|
var mediaUpdatePayload = {
|
||||||
|
metadata: updatePayload
|
||||||
|
}
|
||||||
|
var updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, mediaUpdatePayload).catch((error) => {
|
||||||
|
console.error('Failed to update', error)
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
if (updateResult) {
|
||||||
|
if (updateResult.updated) {
|
||||||
|
this.$toast.success('Item details updated')
|
||||||
|
} else {
|
||||||
|
this.$toast.info('No detail updates were necessary')
|
||||||
|
}
|
||||||
|
this.selectedMatch = null
|
||||||
|
this.$emit('selectTab', 'details')
|
||||||
|
} else {
|
||||||
|
this.$toast.error('Item Details Failed to Update')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.selectedMatch = null
|
||||||
|
}
|
||||||
|
this.isProcessing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.matchListWrapper {
|
||||||
|
height: calc(100% - 80px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,20 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-full px-4 py-2 mb-4">
|
<div class="w-full h-full px-4 py-2 mb-4">
|
||||||
<div v-show="showDirectoryPicker" class="flex items-center py-1 mb-2">
|
|
||||||
<span class="material-icons text-3xl cursor-pointer hover:text-gray-300" @click="backArrowPress">arrow_back</span>
|
|
||||||
<p class="px-4 text-xl">{{ title }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="!showDirectoryPicker" class="w-full h-full py-4">
|
<div v-if="!showDirectoryPicker" class="w-full h-full py-4">
|
||||||
<div class="flex flex-wrap md:flex-nowrap -mx-1">
|
<div class="flex flex-wrap md:flex-nowrap -mx-1">
|
||||||
|
<div class="w-2/5 md:w-72 px-1 py-1 md:py-0">
|
||||||
|
<ui-dropdown v-model="mediaType" :items="mediaTypes" label="Media Type" :disabled="!isNew" small @input="changedMediaType" />
|
||||||
|
</div>
|
||||||
<div class="w-full md:flex-grow px-1 py-1 md:py-0">
|
<div class="w-full md:flex-grow px-1 py-1 md:py-0">
|
||||||
<ui-text-input-with-label v-model="name" label="Library Name" />
|
<ui-text-input-with-label v-model="name" label="Library Name" @blur="nameBlurred" />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-1/2 md:w-72 px-1 py-1 md:py-0">
|
<div class="w-1/5 md:w-18 px-1 py-1 md:py-0">
|
||||||
<ui-media-type-picker v-model="mediaType" />
|
<ui-media-icon-picker v-model="icon" @input="iconChanged" />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-1/2 md:w-72 px-1 py-1 md:py-0">
|
<div class="w-2/5 md:w-72 px-1 py-1 md:py-0">
|
||||||
<ui-dropdown v-model="provider" :items="providers" label="Metadata Provider" small />
|
<ui-dropdown v-model="provider" :items="providers" label="Metadata Provider" small @input="formUpdated" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -22,36 +20,25 @@
|
|||||||
<p class="px-1 text-sm font-semibold">Folders</p>
|
<p class="px-1 text-sm font-semibold">Folders</p>
|
||||||
<div v-for="(folder, index) in folders" :key="index" class="w-full flex items-center py-1 px-2">
|
<div v-for="(folder, index) in folders" :key="index" class="w-full flex items-center py-1 px-2">
|
||||||
<span class="material-icons bg-opacity-50 mr-2 text-yellow-200" style="font-size: 1.2rem">folder</span>
|
<span class="material-icons bg-opacity-50 mr-2 text-yellow-200" style="font-size: 1.2rem">folder</span>
|
||||||
<ui-editable-text v-model="folder.fullPath" type="text" class="w-full" />
|
<ui-editable-text v-model="folder.fullPath" readonly type="text" class="w-full" />
|
||||||
<span v-show="folders.length > 1" class="material-icons ml-2 cursor-pointer hover:text-error" @click="removeFolder(folder)">close</span>
|
<span v-show="folders.length > 1" class="material-icons ml-2 cursor-pointer hover:text-error" @click="removeFolder(folder)">close</span>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="!folders.length" class="text-sm text-gray-300 px-1 py-2">No folders</p>
|
<div class="flex py-1 px-2 items-center w-full">
|
||||||
<ui-btn class="w-full mt-2" color="primary" @click="showDirectoryPicker = true">Browse for Folder</ui-btn>
|
<span class="material-icons bg-opacity-50 mr-2 text-yellow-200" style="font-size: 1.2rem">folder</span>
|
||||||
</div>
|
<ui-editable-text v-model="newFolderPath" placeholder="New folder path" type="text" class="w-full" @blur="newFolderInputBlurred" />
|
||||||
<div class="absolute bottom-0 left-0 w-full py-4 px-4">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="flex-grow" />
|
|
||||||
<ui-btn v-show="!disableSubmit" color="success" :disabled="disableSubmit" @click="submit">{{ library ? 'Update Library' : 'Create Library' }}</ui-btn>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<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" @select="selectFolder" />
|
|
||||||
|
|
||||||
<div v-if="!showDirectoryPicker">
|
|
||||||
<div class="flex items-center pt-2">
|
|
||||||
<ui-toggle-switch v-if="!globalWatcherDisabled" v-model="disableWatcher" />
|
|
||||||
<ui-toggle-switch v-else disabled :value="false" />
|
|
||||||
<p class="pl-4 text-lg">Disable folder watcher for library</p>
|
|
||||||
</div>
|
|
||||||
<p v-if="globalWatcherDisabled" class="text-xs text-warning">*Watcher is disabled globally in server settings</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
|
isNew: Boolean,
|
||||||
library: {
|
library: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => null
|
default: () => null
|
||||||
@@ -61,40 +48,76 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
name: '',
|
name: '',
|
||||||
provider: '',
|
provider: 'google',
|
||||||
mediaType: '',
|
icon: '',
|
||||||
folders: [],
|
folders: [],
|
||||||
showDirectoryPicker: false,
|
showDirectoryPicker: false,
|
||||||
disableWatcher: false
|
newFolderPath: '',
|
||||||
|
mediaType: null,
|
||||||
|
mediaTypes: [
|
||||||
|
{
|
||||||
|
value: 'book',
|
||||||
|
text: 'Books'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'podcast',
|
||||||
|
text: 'Podcasts'
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
title() {
|
|
||||||
if (this.showDirectoryPicker) return 'Choose a Folder'
|
|
||||||
return ''
|
|
||||||
},
|
|
||||||
folderPaths() {
|
folderPaths() {
|
||||||
return this.folders.map((f) => f.fullPath)
|
return this.folders.map((f) => f.fullPath)
|
||||||
},
|
},
|
||||||
disableSubmit() {
|
|
||||||
if (!this.library) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
var newfolderpaths = this.folderPaths.join(',')
|
|
||||||
var origfolderpaths = this.library.folders.map((f) => f.fullPath).join(',')
|
|
||||||
|
|
||||||
return newfolderpaths === origfolderpaths && this.name === this.library.name && this.provider === this.library.provider && this.disableWatcher === this.library.disableWatcher && this.mediaType === this.library.mediaType
|
|
||||||
},
|
|
||||||
providers() {
|
providers() {
|
||||||
|
if (this.mediaType === 'podcast') return this.$store.state.scanners.podcastProviders
|
||||||
return this.$store.state.scanners.providers
|
return this.$store.state.scanners.providers
|
||||||
},
|
|
||||||
globalWatcherDisabled() {
|
|
||||||
return this.$store.getters['getServerSetting']('scannerDisableWatcher')
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
browseForFolder() {
|
||||||
|
this.showDirectoryPicker = true
|
||||||
|
},
|
||||||
|
getLibraryData() {
|
||||||
|
return {
|
||||||
|
name: this.name,
|
||||||
|
provider: this.provider,
|
||||||
|
folders: this.folders,
|
||||||
|
icon: this.icon,
|
||||||
|
mediaType: this.mediaType
|
||||||
|
}
|
||||||
|
},
|
||||||
|
formUpdated() {
|
||||||
|
this.$emit('update', this.getLibraryData())
|
||||||
|
},
|
||||||
|
newFolderInputBlurred() {
|
||||||
|
if (this.newFolderPath) {
|
||||||
|
this.folders.push({ fullPath: this.newFolderPath })
|
||||||
|
this.newFolderPath = ''
|
||||||
|
this.formUpdated()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
iconChanged() {
|
||||||
|
this.formUpdated()
|
||||||
|
},
|
||||||
|
nameBlurred() {
|
||||||
|
if (this.name !== this.library.name) {
|
||||||
|
this.formUpdated()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
changedMediaType() {
|
||||||
|
this.provider = this.providers[0].value
|
||||||
|
this.formUpdated()
|
||||||
|
},
|
||||||
|
selectFolder(fullPath) {
|
||||||
|
this.folders.push({ fullPath })
|
||||||
|
this.showDirectoryPicker = false
|
||||||
|
this.formUpdated()
|
||||||
|
},
|
||||||
removeFolder(folder) {
|
removeFolder(folder) {
|
||||||
this.folders = this.folders.filter((f) => f.fullPath !== folder.fullPath)
|
this.folders = this.folders.filter((f) => f.fullPath !== folder.fullPath)
|
||||||
|
this.formUpdated()
|
||||||
},
|
},
|
||||||
backArrowPress() {
|
backArrowPress() {
|
||||||
if (this.showDirectoryPicker) {
|
if (this.showDirectoryPicker) {
|
||||||
@@ -103,94 +126,11 @@ export default {
|
|||||||
},
|
},
|
||||||
init() {
|
init() {
|
||||||
this.name = this.library ? this.library.name : ''
|
this.name = this.library ? this.library.name : ''
|
||||||
this.provider = this.library ? this.library.provider : ''
|
this.provider = this.library ? this.library.provider : 'google'
|
||||||
this.folders = this.library ? this.library.folders.map((p) => ({ ...p })) : []
|
this.folders = this.library ? this.library.folders.map((p) => ({ ...p })) : []
|
||||||
this.disableWatcher = this.library ? !!this.library.disableWatcher : false
|
this.icon = this.library ? this.library.icon : 'default'
|
||||||
this.mediaType = this.library ? this.library.mediaType : 'default'
|
this.mediaType = this.library ? this.library.mediaType : 'book'
|
||||||
this.showDirectoryPicker = false
|
this.showDirectoryPicker = false
|
||||||
},
|
|
||||||
selectFolder(fullPath) {
|
|
||||||
this.folders.push({ fullPath })
|
|
||||||
this.showDirectoryPicker = false
|
|
||||||
},
|
|
||||||
submit() {
|
|
||||||
if (this.library) {
|
|
||||||
this.updateLibrary()
|
|
||||||
} else {
|
|
||||||
this.createLibrary()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
updateLibrary() {
|
|
||||||
if (!this.name) {
|
|
||||||
this.$toast.error('Library must have a name')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!this.folders.length) {
|
|
||||||
this.$toast.error('Library must have at least 1 path')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var newLibraryPayload = {
|
|
||||||
name: this.name,
|
|
||||||
provider: this.provider,
|
|
||||||
folders: this.folders,
|
|
||||||
mediaType: this.mediaType,
|
|
||||||
icon: this.mediaType,
|
|
||||||
disableWatcher: this.disableWatcher
|
|
||||||
}
|
|
||||||
|
|
||||||
this.$emit('update:processing', true)
|
|
||||||
this.$axios
|
|
||||||
.$patch(`/api/libraries/${this.library.id}`, newLibraryPayload)
|
|
||||||
.then((res) => {
|
|
||||||
this.$emit('update:processing', false)
|
|
||||||
this.$emit('close')
|
|
||||||
this.$toast.success(`Library "${res.name}" updated successfully`)
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error(error)
|
|
||||||
if (error.response && error.response.data) {
|
|
||||||
this.$toast.error(error.response.data)
|
|
||||||
} else {
|
|
||||||
this.$toast.error('Failed to update library')
|
|
||||||
}
|
|
||||||
this.$emit('update:processing', false)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
createLibrary() {
|
|
||||||
if (!this.name) {
|
|
||||||
this.$toast.error('Library must have a name')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!this.folders.length) {
|
|
||||||
this.$toast.error('Library must have at least 1 path')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var newLibraryPayload = {
|
|
||||||
name: this.name,
|
|
||||||
provider: this.provider,
|
|
||||||
folders: this.folders,
|
|
||||||
mediaType: this.mediaType,
|
|
||||||
icon: this.mediaType,
|
|
||||||
disableWatcher: this.disableWatcher
|
|
||||||
}
|
|
||||||
|
|
||||||
this.$emit('update:processing', true)
|
|
||||||
this.$axios
|
|
||||||
.$post('/api/libraries', newLibraryPayload)
|
|
||||||
.then((res) => {
|
|
||||||
this.$emit('update:processing', false)
|
|
||||||
this.$emit('close')
|
|
||||||
this.$toast.success(`Library "${res.name}" created successfully`)
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error(error)
|
|
||||||
if (error.response && error.response.data) {
|
|
||||||
this.$toast.error(error.response.data)
|
|
||||||
} else {
|
|
||||||
this.$toast.error('Failed to create library')
|
|
||||||
}
|
|
||||||
this.$emit('update:processing', false)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
<template>
|
||||||
|
<modals-modal v-model="show" name="edit-library" :width="700" :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 class="absolute -top-10 left-0 z-10 w-full flex">
|
||||||
|
<template v-for="tab in tabs">
|
||||||
|
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-4 w-full text-sm pt-6 pb-20 rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
|
||||||
|
<component v-if="libraryCopy && show" :is="tabName" :is-new="!library" :library="libraryCopy" :processing.sync="processing" @update="updateLibrary" @close="show = false" />
|
||||||
|
|
||||||
|
<div class="absolute bottom-0 left-0 w-full px-4 py-4 border-t border-opacity-10">
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<ui-btn @click="submit">{{ buttonText }}</ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</modals-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: Boolean,
|
||||||
|
library: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
processing: false,
|
||||||
|
selectedTab: 'details',
|
||||||
|
tabs: [
|
||||||
|
{
|
||||||
|
id: 'details',
|
||||||
|
title: 'Details',
|
||||||
|
component: 'modals-libraries-edit-library'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'settings',
|
||||||
|
title: 'Settings',
|
||||||
|
component: 'modals-libraries-library-settings'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
libraryCopy: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
return this.library ? 'Update Library' : 'New Library'
|
||||||
|
},
|
||||||
|
buttonText() {
|
||||||
|
return this.library ? 'Update Library' : 'Create New Library'
|
||||||
|
},
|
||||||
|
tabName() {
|
||||||
|
var _tab = this.tabs.find((t) => t.id === this.selectedTab)
|
||||||
|
return _tab ? _tab.component : ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
show: {
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
selectTab(tab) {
|
||||||
|
this.selectedTab = tab
|
||||||
|
},
|
||||||
|
updateLibrary(library) {
|
||||||
|
this.mapLibraryToCopy(library)
|
||||||
|
},
|
||||||
|
getNewLibraryData() {
|
||||||
|
return {
|
||||||
|
name: '',
|
||||||
|
provider: 'google',
|
||||||
|
folders: [],
|
||||||
|
icon: 'database',
|
||||||
|
mediaType: 'book',
|
||||||
|
settings: {
|
||||||
|
disableWatcher: false,
|
||||||
|
skipMatchingMediaWithAsin: false,
|
||||||
|
skipMatchingMediaWithIsbn: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
this.selectedTab = 'details'
|
||||||
|
this.libraryCopy = this.getNewLibraryData()
|
||||||
|
if (this.library) {
|
||||||
|
this.mapLibraryToCopy(this.library)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mapLibraryToCopy(library) {
|
||||||
|
for (const key in this.libraryCopy) {
|
||||||
|
if (library[key] !== undefined) {
|
||||||
|
if (key === 'folders') {
|
||||||
|
this.libraryCopy.folders = library.folders.map((f) => ({ ...f }))
|
||||||
|
} else if (key === 'settings') {
|
||||||
|
this.libraryCopy.settings = { ...library.settings }
|
||||||
|
} else {
|
||||||
|
this.libraryCopy[key] = library[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
validate() {
|
||||||
|
if (!this.libraryCopy.name) {
|
||||||
|
this.$toast.error('Library must have a name')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!this.libraryCopy.folders.length) {
|
||||||
|
this.$toast.error('Library must have at least 1 path')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
submit() {
|
||||||
|
if (!this.validate()) return
|
||||||
|
|
||||||
|
if (this.library) {
|
||||||
|
this.submitUpdateLibrary()
|
||||||
|
} else {
|
||||||
|
this.submitCreateLibrary()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getLibraryUpdatePayload() {
|
||||||
|
var updatePayload = {}
|
||||||
|
for (const key in this.libraryCopy) {
|
||||||
|
if (key === 'folders') {
|
||||||
|
if (this.libraryCopy.folders.map((f) => f.fullPath).join(',') !== this.library.folders.map((f) => f.fullPath).join(',')) {
|
||||||
|
updatePayload.folders = [...this.libraryCopy.folders]
|
||||||
|
}
|
||||||
|
} else if (key === 'settings') {
|
||||||
|
for (const settingsKey in this.libraryCopy.settings) {
|
||||||
|
if (this.libraryCopy.settings[settingsKey] !== this.library.settings[settingsKey]) {
|
||||||
|
if (!updatePayload.settings) updatePayload.settings = {}
|
||||||
|
updatePayload.settings[settingsKey] = this.libraryCopy.settings[settingsKey]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (key !== 'mediaType' && this.libraryCopy[key] !== this.library[key]) {
|
||||||
|
updatePayload[key] = this.libraryCopy[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return updatePayload
|
||||||
|
},
|
||||||
|
submitUpdateLibrary() {
|
||||||
|
var newLibraryPayload = this.getLibraryUpdatePayload()
|
||||||
|
if (!Object.keys(newLibraryPayload).length) {
|
||||||
|
this.$toast.info('No updates are necessary')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.processing = true
|
||||||
|
this.$axios
|
||||||
|
.$patch(`/api/libraries/${this.library.id}`, newLibraryPayload)
|
||||||
|
.then((res) => {
|
||||||
|
this.processing = false
|
||||||
|
this.show = false
|
||||||
|
this.$toast.success(`Library "${res.name}" updated successfully`)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error)
|
||||||
|
if (error.response && error.response.data) {
|
||||||
|
this.$toast.error(error.response.data)
|
||||||
|
} else {
|
||||||
|
this.$toast.error('Failed to update library')
|
||||||
|
}
|
||||||
|
this.processing = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
submitCreateLibrary() {
|
||||||
|
this.processing = true
|
||||||
|
this.$axios
|
||||||
|
.$post('/api/libraries', this.libraryCopy)
|
||||||
|
.then((res) => {
|
||||||
|
this.processing = false
|
||||||
|
this.show = false
|
||||||
|
this.$toast.success(`Library "${res.name}" created successfully`)
|
||||||
|
if (!this.$store.state.libraries.currentLibraryId) {
|
||||||
|
console.log('Setting initially library id', res.id)
|
||||||
|
// First library added
|
||||||
|
this.$store.dispatch('libraries/fetch', res.id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error)
|
||||||
|
if (error.response && error.response.data) {
|
||||||
|
this.$toast.error(error.response.data)
|
||||||
|
} else {
|
||||||
|
this.$toast.error('Failed to create library')
|
||||||
|
}
|
||||||
|
this.processing = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {},
|
||||||
|
beforeDestroy() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tab {
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
.tab.tab-selected {
|
||||||
|
height: 41px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-full">
|
<div class="w-full h-full bg-bg absolute top-0 left-0 px-4 py-4 z-10">
|
||||||
|
<div class="flex items-center py-1 mb-2">
|
||||||
|
<span class="material-icons text-3xl cursor-pointer hover:text-gray-300" @click="$emit('back')">arrow_back</span>
|
||||||
|
<p class="px-4 text-xl">Choose a Folder</p>
|
||||||
|
</div>
|
||||||
<div v-if="allFolders.length" class="w-full bg-primary bg-opacity-70 py-1 px-4 mb-2">
|
<div v-if="allFolders.length" class="w-full bg-primary bg-opacity-70 py-1 px-4 mb-2">
|
||||||
<p class="font-mono truncate">{{ selectedPath || '\\' }}</p>
|
<p class="font-mono truncate">{{ selectedPath || '\\' }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="allFolders.length" class="flex bg-primary bg-opacity-50 p-4">
|
<div v-if="allFolders.length" class="flex bg-primary bg-opacity-50 p-4 folder-container">
|
||||||
<div class="w-1/2 border-r border-bg">
|
<div class="w-1/2 border-r border-bg h-full overflow-y-auto">
|
||||||
<div v-if="level > 0" class="w-full p-1 cursor-pointer flex items-center" @click="goBack">
|
<div v-if="level > 0" class="w-full p-1 cursor-pointer flex items-center" @click="goBack">
|
||||||
<span class="material-icons bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span>
|
<span class="material-icons bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span>
|
||||||
<p class="text-base font-mono px-2">..</p>
|
<p class="text-base font-mono px-2">..</p>
|
||||||
@@ -15,7 +19,7 @@
|
|||||||
<span v-if="dir.dirs && dir.dirs.length && dir.path === selectedPath" class="material-icons" style="font-size: 1.1rem">arrow_right</span>
|
<span v-if="dir.dirs && dir.dirs.length && dir.path === selectedPath" class="material-icons" style="font-size: 1.1rem">arrow_right</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-1/2">
|
<div class="w-1/2 h-full overflow-y-auto">
|
||||||
<div v-for="dir in _subdirs" :key="dir.path" :class="dir.className" class="dir-item w-full p-1 cursor-pointer flex items-center hover:text-white text-gray-200" @click="selectSubDir(dir)">
|
<div v-for="dir in _subdirs" :key="dir.path" :class="dir.className" class="dir-item w-full p-1 cursor-pointer flex items-center hover:text-white text-gray-200" @click="selectSubDir(dir)">
|
||||||
<span class="material-icons bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span>
|
<span class="material-icons bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span>
|
||||||
<p class="text-base font-mono px-2 truncate">{{ dir.dirname }}</p>
|
<p class="text-base font-mono px-2 truncate">{{ dir.dirname }}</p>
|
||||||
@@ -30,12 +34,8 @@
|
|||||||
<p class="text-gray-300">Note: folders already mapped will not be shown</p>
|
<p class="text-gray-300">Note: folders already mapped will not be shown</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="absolute bottom-0 left-0 w-full py-4 px-8">
|
<div class="w-full py-2">
|
||||||
<ui-btn :disabled="!selectedPath" color="primary" class="w-full mt-2" @click="selectFolder">Select Folder Path</ui-btn>
|
<ui-btn :disabled="!selectedPath" color="primary" class="w-full mt-2" @click="selectFolder">Select Folder Path</ui-btn>
|
||||||
<!-- <div class="flex items-center">
|
|
||||||
<div class="flex-grow" />
|
|
||||||
<ui-btn color="success" @click="selectFolder">Select</ui-btn>
|
|
||||||
</div> -->
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -64,7 +64,6 @@ export default {
|
|||||||
computed: {
|
computed: {
|
||||||
_directories() {
|
_directories() {
|
||||||
return this.directories.map((d) => {
|
return this.directories.map((d) => {
|
||||||
console.log('Directories', d)
|
|
||||||
var isUsed = !!this.paths.find((path) => path.endsWith(d.path))
|
var isUsed = !!this.paths.find((path) => path.endsWith(d.path))
|
||||||
var isSelected = d.path === this.selectedPath
|
var isSelected = d.path === this.selectedPath
|
||||||
var classes = []
|
var classes = []
|
||||||
@@ -162,4 +161,9 @@ export default {
|
|||||||
.dir-item.dir-used {
|
.dir-item.dir-used {
|
||||||
background-color: rgba(255, 25, 0, 0.1);
|
background-color: rgba(255, 25, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
.folder-container {
|
||||||
|
max-height: calc(100% - 130px);
|
||||||
|
height: calc(100% - 130px);
|
||||||
|
min-height: calc(100% - 130px);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full h-full px-4 py-1 mb-4">
|
||||||
|
<div class="py-3">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<ui-toggle-switch v-if="!globalWatcherDisabled" v-model="disableWatcher" @input="formUpdated" />
|
||||||
|
<ui-toggle-switch v-else disabled :value="false" />
|
||||||
|
<p class="pl-4 text-lg">Disable folder watcher for library</p>
|
||||||
|
</div>
|
||||||
|
<p v-if="globalWatcherDisabled" class="text-xs text-warning">*Watcher is disabled globally in server settings</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="mediaType == 'book'" class="py-3">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<ui-toggle-switch v-model="skipMatchingMediaWithAsin" @input="formUpdated" />
|
||||||
|
<p class="pl-4 text-lg">Skip matching books that already have an ASIN</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="mediaType == 'book'" class="py-3">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<ui-toggle-switch v-model="skipMatchingMediaWithIsbn" @input="formUpdated" />
|
||||||
|
<p class="pl-4 text-lg">Skip matching books that already have an ISBN</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
library: {
|
||||||
|
type: Object,
|
||||||
|
default: () => null
|
||||||
|
},
|
||||||
|
processing: Boolean
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
provider: null,
|
||||||
|
disableWatcher: false,
|
||||||
|
skipMatchingMediaWithAsin: false,
|
||||||
|
skipMatchingMediaWithIsbn: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
librarySettings() {
|
||||||
|
return this.library.settings || {}
|
||||||
|
},
|
||||||
|
globalWatcherDisabled() {
|
||||||
|
return this.$store.getters['getServerSetting']('scannerDisableWatcher')
|
||||||
|
},
|
||||||
|
mediaType() {
|
||||||
|
return this.library.mediaType
|
||||||
|
},
|
||||||
|
providers() {
|
||||||
|
if (this.mediaType === 'podcast') return this.$store.state.scanners.podcastProviders
|
||||||
|
return this.$store.state.scanners.providers
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getLibraryData() {
|
||||||
|
return {
|
||||||
|
settings: {
|
||||||
|
disableWatcher: !!this.disableWatcher,
|
||||||
|
skipMatchingMediaWithAsin: !!this.skipMatchingMediaWithAsin,
|
||||||
|
skipMatchingMediaWithIsbn: !!this.skipMatchingMediaWithIsbn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
formUpdated() {
|
||||||
|
this.$emit('update', this.getLibraryData())
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
this.disableWatcher = !!this.librarySettings.disableWatcher
|
||||||
|
this.skipMatchingMediaWithAsin = !!this.librarySettings.skipMatchingMediaWithAsin
|
||||||
|
this.skipMatchingMediaWithIsbn = !!this.librarySettings.skipMatchingMediaWithIsbn
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
<template>
|
||||||
|
<modals-modal v-model="show" name="podcast-episode-edit-modal" :width="800" :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="p-4 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
|
||||||
|
<div class="flex flex-wrap">
|
||||||
|
<div class="w-1/5 p-1">
|
||||||
|
<ui-text-input-with-label v-model="newEpisode.season" label="Season" />
|
||||||
|
</div>
|
||||||
|
<div class="w-1/5 p-1">
|
||||||
|
<ui-text-input-with-label v-model="newEpisode.episode" label="Episode" />
|
||||||
|
</div>
|
||||||
|
<div class="w-1/5 p-1">
|
||||||
|
<ui-text-input-with-label v-model="newEpisode.episodeType" label="Episode Type" />
|
||||||
|
</div>
|
||||||
|
<div class="w-2/5 p-1">
|
||||||
|
<ui-text-input-with-label v-model="pubDateInput" @input="updatePubDate" type="datetime-local" label="Pub Date" />
|
||||||
|
</div>
|
||||||
|
<div class="w-full p-1">
|
||||||
|
<ui-text-input-with-label v-model="newEpisode.title" label="Title" />
|
||||||
|
</div>
|
||||||
|
<div class="w-full p-1">
|
||||||
|
<ui-textarea-with-label v-model="newEpisode.subtitle" label="Subtitle" :rows="3" />
|
||||||
|
</div>
|
||||||
|
<div class="w-full p-1">
|
||||||
|
<ui-textarea-with-label v-model="newEpisode.description" label="Description" :rows="8" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end pt-4">
|
||||||
|
<ui-btn @click="submit">Submit</ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</modals-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
processing: false,
|
||||||
|
newEpisode: {
|
||||||
|
season: null,
|
||||||
|
episode: null,
|
||||||
|
episodeType: null,
|
||||||
|
title: null,
|
||||||
|
subtitle: null,
|
||||||
|
description: null,
|
||||||
|
pubDate: null,
|
||||||
|
publishedAt: null
|
||||||
|
},
|
||||||
|
pubDateInput: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
episode: {
|
||||||
|
immediate: true,
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.$store.state.globals.showEditPodcastEpisode
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$store.commit('globals/setShowEditPodcastEpisodeModal', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
libraryItem() {
|
||||||
|
return this.$store.state.selectedLibraryItem
|
||||||
|
},
|
||||||
|
episode() {
|
||||||
|
return this.$store.state.globals.selectedEpisode
|
||||||
|
},
|
||||||
|
episodeId() {
|
||||||
|
return this.episode ? this.episode.id : null
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
if (!this.libraryItem) return ''
|
||||||
|
return this.libraryItem.media.metadata.title || 'Unknown'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
updatePubDate(val) {
|
||||||
|
if (val) {
|
||||||
|
this.newEpisode.pubDate = this.$formatJsDate(new Date(val), 'E, d MMM yyyy HH:mm:ssxx')
|
||||||
|
this.newEpisode.publishedAt = new Date(val).valueOf()
|
||||||
|
} else {
|
||||||
|
this.newEpisode.pubDate = null
|
||||||
|
this.newEpisode.publishedAt = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
this.newEpisode.season = this.episode.season || ''
|
||||||
|
this.newEpisode.episode = this.episode.episode || ''
|
||||||
|
this.newEpisode.episodeType = this.episode.episodeType || ''
|
||||||
|
this.newEpisode.title = this.episode.title || ''
|
||||||
|
this.newEpisode.subtitle = this.episode.subtitle || ''
|
||||||
|
this.newEpisode.description = this.episode.description || ''
|
||||||
|
this.newEpisode.pubDate = this.episode.pubDate || ''
|
||||||
|
this.newEpisode.publishedAt = this.episode.publishedAt
|
||||||
|
|
||||||
|
this.pubDateInput = this.episode.pubDate ? this.$formatJsDate(new Date(this.episode.pubDate), "yyyy-MM-dd'T'HH:mm") : null
|
||||||
|
},
|
||||||
|
getUpdatePayload() {
|
||||||
|
var updatePayload = {}
|
||||||
|
for (const key in this.newEpisode) {
|
||||||
|
if (this.newEpisode[key] != this.episode[key]) {
|
||||||
|
updatePayload[key] = this.newEpisode[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return updatePayload
|
||||||
|
},
|
||||||
|
submit() {
|
||||||
|
const payload = this.getUpdatePayload()
|
||||||
|
if (!Object.keys(payload).length) {
|
||||||
|
return this.$toast.info('No updates were made')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.processing = true
|
||||||
|
this.$axios
|
||||||
|
.$patch(`/api/podcasts/${this.libraryItem.id}/episode/${this.episodeId}`, payload)
|
||||||
|
.then(() => {
|
||||||
|
this.processing = false
|
||||||
|
this.$toast.success('Podcast episode updated')
|
||||||
|
this.show = false
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed update episode'
|
||||||
|
console.error('Failed update episode', error)
|
||||||
|
this.processing = false
|
||||||
|
this.$toast.error(errorMsg)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
<template>
|
||||||
|
<modals-modal v-model="show" name="podcast-episodes-modal" :width="1200" :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" id="podcast-wrapper" class="p-4 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
|
||||||
|
<div ref="episodeContainer" id="episodes-scroll" class="w-full overflow-x-hidden overflow-y-auto">
|
||||||
|
<div
|
||||||
|
v-for="(episode, index) in episodes"
|
||||||
|
:key="index"
|
||||||
|
class="relative"
|
||||||
|
:class="episode.enclosure && itemEpisodeMap[episode.enclosure.url] ? 'bg-primary bg-opacity-40' : selectedEpisodes[String(index)] ? 'cursor-pointer bg-success bg-opacity-10' : index % 2 == 0 ? 'cursor-pointer bg-primary bg-opacity-25 hover:bg-opacity-40' : 'cursor-pointer bg-primary bg-opacity-5 hover:bg-opacity-25'"
|
||||||
|
@click="toggleSelectEpisode(index)"
|
||||||
|
>
|
||||||
|
<div class="absolute top-0 left-0 h-full flex items-center p-2">
|
||||||
|
<span v-if="episode.enclosure && itemEpisodeMap[episode.enclosure.url]" class="material-icons text-success text-xl">download_done</span>
|
||||||
|
<ui-checkbox v-else v-model="selectedEpisodes[String(index)]" small checkbox-bg="primary" border-color="gray-600" />
|
||||||
|
</div>
|
||||||
|
<div class="px-8 py-2">
|
||||||
|
<p v-if="episode.episode" class="font-semibold text-gray-200">#{{ episode.episode }}</p>
|
||||||
|
<p class="break-words mb-1">{{ episode.title }}</p>
|
||||||
|
<p v-if="episode.subtitle" class="break-words mb-1 text-sm text-gray-300 episode-subtitle">{{ episode.subtitle }}</p>
|
||||||
|
<p class="text-xs text-gray-300">Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}</p>
|
||||||
|
<!-- <span class="material-icons cursor-pointer text-lg hover:text-success" @click="saveEpisode(episode)">save</span> -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end pt-4">
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute top-0 left-0 h-full flex items-center p-2">
|
||||||
|
<ui-checkbox v-model="selectAll" small checkbox-bg="primary" border-color="gray-600" :disabled="allDownloaded" />
|
||||||
|
</div>
|
||||||
|
<div class="px-8 py-2">
|
||||||
|
<p :class="!allDownloaded ? 'font-semibold text-gray-200' : 'text-gray-400'">Select all episodes</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ui-btn :disabled="!episodesSelected.length" @click="submit">{{ buttonText }}</ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</modals-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: Boolean,
|
||||||
|
libraryItem: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
},
|
||||||
|
episodes: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
processing: false,
|
||||||
|
selectedEpisodes: {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
show: {
|
||||||
|
immediate: true,
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selectAll: {
|
||||||
|
get() {
|
||||||
|
return this.episodesSelected.length == this.episodes.filter((_, index) => !(this.episodes[index].enclosure && this.itemEpisodeMap[this.episodes[index].enclosure.url])).length
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
for (const key in this.selectedEpisodes) {
|
||||||
|
this.selectedEpisodes[key] = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
if (!this.libraryItem) return ''
|
||||||
|
return this.libraryItem.media.metadata.title || 'Unknown'
|
||||||
|
},
|
||||||
|
allDownloaded() {
|
||||||
|
return Object.values(this.episodes).filter((episode) => !(episode.enclosure && this.itemEpisodeMap[episode.enclosure.url])).length === 0
|
||||||
|
},
|
||||||
|
episodesSelected() {
|
||||||
|
return Object.keys(this.selectedEpisodes).filter((key) => !!this.selectedEpisodes[key])
|
||||||
|
},
|
||||||
|
buttonText() {
|
||||||
|
if (!this.episodesSelected.length) return 'No Episodes Selected'
|
||||||
|
return `Download ${this.episodesSelected.length} Episode${this.episodesSelected.length > 1 ? 's' : ''}`
|
||||||
|
},
|
||||||
|
itemEpisodes() {
|
||||||
|
if (!this.libraryItem) return []
|
||||||
|
return this.libraryItem.media.episodes || []
|
||||||
|
},
|
||||||
|
itemEpisodeMap() {
|
||||||
|
var map = {}
|
||||||
|
this.itemEpisodes.forEach((item) => {
|
||||||
|
if (item.enclosure) map[item.enclosure.url] = true
|
||||||
|
})
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
toggleSelectEpisode(index) {
|
||||||
|
this.$set(this.selectedEpisodes, String(index), !this.selectedEpisodes[String(index)])
|
||||||
|
},
|
||||||
|
submit() {
|
||||||
|
var episodesToDownload = []
|
||||||
|
if (this.episodesSelected.length) {
|
||||||
|
episodesToDownload = this.episodesSelected.map((episodeIndex) => this.episodes[Number(episodeIndex)])
|
||||||
|
}
|
||||||
|
|
||||||
|
var payloadSize = JSON.stringify(episodesToDownload).length
|
||||||
|
var sizeInMb = payloadSize / 1024 / 1024
|
||||||
|
var sizeInMbPretty = sizeInMb.toFixed(2) + 'MB'
|
||||||
|
console.log('Request size', sizeInMb)
|
||||||
|
if (sizeInMb > 4.99) {
|
||||||
|
return this.$toast.error(`Request is too large (${sizeInMbPretty}) should be < 5Mb`)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.processing = true
|
||||||
|
this.$axios
|
||||||
|
.$post(`/api/podcasts/${this.libraryItem.id}/download-episodes`, episodesToDownload)
|
||||||
|
.then(() => {
|
||||||
|
this.processing = false
|
||||||
|
this.$toast.success('Started downloading episodes')
|
||||||
|
this.show = false
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to download episodes'
|
||||||
|
console.error('Failed to download episodes', error)
|
||||||
|
this.processing = false
|
||||||
|
this.$toast.error(errorMsg)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
this.episodes.sort((a, b) => (a.publishedAt < b.publishedAt) ? 1 : -1)
|
||||||
|
for (let i = 0; i < this.episodes.length; i++) {
|
||||||
|
var episode = this.episodes[i]
|
||||||
|
if (episode.enclosure && !this.itemEpisodeMap[episode.enclosure.url]) {
|
||||||
|
// Do not include episodes already downloaded
|
||||||
|
this.$set(this.selectedEpisodes, String(i), false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
#podcast-wrapper {
|
||||||
|
min-height: 400px;
|
||||||
|
max-height: 80vh;
|
||||||
|
}
|
||||||
|
#episodes-scroll {
|
||||||
|
max-height: calc(80vh - 200px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
<template>
|
||||||
|
<modals-modal v-model="show" name="new-podcast-modal" :width="1000" :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" id="podcast-wrapper" class="p-4 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
|
||||||
|
<div class="w-full p-4">
|
||||||
|
<p class="text-lg font-semibold mb-2">Details</p>
|
||||||
|
|
||||||
|
<div v-if="podcast.imageUrl" class="p-1 w-full">
|
||||||
|
<img :src="podcast.imageUrl" class="h-16 w-16 object-contain" />
|
||||||
|
</div>
|
||||||
|
<div class="flex">
|
||||||
|
<div class="w-full md:w-1/2 p-2">
|
||||||
|
<ui-text-input-with-label v-model="podcast.title" label="Title" @input="titleUpdated" />
|
||||||
|
</div>
|
||||||
|
<div class="w-full md:w-1/2 p-2">
|
||||||
|
<ui-text-input-with-label v-model="podcast.author" label="Author" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex">
|
||||||
|
<div class="w-full md:w-1/2 p-2">
|
||||||
|
<ui-text-input-with-label v-model="podcast.feedUrl" label="Feed URL" readonly />
|
||||||
|
</div>
|
||||||
|
<div class="w-full md:w-1/2 p-2">
|
||||||
|
<ui-multi-select v-model="podcast.genres" :items="podcast.genres" label="Genres" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-2 w-full">
|
||||||
|
<ui-textarea-with-label v-model="podcast.description" label="Description" :rows="3" />
|
||||||
|
</div>
|
||||||
|
<div class="flex">
|
||||||
|
<div class="w-full md:w-1/2 p-2">
|
||||||
|
<ui-dropdown v-model="selectedFolderId" :items="folderItems" :disabled="processing" label="Folder" @input="folderUpdated" />
|
||||||
|
</div>
|
||||||
|
<div class="w-full md:w-1/2 p-2">
|
||||||
|
<ui-text-input-with-label v-model="fullPath" label="Podcast Path" readonly />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center py-4">
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<div class="px-4">
|
||||||
|
<ui-checkbox v-model="podcast.autoDownloadEpisodes" label="Auto Download Episodes" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||||
|
</div>
|
||||||
|
<ui-btn color="success" @click="submit">Add Podcast</ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</modals-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Path from 'path'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: Boolean,
|
||||||
|
podcastData: {
|
||||||
|
type: Object,
|
||||||
|
default: () => null
|
||||||
|
},
|
||||||
|
podcastFeedData: {
|
||||||
|
type: Object,
|
||||||
|
default: () => null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
processing: false,
|
||||||
|
selectedFolderId: null,
|
||||||
|
fullPath: null,
|
||||||
|
podcast: {
|
||||||
|
title: '',
|
||||||
|
author: '',
|
||||||
|
description: '',
|
||||||
|
releaseDate: '',
|
||||||
|
genres: [],
|
||||||
|
feedUrl: '',
|
||||||
|
feedImageUrl: '',
|
||||||
|
itunesPageUrl: '',
|
||||||
|
itunesId: '',
|
||||||
|
itunesArtistId: '',
|
||||||
|
autoDownloadEpisodes: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
show: {
|
||||||
|
immediate: true,
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
return this._podcastData.title
|
||||||
|
},
|
||||||
|
currentLibrary() {
|
||||||
|
return this.$store.getters['libraries/getCurrentLibrary']
|
||||||
|
},
|
||||||
|
folders() {
|
||||||
|
if (!this.currentLibrary) return []
|
||||||
|
return this.currentLibrary.folders || []
|
||||||
|
},
|
||||||
|
folderItems() {
|
||||||
|
return this.folders.map((fold) => {
|
||||||
|
return {
|
||||||
|
value: fold.id,
|
||||||
|
text: fold.fullPath
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
_podcastData() {
|
||||||
|
return this.podcastData || {}
|
||||||
|
},
|
||||||
|
feedMetadata() {
|
||||||
|
if (!this.podcastFeedData) return {}
|
||||||
|
return this.podcastFeedData.metadata || {}
|
||||||
|
},
|
||||||
|
episodes() {
|
||||||
|
if (!this.podcastFeedData) return []
|
||||||
|
return this.podcastFeedData.episodes || []
|
||||||
|
},
|
||||||
|
selectedFolder() {
|
||||||
|
return this.folders.find((f) => f.id === this.selectedFolderId)
|
||||||
|
},
|
||||||
|
selectedFolderPath() {
|
||||||
|
if (!this.selectedFolder) return ''
|
||||||
|
return this.selectedFolder.fullPath
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
titleUpdated() {
|
||||||
|
this.folderUpdated()
|
||||||
|
},
|
||||||
|
folderUpdated() {
|
||||||
|
if (!this.selectedFolderPath || !this.podcast.title) {
|
||||||
|
this.fullPath = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.fullPath = Path.join(this.selectedFolderPath, this.$sanitizeFilename(this.podcast.title))
|
||||||
|
},
|
||||||
|
submit() {
|
||||||
|
const podcastPayload = {
|
||||||
|
path: this.fullPath,
|
||||||
|
folderId: this.selectedFolderId,
|
||||||
|
libraryId: this.currentLibrary.id,
|
||||||
|
media: {
|
||||||
|
metadata: {
|
||||||
|
title: this.podcast.title,
|
||||||
|
author: this.podcast.author,
|
||||||
|
description: this.podcast.description,
|
||||||
|
releaseDate: this.podcast.releaseDate,
|
||||||
|
genres: [...this.podcast.genres],
|
||||||
|
feedUrl: this.podcast.feedUrl,
|
||||||
|
imageUrl: this.podcast.imageUrl,
|
||||||
|
itunesPageUrl: this.podcast.itunesPageUrl,
|
||||||
|
itunesId: this.podcast.itunesId,
|
||||||
|
itunesArtistId: this.podcast.itunesArtistId,
|
||||||
|
language: this.podcast.language
|
||||||
|
},
|
||||||
|
autoDownloadEpisodes: this.podcast.autoDownloadEpisodes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('Podcast payload', podcastPayload)
|
||||||
|
|
||||||
|
this.processing = true
|
||||||
|
this.$axios
|
||||||
|
.$post('/api/podcasts', podcastPayload)
|
||||||
|
.then((libraryItem) => {
|
||||||
|
this.processing = false
|
||||||
|
this.$toast.success('Podcast created successfully')
|
||||||
|
this.show = false
|
||||||
|
this.$router.push(`/item/${libraryItem.id}`)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to create podcast'
|
||||||
|
console.error('Failed to create podcast', error)
|
||||||
|
this.processing = false
|
||||||
|
this.$toast.error(errorMsg)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
// Prefer using itunes podcast data but not always passed in if manually entering rss feed
|
||||||
|
this.podcast.title = this._podcastData.title || this.feedMetadata.title || ''
|
||||||
|
this.podcast.author = this._podcastData.artistName || this.feedMetadata.author || ''
|
||||||
|
this.podcast.description = this._podcastData.description || this.feedMetadata.descriptionPlain || ''
|
||||||
|
this.podcast.releaseDate = this._podcastData.releaseDate || ''
|
||||||
|
this.podcast.genres = this._podcastData.genres || this.feedMetadata.categories || []
|
||||||
|
this.podcast.feedUrl = this._podcastData.feedUrl || this.feedMetadata.feedUrl || ''
|
||||||
|
this.podcast.imageUrl = this._podcastData.cover || this.feedMetadata.image || ''
|
||||||
|
this.podcast.itunesPageUrl = this._podcastData.pageUrl || ''
|
||||||
|
this.podcast.itunesId = this._podcastData.id || ''
|
||||||
|
this.podcast.itunesArtistId = this._podcastData.artistId || ''
|
||||||
|
this.podcast.language = this._podcastData.language || ''
|
||||||
|
this.podcast.autoDownloadEpisodes = false
|
||||||
|
|
||||||
|
if (this.folderItems[0]) {
|
||||||
|
this.selectedFolderId = this.folderItems[0].value
|
||||||
|
this.folderUpdated()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
#podcast-wrapper {
|
||||||
|
min-height: 400px;
|
||||||
|
max-height: 80vh;
|
||||||
|
}
|
||||||
|
#episodes-scroll {
|
||||||
|
max-height: calc(80vh - 200px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
<template>
|
||||||
|
<modals-modal v-model="show" name="rss-feed-modal" :width="600" :height="'unset'" :processing="processing">
|
||||||
|
<template #outer>
|
||||||
|
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||||
|
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div ref="wrapper" class="px-8 py-6 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
|
||||||
|
<div v-if="currentFeedUrl" class="w-full">
|
||||||
|
<p class="text-lg font-semibold mb-4">Podcast RSS Feed is Open</p>
|
||||||
|
|
||||||
|
<div class="w-full relative">
|
||||||
|
<ui-text-input v-model="currentFeedUrl" readonly />
|
||||||
|
|
||||||
|
<span class="material-icons absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(currentFeedUrl)">content_copy</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="w-full">
|
||||||
|
<p class="text-lg font-semibold mb-4">Open RSS Feed</p>
|
||||||
|
|
||||||
|
<div class="w-full relative mb-2">
|
||||||
|
<ui-text-input-with-label v-model="newFeedSlug" label="RSS Feed Slug" />
|
||||||
|
<p class="text-xs text-gray-400 py-0.5 px-1">Feed will be {{ demoFeedUrl }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="isHttp" class="w-full pt-2 text-warning text-xs">Warning: Most podcast apps will require the RSS feed URL is using HTTPS</p>
|
||||||
|
<p v-if="hasEpisodesWithoutPubDate" class="w-full pt-2 text-warning text-xs">Warning: 1 or more of your episodes do not have a Pub Date. Some podcast apps require this.</p>
|
||||||
|
</div>
|
||||||
|
<div v-show="userIsAdminOrUp" class="flex items-center pt-6">
|
||||||
|
<p class="text-xs text-gray-300">Note: RSS feed URLs are not authenticated</p>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<ui-btn v-if="currentFeedUrl" color="error" small @click="closeFeed">Close RSS Feed</ui-btn>
|
||||||
|
<ui-btn v-else color="success" small @click="openFeed">Open RSS Feed</ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</modals-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: Boolean,
|
||||||
|
libraryItem: {
|
||||||
|
type: Object,
|
||||||
|
default: () => null
|
||||||
|
},
|
||||||
|
feedUrl: String
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
processing: false,
|
||||||
|
newFeedSlug: null,
|
||||||
|
currentFeedUrl: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
show: {
|
||||||
|
immediate: true,
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
libraryItemId() {
|
||||||
|
return this.libraryItem.id
|
||||||
|
},
|
||||||
|
media() {
|
||||||
|
return this.libraryItem.media || {}
|
||||||
|
},
|
||||||
|
mediaMetadata() {
|
||||||
|
return this.media.metadata || {}
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
return this.mediaMetadata.title
|
||||||
|
},
|
||||||
|
userIsAdminOrUp() {
|
||||||
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
|
},
|
||||||
|
demoFeedUrl() {
|
||||||
|
return `${window.origin}/feed/${this.newFeedSlug}`
|
||||||
|
},
|
||||||
|
isHttp() {
|
||||||
|
return window.origin.startsWith('http://')
|
||||||
|
},
|
||||||
|
episodes() {
|
||||||
|
return this.media.episodes || []
|
||||||
|
},
|
||||||
|
hasEpisodesWithoutPubDate() {
|
||||||
|
return this.episodes.some((ep) => !ep.pubDate)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
openFeed() {
|
||||||
|
if (!this.newFeedSlug) {
|
||||||
|
this.$toast.error('Must set a feed slug')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var sanitized = this.$sanitizeSlug(this.newFeedSlug)
|
||||||
|
if (this.newFeedSlug !== sanitized) {
|
||||||
|
this.newFeedSlug = sanitized
|
||||||
|
this.$toast.warning('Slug had to be modified - Run again')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
serverAddress: window.origin,
|
||||||
|
slug: this.newFeedSlug
|
||||||
|
}
|
||||||
|
if (this.$isDev) payload.serverAddress = 'http://localhost:3333'
|
||||||
|
|
||||||
|
console.log('Payload', payload)
|
||||||
|
this.$axios
|
||||||
|
.$post(`/api/items/${this.libraryItemId}/open-feed`, payload)
|
||||||
|
.then((data) => {
|
||||||
|
if (data.success) {
|
||||||
|
console.log('Opened RSS Feed', data)
|
||||||
|
this.currentFeedUrl = data.feedUrl
|
||||||
|
} else {
|
||||||
|
const errorMsg = data.error || 'Unknown error'
|
||||||
|
this.$toast.error(errorMsg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to open RSS Feed', error)
|
||||||
|
this.$toast.error()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
copyToClipboard(str) {
|
||||||
|
this.$copyToClipboard(str, this)
|
||||||
|
},
|
||||||
|
closeFeed() {
|
||||||
|
this.processing = true
|
||||||
|
this.$axios
|
||||||
|
.$post(`/api/items/${this.libraryItem.id}/close-feed`)
|
||||||
|
.then(() => {
|
||||||
|
this.$toast.success('RSS Feed Closed')
|
||||||
|
this.show = false
|
||||||
|
this.processing = false
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to close RSS feed', error)
|
||||||
|
this.processing = false
|
||||||
|
this.$toast.error()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
if (!this.libraryItem) return
|
||||||
|
this.newFeedSlug = this.libraryItem.id
|
||||||
|
this.currentFeedUrl = this.feedUrl
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -18,10 +18,7 @@
|
|||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {}
|
||||||
ebookType: '',
|
|
||||||
ebookUrl: ''
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
show(newVal) {
|
show(newVal) {
|
||||||
@@ -47,46 +44,65 @@ export default {
|
|||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
abTitle() {
|
abTitle() {
|
||||||
return this.selectedAudiobook.book.title
|
return this.mediaMetadata.title
|
||||||
},
|
},
|
||||||
abAuthor() {
|
abAuthor() {
|
||||||
return this.selectedAudiobook.book.author
|
return this.mediaMetadata.authorName
|
||||||
},
|
},
|
||||||
selectedAudiobook() {
|
selectedLibraryItem() {
|
||||||
return this.$store.state.selectedAudiobook
|
return this.$store.state.selectedLibraryItem || {}
|
||||||
|
},
|
||||||
|
media() {
|
||||||
|
return this.selectedLibraryItem.media || {}
|
||||||
|
},
|
||||||
|
mediaMetadata() {
|
||||||
|
return this.media.metadata || {}
|
||||||
},
|
},
|
||||||
libraryId() {
|
libraryId() {
|
||||||
return this.selectedAudiobook.libraryId
|
return this.selectedLibraryItem.libraryId
|
||||||
},
|
},
|
||||||
folderId() {
|
folderId() {
|
||||||
return this.selectedAudiobook.folderId
|
return this.selectedLibraryItem.folderId
|
||||||
},
|
},
|
||||||
ebooks() {
|
ebookFile() {
|
||||||
return this.selectedAudiobook.ebooks || []
|
return this.media.ebookFile
|
||||||
},
|
},
|
||||||
epubEbook() {
|
ebookFormat() {
|
||||||
return this.ebooks.find((eb) => eb.ext === '.epub')
|
if (!this.ebookFile) return null
|
||||||
|
return this.ebookFile.ebookFormat
|
||||||
},
|
},
|
||||||
mobiEbook() {
|
ebookType() {
|
||||||
return this.ebooks.find((eb) => eb.ext === '.mobi' || eb.ext === '.azw3')
|
if (this.isMobi) return 'mobi'
|
||||||
|
else if (this.isEpub) return 'epub'
|
||||||
|
else if (this.isPdf) return 'pdf'
|
||||||
|
else if (this.isComic) return 'comic'
|
||||||
|
return null
|
||||||
},
|
},
|
||||||
pdfEbook() {
|
isEpub() {
|
||||||
return this.ebooks.find((eb) => eb.ext === '.pdf')
|
return this.ebookFormat == 'epub'
|
||||||
},
|
},
|
||||||
comicEbook() {
|
isMobi() {
|
||||||
return this.ebooks.find((eb) => eb.ext === '.cbz' || eb.ext === '.cbr')
|
return this.ebookFormat == 'mobi' || this.ebookFormat == 'azw3'
|
||||||
|
},
|
||||||
|
isPdf() {
|
||||||
|
return this.ebookFormat == 'pdf'
|
||||||
|
},
|
||||||
|
isComic() {
|
||||||
|
return this.ebookFormat == 'cbz' || this.ebookFormat == 'cbr'
|
||||||
|
},
|
||||||
|
ebookUrl() {
|
||||||
|
if (!this.ebookFile) return null
|
||||||
|
var itemRelPath = this.selectedLibraryItem.relPath
|
||||||
|
if (itemRelPath.startsWith('/')) itemRelPath = itemRelPath.slice(1)
|
||||||
|
var relPath = this.ebookFile.metadata.relPath
|
||||||
|
if (relPath.startsWith('/')) relPath = relPath.slice(1)
|
||||||
|
return `/ebook/${this.libraryId}/${this.folderId}/${itemRelPath}/${relPath}`
|
||||||
},
|
},
|
||||||
userToken() {
|
userToken() {
|
||||||
return this.$store.getters['user/getToken']
|
return this.$store.getters['user/getToken']
|
||||||
},
|
|
||||||
selectedAudiobookFile() {
|
|
||||||
return this.$store.state.selectedAudiobookFile
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
getEbookUrl(path) {
|
|
||||||
return `/ebook/${this.libraryId}/${this.folderId}/${path}`
|
|
||||||
},
|
|
||||||
hotkey(action) {
|
hotkey(action) {
|
||||||
console.log('Reader hotkey', action)
|
console.log('Reader hotkey', action)
|
||||||
if (!this.$refs.readerComponent) return
|
if (!this.$refs.readerComponent) return
|
||||||
@@ -107,31 +123,6 @@ export default {
|
|||||||
},
|
},
|
||||||
init() {
|
init() {
|
||||||
this.registerListeners()
|
this.registerListeners()
|
||||||
|
|
||||||
if (this.selectedAudiobookFile) {
|
|
||||||
this.ebookUrl = this.getEbookUrl(this.selectedAudiobookFile.path)
|
|
||||||
if (this.selectedAudiobookFile.ext === '.pdf') {
|
|
||||||
this.ebookType = 'pdf'
|
|
||||||
} else if (this.selectedAudiobookFile.ext === '.mobi' || this.selectedAudiobookFile.ext === '.azw3') {
|
|
||||||
this.ebookType = 'mobi'
|
|
||||||
} else if (this.selectedAudiobookFile.ext === '.epub') {
|
|
||||||
this.ebookType = 'epub'
|
|
||||||
} else if (this.selectedAudiobookFile.ext === '.cbr' || this.selectedAudiobookFile.ext === '.cbz') {
|
|
||||||
this.ebookType = 'comic'
|
|
||||||
}
|
|
||||||
} else if (this.epubEbook) {
|
|
||||||
this.ebookType = 'epub'
|
|
||||||
this.ebookUrl = this.getEbookUrl(this.epubEbook.path)
|
|
||||||
} else if (this.mobiEbook) {
|
|
||||||
this.ebookType = 'mobi'
|
|
||||||
this.ebookUrl = this.getEbookUrl(this.mobiEbook.path)
|
|
||||||
} else if (this.pdfEbook) {
|
|
||||||
this.ebookType = 'pdf'
|
|
||||||
this.ebookUrl = this.getEbookUrl(this.pdfEbook.path)
|
|
||||||
} else if (this.comicEbook) {
|
|
||||||
this.ebookType = 'comic'
|
|
||||||
this.ebookUrl = this.getEbookUrl(this.comicEbook.path)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
close() {
|
close() {
|
||||||
this.unregisterListeners()
|
this.unregisterListeners()
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
<template>
|
||||||
|
<div id="heatmap" class="w-full">
|
||||||
|
<div class="mx-auto" :style="{ height: innerHeight + 160 + 'px', width: innerWidth + 52 + 'px' }" style="background-color: rgba(13, 17, 23, 0)">
|
||||||
|
<p class="mb-2 px-1 text-sm text-gray-200">{{ Object.values(daysListening).length }} listening sessions in the last year</p>
|
||||||
|
<div class="border border-opacity-25 rounded py-2 w-full" style="background-color: #232323" :style="{ height: innerHeight + 80 + 'px' }">
|
||||||
|
<div :style="{ width: innerWidth + 'px', height: innerHeight + 'px' }" class="ml-10 mt-5 absolute" @mouseover="mouseover" @mouseout="mouseout">
|
||||||
|
<div v-for="dayLabel in dayLabels" :key="dayLabel.label" :style="dayLabel.style" class="absolute top-0 left-0 text-gray-300">{{ dayLabel.label }}</div>
|
||||||
|
|
||||||
|
<div v-for="monthLabel in monthLabels" :key="monthLabel.id" :style="monthLabel.style" class="absolute top-0 left-0 text-gray-300">{{ monthLabel.label }}</div>
|
||||||
|
|
||||||
|
<div v-for="(block, index) in data" :key="block.dateString" :style="block.style" :data-index="index" class="absolute top-0 left-0 h-2.5 w-2.5 rounded-sm" />
|
||||||
|
|
||||||
|
<div class="flex py-2 px-4" :style="{ marginTop: innerHeight + 'px' }">
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<p style="font-size: 10px; line-height: 10px" class="text-gray-400 px-1">Less</p>
|
||||||
|
<div v-for="block in legendBlocks" :key="block.id" :style="block.style" class="h-2.5 w-2.5 rounded-sm" style="margin-left: 1.5px; margin-right: 1.5px" />
|
||||||
|
<p style="font-size: 10px; line-height: 10px" class="text-gray-400 px-1">More</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
daysListening: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
contentWidth: 0,
|
||||||
|
maxInnerWidth: 0,
|
||||||
|
innerHeight: 13 * 7,
|
||||||
|
blockWidth: 13,
|
||||||
|
data: [],
|
||||||
|
monthLabels: [],
|
||||||
|
tooltipEl: null,
|
||||||
|
tooltipTextEl: null,
|
||||||
|
tooltipArrowEl: null,
|
||||||
|
showingTooltipIndex: -1,
|
||||||
|
outlineColors: ['rgba(27, 31, 35, 0.06)', 'rgba(255,255,255,0.03)'],
|
||||||
|
bgColors: ['rgb(45,45,45)', 'rgb(14, 68, 41)', 'rgb(0, 109, 50)', 'rgb(38, 166, 65)', 'rgb(57, 211, 83)']
|
||||||
|
// GH Colors
|
||||||
|
// outlineColors: ['rgba(27, 31, 35, 0.06)', 'rgba(255,255,255,0.05)'],
|
||||||
|
// bgColors: ['rgb(22, 27, 34)', 'rgb(14, 68, 41)', 'rgb(0, 109, 50)', 'rgb(38, 166, 65)', 'rgb(57, 211, 83)']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
weeksToShow() {
|
||||||
|
return Math.min(52, Math.floor(this.maxInnerWidth / this.blockWidth) - 1)
|
||||||
|
},
|
||||||
|
innerWidth() {
|
||||||
|
return (this.weeksToShow + 1) * 13
|
||||||
|
},
|
||||||
|
daysToShow() {
|
||||||
|
return this.weeksToShow * 7 + this.dayOfWeekToday
|
||||||
|
},
|
||||||
|
dayOfWeekToday() {
|
||||||
|
return new Date().getDay()
|
||||||
|
},
|
||||||
|
firstWeekStart() {
|
||||||
|
return this.$addDaysToToday(-this.daysToShow)
|
||||||
|
},
|
||||||
|
dayLabels() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Mon',
|
||||||
|
style: {
|
||||||
|
transform: `translate(${-25}px, ${13}px)`,
|
||||||
|
lineHeight: '10px',
|
||||||
|
fontSize: '10px'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Wed',
|
||||||
|
style: {
|
||||||
|
transform: `translate(${-25}px, ${13 * 3}px)`,
|
||||||
|
lineHeight: '10px',
|
||||||
|
fontSize: '10px'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Fri',
|
||||||
|
style: {
|
||||||
|
transform: `translate(${-25}px, ${13 * 5}px)`,
|
||||||
|
lineHeight: '10px',
|
||||||
|
fontSize: '10px'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
legendBlocks() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'legend-0',
|
||||||
|
style: `background-color:${this.bgColors[0]};outline:1px solid ${this.outlineColors[0]};outline-offset:-1px;`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'legend-1',
|
||||||
|
style: `background-color:${this.bgColors[1]};outline:1px solid ${this.outlineColors[1]};outline-offset:-1px;`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'legend-2',
|
||||||
|
style: `background-color:${this.bgColors[2]};outline:1px solid ${this.outlineColors[1]};outline-offset:-1px;`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'legend-3',
|
||||||
|
style: `background-color:${this.bgColors[3]};outline:1px solid ${this.outlineColors[1]};outline-offset:-1px;`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'legend-4',
|
||||||
|
style: `background-color:${this.bgColors[4]};outline:1px solid ${this.outlineColors[1]};outline-offset:-1px;`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
destroyTooltip() {
|
||||||
|
if (this.tooltipEl) this.tooltipEl.remove()
|
||||||
|
this.tooltipEl = null
|
||||||
|
this.showingTooltipIndex = -1
|
||||||
|
},
|
||||||
|
createTooltip() {
|
||||||
|
const tooltip = document.createElement('div')
|
||||||
|
tooltip.className = 'absolute top-0 left-0 rounded bg-gray-500 text-white p-2 text-white max-w-xs pointer-events-none'
|
||||||
|
tooltip.style.display = 'none'
|
||||||
|
tooltip.id = 'heatmap-tooltip'
|
||||||
|
|
||||||
|
const tooltipText = document.createElement('p')
|
||||||
|
tooltipText.innerText = 'Tooltip'
|
||||||
|
tooltipText.style.fontSize = '10px'
|
||||||
|
tooltipText.style.lineHeight = '10px'
|
||||||
|
tooltip.appendChild(tooltipText)
|
||||||
|
|
||||||
|
const tooltipArrow = document.createElement('div')
|
||||||
|
tooltipArrow.className = 'text-gray-500 arrow-down-small absolute -bottom-1 left-0 right-0 mx-auto'
|
||||||
|
tooltip.appendChild(tooltipArrow)
|
||||||
|
|
||||||
|
this.tooltipEl = tooltip
|
||||||
|
this.tooltipTextEl = tooltipText
|
||||||
|
this.tooltipArrowEl = tooltipArrow
|
||||||
|
|
||||||
|
document.body.appendChild(this.tooltipEl)
|
||||||
|
},
|
||||||
|
showTooltip(index, block, rect) {
|
||||||
|
if (this.tooltipEl && this.showingTooltipIndex === index) return
|
||||||
|
if (!this.tooltipEl) {
|
||||||
|
this.createTooltip()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showingTooltipIndex = index
|
||||||
|
this.tooltipEl.style.display = 'block'
|
||||||
|
this.tooltipTextEl.innerHTML = block.value ? `<strong>${this.$elapsedPretty(block.value, true)} listening</strong> on ${block.datePretty}` : `No listening sessions on ${block.datePretty}`
|
||||||
|
|
||||||
|
const calculateRect = this.tooltipEl.getBoundingClientRect()
|
||||||
|
|
||||||
|
const w = calculateRect.width / 2
|
||||||
|
var left = rect.x - w
|
||||||
|
var offsetX = 0
|
||||||
|
if (left < 0) {
|
||||||
|
offsetX = Math.abs(left)
|
||||||
|
left = 0
|
||||||
|
} else if (rect.x + w > window.innerWidth - 10) {
|
||||||
|
offsetX = window.innerWidth - 10 - (rect.x + w)
|
||||||
|
left += offsetX
|
||||||
|
}
|
||||||
|
|
||||||
|
this.tooltipEl.style.transform = `translate(${left}px, ${rect.y - 32}px)`
|
||||||
|
this.tooltipArrowEl.style.transform = `translate(${5 - offsetX}px, 0px)`
|
||||||
|
},
|
||||||
|
hideTooltip() {
|
||||||
|
if (this.showingTooltipIndex >= 0 && this.tooltipEl) {
|
||||||
|
this.tooltipEl.style.display = 'none'
|
||||||
|
this.showingTooltipIndex = -1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mouseover(e) {
|
||||||
|
if (isNaN(e.target.dataset.index)) {
|
||||||
|
this.hideTooltip()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var block = this.data[e.target.dataset.index]
|
||||||
|
var rect = e.target.getBoundingClientRect()
|
||||||
|
this.showTooltip(e.target.dataset.index, block, rect)
|
||||||
|
},
|
||||||
|
mouseout(e) {
|
||||||
|
this.hideTooltip()
|
||||||
|
},
|
||||||
|
buildData() {
|
||||||
|
this.data = []
|
||||||
|
|
||||||
|
var maxValue = 0
|
||||||
|
var minValue = 0
|
||||||
|
Object.values(this.daysListening).forEach((val) => {
|
||||||
|
if (val > maxValue) maxValue = val
|
||||||
|
if (!minValue || val < minValue) minValue = val
|
||||||
|
})
|
||||||
|
const range = maxValue - minValue + 0.01
|
||||||
|
|
||||||
|
for (let i = 0; i < this.daysToShow + 1; i++) {
|
||||||
|
const col = Math.floor(i / 7)
|
||||||
|
const row = i % 7
|
||||||
|
|
||||||
|
const date = i === 0 ? this.firstWeekStart : this.$addDaysToDate(this.firstWeekStart, i)
|
||||||
|
const dateString = this.$formatJsDate(date, 'yyyy-MM-dd')
|
||||||
|
const datePretty = this.$formatJsDate(date, 'MMM d, yyyy')
|
||||||
|
const monthString = this.$formatJsDate(date, 'MMM')
|
||||||
|
const value = this.daysListening[dateString] || 0
|
||||||
|
const x = col * 13
|
||||||
|
const y = row * 13
|
||||||
|
|
||||||
|
var bgColor = this.bgColors[0]
|
||||||
|
var outlineColor = this.outlineColors[0]
|
||||||
|
if (value) {
|
||||||
|
outlineColor = this.outlineColors[1]
|
||||||
|
var percentOfAvg = (value - minValue) / range
|
||||||
|
var bgIndex = Math.floor(percentOfAvg * 4) + 1
|
||||||
|
bgColor = this.bgColors[bgIndex] || 'red'
|
||||||
|
}
|
||||||
|
|
||||||
|
this.data.push({
|
||||||
|
date,
|
||||||
|
dateString,
|
||||||
|
datePretty,
|
||||||
|
monthString,
|
||||||
|
dayOfMonth: Number(dateString.split('-').pop()),
|
||||||
|
yearString: dateString.split('-').shift(),
|
||||||
|
value,
|
||||||
|
col,
|
||||||
|
row,
|
||||||
|
style: `transform:translate(${x}px,${y}px);background-color:${bgColor};outline:1px solid ${outlineColor};outline-offset:-1px;`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
console.log('Data', this.data)
|
||||||
|
|
||||||
|
this.monthLabels = []
|
||||||
|
var lastMonth = null
|
||||||
|
for (let i = 0; i < this.data.length; i++) {
|
||||||
|
if (this.data[i].monthString !== lastMonth) {
|
||||||
|
const weekOfMonth = Math.floor(this.data[i].dayOfMonth / 7)
|
||||||
|
if (weekOfMonth <= 2) {
|
||||||
|
this.monthLabels.push({
|
||||||
|
id: this.data[i].dateString + '-ml',
|
||||||
|
label: this.data[i].monthString,
|
||||||
|
style: {
|
||||||
|
transform: `translate(${this.data[i].col * 13}px, -15px)`,
|
||||||
|
lineHeight: '10px',
|
||||||
|
fontSize: '10px'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
lastMonth = this.data[i].monthString
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
const heatmapEl = document.getElementById('heatmap')
|
||||||
|
this.contentWidth = heatmapEl.clientWidth
|
||||||
|
this.maxInnerWidth = this.contentWidth - 52
|
||||||
|
this.buildData()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updated() {},
|
||||||
|
mounted() {
|
||||||
|
this.init()
|
||||||
|
},
|
||||||
|
beforeDestroy() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -5,16 +5,16 @@
|
|||||||
<path fill="currentColor" d="M9 3V18H12V3H9M12 5L16 18L19 17L15 4L12 5M5 5V18H8V5H5M3 19V21H21V19H3Z" />
|
<path fill="currentColor" d="M9 3V18H12V3H9M12 5L16 18L19 17L15 4L12 5M5 5V18H8V5H5M3 19V21H21V19H3Z" />
|
||||||
</svg>
|
</svg>
|
||||||
<div class="px-2">
|
<div class="px-2">
|
||||||
<p class="text-4xl md:text-5xl font-bold">{{ totalBooks }}</p>
|
<p class="text-4xl md:text-5xl font-bold">{{ totalItems }}</p>
|
||||||
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Books in Library</p>
|
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Items in Library</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex px-4">
|
<div class="flex px-4">
|
||||||
<span class="material-icons text-7xl">show_chart</span>
|
<span class="material-icons text-7xl">show_chart</span>
|
||||||
<div class="px-1">
|
<div class="px-1">
|
||||||
<p class="text-4xl md:text-5xl font-bold">{{ totalAudiobookHours }}</p>
|
<p class="text-4xl md:text-5xl font-bold">{{ totalTime }}</p>
|
||||||
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Overall Hours</p>
|
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Overall {{ useOverallHours ? 'Hours' : 'Days' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -61,8 +61,8 @@ export default {
|
|||||||
user() {
|
user() {
|
||||||
return this.$store.state.user.user
|
return this.$store.state.user.user
|
||||||
},
|
},
|
||||||
totalBooks() {
|
totalItems() {
|
||||||
return this.libraryStats ? this.libraryStats.totalBooks : 0
|
return this.libraryStats ? this.libraryStats.totalItems : 0
|
||||||
},
|
},
|
||||||
totalAuthors() {
|
totalAuthors() {
|
||||||
return this.libraryStats ? this.libraryStats.totalAuthors : 0
|
return this.libraryStats ? this.libraryStats.totalAuthors : 0
|
||||||
@@ -70,12 +70,11 @@ export default {
|
|||||||
numAudioTracks() {
|
numAudioTracks() {
|
||||||
return this.libraryStats ? this.libraryStats.numAudioTracks : 0
|
return this.libraryStats ? this.libraryStats.numAudioTracks : 0
|
||||||
},
|
},
|
||||||
totalAudiobookDuration() {
|
totalDuration() {
|
||||||
return this.libraryStats ? this.libraryStats.totalDuration : 0
|
return this.libraryStats ? this.libraryStats.totalDuration : 0
|
||||||
},
|
},
|
||||||
totalAudiobookHours() {
|
totalHours() {
|
||||||
var totalHours = Math.round(this.totalAudiobookDuration / (60 * 60))
|
return Math.round(this.totalDuration / (60 * 60))
|
||||||
return totalHours
|
|
||||||
},
|
},
|
||||||
totalSizePretty() {
|
totalSizePretty() {
|
||||||
var totalSize = this.libraryStats ? this.libraryStats.totalSize : 0
|
var totalSize = this.libraryStats ? this.libraryStats.totalSize : 0
|
||||||
@@ -86,6 +85,13 @@ export default {
|
|||||||
},
|
},
|
||||||
totalSizeMod() {
|
totalSizeMod() {
|
||||||
return this.totalSizePretty.split(' ')[1]
|
return this.totalSizePretty.split(' ')[1]
|
||||||
|
},
|
||||||
|
useOverallHours() {
|
||||||
|
return this.totalHours < 10000
|
||||||
|
},
|
||||||
|
totalTime() {
|
||||||
|
if (this.useOverallHours) return this.totalHours
|
||||||
|
return Math.round(this.totalHours / 24)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {},
|
methods: {},
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="w-full my-2">
|
|
||||||
<div class="w-full bg-primary px-4 py-2 flex items-center cursor-pointer">
|
|
||||||
<p class="pr-4">All Files</p>
|
|
||||||
<span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ allFiles.length }}</span>
|
|
||||||
<div class="flex-grow" />
|
|
||||||
|
|
||||||
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
|
|
||||||
</div>
|
|
||||||
<div class="w-full">
|
|
||||||
<table class="text-sm tracksTable">
|
|
||||||
<tr class="font-book">
|
|
||||||
<th class="text-left px-4">Path</th>
|
|
||||||
<th class="text-left px-4 w-24">Filetype</th>
|
|
||||||
<th v-if="userCanDownload" class="text-center w-20">Download</th>
|
|
||||||
</tr>
|
|
||||||
<template v-for="file in allFiles">
|
|
||||||
<tr :key="file.path">
|
|
||||||
<td class="font-book pl-2">
|
|
||||||
{{ showFullPath ? file.fullPath : file.path }}
|
|
||||||
</td>
|
|
||||||
<td class="text-xs">
|
|
||||||
<p>{{ file.filetype }}</p>
|
|
||||||
</td>
|
|
||||||
<td v-if="userCanDownload" class="text-center">
|
|
||||||
<a :href="`/s/book/${audiobookId}/${file.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
audiobook: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
showFullPath: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
audiobookId() {
|
|
||||||
return this.audiobook.id
|
|
||||||
},
|
|
||||||
audiobookPath() {
|
|
||||||
return this.audiobook.path
|
|
||||||
},
|
|
||||||
userCanDownload() {
|
|
||||||
return this.$store.getters['user/getUserCanDownload']
|
|
||||||
},
|
|
||||||
userToken() {
|
|
||||||
return this.$store.getters['user/getToken']
|
|
||||||
},
|
|
||||||
isMissing() {
|
|
||||||
return this.audiobook.isMissing
|
|
||||||
},
|
|
||||||
showDownload() {
|
|
||||||
return this.userCanDownload && !this.isMissing
|
|
||||||
},
|
|
||||||
otherFiles() {
|
|
||||||
return this.audiobook.otherFiles || []
|
|
||||||
},
|
|
||||||
audioFiles() {
|
|
||||||
return this.audiobook.audioFiles || []
|
|
||||||
},
|
|
||||||
audioFilesCleaned() {
|
|
||||||
return this.audioFiles.map((af) => {
|
|
||||||
return {
|
|
||||||
path: af.path,
|
|
||||||
fullPath: af.fullPath,
|
|
||||||
relativePath: this.getRelativePath(af.path),
|
|
||||||
filetype: 'audio'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
otherFilesCleaned() {
|
|
||||||
return this.otherFiles.map((af) => {
|
|
||||||
return {
|
|
||||||
path: af.path,
|
|
||||||
fullPath: af.fullPath,
|
|
||||||
relativePath: this.getRelativePath(af.path),
|
|
||||||
filetype: af.filetype
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
allFiles() {
|
|
||||||
return this.audioFilesCleaned.concat(this.otherFilesCleaned)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
getRelativePath(path) {
|
|
||||||
var filePath = path.replace(/\\/g, '/')
|
|
||||||
var audiobookPath = this.audiobookPath.replace(/\\/g, '/')
|
|
||||||
return filePath
|
|
||||||
.replace(audiobookPath + '/', '')
|
|
||||||
.replace(/%/g, '%25')
|
|
||||||
.replace(/#/g, '%23')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -13,17 +13,20 @@
|
|||||||
<th class="hidden sm:table-cell w-20 md:w-28">Size</th>
|
<th class="hidden sm:table-cell w-20 md:w-28">Size</th>
|
||||||
<th class="w-36"></th>
|
<th class="w-36"></th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-for="backup in backups" :key="backup.id">
|
<tr v-for="backup in backups" :key="backup.id" :class="!backup.serverVersion ? 'bg-error bg-opacity-10' : ''">
|
||||||
<td>
|
<td>
|
||||||
<p class="truncate text-xs sm:text-sm md:text-base">/{{ backup.path.replace(/\\/g, '/') }}</p>
|
<p class="truncate text-xs sm:text-sm md:text-base">/{{ backup.path.replace(/\\/g, '/') }}</p>
|
||||||
</td>
|
</td>
|
||||||
<td class="hidden sm:table-cell font-sans text-base">{{ backup.datePretty }}</td>
|
<td class="hidden sm:table-cell font-sans text-sm">{{ backup.datePretty }}</td>
|
||||||
<td class="hidden sm:table-cell font-mono md:text-base text-xs">{{ $bytesPretty(backup.fileSize) }}</td>
|
<td class="hidden sm:table-cell font-mono md:text-sm text-xs">{{ $bytesPretty(backup.fileSize) }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="w-full flex flex-row items-center justify-center">
|
<div class="w-full flex flex-row items-center justify-center">
|
||||||
<ui-btn small color="primary" @click="applyBackup(backup)">Apply</ui-btn>
|
<ui-btn v-if="backup.serverVersion" small color="primary" @click="applyBackup(backup)">Apply</ui-btn>
|
||||||
|
|
||||||
<a :href="`/metadata/${backup.path.replace(/%/g, '%25').replace(/#/g, '%23')}?token=${userToken}`" class="mx-1 pt-1 hover:text-opacity-100 text-opacity-70 text-white" download><span class="material-icons text-xl">download</span></a>
|
<a v-if="backup.serverVersion" :href="`/metadata/${$encodeUriPath(backup.path)}?token=${userToken}`" class="mx-1 pt-1 hover:text-opacity-100 text-opacity-70 text-white" download><span class="material-icons text-xl">download</span></a>
|
||||||
|
<ui-tooltip v-else text="This backup was created with an old version of audiobookshelf no longer supported" direction="bottom" class="mx-2 flex items-center">
|
||||||
|
<span class="material-icons-outlined text-error">error_outline</span>
|
||||||
|
</ui-tooltip>
|
||||||
|
|
||||||
<span class="material-icons text-xl hover:text-error hover:text-opacity-100 text-opacity-70 text-white cursor-pointer mx-1" @click="deleteBackupClick(backup)">delete</span>
|
<span class="material-icons text-xl hover:text-error hover:text-opacity-100 text-opacity-70 text-white cursor-pointer mx-1" @click="deleteBackupClick(backup)">delete</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -42,7 +45,7 @@
|
|||||||
<div v-if="selectedBackup" class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
|
<div v-if="selectedBackup" class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
|
||||||
<p class="text-error text-lg font-semibold">Important Notice!</p>
|
<p class="text-error text-lg font-semibold">Important Notice!</p>
|
||||||
<p class="text-base py-1">Applying a backup will overwrite users, user progress, book details, settings, and covers stored in metadata with the backed up data.</p>
|
<p class="text-base py-1">Applying a backup will overwrite users, user progress, book details, settings, and covers stored in metadata with the backed up data.</p>
|
||||||
<p class="text-base py-1">Backups <strong>do not</strong> modify any files in your library folders, only data in the audiobookshelf created <span class="font-mono">/config</span> and <span class="font-mono">/metadata</span> directories.</p>
|
<p class="text-base py-1">Backups <strong>do not</strong> modify any files in your library folders, only data in the audiobookshelf created <span class="font-mono">/config</span> and <span class="font-mono">/metadata</span> directories. If you have enabled server settings to store cover art and metadata in your library folders then those are not backup up or overwritten.</p>
|
||||||
<p class="text-base py-1">All clients using your server will be automatically refreshed.</p>
|
<p class="text-base py-1">All clients using your server will be automatically refreshed.</p>
|
||||||
|
|
||||||
<p class="text-lg text-center my-8">Are you sure you want to apply the backup created on {{ selectedBackup.datePretty }}?</p>
|
<p class="text-lg text-center my-8">Are you sure you want to apply the backup created on {{ selectedBackup.datePretty }}?</p>
|
||||||
@@ -77,14 +80,24 @@ export default {
|
|||||||
methods: {
|
methods: {
|
||||||
confirm() {
|
confirm() {
|
||||||
this.showConfirmApply = false
|
this.showConfirmApply = false
|
||||||
this.$root.socket.once('apply_backup_complete', this.applyBackupComplete)
|
|
||||||
this.$root.socket.emit('apply_backup', this.selectedBackup.id)
|
this.$axios
|
||||||
|
.$get(`/api/backups/${this.selectedBackup.id}/apply`)
|
||||||
|
.then(() => {
|
||||||
|
this.isBackingUp = false
|
||||||
|
location.replace('/config/backups?backup=1')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.isBackingUp = false
|
||||||
|
console.error('Failed', error)
|
||||||
|
this.$toast.error('Failed to apply backup')
|
||||||
|
})
|
||||||
},
|
},
|
||||||
deleteBackupClick(backup) {
|
deleteBackupClick(backup) {
|
||||||
if (confirm(`Are you sure you want to delete backup for ${backup.datePretty}?`)) {
|
if (confirm(`Are you sure you want to delete backup for ${backup.datePretty}?`)) {
|
||||||
this.processing = true
|
this.processing = true
|
||||||
this.$axios
|
this.$axios
|
||||||
.$delete(`/api/backup/${backup.id}`)
|
.$delete(`/api/backups/${backup.id}`)
|
||||||
.then((backups) => {
|
.then((backups) => {
|
||||||
console.log('Backup deleted', backups)
|
console.log('Backup deleted', backups)
|
||||||
this.$store.commit('setBackups', backups)
|
this.$store.commit('setBackups', backups)
|
||||||
@@ -98,29 +111,24 @@ export default {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
applyBackupComplete(success) {
|
|
||||||
if (success) {
|
|
||||||
// this.$toast.success('Backup Applied, refresh the page')
|
|
||||||
location.replace('/config/backups?backup=1')
|
|
||||||
} else {
|
|
||||||
this.$toast.error('Failed to apply backup')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
applyBackup(backup) {
|
applyBackup(backup) {
|
||||||
this.selectedBackup = backup
|
this.selectedBackup = backup
|
||||||
this.showConfirmApply = true
|
this.showConfirmApply = true
|
||||||
},
|
},
|
||||||
backupComplete(backups) {
|
|
||||||
this.isBackingUp = false
|
|
||||||
if (backups) {
|
|
||||||
this.$toast.success('Backup Successful')
|
|
||||||
this.$store.commit('setBackups', backups)
|
|
||||||
} else this.$toast.error('Backup Failed')
|
|
||||||
},
|
|
||||||
clickCreateBackup() {
|
clickCreateBackup() {
|
||||||
this.isBackingUp = true
|
this.isBackingUp = true
|
||||||
this.$root.socket.once('backup_complete', this.backupComplete)
|
this.$axios
|
||||||
this.$root.socket.emit('create_backup')
|
.$post('/api/backups')
|
||||||
|
.then((backups) => {
|
||||||
|
this.isBackingUp = false
|
||||||
|
this.$toast.success('Backup Successful')
|
||||||
|
this.$store.commit('setBackups', backups)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.isBackingUp = false
|
||||||
|
console.error('Failed', error)
|
||||||
|
this.$toast.error('Backup Failed')
|
||||||
|
})
|
||||||
},
|
},
|
||||||
backupUploaded(file) {
|
backupUploaded(file) {
|
||||||
var form = new FormData()
|
var form = new FormData()
|
||||||
@@ -129,7 +137,7 @@ export default {
|
|||||||
this.processing = true
|
this.processing = true
|
||||||
|
|
||||||
this.$axios
|
this.$axios
|
||||||
.$post('/api/backup/upload', form)
|
.$post('/api/backups/upload', form)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
console.log('Upload backup result', result)
|
console.log('Upload backup result', result)
|
||||||
this.$store.commit('setBackups', result)
|
this.$store.commit('setBackups', result)
|
||||||
@@ -171,11 +179,11 @@ export default {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
#backups tr:nth-child(even) {
|
#backups tr:nth-child(even):not(.bg-error) {
|
||||||
background-color: #3a3a3a;
|
background-color: #3a3a3a;
|
||||||
}
|
}
|
||||||
|
|
||||||
#backups tr:not(.staticrow):hover {
|
#backups tr:not(.staticrow):not(.bg-error):hover {
|
||||||
background-color: #444;
|
background-color: #444;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full my-2">
|
||||||
|
<div class="w-full bg-primary px-6 py-2 flex items-center cursor-pointer" @click.stop="clickBar">
|
||||||
|
<p class="pr-4">Chapters</p>
|
||||||
|
<span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ chapters.length }}</span>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<ui-btn v-if="userCanUpdate" small :to="`/audiobook/${libraryItemId}/chapters`" color="primary" class="mr-2">Edit Chapters</ui-btn>
|
||||||
|
<div v-if="!keepOpen" class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="expanded ? 'transform rotate-180' : ''">
|
||||||
|
<span class="material-icons text-4xl">expand_more</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<transition name="slide">
|
||||||
|
<table class="text-sm tracksTable" v-show="expanded || keepOpen">
|
||||||
|
<tr class="font-book">
|
||||||
|
<th class="text-left w-16"><span class="px-4">Id</span></th>
|
||||||
|
<th class="text-left">Title</th>
|
||||||
|
<th class="text-center">Start</th>
|
||||||
|
<th class="text-center">End</th>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="chapter in chapters" :key="chapter.id">
|
||||||
|
<td class="text-left">
|
||||||
|
<p class="px-4">{{ chapter.id }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="font-book">
|
||||||
|
{{ chapter.title }}
|
||||||
|
</td>
|
||||||
|
<td class="font-mono text-center">
|
||||||
|
{{ $secondsToTimestamp(chapter.start) }}
|
||||||
|
</td>
|
||||||
|
<td class="font-mono text-center">
|
||||||
|
{{ $secondsToTimestamp(chapter.end) }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
libraryItem: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
},
|
||||||
|
keepOpen: Boolean
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
expanded: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
libraryItemId() {
|
||||||
|
return this.libraryItem.id
|
||||||
|
},
|
||||||
|
media() {
|
||||||
|
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||||
|
},
|
||||||
|
chapters() {
|
||||||
|
return this.media.chapters || []
|
||||||
|
},
|
||||||
|
userCanUpdate() {
|
||||||
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
clickBar() {
|
||||||
|
this.expanded = !this.expanded
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
<p class="font-mono text-sm">{{ books.length }}</p>
|
<p class="font-mono text-sm">{{ books.length }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<p v-if="totalDuration">{{ totalDurationPretty }}</p>
|
<!-- <p v-if="totalDuration">{{ totalDurationPretty }}</p> -->
|
||||||
</div>
|
</div>
|
||||||
<draggable v-model="booksCopy" v-bind="dragOptions" class="list-group" handle=".drag-handle" draggable=".item" tag="div" @start="drag = true" @end="drag = false" @update="draggableUpdate">
|
<draggable v-model="booksCopy" v-bind="dragOptions" class="list-group" handle=".drag-handle" draggable=".item" tag="div" @start="drag = true" @end="drag = false" @update="draggableUpdate">
|
||||||
<transition-group type="transition" :name="!drag ? 'collection-book' : null">
|
<transition-group type="transition" :name="!drag ? 'collection-book' : null">
|
||||||
@@ -56,16 +56,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
|
||||||
},
|
|
||||||
totalDuration() {
|
|
||||||
var _total = 0
|
|
||||||
this.books.forEach((book) => {
|
|
||||||
_total += book.duration
|
|
||||||
})
|
|
||||||
return _total
|
|
||||||
},
|
|
||||||
totalDurationPretty() {
|
|
||||||
return this.$elapsedPretty(this.totalDuration)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
+16
-41
@@ -1,14 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full my-2">
|
<div class="w-full my-2">
|
||||||
<div class="w-full bg-primary px-4 md:px-6 py-2 flex items-center cursor-pointer" @click.stop="clickBar">
|
<div class="w-full bg-primary px-4 md:px-6 py-2 flex items-center cursor-pointer" @click.stop="clickBar">
|
||||||
<p class="pr-2 md:pr-4">Other Files</p>
|
<p class="pr-2 md:pr-4">Library Files</p>
|
||||||
<div class="h-5 md:h-7 w-5 md:w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
|
<div class="h-5 md:h-7 w-5 md:w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
|
||||||
<span class="text-sm font-mono">{{ files.length }}</span>
|
<span class="text-sm font-mono">{{ files.length }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<!-- <nuxt-link :to="`/audiobook/${audiobookId}/edit`" class="mr-4">
|
|
||||||
<ui-btn small color="primary">Manage Tracks</ui-btn>
|
|
||||||
</nuxt-link> -->
|
|
||||||
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
|
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
|
||||||
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showFiles ? 'transform rotate-180' : ''">
|
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showFiles ? 'transform rotate-180' : ''">
|
||||||
<span class="material-icons text-4xl">expand_more</span>
|
<span class="material-icons text-4xl">expand_more</span>
|
||||||
@@ -19,22 +16,25 @@
|
|||||||
<table class="text-sm tracksTable">
|
<table class="text-sm tracksTable">
|
||||||
<tr class="font-book">
|
<tr class="font-book">
|
||||||
<th class="text-left px-4">Path</th>
|
<th class="text-left px-4">Path</th>
|
||||||
|
<th class="text-left w-24 min-w-24">Size</th>
|
||||||
<th class="text-left px-4 w-24">Filetype</th>
|
<th class="text-left px-4 w-24">Filetype</th>
|
||||||
<th v-if="userCanDownload && !isMissing" class="text-center w-20">Download</th>
|
<th v-if="userCanDownload && !isMissing" class="text-center w-20">Download</th>
|
||||||
</tr>
|
</tr>
|
||||||
<template v-for="file in otherFilesCleaned">
|
<template v-for="file in files">
|
||||||
<tr :key="file.path">
|
<tr :key="file.path">
|
||||||
<td class="font-book pl-2">
|
<td class="font-book px-4">
|
||||||
{{ showFullPath ? file.fullPath : file.path }}
|
{{ showFullPath ? file.metadata.path : file.metadata.relPath }}
|
||||||
|
</td>
|
||||||
|
<td class="font-mono">
|
||||||
|
{{ $bytesPretty(file.metadata.size) }}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-xs">
|
<td class="text-xs">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span v-if="file.filetype === 'ebook'" class="material-icons text-base mr-1 cursor-pointer text-white text-opacity-60 hover:text-opacity-100" @click="readEbookClick(file)">auto_stories </span>
|
<p>{{ file.fileType }}</p>
|
||||||
<p>{{ file.filetype }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td v-if="userCanDownload && !isMissing" class="text-center">
|
<td v-if="userCanDownload && !isMissing" class="text-center">
|
||||||
<a :href="`/s/book/${audiobookId}/${file.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
|
<a :href="`/s/item/${libraryItemId}/${$encodeUriPath(file.metadata.relPath).replace(/^\//, '')}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
@@ -51,10 +51,9 @@ export default {
|
|||||||
type: Array,
|
type: Array,
|
||||||
default: () => []
|
default: () => []
|
||||||
},
|
},
|
||||||
audiobook: {
|
libraryItemId: String,
|
||||||
type: Object,
|
isMissing: Boolean,
|
||||||
default: () => null
|
expanded: Boolean // start expanded
|
||||||
}
|
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -63,44 +62,20 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
audiobookId() {
|
|
||||||
return this.audiobook.id
|
|
||||||
},
|
|
||||||
audiobookPath() {
|
|
||||||
return this.audiobook.path
|
|
||||||
},
|
|
||||||
otherFilesCleaned() {
|
|
||||||
return this.files.map((file) => {
|
|
||||||
var filePath = file.path.replace(/\\/g, '/')
|
|
||||||
var audiobookPath = this.audiobookPath.replace(/\\/g, '/')
|
|
||||||
|
|
||||||
return {
|
|
||||||
...file,
|
|
||||||
relativePath: filePath
|
|
||||||
.replace(audiobookPath + '/', '')
|
|
||||||
.replace(/%/g, '%25')
|
|
||||||
.replace(/#/g, '%23')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
userToken() {
|
userToken() {
|
||||||
return this.$store.getters['user/getToken']
|
return this.$store.getters['user/getToken']
|
||||||
},
|
},
|
||||||
isMissing() {
|
|
||||||
return this.audiobook.isMissing
|
|
||||||
},
|
|
||||||
userCanDownload() {
|
userCanDownload() {
|
||||||
return this.$store.getters['user/getUserCanDownload']
|
return this.$store.getters['user/getUserCanDownload']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
readEbookClick(file) {
|
|
||||||
this.$store.commit('showEReaderForFile', { audiobook: this.audiobook, file })
|
|
||||||
},
|
|
||||||
clickBar() {
|
clickBar() {
|
||||||
this.showFiles = !this.showFiles
|
this.showFiles = !this.showFiles
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {
|
||||||
|
this.showFiles = this.expanded
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full my-2">
|
<div class="w-full my-2">
|
||||||
<div class="w-full bg-primary px-4 md:px-6 py-2 flex items-center cursor-pointer" @click.stop="clickBar">
|
<div class="w-full bg-primary px-4 md:px-6 py-2 flex items-center cursor-pointer" @click.stop="clickBar">
|
||||||
<p class="pr-2 md:pr-4">Audio Tracks</p>
|
<p class="pr-2 md:pr-4">{{ title }}</p>
|
||||||
<div class="h-5 md:h-7 w-5 md:w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
|
<div class="h-5 md:h-7 w-5 md:w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
|
||||||
<span class="text-sm font-mono">{{ tracks.length }}</span>
|
<span class="text-sm font-mono">{{ tracks.length }}</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- <span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ tracks.length }}</span> -->
|
<!-- <span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ tracks.length }}</span> -->
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
|
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
|
||||||
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobookId}/edit`" class="mr-2 md:mr-4">
|
<nuxt-link v-if="userCanUpdate && !isFile" :to="`/audiobook/${libraryItemId}/edit`" class="mr-2 md:mr-4" @mousedown.prevent>
|
||||||
<ui-btn small color="primary">Manage Tracks</ui-btn>
|
<ui-btn small color="primary">Manage Tracks</ui-btn>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showTracks ? 'transform rotate-180' : ''">
|
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showTracks ? 'transform rotate-180' : ''">
|
||||||
@@ -25,20 +25,20 @@
|
|||||||
<th class="text-left w-20">Duration</th>
|
<th class="text-left w-20">Duration</th>
|
||||||
<th v-if="userCanDownload" class="text-center w-20">Download</th>
|
<th v-if="userCanDownload" class="text-center w-20">Download</th>
|
||||||
</tr>
|
</tr>
|
||||||
<template v-for="track in tracksCleaned">
|
<template v-for="track in tracks">
|
||||||
<tr :key="track.index">
|
<tr :key="track.index">
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
<p>{{ track.index }}</p>
|
<p>{{ track.index }}</p>
|
||||||
</td>
|
</td>
|
||||||
<td class="font-sans">{{ showFullPath ? track.fullPath : track.filename }}</td>
|
<td class="font-sans">{{ showFullPath ? track.metadata.path : track.metadata.filename }}</td>
|
||||||
<td class="font-mono">
|
<td class="font-mono">
|
||||||
{{ $bytesPretty(track.size) }}
|
{{ $bytesPretty(track.metadata.size) }}
|
||||||
</td>
|
</td>
|
||||||
<td class="font-mono">
|
<td class="font-mono">
|
||||||
{{ $secondsToTimestamp(track.duration) }}
|
{{ $secondsToTimestamp(track.duration) }}
|
||||||
</td>
|
</td>
|
||||||
<td v-if="userCanDownload" class="text-center">
|
<td v-if="userCanDownload" class="text-center">
|
||||||
<a :href="`/s/book/${audiobook.id}/${track.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
|
<a :href="`/s/item/${libraryItemId}/${$encodeUriPath(track.metadata.relPath).replace(/^\//, '')}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
@@ -51,14 +51,16 @@
|
|||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: 'Audio Tracks'
|
||||||
|
},
|
||||||
tracks: {
|
tracks: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => []
|
default: () => []
|
||||||
},
|
},
|
||||||
audiobook: {
|
libraryItemId: String,
|
||||||
type: Object,
|
isFile: Boolean
|
||||||
default: () => null
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -67,26 +69,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
audiobookId() {
|
|
||||||
return this.audiobook.id
|
|
||||||
},
|
|
||||||
audiobookPath() {
|
|
||||||
return this.audiobook.path
|
|
||||||
},
|
|
||||||
tracksCleaned() {
|
|
||||||
return this.tracks.map((track) => {
|
|
||||||
var trackPath = track.path.replace(/\\/g, '/')
|
|
||||||
var audiobookPath = this.audiobookPath.replace(/\\/g, '/')
|
|
||||||
|
|
||||||
return {
|
|
||||||
...track,
|
|
||||||
relativePath: trackPath
|
|
||||||
.replace(audiobookPath + '/', '')
|
|
||||||
.replace(/%/g, '%25')
|
|
||||||
.replace(/#/g, '%23')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
userToken() {
|
userToken() {
|
||||||
return this.$store.getters['user/getToken']
|
return this.$store.getters['user/getToken']
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full my-4" @mousedown.prevent @mouseup.prevent>
|
<div class="w-full my-4">
|
||||||
<div class="w-full bg-primary px-6 py-1 flex items-center cursor-pointer" @click.stop="clickBar">
|
<div class="w-full bg-primary px-6 py-1 flex items-center cursor-pointer" @click.stop="clickBar">
|
||||||
<p class="pr-4">{{ title }}</p>
|
<p class="pr-4">{{ title }}</p>
|
||||||
<span class="bg-black-400 rounded-xl py-0.5 px-2 text-sm font-mono">{{ files.length }}</span>
|
<span class="bg-black-400 rounded-xl py-0.5 px-2 text-sm font-mono">{{ files.length }}</span>
|
||||||
|
|||||||
@@ -26,11 +26,11 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="text-sm">{{ user.type }}</td>
|
<td class="text-sm">{{ user.type }}</td>
|
||||||
<td class="hidden lg:table-cell">
|
<td class="hidden lg:table-cell">
|
||||||
<div v-if="usersOnline[user.id] && usersOnline[user.id].stream && usersOnline[user.id].stream.audiobook && usersOnline[user.id].stream.audiobook.book">
|
<div v-if="usersOnline[user.id] && usersOnline[user.id].session && usersOnline[user.id].session.libraryItem && usersOnline[user.id].session.libraryItem.media">
|
||||||
<p class="truncate text-xs">Reading: {{ usersOnline[user.id].stream.audiobook.book.title || '' }}</p>
|
<p class="truncate text-xs">Listening: {{ usersOnline[user.id].session.libraryItem.media.metadata.title || '' }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="user.audiobooks && getLastRead(user.audiobooks)">
|
<div v-else-if="user.mostRecent">
|
||||||
<p class="truncate text-xs">Last: {{ getLastRead(user.audiobooks) }}</p>
|
<p class="truncate text-xs">Last: {{ user.mostRecent.metadata.title }}</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-xs font-mono hidden sm:table-cell">
|
<td class="text-xs font-mono hidden sm:table-cell">
|
||||||
@@ -45,8 +45,8 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="py-0">
|
<td class="py-0">
|
||||||
<div class="w-full flex justify-center">
|
<div class="w-full flex justify-center">
|
||||||
<!-- <span class="material-icons hover:text-gray-400 cursor-pointer text-base pr-2" @click.stop="editUser(user)">edit</span> -->
|
<!-- Dont show edit for non-root users -->
|
||||||
<div class="h-8 w-8 flex items-center justify-center text-white text-opacity-50 hover:text-opacity-100 cursor-pointer" @click.stop="editUser(user)">
|
<div v-if="user.type !== 'root' || userIsRoot" class="h-8 w-8 flex items-center justify-center text-white text-opacity-50 hover:text-opacity-100 cursor-pointer" @click.stop="editUser(user)">
|
||||||
<span class="material-icons text-base">edit</span>
|
<span class="material-icons text-base">edit</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="user.type !== 'root'" class="h-8 w-8 flex items-center justify-center text-white text-opacity-50 hover:text-error cursor-pointer" @click.stop="deleteUserClick(user)">
|
<div v-show="user.type !== 'root'" class="h-8 w-8 flex items-center justify-center text-white text-opacity-50 hover:text-error cursor-pointer" @click.stop="deleteUserClick(user)">
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<modals-account-modal v-model="showAccountModal" :account="selectedAccount" />
|
<modals-account-modal ref="accountModal" v-model="showAccountModal" :account="selectedAccount" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -76,28 +76,16 @@ export default {
|
|||||||
currentUserId() {
|
currentUserId() {
|
||||||
return this.$store.state.user.user.id
|
return this.$store.state.user.user.id
|
||||||
},
|
},
|
||||||
userStream() {
|
userIsRoot() {
|
||||||
return this.$store.state.streamAudiobook
|
return this.$store.getters['user/getIsRoot']
|
||||||
},
|
},
|
||||||
usersOnline() {
|
usersOnline() {
|
||||||
var usermap = {}
|
var usermap = {}
|
||||||
this.$store.state.users.users.forEach((u) => (usermap[u.id] = { online: true, stream: u.stream }))
|
this.$store.state.users.users.forEach((u) => (usermap[u.id] = { online: true, session: u.session }))
|
||||||
return usermap
|
return usermap
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
getLastRead(audiobooks) {
|
|
||||||
var abs = Object.values(audiobooks).filter((ab) => {
|
|
||||||
return ab.progress > 0
|
|
||||||
})
|
|
||||||
if (abs.length) {
|
|
||||||
abs = abs.sort((a, b) => b.lastUpdate - a.lastUpdate)
|
|
||||||
// Book object is attached on request
|
|
||||||
if (abs[0].book) return abs[0].book.title
|
|
||||||
return abs[0].audiobookTitle ? abs[0].audiobookTitle : null
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
},
|
|
||||||
deleteUserClick(user) {
|
deleteUserClick(user) {
|
||||||
if (this.isDeletingUser) return
|
if (this.isDeletingUser) return
|
||||||
if (confirm(`Are you sure you want to permanently delete user "${user.username}"?`)) {
|
if (confirm(`Are you sure you want to permanently delete user "${user.username}"?`)) {
|
||||||
@@ -171,6 +159,10 @@ export default {
|
|||||||
this.init()
|
this.init()
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
|
if (this.$refs.accountModal) {
|
||||||
|
this.$refs.accountModal.close()
|
||||||
|
}
|
||||||
|
|
||||||
if (this.$root.socket) {
|
if (this.$root.socket) {
|
||||||
this.$root.socket.off('user_added', this.newUserAdded)
|
this.$root.socket.off('user_added', this.newUserAdded)
|
||||||
this.$root.socket.off('user_updated', this.userUpdated)
|
this.$root.socket.off('user_updated', this.userUpdated)
|
||||||
|
|||||||
@@ -7,20 +7,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="h-full relative" :style="{ width: coverWidth + 'px' }">
|
<div class="h-full relative" :style="{ width: coverWidth + 'px' }">
|
||||||
<covers-book-cover :audiobook="book" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-book-cover :library-item="book" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
<div class="absolute top-0 left-0 bg-black bg-opacity-50 flex items-center justify-center h-full w-full z-10" v-show="isHovering && showPlayBtn">
|
<div class="absolute top-0 left-0 bg-black bg-opacity-50 flex items-center justify-center h-full w-full z-10" v-show="isHovering && showPlayBtn">
|
||||||
<div class="w-8 h-8 bg-white bg-opacity-20 rounded-full flex items-center justify-center hover:bg-opacity-40 cursor-pointer" @click="playClick">
|
<div class="w-8 h-8 bg-white bg-opacity-20 rounded-full flex items-center justify-center hover:bg-opacity-40 cursor-pointer" @click="playClick">
|
||||||
<span class="material-icons">play_arrow</span>
|
<span class="material-icons">play_arrow</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-80 h-full px-2 flex items-center">
|
<div class="flex-grow max-w-md h-full px-2 flex items-center">
|
||||||
<div>
|
<div class="truncate px-1">
|
||||||
<nuxt-link :to="`/audiobook/${book.id}`" class="truncate hover:underline">{{ bookTitle }}</nuxt-link>
|
<nuxt-link :to="`/item/${book.id}`" class="truncate hover:underline">{{ bookTitle }}</nuxt-link>
|
||||||
<nuxt-link :to="`/library/${book.libraryId}/bookshelf?filter=authors.${$encode(bookAuthor)}`" class="truncate block text-gray-400 text-sm hover:underline">{{ bookAuthor }}</nuxt-link>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow flex items-center">
|
<div class="w-20 flex items-center">
|
||||||
<p class="font-mono text-sm">{{ bookDuration }}</p>
|
<p class="font-mono text-sm">{{ bookDuration }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -28,15 +27,10 @@
|
|||||||
<span class="material-icons text-lg text-white text-opacity-70 hover:text-opacity-100 cursor-pointer">radio_button_unchecked</span>
|
<span class="material-icons text-lg text-white text-opacity-70 hover:text-opacity-100 cursor-pointer">radio_button_unchecked</span>
|
||||||
</div> -->
|
</div> -->
|
||||||
</div>
|
</div>
|
||||||
<!-- <div class="absolute top-0 left-0 z-40 bg-red-500 w-full h-full">
|
|
||||||
<div class="w-24 h-full absolute top-0 -right-24 transform transition-transform" :class="isHovering ? 'translate-x-0' : '-translate-x-24'">
|
|
||||||
<span class="material-icons">edit</span>
|
|
||||||
</div>
|
|
||||||
</div> -->
|
|
||||||
<div class="w-40 absolute top-0 -right-24 h-full transform transition-transform" :class="!isHovering ? 'translate-x-0' : '-translate-x-24'">
|
<div class="w-40 absolute top-0 -right-24 h-full transform transition-transform" :class="!isHovering ? 'translate-x-0' : '-translate-x-24'">
|
||||||
<div class="flex h-full items-center">
|
<div class="flex h-full items-center">
|
||||||
<ui-tooltip :text="isRead ? 'Mark as Not Read' : 'Mark as Read'" direction="top">
|
<ui-tooltip :text="userIsFinished ? 'Mark as Not Finished' : 'Mark as Finished'" direction="top">
|
||||||
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="isRead" borderless class="mx-1 mt-0.5" @click="toggleRead" />
|
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
<div class="mx-1" :class="isHovering ? '' : 'ml-6'">
|
<div class="mx-1" :class="isHovering ? '' : 'ml-6'">
|
||||||
<ui-icon-btn icon="edit" borderless @click="clickEdit" />
|
<ui-icon-btn icon="edit" borderless @click="clickEdit" />
|
||||||
@@ -68,12 +62,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
userIsRead: {
|
|
||||||
immediate: true,
|
|
||||||
handler(newVal) {
|
|
||||||
this.isRead = newVal
|
|
||||||
}
|
|
||||||
},
|
|
||||||
isDragging: {
|
isDragging: {
|
||||||
handler(newVal) {
|
handler(newVal) {
|
||||||
if (newVal) {
|
if (newVal) {
|
||||||
@@ -83,17 +71,23 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
_book() {
|
media() {
|
||||||
return this.book.book || {}
|
return this.book.media || {}
|
||||||
|
},
|
||||||
|
mediaMetadata() {
|
||||||
|
return this.media.metadata || {}
|
||||||
|
},
|
||||||
|
tracks() {
|
||||||
|
return this.media.tracks || []
|
||||||
},
|
},
|
||||||
bookTitle() {
|
bookTitle() {
|
||||||
return this._book.title || ''
|
return this.mediaMetadata.title || ''
|
||||||
},
|
},
|
||||||
bookAuthor() {
|
bookAuthor() {
|
||||||
return this._book.authorFL || ''
|
return (this.mediaMetadata.authors || []).map((au) => au.name).join(', ')
|
||||||
},
|
},
|
||||||
bookDuration() {
|
bookDuration() {
|
||||||
return this.$secondsToTimestamp(this.book.duration)
|
return this.$secondsToTimestamp(this.media.duration)
|
||||||
},
|
},
|
||||||
isMissing() {
|
isMissing() {
|
||||||
return this.book.isMissing
|
return this.book.isMissing
|
||||||
@@ -101,23 +95,17 @@ export default {
|
|||||||
isInvalid() {
|
isInvalid() {
|
||||||
return this.book.isInvalid
|
return this.book.isInvalid
|
||||||
},
|
},
|
||||||
numTracks() {
|
|
||||||
return this.book.numTracks
|
|
||||||
},
|
|
||||||
isStreaming() {
|
isStreaming() {
|
||||||
return this.$store.getters['getAudiobookIdStreaming'] === this.book.id
|
return this.$store.getters['getLibraryItemIdStreaming'] === this.book.id
|
||||||
},
|
},
|
||||||
showPlayBtn() {
|
showPlayBtn() {
|
||||||
return !this.isMissing && !this.isInvalid && !this.isStreaming && this.numTracks
|
return !this.isMissing && !this.isInvalid && !this.isStreaming && this.tracks.length
|
||||||
},
|
},
|
||||||
userAudiobooks() {
|
itemProgress() {
|
||||||
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
|
return this.$store.getters['user/getUserMediaProgress'](this.book.id)
|
||||||
},
|
},
|
||||||
userAudiobook() {
|
userIsFinished() {
|
||||||
return this.userAudiobooks[this.book.id] || null
|
return this.itemProgress ? !!this.itemProgress.isFinished : false
|
||||||
},
|
|
||||||
userIsRead() {
|
|
||||||
return this.userAudiobook ? !!this.userAudiobook.isRead : false
|
|
||||||
},
|
},
|
||||||
coverWidth() {
|
coverWidth() {
|
||||||
if (this.bookCoverAspectRatio === 1) return 50 * 1.6
|
if (this.bookCoverAspectRatio === 1) return 50 * 1.6
|
||||||
@@ -133,26 +121,28 @@ export default {
|
|||||||
this.isHovering = false
|
this.isHovering = false
|
||||||
},
|
},
|
||||||
playClick() {
|
playClick() {
|
||||||
this.$eventBus.$emit('play-audiobook', this.book.id)
|
this.$eventBus.$emit('play-item', {
|
||||||
|
libraryItemId: this.book.id
|
||||||
|
})
|
||||||
},
|
},
|
||||||
clickEdit() {
|
clickEdit() {
|
||||||
this.$emit('edit', this.book)
|
this.$emit('edit', this.book)
|
||||||
},
|
},
|
||||||
toggleRead() {
|
toggleFinished() {
|
||||||
var updatePayload = {
|
var updatePayload = {
|
||||||
isRead: !this.isRead
|
isFinished: !this.userIsFinished
|
||||||
}
|
}
|
||||||
this.isProcessingReadUpdate = true
|
this.isProcessingReadUpdate = true
|
||||||
this.$axios
|
this.$axios
|
||||||
.$patch(`/api/me/audiobook/${this.book.id}`, updatePayload)
|
.$patch(`/api/me/progress/${this.book.id}`, updatePayload)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.isProcessingReadUpdate = false
|
this.isProcessingReadUpdate = false
|
||||||
this.$toast.success(`"${this.bookTitle}" Marked as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
|
this.$toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
this.isProcessingReadUpdate = false
|
this.isProcessingReadUpdate = false
|
||||||
this.$toast.error(`Failed to mark as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
|
this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
removeClick() {
|
removeClick() {
|
||||||
|
|||||||
+9
-11
@@ -6,18 +6,20 @@
|
|||||||
<span class="material-icons" style="font-size: 1.4rem">add</span>
|
<span class="material-icons" style="font-size: 1.4rem">add</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<draggable :list="libraryCopies" v-bind="dragOptions" class="list-group" draggable=".item" tag="div" @start="startDrag" @end="endDrag">
|
<draggable v-if="libraryCopies.length" :list="libraryCopies" v-bind="dragOptions" class="list-group" draggable=".item" tag="div" @start="startDrag" @end="endDrag">
|
||||||
<template v-for="library in libraryCopies">
|
<template v-for="library in libraryCopies">
|
||||||
<div :key="library.id" class="item">
|
<div :key="library.id" class="item">
|
||||||
<modals-libraries-library-item :library="library" :selected="currentLibraryId === library.id" :show-edit="true" :dragging="drag" @edit="editLibrary" @click="setLibrary" />
|
<tables-library-item :library="library" :selected="currentLibraryId === library.id" :show-edit="true" :dragging="drag" @edit="editLibrary" @click="setLibrary" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</draggable>
|
</draggable>
|
||||||
<modals-edit-library-modal v-model="showLibraryModal" :library="selectedLibrary" />
|
<div v-if="!libraries.length" class="pb-4">
|
||||||
|
<ui-btn @click="clickAddLibrary">Add your first library</ui-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p class="text-xs mt-4 text-gray-200">*<strong>Force Re-Scan</strong> will scan all files again like a fresh scan. Audio file ID3 tags, OPF files, and text files will be probed/parsed and used for book details.</p>
|
<p v-if="libraries.length" class="text-xs mt-4 text-gray-200">*<strong>Force Re-Scan</strong> will scan all files again like a fresh scan. Audio file ID3 tags, OPF files, and text files will be probed/parsed and used for book details.</p>
|
||||||
|
|
||||||
<p class="text-xs mt-4 text-gray-200">**<strong>Match Books</strong> will attempt to match books in library with a book from the selected search provider and fill in empty details and cover art. Does not overwrite details.</p>
|
<p v-if="libraries.length && libraries.some((li) => li.mediaType === 'book')" class="text-xs mt-4 text-gray-200">**<strong>Match Books</strong> will attempt to match books in library with a book from the selected search provider and fill in empty details and cover art. Does not overwrite details.</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -32,8 +34,6 @@ export default {
|
|||||||
return {
|
return {
|
||||||
libraryCopies: [],
|
libraryCopies: [],
|
||||||
currentOrder: [],
|
currentOrder: [],
|
||||||
showLibraryModal: false,
|
|
||||||
selectedLibrary: null,
|
|
||||||
drag: false,
|
drag: false,
|
||||||
dragOptions: {
|
dragOptions: {
|
||||||
animation: 200,
|
animation: 200,
|
||||||
@@ -97,12 +97,10 @@ export default {
|
|||||||
this.$router.push(`/library/${library.id}`)
|
this.$router.push(`/library/${library.id}`)
|
||||||
},
|
},
|
||||||
clickAddLibrary() {
|
clickAddLibrary() {
|
||||||
this.selectedLibrary = null
|
this.$emit('showLibraryModal', null)
|
||||||
this.showLibraryModal = true
|
|
||||||
},
|
},
|
||||||
editLibrary(library) {
|
editLibrary(library) {
|
||||||
this.selectedLibrary = library
|
this.$emit('showLibraryModal', library)
|
||||||
this.showLibraryModal = true
|
|
||||||
},
|
},
|
||||||
init() {
|
init() {
|
||||||
this.libraryCopies = this.libraries.map((lib) => {
|
this.libraryCopies = this.libraries.map((lib) => {
|
||||||
+30
-15
@@ -7,13 +7,13 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<p class="text-xl font-book pl-4 hover:underline cursor-pointer" @click.stop="$emit('click', library)">{{ library.name }}</p>
|
<p class="text-xl font-book pl-4 hover:underline cursor-pointer" @click.stop="$emit('click', library)">{{ library.name }}</p>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<ui-btn v-show="isHovering && !libraryScan && canScan" small color="success" @click.stop="scan">Scan</ui-btn>
|
<ui-btn v-show="isHovering && !libraryScan" small color="success" @click.stop="scan">Scan</ui-btn>
|
||||||
<ui-btn v-show="isHovering && !libraryScan && canScan" small color="bg" class="ml-2" @click.stop="forceScan">Force Re-Scan</ui-btn>
|
<ui-btn v-show="isHovering && !libraryScan" small color="bg" class="ml-2" @click.stop="forceScan">Force Re-Scan</ui-btn>
|
||||||
|
|
||||||
<ui-btn v-show="isHovering && !libraryScan && canScan" small color="bg" class="ml-2" @click.stop="matchAll">Match Books</ui-btn>
|
<ui-btn v-show="isHovering && !libraryScan && isBookLibrary" small color="bg" class="ml-2" @click.stop="matchAll">Match Books</ui-btn>
|
||||||
|
|
||||||
<span v-show="isHovering && !libraryScan && showEdit && canEdit" class="material-icons text-xl text-gray-300 hover:text-gray-50 ml-4 cursor-pointer" @click.stop="editClick">edit</span>
|
<span v-show="isHovering && !libraryScan && showEdit" class="material-icons text-xl text-gray-300 hover:text-gray-50 ml-4 cursor-pointer" @click.stop="editClick">edit</span>
|
||||||
<span v-show="!libraryScan && isHovering && showEdit && canDelete && !isDeleting" class="material-icons text-xl text-gray-300 ml-3" :class="isMain ? 'text-opacity-5 cursor-not-allowed' : 'hover:text-gray-50 cursor-pointer'" @click.stop="deleteClick">delete</span>
|
<span v-show="!libraryScan && isHovering && showEdit && !isDeleting" class="material-icons text-xl text-gray-300 ml-3" :class="isMain ? 'text-opacity-5 cursor-not-allowed' : 'hover:text-gray-50 cursor-pointer'" @click.stop="deleteClick">delete</span>
|
||||||
<div v-show="isDeleting" class="text-xl text-gray-300 ml-3 animate-spin">
|
<div v-show="isDeleting" class="text-xl text-gray-300 ml-3 animate-spin">
|
||||||
<svg viewBox="0 0 24 24" class="w-6 h-6">
|
<svg viewBox="0 0 24 24" class="w-6 h-6">
|
||||||
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
||||||
@@ -49,20 +49,17 @@ export default {
|
|||||||
libraryScan() {
|
libraryScan() {
|
||||||
return this.$store.getters['scanners/getLibraryScan'](this.library.id)
|
return this.$store.getters['scanners/getLibraryScan'](this.library.id)
|
||||||
},
|
},
|
||||||
canEdit() {
|
mediaType() {
|
||||||
return this.$store.getters['user/getIsRoot']
|
return this.library.mediaType
|
||||||
},
|
},
|
||||||
canDelete() {
|
isBookLibrary() {
|
||||||
return this.$store.getters['user/getIsRoot']
|
return this.mediaType === 'book'
|
||||||
},
|
|
||||||
canScan() {
|
|
||||||
return this.$store.getters['user/getIsRoot']
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
matchAll() {
|
matchAll() {
|
||||||
this.$axios
|
this.$axios
|
||||||
.$post(`/api/libraries/${this.library.id}/matchbooks`)
|
.$post(`/api/libraries/${this.library.id}/matchall`)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
console.log('Starting scan for matches')
|
console.log('Starting scan for matches')
|
||||||
})
|
})
|
||||||
@@ -76,10 +73,28 @@ export default {
|
|||||||
this.$emit('edit', this.library)
|
this.$emit('edit', this.library)
|
||||||
},
|
},
|
||||||
scan() {
|
scan() {
|
||||||
this.$root.socket.emit('scan', 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.$root.socket.emit('scan', this.library.id, { forceRescan: true })
|
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
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
<template>
|
||||||
|
<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 class="flex-grow px-2">
|
||||||
|
<p class="text-sm font-semibold">
|
||||||
|
{{ title }}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5">{{ description }}</p>
|
||||||
|
<div class="flex items-center pt-2">
|
||||||
|
<div class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer" :class="userIsFinished ? 'text-white text-opacity-40' : ''" @click="playClick">
|
||||||
|
<span class="material-icons" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
|
||||||
|
<p class="pl-2 pr-1 text-sm font-semibold">{{ timeRemaining }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ui-tooltip :text="userIsFinished ? 'Mark as Not Finished' : 'Mark as Finished'" direction="top">
|
||||||
|
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
|
||||||
|
</ui-tooltip>
|
||||||
|
<p v-if="episode.season" class="px-4 text-sm text-gray-300">Season #{{ episode.season }}</p>
|
||||||
|
<p v-if="episode.episode" class="px-4 text-sm text-gray-300">Episode #{{ episode.episode }}</p>
|
||||||
|
<p v-if="publishedAt" class="px-4 text-sm text-gray-300">Published {{ $formatDate(publishedAt, 'MMM do, yyyy') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-24 min-w-24" />
|
||||||
|
</div>
|
||||||
|
<div class="w-24 min-w-24 -right-0 absolute top-0 h-full transform transition-transform" :class="!isHovering ? 'translate-x-32' : 'translate-x-0'">
|
||||||
|
<div class="flex h-full items-center">
|
||||||
|
<div class="mx-1">
|
||||||
|
<ui-icon-btn v-if="userCanUpdate" icon="edit" borderless @click="clickEdit" />
|
||||||
|
</div>
|
||||||
|
<div class="mx-1">
|
||||||
|
<ui-icon-btn v-if="userCanDelete" icon="close" borderless @click="removeClick" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!userIsFinished" class="absolute bottom-0 left-0 h-0.5 bg-warning" :style="{ width: itemProgressPercent * 100 + '%' }" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
libraryItemId: String,
|
||||||
|
episode: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
isProcessingReadUpdate: false,
|
||||||
|
processingRemove: false,
|
||||||
|
isHovering: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
userCanUpdate() {
|
||||||
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
|
},
|
||||||
|
userCanDelete() {
|
||||||
|
return this.$store.getters['user/getUserCanDelete']
|
||||||
|
},
|
||||||
|
audioFile() {
|
||||||
|
return this.episode.audioFile
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
return this.episode.title || ''
|
||||||
|
},
|
||||||
|
description() {
|
||||||
|
if (this.episode.subtitle) return this.episode.subtitle
|
||||||
|
var desc = this.episode.description || ''
|
||||||
|
return desc
|
||||||
|
},
|
||||||
|
duration() {
|
||||||
|
return this.$secondsToTimestamp(this.episode.duration)
|
||||||
|
},
|
||||||
|
isStreaming() {
|
||||||
|
return this.$store.getters['getIsEpisodeStreaming'](this.libraryItemId, this.episode.id)
|
||||||
|
},
|
||||||
|
streamIsPlaying() {
|
||||||
|
return this.$store.state.streamIsPlaying && this.isStreaming
|
||||||
|
},
|
||||||
|
itemProgress() {
|
||||||
|
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, this.episode.id)
|
||||||
|
},
|
||||||
|
itemProgressPercent() {
|
||||||
|
return this.itemProgress ? this.itemProgress.progress : 0
|
||||||
|
},
|
||||||
|
userIsFinished() {
|
||||||
|
return this.itemProgress ? !!this.itemProgress.isFinished : false
|
||||||
|
},
|
||||||
|
timeRemaining() {
|
||||||
|
if (this.streamIsPlaying) return 'Playing'
|
||||||
|
if (!this.itemProgress) return this.$elapsedPretty(this.episode.duration)
|
||||||
|
if (this.userIsFinished) return 'Finished'
|
||||||
|
var remaining = Math.floor(this.itemProgress.duration - this.itemProgress.currentTime)
|
||||||
|
return `${this.$elapsedPretty(remaining)} left`
|
||||||
|
},
|
||||||
|
publishedAt() {
|
||||||
|
return this.episode.publishedAt
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
mouseover() {
|
||||||
|
// if (this.isDragging) return
|
||||||
|
this.isHovering = true
|
||||||
|
},
|
||||||
|
mouseleave() {
|
||||||
|
this.isHovering = false
|
||||||
|
},
|
||||||
|
clickEdit() {
|
||||||
|
this.$emit('edit', this.episode)
|
||||||
|
},
|
||||||
|
playClick() {
|
||||||
|
if (this.streamIsPlaying) {
|
||||||
|
this.$eventBus.$emit('pause-item')
|
||||||
|
} else {
|
||||||
|
this.$eventBus.$emit('play-item', {
|
||||||
|
libraryItemId: this.libraryItemId,
|
||||||
|
episodeId: this.episode.id
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
toggleFinished() {
|
||||||
|
var updatePayload = {
|
||||||
|
isFinished: !this.userIsFinished
|
||||||
|
}
|
||||||
|
this.isProcessingReadUpdate = true
|
||||||
|
this.$axios
|
||||||
|
.$patch(`/api/me/progress/${this.libraryItemId}/${this.episode.id}`, updatePayload)
|
||||||
|
.then(() => {
|
||||||
|
this.isProcessingReadUpdate = false
|
||||||
|
this.$toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed', error)
|
||||||
|
this.isProcessingReadUpdate = false
|
||||||
|
this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
removeClick() {
|
||||||
|
this.$emit('remove', this.episode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full py-6">
|
||||||
|
<div class="flex items-center mb-4">
|
||||||
|
<p class="text-lg mb-0 font-semibold">Episodes</p>
|
||||||
|
<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" />
|
||||||
|
</div>
|
||||||
|
<p v-if="!episodes.length" class="py-4 text-center text-lg">No Episodes</p>
|
||||||
|
<template v-for="episode in episodesSorted">
|
||||||
|
<tables-podcast-episode-table-row :key="episode.id" :episode="episode" :library-item-id="libraryItem.id" class="item" @remove="removeEpisode" @edit="editEpisode" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<modals-podcast-remove-episode v-model="showPodcastRemoveModal" :library-item="libraryItem" :episode="selectedEpisode" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
libraryItem: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
episodesCopy: [],
|
||||||
|
sortKey: 'publishedAt',
|
||||||
|
sortDesc: true,
|
||||||
|
selectedEpisode: null,
|
||||||
|
showPodcastRemoveModal: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
libraryItem() {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
userCanUpdate() {
|
||||||
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
|
},
|
||||||
|
media() {
|
||||||
|
return this.libraryItem.media || {}
|
||||||
|
},
|
||||||
|
mediaMetadata() {
|
||||||
|
return this.media.metadata || {}
|
||||||
|
},
|
||||||
|
episodes() {
|
||||||
|
return this.media.episodes || []
|
||||||
|
},
|
||||||
|
episodesSorted() {
|
||||||
|
return this.episodesCopy.sort((a, b) => {
|
||||||
|
if (this.sortDesc) {
|
||||||
|
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' })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
removeEpisode(episode) {
|
||||||
|
this.selectedEpisode = episode
|
||||||
|
this.showPodcastRemoveModal = true
|
||||||
|
},
|
||||||
|
editEpisode(episode) {
|
||||||
|
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
|
||||||
|
this.$store.commit('globals/setSelectedEpisode', episode)
|
||||||
|
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
this.episodesCopy = this.episodes.map((ep) => ({ ...ep }))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.episode-item {
|
||||||
|
transition: all 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-enter-from,
|
||||||
|
.episode-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(30px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-leave-active {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<nuxt-link v-if="to" :to="to" class="btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :class="classList">
|
<nuxt-link v-if="to" :to="to" class="btn outline-none rounded-md shadow-md relative border border-gray-600 text-center" :disabled="disabled || loading" :class="classList">
|
||||||
<slot />
|
<slot />
|
||||||
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
|
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
|
||||||
<!-- <span class="material-icons animate-spin">refresh</span> -->
|
<!-- <span class="material-icons animate-spin">refresh</span> -->
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<label class="flex justify-start items-center cursor-pointer">
|
<label class="flex justify-start items-center" :class="!disabled ? 'cursor-pointer' : ''">
|
||||||
<div class="border-2 rounded border-gray-400 flex flex-shrink-0 justify-center items-center" :class="wrapperClass">
|
<div class="border-2 rounded flex flex-shrink-0 justify-center items-center" :class="wrapperClass">
|
||||||
<input v-model="selected" type="checkbox" class="opacity-0 absolute cursor-pointer" />
|
<input v-model="selected" :disabled="disabled" type="checkbox" class="opacity-0 absolute" :class="!disabled ? 'cursor-pointer' : ''" />
|
||||||
<svg v-if="selected" class="fill-current pointer-events-none" :class="svgClass" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg>
|
<svg v-if="selected" class="fill-current pointer-events-none" :class="svgClass" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="label" class="select-none pl-1 text-gray-100" :class="labelClass">{{ label }}</div>
|
<div v-if="label" class="select-none text-gray-100" :class="labelClassname">{{ label }}</div>
|
||||||
</label>
|
</label>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -18,10 +18,19 @@ export default {
|
|||||||
type: String,
|
type: String,
|
||||||
default: 'white'
|
default: 'white'
|
||||||
},
|
},
|
||||||
|
borderColor: {
|
||||||
|
type: String,
|
||||||
|
default: 'gray-400'
|
||||||
|
},
|
||||||
checkColor: {
|
checkColor: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'green-500'
|
default: 'green-500'
|
||||||
}
|
},
|
||||||
|
labelClass: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
disabled: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
@@ -36,15 +45,17 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
wrapperClass() {
|
wrapperClass() {
|
||||||
var classes = [`bg-${this.checkboxBg}`]
|
var classes = [`bg-${this.checkboxBg} border-${this.borderColor}`]
|
||||||
if (this.small) classes.push('w-4 h-4')
|
if (this.small) classes.push('w-4 h-4')
|
||||||
else classes.push('w-6 h-6')
|
else classes.push('w-6 h-6')
|
||||||
|
|
||||||
return classes.join(' ')
|
return classes.join(' ')
|
||||||
},
|
},
|
||||||
labelClass() {
|
labelClassname() {
|
||||||
if (this.small) return 'text-xs md:text-sm'
|
if (this.labelClass) return this.labelClass
|
||||||
return ''
|
var classes = ['pl-1']
|
||||||
|
if (this.small) classes.push('text-xs md:text-sm')
|
||||||
|
return classes.join(' ')
|
||||||
},
|
},
|
||||||
svgClass() {
|
svgClass() {
|
||||||
var classes = [`text-${this.checkColor}`]
|
var classes = [`text-${this.checkColor}`]
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative w-full" v-click-outside="clickOutsideObj">
|
<div class="relative w-full" v-click-outside="clickOutsideObj">
|
||||||
<p class="text-sm font-semibold">{{ label }}</p>
|
<p class="text-sm font-semibold px-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
|
||||||
<button type="button" :disabled="disabled" class="relative w-full border border-gray-600 rounded shadow-sm pl-3 pr-8 py-2 text-left focus:outline-none sm:text-sm cursor-pointer bg-primary" :class="small ? 'h-9' : 'h-10'" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
<button type="button" :disabled="disabled" class="relative w-full border rounded shadow-sm pl-3 pr-8 py-2 text-left focus:outline-none sm:text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
||||||
<span class="flex items-center">
|
<span class="flex items-center">
|
||||||
<span class="block truncate" :class="small ? 'text-sm' : ''">{{ selectedText }}</span>
|
<span class="block truncate" :class="small ? 'text-sm' : ''">{{ selectedText }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||||
<span class="material-icons text-gray-100">expand_more</span>
|
<span class="material-icons">expand_more</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -63,6 +63,16 @@ export default {
|
|||||||
},
|
},
|
||||||
selectedText() {
|
selectedText() {
|
||||||
return this.selectedItem ? this.selectedItem.text : ''
|
return this.selectedItem ? this.selectedItem.text : ''
|
||||||
|
},
|
||||||
|
buttonClass() {
|
||||||
|
var classes = []
|
||||||
|
if (this.small) classes.push('h-9')
|
||||||
|
else classes.push('h-10')
|
||||||
|
|
||||||
|
if (this.disabled) classes.push('cursor-not-allowed border-gray-600 bg-primary bg-opacity-70 border-opacity-70 text-gray-400')
|
||||||
|
else classes.push('cursor-pointer border-gray-600 bg-primary text-gray-100')
|
||||||
|
|
||||||
|
return classes.join(' ')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ export default {
|
|||||||
var _files = Array.from(e.target.files)
|
var _files = Array.from(e.target.files)
|
||||||
if (_files && _files.length) {
|
if (_files && _files.length) {
|
||||||
var file = _files[0]
|
var file = _files[0]
|
||||||
console.log('File', file)
|
|
||||||
this.$emit('change', file)
|
this.$emit('change', file)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<button class="icon-btn rounded-md flex items-center justify-center h-9 w-9 relative" @mousedown.prevent :disabled="disabled" :class="className" @click="clickBtn">
|
<button class="icon-btn rounded-md flex items-center justify-center h-9 w-9 relative" @mousedown.prevent :disabled="disabled || loading" :class="className" @click="clickBtn">
|
||||||
<span :class="outlined ? 'material-icons-outlined' : 'material-icons'" :style="{ fontSize }">{{ icon }}</span>
|
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
|
||||||
|
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
|
||||||
|
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span v-else :class="outlined ? 'material-icons-outlined' : 'material-icons'" :style="{ fontSize }">{{ icon }}</span>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -14,7 +19,8 @@ export default {
|
|||||||
default: 'primary'
|
default: 'primary'
|
||||||
},
|
},
|
||||||
outlined: Boolean,
|
outlined: Boolean,
|
||||||
borderless: Boolean
|
borderless: Boolean,
|
||||||
|
loading: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
@@ -34,7 +40,7 @@ export default {
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
clickBtn(e) {
|
clickBtn(e) {
|
||||||
if (this.disabled) {
|
if (this.disabled || this.loading) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user