mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-06-04 09:50:42 +02:00
Compare commits
220 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 277a5fa37c | |||
| 51b87912f8 | |||
| 653019921e | |||
| ccc291067d | |||
| af7e3a03f0 | |||
| 7c40d26857 | |||
| 6c507de501 | |||
| 482a4340f5 | |||
| 21e704e12c | |||
| 2b91bff1af | |||
| d11f9608b4 | |||
| 2b0b691b69 | |||
| 5dfd5c4971 | |||
| 201f1bff3e | |||
| a22ebb257f | |||
| bf6e87d4bc | |||
| b823a93ae2 | |||
| 05afd12682 | |||
| 997e23150e | |||
| 3c5bf376b5 | |||
| bca2cfda13 | |||
| 916b41d587 | |||
| ab08d83c04 | |||
| 415e0a7b5a | |||
| d301c12acd | |||
| 7aa7e662b2 | |||
| 1dbfb5637a | |||
| 4e1aacb44f | |||
| 954cf3e14e | |||
| b61ecefce4 | |||
| 8562b8d1b3 | |||
| 06ec2159f5 | |||
| 68b565505e | |||
| 83ff2752dd | |||
| d0af1c3c9a | |||
| 1ad46d4fb8 | |||
| d3dd13eae5 | |||
| f27982d887 | |||
| 624a44f572 | |||
| e623bf7fde | |||
| 6fc70b8656 | |||
| 354cefb9f4 | |||
| a78aa88dbc | |||
| 9ac2453676 | |||
| bb70800b4e | |||
| 855272a558 | |||
| ebb2c5f791 | |||
| 2e466bb164 | |||
| 95ebe0f087 | |||
| 0a6aa43b07 | |||
| 806a8cf659 | |||
| 1a32fbfeec | |||
| 67396c16dd | |||
| b0684b6f1b | |||
| 661778c02c | |||
| 5c4241aefe | |||
| 3f6bc90824 | |||
| 4ade6e04a8 | |||
| 49d0835236 | |||
| d90bd92bcc | |||
| 41c016b8c7 | |||
| 5b4d3f71f9 | |||
| 256a9322ef | |||
| 793f82e445 | |||
| ab6da3914b | |||
| 0b53f0ebf3 | |||
| 76d668514e | |||
| 3c347bef7d | |||
| e837e5f780 | |||
| 26348ccc74 | |||
| 729a756e21 | |||
| 4dbddcf179 | |||
| f2fff34d4d | |||
| 59c5e2c1d9 | |||
| 067006f406 | |||
| 93d82b973e | |||
| a9a3423b58 | |||
| f4ee215ad8 | |||
| 48431b1c35 | |||
| ce961f90ba | |||
| 916d2f6bb3 | |||
| 01e7098f00 | |||
| e02fbac4cd | |||
| a8fce32e70 | |||
| d0637c1e3d | |||
| f6702d299d | |||
| 033b7ece28 | |||
| 5f5dce6d53 | |||
| 82c5c7518b | |||
| 7a60ffb3c4 | |||
| 2795f657b5 | |||
| 9ef5b5830e | |||
| 879adfa633 | |||
| b12a344776 | |||
| 50b1098797 | |||
| fdfaa7eba4 | |||
| 5525587513 | |||
| 1f20ed7640 | |||
| f741064843 | |||
| d5138e4c0a | |||
| 42a30c33db | |||
| e5d978f8e8 | |||
| ccc82520a9 | |||
| 22acf52a26 | |||
| 2ccd2786f4 | |||
| 0028136935 | |||
| 0edc46b771 | |||
| 2261f3d1c3 | |||
| 5c0e792782 | |||
| 644882e04f | |||
| 67f51c6de9 | |||
| 0c8fd6ab0e | |||
| 5452a57a14 | |||
| 19f020e7a6 | |||
| 825641f2a9 | |||
| 35ab4cb2fe | |||
| fd13607d89 | |||
| f79b4d44b9 | |||
| 91e30a6e84 | |||
| 8ab0f164a6 | |||
| 578bb03404 | |||
| 06582b5371 | |||
| 7f6baf35b7 | |||
| 6227d0baa1 | |||
| e334b585be | |||
| c83b3f19f7 | |||
| d31ec055f9 | |||
| 38c259a45e | |||
| b2ee24de98 | |||
| 9ba0e52bb7 | |||
| edc712e6f6 | |||
| 485888b2d9 | |||
| c2e90d4d83 | |||
| f5d89b8f52 | |||
| 378b40790a | |||
| be3d38392d | |||
| 27fef50983 | |||
| 167df85c1e | |||
| 009e16c9a4 | |||
| b40cc767b2 | |||
| 4f5f2d32be | |||
| 66be3e0281 | |||
| 987f188f00 | |||
| daca2bdf2a | |||
| 8894f52439 | |||
| 863f81e55a | |||
| d03d3735e5 | |||
| 3bb2df6e12 | |||
| 80c9efc618 | |||
| 3279901ab0 | |||
| d43d351721 | |||
| 8210eba439 | |||
| cbd7294b0b | |||
| 6064e8af87 | |||
| 8754f0c25f | |||
| f31700f668 | |||
| 9877b139f6 | |||
| 5643c846ee | |||
| 5a071babe9 | |||
| 3d85d0bce6 | |||
| 68afc2c718 | |||
| b3d9323f66 | |||
| effc63755b | |||
| b90934a72a | |||
| e01748eb2f | |||
| 430fbf5e46 | |||
| 27e6b9ce0d | |||
| fc614b9833 | |||
| a97c102369 | |||
| 745a491f90 | |||
| b2880ab0a9 | |||
| f916454c55 | |||
| 701b8ea12e | |||
| 2079942ccd | |||
| 140b718592 | |||
| 2de8c72131 | |||
| 089d4b5cee | |||
| e06a015d6e | |||
| b7e546f2f5 | |||
| 26ef275ab4 | |||
| 416db7c981 | |||
| 78079b2e60 | |||
| 03bffb725a | |||
| 46fc89e247 | |||
| fbbceaa642 | |||
| f5aae25cc8 | |||
| 8d03943acb | |||
| 853513b926 | |||
| c606a41314 | |||
| 35f29ca22b | |||
| ac00f3ebe7 | |||
| 6846de98f8 | |||
| 881baa818d | |||
| b671145e73 | |||
| 8809c7b900 | |||
| ae8f3aa918 | |||
| 5d4047c171 | |||
| 6f80591afd | |||
| 9b6fa8fe8c | |||
| d6c02ebb2c | |||
| 788d867ec3 | |||
| 3bc3914fd9 | |||
| 3d821dacb7 | |||
| e0546c6164 | |||
| be7ccfb209 | |||
| 938a8c6f80 | |||
| 5cd343cb01 | |||
| ab0094a53b | |||
| 2d5e4ebcf0 | |||
| 3171ce5aba | |||
| 0e1692d26b | |||
| e8cd18eac2 | |||
| bf928692d5 | |||
| 792490b629 | |||
| 0d1ff35c5e | |||
| 67e02fddbd | |||
| 09beb6a2ae | |||
| 2dba17a7ae | |||
| 4900649908 | |||
| 1350a91fba |
@@ -9,6 +9,12 @@ body:
|
|||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: "### Mobile app issues report [here](https://github.com/advplyr/audiobookshelf-app/issues/new/choose)."
|
value: "### Mobile app issues report [here](https://github.com/advplyr/audiobookshelf-app/issues/new/choose)."
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: "### Join the [discord server](https://discord.gg/pJsjuNCKRq) for questions or if you are not sure about a bug."
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: "## Be as descriptive as you can. Include screenshots, error logs, browser, file types, everything you can think of that might be relevant."
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: what-happened
|
id: what-happened
|
||||||
attributes:
|
attributes:
|
||||||
@@ -27,6 +33,7 @@ body:
|
|||||||
id: version
|
id: version
|
||||||
attributes:
|
attributes:
|
||||||
label: Audiobookshelf version
|
label: Audiobookshelf version
|
||||||
|
description: Do not put 'Latest version', please put the actual version here
|
||||||
placeholder: "e.g. v1.6.60"
|
placeholder: "e.g. v1.6.60"
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
@@ -3,8 +3,15 @@
|
|||||||
name: Build and Push Docker Image
|
name: Build and Push Docker Image
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
# Allows you to run workflow manually from Actions tab
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tags:
|
||||||
|
description: 'Docker Tag'
|
||||||
|
required: true
|
||||||
|
default: 'latest'
|
||||||
push:
|
push:
|
||||||
branches: [master]
|
branches: [main,master]
|
||||||
tags:
|
tags:
|
||||||
- 'v*.*.*'
|
- 'v*.*.*'
|
||||||
# Only build when files in these directories have been changed
|
# Only build when files in these directories have been changed
|
||||||
@@ -13,8 +20,6 @@ on:
|
|||||||
- server/**
|
- server/**
|
||||||
- index.js
|
- index.js
|
||||||
- package.json
|
- package.json
|
||||||
# Allows you to run workflow manually from Actions tab
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -23,24 +28,25 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out
|
- name: Check out
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v3
|
uses: docker/metadata-action@v4
|
||||||
with:
|
with:
|
||||||
images: advplyr/audiobookshelf,ghcr.io/${{ github.repository_owner }}/audiobookshelf
|
images: advplyr/audiobookshelf,ghcr.io/${{ github.repository_owner }}/audiobookshelf
|
||||||
tags: |
|
tags: |
|
||||||
type=edge,branch=master
|
type=edge,branch=master
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
|
|
||||||
- name: Setup QEMU
|
- name: Setup QEMU
|
||||||
uses: docker/setup-qemu-action@v1
|
uses: docker/setup-qemu-action@v2
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v1
|
uses: docker/setup-buildx-action@v2
|
||||||
|
|
||||||
- name: Cache Docker layers
|
- name: Cache Docker layers
|
||||||
uses: actions/cache@v2
|
uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
path: /tmp/.buildx-cache
|
path: /tmp/.buildx-cache
|
||||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||||
@@ -48,22 +54,22 @@ jobs:
|
|||||||
${{ runner.os }}-buildx-
|
${{ runner.os }}-buildx-
|
||||||
|
|
||||||
- name: Login to Dockerhub
|
- name: Login to Dockerhub
|
||||||
uses: docker/login-action@v1
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||||
|
|
||||||
- name: Login to ghcr
|
- name: Login to ghcr
|
||||||
uses: docker/login-action@v1
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GHCR_PASSWORD }}
|
password: ${{ secrets.GHCR_PASSWORD }}
|
||||||
|
|
||||||
- name: Build image
|
- name: Build image
|
||||||
uses: docker/build-push-action@v2
|
uses: docker/build-push-action@v3
|
||||||
with:
|
with:
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ github.event.inputs.tags || steps.meta.outputs.tags }}
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||||
push: true
|
push: true
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ test/
|
|||||||
/client/.nuxt/
|
/client/.nuxt/
|
||||||
/client/dist/
|
/client/dist/
|
||||||
/dist/
|
/dist/
|
||||||
|
library/
|
||||||
|
|
||||||
sw.*
|
sw.*
|
||||||
.DS_STORE
|
.DS_STORE
|
||||||
+14
-4
@@ -7,13 +7,23 @@ RUN npm run generate
|
|||||||
|
|
||||||
### STAGE 1: Build server ###
|
### STAGE 1: Build server ###
|
||||||
FROM node:16-alpine
|
FROM node:16-alpine
|
||||||
RUN apk update && apk add --no-cache --update ffmpeg
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
RUN apk update && \
|
||||||
|
apk add --no-cache --update \
|
||||||
|
curl \
|
||||||
|
tzdata \
|
||||||
|
ffmpeg
|
||||||
|
|
||||||
COPY --from=build /client/dist /client/dist
|
COPY --from=build /client/dist /client/dist
|
||||||
COPY index.js index.js
|
COPY index.js package* /
|
||||||
COPY package-lock.json package-lock.json
|
|
||||||
COPY package.json package.json
|
|
||||||
COPY server server
|
COPY server server
|
||||||
|
|
||||||
RUN npm ci --only=production
|
RUN npm ci --only=production
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
HEALTHCHECK \
|
||||||
|
--interval=30s \
|
||||||
|
--timeout=3s \
|
||||||
|
--start-period=10s \
|
||||||
|
CMD curl -f http://127.0.0.1/ping || exit 1
|
||||||
CMD ["npm", "start"]
|
CMD ["npm", "start"]
|
||||||
|
|||||||
@@ -226,3 +226,17 @@ Bookshelf Label
|
|||||||
/* number of lines to show */
|
/* number of lines to show */
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Padding for toastification toasts in the top right to not cover appbar/toolbar */
|
||||||
|
.app-bar-and-toolbar .Vue-Toastification__container.top-right {
|
||||||
|
padding-top: 104px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-bar .Vue-Toastification__container.top-right {
|
||||||
|
padding-top: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-bars .Vue-Toastification__container.top-right {
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
+26
-26
@@ -74,7 +74,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
|
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
||||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
|
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
||||||
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
|
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
||||||
unicode-range: U+1F00-1FFF;
|
unicode-range: U+1F00-1FFF;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +104,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
|
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
||||||
unicode-range: U+0370-03FF;
|
unicode-range: U+0370-03FF;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +114,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
|
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
||||||
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;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +124,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
|
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +134,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
|
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,7 +154,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
|
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
|
||||||
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +164,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
|
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
|
||||||
unicode-range: U+1F00-1FFF;
|
unicode-range: U+1F00-1FFF;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,7 +174,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
|
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
|
||||||
unicode-range: U+0370-03FF;
|
unicode-range: U+0370-03FF;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,7 +184,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
|
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
|
||||||
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;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,7 +194,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
|
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,7 +204,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
|
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,7 +214,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
|
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
||||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,7 +224,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
|
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
||||||
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,7 +234,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
|
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
||||||
unicode-range: U+1F00-1FFF;
|
unicode-range: U+1F00-1FFF;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,7 +244,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
|
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
||||||
unicode-range: U+0370-03FF;
|
unicode-range: U+0370-03FF;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,7 +254,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
|
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
||||||
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;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,7 +264,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
|
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,7 +274,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
|
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,7 +284,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
|
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
|
||||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,7 +294,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
|
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
|
||||||
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,7 +304,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
|
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
|
||||||
unicode-range: U+1F00-1FFF;
|
unicode-range: U+1F00-1FFF;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,7 +314,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
|
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
|
||||||
unicode-range: U+0370-03FF;
|
unicode-range: U+0370-03FF;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,7 +324,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
|
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,6 +334,6 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
|
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
|
||||||
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;
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<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">
|
||||||
<nuxt-link to="/">
|
<nuxt-link to="/">
|
||||||
<img src="/icon48.png" class="w-10 h-10 md:w-12 md:h-12 mr-4" />
|
<img src="/icon48.png" class="w-8 h-8 mr-8 sm:w-12 sm:h-12 sm:mr-4" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link to="/">
|
<nuxt-link to="/">
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
<ui-libraries-dropdown />
|
<ui-libraries-dropdown />
|
||||||
|
|
||||||
<controls-global-search v-if="currentLibrary" class="hidden md:block" />
|
<controls-global-search v-if="currentLibrary" class="" />
|
||||||
<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>
|
||||||
@@ -20,11 +20,11 @@
|
|||||||
<ui-tooltip v-if="isChromecastInitialized && !isHttps" direction="bottom" text="Casting requires a secure connection" class="flex items-center">
|
<ui-tooltip v-if="isChromecastInitialized && !isHttps" direction="bottom" text="Casting requires a secure connection" class="flex items-center">
|
||||||
<span class="material-icons-outlined text-warning text-opacity-50"> cast </span>
|
<span class="material-icons-outlined text-warning text-opacity-50"> cast </span>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
<div v-if="isChromecastInitialized" class="w-6 h-6 mr-2 cursor-pointer">
|
<div v-if="isChromecastInitialized" class="w-6 min-w-6 h-6 ml-2 mr-1 sm:mx-2 cursor-pointer">
|
||||||
<google-cast-launcher></google-cast-launcher>
|
<google-cast-launcher></google-cast-launcher>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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">
|
<nuxt-link v-if="currentLibrary" to="/config/stats" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 hidden sm:flex items-center justify-center mx-1">
|
||||||
<span class="material-icons" aria-label="User Stats" role="button">equalizer</span>
|
<span class="material-icons" aria-label="User Stats" role="button">equalizer</span>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="absolute text-center categoryPlacard font-book transform z-30 bottom-0.5 left-4 md:left-8 w-36 rounded-md" style="height: 22px">
|
<div class="absolute text-center categoryPlacard font-book transform z-30 bottom-px left-4 md:left-8 w-44 rounded-md" style="height: 22px">
|
||||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border">
|
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border">
|
||||||
<p class="transform text-sm">{{ shelf.label }}</p>
|
<p class="transform text-sm">{{ shelf.label }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,13 +2,19 @@
|
|||||||
<div class="w-full h-20 md:h-10 relative">
|
<div class="w-full h-20 md:h-10 relative">
|
||||||
<div class="flex md:hidden h-10 items-center">
|
<div class="flex md:hidden h-10 items-center">
|
||||||
<nuxt-link :to="`/library/${currentLibraryId}`" class="flex-grow h-full flex justify-center items-center" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
<nuxt-link :to="`/library/${currentLibraryId}`" class="flex-grow h-full flex justify-center items-center" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||||
<p>Home</p>
|
<p class="text-sm">Home</p>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf`" class="flex-grow h-full flex justify-center items-center" :class="showLibrary ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf`" class="flex-grow h-full flex justify-center items-center" :class="showLibrary ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||||
<p>Library</p>
|
<p class="text-sm">Library</p>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf/series`" class="flex-grow h-full flex justify-center items-center" :class="paramId === 'series' ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
<nuxt-link v-if="!isPodcastLibrary" :to="`/library/${currentLibraryId}/bookshelf/series`" class="flex-grow h-full flex justify-center items-center" :class="paramId === 'series' ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||||
<p>Series</p>
|
<p class="text-sm">Series</p>
|
||||||
|
</nuxt-link>
|
||||||
|
<nuxt-link v-if="!isPodcastLibrary" :to="`/library/${currentLibraryId}/bookshelf/collections`" class="flex-grow h-full flex justify-center items-center" :class="paramId === 'collections' ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||||
|
<p class="text-sm">Collections</p>
|
||||||
|
</nuxt-link>
|
||||||
|
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="flex-grow h-full flex justify-center items-center" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||||
|
<p class="text-sm">Search</p>
|
||||||
</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">
|
||||||
@@ -98,6 +104,9 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
userIsAdminOrUp() {
|
||||||
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
|
},
|
||||||
userCanDelete() {
|
userCanDelete() {
|
||||||
return this.$store.getters['user/getUserCanDelete']
|
return this.$store.getters['user/getUserCanDelete']
|
||||||
},
|
},
|
||||||
@@ -129,6 +138,12 @@ 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'
|
||||||
|
},
|
||||||
homePage() {
|
homePage() {
|
||||||
return this.$route.name === 'library-library'
|
return this.$route.name === 'library-library'
|
||||||
},
|
},
|
||||||
@@ -156,6 +171,9 @@ export default {
|
|||||||
},
|
},
|
||||||
isIssuesFilter() {
|
isIssuesFilter() {
|
||||||
return this.filterBy === 'issues' && this.$route.query.filter === 'issues'
|
return this.filterBy === 'issues' && this.$route.query.filter === 'issues'
|
||||||
|
},
|
||||||
|
isPodcastSearchPage() {
|
||||||
|
return this.$route.name === 'library-library-podcast-search'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -4,19 +4,21 @@
|
|||||||
<span class="material-icons text-2xl">arrow_back</span>
|
<span class="material-icons text-2xl">arrow_back</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nuxt-link v-for="route in configRoutes" :key="route.id" :to="route.path" class="w-full px-4 h-12 border-b border-opacity-0 flex items-center cursor-pointer relative" :class="routeName === route.id ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
|
<nuxt-link v-for="route in configRoutes" :key="route.id" :to="route.path" class="w-full px-4 h-12 border-b border-primary border-opacity-30 flex items-center cursor-pointer relative" :class="routeName === route.id ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
|
||||||
<p>{{ route.title }}</p>
|
<p>{{ route.title }}</p>
|
||||||
<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-12 px-4 border-t border-black border-opacity-20 absolute left-0 flex flex-col justify-center" :style="{ bottom: streamLibraryItem && isMobileLandscape ? '300px' : '65px' }">
|
<div class="w-full h-12 px-4 border-t border-black border-opacity-20 absolute left-0 flex flex-col justify-center" :style="{ bottom: streamLibraryItem && isMobileLandscape ? '300px' : '65px' }">
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<p class="font-mono text-sm">v{{ $config.version }}</p>
|
<p class="underline font-mono text-sm" @click="clickChangelog">v{{ $config.version }}</p>
|
||||||
|
|
||||||
<p class="font-mono text-xs text-gray-300 italic">{{ Source }}</p>
|
<p class="font-mono text-xs text-gray-300 italic">{{ Source }}</p>
|
||||||
</div>
|
</div>
|
||||||
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xs">Latest: {{ latestVersion }}</a>
|
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xs">Latest: {{ latestVersion }}</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<modals-changelog-view-modal v-model="showChangelogModal" :changelog="currentVersionChangelog" :currentVersion="$config.version"/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -26,7 +28,9 @@ export default {
|
|||||||
isOpen: Boolean
|
isOpen: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {
|
||||||
|
showChangelogModal: false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
Source() {
|
Source() {
|
||||||
@@ -64,6 +68,11 @@ export default {
|
|||||||
title: 'Users',
|
title: 'Users',
|
||||||
path: '/config/users'
|
path: '/config/users'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'config-sessions',
|
||||||
|
title: 'Listening Sessions',
|
||||||
|
path: '/config/sessions'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'config-backups',
|
id: 'config-backups',
|
||||||
title: 'Backups',
|
title: 'Backups',
|
||||||
@@ -71,7 +80,7 @@ export default {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'config-log',
|
id: 'config-log',
|
||||||
title: 'Log',
|
title: 'Logs',
|
||||||
path: '/config/log'
|
path: '/config/log'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -124,18 +133,24 @@ export default {
|
|||||||
githubTagUrl() {
|
githubTagUrl() {
|
||||||
return this.versionData.githubTagUrl
|
return this.versionData.githubTagUrl
|
||||||
},
|
},
|
||||||
|
currentVersionChangelog() {
|
||||||
|
return this.versionData.currentVersionChangelog || 'No Changelog Available'
|
||||||
|
},
|
||||||
streamLibraryItem() {
|
streamLibraryItem() {
|
||||||
return this.$store.state.streamLibraryItem
|
return this.$store.state.streamLibraryItem
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
clickChangelog(){
|
||||||
|
this.showChangelogModal = true
|
||||||
|
},
|
||||||
clickOutside() {
|
clickOutside() {
|
||||||
if (!this.isOpen) return
|
if (!this.isOpen) return
|
||||||
this.closeDrawer()
|
this.closeDrawer()
|
||||||
},
|
},
|
||||||
closeDrawer() {
|
closeDrawer() {
|
||||||
this.$emit('update:isOpen', false)
|
this.$emit('update:isOpen', false)
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -75,17 +75,21 @@
|
|||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<div class="w-full h-12 px-1 py-2 border-t border-black border-opacity-20 absolute left-0" :style="{ bottom: streamLibraryItem ? '240px' : '65px' }">
|
<div class="w-full h-12 px-1 py-2 border-t border-black border-opacity-20 absolute left-0" :style="{ bottom: streamLibraryItem ? '240px' : '65px' }">
|
||||||
<p class="font-mono text-xs text-center text-gray-300 leading-3 mb-1">v{{ $config.version }}</p>
|
<p class="underline font-mono text-xs text-center text-gray-300 leading-3 mb-1" @click="clickChangelog">v{{ $config.version }}</p>
|
||||||
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xxs text-center block leading-3">Update</a>
|
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xxs text-center block leading-3">Update</a>
|
||||||
<p v-else class="text-xxs text-gray-400 leading-3 text-center italic">{{ Source }}</p>
|
<p v-else class="text-xxs text-gray-400 leading-3 text-center italic">{{ Source }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<modals-changelog-view-modal v-model="showChangelogModal" :changelog="currentVersionChangelog" :currentVersion="$config.version"/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {
|
||||||
|
showChangelogModal: false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
Source() {
|
Source() {
|
||||||
@@ -150,17 +154,21 @@ export default {
|
|||||||
hasUpdate() {
|
hasUpdate() {
|
||||||
return !!this.versionData.hasUpdate
|
return !!this.versionData.hasUpdate
|
||||||
},
|
},
|
||||||
latestVersion() {
|
|
||||||
return this.versionData.latestVersion
|
|
||||||
},
|
|
||||||
githubTagUrl() {
|
githubTagUrl() {
|
||||||
return this.versionData.githubTagUrl
|
return this.versionData.githubTagUrl
|
||||||
},
|
},
|
||||||
|
currentVersionChangelog() {
|
||||||
|
return this.versionData.currentVersionChangelog || 'No Changelog Available'
|
||||||
|
},
|
||||||
streamLibraryItem() {
|
streamLibraryItem() {
|
||||||
return this.$store.state.streamLibraryItem
|
return this.$store.state.streamLibraryItem
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {},
|
methods: {
|
||||||
|
clickChangelog(){
|
||||||
|
this.showChangelogModal = true
|
||||||
|
}
|
||||||
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -1,30 +1,30 @@
|
|||||||
<template>
|
<template>
|
||||||
<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">
|
<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">
|
||||||
<div id="videoDock" />
|
<div id="videoDock" />
|
||||||
<nuxt-link v-if="!playerHandler.isVideo" :to="`/item/${streamLibraryItem.id}`" class="absolute left-4 cursor-pointer" :style="{ top: bookCoverPosTop + 'px' }">
|
<nuxt-link v-if="!playerHandler.isVideo" :to="`/item/${streamLibraryItem.id}`" class="absolute left-1 sm:left-4 cursor-pointer" :style="{ top: bookCoverPosTop + 'px' }">
|
||||||
<covers-book-cover :library-item="streamLibraryItem" :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 mb-6 md:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : 'pl-24'">
|
<div class="flex items-start mb-6 md:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : 'pl-20 sm:pl-24'">
|
||||||
<div>
|
<div>
|
||||||
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-base sm:text-lg">
|
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-sm sm:text-lg">
|
||||||
{{ title }}
|
{{ title }}
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
<div v-if="!playerHandler.isVideo" class="text-gray-400 flex items-center">
|
<div v-if="!playerHandler.isVideo" 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="podcastAuthor">{{ podcastAuthor }}</p>
|
<p v-if="podcastAuthor" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ podcastAuthor }}</p>
|
||||||
<p v-else-if="authors.length" class="pl-1.5 text-sm sm:text-base">
|
<p v-else-if="authors.length" class="pl-1 sm:pl-1.5 text-xs 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>
|
<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-xs sm:text-base cursor-pointer pl-1 sm:pl-1.5">Unknown</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-gray-400 flex items-center">
|
<div class="text-gray-400 flex items-center">
|
||||||
<span class="material-icons text-xs">schedule</span>
|
<span class="material-icons text-xs">schedule</span>
|
||||||
<p class="font-mono text-sm pl-2 pb-px">{{ totalDurationPretty }}</p>
|
<p class="font-mono text-xs sm:text-sm pl-1 sm:pl-1.5 pb-px">{{ totalDurationPretty }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<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 sm:px-2 py-1 md:p-4 cursor-pointer text-xl sm:text-2xl" @click="closePlayer">close</span>
|
||||||
</div>
|
</div>
|
||||||
<player-ui
|
<player-ui
|
||||||
ref="audioPlayer"
|
ref="audioPlayer"
|
||||||
@@ -71,7 +71,8 @@ export default {
|
|||||||
sleepTimerRemaining: 0,
|
sleepTimerRemaining: 0,
|
||||||
sleepTimer: null,
|
sleepTimer: null,
|
||||||
displayTitle: null,
|
displayTitle: null,
|
||||||
initialPlaybackRate: 1
|
initialPlaybackRate: 1,
|
||||||
|
syncFailedToast: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -376,10 +377,18 @@ export default {
|
|||||||
libraryItem,
|
libraryItem,
|
||||||
episodeId
|
episodeId
|
||||||
})
|
})
|
||||||
|
this.$nextTick(() => {
|
||||||
|
if (this.$refs.audioPlayer) this.$refs.audioPlayer.checkUpdateChapterTrack()
|
||||||
|
})
|
||||||
|
|
||||||
this.playerHandler.load(libraryItem, episodeId, true, this.initialPlaybackRate)
|
this.playerHandler.load(libraryItem, episodeId, true, this.initialPlaybackRate)
|
||||||
},
|
},
|
||||||
pauseItem() {
|
pauseItem() {
|
||||||
this.playerHandler.pause()
|
this.playerHandler.pause()
|
||||||
|
},
|
||||||
|
showFailedProgressSyncs() {
|
||||||
|
if (!isNaN(this.syncFailedToast)) this.$toast.dismiss(this.syncFailedToast)
|
||||||
|
this.syncFailedToast = this.$toast('Progress is not being synced. Restart playback', { timeout: false, type: 'error' })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|||||||
@@ -6,11 +6,18 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="!isPodcast" class="px-4 flex-grow">
|
<div v-if="!isPodcast" class="px-4 flex-grow">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<h1>{{ book.title }}</h1>
|
<h1 class="text-base">{{ book.title }}</h1>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<p>{{ book.publishedYear }}</p>
|
<p>{{ book.publishedYear }}</p>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-gray-400">{{ book.author }}</p>
|
<p class="text-gray-300 text-sm">{{ book.author }}</p>
|
||||||
|
<div v-if="book.series && book.series.length" class="flex py-1 -mx-1">
|
||||||
|
<div v-for="(series, index) in book.series" :key="index" class="bg-white bg-opacity-10 rounded-full px-1 py-0.5 mx-1">
|
||||||
|
<p class="leading-3 text-xs text-gray-400">
|
||||||
|
{{ series.series }}<span v-if="series.volumeNumber"> #{{ series.volumeNumber }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<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>
|
||||||
|
|||||||
@@ -62,21 +62,10 @@ export default {
|
|||||||
matchHtml() {
|
matchHtml() {
|
||||||
if (!this.matchText || !this.search) return ''
|
if (!this.matchText || !this.search) return ''
|
||||||
if (this.matchKey === 'subtitle') return ''
|
if (this.matchKey === 'subtitle') return ''
|
||||||
var matchSplit = this.matchText.toLowerCase().split(this.search.toLowerCase().trim())
|
|
||||||
if (matchSplit.length < 2) return ''
|
|
||||||
|
|
||||||
var html = ''
|
// This used to highlight the part of the search found
|
||||||
var totalLenSoFar = 0
|
// but with removing commas periods etc this is no longer plausible
|
||||||
for (let i = 0; i < matchSplit.length - 1; i++) {
|
const html = this.matchText
|
||||||
var indexOf = matchSplit[i].length
|
|
||||||
var firstPart = this.matchText.substr(totalLenSoFar, indexOf)
|
|
||||||
var actualWasThere = this.matchText.substr(totalLenSoFar + indexOf, this.search.length)
|
|
||||||
totalLenSoFar += indexOf + this.search.length
|
|
||||||
|
|
||||||
html += `${firstPart}<strong class="text-warning">${actualWasThere}</strong>`
|
|
||||||
}
|
|
||||||
var lastPart = this.matchText.substr(totalLenSoFar)
|
|
||||||
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 === 'authors') return `by ${html}`
|
if (this.matchKey === 'authors') return `by ${html}`
|
||||||
|
|||||||
@@ -144,6 +144,9 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
dateFormat() {
|
||||||
|
return this.store.state.serverSettings.dateFormat
|
||||||
|
},
|
||||||
showExperimentalFeatures() {
|
showExperimentalFeatures() {
|
||||||
return this.store.state.showExperimentalFeatures
|
return this.store.state.showExperimentalFeatures
|
||||||
},
|
},
|
||||||
@@ -245,13 +248,17 @@ export default {
|
|||||||
return this.mediaMetadata.authorNameLF
|
return this.mediaMetadata.authorNameLF
|
||||||
},
|
},
|
||||||
displayTitle() {
|
displayTitle() {
|
||||||
|
if (this.recentEpisode) return this.recentEpisode.title
|
||||||
|
if (this.collapsedSeries) return this.collapsedSeries.name
|
||||||
if (this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix) {
|
if (this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix) {
|
||||||
return this.mediaMetadata.titleIgnorePrefix
|
return this.mediaMetadata.titleIgnorePrefix
|
||||||
}
|
}
|
||||||
return this.title
|
return this.title
|
||||||
},
|
},
|
||||||
displayLineTwo() {
|
displayLineTwo() {
|
||||||
|
if (this.recentEpisode) return this.title
|
||||||
if (this.isPodcast) return this.author
|
if (this.isPodcast) return this.author
|
||||||
|
if (this.collapsedSeries) return ''
|
||||||
if (this.isAuthorBookshelfView) {
|
if (this.isAuthorBookshelfView) {
|
||||||
return this.mediaMetadata.publishedYear || ''
|
return this.mediaMetadata.publishedYear || ''
|
||||||
}
|
}
|
||||||
@@ -259,9 +266,10 @@ export default {
|
|||||||
return this.author
|
return this.author
|
||||||
},
|
},
|
||||||
displaySortLine() {
|
displaySortLine() {
|
||||||
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs)
|
if (this.collapsedSeries) return null
|
||||||
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs)
|
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs, this.dateFormat)
|
||||||
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt)
|
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs, this.dateFormat)
|
||||||
|
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt, this.dateFormat)
|
||||||
if (this.orderBy === 'media.duration') return 'Duration: ' + this.$elapsedPrettyExtended(this.media.duration, false)
|
if (this.orderBy === 'media.duration') return 'Duration: ' + this.$elapsedPrettyExtended(this.media.duration, false)
|
||||||
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size)
|
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size)
|
||||||
if (this.orderBy === 'media.numTracks') return `${this.numEpisodes} Episodes`
|
if (this.orderBy === 'media.numTracks') return `${this.numEpisodes} Episodes`
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ export default {
|
|||||||
return this.filterData.languages || []
|
return this.filterData.languages || []
|
||||||
},
|
},
|
||||||
progress() {
|
progress() {
|
||||||
return ['Finished', 'In Progress', 'Not Started']
|
return ['Finished', 'In Progress', 'Not Started', 'Not Finished']
|
||||||
},
|
},
|
||||||
missing() {
|
missing() {
|
||||||
return ['ASIN', 'ISBN', 'Subtitle', 'Author', 'Publish Year', 'Series', 'Description', 'Genres', 'Tags', 'Narrator', 'Publisher', 'Language']
|
return ['ASIN', 'ISBN', 'Subtitle', 'Author', 'Publish Year', 'Series', 'Description', 'Genres', 'Tags', 'Narrator', 'Publisher', 'Language']
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-80 ml-6 relative">
|
<div class="sm:w-80 w-full sm:ml-6 relative">
|
||||||
<form @submit.prevent="submitSearch">
|
<form @submit.prevent="submitSearch">
|
||||||
<ui-text-input ref="input" v-model="search" placeholder="Search.." @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
|
<ui-text-input ref="input" v-model="search" placeholder="Search.." @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
|
||||||
</form>
|
</form>
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
<span v-if="!search" class="material-icons" style="font-size: 1.2rem">search</span>
|
<span v-if="!search" class="material-icons" style="font-size: 1.2rem">search</span>
|
||||||
<span v-else class="material-icons" style="font-size: 1.2rem">close</span>
|
<span v-else class="material-icons" style="font-size: 1.2rem">close</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 px-2 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalSearchMenu">
|
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-40 sm:w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 px-2 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalSearchMenu">
|
||||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||||
<li v-if="isTyping" class="py-2 px-2">
|
<li v-if="isTyping" class="py-2 px-2">
|
||||||
<p>Thinking...</p>
|
<p>Thinking...</p>
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
<p v-if="bookResults.length" 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 bookResults">
|
<template v-for="item in bookResults">
|
||||||
<li :key="item.libraryItem.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" @click="clickOption">
|
||||||
<nuxt-link :to="`/item/${item.libraryItem.id}`">
|
<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" />
|
<cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
|
|
||||||
<p v-if="podcastResults.length" class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">Podcasts</p>
|
<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">
|
<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">
|
<li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
||||||
<nuxt-link :to="`/item/${item.libraryItem.id}`">
|
<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" />
|
<cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
|
|
||||||
<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.id" 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" @click="clickOption">
|
||||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(item.id)}`">
|
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(item.id)}`">
|
||||||
<cards-author-search-card :author="item" />
|
<cards-author-search-card :author="item" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
|
|
||||||
<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.id" 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" @click="clickOption">
|
||||||
<nuxt-link :to="`/library/${currentLibraryId}/series/${item.series.id}`">
|
<nuxt-link :to="`/library/${currentLibraryId}/series/${item.series.id}`">
|
||||||
<cards-series-search-card :series="item.series" :book-items="item.books" />
|
<cards-series-search-card :series="item.series" :book-items="item.books" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
|
|
||||||
<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.name" 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" @click="clickOption">
|
||||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=tags.${$encode(item.name)}`">
|
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=tags.${$encode(item.name)}`">
|
||||||
<cards-tag-search-card :tag="item.name" />
|
<cards-tag-search-card :tag="item.name" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
@@ -97,6 +97,9 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
clickOption() {
|
||||||
|
this.clearResults()
|
||||||
|
},
|
||||||
submitSearch() {
|
submitSearch() {
|
||||||
if (!this.search) return
|
if (!this.search) return
|
||||||
var search = this.search
|
var search = this.search
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative ml-8" v-click-outside="clickOutside">
|
<div ref="wrapper" class="relative ml-4 sm:ml-8" v-click-outside="clickOutside">
|
||||||
<div class="flex items-center justify-center text-gray-300 cursor-pointer h-full" @mousedown.prevent @mouseup.prevent @click="setShowMenu(true)">
|
<div class="flex items-center justify-center text-gray-300 cursor-pointer h-full" @mousedown.prevent @mouseup.prevent @click="setShowMenu(true)">
|
||||||
<span class="font-mono uppercase text-gray-200">{{ playbackRate.toFixed(1) }}<span class="text-lg">⨯</span></span>
|
<span class="font-mono uppercase text-gray-200 text-sm sm:text-base">{{ playbackRate.toFixed(1) }}<span class="text-base sm:text-lg">⨯</span></span>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="showMenu" class="absolute -top-20 left-0 z-20 bg-bg border-black-200 border shadow-xl rounded-lg" style="left: -92px">
|
<div v-show="showMenu" class="absolute -top-20 z-20 bg-bg border-black-200 border shadow-xl rounded-lg" :style="{ left: menuLeft + 'px' }">
|
||||||
<div class="absolute -bottom-2 left-0 right-0 w-full flex justify-center">
|
<div class="absolute -bottom-1.5 right-0 w-full flex justify-center" :style="{ left: arrowLeft + 'px' }">
|
||||||
<div class="arrow-down" />
|
<div class="arrow-down" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center h-9 relative overflow-hidden rounded-lg" style="width: 220px">
|
<div class="flex items-center h-9 relative overflow-hidden rounded-lg" style="width: 220px">
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
<div class="w-full py-1 px-4">
|
<div class="w-full py-1 px-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<ui-icon-btn :disabled="!canDecrement" icon="remove" @click="decrement" />
|
<ui-icon-btn :disabled="!canDecrement" icon="remove" @click="decrement" />
|
||||||
<p class="px-2 text-3xl">{{ playbackRate }}<span class="text-2xl">⨯</span></p>
|
<p class="px-2 text-2xl sm:text-3xl">{{ playbackRate }}<span class="text-2xl">⨯</span></p>
|
||||||
<ui-icon-btn :disabled="!canIncrement" icon="add" @click="increment" />
|
<ui-icon-btn :disabled="!canIncrement" icon="add" @click="increment" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -40,7 +40,9 @@ export default {
|
|||||||
showMenu: false,
|
showMenu: false,
|
||||||
currentPlaybackRate: 0,
|
currentPlaybackRate: 0,
|
||||||
MIN_SPEED: 0.5,
|
MIN_SPEED: 0.5,
|
||||||
MAX_SPEED: 3
|
MAX_SPEED: 3,
|
||||||
|
menuLeft: -92,
|
||||||
|
arrowLeft: 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -80,8 +82,22 @@ export default {
|
|||||||
var newPlaybackRate = this.playbackRate - 0.1
|
var newPlaybackRate = this.playbackRate - 0.1
|
||||||
this.playbackRate = Number(newPlaybackRate.toFixed(1))
|
this.playbackRate = Number(newPlaybackRate.toFixed(1))
|
||||||
},
|
},
|
||||||
|
updateMenuPositions() {
|
||||||
|
if (!this.$refs.wrapper) return
|
||||||
|
const boundingBox = this.$refs.wrapper.getBoundingClientRect()
|
||||||
|
|
||||||
|
if (boundingBox.left + 110 > window.innerWidth - 10) {
|
||||||
|
this.menuLeft = window.innerWidth - 230 - boundingBox.left
|
||||||
|
|
||||||
|
this.arrowLeft = Math.abs(this.menuLeft) - 92
|
||||||
|
} else {
|
||||||
|
this.menuLeft = -92
|
||||||
|
this.arrowLeft = 0
|
||||||
|
}
|
||||||
|
},
|
||||||
setShowMenu(val) {
|
setShowMenu(val) {
|
||||||
if (val) {
|
if (val) {
|
||||||
|
this.updateMenuPositions()
|
||||||
this.currentPlaybackRate = this.playbackRate
|
this.currentPlaybackRate = this.playbackRate
|
||||||
} else if (this.currentPlaybackRate !== this.playbackRate) {
|
} else if (this.currentPlaybackRate !== this.playbackRate) {
|
||||||
this.$emit('change', this.playbackRate)
|
this.$emit('change', this.playbackRate)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative" v-click-outside="clickOutside" @mouseover="mouseover" @mouseleave="mouseleave">
|
<div class="relative" v-click-outside="clickOutside" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||||
<div class="cursor-pointer" @mousedown.prevent @mouseup.prevent @click="clickVolumeIcon">
|
<div class="cursor-pointer text-gray-300 hover:text-white" @mousedown.prevent @mouseup.prevent @click="clickVolumeIcon">
|
||||||
<span class="material-icons text-3xl">{{ volumeIcon }}</span>
|
<span class="material-icons text-2xl sm:text-3xl">{{ volumeIcon }}</span>
|
||||||
</div>
|
</div>
|
||||||
<transition name="menux">
|
<transition name="menux">
|
||||||
<div v-show="isOpen" class="volumeMenu h-6 absolute bottom-2 w-28 px-2 bg-bg shadow-sm rounded-lg" style="left: -116px">
|
<div v-show="isOpen" class="volumeMenu h-6 absolute bottom-2 w-28 px-2 bg-bg shadow-sm rounded-lg" style="left: -116px">
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<modals-modal v-model="show" name="chapters" :width="500" :height="'unset'">
|
<modals-modal v-model="show" name="chapters" :width="600" :height="'unset'">
|
||||||
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
<div id="chapter-modal-wrapper" ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||||
<template v-for="chap in chapters">
|
<template v-for="chap in chapters">
|
||||||
<div :key="chap.id" :id="`chapter-row-${chap.id}`" class="flex items-center px-6 py-3 justify-start cursor-pointer hover:bg-bg relative" :class="chap.id === currentChapterId ? 'bg-yellow-400 bg-opacity-10' : chap.end <= currentChapterStart ? 'bg-success bg-opacity-5' : 'bg-opacity-20'" @click="clickChapter(chap)">
|
<div :key="chap.id" :id="`chapter-row-${chap.id}`" class="flex items-center px-6 py-3 justify-start cursor-pointer hover:bg-bg relative" :class="chap.id === currentChapterId ? 'bg-yellow-400 bg-opacity-10' : chap.end <= currentChapterStart ? 'bg-success bg-opacity-5' : 'bg-opacity-20'" @click="clickChapter(chap)">
|
||||||
{{ chap.title }}
|
<p class="chapter-title truncate text-sm md:text-base">
|
||||||
|
{{ chap.title }}
|
||||||
|
</p>
|
||||||
|
<span class="font-mono text-xxs sm:text-xs text-gray-400 pl-2 whitespace-nowrap">{{ $elapsedPrettyExtended(chap.end - chap.start) }}</span>
|
||||||
<span class="flex-grow" />
|
<span class="flex-grow" />
|
||||||
<span class="font-mono text-sm text-gray-300">{{ $secondsToTimestamp(chap.start) }}</span>
|
<span class="font-mono text-xs sm:text-sm text-gray-300">{{ $secondsToTimestamp(chap.start) }}</span>
|
||||||
|
|
||||||
<div v-show="chap.id === currentChapterId" class="w-0.5 h-full absolute top-0 left-0 bg-yellow-400" />
|
<div v-show="chap.id === currentChapterId" class="w-0.5 h-full absolute top-0 left-0 bg-yellow-400" />
|
||||||
</div>
|
</div>
|
||||||
@@ -71,3 +74,14 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#chapter-modal-wrapper .chapter-title {
|
||||||
|
max-width: calc(100% - 120px);
|
||||||
|
}
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
#chapter-modal-wrapper .chapter-title {
|
||||||
|
max-width: calc(100% - 150px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<template>
|
||||||
|
<modals-modal v-model="show" :width="300" height="100%">
|
||||||
|
<template #outer>
|
||||||
|
<div v-if="title" class="absolute top-7 left-4 z-40" style="max-width: 80%">
|
||||||
|
<p class="text-white text-lg truncate">{{ title }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
||||||
|
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
|
||||||
|
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||||
|
<template v-for="item in items">
|
||||||
|
<li :key="item.value" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" :class="selected === item.value ? 'bg-success bg-opacity-10' : ''" role="option" @click="clickedOption(item.value)">
|
||||||
|
<div class="relative flex items-center px-3">
|
||||||
|
<p class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</modals-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: Boolean,
|
||||||
|
title: String,
|
||||||
|
items: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
selected: String // optional
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
clickedOption(action) {
|
||||||
|
this.$emit('action', action)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="wrapper" class="hidden absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 rounded-lg items-center justify-center" style="z-index: 51" @click="clickClose">
|
<div ref="wrapper" class="hidden absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 rounded-lg items-center justify-center" style="z-index: 51" @click="clickClose">
|
||||||
<div class="absolute top-5 right-5 h-12 w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300">
|
<div class="absolute top-3 right-3 md:top-5 md:right-5 h-8 w-8 md:h-12 md:w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300">
|
||||||
<span class="material-icons text-4xl">close</span>
|
<span class="material-icons text-2xl md:text-4xl">close</span>
|
||||||
</div>
|
</div>
|
||||||
<div ref="content" class="text-white">
|
<div ref="content" class="text-white">
|
||||||
<form v-if="selectedSeries" @submit.prevent="submitSeriesForm">
|
<form v-if="selectedSeries" @submit.prevent="submitSeriesForm">
|
||||||
<div class="bg-bg rounded-lg p-8" @click.stop>
|
<div class="bg-bg rounded-lg px-2 py-6 sm:p-6 md:p-8" @click.stop>
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<div class="flex-grow p-1 min-w-80">
|
<div class="flex-grow p-1 min-w-48 sm:min-w-64 md:min-w-80">
|
||||||
<ui-input-dropdown ref="newSeriesSelect" v-model="selectedSeries.name" :items="existingSeriesNames" :disabled="!selectedSeries.id.startsWith('new')" label="Series Name" />
|
<ui-input-dropdown ref="newSeriesSelect" v-model="selectedSeries.name" :items="existingSeriesNames" :disabled="!selectedSeries.id.startsWith('new')" label="Series Name" />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-40 p-1">
|
<div class="w-24 sm:w-28 md:w-40 p-1">
|
||||||
<ui-text-input-with-label v-model="selectedSeries.sequence" label="Sequence" />
|
<ui-text-input-with-label v-model="selectedSeries.sequence" label="Sequence" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<p class="font-book text-3xl text-white truncate">Session {{ _session.id }}</p>
|
<p class="font-book text-3xl text-white truncate">Session {{ _session.id }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden p-6" style="max-height: 80vh">
|
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden p-6" style="max-height: 80vh">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<p class="text-base text-gray-200">{{ _session.displayTitle }}</p>
|
<p class="text-base text-gray-200">{{ _session.displayTitle }}</p>
|
||||||
<p v-if="_session.displayAuthor" class="text-xs text-gray-400 px-4">by {{ _session.displayAuthor }}</p>
|
<p v-if="_session.displayAuthor" class="text-xs text-gray-400 px-4">by {{ _session.displayAuthor }}</p>
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
<p class="mb-1">{{ playMethodName }}</p>
|
<p class="mb-1">{{ playMethodName }}</p>
|
||||||
<p class="mb-1">{{ _session.mediaPlayer }}</p>
|
<p class="mb-1">{{ _session.mediaPlayer }}</p>
|
||||||
|
|
||||||
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">Device</p>
|
<p v-if="hasDeviceInfo" class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">Device</p>
|
||||||
<p v-if="deviceInfo.ipAddress" class="mb-1">{{ deviceInfo.ipAddress }}</p>
|
<p v-if="deviceInfo.ipAddress" class="mb-1">{{ deviceInfo.ipAddress }}</p>
|
||||||
<p v-if="osDisplayName" class="mb-1">{{ osDisplayName }}</p>
|
<p v-if="osDisplayName" class="mb-1">{{ osDisplayName }}</p>
|
||||||
<p v-if="deviceInfo.browserName" class="mb-1">{{ deviceInfo.browserName }}</p>
|
<p v-if="deviceInfo.browserName" class="mb-1">{{ deviceInfo.browserName }}</p>
|
||||||
@@ -127,6 +127,9 @@ export default {
|
|||||||
deviceInfo() {
|
deviceInfo() {
|
||||||
return this._session.deviceInfo || {}
|
return this._session.deviceInfo || {}
|
||||||
},
|
},
|
||||||
|
hasDeviceInfo() {
|
||||||
|
return Object.keys(this.deviceInfo).length
|
||||||
|
},
|
||||||
osDisplayName() {
|
osDisplayName() {
|
||||||
if (!this.deviceInfo.osName) return null
|
if (!this.deviceInfo.osName) return null
|
||||||
return `${this.deviceInfo.osName} ${this.deviceInfo.osVersion}`
|
return `${this.deviceInfo.osName} ${this.deviceInfo.osVersion}`
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary items-center justify-center opacity-0 hidden" :class="`z-${zIndex} bg-opacity-${bgOpacity}`">
|
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary items-center justify-center opacity-0 hidden" :class="`z-${zIndex} bg-opacity-${bgOpacity}`">
|
||||||
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
|
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
|
||||||
|
|
||||||
<div class="absolute top-5 right-5 h-12 w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="clickClose">
|
<div class="absolute top-3 right-3 landscape:top-2 landscape:right-2 md:portrait:top-5 md:portrait:right-5 lg:top-5 lg:right-5 h-8 w-8 landscape:h-8 landscape:w-8 md:portrait:h-12 md:portrait:w-12 lg:w-12 lg:h-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="clickClose">
|
||||||
<span class="material-icons text-4xl">close</span>
|
<span class="material-icons text-2xl landscape:text-2xl md:portrait:text-4xl lg:text-4xl">close</span>
|
||||||
</div>
|
</div>
|
||||||
<slot name="outer" />
|
<slot name="outer" />
|
||||||
<div ref="content" style="min-width: 400px; min-height: 200px" class="relative text-white" :style="{ height: modalHeight, width: modalWidth, marginTop: contentMarginTop + 'px' }" @mousedown="mousedownModal" @mouseup="mouseupModal" v-click-outside="clickBg">
|
<div ref="content" style="min-width: 380px; min-height: 200px; max-width: 100vw" class="relative text-white" :style="{ height: modalHeight, width: modalWidth, marginTop: contentMarginTop + 'px' }" @mousedown="mousedownModal" @mouseup="mouseupModal" v-click-outside="clickBg">
|
||||||
<slot />
|
<slot />
|
||||||
<div v-if="processing" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-black bg-opacity-60 rounded-lg flex items-center justify-center">
|
<div v-if="processing" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-black bg-opacity-60 rounded-lg flex items-center justify-center">
|
||||||
<ui-loading-indicator />
|
<ui-loading-indicator />
|
||||||
|
|||||||
@@ -25,6 +25,9 @@
|
|||||||
<ui-text-input-with-label v-model="authorCopy.asin" :disabled="processing" label="ASIN" />
|
<ui-text-input-with-label v-model="authorCopy.asin" :disabled="processing" label="ASIN" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="p-2">
|
||||||
|
<ui-text-input-with-label v-model="authorCopy.imagePath" :disabled="processing" label="Photo Path/URL" />
|
||||||
|
</div>
|
||||||
<div class="p-2">
|
<div class="p-2">
|
||||||
<ui-textarea-with-label v-model="authorCopy.description" :disabled="processing" label="Description" :rows="8" />
|
<ui-textarea-with-label v-model="authorCopy.description" :disabled="processing" label="Description" :rows="8" />
|
||||||
</div>
|
</div>
|
||||||
@@ -43,19 +46,13 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
// props: {
|
|
||||||
// value: Boolean,
|
|
||||||
// author: {
|
|
||||||
// type: Object,
|
|
||||||
// default: () => {}
|
|
||||||
// }
|
|
||||||
// },
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
authorCopy: {
|
authorCopy: {
|
||||||
name: '',
|
name: '',
|
||||||
asin: '',
|
asin: '',
|
||||||
description: ''
|
description: '',
|
||||||
|
imagePath: ''
|
||||||
},
|
},
|
||||||
processing: false
|
processing: false
|
||||||
}
|
}
|
||||||
@@ -95,9 +92,10 @@ export default {
|
|||||||
this.authorCopy.name = this.author.name
|
this.authorCopy.name = this.author.name
|
||||||
this.authorCopy.asin = this.author.asin
|
this.authorCopy.asin = this.author.asin
|
||||||
this.authorCopy.description = this.author.description
|
this.authorCopy.description = this.author.description
|
||||||
|
this.authorCopy.imagePath = this.author.imagePath
|
||||||
},
|
},
|
||||||
async submitForm() {
|
async submitForm() {
|
||||||
var keysToCheck = ['name', 'asin', 'description']
|
var keysToCheck = ['name', 'asin', 'description', 'imagePath']
|
||||||
var updatePayload = {}
|
var updatePayload = {}
|
||||||
keysToCheck.forEach((key) => {
|
keysToCheck.forEach((key) => {
|
||||||
if (this.authorCopy[key] !== this.author[key]) {
|
if (this.authorCopy[key] !== this.author[key]) {
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<template>
|
||||||
|
<modals-modal v-model="show" name="changelog" :width="800" :height="'unset'">
|
||||||
|
<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">Changelog</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="px-8 py-6 w-full rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-y-scroll" style="max-height: 80vh">
|
||||||
|
<p class="text-xl font-bold pb-4">Changelog v{{ currentVersionNumber }}</p>
|
||||||
|
<div class="custom-text" v-html="compiledMarkedown" />
|
||||||
|
</div>
|
||||||
|
</modals-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { marked } from '@/static/libs/marked/index.js'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: Boolean,
|
||||||
|
changelog: String,
|
||||||
|
currentVersion: String
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
show: {
|
||||||
|
immediate: true,
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
compiledMarkedown() {
|
||||||
|
return marked.parse(this.changelog, { gfm: true, breaks: true })
|
||||||
|
},
|
||||||
|
currentVersionNumber() {
|
||||||
|
return this.currentVersion
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
init() {}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/*
|
||||||
|
1. we need to manually define styles to apply to the parsed markdown elements,
|
||||||
|
since we don't have access to the actual elements in this component
|
||||||
|
|
||||||
|
2. v-deep allows these to take effect on the content passed in to the v-html in the div above
|
||||||
|
*/
|
||||||
|
.custom-text ::v-deep > h2 {
|
||||||
|
@apply text-lg font-bold;
|
||||||
|
}
|
||||||
|
.custom-text ::v-deep > h3 {
|
||||||
|
@apply text-lg font-bold;
|
||||||
|
}
|
||||||
|
.custom-text ::v-deep > ul {
|
||||||
|
@apply list-disc list-inside pb-4;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<modals-modal v-model="show" name="edit-book" :width="800" :height="height" :processing="processing" :content-margin-top="75">
|
<modals-modal v-model="show" name="edit-book" :width="800" :height="height" :processing="processing" :content-margin-top="marginTop">
|
||||||
<template #outer>
|
<template #outer>
|
||||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none">
|
<div class="absolute top-0 left-0 p-4 landscape:px-4 landscape:py-2 md:portrait:p-5 lg:p-5 w-2/3 overflow-hidden pointer-events-none">
|
||||||
<p class="font-book text-3xl text-white truncate pointer-events-none">{{ title }}</p>
|
<p class="font-book text-xl md:portrait:text-3xl md:landscape:text-lg lg:text-3xl text-white truncate pointer-events-none">{{ title }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div class="absolute -top-10 left-0 z-10 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-0.5 sm: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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -30,6 +30,8 @@ export default {
|
|||||||
return {
|
return {
|
||||||
processing: false,
|
processing: false,
|
||||||
libraryItem: null,
|
libraryItem: null,
|
||||||
|
availableHeight: 0,
|
||||||
|
marginTop: 0,
|
||||||
tabs: [
|
tabs: [
|
||||||
{
|
{
|
||||||
id: 'details',
|
id: 'details',
|
||||||
@@ -133,8 +135,7 @@ export default {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
height() {
|
height() {
|
||||||
var maxHeightAllowed = window.innerHeight - 150
|
return Math.min(this.availableHeight, 650)
|
||||||
return Math.min(maxHeightAllowed, 650)
|
|
||||||
},
|
},
|
||||||
tabName() {
|
tabName() {
|
||||||
var _tab = this.tabs.find((t) => t.id === this.selectedTab)
|
var _tab = this.tabs.find((t) => t.id === this.selectedTab)
|
||||||
@@ -246,15 +247,29 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
registerListeners() {
|
registerListeners() {
|
||||||
|
window.addEventListener('orientationchange', this.orientationChange)
|
||||||
this.$eventBus.$on('modal-hotkey', this.hotkey)
|
this.$eventBus.$on('modal-hotkey', this.hotkey)
|
||||||
this.$eventBus.$on(`${this.selectedLibraryItemId}_updated`, this.libraryItemUpdated)
|
this.$eventBus.$on(`${this.selectedLibraryItemId}_updated`, this.libraryItemUpdated)
|
||||||
},
|
},
|
||||||
unregisterListeners() {
|
unregisterListeners() {
|
||||||
|
window.removeEventListener('orientationchange', this.orientationChange)
|
||||||
this.$eventBus.$off('modal-hotkey', this.hotkey)
|
this.$eventBus.$off('modal-hotkey', this.hotkey)
|
||||||
this.$eventBus.$off(`${this.selectedLibraryItemId}_updated`, this.libraryItemUpdated)
|
this.$eventBus.$off(`${this.selectedLibraryItemId}_updated`, this.libraryItemUpdated)
|
||||||
|
},
|
||||||
|
orientationChange() {
|
||||||
|
setTimeout(this.setHeight, 50)
|
||||||
|
},
|
||||||
|
setHeight() {
|
||||||
|
const smAndBelow = window.innerWidth < 1024 && window.innerWidth > window.innerHeight
|
||||||
|
|
||||||
|
this.marginTop = smAndBelow ? 90 : 75
|
||||||
|
const heightModifier = smAndBelow ? 95 : 150
|
||||||
|
this.availableHeight = window.innerHeight - heightModifier
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {},
|
mounted() {
|
||||||
|
this.setHeight()
|
||||||
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
this.unregisterListeners()
|
this.unregisterListeners()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<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-2 sm:px-4 py-6 relative">
|
||||||
<div class="flex">
|
<div class="flex flex-wrap">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<covers-book-cover :library-item="libraryItem" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-book-cover :library-item="libraryItem" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
<!-- book cover overlay -->
|
<!-- book cover overlay -->
|
||||||
@@ -11,14 +11,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow pl-6 pr-2">
|
<div class="flex-grow sm:pl-2 md:pl-6 sm:pr-2 mt-2 md:mt-0">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div v-if="userCanUpload" class="w-40 pr-2 pt-4" style="min-width: 160px">
|
<div v-if="userCanUpload" class="w-10 md:w-40 pr-2 pt-4 md:min-w-32">
|
||||||
<ui-file-input ref="fileInput" @change="fileUploadSelected">Upload Cover</ui-file-input>
|
<ui-file-input ref="fileInput" @change="fileUploadSelected"><span class="hidden md:inline-block">Upload Cover</span><span class="material-icons inline-block md:!hidden">upload</span></ui-file-input>
|
||||||
</div>
|
</div>
|
||||||
<form @submit.prevent="submitForm" class="flex flex-grow">
|
<form @submit.prevent="submitForm" class="flex flex-grow">
|
||||||
<ui-text-input-with-label v-model="imageUrl" label="Cover Image URL" />
|
<ui-text-input-with-label v-model="imageUrl" label="Cover Image URL" />
|
||||||
<ui-btn color="success" type="submit" :padding-x="4" class="mt-5 ml-3 w-24">Update</ui-btn>
|
<ui-btn color="success" type="submit" :padding-x="4" class="mt-5 ml-2 sm:ml-3 w-24">Update</ui-btn>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,23 +5,24 @@
|
|||||||
<widgets-podcast-details-edit v-else ref="itemDetailsEdit" :library-item="libraryItem" @submit="saveAndClose" />
|
<widgets-podcast-details-edit v-else ref="itemDetailsEdit" :library-item="libraryItem" @submit="saveAndClose" />
|
||||||
</div>
|
</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="absolute bottom-0 left-0 w-full py-2 md:py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'border-t border-white border-opacity-5'">
|
||||||
<div class="flex items-center px-4">
|
<div class="flex items-center px-4">
|
||||||
<ui-btn v-if="userCanDelete" color="error" type="button" class="h-8" :padding-x="3" small @click.stop.prevent="removeItem">Remove</ui-btn>
|
<ui-btn v-if="userCanDelete" color="error" type="button" class="h-8 hidden md:block" :padding-x="3" small @click.stop.prevent="removeItem">Remove</ui-btn>
|
||||||
|
<ui-icon-btn bg-color="error" icon="delete" class="md:hidden" :size="7" icon-font-size="1rem" @click.stop.prevent="removeItem" />
|
||||||
|
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
|
|
||||||
<ui-tooltip v-if="mediaType == 'book'" :disabled="!!quickMatching" :text="`(Root User Only) Populate empty book details & cover with first book result from '${libraryProvider}'. Does not overwrite details.`" direction="bottom" class="mr-4">
|
<ui-tooltip v-if="mediaType == 'book'" :disabled="!!quickMatching" :text="`(Root User Only) Populate empty book details & cover with first book result from '${libraryProvider}'. Does not overwrite details.`" direction="bottom" class="mr-2 md: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-btn v-if="userIsAdminOrUp" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">Quick Match</ui-btn>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|
||||||
<ui-tooltip :disabled="!!libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="mr-4">
|
<ui-tooltip :disabled="!!libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="mr-2 md: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-btn v-if="userIsAdminOrUp && !isFile" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|
||||||
<ui-btn @click="save" class="mx-2">Save</ui-btn>
|
<ui-btn @click="save" class="mx-2 hidden md:block">Save</ui-btn>
|
||||||
|
|
||||||
<ui-btn @click="saveAndClose">Save & Close</ui-btn>
|
<ui-btn @click="saveAndClose">Save<span class="hidden md:inline-block"> & Close</span></ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
|
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
|
||||||
<!-- Merge to m4b -->
|
<!-- Merge to m4b -->
|
||||||
<div v-if="showM4bDownload" class="w-full border border-black-200 p-4 my-8">
|
<div v-if="showM4bDownload" class="w-full border border-black-200 p-4 my-8">
|
||||||
<div class="flex items-center">
|
<div class="flex flex-wrap items-center">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-lg">Make M4B Audiobook File <span class="text-error">*</span></p>
|
<p class="text-lg">Make M4B Audiobook File <span class="text-error">*</span></p>
|
||||||
<p class="max-w-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>
|
<p class="max-w-sm text-sm pt-2 text-gray-300">Generate a .M4B audiobook file with embedded metadata, cover image, and chapters. <br /><span class="text-warning">*</span> Does not delete existing audio files.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<div>
|
<div class="mt-2 md:mt-0">
|
||||||
<p v-if="abmergeStatus === $constants.DownloadStatus.FAILED" class="text-error mb-2">Download Failed</p>
|
<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.READY" class="text-success mb-2">Download Ready!</p>
|
||||||
<p v-if="abmergeStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
|
<p v-if="abmergeStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
|
||||||
@@ -69,7 +69,7 @@
|
|||||||
|
|
||||||
<p v-if="showM4bDownload" class="text-left text-base mb-4 py-4">
|
<p v-if="showM4bDownload" class="text-left text-base mb-4 py-4">
|
||||||
<span class="text-error">* <strong>Experimental</strong></span
|
<span class="text-error">* <strong>Experimental</strong></span
|
||||||
> - M4b merge can take several minutes and will be stored in <span class="bg-primary bg-opacity-75 font-mono p-1 text-base">/metadata/downloads</span>. After the download is ready, it will remain available for 60 minutes, then be deleted. Download will timeout after 20 minutes.
|
> - M4b merge can take several minutes and will be stored in <span class="bg-primary bg-opacity-75 font-mono p-1 text-base">/metadata/downloads</span>. After the download is ready, it will remain available for 60 minutes, then be deleted. Download will timeout after 30 minutes.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- <p v-if="isSingleM4b" class="text-lg text-center my-8">Audiobook is already a single m4b!</p> -->
|
<!-- <p v-if="isSingleM4b" class="text-lg text-center my-8">Audiobook is already a single m4b!</p> -->
|
||||||
|
|||||||
@@ -365,17 +365,27 @@ export default {
|
|||||||
else this.provider = localStorage.getItem('book-provider') || 'google'
|
else this.provider = localStorage.getItem('book-provider') || 'google'
|
||||||
},
|
},
|
||||||
selectMatch(match) {
|
selectMatch(match) {
|
||||||
if (match && match.series) {
|
if (match) {
|
||||||
match.series = match.series.map((se) => {
|
if (match.series) {
|
||||||
return {
|
if (!match.series.length) {
|
||||||
id: `new-${Math.floor(Math.random() * 10000)}`,
|
delete match.series
|
||||||
displayName: se.volumeNumber ? `${se.series} #${se.volumeNumber}` : se.series,
|
} else {
|
||||||
name: se.series,
|
match.series = match.series.map((se) => {
|
||||||
sequence: se.volumeNumber || ''
|
return {
|
||||||
|
id: `new-${Math.floor(Math.random() * 10000)}`,
|
||||||
|
displayName: se.volumeNumber ? `${se.series} #${se.volumeNumber}` : se.series,
|
||||||
|
name: se.series,
|
||||||
|
sequence: se.volumeNumber || ''
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
if (match.genres && Array.isArray(match.genres)) {
|
||||||
|
match.genres = match.genres.join(',')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('Select Match', match)
|
||||||
this.selectedMatch = match
|
this.selectedMatch = match
|
||||||
},
|
},
|
||||||
buildMatchUpdatePayload() {
|
buildMatchUpdatePayload() {
|
||||||
@@ -405,9 +415,12 @@ export default {
|
|||||||
|
|
||||||
updatePayload.metadata.series = seriesPayload
|
updatePayload.metadata.series = seriesPayload
|
||||||
} else if (key === 'author' && !this.isPodcast) {
|
} else if (key === 'author' && !this.isPodcast) {
|
||||||
if (!Array.isArray(this.selectedMatch[key])) this.selectedMatch[key] = [this.selectedMatch[key]]
|
var authors = this.selectedMatch[key]
|
||||||
|
if (!Array.isArray(authors)) {
|
||||||
|
authors = authors.split(',').map((au) => au.trim())
|
||||||
|
}
|
||||||
var authorPayload = []
|
var authorPayload = []
|
||||||
this.selectedMatch[key].forEach((authorName) =>
|
authors.forEach((authorName) =>
|
||||||
authorPayload.push({
|
authorPayload.push({
|
||||||
id: `new-${Math.floor(Math.random() * 10000)}`,
|
id: `new-${Math.floor(Math.random() * 10000)}`,
|
||||||
name: authorName
|
name: authorName
|
||||||
@@ -415,11 +428,11 @@ export default {
|
|||||||
)
|
)
|
||||||
updatePayload.metadata.authors = authorPayload
|
updatePayload.metadata.authors = authorPayload
|
||||||
} else if (key === 'narrator') {
|
} else if (key === 'narrator') {
|
||||||
updatePayload.metadata.narrators = [this.selectedMatch[key]]
|
updatePayload.metadata.narrators = this.selectedMatch[key].split(',').map((v) => v.trim())
|
||||||
} else if (key === 'genres') {
|
} else if (key === 'genres') {
|
||||||
updatePayload.metadata.genres = this.selectedMatch[key].split(',')
|
updatePayload.metadata.genres = this.selectedMatch[key].split(',').map((v) => v.trim())
|
||||||
} else if (key === 'tags') {
|
} else if (key === 'tags') {
|
||||||
updatePayload.tags = this.selectedMatch[key].split(',')
|
updatePayload.tags = this.selectedMatch[key].split(',').map((v) => v.trim())
|
||||||
} else if (key === 'itunesId') {
|
} else if (key === 'itunesId') {
|
||||||
updatePayload.metadata.itunesId = Number(this.selectedMatch[key])
|
updatePayload.metadata.itunesId = Number(this.selectedMatch[key])
|
||||||
} else {
|
} else {
|
||||||
@@ -435,6 +448,8 @@ export default {
|
|||||||
if (!Object.keys(updatePayload).length) {
|
if (!Object.keys(updatePayload).length) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('Match payload', updatePayload)
|
||||||
this.isProcessing = true
|
this.isProcessing = true
|
||||||
|
|
||||||
if (updatePayload.metadata.cover) {
|
if (updatePayload.metadata.cover) {
|
||||||
|
|||||||
@@ -27,7 +27,6 @@
|
|||||||
<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>
|
<p v-if="hasEpisodesWithoutPubDate" class="w-full pt-2 text-warning text-xs">Warning: 1 or more of your episodes do not have a Pub Date. Some podcast apps require this.</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="userIsAdminOrUp" class="flex items-center pt-6">
|
<div v-show="userIsAdminOrUp" class="flex items-center pt-6">
|
||||||
<p class="text-xs text-gray-300">Note: RSS feed URLs are not authenticated</p>
|
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<ui-btn v-if="currentFeedUrl" color="error" small @click="closeFeed">Close RSS Feed</ui-btn>
|
<ui-btn v-if="currentFeedUrl" color="error" small @click="closeFeed">Close RSS Feed</ui-btn>
|
||||||
<ui-btn v-else color="success" small @click="openFeed">Open RSS Feed</ui-btn>
|
<ui-btn v-else color="success" small @click="openFeed">Open RSS Feed</ui-btn>
|
||||||
|
|||||||
@@ -2,19 +2,22 @@
|
|||||||
<div class="flex pt-4 pb-2 md:pt-0 md:pb-2">
|
<div class="flex pt-4 pb-2 md:pt-0 md:pb-2">
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<template v-if="!loading">
|
<template v-if="!loading">
|
||||||
<div class="cursor-pointer flex items-center justify-center text-gray-300 mr-8" @mousedown.prevent @mouseup.prevent @click.stop="restart">
|
<div class="cursor-pointer flex items-center justify-center text-gray-300 mr-4 md:mr-8" @mousedown.prevent @mouseup.prevent @click.stop="prevChapter">
|
||||||
<span class="material-icons text-3xl">first_page</span>
|
<span class="material-icons text-2xl sm:text-3xl">first_page</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpBackward">
|
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpBackward">
|
||||||
<span class="material-icons text-3xl">replay_10</span>
|
<span class="material-icons text-2xl sm:text-3xl">replay_10</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPause">
|
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-4 md:mx-8" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPause">
|
||||||
<span class="material-icons">{{ seekLoading ? 'autorenew' : paused ? 'play_arrow' : 'pause' }}</span>
|
<span class="material-icons">{{ seekLoading ? 'autorenew' : paused ? 'play_arrow' : 'pause' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpForward">
|
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpForward">
|
||||||
<span class="material-icons text-3xl">forward_10</span>
|
<span class="material-icons text-2xl sm:text-3xl">forward_10</span>
|
||||||
</div>
|
</div>
|
||||||
<controls-playback-speed-control v-model="playbackRate" @input="playbackRateUpdated" @change="playbackRateChanged" />
|
<div class="flex items-center justify-center ml-4 md:ml-8" :class="hasNextChapter ? 'text-gray-300 cursor-pointer' : 'text-gray-500'" @mousedown.prevent @mouseup.prevent @click.stop="nextChapter">
|
||||||
|
<span class="material-icons text-2xl sm:text-3xl">last_page</span>
|
||||||
|
</div>
|
||||||
|
<controls-playback-speed-control v-model="playbackRateInput" @input="playbackRateUpdated" @change="playbackRateChanged" />
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8 animate-spin">
|
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8 animate-spin">
|
||||||
@@ -31,18 +34,32 @@ export default {
|
|||||||
loading: Boolean,
|
loading: Boolean,
|
||||||
seekLoading: Boolean,
|
seekLoading: Boolean,
|
||||||
playbackRate: Number,
|
playbackRate: Number,
|
||||||
paused: Boolean
|
paused: Boolean,
|
||||||
|
hasNextChapter: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
},
|
},
|
||||||
computed: {},
|
computed: {
|
||||||
|
playbackRateInput: {
|
||||||
|
get() {
|
||||||
|
return this.playbackRate
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('update:playbackRate', val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
playPause() {
|
playPause() {
|
||||||
this.$emit('playPause')
|
this.$emit('playPause')
|
||||||
},
|
},
|
||||||
restart() {
|
prevChapter() {
|
||||||
this.$emit('restart')
|
this.$emit('prevChapter')
|
||||||
|
},
|
||||||
|
nextChapter() {
|
||||||
|
if (!this.hasNextChapter) return
|
||||||
|
this.$emit('nextChapter')
|
||||||
},
|
},
|
||||||
jumpBackward() {
|
jumpBackward() {
|
||||||
this.$emit('jumpBackward')
|
this.$emit('jumpBackward')
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<div ref="trackCursor" class="h-full w-0.5 bg-gray-50 absolute top-0 left-0 opacity-0 pointer-events-none" />
|
<div ref="trackCursor" class="h-full w-0.5 bg-gray-50 absolute top-0 left-0 opacity-0 pointer-events-none" />
|
||||||
<div v-if="loading" class="h-full w-1/4 absolute left-0 top-0 loadingTrack pointer-events-none bg-white bg-opacity-25" />
|
<div v-if="loading" class="h-full w-1/4 absolute left-0 top-0 loadingTrack pointer-events-none bg-white bg-opacity-25" />
|
||||||
</div>
|
</div>
|
||||||
<div ref="track" class="w-full h-2 relative overflow-hidden">
|
<div class="w-full h-2 relative overflow-hidden" :class="useChapterTrack ? 'opacity-0' : ''">
|
||||||
<template v-for="(tick, index) in chapterTicks">
|
<template v-for="(tick, index) in chapterTicks">
|
||||||
<div :key="index" :style="{ left: tick.left + 'px' }" class="absolute top-0 w-px bg-white bg-opacity-30 h-1 pointer-events-none" />
|
<div :key="index" :style="{ left: tick.left + 'px' }" class="absolute top-0 w-px bg-white bg-opacity-30 h-1 pointer-events-none" />
|
||||||
</template>
|
</template>
|
||||||
@@ -34,6 +34,10 @@ export default {
|
|||||||
chapters: {
|
chapters: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => []
|
default: () => []
|
||||||
|
},
|
||||||
|
currentChapter: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
@@ -46,7 +50,8 @@ export default {
|
|||||||
trackOffsetLeft: 16, // Track is 16px from edge
|
trackOffsetLeft: 16, // Track is 16px from edge
|
||||||
playedTrackWidth: 0,
|
playedTrackWidth: 0,
|
||||||
readyTrackWidth: 0,
|
readyTrackWidth: 0,
|
||||||
bufferTrackWidth: 0
|
bufferTrackWidth: 0,
|
||||||
|
useChapterTrack: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -57,14 +62,30 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {},
|
computed: {
|
||||||
|
currentChapterDuration() {
|
||||||
|
if (!this.currentChapter) return 0
|
||||||
|
return this.currentChapter.end - this.currentChapter.start
|
||||||
|
},
|
||||||
|
currentChapterStart() {
|
||||||
|
if (!this.currentChapter) return 0
|
||||||
|
return this.currentChapter.start
|
||||||
|
}
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
setUseChapterTrack(useChapterTrack) {
|
||||||
|
this.useChapterTrack = useChapterTrack
|
||||||
|
this.updateBufferTrack()
|
||||||
|
this.updatePlayedTrackWidth()
|
||||||
|
},
|
||||||
clickTrack(e) {
|
clickTrack(e) {
|
||||||
if (this.loading) return
|
if (this.loading) return
|
||||||
|
|
||||||
var offsetX = e.offsetX
|
var offsetX = e.offsetX
|
||||||
var perc = offsetX / this.trackWidth
|
var perc = offsetX / this.trackWidth
|
||||||
var time = perc * this.duration
|
const baseTime = this.useChapterTrack ? this.currentChapterStart : 0;
|
||||||
|
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration;
|
||||||
|
const time = baseTime + (perc * duration);
|
||||||
if (isNaN(time) || time === null) {
|
if (isNaN(time) || time === null) {
|
||||||
console.error('Invalid time', perc, time)
|
console.error('Invalid time', perc, time)
|
||||||
return
|
return
|
||||||
@@ -76,7 +97,10 @@ export default {
|
|||||||
this.updateBufferTrack()
|
this.updateBufferTrack()
|
||||||
},
|
},
|
||||||
updateBufferTrack() {
|
updateBufferTrack() {
|
||||||
var bufferlen = (this.bufferTime / this.duration) * this.trackWidth
|
const time = this.useChapterTrack ? Math.max(0, this.bufferTime - this.currentChapterStart) : this.bufferTime
|
||||||
|
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
|
||||||
|
|
||||||
|
var bufferlen = (time / duration) * this.trackWidth
|
||||||
bufferlen = Math.round(bufferlen)
|
bufferlen = Math.round(bufferlen)
|
||||||
if (this.bufferTrackWidth === bufferlen || !this.$refs.bufferTrack) return
|
if (this.bufferTrackWidth === bufferlen || !this.$refs.bufferTrack) return
|
||||||
if (this.$refs.bufferTrack) this.$refs.bufferTrack.style.width = bufferlen + 'px'
|
if (this.$refs.bufferTrack) this.$refs.bufferTrack.style.width = bufferlen + 'px'
|
||||||
@@ -97,8 +121,10 @@ export default {
|
|||||||
this.updatePlayedTrackWidth()
|
this.updatePlayedTrackWidth()
|
||||||
},
|
},
|
||||||
updatePlayedTrackWidth() {
|
updatePlayedTrackWidth() {
|
||||||
var perc = this.currentTime / this.duration
|
const time = this.useChapterTrack ? Math.max(0, this.currentTime - this.currentChapterStart) : this.currentTime
|
||||||
var ptWidth = Math.round(perc * this.trackWidth)
|
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
|
||||||
|
|
||||||
|
var ptWidth = Math.round((time / duration) * this.trackWidth)
|
||||||
if (this.playedTrackWidth === ptWidth) {
|
if (this.playedTrackWidth === ptWidth) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -116,9 +142,11 @@ export default {
|
|||||||
},
|
},
|
||||||
mousemoveTrack(e) {
|
mousemoveTrack(e) {
|
||||||
var offsetX = e.offsetX
|
var offsetX = e.offsetX
|
||||||
var time = (offsetX / this.trackWidth) * this.duration
|
|
||||||
|
|
||||||
console.log('Mousemove track', this.trackWidth, this.duration)
|
const baseTime = this.useChapterTrack ? this.currentChapterStart : 0;
|
||||||
|
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration;
|
||||||
|
const progressTime = (offsetX / this.trackWidth) * duration;
|
||||||
|
const totalTime = baseTime + progressTime;
|
||||||
|
|
||||||
if (this.$refs.hoverTimestamp) {
|
if (this.$refs.hoverTimestamp) {
|
||||||
var width = this.$refs.hoverTimestamp.clientWidth
|
var width = this.$refs.hoverTimestamp.clientWidth
|
||||||
@@ -139,9 +167,9 @@ export default {
|
|||||||
this.$refs.hoverTimestampArrow.style.left = posLeft + 'px'
|
this.$refs.hoverTimestampArrow.style.left = posLeft + 'px'
|
||||||
}
|
}
|
||||||
if (this.$refs.hoverTimestampText) {
|
if (this.$refs.hoverTimestampText) {
|
||||||
var hoverText = this.$secondsToTimestamp(time)
|
var hoverText = this.$secondsToTimestamp(progressTime)
|
||||||
|
|
||||||
var chapter = this.chapters.find((chapter) => chapter.start <= time && time < chapter.end)
|
var chapter = this.chapters.find((chapter) => chapter.start <= totalTime && totalTime < chapter.end)
|
||||||
if (chapter && chapter.title) {
|
if (chapter && chapter.title) {
|
||||||
hoverText += ` - ${chapter.title}`
|
hoverText += ` - ${chapter.title}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +1,48 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full -mt-6">
|
<div class="w-full -mt-6">
|
||||||
<div class="w-full relative mb-1">
|
<div class="w-full relative mb-1">
|
||||||
<div class="absolute -top-10 md:top-0 right-0 md:right-2 flex items-center h-full">
|
<div class="absolute -top-10 md:top-0 right-0 lg:right-2 flex items-center h-full">
|
||||||
<span class="material-icons text-2xl cursor-pointer" @click="toggleFullscreen(true)">expand_less</span>
|
<!-- <span class="material-icons text-2xl cursor-pointer" @click="toggleFullscreen(true)">expand_less</span> -->
|
||||||
|
|
||||||
<controls-volume-control ref="volumeControl" v-model="volume" @input="setVolume" class="mx-2 hidden md:block" />
|
<controls-volume-control ref="volumeControl" v-model="volume" @input="setVolume" class="mx-2 hidden md:block" />
|
||||||
|
|
||||||
<div class="cursor-pointer text-gray-300 mx-1 md:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showSleepTimer')">
|
<div class="cursor-pointer text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showSleepTimer')">
|
||||||
<span v-if="!sleepTimerSet" class="material-icons" style="font-size: 1.7rem">snooze</span>
|
<span v-if="!sleepTimerSet" class="material-icons text-2xl sm:text-2.5xl">snooze</span>
|
||||||
<div v-else class="flex items-center">
|
<div v-else class="flex items-center">
|
||||||
<span class="material-icons text-lg text-warning">snooze</span>
|
<span class="material-icons text-lg text-warning">snooze</span>
|
||||||
<p class="text-xl text-warning font-mono font-semibold text-center px-0.5 pb-0.5" style="min-width: 30px">{{ sleepTimerRemainingString }}</p>
|
<p class="text-xl text-warning font-mono font-semibold text-center px-0.5 pb-0.5" style="min-width: 30px">{{ sleepTimerRemainingString }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!isPodcast" 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 hover:text-white mx-1 lg: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 text-2xl sm:text-2.5xl">{{ bookmarks.length ? 'bookmarks' : 'bookmark_border' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="chapters.length" class="cursor-pointer text-gray-300 mx-1 md:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="showChapters">
|
<div v-if="chapters.length" class="cursor-pointer text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="showChapters">
|
||||||
<span class="material-icons text-3xl">format_list_bulleted</span>
|
<span class="material-icons text-2xl sm:text-3xl">format_list_bulleted</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ui-tooltip v-if="chapters.length" direction="top" :text="useChapterTrack ? 'Use full track' : 'Use chapter track'">
|
||||||
|
<div class="cursor-pointer text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="setUseChapterTrack">
|
||||||
|
<span class="material-icons text-2xl sm:text-3xl transform transition-transform" :class="useChapterTrack ? 'rotate-180' : ''">timelapse</span>
|
||||||
|
</div>
|
||||||
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<player-playback-controls :loading="loading" :seek-loading="seekLoading" :playback-rate="playbackRate" :paused="paused" @restart="restart" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setPlaybackRate="setPlaybackRate" @playPause="playPause" />
|
<player-playback-controls :loading="loading" :seek-loading="seekLoading" :playback-rate.sync="playbackRate" :paused="paused" :has-next-chapter="hasNextChapter" @prevChapter="prevChapter" @nextChapter="nextChapter" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setPlaybackRate="setPlaybackRate" @playPause="playPause" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<player-track-bar ref="trackbar" :loading="loading" :chapters="chapters" :duration="duration" @seek="seek" />
|
<player-track-bar ref="trackbar" :loading="loading" :chapters="chapters" :duration="duration" :current-chapter="currentChapter" @seek="seek" />
|
||||||
|
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<p ref="currentTimestamp" class="font-mono text-sm text-gray-100 pointer-events-auto">00:00:00</p>
|
<p ref="currentTimestamp" class="font-mono text-xxs sm:text-sm text-gray-100 pointer-events-auto">00:00:00</p>
|
||||||
<p class="font-mono text-sm text-gray-100 pointer-events-auto"> / {{ progressPercent }}%</p>
|
<p class="font-mono text-sm hidden sm:block text-gray-100 pointer-events-auto"> / {{ progressPercent }}%</p>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<p class="text-sm text-gray-300 pt-0.5">{{ currentChapterName }}</p>
|
<p class="text-xs sm:text-sm text-gray-300 pt-0.5">
|
||||||
|
{{ currentChapterName }} <span v-if="useChapterTrack" class="text-xs text-gray-400"> ({{ currentChapterIndex + 1 }} of {{ chapters.length }})</span>
|
||||||
|
</p>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<p class="font-mono text-sm text-gray-100 pointer-events-auto">{{ timeRemainingPretty }}</p>
|
<p class="font-mono text-xxs sm:text-sm text-gray-100 pointer-events-auto">{{ timeRemainingPretty }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
|
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
|
||||||
@@ -66,7 +74,8 @@ export default {
|
|||||||
seekLoading: false,
|
seekLoading: false,
|
||||||
showChaptersModal: false,
|
showChaptersModal: false,
|
||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
duration: 0
|
duration: 0,
|
||||||
|
useChapterTrack: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -86,6 +95,10 @@ export default {
|
|||||||
return this.$store.getters['user/getToken']
|
return this.$store.getters['user/getToken']
|
||||||
},
|
},
|
||||||
timeRemaining() {
|
timeRemaining() {
|
||||||
|
if (this.useChapterTrack && this.currentChapter) {
|
||||||
|
var currChapTime = this.currentTime - this.currentChapter.start
|
||||||
|
return (this.currentChapterDuration - currChapTime) / this.playbackRate
|
||||||
|
}
|
||||||
return (this.duration - this.currentTime) / this.playbackRate
|
return (this.duration - this.currentTime) / this.playbackRate
|
||||||
},
|
},
|
||||||
timeRemainingPretty() {
|
timeRemainingPretty() {
|
||||||
@@ -95,8 +108,11 @@ export default {
|
|||||||
return '-' + this.$secondsToTimestamp(this.timeRemaining)
|
return '-' + this.$secondsToTimestamp(this.timeRemaining)
|
||||||
},
|
},
|
||||||
progressPercent() {
|
progressPercent() {
|
||||||
if (!this.duration) return 0
|
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
|
||||||
return Math.round((100 * this.currentTime) / this.duration)
|
const time = this.useChapterTrack ? Math.max(this.currentTime - this.currentChapterStart) : this.currentTime
|
||||||
|
|
||||||
|
if (!duration) return 0
|
||||||
|
return Math.round((100 * time) / duration)
|
||||||
},
|
},
|
||||||
currentChapter() {
|
currentChapter() {
|
||||||
return this.chapters.find((chapter) => chapter.start <= this.currentTime && this.currentTime < chapter.end)
|
return this.chapters.find((chapter) => chapter.start <= this.currentTime && this.currentTime < chapter.end)
|
||||||
@@ -104,8 +120,24 @@ export default {
|
|||||||
currentChapterName() {
|
currentChapterName() {
|
||||||
return this.currentChapter ? this.currentChapter.title : ''
|
return this.currentChapter ? this.currentChapter.title : ''
|
||||||
},
|
},
|
||||||
|
currentChapterDuration() {
|
||||||
|
if (!this.currentChapter) return 0
|
||||||
|
return this.currentChapter.end - this.currentChapter.start
|
||||||
|
},
|
||||||
|
currentChapterStart() {
|
||||||
|
if (!this.currentChapter) return 0
|
||||||
|
return this.currentChapter.start
|
||||||
|
},
|
||||||
isFullscreen() {
|
isFullscreen() {
|
||||||
return this.$store.state.playerIsFullscreen
|
return this.$store.state.playerIsFullscreen
|
||||||
|
},
|
||||||
|
currentChapterIndex() {
|
||||||
|
if (!this.currentChapter) return 0
|
||||||
|
return this.chapters.findIndex((ch) => ch.id === this.currentChapter.id)
|
||||||
|
},
|
||||||
|
hasNextChapter() {
|
||||||
|
if (!this.chapters.length) return false
|
||||||
|
return this.currentChapterIndex < this.chapters.length - 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -184,12 +216,46 @@ export default {
|
|||||||
this.seek(chapter.start)
|
this.seek(chapter.start)
|
||||||
this.showChaptersModal = false
|
this.showChaptersModal = false
|
||||||
},
|
},
|
||||||
|
setUseChapterTrack() {
|
||||||
|
var useChapterTrack = !this.useChapterTrack
|
||||||
|
this.useChapterTrack = useChapterTrack
|
||||||
|
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(useChapterTrack)
|
||||||
|
|
||||||
|
this.$store.dispatch('user/updateUserSettings', { useChapterTrack }).catch((err) => {
|
||||||
|
console.error('Failed to update settings', err)
|
||||||
|
})
|
||||||
|
this.updateTimestamp()
|
||||||
|
},
|
||||||
|
checkUpdateChapterTrack() {
|
||||||
|
// Changing media in player may not have chapters
|
||||||
|
if (!this.chapters.length && this.useChapterTrack) {
|
||||||
|
this.useChapterTrack = false
|
||||||
|
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack)
|
||||||
|
}
|
||||||
|
},
|
||||||
seek(time) {
|
seek(time) {
|
||||||
this.$emit('seek', time)
|
this.$emit('seek', time)
|
||||||
},
|
},
|
||||||
restart() {
|
restart() {
|
||||||
this.seek(0)
|
this.seek(0)
|
||||||
},
|
},
|
||||||
|
prevChapter() {
|
||||||
|
if (!this.currentChapter || this.currentChapterIndex === 0) {
|
||||||
|
return this.restart()
|
||||||
|
}
|
||||||
|
var timeInCurrentChapter = this.currentTime - this.currentChapter.start
|
||||||
|
if (timeInCurrentChapter <= 3 && this.chapters[this.currentChapterIndex - 1]) {
|
||||||
|
var prevChapter = this.chapters[this.currentChapterIndex - 1]
|
||||||
|
this.seek(prevChapter.start)
|
||||||
|
} else {
|
||||||
|
this.seek(this.currentChapter.start)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
nextChapter() {
|
||||||
|
if (!this.currentChapter || !this.hasNextChapter) return
|
||||||
|
var nextChapter = this.chapters[this.currentChapterIndex + 1]
|
||||||
|
this.seek(nextChapter.start)
|
||||||
|
},
|
||||||
setStreamReady() {
|
setStreamReady() {
|
||||||
if (this.$refs.trackbar) this.$refs.trackbar.setPercentageReady(1)
|
if (this.$refs.trackbar) this.$refs.trackbar.setPercentageReady(1)
|
||||||
},
|
},
|
||||||
@@ -214,10 +280,10 @@ export default {
|
|||||||
console.error('No timestamp el')
|
console.error('No timestamp el')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var currTimeClean = this.$secondsToTimestamp(this.currentTime)
|
const time = this.useChapterTrack ? Math.max(0, this.currentTime - this.currentChapterStart) : this.currentTime
|
||||||
|
var currTimeClean = this.$secondsToTimestamp(time)
|
||||||
ts.innerText = currTimeClean
|
ts.innerText = currTimeClean
|
||||||
},
|
},
|
||||||
|
|
||||||
setBufferTime(bufferTime) {
|
setBufferTime(bufferTime) {
|
||||||
if (this.$refs.trackbar) this.$refs.trackbar.setBufferTime(bufferTime)
|
if (this.$refs.trackbar) this.$refs.trackbar.setBufferTime(bufferTime)
|
||||||
},
|
},
|
||||||
@@ -227,6 +293,11 @@ export default {
|
|||||||
},
|
},
|
||||||
init() {
|
init() {
|
||||||
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
|
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
|
||||||
|
|
||||||
|
var _useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack') || false
|
||||||
|
this.useChapterTrack = this.chapters.length ? _useChapterTrack : false
|
||||||
|
|
||||||
|
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack)
|
||||||
this.$emit('setPlaybackRate', this.playbackRate)
|
this.$emit('setPlaybackRate', this.playbackRate)
|
||||||
},
|
},
|
||||||
settingsUpdated(settings) {
|
settingsUpdated(settings) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div id="heatmap" class="w-full">
|
<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)">
|
<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>
|
<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 class="border border-white 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 :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="dayLabel in dayLabels" :key="dayLabel.label" :style="dayLabel.style" class="absolute top-0 left-0 text-gray-300">{{ dayLabel.label }}</div>
|
||||||
|
|
||||||
|
|||||||
@@ -192,7 +192,11 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#accounts tr:nth-child(even) {
|
#accounts tr:nth-child(even) {
|
||||||
background-color: #3a3a3a;
|
background-color: #373838;
|
||||||
|
}
|
||||||
|
|
||||||
|
#accounts tr:nth-child(odd) {
|
||||||
|
background-color: #2f2f2f;
|
||||||
}
|
}
|
||||||
|
|
||||||
#accounts tr:hover {
|
#accounts tr:hover {
|
||||||
@@ -204,6 +208,6 @@ export default {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding-top: 5px;
|
padding-top: 5px;
|
||||||
padding-bottom: 5px;
|
padding-bottom: 5px;
|
||||||
background-color: #333;
|
background-color: #272727
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
<draggable v-if="libraryCopies.length" :list="libraryCopies" v-bind="dragOptions" class="list-group" draggable=".item" tag="div" @start="startDrag" @end="endDrag">
|
<draggable v-if="libraryCopies.length" :list="libraryCopies" v-bind="dragOptions" class="list-group" draggable=".item" tag="div" @start="startDrag" @end="endDrag">
|
||||||
<template v-for="library in libraryCopies">
|
<template v-for="library in libraryCopies">
|
||||||
<div :key="library.id" class="item">
|
<div :key="library.id" class="item">
|
||||||
<tables-library-item :library="library" :selected="currentLibraryId === library.id" :show-edit="true" :dragging="drag" @edit="editLibrary" @click="setLibrary" />
|
<tables-library-item :library="library" :selected="currentLibraryId === library.id" :dragging="drag" @edit="editLibrary" @click="setLibrary" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</draggable>
|
</draggable>
|
||||||
|
|||||||
@@ -7,18 +7,26 @@
|
|||||||
</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" small color="success" @click.stop="scan">Scan</ui-btn>
|
<ui-btn v-show="isHovering && !libraryScan" class="hidden md:block" small color="success" @click.stop="scan">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" small color="bg" class="ml-2 hidden md:block" @click.stop="forceScan">Force Re-Scan</ui-btn>
|
||||||
|
|
||||||
<ui-btn v-show="isHovering && !libraryScan && isBookLibrary" 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 hidden md:block" @click.stop="matchAll">Match Books</ui-btn>
|
||||||
|
|
||||||
<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-if="isHovering && !libraryScan" class="!hidden md:!block 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 && !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-if="!libraryScan && isHovering && !isDeleting" class="!hidden md:!block material-icons text-xl text-gray-300 ml-3 hover:text-gray-50 cursor-pointer" @click.stop="deleteClick">delete</span>
|
||||||
|
|
||||||
|
<!-- For mobile -->
|
||||||
|
<span v-if="!libraryScan" class="!block md:!hidden material-icons text-xl text-gray-300 ml-4 cursor-pointer" @click.stop="editClick">edit</span>
|
||||||
|
<span v-if="!libraryScan && !isDeleting" class="!block md:!hidden material-icons text-2xl text-gray-300 ml-3 cursor-pointer" @click.stop="showMenu">more_vert</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" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
<span class="material-icons text-xl text-gray-400 hover:text-gray-50 ml-4">reorder</span>
|
||||||
|
|
||||||
|
<!-- For mobile -->
|
||||||
|
<modals-dialog v-model="showMobileMenu" :title="menuTitle" :items="mobileMenuItems" @action="mobileMenuAction" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -30,22 +38,19 @@ export default {
|
|||||||
default: () => {}
|
default: () => {}
|
||||||
},
|
},
|
||||||
selected: Boolean,
|
selected: Boolean,
|
||||||
showEdit: Boolean,
|
|
||||||
dragging: Boolean
|
dragging: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
mouseover: false,
|
mouseover: false,
|
||||||
isDeleting: false
|
isDeleting: false,
|
||||||
|
showMobileMenu: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
isHovering() {
|
isHovering() {
|
||||||
return this.mouseover && !this.dragging
|
return this.mouseover && !this.dragging
|
||||||
},
|
},
|
||||||
isMain() {
|
|
||||||
return this.library.id === 'main'
|
|
||||||
},
|
|
||||||
libraryScan() {
|
libraryScan() {
|
||||||
return this.$store.getters['scanners/getLibraryScan'](this.library.id)
|
return this.$store.getters['scanners/getLibraryScan'](this.library.id)
|
||||||
},
|
},
|
||||||
@@ -54,9 +59,50 @@ export default {
|
|||||||
},
|
},
|
||||||
isBookLibrary() {
|
isBookLibrary() {
|
||||||
return this.mediaType === 'book'
|
return this.mediaType === 'book'
|
||||||
|
},
|
||||||
|
menuTitle() {
|
||||||
|
return this.library.name
|
||||||
|
},
|
||||||
|
mobileMenuItems() {
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
text: 'Scan',
|
||||||
|
value: 'scan'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Force Re-Scan',
|
||||||
|
value: 'force-scan'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
if (this.isBookLibrary) {
|
||||||
|
items.push({
|
||||||
|
text: 'Match Books',
|
||||||
|
value: 'match-books'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
items.push({
|
||||||
|
text: 'Delete',
|
||||||
|
value: 'delete'
|
||||||
|
})
|
||||||
|
return items
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
mobileMenuAction(action) {
|
||||||
|
this.showMobileMenu = false
|
||||||
|
if (action === 'scan') {
|
||||||
|
this.scan()
|
||||||
|
} else if (action === 'force-scan') {
|
||||||
|
this.forceScan()
|
||||||
|
} else if (action === 'match-books') {
|
||||||
|
this.matchAll()
|
||||||
|
} else if (action === 'delete') {
|
||||||
|
this.deleteClick()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
showMenu() {
|
||||||
|
this.showMobileMenu = true
|
||||||
|
},
|
||||||
matchAll() {
|
matchAll() {
|
||||||
this.$axios
|
this.$axios
|
||||||
.$post(`/api/libraries/${this.library.id}/matchall`)
|
.$post(`/api/libraries/${this.library.id}/matchall`)
|
||||||
@@ -97,7 +143,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
deleteClick() {
|
deleteClick() {
|
||||||
if (this.isMain) return
|
|
||||||
if (confirm(`Are you sure you want to permanently delete library "${this.library.name}"?`)) {
|
if (confirm(`Are you sure you want to permanently delete library "${this.library.name}"?`)) {
|
||||||
this.isDeleting = true
|
this.isDeleting = true
|
||||||
this.$axios
|
this.$axios
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<input ref="fileInput" type="file" :accept="accept" class="hidden" @change="inputChanged" />
|
<input ref="fileInput" type="file" :accept="accept" class="hidden" @change="inputChanged" />
|
||||||
<ui-btn @click="clickUpload" color="primary" type="text"><slot /></ui-btn>
|
<ui-btn @click="clickUpload" color="primary" class="hidden md:block" type="text"><slot /></ui-btn>
|
||||||
|
<ui-icon-btn @click="clickUpload" icon="upload" class="block md:hidden" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<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">
|
<button class="icon-btn rounded-md flex items-center justify-center relative" @mousedown.prevent :disabled="disabled || loading" :class="className" @click="clickBtn">
|
||||||
<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">
|
||||||
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
|
<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" />
|
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
||||||
@@ -20,20 +20,29 @@ export default {
|
|||||||
},
|
},
|
||||||
outlined: Boolean,
|
outlined: Boolean,
|
||||||
borderless: Boolean,
|
borderless: Boolean,
|
||||||
loading: Boolean
|
loading: Boolean,
|
||||||
|
iconFontSize: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: Number,
|
||||||
|
default: 9
|
||||||
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
className() {
|
className() {
|
||||||
var classes = []
|
var classes = [`h-${this.size} w-${this.size}`]
|
||||||
if (!this.borderless) {
|
if (!this.borderless) {
|
||||||
classes.push(`bg-${this.bgColor} border border-gray-600`)
|
classes.push(`bg-${this.bgColor} border border-gray-600`)
|
||||||
}
|
}
|
||||||
return classes.join(' ')
|
return classes.join(' ')
|
||||||
},
|
},
|
||||||
fontSize() {
|
fontSize() {
|
||||||
|
if (this.iconFontSize) return this.iconFontSize
|
||||||
if (this.icon === 'edit') return '1.25rem'
|
if (this.icon === 'edit') return '1.25rem'
|
||||||
return '1.4rem'
|
return '1.4rem'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ export default {
|
|||||||
editable: {
|
editable: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true
|
default: true
|
||||||
}
|
},
|
||||||
|
showAllWhenEmpty: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -72,6 +73,7 @@ export default {
|
|||||||
itemsToShow() {
|
itemsToShow() {
|
||||||
if (!this.editable) return this.items
|
if (!this.editable) return this.items
|
||||||
if (!this.textInput || this.textInput === this.input) {
|
if (!this.textInput || this.textInput === this.input) {
|
||||||
|
if (this.showAllWhenEmpty) return this.items
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
return this.items.filter((i) => {
|
return this.items.filter((i) => {
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="currentLibrary" class="relative w-36 h-8" v-click-outside="clickOutsideObj">
|
<div v-if="currentLibrary" class="relative sm:w-36 h-8 px-1.5" v-click-outside="clickOutsideObj">
|
||||||
<button type="button" :disabled="disabled" class="relative h-full w-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
<button type="button" :disabled="disabled" class="w-10 sm:w-36 relative h-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm px-2 text-left text-sm focus:outline-none cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
|
||||||
<span class="flex items-center">
|
<span class="flex items-center justify-center sm:justify-start">
|
||||||
<widgets-library-icon :icon="currentLibraryIcon" class="mr-2" />
|
<widgets-library-icon :icon="currentLibraryIcon" class="sm:mr-2" />
|
||||||
<span class="block truncate text-sm">{{ currentLibrary.name }}</span>
|
<span class="hidden sm:block">{{ currentLibrary.name }}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<transition name="menu">
|
<transition name="menu">
|
||||||
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox">
|
<ul v-show="showMenu" class="absolute z-10 -mt-px w-36 bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox">
|
||||||
<template v-for="library in librariesFiltered">
|
<template v-for="library in librariesFiltered">
|
||||||
<li :key="library.id" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="selectLibrary(library)">
|
<li :key="library.id" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="selectLibrary(library)">
|
||||||
<div class="flex items-center px-3">
|
<div class="flex items-center px-3">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<div ref="wrapper" class="relative">
|
<div ref="wrapper" class="relative">
|
||||||
<form @submit.prevent="submitForm">
|
<form @submit.prevent="submitForm">
|
||||||
<div ref="inputWrapper" style="min-height: 36px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-1" :class="wrapperClass" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
|
<div ref="inputWrapper" style="min-height: 36px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-1" :class="wrapperClass" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
|
||||||
<div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 mx-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center relative">
|
<div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 mx-0.5 my-0.5 text-xs bg-bg flex flex-nowrap break-all items-center relative">
|
||||||
<div class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg bg-opacity-75 flex items-center justify-end cursor-pointer">
|
<div class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg bg-opacity-75 flex items-center justify-end cursor-pointer">
|
||||||
<span v-if="showEdit" class="material-icons text-white hover:text-warning" style="font-size: 1.1rem" @click.stop="editItem(item)">edit</span>
|
<span v-if="showEdit" class="material-icons text-white hover:text-warning" style="font-size: 1.1rem" @click.stop="editItem(item)">edit</span>
|
||||||
<span class="material-icons text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item)">close</span>
|
<span class="material-icons text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item)">close</span>
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export default {
|
|||||||
var tooltip = document.createElement('div')
|
var tooltip = document.createElement('div')
|
||||||
this.tooltipId = String(Math.floor(Math.random() * 10000))
|
this.tooltipId = String(Math.floor(Math.random() * 10000))
|
||||||
tooltip.id = this.tooltipId
|
tooltip.id = this.tooltipId
|
||||||
tooltip.className = 'tooltip-wrapper absolute px-2 py-1 text-white pointer-events-none text-xs rounded shadow-lg max-w-xs'
|
tooltip.className = 'tooltip-wrapper absolute px-2 py-1 text-white pointer-events-none text-xs rounded shadow-lg max-w-xs text-center hidden sm:block'
|
||||||
tooltip.style.zIndex = 100
|
tooltip.style.zIndex = 100
|
||||||
tooltip.style.backgroundColor = 'rgba(0,0,0,0.85)'
|
tooltip.style.backgroundColor = 'rgba(0,0,0,0.85)'
|
||||||
tooltip.innerHTML = this.text
|
tooltip.innerHTML = this.text
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-full relative">
|
<div class="w-full h-full relative">
|
||||||
<form class="w-full h-full px-4 py-6" @submit.prevent="submitForm">
|
<form class="w-full h-full px-2 md:px-4 py-6" @submit.prevent="submitForm">
|
||||||
<div class="flex -mx-1">
|
<div class="flex flex-wrap -mx-1">
|
||||||
<div class="w-1/2 px-1">
|
<div class="w-full md:w-1/2 px-1">
|
||||||
<ui-text-input-with-label ref="titleInput" v-model="details.title" label="Title" />
|
<ui-text-input-with-label ref="titleInput" v-model="details.title" label="Title" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow px-1">
|
<div class="flex-grow px-1 mt-2 md:mt-0">
|
||||||
<ui-text-input-with-label ref="subtitleInput" v-model="details.subtitle" label="Subtitle" />
|
<ui-text-input-with-label ref="subtitleInput" v-model="details.subtitle" label="Subtitle" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex mt-2 -mx-1">
|
<div class="flex flex-wrap mt-2 -mx-1">
|
||||||
<div class="w-3/4 px-1">
|
<div class="w-full md:w-3/4 px-1">
|
||||||
<!-- Authors filter only contains authors in this library, use query input to query all authors -->
|
<!-- Authors filter only contains authors in this library, use query input to query all authors -->
|
||||||
<ui-multi-select-query-input ref="authorsSelect" v-model="details.authors" label="Authors" endpoint="authors/search" />
|
<ui-multi-select-query-input ref="authorsSelect" v-model="details.authors" label="Authors" endpoint="authors/search" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow px-1">
|
<div class="flex-grow px-1 mt-2 md:mt-0">
|
||||||
<ui-text-input-with-label ref="publishYearInput" v-model="details.publishedYear" type="number" label="Publish Year" />
|
<ui-text-input-with-label ref="publishYearInput" v-model="details.publishedYear" type="number" label="Publish Year" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -28,35 +28,35 @@
|
|||||||
|
|
||||||
<ui-textarea-with-label ref="descriptionInput" v-model="details.description" :rows="3" label="Description" class="mt-2" />
|
<ui-textarea-with-label ref="descriptionInput" v-model="details.description" :rows="3" label="Description" class="mt-2" />
|
||||||
|
|
||||||
<div class="flex mt-2 -mx-1">
|
<div class="flex flex-wrap mt-2 -mx-1">
|
||||||
<div class="w-1/2 px-1">
|
<div class="w-full md:w-1/2 px-1">
|
||||||
<ui-multi-select ref="genresSelect" v-model="details.genres" label="Genres" :items="genres" />
|
<ui-multi-select ref="genresSelect" v-model="details.genres" label="Genres" :items="genres" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow px-1">
|
<div class="flex-grow px-1 mt-2 md:mt-0">
|
||||||
<ui-multi-select ref="tagsSelect" v-model="newTags" label="Tags" :items="tags" />
|
<ui-multi-select ref="tagsSelect" v-model="newTags" label="Tags" :items="tags" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex mt-2 -mx-1">
|
<div class="flex flex-wrap mt-2 -mx-1">
|
||||||
<div class="w-1/2 px-1">
|
<div class="w-full md:w-1/2 px-1">
|
||||||
<ui-multi-select ref="narratorsSelect" v-model="details.narrators" label="Narrators" :items="narrators" />
|
<ui-multi-select ref="narratorsSelect" v-model="details.narrators" label="Narrators" :items="narrators" />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-1/4 px-1">
|
<div class="w-1/2 md:w-1/4 px-1 mt-2 md:mt-0">
|
||||||
<ui-text-input-with-label ref="isbnInput" v-model="details.isbn" label="ISBN" />
|
<ui-text-input-with-label ref="isbnInput" v-model="details.isbn" label="ISBN" />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-1/4 px-1">
|
<div class="w-1/2 md:w-1/4 px-1 mt-2 md:mt-0">
|
||||||
<ui-text-input-with-label ref="asinInput" v-model="details.asin" label="ASIN" />
|
<ui-text-input-with-label ref="asinInput" v-model="details.asin" label="ASIN" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex mt-2 -mx-1">
|
<div class="flex flex-wrap mt-2 -mx-1">
|
||||||
<div class="w-1/2 px-1">
|
<div class="w-full md:w-1/2 px-1">
|
||||||
<ui-text-input-with-label ref="publisherInput" v-model="details.publisher" label="Publisher" />
|
<ui-text-input-with-label ref="publisherInput" v-model="details.publisher" label="Publisher" />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-1/4 px-1">
|
<div class="w-1/2 md:w-1/4 px-1 mt-2 md:mt-0">
|
||||||
<ui-text-input-with-label ref="languageInput" v-model="details.language" label="Language" />
|
<ui-text-input-with-label ref="languageInput" v-model="details.language" label="Language" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow px-1 pt-6">
|
<div class="flex-grow px-1 pt-6 mt-2 md:mt-0">
|
||||||
<div class="flex justify-center">
|
<div class="flex justify-center">
|
||||||
<ui-checkbox v-model="details.explicit" label="Explicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
<ui-checkbox v-model="details.explicit" label="Explicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,5 +5,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {}
|
export default {
|
||||||
|
mounted() {
|
||||||
|
document.body.classList.remove('app-bar', 'app-bar-and-toolbar')
|
||||||
|
document.body.classList.add('no-bars')
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
+19
-11
@@ -40,6 +40,7 @@ export default {
|
|||||||
if (this.$store.state.selectedLibraryItems) {
|
if (this.$store.state.selectedLibraryItems) {
|
||||||
this.$store.commit('setSelectedLibraryItems', [])
|
this.$store.commit('setSelectedLibraryItems', [])
|
||||||
}
|
}
|
||||||
|
this.updateBodyClass()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -53,11 +54,23 @@ export default {
|
|||||||
if (!this.$route.name) return false
|
if (!this.$route.name) return false
|
||||||
return !this.$route.name.startsWith('config') && this.$store.state.libraries.currentLibraryId
|
return !this.$route.name.startsWith('config') && this.$store.state.libraries.currentLibraryId
|
||||||
},
|
},
|
||||||
|
isShowingToolbar() {
|
||||||
|
return this.isShowingSideRail && this.$route.name !== 'upload' && this.$route.name !== 'account'
|
||||||
|
},
|
||||||
appContentMarginLeft() {
|
appContentMarginLeft() {
|
||||||
return this.isShowingSideRail ? 80 : 0
|
return this.isShowingSideRail ? 80 : 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
updateBodyClass() {
|
||||||
|
if (this.isShowingToolbar) {
|
||||||
|
document.body.classList.remove('no-bars', 'app-bar')
|
||||||
|
document.body.classList.add('app-bar-and-toolbar')
|
||||||
|
} else {
|
||||||
|
document.body.classList.remove('no-bars', 'app-bar-and-toolbar')
|
||||||
|
document.body.classList.add('app-bar')
|
||||||
|
}
|
||||||
|
},
|
||||||
updateSocketConnectionToast(content, type, timeout) {
|
updateSocketConnectionToast(content, type, timeout) {
|
||||||
if (this.socketConnectionToastId !== null && this.socketConnectionToastId !== undefined) {
|
if (this.socketConnectionToastId !== null && this.socketConnectionToastId !== undefined) {
|
||||||
this.$toast.update(this.socketConnectionToastId, { content: content, options: { timeout: timeout, type: type, closeButton: false, position: 'bottom-center', onClose: () => null, closeOnClick: timeout !== null } }, false)
|
this.$toast.update(this.socketConnectionToastId, { content: content, options: { timeout: timeout, type: type, closeButton: false, position: 'bottom-center', onClose: () => null, closeOnClick: timeout !== null } }, false)
|
||||||
@@ -110,13 +123,7 @@ export default {
|
|||||||
}
|
}
|
||||||
console.log('Init Payload', payload)
|
console.log('Init Payload', payload)
|
||||||
if (payload.session) {
|
if (payload.session) {
|
||||||
if (this.$refs.streamContainer) {
|
this.$refs.streamContainer.sessionOpen(payload.session)
|
||||||
this.$refs.streamContainer.sessionOpen(payload.session)
|
|
||||||
} else {
|
|
||||||
console.warn('Stream Container not mounted')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (payload.serverSettings) {
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start scans currently running
|
// Start scans currently running
|
||||||
@@ -243,9 +250,9 @@ export default {
|
|||||||
|
|
||||||
var existingScan = this.$store.getters['scanners/getLibraryScan'](data.id)
|
var existingScan = this.$store.getters['scanners/getLibraryScan'](data.id)
|
||||||
if (existingScan && !isNaN(existingScan.toastId)) {
|
if (existingScan && !isNaN(existingScan.toastId)) {
|
||||||
this.$toast.update(existingScan.toastId, { content: message, options: { timeout: 5000, type: 'success', closeButton: false, position: 'bottom-center', onClose: () => null } }, true)
|
this.$toast.update(existingScan.toastId, { content: message, options: { timeout: 5000, type: 'success', closeButton: false, onClose: () => null } }, true)
|
||||||
} else {
|
} else {
|
||||||
this.$toast.success(message, { timeout: 5000, position: 'bottom-center' })
|
this.$toast.success(message, { timeout: 5000 })
|
||||||
}
|
}
|
||||||
|
|
||||||
this.$store.commit('scanners/remove', data)
|
this.$store.commit('scanners/remove', data)
|
||||||
@@ -254,7 +261,7 @@ export default {
|
|||||||
this.$root.socket.emit('cancel_scan', id)
|
this.$root.socket.emit('cancel_scan', id)
|
||||||
},
|
},
|
||||||
scanStart(data) {
|
scanStart(data) {
|
||||||
data.toastId = this.$toast(`${data.type === 'match' ? 'Matching' : 'Scanning'} "${data.name}"...`, { timeout: false, type: 'info', draggable: false, closeOnClick: false, closeButton: CloseButton, closeButtonClassName: 'cancel-scan-btn', showCloseButtonOnHover: false, position: 'bottom-center', onClose: () => this.onScanToastCancel(data.id) })
|
data.toastId = this.$toast(`${data.type === 'match' ? 'Matching' : 'Scanning'} "${data.name}"...`, { timeout: false, type: 'info', draggable: false, closeOnClick: false, closeButton: CloseButton, closeButtonClassName: 'cancel-scan-btn', showCloseButtonOnHover: false, onClose: () => this.onScanToastCancel(data.id) })
|
||||||
this.$store.commit('scanners/addUpdate', data)
|
this.$store.commit('scanners/addUpdate', data)
|
||||||
},
|
},
|
||||||
scanProgress(data) {
|
scanProgress(data) {
|
||||||
@@ -263,7 +270,7 @@ export default {
|
|||||||
data.toastId = existingScan.toastId
|
data.toastId = existingScan.toastId
|
||||||
this.$toast.update(existingScan.toastId, { content: `Scanning "${existingScan.name}"... ${data.progress.progress || 0}%`, options: { timeout: false } }, true)
|
this.$toast.update(existingScan.toastId, { content: `Scanning "${existingScan.name}"... ${data.progress.progress || 0}%`, options: { timeout: false } }, true)
|
||||||
} else {
|
} else {
|
||||||
data.toastId = this.$toast(`Scanning "${data.name}"...`, { timeout: false, type: 'info', draggable: false, closeOnClick: false, closeButton: CloseButton, closeButtonClassName: 'cancel-scan-btn', showCloseButtonOnHover: false, position: 'bottom-center', onClose: () => this.onScanToastCancel(data.id) })
|
data.toastId = this.$toast(`Scanning "${data.name}"...`, { timeout: false, type: 'info', draggable: false, closeOnClick: false, closeButton: CloseButton, closeButtonClassName: 'cancel-scan-btn', showCloseButtonOnHover: false, onClose: () => this.onScanToastCancel(data.id) })
|
||||||
}
|
}
|
||||||
|
|
||||||
this.$store.commit('scanners/addUpdate', data)
|
this.$store.commit('scanners/addUpdate', data)
|
||||||
@@ -527,6 +534,7 @@ export default {
|
|||||||
this.initializeSocket()
|
this.initializeSocket()
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
this.updateBodyClass()
|
||||||
this.resize()
|
this.resize()
|
||||||
window.addEventListener('resize', this.resize)
|
window.addEventListener('resize', this.resize)
|
||||||
window.addEventListener('keydown', this.keyDown)
|
window.addEventListener('keydown', this.keyDown)
|
||||||
|
|||||||
@@ -28,11 +28,11 @@ export default {
|
|||||||
var validOtherFiles = []
|
var validOtherFiles = []
|
||||||
var ignoredFiles = []
|
var ignoredFiles = []
|
||||||
files.forEach((file) => {
|
files.forEach((file) => {
|
||||||
var filetype = this.checkFileType(file.name)
|
// var filetype = this.checkFileType(file.name)
|
||||||
if (!filetype) ignoredFiles.push(file)
|
if (!file.filetype) ignoredFiles.push(file)
|
||||||
else {
|
else {
|
||||||
file.filetype = filetype
|
// file.filetype = filetype
|
||||||
if (filetype === 'audio' || (filetype === 'ebook' && mediaType === 'book')) validItemFiles.push(file)
|
if (file.filetype === 'audio' || (file.filetype === 'ebook' && mediaType === 'book')) validItemFiles.push(file)
|
||||||
else validOtherFiles.push(file)
|
else validOtherFiles.push(file)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -82,12 +82,18 @@ export default {
|
|||||||
items: itemResults,
|
items: itemResults,
|
||||||
ignoredFiles: ignoredFilesInRoot
|
ignoredFiles: ignoredFilesInRoot
|
||||||
}
|
}
|
||||||
} else {
|
} else if (filetree.some((f) => f.filetype !== 'audio') || mediaType !== 'book') {
|
||||||
// Single Book drop
|
// Single Book drop
|
||||||
return {
|
return {
|
||||||
items: this.itemFromTreeItems(filetree, mediaType),
|
items: this.itemFromTreeItems(filetree, mediaType),
|
||||||
ignoredFiles: []
|
ignoredFiles: []
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Only audio files dropped so treat each one as an audiobook
|
||||||
|
return {
|
||||||
|
items: filetree.map((audioFile) => ({ itemFiles: [audioFile], otherFiles: [], ignoredFiles: [] })),
|
||||||
|
ignoredFiles: []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getFilesDropped(dataTransferItems) {
|
getFilesDropped(dataTransferItems) {
|
||||||
@@ -95,11 +101,12 @@ export default {
|
|||||||
path: '/',
|
path: '/',
|
||||||
items: []
|
items: []
|
||||||
}
|
}
|
||||||
function traverseFileTreePromise(item, currtreemap) {
|
function traverseFileTreePromise(item, currtreemap, checkFileType) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
if (item.isFile) {
|
if (item.isFile) {
|
||||||
item.file((file) => {
|
item.file((file) => {
|
||||||
file.filepath = currtreemap.path + file.name //save full path
|
file.filepath = currtreemap.path + file.name //save full path
|
||||||
|
file.filetype = checkFileType(file.name)
|
||||||
currtreemap.items.push(file)
|
currtreemap.items.push(file)
|
||||||
resolve(file)
|
resolve(file)
|
||||||
})
|
})
|
||||||
@@ -119,7 +126,7 @@ export default {
|
|||||||
dirReader.readEntries((entries) => {
|
dirReader.readEntries((entries) => {
|
||||||
if (entries.length > 0) {
|
if (entries.length > 0) {
|
||||||
for (let entr of entries) {
|
for (let entr of entries) {
|
||||||
entriesPromises.push(traverseFileTreePromise(entr, newtreemap))
|
entriesPromises.push(traverseFileTreePromise(entr, newtreemap, checkFileType))
|
||||||
}
|
}
|
||||||
readEntries()
|
readEntries()
|
||||||
} else {
|
} else {
|
||||||
@@ -135,7 +142,7 @@ export default {
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let entriesPromises = []
|
let entriesPromises = []
|
||||||
for (let it of dataTransferItems) {
|
for (let it of dataTransferItems) {
|
||||||
var filetree = traverseFileTreePromise(it.webkitGetAsEntry(), treemap)
|
var filetree = traverseFileTreePromise(it.webkitGetAsEntry(), treemap, this.checkFileType)
|
||||||
entriesPromises.push(filetree)
|
entriesPromises.push(filetree)
|
||||||
}
|
}
|
||||||
Promise.all(entriesPromises).then(() => {
|
Promise.all(entriesPromises).then(() => {
|
||||||
@@ -152,7 +159,9 @@ export default {
|
|||||||
...book
|
...book
|
||||||
}
|
}
|
||||||
var firstBookFile = book.itemFiles[0]
|
var firstBookFile = book.itemFiles[0]
|
||||||
if (!firstBookFile.filepath) return audiobook // No path
|
if (!firstBookFile.filepath) {
|
||||||
|
return audiobook // No path
|
||||||
|
}
|
||||||
|
|
||||||
var firstBookPath = Path.dirname(firstBookFile.filepath)
|
var firstBookPath = Path.dirname(firstBookFile.filepath)
|
||||||
|
|
||||||
@@ -165,6 +174,9 @@ export default {
|
|||||||
if (dirs.length) {
|
if (dirs.length) {
|
||||||
audiobook.author = dirs.pop()
|
audiobook.author = dirs.pop()
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Use file basename as title
|
||||||
|
audiobook.title = Path.basename(firstBookFile.name, Path.extname(firstBookFile.name))
|
||||||
}
|
}
|
||||||
return audiobook
|
return audiobook
|
||||||
},
|
},
|
||||||
@@ -178,7 +190,12 @@ export default {
|
|||||||
if (!firstAudioFile.filepath) return podcast // No path
|
if (!firstAudioFile.filepath) return podcast // No path
|
||||||
var firstPath = Path.dirname(firstAudioFile.filepath)
|
var firstPath = Path.dirname(firstAudioFile.filepath)
|
||||||
var dirs = firstPath.split('/').filter(d => !!d && d !== '.')
|
var dirs = firstPath.split('/').filter(d => !!d && d !== '.')
|
||||||
podcast.title = dirs.length > 1 ? dirs[1] : dirs[0]
|
if (dirs.length) {
|
||||||
|
podcast.title = dirs.length > 1 ? dirs[1] : dirs[0]
|
||||||
|
} else {
|
||||||
|
podcast.title = Path.basename(firstAudioFile.name, Path.extname(firstAudioFile.name))
|
||||||
|
}
|
||||||
|
|
||||||
return podcast
|
return podcast
|
||||||
},
|
},
|
||||||
cleanItem(item, mediaType, index) {
|
cleanItem(item, mediaType, index) {
|
||||||
@@ -188,6 +205,7 @@ export default {
|
|||||||
async getItemsFromDataTransferItems(dataTransferItems, mediaType) {
|
async getItemsFromDataTransferItems(dataTransferItems, mediaType) {
|
||||||
var files = await this.getFilesDropped(dataTransferItems)
|
var files = await this.getFilesDropped(dataTransferItems)
|
||||||
if (!files || !files.length) return { error: 'No files found ' }
|
if (!files || !files.length) return { error: 'No files found ' }
|
||||||
|
|
||||||
var itemData = this.fileTreeToItems(files, mediaType)
|
var itemData = this.fileTreeToItems(files, mediaType)
|
||||||
if (!itemData.items.length && !itemData.ignoredFiles.length) {
|
if (!itemData.items.length && !itemData.ignoredFiles.length) {
|
||||||
return { error: 'Invalid file drop' }
|
return { error: 'Invalid file drop' }
|
||||||
@@ -218,9 +236,12 @@ export default {
|
|||||||
else {
|
else {
|
||||||
file.filetype = filetype
|
file.filetype = filetype
|
||||||
if (file.webkitRelativePath) file.filepath = file.webkitRelativePath
|
if (file.webkitRelativePath) file.filepath = file.webkitRelativePath
|
||||||
|
else file.filepath = file.name
|
||||||
|
|
||||||
if (filetype === 'audio' || (filetype === 'ebook' && mediaType === 'book')) {
|
if (filetype === 'audio' || (filetype === 'ebook' && mediaType === 'book')) {
|
||||||
var dir = file.filepath ? Path.dirname(file.filepath) : ''
|
var dir = file.filepath ? Path.dirname(file.filepath) : ''
|
||||||
|
if (dir === '.') dir = ''
|
||||||
|
|
||||||
if (!itemMap[dir]) {
|
if (!itemMap[dir]) {
|
||||||
itemMap[dir] = {
|
itemMap[dir] = {
|
||||||
path: dir,
|
path: dir,
|
||||||
@@ -246,8 +267,17 @@ export default {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
var items = []
|
||||||
var index = 1
|
var index = 1
|
||||||
var items = Object.values(itemMap).map(i => this.cleanItem(i, mediaType, index++))
|
// If book media type and all files are audio files then treat each one as an audiobook
|
||||||
|
if (itemMap[''] && !otherFiles.length && mediaType === 'book' && !itemMap[''].itemFiles.some(f => f.filetype !== 'audio')) {
|
||||||
|
items = itemMap[''].itemFiles.map((audioFile) => {
|
||||||
|
return this.cleanItem({ itemFiles: [audioFile], otherFiles: [], ignoredFiles: [] }, mediaType, index++)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
items = Object.values(itemMap).map(i => this.cleanItem(i, mediaType, index++))
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items,
|
items,
|
||||||
ignoredFiles: ignoredFiles
|
ignoredFiles: ignoredFiles
|
||||||
|
|||||||
+22
-3
@@ -58,7 +58,8 @@ module.exports = {
|
|||||||
buildModules: [
|
buildModules: [
|
||||||
// https://go.nuxtjs.dev/tailwindcss
|
// https://go.nuxtjs.dev/tailwindcss
|
||||||
'@nuxtjs/tailwindcss',
|
'@nuxtjs/tailwindcss',
|
||||||
'@nuxtjs/pwa'
|
'@nuxtjs/pwa',
|
||||||
|
'@nuxt/postcss8'
|
||||||
],
|
],
|
||||||
|
|
||||||
// Modules: https://go.nuxtjs.dev/config-modules
|
// Modules: https://go.nuxtjs.dev/config-modules
|
||||||
@@ -119,11 +120,21 @@ module.exports = {
|
|||||||
sizes: "512x512"
|
sizes: "512x512"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
enabled: false,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Build Configuration: https://go.nuxtjs.dev/config-build
|
// Build Configuration: https://go.nuxtjs.dev/config-build
|
||||||
build: {},
|
build: {
|
||||||
|
postcss: {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
watchers: {
|
watchers: {
|
||||||
webpack: {
|
webpack: {
|
||||||
aggregateTimeout: 300,
|
aggregateTimeout: 300,
|
||||||
@@ -133,5 +144,13 @@ module.exports = {
|
|||||||
server: {
|
server: {
|
||||||
port: process.env.NODE_ENV === 'production' ? 80 : 3000,
|
port: process.env.NODE_ENV === 'production' ? 80 : 3000,
|
||||||
host: '0.0.0.0'
|
host: '0.0.0.0'
|
||||||
}
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Temporary workaround for @nuxt-community/tailwindcss-module.
|
||||||
|
*
|
||||||
|
* Reported: 2022-05-23
|
||||||
|
* See: [Issue tracker](https://github.com/nuxt-community/tailwindcss-module/issues/480)
|
||||||
|
*/
|
||||||
|
devServerHandlers: [],
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+566
-397
File diff suppressed because it is too large
Load Diff
+5
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.0.18",
|
"version": "2.0.24",
|
||||||
"description": "Self-hosted audiobook and podcast client",
|
"description": "Self-hosted audiobook and podcast client",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -29,8 +29,11 @@
|
|||||||
"vuedraggable": "^2.24.3"
|
"vuedraggable": "^2.24.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@nuxt/postcss8": "^1.1.3",
|
||||||
"@nuxtjs/pwa": "^3.3.5",
|
"@nuxtjs/pwa": "^3.3.5",
|
||||||
"@nuxtjs/tailwindcss": "^4.2.1",
|
"@nuxtjs/tailwindcss": "^4.2.1",
|
||||||
"postcss": "^8.3.6"
|
"autoprefixer": "^10.4.7",
|
||||||
|
"postcss": "^8.3.6",
|
||||||
|
"tailwindcss": "^3.1.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="page-wrapper" class="bg-bg page overflow-y-auto p-8" :class="streamLibraryItem ? 'streaming' : ''">
|
<div id="page-wrapper" class="bg-bg page overflow-y-auto p-4 md:p-8" :class="streamLibraryItem ? 'streaming' : ''">
|
||||||
<div class="max-w-6xl mx-auto">
|
<div class="max-w-6xl mx-auto">
|
||||||
<div class="flex mb-6">
|
<div class="flex flex-wrap sm:flex-nowrap justify-center mb-6">
|
||||||
<div class="w-48 min-w-48">
|
<div class="w-48 min-w-48">
|
||||||
<div class="w-full h-52">
|
<div class="w-full h-52">
|
||||||
<covers-author-image :author="author" rounded="0" />
|
<covers-author-image :author="author" rounded="0" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow px-8">
|
<div class="flex-grow py-4 sm:py-0 px-4 md:px-8">
|
||||||
<div class="flex items-center mb-8">
|
<div class="flex items-center mb-8">
|
||||||
<h1 class="text-2xl">{{ author.name }}</h1>
|
<h1 class="text-2xl">{{ author.name }}</h1>
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export default {
|
|||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
dailyBackupsTooltip() {
|
dailyBackupsTooltip() {
|
||||||
return 'Runs at 1am every day (your server time). Saved in /metadata/backups.'
|
return 'Runs at 1:30am every day (your server time). Saved in /metadata/backups.'
|
||||||
},
|
},
|
||||||
maxBackupSizeTooltip() {
|
maxBackupSizeTooltip() {
|
||||||
return 'As a safeguard against misconfiguration, backups will fail if they exceed the configured size.'
|
return 'As a safeguard against misconfiguration, backups will fail if they exceed the configured size.'
|
||||||
@@ -74,7 +74,7 @@ export default {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var updatePayload = {
|
var updatePayload = {
|
||||||
backupSchedule: this.dailyBackups ? '0 1 * * *' : false,
|
backupSchedule: this.dailyBackups ? '30 1 * * *' : false,
|
||||||
backupsToKeep: Number(this.backupsToKeep),
|
backupsToKeep: Number(this.backupsToKeep),
|
||||||
maxBackupSize: Number(this.maxBackupSize)
|
maxBackupSize: Number(this.maxBackupSize)
|
||||||
}
|
}
|
||||||
|
|||||||
+179
-154
@@ -1,150 +1,190 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<!-- <div class="h-0.5 bg-primary bg-opacity-50 w-full" /> -->
|
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-2">
|
||||||
|
<div class="mb-2">
|
||||||
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
|
|
||||||
<div class="flex items-center mb-2">
|
|
||||||
<h1 class="text-xl">Settings</h1>
|
<h1 class="text-xl">Settings</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center py-2">
|
<div class="lg:flex">
|
||||||
<ui-toggle-switch v-model="newServerSettings.storeCoverWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeCoverWithItem', val)" />
|
<div class="flex-1">
|
||||||
<ui-tooltip :text="tooltips.storeCoverWithItem">
|
<div class="pt-4">
|
||||||
<p class="pl-4 text-lg">
|
<h2 class="font-semibold">General</h2>
|
||||||
Store covers with item
|
</div>
|
||||||
<span class="material-icons icon-text">info_outlined</span>
|
<div class="flex items-end py-2">
|
||||||
</p>
|
<ui-toggle-switch v-model="newServerSettings.storeCoverWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeCoverWithItem', val)" />
|
||||||
</ui-tooltip>
|
<ui-tooltip :text="tooltips.storeCoverWithItem">
|
||||||
</div>
|
<p class="pl-4">
|
||||||
|
Store covers with item
|
||||||
|
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||||
|
</p>
|
||||||
|
</ui-tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center py-2">
|
<div class="flex items-center py-2">
|
||||||
<ui-toggle-switch v-model="newServerSettings.storeMetadataWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeMetadataWithItem', val)" />
|
<ui-toggle-switch v-model="newServerSettings.storeMetadataWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeMetadataWithItem', val)" />
|
||||||
<ui-tooltip :text="tooltips.storeMetadataWithItem">
|
<ui-tooltip :text="tooltips.storeMetadataWithItem">
|
||||||
<p class="pl-4 text-lg">
|
<p class="pl-4">
|
||||||
Store metadata with item
|
Store metadata with item
|
||||||
<span class="material-icons icon-text">info_outlined</span>
|
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||||
</p>
|
</p>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center py-2">
|
<div class="flex items-center py-2">
|
||||||
<ui-toggle-switch v-model="useSquareBookCovers" :disabled="updatingServerSettings" @input="updateBookCoverAspectRatio" />
|
<ui-toggle-switch v-model="newServerSettings.sortingIgnorePrefix" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('sortingIgnorePrefix', val)" />
|
||||||
<ui-tooltip :text="tooltips.coverAspectRatio">
|
<ui-tooltip :text="tooltips.sortingIgnorePrefix">
|
||||||
<p class="pl-4 text-lg">
|
<p class="pl-4">
|
||||||
Use square book covers
|
Ignore prefixes when sorting
|
||||||
<span class="material-icons icon-text">info_outlined</span>
|
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||||
</p>
|
</p>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="newServerSettings.sortingIgnorePrefix" class="w-72 ml-14 mb-2">
|
||||||
|
<ui-multi-select v-model="newServerSettings.sortingPrefixes" small :items="newServerSettings.sortingPrefixes" label="Prefixes to Ignore (case insensitive)" @input="updateSortingPrefixes" :disabled="updatingServerSettings" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center py-2">
|
<div class="flex items-center py-2">
|
||||||
<ui-toggle-switch v-model="useAlternativeBookshelfView" :disabled="updatingServerSettings" @input="updateAlternativeBookshelfView" />
|
<ui-toggle-switch v-model="newServerSettings.chromecastEnabled" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('chromecastEnabled', val)" />
|
||||||
<ui-tooltip :text="tooltips.bookshelfView">
|
<p class="pl-4">Chromecast support</p>
|
||||||
<p class="pl-4 text-lg">
|
</div>
|
||||||
Use alternative bookshelf view
|
|
||||||
<span class="material-icons icon-text">info_outlined</span>
|
|
||||||
</p>
|
|
||||||
</ui-tooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center py-2">
|
<div class="pt-4">
|
||||||
<ui-toggle-switch v-model="newServerSettings.sortingIgnorePrefix" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('sortingIgnorePrefix', val)" />
|
<h2 class="font-semibold">Display</h2>
|
||||||
<ui-tooltip :text="tooltips.sortingIgnorePrefix">
|
</div>
|
||||||
<p class="pl-4 text-lg">
|
|
||||||
Ignore prefixes when sorting title and series
|
|
||||||
<span class="material-icons icon-text">info_outlined</span>
|
|
||||||
</p>
|
|
||||||
</ui-tooltip>
|
|
||||||
</div>
|
|
||||||
<div v-if="newServerSettings.sortingIgnorePrefix" class="w-72 ml-14 mb-2">
|
|
||||||
<ui-multi-select v-model="newServerSettings.sortingPrefixes" small :items="newServerSettings.sortingPrefixes" label="Prefixes to Ignore (case insensitive)" @input="updateSortingPrefixes" :disabled="updatingServerSettings" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center py-2">
|
<div class="flex items-center py-2">
|
||||||
<ui-toggle-switch v-model="newServerSettings.chromecastEnabled" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('chromecastEnabled', val)" />
|
<ui-toggle-switch v-model="useSquareBookCovers" :disabled="updatingServerSettings" @input="updateBookCoverAspectRatio" />
|
||||||
<p class="pl-4 text-lg">Enable Chromecast</p>
|
<ui-tooltip :text="tooltips.coverAspectRatio">
|
||||||
</div>
|
<p class="pl-4">
|
||||||
|
Square book covers
|
||||||
|
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||||
|
</p>
|
||||||
|
</ui-tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center mb-2 mt-8">
|
<div class="flex items-center py-2">
|
||||||
<h1 class="text-xl">Scanner Settings</h1>
|
<ui-toggle-switch v-model="useAlternativeBookshelfView" :disabled="updatingServerSettings" @input="updateAlternativeBookshelfView" />
|
||||||
</div>
|
<ui-tooltip :text="tooltips.bookshelfView">
|
||||||
|
<p class="pl-4">
|
||||||
|
Alternative bookshelf view
|
||||||
|
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||||
|
</p>
|
||||||
|
</ui-tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center py-2">
|
<div class="flex items-center py-2">
|
||||||
<ui-toggle-switch v-model="newServerSettings.scannerParseSubtitle" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerParseSubtitle', val)" />
|
<p class="pr-4">Date Format</p>
|
||||||
<ui-tooltip :text="tooltips.scannerParseSubtitle">
|
<ui-dropdown v-model="newServerSettings.dateFormat" :items="dateFormats" small class="max-w-40" @input="(val) => updateSettingsKey('dateFormat', val)" />
|
||||||
<p class="pl-4 text-lg">
|
</div>
|
||||||
Scanner parse subtitles
|
</div>
|
||||||
<span class="material-icons icon-text">info_outlined</span>
|
|
||||||
</p>
|
|
||||||
</ui-tooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center py-2">
|
<div class="flex-1">
|
||||||
<ui-toggle-switch v-model="newServerSettings.scannerFindCovers" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerFindCovers', val)" />
|
<div class="pt-4">
|
||||||
<ui-tooltip :text="tooltips.scannerFindCovers">
|
<h2 class="font-semibold">Scanner</h2>
|
||||||
<p class="pl-4 text-lg">
|
</div>
|
||||||
Scanner find covers
|
|
||||||
<span class="material-icons icon-text">info_outlined</span>
|
|
||||||
</p>
|
|
||||||
</ui-tooltip>
|
|
||||||
<div class="flex-grow" />
|
|
||||||
</div>
|
|
||||||
<div v-if="newServerSettings.scannerFindCovers" class="w-44 ml-14 mb-2">
|
|
||||||
<ui-dropdown v-model="newServerSettings.scannerCoverProvider" small :items="providers" label="Cover Provider" @input="updateScannerCoverProvider" :disabled="updatingServerSettings" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center py-2">
|
<div class="flex items-center py-2">
|
||||||
<ui-toggle-switch v-model="newServerSettings.scannerPreferAudioMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferAudioMetadata', val)" />
|
<ui-toggle-switch v-model="newServerSettings.scannerParseSubtitle" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerParseSubtitle', val)" />
|
||||||
<ui-tooltip :text="tooltips.scannerPreferAudioMetadata">
|
<ui-tooltip :text="tooltips.scannerParseSubtitle">
|
||||||
<p class="pl-4 text-lg">
|
<p class="pl-4">
|
||||||
Scanner prefer audio metadata
|
Parse subtitles
|
||||||
<span class="material-icons icon-text">info_outlined</span>
|
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||||
</p>
|
</p>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center py-2">
|
<div class="flex items-center py-2">
|
||||||
<ui-toggle-switch v-model="newServerSettings.scannerPreferOpfMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferOpfMetadata', val)" />
|
<ui-toggle-switch v-model="newServerSettings.scannerFindCovers" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerFindCovers', val)" />
|
||||||
<ui-tooltip :text="tooltips.scannerPreferOpfMetadata">
|
<ui-tooltip :text="tooltips.scannerFindCovers">
|
||||||
<p class="pl-4 text-lg">
|
<p class="pl-4">
|
||||||
Scanner prefer OPF metadata
|
Find covers
|
||||||
<span class="material-icons icon-text">info_outlined</span>
|
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||||
</p>
|
</p>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
<div class="flex-grow" />
|
||||||
|
</div>
|
||||||
|
<div v-if="newServerSettings.scannerFindCovers" class="w-44 ml-14 mb-2">
|
||||||
|
<ui-dropdown v-model="newServerSettings.scannerCoverProvider" small :items="providers" label="Cover Provider" @input="updateScannerCoverProvider" :disabled="updatingServerSettings" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center py-2">
|
<div class="flex items-center py-2">
|
||||||
<ui-toggle-switch v-model="newServerSettings.scannerPreferMatchedMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferMatchedMetadata', val)" />
|
<ui-toggle-switch v-model="newServerSettings.scannerPreferOverdriveMediaMarker" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferOverdriveMediaMarker', val)" />
|
||||||
<ui-tooltip :text="tooltips.scannerPreferMatchedMetadata">
|
<ui-tooltip :text="tooltips.scannerPreferOverdriveMediaMarker">
|
||||||
<p class="pl-4 text-lg">
|
<p class="pl-4">
|
||||||
Scanner prefer matched metadata
|
Use Overdrive Media Markers for chapters
|
||||||
<span class="material-icons icon-text">info_outlined</span>
|
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||||
</p>
|
</p>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center py-2">
|
<div class="flex items-center py-2">
|
||||||
<ui-toggle-switch v-model="newServerSettings.scannerDisableWatcher" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerDisableWatcher', val)" />
|
<ui-toggle-switch v-model="newServerSettings.scannerPreferAudioMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferAudioMetadata', val)" />
|
||||||
<ui-tooltip :text="tooltips.scannerDisableWatcher">
|
<ui-tooltip :text="tooltips.scannerPreferAudioMetadata">
|
||||||
<p class="pl-4 text-lg">
|
<p class="pl-4">
|
||||||
Disable Watcher
|
Prefer audio metadata
|
||||||
<span class="material-icons icon-text">info_outlined</span>
|
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||||
</p>
|
</p>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center mb-2 mt-8">
|
<div class="flex items-center py-2">
|
||||||
<h1 class="text-xl">Experimental Feature Settings</h1>
|
<ui-toggle-switch v-model="newServerSettings.scannerPreferOpfMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferOpfMetadata', val)" />
|
||||||
</div>
|
<ui-tooltip :text="tooltips.scannerPreferOpfMetadata">
|
||||||
|
<p class="pl-4">
|
||||||
|
Prefer OPF metadata
|
||||||
|
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||||
|
</p>
|
||||||
|
</ui-tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center py-2">
|
<div class="flex items-center py-2">
|
||||||
<ui-toggle-switch v-model="newServerSettings.enableEReader" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('enableEReader', val)" />
|
<ui-toggle-switch v-model="newServerSettings.scannerPreferMatchedMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferMatchedMetadata', val)" />
|
||||||
<ui-tooltip :text="tooltips.enableEReader">
|
<ui-tooltip :text="tooltips.scannerPreferMatchedMetadata">
|
||||||
<p class="pl-4 text-lg">
|
<p class="pl-4">
|
||||||
Enable e-reader for all users
|
Prefer matched metadata
|
||||||
<span class="material-icons icon-text">info_outlined</span>
|
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||||
</p>
|
</p>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center py-2">
|
||||||
|
<ui-toggle-switch v-model="newServerSettings.scannerDisableWatcher" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerDisableWatcher', val)" />
|
||||||
|
<ui-tooltip :text="tooltips.scannerDisableWatcher">
|
||||||
|
<p class="pl-4">
|
||||||
|
Disable Watcher
|
||||||
|
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||||
|
</p>
|
||||||
|
</ui-tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pt-4">
|
||||||
|
<h2 class="font-semibold">Experimental Features</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center py-2">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<ui-toggle-switch v-model="showExperimentalFeatures" />
|
||||||
|
<ui-tooltip :text="tooltips.experimentalFeatures">
|
||||||
|
<p class="pl-4">
|
||||||
|
Experimental Features
|
||||||
|
<a href="https://github.com/advplyr/audiobookshelf/discussions/75" target="_blank">
|
||||||
|
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</ui-tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center py-2">
|
||||||
|
<ui-toggle-switch v-model="newServerSettings.enableEReader" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('enableEReader', val)" />
|
||||||
|
<ui-tooltip :text="tooltips.enableEReader">
|
||||||
|
<p class="pl-4">
|
||||||
|
Enable e-reader for all users
|
||||||
|
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||||
|
</p>
|
||||||
|
</ui-tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -152,7 +192,7 @@
|
|||||||
|
|
||||||
<div class="flex items-center py-4">
|
<div class="flex items-center py-4">
|
||||||
<ui-btn color="bg" small :padding-x="4" class="hidden lg:block mr-2" :loading="isPurgingCache" @click="purgeCache">Purge Cache</ui-btn>
|
<ui-btn color="bg" small :padding-x="4" class="hidden lg:block mr-2" :loading="isPurgingCache" @click="purgeCache">Purge Cache</ui-btn>
|
||||||
<ui-btn color="bg" small :padding-x="4" class="hidden lg:block" :loading="isResettingLibraryItems" @click="resetLibraryItems">Remove All Library Items</ui-btn>
|
<ui-btn color="bg" small :padding-x="4" class="hidden lg:block mr-2" :loading="isResettingLibraryItems" @click="resetLibraryItems">Remove All Library Items</ui-btn>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<p class="pr-2 text-sm font-book text-yellow-400">
|
<p class="pr-2 text-sm font-book text-yellow-400">
|
||||||
Report bugs, request features, and contribute on
|
Report bugs, request features, and contribute on
|
||||||
@@ -188,30 +228,12 @@
|
|||||||
|
|
||||||
<div class="h-0.5 bg-primary bg-opacity-30 w-full" />
|
<div class="h-0.5 bg-primary bg-opacity-30 w-full" />
|
||||||
|
|
||||||
<div class="py-12 mb-4 opacity-60 hover:opacity-100">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<ui-toggle-switch v-model="showExperimentalFeatures" />
|
|
||||||
<ui-tooltip :text="tooltips.experimentalFeatures">
|
|
||||||
<p class="pl-4 text-lg">
|
|
||||||
Experimental Features
|
|
||||||
<a href="https://github.com/advplyr/audiobookshelf/discussions/75" target="_blank">
|
|
||||||
<span class="material-icons icon-text">info_outlined</span>
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</ui-tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<prompt-dialog v-model="showConfirmPurgeCache" :width="675">
|
<prompt-dialog v-model="showConfirmPurgeCache" :width="675">
|
||||||
<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">
|
||||||
<p class="text-error text-lg font-semibold">Important Notice!</p>
|
<p class="text-error font-semibold">Important Notice!</p>
|
||||||
<p class="text-lg my-2 text-center">Purge cache will delete the entire directory at <span class="font-mono">/metadata/cache</span>.</p>
|
<p class="my-2 text-center">Purge cache will delete the entire directory at <span class="font-mono">/metadata/cache</span>.</p>
|
||||||
|
|
||||||
<p class="text-lg text-center mb-8">Are you sure you want to remove the cache directory?</p>
|
<p class="text-center mb-8">Are you sure you want to remove the cache directory?</p>
|
||||||
<div class="flex px-1 items-center">
|
<div class="flex px-1 items-center">
|
||||||
<ui-btn color="primary" @click="showConfirmPurgeCache = false">Nevermind</ui-btn>
|
<ui-btn color="primary" @click="showConfirmPurgeCache = false">Nevermind</ui-btn>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
@@ -245,7 +267,8 @@ export default {
|
|||||||
storeCoverWithItem: 'By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named "cover" will be kept',
|
storeCoverWithItem: 'By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named "cover" will be kept',
|
||||||
storeMetadataWithItem: 'By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders. Uses .abs file extension',
|
storeMetadataWithItem: 'By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders. Uses .abs file extension',
|
||||||
coverAspectRatio: 'Prefer to use square covers over standard 1.6:1 book covers',
|
coverAspectRatio: 'Prefer to use square covers over standard 1.6:1 book covers',
|
||||||
enableEReader: 'E-reader is still a work in progress, but use this setting to open it up to all your users (or use the "Experimental Features" toggle below just for you)'
|
enableEReader: 'E-reader is still a work in progress, but use this setting to open it up to all your users (or use the "Experimental Features" toggle just for use by you)',
|
||||||
|
scannerPreferOverdriveMediaMarker: 'MP3 files from Overdrive come with chapter timings embedded as custom metadata. Enabling this will use these tags for chapter timings automatically'
|
||||||
},
|
},
|
||||||
showConfirmPurgeCache: false
|
showConfirmPurgeCache: false
|
||||||
}
|
}
|
||||||
@@ -271,12 +294,12 @@ export default {
|
|||||||
set(val) {
|
set(val) {
|
||||||
this.$store.commit('setExperimentalFeatures', val)
|
this.$store.commit('setExperimentalFeatures', val)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
dateFormats() {
|
||||||
|
return this.$store.state.globals.dateFormats
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
updateEnableChromecast(val) {
|
|
||||||
this.updateServerSettings({ enableChromecast: val })
|
|
||||||
},
|
|
||||||
updateSortingPrefixes(val) {
|
updateSortingPrefixes(val) {
|
||||||
if (!val || !val.length) {
|
if (!val || !val.length) {
|
||||||
this.$toast.error('Must have at least 1 prefix')
|
this.$toast.error('Must have at least 1 prefix')
|
||||||
@@ -314,10 +337,12 @@ export default {
|
|||||||
.then((success) => {
|
.then((success) => {
|
||||||
console.log('Updated Server Settings', success)
|
console.log('Updated Server Settings', success)
|
||||||
this.updatingServerSettings = false
|
this.updatingServerSettings = false
|
||||||
|
this.$toast.success('Server settings updated')
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to update server settings', error)
|
console.error('Failed to update server settings', error)
|
||||||
this.updatingServerSettings = false
|
this.updatingServerSettings = false
|
||||||
|
this.$toast.error('Failed to update server settings')
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
initServerSettings() {
|
initServerSettings() {
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
|
||||||
<p class="text-xl">Stats for library {{ currentLibraryName }}</p>
|
<h1 class="text-xl">Stats for library {{ currentLibraryName }}</h1>
|
||||||
|
|
||||||
<stats-preview-icons v-if="totalItems" :library-stats="libraryStats" />
|
<stats-preview-icons v-if="totalItems" :library-stats="libraryStats" />
|
||||||
|
|
||||||
<div class="flex md:flex-row flex-wrap justify-between flex-col mt-12">
|
<div class="flex md:flex-row flex-wrap justify-between flex-col mt-8">
|
||||||
<div class="w-80 my-6 mx-auto">
|
<div class="w-80 my-6 mx-auto">
|
||||||
<h1 class="text-2xl mb-4 font-book">Top 5 Genres</h1>
|
<h1 class="text-2xl mb-4 font-book">Top 5 Genres</h1>
|
||||||
<p v-if="!top5Genres.length">No Genres</p>
|
<p v-if="!top5Genres.length">No Genres</p>
|
||||||
|
|||||||
+12
-14
@@ -1,24 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="page-wrapper" class="page overflow-y-auto" :class="streamLibraryItem ? 'streaming' : ''">
|
<div class="w-full h-full">
|
||||||
<div class="w-full max-w-4xl mx-auto">
|
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
|
||||||
<div class="mb-4 flex flex-col sm:flex-row items-start sm:items-end">
|
<div class="flex items-center mb-2">
|
||||||
<p class="text-2xl mr-4 mb-2 sm:mb-0">Logger</p>
|
<h1 class="text-xl">Logs</h1>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between mb-2 place-items-end">
|
||||||
|
<ui-text-input ref="input" v-model="search" placeholder="Search filter.." @input="inputUpdate" clearable class="w-full sm:w-40 h-8 text-sm sm:mb-0" />
|
||||||
|
|
||||||
<ui-text-input ref="input" v-model="search" placeholder="Search filter.." @input="inputUpdate" clearable class="w-full sm:w-40 h-8 text-sm mb-2 sm:mb-0" />
|
<ui-dropdown v-model="newServerSettings.logLevel" label="Server Log Level" :items="logLevelItems" @input="logLevelUpdated" class="w-full sm:w-44" />
|
||||||
|
|
||||||
<div class="flex-grow" />
|
|
||||||
|
|
||||||
<div class="w-full sm:w-44">
|
|
||||||
<ui-dropdown v-model="newServerSettings.logLevel" label="Server Log Level" :items="logLevelItems" @input="logLevelUpdated" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div ref="container" class="relative w-full h-full bg-primary border-bg overflow-x-hidden overflow-y-auto text-red shadow-inner rounded-md" style="max-height: 550px; min-height: 550px">
|
<div ref="container" class="relative w-full h-full bg-primary border-bg overflow-x-hidden overflow-y-auto text-red shadow-inner rounded-md" style="max-height: 800px; min-height: 550px">
|
||||||
<template v-for="(log, index) in logs">
|
<template v-for="(log, index) in logs">
|
||||||
<div :key="index" class="flex flex-nowrap px-2 py-1 items-start text-sm bg-opacity-10" :class="`bg-${logColors[log.level]}`">
|
<div :key="index" class="flex flex-nowrap px-2 py-1 items-start text-sm bg-opacity-10" :class="`bg-${logColors[log.level]}`">
|
||||||
<p class="text-gray-400 w-40 font-mono">{{ log.timestamp.split('.')[0].split('T').join(' ') }}</p>
|
<p class="text-gray-400 w-36 font-mono text-xs">{{ log.timestamp }}</p>
|
||||||
<p class="font-semibold w-12 text-right" :class="`text-${logColors[log.level]}`">{{ log.levelName }}</p>
|
<p class="font-semibold w-12 text-right text-sm" :class="`text-${logColors[log.level]}`">{{ log.levelName }}</p>
|
||||||
<p class="px-4 logmessage">{{ log.message }}</p>
|
<p class="px-4 logmessage">{{ log.message }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -164,6 +161,7 @@ export default {
|
|||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
if (!this.$root.socket) return
|
if (!this.$root.socket) return
|
||||||
|
this.$root.socket.emit('remove_log_listener')
|
||||||
this.$root.socket.off('daily_logs', this.dailyLogsLoaded)
|
this.$root.socket.off('daily_logs', this.dailyLogsLoaded)
|
||||||
this.$root.socket.off('log', this.logEvtReceived)
|
this.$root.socket.off('log', this.logEvtReceived)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full h-full">
|
||||||
|
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
|
||||||
|
<div class="flex items-center mb-2">
|
||||||
|
<h1 class="text-xl">Listening Sessions</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end mb-2">
|
||||||
|
<ui-dropdown v-model="selectedUser" :items="userItems" label="Filter by User" small class="max-w-48" @input="updateUserFilter" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="listeningSessions.length" class="block max-w-full">
|
||||||
|
<table class="userSessionsTable">
|
||||||
|
<tr class="bg-primary bg-opacity-40">
|
||||||
|
<th class="w-48 min-w-48 text-left">Item</th>
|
||||||
|
<th class="w-20 min-w-20 text-left hidden md:table-cell">User</th>
|
||||||
|
<th class="w-32 min-w-32 text-left hidden md:table-cell">Play Method</th>
|
||||||
|
<th class="w-32 min-w-32 text-left hidden sm:table-cell">Device Info</th>
|
||||||
|
<th class="w-32 min-w-32">Listened</th>
|
||||||
|
<th class="w-16 min-w-16">Last Time</th>
|
||||||
|
<th class="flex-grow hidden sm:table-cell">Last Update</th>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr v-for="session in listeningSessions" :key="session.id" class="cursor-pointer" @click="showSession(session)">
|
||||||
|
<td class="py-1 max-w-48">
|
||||||
|
<p class="text-xs text-gray-200 truncate">{{ session.displayTitle }}</p>
|
||||||
|
<p class="text-xs text-gray-400 truncate">{{ session.displayAuthor }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="hidden md:table-cell">
|
||||||
|
<p v-if="filteredUserUsername" class="text-xs">{{ filteredUserUsername }}</p>
|
||||||
|
<p v-else class="text-xs">{{ session.user ? session.user.username : 'N/A' }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="hidden md:table-cell">
|
||||||
|
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="hidden sm:table-cell">
|
||||||
|
<p class="text-xs" v-html="getDeviceInfoString(session.deviceInfo)" />
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="text-center hidden sm:table-cell">
|
||||||
|
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDate(session.updatedAt, 'MMMM do, yyyy HH:mm')">
|
||||||
|
<p class="text-xs text-gray-200">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
|
||||||
|
</ui-tooltip>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<div class="flex items-center justify-end my-2">
|
||||||
|
<ui-icon-btn icon="arrow_back_ios_new" :size="7" icon-font-size="1rem" class="mx-1" :disabled="currentPage === 0" @click="prevPage" />
|
||||||
|
<p class="text-sm mx-1">Page {{ currentPage + 1 }} of {{ numPages }}</p>
|
||||||
|
<ui-icon-btn icon="arrow_forward_ios" :size="7" icon-font-size="1rem" class="mx-1" :disabled="currentPage >= numPages - 1" @click="nextPage" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-else class="text-white text-opacity-50">No sessions yet...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<modals-listening-session-modal v-model="showSessionModal" :session="selectedSession" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
async asyncData({ params, redirect, app }) {
|
||||||
|
var users = await app.$axios
|
||||||
|
.$get('/api/users')
|
||||||
|
.then((users) => {
|
||||||
|
return users.sort((a, b) => {
|
||||||
|
return a.createdAt - b.createdAt
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed', error)
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
users
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
showSessionModal: false,
|
||||||
|
selectedSession: null,
|
||||||
|
listeningSessions: [],
|
||||||
|
numPages: 0,
|
||||||
|
total: 0,
|
||||||
|
currentPage: 0,
|
||||||
|
userFilter: null,
|
||||||
|
selectedUser: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
username() {
|
||||||
|
return this.user.username
|
||||||
|
},
|
||||||
|
userOnline() {
|
||||||
|
return this.$store.getters['users/getIsUserOnline'](this.user.id)
|
||||||
|
},
|
||||||
|
userItems() {
|
||||||
|
var userItems = [{ value: '', text: 'All Users' }]
|
||||||
|
return userItems.concat(this.users.map((u) => ({ value: u.id, text: u.username })))
|
||||||
|
},
|
||||||
|
filteredUserUsername() {
|
||||||
|
if (!this.userFilter) return null
|
||||||
|
var user = this.users.find((u) => u.id === this.userFilter)
|
||||||
|
return user ? user.username : null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
updateUserFilter() {
|
||||||
|
this.loadSessions(0)
|
||||||
|
},
|
||||||
|
prevPage() {
|
||||||
|
this.loadSessions(this.currentPage - 1)
|
||||||
|
},
|
||||||
|
nextPage() {
|
||||||
|
this.loadSessions(this.currentPage + 1)
|
||||||
|
},
|
||||||
|
showSession(session) {
|
||||||
|
this.selectedSession = session
|
||||||
|
this.showSessionModal = true
|
||||||
|
},
|
||||||
|
getDeviceInfoString(deviceInfo) {
|
||||||
|
if (!deviceInfo) return ''
|
||||||
|
var lines = []
|
||||||
|
if (deviceInfo.osName) lines.push(`${deviceInfo.osName} ${deviceInfo.osVersion}`)
|
||||||
|
if (deviceInfo.browserName) lines.push(deviceInfo.browserName)
|
||||||
|
|
||||||
|
if (deviceInfo.manufacturer && deviceInfo.model) lines.push(`${deviceInfo.manufacturer} ${deviceInfo.model}`)
|
||||||
|
if (deviceInfo.sdkVersion) lines.push(`SDK Version: ${deviceInfo.sdkVersion}`)
|
||||||
|
return lines.join('<br>')
|
||||||
|
},
|
||||||
|
getPlayMethodName(playMethod) {
|
||||||
|
if (playMethod === this.$constants.PlayMethod.DIRECTPLAY) return 'Direct Play'
|
||||||
|
else if (playMethod === this.$constants.PlayMethod.TRANSCODE) return 'Transcode'
|
||||||
|
else if (playMethod === this.$constants.PlayMethod.DIRECTSTREAM) return 'Direct Stream'
|
||||||
|
else if (playMethod === this.$constants.PlayMethod.LOCAL) return 'Local'
|
||||||
|
return 'Unknown'
|
||||||
|
},
|
||||||
|
async loadSessions(page) {
|
||||||
|
var userFilterQuery = this.selectedUser ? `&user=${this.selectedUser}` : ''
|
||||||
|
const data = await this.$axios.$get(`/api/sessions?page=${page}&itemsPerPage=10${userFilterQuery}`).catch((err) => {
|
||||||
|
console.error('Failed to load listening sesions', err)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
if (!data) {
|
||||||
|
this.$toast.error('Failed to load listening sessions')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.numPages = data.numPages
|
||||||
|
this.total = data.total
|
||||||
|
this.currentPage = data.page
|
||||||
|
this.listeningSessions = data.sessions
|
||||||
|
this.userFilter = data.userFilter
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
this.loadSessions(0)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.userSessionsTable {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
border: 1px solid #474747;
|
||||||
|
}
|
||||||
|
.userSessionsTable tr:first-child {
|
||||||
|
background-color: #272727;
|
||||||
|
}
|
||||||
|
.userSessionsTable tr:not(:first-child) {
|
||||||
|
background-color: #373838;
|
||||||
|
}
|
||||||
|
.userSessionsTable tr:not(:first-child):nth-child(odd) {
|
||||||
|
background-color: #2f2f2f;
|
||||||
|
}
|
||||||
|
.userSessionsTable tr:hover:not(:first-child) {
|
||||||
|
background-color: #474747;
|
||||||
|
}
|
||||||
|
.userSessionsTable td {
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
.userSessionsTable th {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
|
||||||
|
<h1 class="text-xl">Stats for {{ username }}</h1>
|
||||||
|
|
||||||
<div class="flex justify-center">
|
<div class="flex justify-center">
|
||||||
<div class="flex p-2">
|
<div class="flex p-2">
|
||||||
<svg class="hidden sm:block h-14 w-14 lg:h-18 lg:w-18" viewBox="0 0 24 24">
|
<svg class="hidden sm:block h-14 w-14 lg:h-18 lg:w-18" viewBox="0 0 24 24">
|
||||||
@@ -46,7 +48,7 @@
|
|||||||
<template v-for="(item, index) in mostRecentListeningSessions">
|
<template v-for="(item, index) in mostRecentListeningSessions">
|
||||||
<div :key="item.id" class="w-full py-0.5">
|
<div :key="item.id" class="w-full py-0.5">
|
||||||
<div class="flex items-center mb-1">
|
<div class="flex items-center mb-1">
|
||||||
<p class="text-sm font-book text-white text-opacity-70 w-6 truncate">{{ index + 1 }}. </p>
|
<p class="text-sm font-book text-white text-opacity-70 w-8">{{ index + 1 }}. </p>
|
||||||
<div class="w-56">
|
<div class="w-56">
|
||||||
<p class="text-sm font-book text-white text-opacity-80 truncate">{{ item.mediaMetadata ? item.mediaMetadata.title : '' }}</p>
|
<p class="text-sm font-book text-white text-opacity-80 truncate">{{ item.mediaMetadata ? item.mediaMetadata.title : '' }}</p>
|
||||||
<p class="text-xs text-white text-opacity-50">{{ $dateDistanceFromNow(item.updatedAt) }}</p>
|
<p class="text-xs text-white text-opacity-50">{{ $dateDistanceFromNow(item.updatedAt) }}</p>
|
||||||
@@ -84,6 +86,9 @@ export default {
|
|||||||
user() {
|
user() {
|
||||||
return this.$store.state.user.user
|
return this.$store.state.user.user
|
||||||
},
|
},
|
||||||
|
username() {
|
||||||
|
return this.user.username
|
||||||
|
},
|
||||||
currentLibraryId() {
|
currentLibraryId() {
|
||||||
return this.$store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -138,7 +138,9 @@ export default {
|
|||||||
this.$copyToClipboard(str, this)
|
this.$copyToClipboard(str, this)
|
||||||
},
|
},
|
||||||
async init() {
|
async init() {
|
||||||
this.listeningSessions = await this.$axios.$get(`/api/users/${this.user.id}/listening-sessions`).catch((err) => {
|
this.listeningSessions = await this.$axios.$get(`/api/users/${this.user.id}/listening-sessions?page=0&itemsPerPage=10`).then((data) => {
|
||||||
|
return data.sessions || []
|
||||||
|
}).catch((err) => {
|
||||||
console.error('Failed to load listening sesions', err)
|
console.error('Failed to load listening sesions', err)
|
||||||
return []
|
return []
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -17,40 +17,47 @@
|
|||||||
<div class="w-full h-px bg-white bg-opacity-10 my-2" />
|
<div class="w-full h-px bg-white bg-opacity-10 my-2" />
|
||||||
|
|
||||||
<div class="py-2">
|
<div class="py-2">
|
||||||
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Listening Sessions ({{ listeningSessions.length }})</h1>
|
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Listening Sessions</h1>
|
||||||
<table v-if="listeningSessions.length" class="userSessionsTable">
|
<div v-if="listeningSessions.length">
|
||||||
<tr class="bg-primary bg-opacity-40">
|
<table class="userSessionsTable">
|
||||||
<th class="flex-grow text-left">Item</th>
|
<tr class="bg-primary bg-opacity-40">
|
||||||
<th class="w-32 text-left hidden md:table-cell">Play Method</th>
|
<th class="w-48 min-w-48 text-left">Item</th>
|
||||||
<th class="w-40 text-left hidden sm:table-cell">Device Info</th>
|
<th class="w-32 min-w-32 text-left hidden md:table-cell">Play Method</th>
|
||||||
<th class="w-20">Listened</th>
|
<th class="w-32 min-w-32 text-left hidden sm:table-cell">Device Info</th>
|
||||||
<th class="w-20">Last Time</th>
|
<th class="w-32 min-w-32">Listened</th>
|
||||||
<th class="w-40 hidden sm:table-cell">Last Update</th>
|
<th class="w-16 min-w-16">Last Time</th>
|
||||||
</tr>
|
<th class="flex-grow hidden sm:table-cell">Last Update</th>
|
||||||
<tr v-for="session in listeningSessions" :key="session.id" class="cursor-pointer" @click="showSession(session)">
|
</tr>
|
||||||
<td class="py-1">
|
<tr v-for="session in listeningSessions" :key="session.id" class="cursor-pointer" @click="showSession(session)">
|
||||||
<p class="text-sm text-gray-200">{{ session.displayTitle }}</p>
|
<td class="py-1 max-w-48">
|
||||||
<p class="text-xs text-gray-400">{{ session.displayAuthor }}</p>
|
<p class="text-xs text-gray-200 truncate">{{ session.displayTitle }}</p>
|
||||||
</td>
|
<p class="text-xs text-gray-400 truncate">{{ session.displayAuthor }}</p>
|
||||||
<td class="hidden md:table-cell">
|
</td>
|
||||||
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p>
|
<td class="hidden md:table-cell">
|
||||||
</td>
|
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p>
|
||||||
<td class="hidden sm:table-cell">
|
</td>
|
||||||
<p class="text-xs" v-html="getDeviceInfoString(session.deviceInfo)" />
|
<td class="hidden sm:table-cell">
|
||||||
</td>
|
<p class="text-xs" v-html="getDeviceInfoString(session.deviceInfo)" />
|
||||||
<td class="text-center">
|
</td>
|
||||||
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
|
<td class="text-center">
|
||||||
</td>
|
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
|
||||||
<td class="text-center">
|
</td>
|
||||||
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
|
<td class="text-center">
|
||||||
</td>
|
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
|
||||||
<td class="text-center hidden sm:table-cell">
|
</td>
|
||||||
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDate(session.updatedAt, 'MMMM do, yyyy HH:mm')">
|
<td class="text-center hidden sm:table-cell">
|
||||||
<p class="text-xs">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
|
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDate(session.updatedAt, 'MMMM do, yyyy HH:mm')">
|
||||||
</ui-tooltip>
|
<p class="text-xs text-gray-200">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
|
||||||
</td>
|
</ui-tooltip>
|
||||||
</tr>
|
</td>
|
||||||
</table>
|
</tr>
|
||||||
|
</table>
|
||||||
|
<div class="flex items-center justify-end py-1">
|
||||||
|
<ui-icon-btn icon="arrow_back_ios_new" :size="7" icon-font-size="1rem" class="mx-1" :disabled="currentPage === 0" @click="prevPage" />
|
||||||
|
<p class="text-sm mx-1">Page {{ currentPage + 1 }} of {{ numPages }}</p>
|
||||||
|
<ui-icon-btn icon="arrow_forward_ios" :size="7" icon-font-size="1rem" class="mx-1" :disabled="currentPage >= numPages - 1" @click="nextPage" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<p v-else class="text-white text-opacity-50">No sessions yet...</p>
|
<p v-else class="text-white text-opacity-50">No sessions yet...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -75,7 +82,10 @@ export default {
|
|||||||
return {
|
return {
|
||||||
showSessionModal: false,
|
showSessionModal: false,
|
||||||
selectedSession: null,
|
selectedSession: null,
|
||||||
listeningSessions: []
|
listeningSessions: [],
|
||||||
|
numPages: 0,
|
||||||
|
total: 0,
|
||||||
|
currentPage: 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -87,6 +97,12 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
prevPage() {
|
||||||
|
this.loadSessions(this.currentPage - 1)
|
||||||
|
},
|
||||||
|
nextPage() {
|
||||||
|
this.loadSessions(this.currentPage + 1)
|
||||||
|
},
|
||||||
showSession(session) {
|
showSession(session) {
|
||||||
this.selectedSession = session
|
this.selectedSession = session
|
||||||
this.showSessionModal = true
|
this.showSessionModal = true
|
||||||
@@ -108,13 +124,23 @@ export default {
|
|||||||
else if (playMethod === this.$constants.PlayMethod.LOCAL) return 'Local'
|
else if (playMethod === this.$constants.PlayMethod.LOCAL) return 'Local'
|
||||||
return 'Unknown'
|
return 'Unknown'
|
||||||
},
|
},
|
||||||
async init() {
|
async loadSessions(page) {
|
||||||
console.log(navigator)
|
const data = await this.$axios.$get(`/api/users/${this.user.id}/listening-sessions?page=${page}&itemsPerPage=10`).catch((err) => {
|
||||||
|
|
||||||
this.listeningSessions = await this.$axios.$get(`/api/users/${this.user.id}/listening-sessions`).catch((err) => {
|
|
||||||
console.error('Failed to load listening sesions', err)
|
console.error('Failed to load listening sesions', err)
|
||||||
return []
|
return null
|
||||||
})
|
})
|
||||||
|
if (!data) {
|
||||||
|
this.$toast.error('Failed to load listening sessions')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.numPages = data.numPages
|
||||||
|
this.total = data.total
|
||||||
|
this.currentPage = data.page
|
||||||
|
this.listeningSessions = data.sessions
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
this.loadSessions(0)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
@@ -123,10 +149,11 @@ export default {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style scoped>
|
||||||
.userSessionsTable {
|
.userSessionsTable {
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
border: 1px solid #474747;
|
border: 1px solid #474747;
|
||||||
}
|
}
|
||||||
.userSessionsTable tr:first-child {
|
.userSessionsTable tr:first-child {
|
||||||
|
|||||||
@@ -118,9 +118,9 @@
|
|||||||
<!-- Progress -->
|
<!-- Progress -->
|
||||||
<div v-if="!isPodcast && progressPercent > 0" class="px-4 py-2 mt-4 bg-primary text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0" :class="resettingProgress ? 'opacity-25' : ''">
|
<div v-if="!isPodcast && progressPercent > 0" class="px-4 py-2 mt-4 bg-primary text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0" :class="resettingProgress ? 'opacity-25' : ''">
|
||||||
<p v-if="progressPercent < 1" class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p>
|
<p v-if="progressPercent < 1" class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p>
|
||||||
<p v-else class="text-xs">Finished {{ $formatDate(userProgressFinishedAt, 'MM/dd/yyyy') }}</p>
|
<p v-else class="text-xs">Finished {{ $formatDate(userProgressFinishedAt, dateFormat) }}</p>
|
||||||
<p v-if="progressPercent < 1" class="text-gray-200 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p>
|
<p v-if="progressPercent < 1" class="text-gray-200 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p>
|
||||||
<p class="text-gray-400 text-xs pt-1">Started {{ $formatDate(userProgressStartedAt, 'MM/dd/yyyy') }}</p>
|
<p class="text-gray-400 text-xs pt-1">Started {{ $formatDate(userProgressStartedAt, dateFormat) }}</p>
|
||||||
|
|
||||||
<div v-if="!resettingProgress" class="absolute -top-1.5 -right-1.5 p-1 w-5 h-5 rounded-full bg-bg hover:bg-error border border-primary flex items-center justify-center cursor-pointer" @click.stop="clearProgressClick">
|
<div v-if="!resettingProgress" class="absolute -top-1.5 -right-1.5 p-1 w-5 h-5 rounded-full bg-bg hover:bg-error border border-primary flex items-center justify-center cursor-pointer" @click.stop="clearProgressClick">
|
||||||
<span class="material-icons text-sm">close</span>
|
<span class="material-icons text-sm">close</span>
|
||||||
@@ -226,6 +226,9 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
dateFormat() {
|
||||||
|
return this.$store.state.serverSettings.dateFormat
|
||||||
|
},
|
||||||
showExperimentalFeatures() {
|
showExperimentalFeatures() {
|
||||||
return this.$store.state.showExperimentalFeatures
|
return this.$store.state.showExperimentalFeatures
|
||||||
},
|
},
|
||||||
@@ -534,13 +537,13 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
rssFeedOpen(data) {
|
rssFeedOpen(data) {
|
||||||
if (data.libraryItemId === this.libraryItemId) {
|
if (data.entityId === this.libraryItemId) {
|
||||||
console.log('RSS Feed Opened', data)
|
console.log('RSS Feed Opened', data)
|
||||||
this.rssFeedUrl = data.feedUrl
|
this.rssFeedUrl = data.feedUrl
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
rssFeedClosed(data) {
|
rssFeedClosed(data) {
|
||||||
if (data.libraryItemId === this.libraryItemId) {
|
if (data.entityId === this.libraryItemId) {
|
||||||
console.log('RSS Feed Closed', data)
|
console.log('RSS Feed Closed', data)
|
||||||
this.rssFeedUrl = null
|
this.rssFeedUrl = null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,25 +2,26 @@
|
|||||||
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
||||||
<app-book-shelf-toolbar page="podcast-search" />
|
<app-book-shelf-toolbar page="podcast-search" />
|
||||||
|
|
||||||
<div class="w-full h-full overflow-y-auto p-12 relative">
|
<div class="w-full h-full overflow-y-auto px-2 py-6 sm:px-4 md:p-12 relative">
|
||||||
<div class="w-full max-w-4xl mx-auto flex">
|
<div class="w-full max-w-4xl mx-auto flex">
|
||||||
<form @submit.prevent="submit" class="flex flex-grow">
|
<form @submit.prevent="submit" class="flex flex-grow">
|
||||||
<ui-text-input v-model="searchInput" :disabled="processing" placeholder="Enter search term or RSS feed URL" class="flex-grow mr-2" />
|
<ui-text-input v-model="searchInput" :disabled="processing" placeholder="Enter search term or RSS feed URL" class="flex-grow mr-2 text-sm md:text-base" />
|
||||||
<ui-btn type="submit" :disabled="processing">Submit</ui-btn>
|
<ui-btn type="submit" :disabled="processing" class="hidden md:block">Submit</ui-btn>
|
||||||
|
<ui-btn type="submit" :disabled="processing" class="block md:hidden" small>Submit</ui-btn>
|
||||||
</form>
|
</form>
|
||||||
<ui-file-input ref="fileInput" :accept="'.opml, .txt'" class="mx-2" @change="opmlFileUpload"> Upload OPML File </ui-file-input>
|
<ui-file-input ref="fileInput" :accept="'.opml, .txt'" class="ml-2" @change="opmlFileUpload"> Upload OPML File </ui-file-input>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-full max-w-3xl mx-auto py-4">
|
<div class="w-full max-w-3xl mx-auto py-4">
|
||||||
<p v-if="termSearched && !results.length && !processing" class="text-center text-xl">No podcasts found</p>
|
<p v-if="termSearched && !results.length && !processing" class="text-center text-xl">No podcasts found</p>
|
||||||
<template v-for="podcast in results">
|
<template v-for="podcast in results">
|
||||||
<div :key="podcast.id" class="flex p-1 hover:bg-primary hover:bg-opacity-25 cursor-pointer" @click="selectPodcast(podcast)">
|
<div :key="podcast.id" class="flex p-1 hover:bg-primary hover:bg-opacity-25 cursor-pointer" @click="selectPodcast(podcast)">
|
||||||
<div class="w-24 min-w-24 h-24 bg-primary">
|
<div class="w-20 min-w-20 h-20 md:w-24 md:min-w-24 md:h-24 bg-primary">
|
||||||
<img v-if="podcast.cover" :src="podcast.cover" class="h-full w-full" />
|
<img v-if="podcast.cover" :src="podcast.cover" class="h-full w-full" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow pl-4 max-w-2xl">
|
<div class="flex-grow pl-4 max-w-2xl">
|
||||||
<a :href="podcast.pageUrl" class="text-lg text-gray-200 hover:underline" target="_blank" @click.stop>{{ podcast.title }}</a>
|
<a :href="podcast.pageUrl" class="text-base md:text-lg text-gray-200 hover:underline" target="_blank" @click.stop>{{ podcast.title }}</a>
|
||||||
<p class="text-base text-gray-300 whitespace-nowrap truncate">by {{ podcast.artistName }}</p>
|
<p class="text-sm md:text-base text-gray-300 whitespace-nowrap truncate">by {{ podcast.artistName }}</p>
|
||||||
<p class="text-xs text-gray-400 leading-5">{{ podcast.genres.join(', ') }}</p>
|
<p class="text-xs text-gray-400 leading-5">{{ podcast.genres.join(', ') }}</p>
|
||||||
<p class="text-xs text-gray-400 leading-5">{{ podcast.trackCount }} Episodes</p>
|
<p class="text-xs text-gray-400 leading-5">{{ podcast.trackCount }} Episodes</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,7 +29,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pt-8 text-center">
|
<div class="pt-8 text-center">
|
||||||
<p class="text-xs text-white text-opacity-50 font-mono"><strong>Supported File Types: </strong>{{ inputAccept.join(', ') }}</p>
|
<p class="text-xs text-white text-opacity-50 font-mono mb-4"><strong>Supported File Types: </strong>{{ inputAccept.join(', ') }}</p>
|
||||||
|
|
||||||
|
<p class="text-sm text-white text-opacity-70">Folders with media files will be treated as separate library items. <span v-if="selectedLibraryMediaType === 'book'">If uploading only audio files then each audio file will be treated as a separate audiobook.</span></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Item list header -->
|
<!-- Item list header -->
|
||||||
|
|||||||
@@ -124,6 +124,8 @@ export default class CastPlayer extends EventEmitter {
|
|||||||
|
|
||||||
async seek(time, playWhenReady) {
|
async seek(time, playWhenReady) {
|
||||||
if (!this.player) return
|
if (!this.player) return
|
||||||
|
|
||||||
|
this.playWhenReady = playWhenReady
|
||||||
if (time < this.currentTrack.startOffset || time > this.currentTrack.startOffset + this.currentTrack.duration) {
|
if (time < this.currentTrack.startOffset || time > this.currentTrack.startOffset + this.currentTrack.duration) {
|
||||||
// Change Track
|
// Change Track
|
||||||
var request = buildCastLoadRequest(this.libraryItem, this.coverUrl, this.audioTracks, time, playWhenReady, this.defaultPlaybackRate)
|
var request = buildCastLoadRequest(this.libraryItem, this.coverUrl, this.audioTracks, time, playWhenReady, this.defaultPlaybackRate)
|
||||||
|
|||||||
@@ -71,7 +71,6 @@ export default class LocalAudioPlayer extends EventEmitter {
|
|||||||
console.log(`[LocalPlayer] Track ended - loading next track ${this.currentTrackIndex + 1}`)
|
console.log(`[LocalPlayer] Track ended - loading next track ${this.currentTrackIndex + 1}`)
|
||||||
// Has next track
|
// Has next track
|
||||||
this.currentTrackIndex++
|
this.currentTrackIndex++
|
||||||
this.playWhenReady = true
|
|
||||||
this.startTime = this.currentTrack.startOffset
|
this.startTime = this.currentTrack.startOffset
|
||||||
this.loadCurrentTrack()
|
this.loadCurrentTrack()
|
||||||
} else {
|
} else {
|
||||||
@@ -89,6 +88,7 @@ export default class LocalAudioPlayer extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.emit('stateChange', 'LOADED')
|
this.emit('stateChange', 'LOADED')
|
||||||
|
|
||||||
if (this.playWhenReady) {
|
if (this.playWhenReady) {
|
||||||
this.playWhenReady = false
|
this.playWhenReady = false
|
||||||
this.play()
|
this.play()
|
||||||
@@ -205,10 +205,12 @@ export default class LocalAudioPlayer extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
play() {
|
play() {
|
||||||
|
this.playWhenReady = true
|
||||||
if (this.player) this.player.play()
|
if (this.player) this.player.play()
|
||||||
}
|
}
|
||||||
|
|
||||||
pause() {
|
pause() {
|
||||||
|
this.playWhenReady = false
|
||||||
if (this.player) this.player.pause()
|
if (this.player) this.player.pause()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,8 +231,11 @@ export default class LocalAudioPlayer extends EventEmitter {
|
|||||||
this.player.playbackRate = playbackRate
|
this.player.playbackRate = playbackRate
|
||||||
}
|
}
|
||||||
|
|
||||||
seek(time) {
|
seek(time, playWhenReady) {
|
||||||
if (!this.player) return
|
if (!this.player) return
|
||||||
|
|
||||||
|
this.playWhenReady = playWhenReady
|
||||||
|
|
||||||
if (this.isHlsTranscode) {
|
if (this.isHlsTranscode) {
|
||||||
// Seeking HLS stream
|
// Seeking HLS stream
|
||||||
var offsetTime = time - (this.currentTrack.startOffset || 0)
|
var offsetTime = time - (this.currentTrack.startOffset || 0)
|
||||||
@@ -255,7 +260,6 @@ export default class LocalAudioPlayer extends EventEmitter {
|
|||||||
this.player.currentTime = Math.max(0, offsetTime)
|
this.player.currentTime = Math.max(0, offsetTime)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setVolume(volume) {
|
setVolume(volume) {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export default class PlayerHandler {
|
|||||||
this.currentSessionId = null
|
this.currentSessionId = null
|
||||||
this.startTime = 0
|
this.startTime = 0
|
||||||
|
|
||||||
|
this.failedProgressSyncs = 0
|
||||||
this.lastSyncTime = 0
|
this.lastSyncTime = 0
|
||||||
this.lastSyncedAt = 0
|
this.lastSyncedAt = 0
|
||||||
this.listeningTimeSinceSync = 0
|
this.listeningTimeSinceSync = 0
|
||||||
@@ -176,16 +177,18 @@ export default class PlayerHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
prepareOpenSession(session, playbackRate) { // Session opened on init socket
|
prepareOpenSession(session, playbackRate) { // Session opened on init socket
|
||||||
|
if (!this.player) this.switchPlayer() // Must set player first for open sessions
|
||||||
|
|
||||||
this.libraryItem = session.libraryItem
|
this.libraryItem = session.libraryItem
|
||||||
this.isVideo = session.libraryItem.mediaType === 'video'
|
this.isVideo = session.libraryItem.mediaType === 'video'
|
||||||
this.playWhenReady = false
|
this.playWhenReady = false
|
||||||
this.initialPlaybackRate = playbackRate
|
this.initialPlaybackRate = playbackRate
|
||||||
|
|
||||||
if (!this.player) this.switchPlayer()
|
|
||||||
this.prepareSession(session)
|
this.prepareSession(session)
|
||||||
}
|
}
|
||||||
|
|
||||||
prepareSession(session) {
|
prepareSession(session) {
|
||||||
|
this.failedProgressSyncs = 0
|
||||||
this.startTime = session.currentTime
|
this.startTime = session.currentTime
|
||||||
this.currentSessionId = session.id
|
this.currentSessionId = session.id
|
||||||
this.displayTitle = session.displayTitle
|
this.displayTitle = session.displayTitle
|
||||||
@@ -286,8 +289,15 @@ export default class PlayerHandler {
|
|||||||
currentTime
|
currentTime
|
||||||
}
|
}
|
||||||
this.listeningTimeSinceSync = 0
|
this.listeningTimeSinceSync = 0
|
||||||
this.ctx.$axios.$post(`/api/session/${this.currentSessionId}/sync`, syncData, { timeout: 1000 }).catch((error) => {
|
this.ctx.$axios.$post(`/api/session/${this.currentSessionId}/sync`, syncData, { timeout: 1000 }).then(() => {
|
||||||
|
this.failedProgressSyncs = 0
|
||||||
|
}).catch((error) => {
|
||||||
console.error('Failed to update session progress', error)
|
console.error('Failed to update session progress', error)
|
||||||
|
this.failedProgressSyncs++
|
||||||
|
if (this.failedProgressSyncs >= 2) {
|
||||||
|
this.ctx.showFailedProgressSyncs()
|
||||||
|
this.failedProgressSyncs = 0
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export default (ctx) => {
|
|||||||
var castContext = cast.framework.CastContext.getInstance()
|
var castContext = cast.framework.CastContext.getInstance()
|
||||||
castContext.setOptions({
|
castContext.setOptions({
|
||||||
receiverApplicationId: process.env.chromecastReceiver,
|
receiverApplicationId: process.env.chromecastReceiver,
|
||||||
autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED
|
autoJoinPolicy: chrome.cast ? chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED : null
|
||||||
});
|
});
|
||||||
|
|
||||||
castContext.addEventListener(
|
castContext.addEventListener(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const SupportedFileTypes = {
|
const SupportedFileTypes = {
|
||||||
image: ['png', 'jpg', 'jpeg', 'webp'],
|
image: ['png', 'jpg', 'jpeg', 'webp'],
|
||||||
audio: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'mp4', 'aac', 'wma', 'aiff', 'wav'],
|
audio: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav'],
|
||||||
ebook: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'],
|
ebook: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'],
|
||||||
info: ['nfo'],
|
info: ['nfo'],
|
||||||
text: ['txt'],
|
text: ['txt'],
|
||||||
|
|||||||
@@ -204,7 +204,6 @@ Vue.prototype.$copyToClipboard = (str, ctx) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function xmlToJson(xml) {
|
function xmlToJson(xml) {
|
||||||
const json = {};
|
const json = {};
|
||||||
for (const res of xml.matchAll(/(?:<(\w*)(?:\s[^>]*)*>)((?:(?!<\1).)*)(?:<\/\1>)|<(\w*)(?:\s*)*\/>/gm)) {
|
for (const res of xml.matchAll(/(?:<(\w*)(?:\s[^>]*)*>)((?:(?!<\1).)*)(?:<\/\1>)|<(\w*)(?:\s*)*\/>/gm)) {
|
||||||
|
|||||||
@@ -47,8 +47,13 @@ export async function checkForUpdate() {
|
|||||||
largestVer = verObj
|
largestVer = verObj
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (verObj.version == currVerObj.version) {
|
||||||
|
currVerObj.changelog = release.body
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
})
|
})
|
||||||
if (!largestVer) {
|
if (!largestVer) {
|
||||||
console.error('No valid version tags to compare with')
|
console.error('No valid version tags to compare with')
|
||||||
@@ -59,6 +64,7 @@ export async function checkForUpdate() {
|
|||||||
hasUpdate: largestVer.total > currVerObj.total,
|
hasUpdate: largestVer.total > currVerObj.total,
|
||||||
latestVersion: largestVer.version,
|
latestVersion: largestVer.version,
|
||||||
githubTagUrl: `https://github.com/advplyr/audiobookshelf/releases/tag/v${largestVer.version}`,
|
githubTagUrl: `https://github.com/advplyr/audiobookshelf/releases/tag/v${largestVer.version}`,
|
||||||
currentVersion: currVerObj.version
|
currentVersion: currVerObj.version,
|
||||||
|
currentVersionChangelog: currVerObj.changelog
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# License information
|
||||||
|
|
||||||
|
## Contribution License Agreement
|
||||||
|
|
||||||
|
If you contribute code to this project, you are implicitly allowing your code
|
||||||
|
to be distributed under the MIT license. You are also implicitly verifying that
|
||||||
|
all code is your original work. `</legalese>`
|
||||||
|
|
||||||
|
## Marked
|
||||||
|
|
||||||
|
Copyright (c) 2018+, MarkedJS (https://github.com/markedjs/)
|
||||||
|
Copyright (c) 2011-2018, Christopher Jeffrey (https://github.com/chjj/)
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
|
|
||||||
|
## Markdown
|
||||||
|
|
||||||
|
Copyright © 2004, John Gruber
|
||||||
|
http://daringfireball.net/
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
||||||
|
* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
||||||
|
* Neither the name “Markdown” nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
|
||||||
|
|
||||||
|
This software is provided by the copyright holders and contributors “as is” and any express or implied warranties, including, but not limited to, the implied warranties of merchantability and fitness for a particular purpose are disclaimed. In no event shall the copyright owner or contributors be liable for any direct, indirect, incidental, special, exemplary, or consequential damages (including, but not limited to, procurement of substitute goods or services; loss of use, data, or profits; or business interruption) however caused and on any theory of liability, whether in contract, strict liability, or tort (including negligence or otherwise) arising in any way out of the use of this software, even if advised of the possibility of such damage.
|
||||||
File diff suppressed because one or more lines are too long
+15
-2
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
export const state = () => ({
|
export const state = () => ({
|
||||||
isMobile: false,
|
isMobile: false,
|
||||||
isMobileLandscape: false,
|
isMobileLandscape: false,
|
||||||
@@ -12,7 +11,21 @@ export const state = () => ({
|
|||||||
selectedCollection: null,
|
selectedCollection: null,
|
||||||
selectedAuthor: null,
|
selectedAuthor: null,
|
||||||
isCasting: false, // Actively casting
|
isCasting: false, // Actively casting
|
||||||
isChromecastInitialized: false // Script loaded
|
isChromecastInitialized: false, // Script loaded
|
||||||
|
dateFormats: [
|
||||||
|
{
|
||||||
|
text: 'MM/DD/YYYY',
|
||||||
|
value: 'MM/dd/yyyy'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'DD/MM/YYYY',
|
||||||
|
value: 'dd/MM/yyyy'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'YYYY-MM-DD',
|
||||||
|
value: 'yyyy-MM-dd'
|
||||||
|
}
|
||||||
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
export const getters = {
|
export const getters = {
|
||||||
|
|||||||
+22
-16
@@ -1,19 +1,22 @@
|
|||||||
const defaultTheme = require('tailwindcss/defaultTheme')
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
purge: {
|
purge: {
|
||||||
options: {
|
content: [
|
||||||
safelist: [
|
'components/**/*.vue',
|
||||||
'bg-success',
|
'layouts/**/*.vue',
|
||||||
'bg-red-600',
|
'pages/**/*.vue',
|
||||||
'text-green-500',
|
'templates/**/*.vue',
|
||||||
'py-1.5',
|
'plugins/**/*.js',
|
||||||
'bg-info',
|
'nuxt.config.js'
|
||||||
'px-1.5'
|
],
|
||||||
]
|
safelist: [
|
||||||
}
|
'bg-success',
|
||||||
|
'bg-red-600',
|
||||||
|
'text-green-500',
|
||||||
|
'py-1.5',
|
||||||
|
'bg-info',
|
||||||
|
'px-1.5'
|
||||||
|
],
|
||||||
},
|
},
|
||||||
darkMode: false,
|
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
height: {
|
height: {
|
||||||
@@ -31,6 +34,7 @@ module.exports = {
|
|||||||
'20': '5rem',
|
'20': '5rem',
|
||||||
'24': '6rem',
|
'24': '6rem',
|
||||||
'32': '8rem',
|
'32': '8rem',
|
||||||
|
'40': '10rem',
|
||||||
'48': '12rem',
|
'48': '12rem',
|
||||||
'64': '16rem',
|
'64': '16rem',
|
||||||
'80': '20rem'
|
'80': '20rem'
|
||||||
@@ -39,6 +43,7 @@ module.exports = {
|
|||||||
'6': '1.5rem',
|
'6': '1.5rem',
|
||||||
'12': '3rem',
|
'12': '3rem',
|
||||||
'16': '4rem',
|
'16': '4rem',
|
||||||
|
'20': '5rem',
|
||||||
'24': '6rem',
|
'24': '6rem',
|
||||||
'32': '8rem',
|
'32': '8rem',
|
||||||
'48': '12rem',
|
'48': '12rem',
|
||||||
@@ -73,12 +78,13 @@ module.exports = {
|
|||||||
none: 'none'
|
none: 'none'
|
||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ['Source Sans Pro', ...defaultTheme.fontFamily.sans],
|
sans: ['Source Sans Pro'],
|
||||||
mono: ['Ubuntu Mono', ...defaultTheme.fontFamily.mono],
|
mono: ['Ubuntu Mono'],
|
||||||
book: ['Gentium Book Basic', 'serif']
|
book: ['Gentium Book Basic', 'serif']
|
||||||
},
|
},
|
||||||
fontSize: {
|
fontSize: {
|
||||||
xxs: '0.625rem'
|
xxs: '0.625rem',
|
||||||
|
'2.5xl': '1.6875rem'
|
||||||
},
|
},
|
||||||
zIndex: {
|
zIndex: {
|
||||||
'50': 50
|
'50': 50
|
||||||
|
|||||||
Generated
+22
-2104
File diff suppressed because it is too large
Load Diff
+5
-20
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.0.18",
|
"version": "2.0.24",
|
||||||
"description": "Self-hosted audiobook and podcast server",
|
"description": "Self-hosted audiobook and podcast server",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -11,6 +11,9 @@
|
|||||||
"build-win": "npm run client && pkg -t node16-win-x64 -o ./dist/win/audiobookshelf -C GZip .",
|
"build-win": "npm run client && pkg -t node16-win-x64 -o ./dist/win/audiobookshelf -C GZip .",
|
||||||
"build-linux": "build/linuxpackager",
|
"build-linux": "build/linuxpackager",
|
||||||
"docker": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 --push . -t advplyr/audiobookshelf",
|
"docker": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 --push . -t advplyr/audiobookshelf",
|
||||||
|
"docker-amd64-local": "docker buildx build --platform linux/amd64 --load . -t advplyr/audiobookshelf-amd64-local",
|
||||||
|
"docker-arm64-local": "docker buildx build --platform linux/arm64 --load . -t advplyr/audiobookshelf-arm64-local",
|
||||||
|
"docker-armv7-local": "docker buildx build --platform linux/arm/v7 --load . -t advplyr/audiobookshelf-armv7-local",
|
||||||
"deploy": "node dist/autodeploy"
|
"deploy": "node dist/autodeploy"
|
||||||
},
|
},
|
||||||
"bin": "prod.js",
|
"bin": "prod.js",
|
||||||
@@ -27,28 +30,10 @@
|
|||||||
"author": "advplyr",
|
"author": "advplyr",
|
||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"archiver": "^5.3.0",
|
|
||||||
"axios": "^0.26.1",
|
"axios": "^0.26.1",
|
||||||
"bcryptjs": "^2.4.3",
|
|
||||||
"command-line-args": "^5.2.0",
|
|
||||||
"date-and-time": "^2.0.1",
|
|
||||||
"express": "^4.17.1",
|
"express": "^4.17.1",
|
||||||
"express-fileupload": "^1.2.1",
|
"graceful-fs": "^4.2.10",
|
||||||
"express-rate-limit": "^5.3.0",
|
|
||||||
"fast-sort": "^3.1.1",
|
|
||||||
"fluent-ffmpeg": "^2.1.2",
|
|
||||||
"fs-extra": "^10.0.0",
|
|
||||||
"htmlparser2": "^8.0.1",
|
"htmlparser2": "^8.0.1",
|
||||||
"image-type": "^4.1.0",
|
|
||||||
"jsonwebtoken": "^8.5.1",
|
|
||||||
"libgen": "^2.1.0",
|
|
||||||
"node-cron": "^3.0.0",
|
|
||||||
"node-ffprobe": "^3.0.0",
|
|
||||||
"node-stream-zip": "^1.15.0",
|
|
||||||
"podcast": "^2.0.0",
|
|
||||||
"proper-lockfile": "^4.1.2",
|
|
||||||
"read-chunk": "^3.1.0",
|
|
||||||
"recursive-readdir-async": "^1.1.8",
|
|
||||||
"socket.io": "^4.4.1",
|
"socket.io": "^4.4.1",
|
||||||
"xml2js": "^0.4.23"
|
"xml2js": "^0.4.23"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const optionDefinitions = [
|
|||||||
{ name: 'source', alias: 's', type: String }
|
{ name: 'source', alias: 's', type: String }
|
||||||
]
|
]
|
||||||
|
|
||||||
const commandLineArgs = require('command-line-args')
|
const commandLineArgs = require('./server/libs/commandLineArgs')
|
||||||
const options = commandLineArgs(optionDefinitions)
|
const options = commandLineArgs(optionDefinitions)
|
||||||
|
|
||||||
const Path = require('path')
|
const Path = require('path')
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ docker run -d \
|
|||||||
-e AUDIOBOOKSHELF_GID=100 \
|
-e AUDIOBOOKSHELF_GID=100 \
|
||||||
-p 13378:80 \
|
-p 13378:80 \
|
||||||
-v </path/to/audiobooks>:/audiobooks \
|
-v </path/to/audiobooks>:/audiobooks \
|
||||||
-v </path/to/your/podcasts>:/podcasts \
|
-v </path/to/podcasts>:/podcasts \
|
||||||
-v </path/to/config>:/config \
|
-v </path/to/config>:/config \
|
||||||
-v </path/to/metadata>:/metadata \
|
-v </path/to/metadata>:/metadata \
|
||||||
--name audiobookshelf \
|
--name audiobookshelf \
|
||||||
@@ -90,6 +90,7 @@ docker start audiobookshelf
|
|||||||
### docker-compose.yml ###
|
### docker-compose.yml ###
|
||||||
services:
|
services:
|
||||||
audiobookshelf:
|
audiobookshelf:
|
||||||
|
container_name: audiobookshelf
|
||||||
image: ghcr.io/advplyr/audiobookshelf:latest
|
image: ghcr.io/advplyr/audiobookshelf:latest
|
||||||
environment:
|
environment:
|
||||||
- AUDIOBOOKSHELF_UID=99
|
- AUDIOBOOKSHELF_UID=99
|
||||||
@@ -97,8 +98,8 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- 13378:80
|
- 13378:80
|
||||||
volumes:
|
volumes:
|
||||||
- </path/to/your/audiobooks>:/audiobooks
|
- </path/to/audiobooks>:/audiobooks
|
||||||
- </path/to/your/podcasts>:/podcasts
|
- </path/to/podcasts>:/podcasts
|
||||||
- </path/to/config>:/config
|
- </path/to/config>:/config
|
||||||
- </path/to/metadata>:/metadata
|
- </path/to/metadata>:/metadata
|
||||||
```
|
```
|
||||||
|
|||||||
+5
-3
@@ -1,5 +1,5 @@
|
|||||||
const bcrypt = require('bcryptjs')
|
const bcrypt = require('./libs/bcryptjs')
|
||||||
const jwt = require('jsonwebtoken')
|
const jwt = require('./libs/jsonwebtoken')
|
||||||
const Logger = require('./Logger')
|
const Logger = require('./Logger')
|
||||||
|
|
||||||
class Auth {
|
class Auth {
|
||||||
@@ -20,7 +20,9 @@ class Auth {
|
|||||||
cors(req, res, next) {
|
cors(req, res, next) {
|
||||||
res.header('Access-Control-Allow-Origin', '*')
|
res.header('Access-Control-Allow-Origin', '*')
|
||||||
res.header("Access-Control-Allow-Methods", 'GET, POST, PATCH, PUT, DELETE, OPTIONS')
|
res.header("Access-Control-Allow-Methods", 'GET, POST, PATCH, PUT, DELETE, OPTIONS')
|
||||||
res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Accept-Encoding, Range, Authorization")
|
res.header('Access-Control-Allow-Headers', '*')
|
||||||
|
// TODO: Make sure allowing all headers is not a security concern. It is required for adding custom headers for SSO
|
||||||
|
// res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Accept-Encoding, Range, Authorization")
|
||||||
res.header('Access-Control-Allow-Credentials', true)
|
res.header('Access-Control-Allow-Credentials', true)
|
||||||
if (req.method === 'OPTIONS') {
|
if (req.method === 'OPTIONS') {
|
||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
|
|||||||
+24
-17
@@ -1,6 +1,5 @@
|
|||||||
const Path = require('path')
|
const Path = require('path')
|
||||||
const njodb = require('./njodb')
|
const njodb = require('./libs/njodb')
|
||||||
const jwt = require('jsonwebtoken')
|
|
||||||
const Logger = require('./Logger')
|
const Logger = require('./Logger')
|
||||||
const { version } = require('../package.json')
|
const { version } = require('../package.json')
|
||||||
const LibraryItem = require('./objects/LibraryItem')
|
const LibraryItem = require('./objects/LibraryItem')
|
||||||
@@ -11,6 +10,7 @@ const Author = require('./objects/entities/Author')
|
|||||||
const Series = require('./objects/entities/Series')
|
const Series = require('./objects/entities/Series')
|
||||||
const ServerSettings = require('./objects/settings/ServerSettings')
|
const ServerSettings = require('./objects/settings/ServerSettings')
|
||||||
const PlaybackSession = require('./objects/PlaybackSession')
|
const PlaybackSession = require('./objects/PlaybackSession')
|
||||||
|
const Feed = require('./objects/Feed')
|
||||||
|
|
||||||
class Db {
|
class Db {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -22,6 +22,7 @@ class Db {
|
|||||||
this.CollectionsPath = Path.join(global.ConfigPath, 'collections')
|
this.CollectionsPath = Path.join(global.ConfigPath, 'collections')
|
||||||
this.AuthorsPath = Path.join(global.ConfigPath, 'authors')
|
this.AuthorsPath = Path.join(global.ConfigPath, 'authors')
|
||||||
this.SeriesPath = Path.join(global.ConfigPath, 'series')
|
this.SeriesPath = Path.join(global.ConfigPath, 'series')
|
||||||
|
this.FeedsPath = Path.join(global.ConfigPath, 'feeds')
|
||||||
|
|
||||||
this.libraryItemsDb = new njodb.Database(this.LibraryItemsPath)
|
this.libraryItemsDb = new njodb.Database(this.LibraryItemsPath)
|
||||||
this.usersDb = new njodb.Database(this.UsersPath)
|
this.usersDb = new njodb.Database(this.UsersPath)
|
||||||
@@ -31,6 +32,7 @@ class Db {
|
|||||||
this.collectionsDb = new njodb.Database(this.CollectionsPath, { datastores: 2 })
|
this.collectionsDb = new njodb.Database(this.CollectionsPath, { datastores: 2 })
|
||||||
this.authorsDb = new njodb.Database(this.AuthorsPath)
|
this.authorsDb = new njodb.Database(this.AuthorsPath)
|
||||||
this.seriesDb = new njodb.Database(this.SeriesPath, { datastores: 2 })
|
this.seriesDb = new njodb.Database(this.SeriesPath, { datastores: 2 })
|
||||||
|
this.feedsDb = new njodb.Database(this.FeedsPath, { datastores: 2 })
|
||||||
|
|
||||||
this.libraryItems = []
|
this.libraryItems = []
|
||||||
this.users = []
|
this.users = []
|
||||||
@@ -59,6 +61,7 @@ class Db {
|
|||||||
else if (entityName === 'collection') return this.collectionsDb
|
else if (entityName === 'collection') return this.collectionsDb
|
||||||
else if (entityName === 'author') return this.authorsDb
|
else if (entityName === 'author') return this.authorsDb
|
||||||
else if (entityName === 'series') return this.seriesDb
|
else if (entityName === 'series') return this.seriesDb
|
||||||
|
else if (entityName === 'feed') return this.feedsDb
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +74,7 @@ class Db {
|
|||||||
else if (entityName === 'collection') return 'collections'
|
else if (entityName === 'collection') return 'collections'
|
||||||
else if (entityName === 'author') return 'authors'
|
else if (entityName === 'author') return 'authors'
|
||||||
else if (entityName === 'series') return 'series'
|
else if (entityName === 'series') return 'series'
|
||||||
|
else if (entityName === 'feed') return 'feeds'
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +87,7 @@ class Db {
|
|||||||
this.collectionsDb = new njodb.Database(this.CollectionsPath, { datastores: 2 })
|
this.collectionsDb = new njodb.Database(this.CollectionsPath, { datastores: 2 })
|
||||||
this.authorsDb = new njodb.Database(this.AuthorsPath)
|
this.authorsDb = new njodb.Database(this.AuthorsPath)
|
||||||
this.seriesDb = new njodb.Database(this.SeriesPath, { datastores: 2 })
|
this.seriesDb = new njodb.Database(this.SeriesPath, { datastores: 2 })
|
||||||
|
this.feedsDb = new njodb.Database(this.FeedsPath, { datastores: 2 })
|
||||||
return this.init()
|
return this.init()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,21 +121,6 @@ class Db {
|
|||||||
async init() {
|
async init() {
|
||||||
await this.load()
|
await this.load()
|
||||||
|
|
||||||
// Insert Defaults
|
|
||||||
// var rootUser = this.users.find(u => u.type === 'root')
|
|
||||||
// if (!rootUser) {
|
|
||||||
// var token = await jwt.sign({ userId: 'root' }, process.env.TOKEN_SECRET)
|
|
||||||
// Logger.debug('Generated default token', token)
|
|
||||||
// Logger.info('[Db] Root user created')
|
|
||||||
// await this.insertEntity('user', this.getDefaultUser(token))
|
|
||||||
// } else {
|
|
||||||
// Logger.info(`[Db] Root user exists, pw: ${rootUser.hasPw}`)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// if (!this.libraries.length) {
|
|
||||||
// await this.insertEntity('library', this.getDefaultLibrary())
|
|
||||||
// }
|
|
||||||
|
|
||||||
if (!this.serverSettings) {
|
if (!this.serverSettings) {
|
||||||
this.serverSettings = new ServerSettings()
|
this.serverSettings = new ServerSettings()
|
||||||
await this.insertEntity('settings', this.serverSettings)
|
await this.insertEntity('settings', this.serverSettings)
|
||||||
@@ -278,6 +268,14 @@ class Db {
|
|||||||
return this.updateEntity('settings', this.serverSettings)
|
return this.updateEntity('settings', this.serverSettings)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getAllEntities(entityName) {
|
||||||
|
var entityDb = this.getEntityDb(entityName)
|
||||||
|
return entityDb.select(() => true).then((results) => results.data).catch((error) => {
|
||||||
|
Logger.error(`[DB] Failed to get all ${entityName}`, error)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
insertEntities(entityName, entities) {
|
insertEntities(entityName, entities) {
|
||||||
var entityDb = this.getEntityDb(entityName)
|
var entityDb = this.getEntityDb(entityName)
|
||||||
return entityDb.insert(entities).then((results) => {
|
return entityDb.insert(entities).then((results) => {
|
||||||
@@ -428,6 +426,15 @@ class Db {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getAllSessions() {
|
||||||
|
return this.sessionsDb.select(() => true).then((results) => {
|
||||||
|
return results.data || []
|
||||||
|
}).catch((error) => {
|
||||||
|
Logger.error('[Db] Failed to select sessions', error)
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
selectUserSessions(userId) {
|
selectUserSessions(userId) {
|
||||||
return this.sessionsDb.select((session) => session.userId === userId).then((results) => {
|
return this.sessionsDb.select((session) => session.userId === userId).then((results) => {
|
||||||
return results.data || []
|
return results.data || []
|
||||||
|
|||||||
+2
-2
@@ -1,16 +1,16 @@
|
|||||||
|
const date = require('./libs/dateAndTime')
|
||||||
const { LogLevel } = require('./utils/constants')
|
const { LogLevel } = require('./utils/constants')
|
||||||
|
|
||||||
class Logger {
|
class Logger {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.logLevel = process.env.NODE_ENV === 'production' ? LogLevel.INFO : LogLevel.TRACE
|
this.logLevel = process.env.NODE_ENV === 'production' ? LogLevel.INFO : LogLevel.TRACE
|
||||||
// this.logFileLevel = LogLevel.INFO
|
|
||||||
this.socketListeners = []
|
this.socketListeners = []
|
||||||
|
|
||||||
this.logManager = null
|
this.logManager = null
|
||||||
}
|
}
|
||||||
|
|
||||||
get timestamp() {
|
get timestamp() {
|
||||||
return (new Date()).toISOString()
|
return date.format(new Date(), 'YYYY-MM-DD HH:mm:ss')
|
||||||
}
|
}
|
||||||
|
|
||||||
get levelString() {
|
get levelString() {
|
||||||
|
|||||||
+14
-15
@@ -2,9 +2,9 @@ const Path = require('path')
|
|||||||
const express = require('express')
|
const express = require('express')
|
||||||
const http = require('http')
|
const http = require('http')
|
||||||
const SocketIO = require('socket.io')
|
const SocketIO = require('socket.io')
|
||||||
const fs = require('fs-extra')
|
const fs = require('./libs/fsExtra')
|
||||||
const fileUpload = require('express-fileupload')
|
const fileUpload = require('./libs/expressFileupload')
|
||||||
const rateLimit = require('express-rate-limit')
|
const rateLimit = require('./libs/expressRateLimit')
|
||||||
|
|
||||||
const { version } = require('../package.json')
|
const { version } = require('../package.json')
|
||||||
|
|
||||||
@@ -139,9 +139,11 @@ class Server {
|
|||||||
await this.checkUserMediaProgress() // Remove invalid user item progress
|
await this.checkUserMediaProgress() // Remove invalid user item progress
|
||||||
await this.purgeMetadata() // Remove metadata folders without library item
|
await this.purgeMetadata() // Remove metadata folders without library item
|
||||||
await this.cacheManager.ensureCachePaths()
|
await this.cacheManager.ensureCachePaths()
|
||||||
|
await this.abMergeManager.ensureDownloadDirPath()
|
||||||
|
|
||||||
await this.backupManager.init()
|
await this.backupManager.init()
|
||||||
await this.logManager.init()
|
await this.logManager.init()
|
||||||
|
await this.rssFeedManager.init()
|
||||||
this.podcastManager.init()
|
this.podcastManager.init()
|
||||||
|
|
||||||
if (this.db.serverSettings.scannerDisableWatcher) {
|
if (this.db.serverSettings.scannerDisableWatcher) {
|
||||||
@@ -194,14 +196,14 @@ class Server {
|
|||||||
|
|
||||||
// RSS Feed temp route
|
// RSS Feed temp route
|
||||||
app.get('/feed/:id', (req, res) => {
|
app.get('/feed/:id', (req, res) => {
|
||||||
Logger.info(`[Server] requesting rss feed ${req.params.id}`)
|
Logger.info(`[Server] Requesting rss feed ${req.params.id}`)
|
||||||
this.rssFeedManager.getFeed(req, res)
|
this.rssFeedManager.getFeed(req, res)
|
||||||
})
|
})
|
||||||
app.get('/feed/:id/cover', (req, res) => {
|
app.get('/feed/:id/cover', (req, res) => {
|
||||||
this.rssFeedManager.getFeedCover(req, res)
|
this.rssFeedManager.getFeedCover(req, res)
|
||||||
})
|
})
|
||||||
app.get('/feed/:id/item/*', (req, res) => {
|
app.get('/feed/:id/item/:episodeId/*', (req, res) => {
|
||||||
Logger.info(`[Server] requesting rss feed ${req.params.id}`)
|
Logger.debug(`[Server] Requesting rss feed episode ${req.params.id}/${req.params.episodeId}`)
|
||||||
this.rssFeedManager.getFeedItem(req, res)
|
this.rssFeedManager.getFeedItem(req, res)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -276,6 +278,7 @@ class Server {
|
|||||||
|
|
||||||
// Logs
|
// Logs
|
||||||
socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level))
|
socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level))
|
||||||
|
socket.on('remove_log_listener', () => Logger.removeSocketListener(socket.id))
|
||||||
socket.on('fetch_daily_logs', () => this.logManager.socketRequestDailyLogs(socket))
|
socket.on('fetch_daily_logs', () => this.logManager.socketRequestDailyLogs(socket))
|
||||||
|
|
||||||
socket.on('ping', () => {
|
socket.on('ping', () => {
|
||||||
@@ -285,21 +288,21 @@ class Server {
|
|||||||
socket.emit('pong')
|
socket.emit('pong')
|
||||||
})
|
})
|
||||||
|
|
||||||
socket.on('disconnect', () => {
|
socket.on('disconnect', (reason) => {
|
||||||
Logger.removeSocketListener(socket.id)
|
Logger.removeSocketListener(socket.id)
|
||||||
|
|
||||||
var _client = this.clients[socket.id]
|
var _client = this.clients[socket.id]
|
||||||
if (!_client) {
|
if (!_client) {
|
||||||
Logger.warn('[Server] Socket disconnect, no client ' + socket.id)
|
Logger.warn(`[Server] Socket ${socket.id} disconnect, no client (Reason: ${reason})`)
|
||||||
} else if (!_client.user) {
|
} else if (!_client.user) {
|
||||||
Logger.info('[Server] Unauth socket disconnected ' + socket.id)
|
Logger.info(`[Server] Unauth socket ${socket.id} disconnected (Reason: ${reason})`)
|
||||||
delete this.clients[socket.id]
|
delete this.clients[socket.id]
|
||||||
} else {
|
} else {
|
||||||
Logger.debug('[Server] User Offline ' + _client.user.username)
|
Logger.debug('[Server] User Offline ' + _client.user.username)
|
||||||
this.io.emit('user_offline', _client.user.toJSONForPublic(this.playbackSessionManager.sessions, this.db.libraryItems))
|
this.io.emit('user_offline', _client.user.toJSONForPublic(this.playbackSessionManager.sessions, this.db.libraryItems))
|
||||||
|
|
||||||
const disconnectTime = Date.now() - _client.connected_at
|
const disconnectTime = Date.now() - _client.connected_at
|
||||||
Logger.info(`[Server] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms`)
|
Logger.info(`[Server] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms (Reason: ${reason})`)
|
||||||
delete this.clients[socket.id]
|
delete this.clients[socket.id]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -449,6 +452,7 @@ class Server {
|
|||||||
} else {
|
} else {
|
||||||
Logger.debug(`[Server] User Online ${client.user.username}`)
|
Logger.debug(`[Server] User Online ${client.user.username}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.io.emit('user_online', client.user.toJSONForPublic(this.playbackSessionManager.sessions, this.db.libraryItems))
|
this.io.emit('user_online', client.user.toJSONForPublic(this.playbackSessionManager.sessions, this.db.libraryItems))
|
||||||
|
|
||||||
user.lastSeen = Date.now()
|
user.lastSeen = Date.now()
|
||||||
@@ -468,11 +472,6 @@ class Server {
|
|||||||
initialPayload.usersOnline = this.usersOnline
|
initialPayload.usersOnline = this.usersOnline
|
||||||
}
|
}
|
||||||
client.socket.emit('init', initialPayload)
|
client.socket.emit('init', initialPayload)
|
||||||
|
|
||||||
// Setup log listener for root user
|
|
||||||
if (user.type === 'root') {
|
|
||||||
Logger.addSocketListener(socket, this.db.serverSettings.logLevel || 0)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async stop() {
|
async stop() {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const { reqSupportsWebp } = require('../utils/index')
|
const { reqSupportsWebp } = require('../utils/index')
|
||||||
const { createNewSortInstance } = require('fast-sort')
|
const { createNewSortInstance } = require('../libs/fastSort')
|
||||||
|
|
||||||
const naturalSort = createNewSortInstance({
|
const naturalSort = createNewSortInstance({
|
||||||
comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
|
comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
|
||||||
@@ -63,15 +63,27 @@ class AuthorController {
|
|||||||
// If updating or removing cover image then clear cache
|
// If updating or removing cover image then clear cache
|
||||||
if (payload.imagePath !== undefined && req.author.imagePath && payload.imagePath !== req.author.imagePath) {
|
if (payload.imagePath !== undefined && req.author.imagePath && payload.imagePath !== req.author.imagePath) {
|
||||||
this.cacheManager.purgeImageCache(req.author.id)
|
this.cacheManager.purgeImageCache(req.author.id)
|
||||||
|
|
||||||
if (!payload.imagePath) { // If removing image then remove file
|
if (!payload.imagePath) { // If removing image then remove file
|
||||||
var currentImagePath = req.author.imagePath
|
var currentImagePath = req.author.imagePath
|
||||||
await this.coverManager.removeFile(currentImagePath)
|
await this.coverManager.removeFile(currentImagePath)
|
||||||
|
} else if (payload.imagePath.startsWith('http')) { // Check if image path is a url
|
||||||
|
var imageData = await this.authorFinder.saveAuthorImage(req.author.id, payload.imagePath)
|
||||||
|
if (imageData) {
|
||||||
|
req.author.imagePath = imageData.path
|
||||||
|
req.author.relImagePath = imageData.relPath
|
||||||
|
hasUpdated = hasUpdated || true;
|
||||||
|
} else {
|
||||||
|
req.author.imagePath = null
|
||||||
|
req.author.relImagePath = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var authorNameUpdate = payload.name !== undefined && payload.name !== req.author.name
|
var authorNameUpdate = payload.name !== undefined && payload.name !== req.author.name
|
||||||
|
|
||||||
var hasUpdated = req.author.update(payload)
|
var hasUpdated = req.author.update(payload)
|
||||||
|
|
||||||
if (hasUpdated) {
|
if (hasUpdated) {
|
||||||
if (authorNameUpdate) { // Update author name on all books
|
if (authorNameUpdate) { // Update author name on all books
|
||||||
var itemsWithAuthor = this.db.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasAuthor(req.author.id))
|
var itemsWithAuthor = this.db.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasAuthor(req.author.id))
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
const Path = require('path')
|
const Path = require('path')
|
||||||
const fs = require('fs-extra')
|
const fs = require('../libs/fsExtra')
|
||||||
const filePerms = require('../utils/filePerms')
|
const filePerms = require('../utils/filePerms')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const Library = require('../objects/Library')
|
const Library = require('../objects/Library')
|
||||||
const libraryHelpers = require('../utils/libraryHelpers')
|
const libraryHelpers = require('../utils/libraryHelpers')
|
||||||
const { sort, createNewSortInstance } = require('fast-sort')
|
const { sort, createNewSortInstance } = require('../libs/fastSort')
|
||||||
const naturalSort = createNewSortInstance({
|
const naturalSort = createNewSortInstance({
|
||||||
comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
|
comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
|
||||||
})
|
})
|
||||||
@@ -176,7 +176,8 @@ class LibraryController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle server setting sortingIgnorePrefix
|
// Handle server setting sortingIgnorePrefix
|
||||||
if (sortKey === 'media.metadata.title' && this.db.serverSettings.sortingIgnorePrefix) {
|
const sortByTitle = sortKey === 'media.metadata.title'
|
||||||
|
if (sortByTitle && this.db.serverSettings.sortingIgnorePrefix) {
|
||||||
// BookMetadata.js has titleIgnorePrefix getter
|
// BookMetadata.js has titleIgnorePrefix getter
|
||||||
sortKey += 'IgnorePrefix'
|
sortKey += 'IgnorePrefix'
|
||||||
}
|
}
|
||||||
@@ -186,6 +187,16 @@ class LibraryController {
|
|||||||
var sortArray = [
|
var sortArray = [
|
||||||
{
|
{
|
||||||
[direction]: (li) => {
|
[direction]: (li) => {
|
||||||
|
// When collapsing by series and sorting by title use the series name instead of the book title
|
||||||
|
if (payload.mediaType === 'book' && payload.collapseseries && li.media.metadata.seriesName) {
|
||||||
|
if (sortByTitle) {
|
||||||
|
return li.media.metadata.seriesName
|
||||||
|
} else {
|
||||||
|
// When not sorting by title always show the collapsed series at the end
|
||||||
|
return direction === 'desc' ? -1 : 'zzzz'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Supports dot notation strings i.e. "media.metadata.title"
|
// Supports dot notation strings i.e. "media.metadata.title"
|
||||||
return sortKey.split('.').reduce((a, b) => a[b], li)
|
return sortKey.split('.').reduce((a, b) => a[b], li)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -378,7 +378,7 @@ class LibraryItemController {
|
|||||||
return res.sendStatus(500)
|
return res.sendStatus(500)
|
||||||
}
|
}
|
||||||
|
|
||||||
const feedData = this.rssFeedManager.openFeedForItem(req.user, req.libraryItem, req.body)
|
const feedData = await this.rssFeedManager.openFeedForItem(req.user, req.libraryItem, req.body)
|
||||||
if (feedData.error) {
|
if (feedData.error) {
|
||||||
return res.json({
|
return res.json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -398,7 +398,7 @@ class LibraryItemController {
|
|||||||
return res.sendStatus(500)
|
return res.sendStatus(500)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.rssFeedManager.closeFeedForItem(req.params.id)
|
await this.rssFeedManager.closeFeedForItem(req.params.id)
|
||||||
|
|
||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const { isObject } = require('../utils/index')
|
const { isObject, toNumber } = require('../utils/index')
|
||||||
|
|
||||||
class MeController {
|
class MeController {
|
||||||
constructor() { }
|
constructor() { }
|
||||||
@@ -7,7 +7,22 @@ class MeController {
|
|||||||
// GET: api/me/listening-sessions
|
// GET: api/me/listening-sessions
|
||||||
async getListeningSessions(req, res) {
|
async getListeningSessions(req, res) {
|
||||||
var listeningSessions = await this.getUserListeningSessionsHelper(req.user.id)
|
var listeningSessions = await this.getUserListeningSessionsHelper(req.user.id)
|
||||||
res.json(listeningSessions.slice(0, 10))
|
|
||||||
|
const itemsPerPage = toNumber(req.query.itemsPerPage, 10) || 10
|
||||||
|
const page = toNumber(req.query.page, 0)
|
||||||
|
|
||||||
|
const start = page * itemsPerPage
|
||||||
|
const sessions = listeningSessions.slice(start, start + itemsPerPage)
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
total: listeningSessions.length,
|
||||||
|
numPages: Math.ceil(listeningSessions.length / itemsPerPage),
|
||||||
|
page,
|
||||||
|
itemsPerPage,
|
||||||
|
sessions
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET: api/me/listening-stats
|
// GET: api/me/listening-stats
|
||||||
@@ -16,6 +31,15 @@ class MeController {
|
|||||||
res.json(listeningStats)
|
res.json(listeningStats)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GET: api/me/progress/:id/:episodeId?
|
||||||
|
async getMediaProgress(req, res) {
|
||||||
|
const mediaProgress = req.user.getMediaProgress(req.params.id, req.params.episodeId || null)
|
||||||
|
if (!mediaProgress) {
|
||||||
|
return res.sendStatus(404)
|
||||||
|
}
|
||||||
|
res.json(mediaProgress)
|
||||||
|
}
|
||||||
|
|
||||||
// DELETE: api/me/progress/:id
|
// DELETE: api/me/progress/:id
|
||||||
async removeMediaProgress(req, res) {
|
async removeMediaProgress(req, res) {
|
||||||
var wasRemoved = req.user.removeMediaProgress(req.params.id)
|
var wasRemoved = req.user.removeMediaProgress(req.params.id)
|
||||||
@@ -33,6 +57,7 @@ class MeController {
|
|||||||
if (!libraryItem) {
|
if (!libraryItem) {
|
||||||
return res.status(404).send('Item not found')
|
return res.status(404).send('Item not found')
|
||||||
}
|
}
|
||||||
|
|
||||||
var wasUpdated = req.user.createUpdateMediaProgress(libraryItem, req.body)
|
var wasUpdated = req.user.createUpdateMediaProgress(libraryItem, req.body)
|
||||||
if (wasUpdated) {
|
if (wasUpdated) {
|
||||||
await this.db.updateEntity('user', req.user)
|
await this.db.updateEntity('user', req.user)
|
||||||
@@ -165,6 +190,7 @@ class MeController {
|
|||||||
const updatedLocalMediaProgress = []
|
const updatedLocalMediaProgress = []
|
||||||
var numServerProgressUpdates = 0
|
var numServerProgressUpdates = 0
|
||||||
var localMediaProgress = req.body.localMediaProgress || []
|
var localMediaProgress = req.body.localMediaProgress || []
|
||||||
|
|
||||||
localMediaProgress.forEach(localProgress => {
|
localMediaProgress.forEach(localProgress => {
|
||||||
if (!localProgress.libraryItemId) {
|
if (!localProgress.libraryItemId) {
|
||||||
Logger.error(`[MeController] syncLocalMediaProgress invalid local media progress object`, localProgress)
|
Logger.error(`[MeController] syncLocalMediaProgress invalid local media progress object`, localProgress)
|
||||||
@@ -191,7 +217,8 @@ class MeController {
|
|||||||
Logger.debug(`[MeController] syncLocalMediaProgress server progress is more recent by ${updateTimeDifference}ms - ${mediaProgress.id}`)
|
Logger.debug(`[MeController] syncLocalMediaProgress server progress is more recent by ${updateTimeDifference}ms - ${mediaProgress.id}`)
|
||||||
|
|
||||||
for (const key in localProgress) {
|
for (const key in localProgress) {
|
||||||
if (mediaProgress[key] != undefined && localProgress[key] !== mediaProgress[key]) {
|
// Local media progress ID uses the local library item id and server media progress uses the library item id
|
||||||
|
if (key !== 'id' && mediaProgress[key] != undefined && localProgress[key] !== mediaProgress[key]) {
|
||||||
// Logger.debug(`[MeController] syncLocalMediaProgress key ${key} changed from ${localProgress[key]} to ${mediaProgress[key]} - ${mediaProgress.id}`)
|
// Logger.debug(`[MeController] syncLocalMediaProgress key ${key} changed from ${localProgress[key]} to ${mediaProgress[key]} - ${mediaProgress.id}`)
|
||||||
localProgress[key] = mediaProgress[key]
|
localProgress[key] = mediaProgress[key]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const Path = require('path')
|
const Path = require('path')
|
||||||
const fs = require('fs-extra')
|
const fs = require('../libs/fsExtra')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const filePerms = require('../utils/filePerms')
|
const filePerms = require('../utils/filePerms')
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const axios = require('axios')
|
const axios = require('axios')
|
||||||
const fs = require('fs-extra')
|
const fs = require('../libs/fsExtra')
|
||||||
const Path = require('path')
|
const Path = require('path')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const { parsePodcastRssFeedXml } = require('../utils/podcastUtils')
|
const { parsePodcastRssFeedXml } = require('../utils/podcastUtils')
|
||||||
@@ -103,7 +103,7 @@ class PodcastController {
|
|||||||
Logger.error('Invalid podcast feed request response')
|
Logger.error('Invalid podcast feed request response')
|
||||||
return res.status(500).send('Bad response from feed request')
|
return res.status(500).send('Bad response from feed request')
|
||||||
}
|
}
|
||||||
Logger.debug(`[PdocastController] Podcast feed size ${(data.data.length / 1024 / 1024).toFixed(2)}MB`)
|
Logger.debug(`[PodcastController] Podcast feed size ${(data.data.length / 1024 / 1024).toFixed(2)}MB`)
|
||||||
var payload = await parsePodcastRssFeedXml(data.data, false, includeRaw)
|
var payload = await parsePodcastRssFeedXml(data.data, false, includeRaw)
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
return res.status(500).send('Invalid podcast RSS feed')
|
return res.status(500).send('Invalid podcast RSS feed')
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
|
const { toNumber } = require('../utils/index')
|
||||||
|
|
||||||
class SessionController {
|
class SessionController {
|
||||||
constructor() { }
|
constructor() { }
|
||||||
@@ -7,6 +8,46 @@ class SessionController {
|
|||||||
return res.json(req.session)
|
return res.json(req.session)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getAllWithUserData(req, res) {
|
||||||
|
if (!req.user.isAdminOrUp) {
|
||||||
|
Logger.error(`[SessionController] getAllWithUserData: Non-admin user requested all session data ${req.user.id}/"${req.user.username}"`)
|
||||||
|
return res.sendStatus(404)
|
||||||
|
}
|
||||||
|
|
||||||
|
var listeningSessions = []
|
||||||
|
if (req.query.user) {
|
||||||
|
listeningSessions = await this.getUserListeningSessionsHelper(req.query.user)
|
||||||
|
} else {
|
||||||
|
listeningSessions = await this.getAllSessionsWithUserData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemsPerPage = toNumber(req.query.itemsPerPage, 10) || 10
|
||||||
|
const page = toNumber(req.query.page, 0)
|
||||||
|
|
||||||
|
const start = page * itemsPerPage
|
||||||
|
const sessions = listeningSessions.slice(start, start + itemsPerPage)
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
total: listeningSessions.length,
|
||||||
|
numPages: Math.ceil(listeningSessions.length / itemsPerPage),
|
||||||
|
page,
|
||||||
|
itemsPerPage,
|
||||||
|
sessions
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.query.user) {
|
||||||
|
payload.userFilter = req.query.user
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
getSession(req, res) {
|
||||||
|
var libraryItem = this.db.getLibraryItem(req.session.libraryItemId)
|
||||||
|
var sessionForClient = req.session.toJSONForClient(libraryItem)
|
||||||
|
res.json(sessionForClient)
|
||||||
|
}
|
||||||
|
|
||||||
// POST: api/session/:id/sync
|
// POST: api/session/:id/sync
|
||||||
sync(req, res) {
|
sync(req, res) {
|
||||||
this.playbackSessionManager.syncSessionRequest(req.user, req.session, req.body, res)
|
this.playbackSessionManager.syncSessionRequest(req.user, req.session, req.body, res)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const User = require('../objects/user/User')
|
const User = require('../objects/user/User')
|
||||||
|
|
||||||
const { getId } = require('../utils/index')
|
const { getId, toNumber } = require('../utils/index')
|
||||||
|
|
||||||
class UserController {
|
class UserController {
|
||||||
constructor() { }
|
constructor() { }
|
||||||
@@ -142,8 +142,24 @@ class UserController {
|
|||||||
if (!req.user.isAdminOrUp && req.user.id !== req.params.id) {
|
if (!req.user.isAdminOrUp && req.user.id !== req.params.id) {
|
||||||
return res.sendStatus(403)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
|
|
||||||
var listeningSessions = await this.getUserListeningSessionsHelper(req.params.id)
|
var listeningSessions = await this.getUserListeningSessionsHelper(req.params.id)
|
||||||
res.json(listeningSessions.slice(0, 10))
|
|
||||||
|
const itemsPerPage = toNumber(req.query.itemsPerPage, 10) || 10
|
||||||
|
const page = toNumber(req.query.page, 0)
|
||||||
|
|
||||||
|
const start = page * itemsPerPage
|
||||||
|
const sessions = listeningSessions.slice(start, start + itemsPerPage)
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
total: listeningSessions.length,
|
||||||
|
numPages: Math.ceil(listeningSessions.length / itemsPerPage),
|
||||||
|
page,
|
||||||
|
itemsPerPage,
|
||||||
|
sessions
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET: api/users/:id/listening-stats
|
// GET: api/users/:id/listening-stats
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
const fs = require('fs-extra')
|
const fs = require('../libs/fsExtra')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const Path = require('path')
|
const Path = require('path')
|
||||||
const Audnexus = require('../providers/Audnexus')
|
const Audnexus = require('../providers/Audnexus')
|
||||||
|
|
||||||
const { downloadFile } = require('../utils/fileUtils')
|
const { downloadFile } = require('../utils/fileUtils')
|
||||||
|
const filePerms = require('../utils/filePerms')
|
||||||
|
|
||||||
class AuthorFinder {
|
class AuthorFinder {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -38,7 +39,11 @@ class AuthorFinder {
|
|||||||
async saveAuthorImage(authorId, url) {
|
async saveAuthorImage(authorId, url) {
|
||||||
var authorDir = this.AuthorPath
|
var authorDir = this.AuthorPath
|
||||||
var relAuthorDir = Path.posix.join('/metadata', 'authors')
|
var relAuthorDir = Path.posix.join('/metadata', 'authors')
|
||||||
await fs.ensureDir(authorDir)
|
|
||||||
|
if (!await fs.pathExists(authorDir)) {
|
||||||
|
await fs.ensureDir(authorDir)
|
||||||
|
await filePerms.setDefault(authorDir)
|
||||||
|
}
|
||||||
|
|
||||||
var imageExtension = url.toLowerCase().split('.').pop()
|
var imageExtension = url.toLowerCase().split('.').pop()
|
||||||
var ext = imageExtension === 'png' ? 'png' : 'jpg'
|
var ext = imageExtension === 'png' ? 'png' : 'jpg'
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
const OpenLibrary = require('../providers/OpenLibrary')
|
const OpenLibrary = require('../providers/OpenLibrary')
|
||||||
const LibGen = require('../providers/LibGen')
|
|
||||||
const GoogleBooks = require('../providers/GoogleBooks')
|
const GoogleBooks = require('../providers/GoogleBooks')
|
||||||
const Audible = require('../providers/Audible')
|
const Audible = require('../providers/Audible')
|
||||||
const iTunes = require('../providers/iTunes')
|
const iTunes = require('../providers/iTunes')
|
||||||
@@ -10,7 +9,6 @@ const { levenshteinDistance } = require('../utils/index')
|
|||||||
class BookFinder {
|
class BookFinder {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.openLibrary = new OpenLibrary()
|
this.openLibrary = new OpenLibrary()
|
||||||
this.libGen = new LibGen()
|
|
||||||
this.googleBooks = new GoogleBooks()
|
this.googleBooks = new GoogleBooks()
|
||||||
this.audible = new Audible()
|
this.audible = new Audible()
|
||||||
this.iTunesApi = new iTunes()
|
this.iTunesApi = new iTunes()
|
||||||
@@ -123,20 +121,6 @@ class BookFinder {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async getLibGenResults(title, author, maxTitleDistance, maxAuthorDistance) {
|
|
||||||
var books = await this.libGen.search(title)
|
|
||||||
if (this.verbose) Logger.debug(`LibGen Book Search Results: ${books.length || 0}`)
|
|
||||||
if (books.errorCode) {
|
|
||||||
Logger.error(`LibGen Search Error ${books.errorCode}`)
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
var booksFiltered = this.filterSearchResults(books, title, author, maxTitleDistance, maxAuthorDistance)
|
|
||||||
if (!booksFiltered.length && books.length) {
|
|
||||||
if (this.verbose) Logger.debug(`Search has ${books.length} matches, but no close title matches`)
|
|
||||||
}
|
|
||||||
return booksFiltered
|
|
||||||
}
|
|
||||||
|
|
||||||
async getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance) {
|
async getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance) {
|
||||||
var books = await this.openLibrary.searchTitle(title)
|
var books = await this.openLibrary.searchTitle(title)
|
||||||
if (this.verbose) Logger.debug(`OpenLib Book Search Results: ${books.length || 0}`)
|
if (this.verbose) Logger.debug(`OpenLib Book Search Results: ${books.length || 0}`)
|
||||||
@@ -180,34 +164,29 @@ class BookFinder {
|
|||||||
Logger.debug(`Book Search: title: "${title}", author: "${author}", provider: ${provider}`)
|
Logger.debug(`Book Search: title: "${title}", author: "${author}", provider: ${provider}`)
|
||||||
|
|
||||||
if (provider === 'google') {
|
if (provider === 'google') {
|
||||||
return this.getGoogleBooksResults(title, author)
|
books = await this.getGoogleBooksResults(title, author)
|
||||||
} else if (provider === 'audible') {
|
} else if (provider === 'audible') {
|
||||||
return this.getAudibleResults(title, author, asin)
|
books = await this.getAudibleResults(title, author, asin)
|
||||||
} else if (provider === 'itunes') {
|
} else if (provider === 'itunes') {
|
||||||
return this.getiTunesAudiobooksResults(title, author)
|
books = await this.getiTunesAudiobooksResults(title, author)
|
||||||
} else if (provider === 'libgen') {
|
|
||||||
books = await this.getLibGenResults(title, author, maxTitleDistance, maxAuthorDistance)
|
|
||||||
} else if (provider === 'openlibrary') {
|
} else if (provider === 'openlibrary') {
|
||||||
books = await this.getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance)
|
books = await this.getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance)
|
||||||
} else if (provider === 'all') {
|
|
||||||
var lbBooks = await this.getLibGenResults(title, author, maxTitleDistance, maxAuthorDistance)
|
|
||||||
var olBooks = await this.getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance)
|
|
||||||
books = books.concat(lbBooks, olBooks)
|
|
||||||
} else {
|
} else {
|
||||||
books = await this.getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance)
|
books = await this.getGoogleBooksResults(title, author)
|
||||||
var hasCloseMatch = books.find(b => (b.totalDistance < 2 && b.totalPossibleDistance > 6))
|
|
||||||
if (!hasCloseMatch) {
|
|
||||||
Logger.debug(`Book Search, openlib has no super close matches - get libgen results also`)
|
|
||||||
var lbBooks = await this.getLibGenResults(title, author, maxTitleDistance, maxAuthorDistance)
|
|
||||||
books = books.concat(lbBooks)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!books.length && author && options.fallbackTitleOnly) {
|
|
||||||
Logger.debug(`Book Search, no matches for title and author.. check title only`)
|
|
||||||
return this.search(provider, title, null, options)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!books.length && !options.currentlyTryingCleaned) {
|
||||||
|
var cleanedTitle = this.cleanTitleForCompares(title)
|
||||||
|
var cleanedAuthor = this.cleanAuthorForCompares(author)
|
||||||
|
if (cleanedTitle == title && cleanedAuthor == author) return books
|
||||||
|
|
||||||
|
Logger.debug(`Book Search, no matches.. checking cleaned title and author`)
|
||||||
|
options.currentlyTryingCleaned = true
|
||||||
|
return this.search(provider, cleanedTitle, cleanedAuthor, isbn, asin, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (["google", "audible", "itunes"].includes(provider)) return books
|
||||||
|
|
||||||
return books.sort((a, b) => {
|
return books.sort((a, b) => {
|
||||||
return a.totalDistance - b.totalDistance
|
return a.totalDistance - b.totalDistance
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
Copyright (c) 2012-2014 Chris Talkington, contributors.
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person
|
||||||
|
obtaining a copy of this software and associated documentation
|
||||||
|
files (the "Software"), to deal in the Software without
|
||||||
|
restriction, including without limitation the rights to use,
|
||||||
|
copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the
|
||||||
|
Software is furnished to do so, subject to the following
|
||||||
|
conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be
|
||||||
|
included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
||||||
|
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||||
|
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||||
|
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||||
|
OTHER DEALINGS IN THE SOFTWARE.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
(MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2013 Julian Gruber <julian@juliangruber.com>
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
this software and associated documentation files (the "Software"), to deal in
|
||||||
|
the Software without restriction, including without limitation the rights to
|
||||||
|
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||||
|
of the Software, and to permit persons to whom the Software is furnished to do
|
||||||
|
so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
'use strict'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string | RegExp} a
|
||||||
|
* @param {string | RegExp} b
|
||||||
|
* @param {string} str
|
||||||
|
*/
|
||||||
|
function balanced(a, b, str) {
|
||||||
|
if (a instanceof RegExp) a = maybeMatch(a, str)
|
||||||
|
if (b instanceof RegExp) b = maybeMatch(b, str)
|
||||||
|
|
||||||
|
const r = range(a, b, str)
|
||||||
|
|
||||||
|
return (
|
||||||
|
r && {
|
||||||
|
start: r[0],
|
||||||
|
end: r[1],
|
||||||
|
pre: str.slice(0, r[0]),
|
||||||
|
body: str.slice(r[0] + a.length, r[1]),
|
||||||
|
post: str.slice(r[1] + b.length)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {RegExp} reg
|
||||||
|
* @param {string} str
|
||||||
|
*/
|
||||||
|
function maybeMatch(reg, str) {
|
||||||
|
const m = str.match(reg)
|
||||||
|
return m ? m[0] : null
|
||||||
|
}
|
||||||
|
|
||||||
|
balanced.range = range
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} a
|
||||||
|
* @param {string} b
|
||||||
|
* @param {string} str
|
||||||
|
*/
|
||||||
|
function range(a, b, str) {
|
||||||
|
let begs, beg, left, right, result
|
||||||
|
let ai = str.indexOf(a)
|
||||||
|
let bi = str.indexOf(b, ai + 1)
|
||||||
|
let i = ai
|
||||||
|
|
||||||
|
if (ai >= 0 && bi > 0) {
|
||||||
|
if (a === b) {
|
||||||
|
return [ai, bi]
|
||||||
|
}
|
||||||
|
begs = []
|
||||||
|
left = str.length
|
||||||
|
|
||||||
|
while (i >= 0 && !result) {
|
||||||
|
if (i === ai) {
|
||||||
|
begs.push(i)
|
||||||
|
ai = str.indexOf(a, i + 1)
|
||||||
|
} else if (begs.length === 1) {
|
||||||
|
result = [begs.pop(), bi]
|
||||||
|
} else {
|
||||||
|
beg = begs.pop()
|
||||||
|
if (beg < left) {
|
||||||
|
left = beg
|
||||||
|
right = bi
|
||||||
|
}
|
||||||
|
|
||||||
|
bi = str.indexOf(b, i + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
i = ai < bi && ai >= 0 ? ai : bi
|
||||||
|
}
|
||||||
|
|
||||||
|
if (begs.length) {
|
||||||
|
result = [left, right]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = balanced
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2013 Julian Gruber <julian@juliangruber.com>
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
const balanced = require('../balancedMatch');
|
||||||
|
|
||||||
|
const escSlash = '\0SLASH' + Math.random() + '\0';
|
||||||
|
const escOpen = '\0OPEN' + Math.random() + '\0';
|
||||||
|
const escClose = '\0CLOSE' + Math.random() + '\0';
|
||||||
|
const escComma = '\0COMMA' + Math.random() + '\0';
|
||||||
|
const escPeriod = '\0PERIOD' + Math.random() + '\0';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {number}
|
||||||
|
*/
|
||||||
|
function numeric(str) {
|
||||||
|
return parseInt(str, 10) == str
|
||||||
|
? parseInt(str, 10)
|
||||||
|
: str.charCodeAt(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} str
|
||||||
|
*/
|
||||||
|
function escapeBraces(str) {
|
||||||
|
return str.split('\\\\').join(escSlash)
|
||||||
|
.split('\\{').join(escOpen)
|
||||||
|
.split('\\}').join(escClose)
|
||||||
|
.split('\\,').join(escComma)
|
||||||
|
.split('\\.').join(escPeriod);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} str
|
||||||
|
*/
|
||||||
|
function unescapeBraces(str) {
|
||||||
|
return str.split(escSlash).join('\\')
|
||||||
|
.split(escOpen).join('{')
|
||||||
|
.split(escClose).join('}')
|
||||||
|
.split(escComma).join(',')
|
||||||
|
.split(escPeriod).join('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Basically just str.split(","), but handling cases
|
||||||
|
* where we have nested braced sections, which should be
|
||||||
|
* treated as individual members, like {a,{b,c},d}
|
||||||
|
* @param {string} str
|
||||||
|
*/
|
||||||
|
function parseCommaParts(str) {
|
||||||
|
if (!str)
|
||||||
|
return [''];
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
const m = balanced('{', '}', str);
|
||||||
|
|
||||||
|
if (!m)
|
||||||
|
return str.split(',');
|
||||||
|
|
||||||
|
const { pre, body, post } = m;
|
||||||
|
const p = pre.split(',');
|
||||||
|
|
||||||
|
p[p.length - 1] += '{' + body + '}';
|
||||||
|
const postParts = parseCommaParts(post);
|
||||||
|
if (post.length) {
|
||||||
|
p[p.length - 1] += postParts.shift();
|
||||||
|
p.push.apply(p, postParts);
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push.apply(parts, p);
|
||||||
|
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} str
|
||||||
|
*/
|
||||||
|
function expandTop(str) {
|
||||||
|
if (!str)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
// I don't know why Bash 4.3 does this, but it does.
|
||||||
|
// Anything starting with {} will have the first two bytes preserved
|
||||||
|
// but *only* at the top level, so {},a}b will not expand to anything,
|
||||||
|
// but a{},b}c will be expanded to [a}c,abc].
|
||||||
|
// One could argue that this is a bug in Bash, but since the goal of
|
||||||
|
// this module is to match Bash's rules, we escape a leading {}
|
||||||
|
if (str.slice(0, 2) === '{}') {
|
||||||
|
str = '\\{\\}' + str.slice(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return expand(escapeBraces(str), true).map(unescapeBraces);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} str
|
||||||
|
*/
|
||||||
|
function embrace(str) {
|
||||||
|
return '{' + str + '}';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} el
|
||||||
|
*/
|
||||||
|
function isPadded(el) {
|
||||||
|
return /^-?0\d/.test(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} i
|
||||||
|
* @param {number} y
|
||||||
|
*/
|
||||||
|
function lte(i, y) {
|
||||||
|
return i <= y;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} i
|
||||||
|
* @param {number} y
|
||||||
|
*/
|
||||||
|
function gte(i, y) {
|
||||||
|
return i >= y;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} str
|
||||||
|
* @param {boolean} [isTop]
|
||||||
|
*/
|
||||||
|
function expand(str, isTop) {
|
||||||
|
/** @type {string[]} */
|
||||||
|
const expansions = [];
|
||||||
|
|
||||||
|
const m = balanced('{', '}', str);
|
||||||
|
if (!m) return [str];
|
||||||
|
|
||||||
|
// no need to expand pre, since it is guaranteed to be free of brace-sets
|
||||||
|
const pre = m.pre;
|
||||||
|
const post = m.post.length
|
||||||
|
? expand(m.post, false)
|
||||||
|
: [''];
|
||||||
|
|
||||||
|
if (/\$$/.test(m.pre)) {
|
||||||
|
for (let k = 0; k < post.length; k++) {
|
||||||
|
const expansion = pre + '{' + m.body + '}' + post[k];
|
||||||
|
expansions.push(expansion);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const isNumericSequence = /^-?\d+\.\.-?\d+(?:\.\.-?\d+)?$/.test(m.body);
|
||||||
|
const isAlphaSequence = /^[a-zA-Z]\.\.[a-zA-Z](?:\.\.-?\d+)?$/.test(m.body);
|
||||||
|
const isSequence = isNumericSequence || isAlphaSequence;
|
||||||
|
const isOptions = m.body.indexOf(',') >= 0;
|
||||||
|
if (!isSequence && !isOptions) {
|
||||||
|
// {a},b}
|
||||||
|
if (m.post.match(/,.*\}/)) {
|
||||||
|
str = m.pre + '{' + m.body + escClose + m.post;
|
||||||
|
return expand(str);
|
||||||
|
}
|
||||||
|
return [str];
|
||||||
|
}
|
||||||
|
|
||||||
|
let n;
|
||||||
|
if (isSequence) {
|
||||||
|
n = m.body.split(/\.\./);
|
||||||
|
} else {
|
||||||
|
n = parseCommaParts(m.body);
|
||||||
|
if (n.length === 1) {
|
||||||
|
// x{{a,b}}y ==> x{a}y x{b}y
|
||||||
|
n = expand(n[0], false).map(embrace);
|
||||||
|
if (n.length === 1) {
|
||||||
|
return post.map(function (p) {
|
||||||
|
return m.pre + n[0] + p;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// at this point, n is the parts, and we know it's not a comma set
|
||||||
|
// with a single entry.
|
||||||
|
let N;
|
||||||
|
|
||||||
|
if (isSequence) {
|
||||||
|
const x = numeric(n[0]);
|
||||||
|
const y = numeric(n[1]);
|
||||||
|
const width = Math.max(n[0].length, n[1].length)
|
||||||
|
let incr = n.length == 3
|
||||||
|
? Math.abs(numeric(n[2]))
|
||||||
|
: 1;
|
||||||
|
let test = lte;
|
||||||
|
const reverse = y < x;
|
||||||
|
if (reverse) {
|
||||||
|
incr *= -1;
|
||||||
|
test = gte;
|
||||||
|
}
|
||||||
|
const pad = n.some(isPadded);
|
||||||
|
|
||||||
|
N = [];
|
||||||
|
|
||||||
|
for (let i = x; test(i, y); i += incr) {
|
||||||
|
let c;
|
||||||
|
if (isAlphaSequence) {
|
||||||
|
c = String.fromCharCode(i);
|
||||||
|
if (c === '\\')
|
||||||
|
c = '';
|
||||||
|
} else {
|
||||||
|
c = String(i);
|
||||||
|
if (pad) {
|
||||||
|
const need = width - c.length;
|
||||||
|
if (need > 0) {
|
||||||
|
const z = new Array(need + 1).join('0');
|
||||||
|
if (i < 0)
|
||||||
|
c = '-' + z + c.slice(1);
|
||||||
|
else
|
||||||
|
c = z + c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
N.push(c);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
N = [];
|
||||||
|
|
||||||
|
for (let j = 0; j < n.length; j++) {
|
||||||
|
N.push.apply(N, expand(n[j], false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let j = 0; j < N.length; j++) {
|
||||||
|
for (let k = 0; k < post.length; k++) {
|
||||||
|
const expansion = pre + N[j] + post[k];
|
||||||
|
if (!isTop || isSequence || expansion)
|
||||||
|
expansions.push(expansion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return expansions;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = expandTop;
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
/**
|
||||||
|
* archiver-utils
|
||||||
|
*
|
||||||
|
* Copyright (c) 2012-2014 Chris Talkington, contributors.
|
||||||
|
* Licensed under the MIT license.
|
||||||
|
* https://github.com/archiverjs/node-archiver/blob/master/LICENSE-MIT
|
||||||
|
*/
|
||||||
|
var fs = require('graceful-fs');
|
||||||
|
var path = require('path');
|
||||||
|
|
||||||
|
var flatten = require('./lodash.flatten')
|
||||||
|
var difference = require('./lodash.difference');
|
||||||
|
var union = require('./lodash.union');
|
||||||
|
var isPlainObject = require('./lodash.isplainobject');
|
||||||
|
|
||||||
|
var glob = require('./glob');
|
||||||
|
|
||||||
|
var file = module.exports = {};
|
||||||
|
|
||||||
|
var pathSeparatorRe = /[\/\\]/g;
|
||||||
|
|
||||||
|
// Process specified wildcard glob patterns or filenames against a
|
||||||
|
// callback, excluding and uniquing files in the result set.
|
||||||
|
var processPatterns = function (patterns, fn) {
|
||||||
|
// Filepaths to return.
|
||||||
|
var result = [];
|
||||||
|
// Iterate over flattened patterns array.
|
||||||
|
flatten(patterns).forEach(function (pattern) {
|
||||||
|
// If the first character is ! it should be omitted
|
||||||
|
var exclusion = pattern.indexOf('!') === 0;
|
||||||
|
// If the pattern is an exclusion, remove the !
|
||||||
|
if (exclusion) { pattern = pattern.slice(1); }
|
||||||
|
// Find all matching files for this pattern.
|
||||||
|
var matches = fn(pattern);
|
||||||
|
if (exclusion) {
|
||||||
|
// If an exclusion, remove matching files.
|
||||||
|
result = difference(result, matches);
|
||||||
|
} else {
|
||||||
|
// Otherwise add matching files.
|
||||||
|
result = union(result, matches);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
// True if the file path exists.
|
||||||
|
file.exists = function () {
|
||||||
|
var filepath = path.join.apply(path, arguments);
|
||||||
|
return fs.existsSync(filepath);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Return an array of all file paths that match the given wildcard patterns.
|
||||||
|
file.expand = function (...args) {
|
||||||
|
// If the first argument is an options object, save those options to pass
|
||||||
|
// into the File.prototype.glob.sync method.
|
||||||
|
var options = isPlainObject(args[0]) ? args.shift() : {};
|
||||||
|
// Use the first argument if it's an Array, otherwise convert the arguments
|
||||||
|
// object to an array and use that.
|
||||||
|
var patterns = Array.isArray(args[0]) ? args[0] : args;
|
||||||
|
// Return empty set if there are no patterns or filepaths.
|
||||||
|
if (patterns.length === 0) { return []; }
|
||||||
|
// Return all matching filepaths.
|
||||||
|
var matches = processPatterns(patterns, function (pattern) {
|
||||||
|
// Find all matching files for this pattern.
|
||||||
|
return glob.sync(pattern, options);
|
||||||
|
});
|
||||||
|
// Filter result set?
|
||||||
|
if (options.filter) {
|
||||||
|
matches = matches.filter(function (filepath) {
|
||||||
|
filepath = path.join(options.cwd || '', filepath);
|
||||||
|
try {
|
||||||
|
if (typeof options.filter === 'function') {
|
||||||
|
return options.filter(filepath);
|
||||||
|
} else {
|
||||||
|
// If the file is of the right type and exists, this should work.
|
||||||
|
return fs.statSync(filepath)[options.filter]();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Otherwise, it's probably not the right type.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return matches;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build a multi task "files" object dynamically.
|
||||||
|
file.expandMapping = function (patterns, destBase, options) {
|
||||||
|
options = Object.assign({
|
||||||
|
rename: function (destBase, destPath) {
|
||||||
|
return path.join(destBase || '', destPath);
|
||||||
|
}
|
||||||
|
}, options);
|
||||||
|
var files = [];
|
||||||
|
var fileByDest = {};
|
||||||
|
// Find all files matching pattern, using passed-in options.
|
||||||
|
file.expand(options, patterns).forEach(function (src) {
|
||||||
|
var destPath = src;
|
||||||
|
// Flatten?
|
||||||
|
if (options.flatten) {
|
||||||
|
destPath = path.basename(destPath);
|
||||||
|
}
|
||||||
|
// Change the extension?
|
||||||
|
if (options.ext) {
|
||||||
|
destPath = destPath.replace(/(\.[^\/]*)?$/, options.ext);
|
||||||
|
}
|
||||||
|
// Generate destination filename.
|
||||||
|
var dest = options.rename(destBase, destPath, options);
|
||||||
|
// Prepend cwd to src path if necessary.
|
||||||
|
if (options.cwd) { src = path.join(options.cwd, src); }
|
||||||
|
// Normalize filepaths to be unix-style.
|
||||||
|
dest = dest.replace(pathSeparatorRe, '/');
|
||||||
|
src = src.replace(pathSeparatorRe, '/');
|
||||||
|
// Map correct src path to dest path.
|
||||||
|
if (fileByDest[dest]) {
|
||||||
|
// If dest already exists, push this src onto that dest's src array.
|
||||||
|
fileByDest[dest].src.push(src);
|
||||||
|
} else {
|
||||||
|
// Otherwise create a new src-dest file mapping object.
|
||||||
|
files.push({
|
||||||
|
src: [src],
|
||||||
|
dest: dest,
|
||||||
|
});
|
||||||
|
// And store a reference for later use.
|
||||||
|
fileByDest[dest] = files[files.length - 1];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return files;
|
||||||
|
};
|
||||||
|
|
||||||
|
// reusing bits of grunt's multi-task source normalization
|
||||||
|
file.normalizeFilesArray = function (data) {
|
||||||
|
var files = [];
|
||||||
|
|
||||||
|
data.forEach(function (obj) {
|
||||||
|
var prop;
|
||||||
|
if ('src' in obj || 'dest' in obj) {
|
||||||
|
files.push(obj);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
files = _(files).chain().forEach(function (obj) {
|
||||||
|
if (!('src' in obj) || !obj.src) { return; }
|
||||||
|
// Normalize .src properties to flattened array.
|
||||||
|
if (Array.isArray(obj.src)) {
|
||||||
|
obj.src = flatten(obj.src);
|
||||||
|
} else {
|
||||||
|
obj.src = [obj.src];
|
||||||
|
}
|
||||||
|
}).map(function (obj) {
|
||||||
|
// Build options object, removing unwanted properties.
|
||||||
|
var expandOptions = Object.assign({}, obj);
|
||||||
|
delete expandOptions.src;
|
||||||
|
delete expandOptions.dest;
|
||||||
|
|
||||||
|
// Expand file mappings.
|
||||||
|
if (obj.expand) {
|
||||||
|
return file.expandMapping(obj.src, obj.dest, expandOptions).map(function (mapObj) {
|
||||||
|
// Copy obj properties to result.
|
||||||
|
var result = Object.assign({}, obj);
|
||||||
|
// Make a clone of the orig obj available.
|
||||||
|
result.orig = Object.assign({}, obj);
|
||||||
|
// Set .src and .dest, processing both as templates.
|
||||||
|
result.src = mapObj.src;
|
||||||
|
result.dest = mapObj.dest;
|
||||||
|
// Remove unwanted properties.
|
||||||
|
['expand', 'cwd', 'flatten', 'rename', 'ext'].forEach(function (prop) {
|
||||||
|
delete result[prop];
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy obj properties to result, adding an .orig property.
|
||||||
|
var result = Object.assign({}, obj);
|
||||||
|
// Make a clone of the orig obj available.
|
||||||
|
result.orig = Object.assign({}, obj);
|
||||||
|
|
||||||
|
if ('src' in result) {
|
||||||
|
// Expose an expand-on-demand getter method as .src.
|
||||||
|
Object.defineProperty(result, 'src', {
|
||||||
|
enumerable: true,
|
||||||
|
get: function fn() {
|
||||||
|
var src;
|
||||||
|
if (!('result' in fn)) {
|
||||||
|
src = obj.src;
|
||||||
|
// If src is an array, flatten it. Otherwise, make it into an array.
|
||||||
|
src = Array.isArray(src) ? flatten(src) : [src];
|
||||||
|
// Expand src files, memoizing result.
|
||||||
|
fn.result = file.expand(expandOptions, src);
|
||||||
|
}
|
||||||
|
return fn.result;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('dest' in result) {
|
||||||
|
result.dest = obj.dest;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}).flatten().value();
|
||||||
|
|
||||||
|
return files;
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
The ISC License
|
||||||
|
|
||||||
|
Copyright (c) 2016-2022 Isaac Z. Schlueter and Contributors
|
||||||
|
|
||||||
|
Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
purpose with or without fee is hereby granted, provided that the above
|
||||||
|
copyright notice and this permission notice appear in all copies.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||||
|
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||||
|
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||||
|
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||||
|
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||||
|
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
|
||||||
|
IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
----
|
||||||
|
|
||||||
|
This library bundles a version of the `fs.realpath` and `fs.realpathSync`
|
||||||
|
methods from Node.js v0.10 under the terms of the Node.js MIT license.
|
||||||
|
|
||||||
|
Node's license follows, also included at the header of `old.js` which contains
|
||||||
|
the licensed code:
|
||||||
|
|
||||||
|
Copyright (c) 2016-2022 Joyent, Inc. and other Node contributors.
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a
|
||||||
|
copy of this software and associated documentation files (the "Software"),
|
||||||
|
to deal in the Software without restriction, including without limitation
|
||||||
|
the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||||
|
and/or sell copies of the Software, and to permit persons to whom the
|
||||||
|
Software is furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||||
|
DEALINGS IN THE SOFTWARE.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user