mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-06-05 02:02:44 +02:00
Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e0546c6164 | |||
| be7ccfb209 | |||
| 938a8c6f80 | |||
| 5cd343cb01 | |||
| ab0094a53b | |||
| 2d5e4ebcf0 | |||
| 3171ce5aba | |||
| 0e1692d26b | |||
| e8cd18eac2 | |||
| bf928692d5 | |||
| 792490b629 | |||
| 0d1ff35c5e | |||
| 67e02fddbd | |||
| 09beb6a2ae | |||
| 1bd657f07d | |||
| 2dba17a7ae | |||
| 4900649908 | |||
| c3b33ea37a | |||
| 36bd6e649a | |||
| 4621c78573 | |||
| c88bbf1ce4 | |||
| d37b25a6f6 | |||
| 792268f5ee | |||
| 5f2d6f4d5e | |||
| 1350a91fba | |||
| acf22ca4fa | |||
| 705aac40d7 | |||
| 7456052620 | |||
| 6cd4ec7fce | |||
| 93b8e11378 | |||
| 6161daeef0 | |||
| cfcd351570 | |||
| 514893646a | |||
| e5469cc0f8 | |||
| ec6e70725c | |||
| 160dac109d | |||
| 6be741045f | |||
| f41d6d5c77 | |||
| a5dacd7821 | |||
| 8b12508b0c | |||
| a394f38fe9 | |||
| c4bfa266b0 | |||
| 96232676cb | |||
| b2aab06e01 | |||
| f002532c1e | |||
| 54663f0f01 | |||
| d8df9a9dff | |||
| aeb87c81a1 | |||
| ce88ebb55b | |||
| c7e3f08d39 | |||
| d15264832d |
@@ -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,28 +54,28 @@ 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
|
||||||
cache-from: type=local,src=/tmp/.buildx-cache
|
cache-from: type=local,src=/tmp/.buildx-cache
|
||||||
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
|
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
|
||||||
|
|
||||||
- name: Move cache
|
- name: Move cache
|
||||||
run: |
|
run: |
|
||||||
rm -rf /tmp/.buildx-cache
|
rm -rf /tmp/.buildx-cache
|
||||||
|
|||||||
+15
-5
@@ -2,18 +2,28 @@
|
|||||||
FROM node:16-alpine AS build
|
FROM node:16-alpine AS build
|
||||||
WORKDIR /client
|
WORKDIR /client
|
||||||
COPY /client /client
|
COPY /client /client
|
||||||
RUN npm install
|
RUN npm ci && npm cache clean --force
|
||||||
RUN npm run generate
|
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"]
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
@import './fonts.css';
|
@import './fonts.css';
|
||||||
@import './transitions.css';
|
@import './transitions.css';
|
||||||
@import './draggable.css';
|
@import './draggable.css';
|
||||||
|
@import './defaultStyles.css';
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bookshelf-texture-img: url(/textures/wood_default.jpg);
|
--bookshelf-texture-img: url(/textures/wood_default.jpg);
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/*
|
||||||
|
|
||||||
|
This is for setting regular html styles for places where embedding HTML will be
|
||||||
|
like podcast episode descriptions. Otherwise TailwindCSS will have stripped all default markup.
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
.default-style p {
|
||||||
|
display: block;
|
||||||
|
margin-block-start: 1em;
|
||||||
|
margin-block-end: 1em;
|
||||||
|
margin-inline-start: 0px;
|
||||||
|
margin-inline-end: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.default-style a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: #5985ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.default-style ul {
|
||||||
|
display: block;
|
||||||
|
list-style: circle;
|
||||||
|
list-style-type: disc;
|
||||||
|
margin-block-start: 1em;
|
||||||
|
margin-block-end: 1em;
|
||||||
|
margin-inline-start: 0px;
|
||||||
|
margin-inline-end: 0px;
|
||||||
|
padding-inline-start: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.default-style ol {
|
||||||
|
display: block;
|
||||||
|
list-style: decimal;
|
||||||
|
list-style-type: decimal;
|
||||||
|
margin-block-start: 1em;
|
||||||
|
margin-block-end: 1em;
|
||||||
|
margin-inline-start: 0px;
|
||||||
|
margin-inline-end: 0px;
|
||||||
|
padding-inline-start: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.default-style li {
|
||||||
|
display: list-item;
|
||||||
|
text-align: -webkit-match-parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.default-style li::marker {
|
||||||
|
unicode-bidi: isolate;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-transform: none;
|
||||||
|
text-indent: 0px !important;
|
||||||
|
text-align: start !important;
|
||||||
|
text-align-last: start !important;
|
||||||
|
}
|
||||||
@@ -0,0 +1,563 @@
|
|||||||
|
@charset "UTF-8";
|
||||||
|
|
||||||
|
/*
|
||||||
|
Trix 1.3.1
|
||||||
|
Copyright © 2020 Basecamp, LLC
|
||||||
|
http://trix-editor.org/*/
|
||||||
|
trix-editor {
|
||||||
|
border: 1px solid rgb(75, 85, 99);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgb(35, 35, 35);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.4em 0.6em;
|
||||||
|
min-height: 5em;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar * {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button-group {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border: 1px solid rgb(75, 85, 99);
|
||||||
|
border-top-color: rgb(75, 85, 99);
|
||||||
|
border-bottom-color: rgb(75, 85, 99);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button-group:not(:first-child) {
|
||||||
|
margin-left: 1.5vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-device-width: 768px) {
|
||||||
|
trix-toolbar .trix-button-group:not(:first-child) {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button-group-spacer {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-device-width: 768px) {
|
||||||
|
trix-toolbar .trix-button-group-spacer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button {
|
||||||
|
position: relative;
|
||||||
|
float: left;
|
||||||
|
color: rgba(0, 0, 0, 0.6);
|
||||||
|
font-size: 0.75em;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 0 0.5em;
|
||||||
|
margin: 0;
|
||||||
|
outline: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button:not(:first-child) {
|
||||||
|
border-left: 1px solid rgb(75, 85, 99);
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button.trix-active {
|
||||||
|
background: #bbb;
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button:not(:disabled) {
|
||||||
|
cursor: pointer;
|
||||||
|
background: rgb(35, 35, 35);
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button:disabled {
|
||||||
|
color: rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-device-width: 768px) {
|
||||||
|
trix-toolbar .trix-button {
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
padding: 0 0.3em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon {
|
||||||
|
font-size: inherit;
|
||||||
|
width: 2.6em;
|
||||||
|
height: 1.6em;
|
||||||
|
max-width: calc(0.8em + 4vw);
|
||||||
|
text-indent: -9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-device-width: 768px) {
|
||||||
|
trix-toolbar .trix-button--icon {
|
||||||
|
height: 2em;
|
||||||
|
max-width: calc(0.8em + 3.5vw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon::before {
|
||||||
|
display: inline-block;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
opacity: 0.6;
|
||||||
|
content: "";
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: contain;
|
||||||
|
filter: invert(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-device-width: 768px) {
|
||||||
|
trix-toolbar .trix-button--icon::before {
|
||||||
|
right: 6%;
|
||||||
|
left: 6%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon.trix-active::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon:disabled::before {
|
||||||
|
opacity: 0.125;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon-attach::before {
|
||||||
|
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M16.5%206v11.5a4%204%200%201%201-8%200V5a2.5%202.5%200%200%201%205%200v10.5a1%201%200%201%201-2%200V6H10v9.5a2.5%202.5%200%200%200%205%200V5a4%204%200%201%200-8%200v12.5a5.5%205.5%200%200%200%2011%200V6h-1.5z%22%2F%3E%3C%2Fsvg%3E);
|
||||||
|
top: 8%;
|
||||||
|
bottom: 4%;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon-bold::before {
|
||||||
|
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M15.6%2011.8c1-.7%201.6-1.8%201.6-2.8a4%204%200%200%200-4-4H7v14h7c2.1%200%203.7-1.7%203.7-3.8%200-1.5-.8-2.8-2.1-3.4zM10%207.5h3a1.5%201.5%200%201%201%200%203h-3v-3zm3.5%209H10v-3h3.5a1.5%201.5%200%201%201%200%203z%22%2F%3E%3C%2Fsvg%3E);
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon-italic::before {
|
||||||
|
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M10%205v3h2.2l-3.4%208H6v3h8v-3h-2.2l3.4-8H18V5h-8z%22%2F%3E%3C%2Fsvg%3E);
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon-link::before {
|
||||||
|
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M9.88%2013.7a4.3%204.3%200%200%201%200-6.07l3.37-3.37a4.26%204.26%200%200%201%206.07%200%204.3%204.3%200%200%201%200%206.06l-1.96%201.72a.91.91%200%201%201-1.3-1.3l1.97-1.71a2.46%202.46%200%200%200-3.48-3.48l-3.38%203.37a2.46%202.46%200%200%200%200%203.48.91.91%200%201%201-1.3%201.3z%22%2F%3E%3Cpath%20d%3D%22M4.25%2019.46a4.3%204.3%200%200%201%200-6.07l1.93-1.9a.91.91%200%201%201%201.3%201.3l-1.93%201.9a2.46%202.46%200%200%200%203.48%203.48l3.37-3.38c.96-.96.96-2.52%200-3.48a.91.91%200%201%201%201.3-1.3%204.3%204.3%200%200%201%200%206.07l-3.38%203.38a4.26%204.26%200%200%201-6.07%200z%22%2F%3E%3C%2Fsvg%3E);
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon-strike::before {
|
||||||
|
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M12.73%2014l.28.14c.26.15.45.3.57.44.12.14.18.3.18.5%200%20.3-.15.56-.44.75-.3.2-.76.3-1.39.3A13.52%2013.52%200%200%201%207%2014.95v3.37a10.64%2010.64%200%200%200%204.84.88c1.26%200%202.35-.19%203.28-.56.93-.37%201.64-.9%202.14-1.57s.74-1.45.74-2.32c0-.26-.02-.51-.06-.75h-5.21zm-5.5-4c-.08-.34-.12-.7-.12-1.1%200-1.29.52-2.3%201.58-3.02%201.05-.72%202.5-1.08%204.34-1.08%201.62%200%203.28.34%204.97%201l-1.3%202.93c-1.47-.6-2.73-.9-3.8-.9-.55%200-.96.08-1.2.26-.26.17-.38.38-.38.64%200%20.27.16.52.48.74.17.12.53.3%201.05.53H7.23zM3%2013h18v-2H3v2z%22%2F%3E%3C%2Fsvg%3E);
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon-quote::before {
|
||||||
|
background-image: url(data:image/svg+xml,%3Csvg%20version%3D%221%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M6%2017h3l2-4V7H5v6h3zm8%200h3l2-4V7h-6v6h3z%22%2F%3E%3C%2Fsvg%3E);
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon-heading-1::before {
|
||||||
|
background-image: url(data:image/svg+xml,%3Csvg%20version%3D%221%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M12%209v3H9v7H6v-7H3V9h9zM8%204h14v3h-6v12h-3V7H8V4z%22%2F%3E%3C%2Fsvg%3E);
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon-code::before {
|
||||||
|
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M18.2%2012L15%2015.2l1.4%201.4L21%2012l-4.6-4.6L15%208.8l3.2%203.2zM5.8%2012L9%208.8%207.6%207.4%203%2012l4.6%204.6L9%2015.2%205.8%2012z%22%2F%3E%3C%2Fsvg%3E);
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon-bullet-list::before {
|
||||||
|
background-image: url(data:image/svg+xml,%3Csvg%20version%3D%221%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M4%204a2%202%200%201%200%200%204%202%202%200%200%200%200-4zm0%206a2%202%200%201%200%200%204%202%202%200%200%200%200-4zm0%206a2%202%200%201%200%200%204%202%202%200%200%200%200-4zm4%203h14v-2H8v2zm0-6h14v-2H8v2zm0-8v2h14V5H8z%22%2F%3E%3C%2Fsvg%3E);
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon-number-list::before {
|
||||||
|
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M2%2017h2v.5H3v1h1v.5H2v1h3v-4H2v1zm1-9h1V4H2v1h1v3zm-1%203h1.8L2%2013.1v.9h3v-1H3.2L5%2010.9V10H2v1zm5-6v2h14V5H7zm0%2014h14v-2H7v2zm0-6h14v-2H7v2z%22%2F%3E%3C%2Fsvg%3E);
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon-undo::before {
|
||||||
|
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M12.5%208c-2.6%200-5%201-6.9%202.6L2%207v9h9l-3.6-3.6A8%208%200%200%201%2020%2016l2.4-.8a10.5%2010.5%200%200%200-10-7.2z%22%2F%3E%3C%2Fsvg%3E);
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon-redo::before {
|
||||||
|
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M18.4%2010.6a10.5%2010.5%200%200%200-16.9%204.6L4%2016a8%208%200%200%201%2012.7-3.6L13%2016h9V7l-3.6%203.6z%22%2F%3E%3C%2Fsvg%3E);
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon-decrease-nesting-level::before {
|
||||||
|
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M3%2019h19v-2H3v2zm7-6h12v-2H10v2zm-8.3-.3l2.8%202.9L6%2014.2%204%2012l2-2-1.4-1.5L1%2012l.7.7zM3%205v2h19V5H3z%22%2F%3E%3C%2Fsvg%3E);
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon-increase-nesting-level::before {
|
||||||
|
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M3%2019h19v-2H3v2zm7-6h12v-2H10v2zm-6.9-1L1%2014.2l1.4%201.4L6%2012l-.7-.7-2.8-2.8L1%209.9%203.1%2012zM3%205v2h19V5H3z%22%2F%3E%3C%2Fsvg%3E);
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-dialogs {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-dialog {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
font-size: 0.75em;
|
||||||
|
padding: 15px 10px;
|
||||||
|
background: rgb(48, 48, 48);
|
||||||
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||||
|
border: 1px solid rgb(112, 112, 112);
|
||||||
|
border-radius: 5px;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-input--dialog {
|
||||||
|
font-size: inherit;
|
||||||
|
font-weight: normal;
|
||||||
|
padding: 0.5em 0.8em;
|
||||||
|
margin: 0 10px 0 0;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid #bbb;
|
||||||
|
background-color: rgb(95, 95, 95);
|
||||||
|
box-shadow: none;
|
||||||
|
outline: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-input--dialog.validate:invalid {
|
||||||
|
box-shadow: #F00 0px 0px 1.5px 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--dialog {
|
||||||
|
font-size: inherit;
|
||||||
|
padding: 0.5em;
|
||||||
|
border-bottom: none;
|
||||||
|
color: #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-dialog--link {
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-dialog__link-fields {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-dialog__link-fields .trix-input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-dialog__link-fields .trix-button-group {
|
||||||
|
flex: 0 0 content;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor [data-trix-mutable]:not(.attachment__caption-editor) {
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor [data-trix-mutable]::-moz-selection,
|
||||||
|
trix-editor [data-trix-cursor-target]::-moz-selection,
|
||||||
|
trix-editor [data-trix-mutable] ::-moz-selection {
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor [data-trix-mutable]::selection,
|
||||||
|
trix-editor [data-trix-cursor-target]::selection,
|
||||||
|
trix-editor [data-trix-mutable] ::selection {
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor [data-trix-mutable].attachment__caption-editor:focus::-moz-selection {
|
||||||
|
background: highlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor [data-trix-mutable].attachment__caption-editor:focus::selection {
|
||||||
|
background: highlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor [data-trix-mutable].attachment.attachment--file {
|
||||||
|
box-shadow: 0 0 0 2px highlight;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor [data-trix-mutable].attachment img {
|
||||||
|
box-shadow: 0 0 0 2px highlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .attachment {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .attachment:hover {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .attachment--preview .attachment__caption:hover {
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .attachment__progress {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
height: 20px;
|
||||||
|
top: calc(50% - 10px);
|
||||||
|
left: 5%;
|
||||||
|
width: 90%;
|
||||||
|
opacity: 0.9;
|
||||||
|
transition: opacity 200ms ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .attachment__progress[value="100"] {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .attachment__caption-editor {
|
||||||
|
display: inline-block;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-size: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
color: inherit;
|
||||||
|
text-align: center;
|
||||||
|
vertical-align: top;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .attachment__toolbar {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
top: -0.9em;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .trix-button-group {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .trix-button {
|
||||||
|
position: relative;
|
||||||
|
float: left;
|
||||||
|
color: #666;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 80%;
|
||||||
|
padding: 0 0.8em;
|
||||||
|
margin: 0;
|
||||||
|
outline: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .trix-button:not(:first-child) {
|
||||||
|
border-left: 1px solid #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .trix-button.trix-active {
|
||||||
|
background: #cbeefa;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .trix-button:not(:disabled) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .trix-button--remove {
|
||||||
|
text-indent: -9999px;
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0;
|
||||||
|
outline: none;
|
||||||
|
width: 1.8em;
|
||||||
|
height: 1.8em;
|
||||||
|
line-height: 1.8em;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #fff;
|
||||||
|
border: 2px solid highlight;
|
||||||
|
box-shadow: 1px 1px 6px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .trix-button--remove::before {
|
||||||
|
display: inline-block;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
opacity: 0.7;
|
||||||
|
content: "";
|
||||||
|
background-image: url(data:image/svg+xml,%3Csvg%20height%3D%2224%22%20width%3D%2224%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M19%206.4L17.6%205%2012%2010.6%206.4%205%205%206.4l5.6%205.6L5%2017.6%206.4%2019l5.6-5.6%205.6%205.6%201.4-1.4-5.6-5.6z%22%2F%3E%3Cpath%20d%3D%22M0%200h24v24H0z%22%20fill%3D%22none%22%2F%3E%3C%2Fsvg%3E);
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .trix-button--remove:hover {
|
||||||
|
border-color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .trix-button--remove:hover::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .attachment__metadata-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .attachment__metadata {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 2em;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
max-width: 90%;
|
||||||
|
padding: 0.1em 0.6em;
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: #fff;
|
||||||
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .attachment__metadata .attachment__name {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
|
vertical-align: bottom;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .attachment__metadata .attachment__size {
|
||||||
|
margin-left: 0.2em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content {
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content * {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content h1 {
|
||||||
|
font-size: 1.2em;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content blockquote {
|
||||||
|
border: 0 solid #ccc;
|
||||||
|
border-left-width: 0.3em;
|
||||||
|
margin-left: 0.3em;
|
||||||
|
padding-left: 0.6em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content [dir=rtl] blockquote,
|
||||||
|
.trix-content blockquote[dir=rtl] {
|
||||||
|
border-width: 0;
|
||||||
|
border-right-width: 0.3em;
|
||||||
|
margin-right: 0.3em;
|
||||||
|
padding-right: 0.6em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content li {
|
||||||
|
margin-left: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content [dir=rtl] li {
|
||||||
|
margin-right: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content pre {
|
||||||
|
display: inline-block;
|
||||||
|
width: 100%;
|
||||||
|
vertical-align: top;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
padding: 0.5em;
|
||||||
|
white-space: pre;
|
||||||
|
background-color: #eee;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content .attachment {
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content .attachment a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content .attachment a:hover,
|
||||||
|
.trix-content .attachment a:visited:hover {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content .attachment__caption {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content .attachment__caption .attachment__name+.attachment__size::before {
|
||||||
|
content: ' · ';
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content .attachment--preview {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content .attachment--preview .attachment__caption {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9em;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content .attachment--file {
|
||||||
|
color: #333;
|
||||||
|
line-height: 1;
|
||||||
|
margin: 0 2px 2px 2px;
|
||||||
|
padding: 0.4em 1em;
|
||||||
|
border: 1px solid #bbb;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content .attachment-gallery {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content .attachment-gallery .attachment {
|
||||||
|
flex: 1 0 33%;
|
||||||
|
padding: 0 0.5em;
|
||||||
|
max-width: 33%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content .attachment-gallery.attachment-gallery--2 .attachment,
|
||||||
|
.trix-content .attachment-gallery.attachment-gallery--4 .attachment {
|
||||||
|
flex-basis: 50%;
|
||||||
|
max-width: 50%;
|
||||||
|
}
|
||||||
@@ -147,9 +147,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
toggleBookshelfTexture() {
|
|
||||||
this.$store.dispatch('setBookshelfTexture', 'wood2.png')
|
|
||||||
},
|
|
||||||
cancelSelectionMode() {
|
cancelSelectionMode() {
|
||||||
if (this.processingBatchDelete) return
|
if (this.processingBatchDelete) return
|
||||||
this.$store.commit('setSelectedLibraryItems', [])
|
this.$store.commit('setSelectedLibraryItems', [])
|
||||||
|
|||||||
@@ -2,10 +2,6 @@
|
|||||||
<div id="bookshelf" ref="wrapper" class="w-full max-w-full h-full overflow-y-scroll relative">
|
<div id="bookshelf" ref="wrapper" class="w-full max-w-full h-full overflow-y-scroll relative">
|
||||||
<!-- Cover size widget -->
|
<!-- Cover size widget -->
|
||||||
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
|
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
|
||||||
<!-- Experimental Bookshelf Texture -->
|
|
||||||
<div v-show="showExperimentalFeatures && !isAlternativeBookshelfView" class="fixed bottom-4 right-28 z-40">
|
|
||||||
<div class="rounded-full py-1 bg-primary hover:bg-bg cursor-pointer px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent @click="showBookshelfTextureModal"><p class="text-sm py-0.5">Texture</p></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="loaded && !shelves.length && !search" class="w-full flex flex-col items-center justify-center py-12">
|
<div v-if="loaded && !shelves.length && !search" class="w-full flex flex-col items-center justify-center py-12">
|
||||||
<p class="text-center text-2xl font-book mb-4 py-4">{{ libraryName }} Library is empty!</p>
|
<p class="text-center text-2xl font-book mb-4 py-4">{{ libraryName }} Library is empty!</p>
|
||||||
@@ -100,9 +96,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
showBookshelfTextureModal() {
|
|
||||||
this.$store.commit('globals/setShowBookshelfTextureModal', true)
|
|
||||||
},
|
|
||||||
async init() {
|
async init() {
|
||||||
this.wrapperClientWidth = this.$refs.wrapper ? this.$refs.wrapper.clientWidth : 0
|
this.wrapperClientWidth = this.$refs.wrapper ? this.$refs.wrapper.clientWidth : 0
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,10 @@
|
|||||||
<p>Search results for "{{ searchQuery }}"</p>
|
<p>Search results for "{{ searchQuery }}"</p>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="page === 'authors'">
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<ui-btn v-if="userCanUpdate && authors && authors.length" :loading="processingAuthors" color="primary" small @click="matchAllAuthors">Match All Authors</ui-btn>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -75,7 +79,11 @@ export default {
|
|||||||
default: () => null
|
default: () => null
|
||||||
},
|
},
|
||||||
searchQuery: String,
|
searchQuery: String,
|
||||||
viewMode: String
|
viewMode: String,
|
||||||
|
authors: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -85,13 +93,17 @@ export default {
|
|||||||
keywordFilter: null,
|
keywordFilter: null,
|
||||||
keywordTimeout: null,
|
keywordTimeout: null,
|
||||||
processingSeries: false,
|
processingSeries: false,
|
||||||
processingIssues: false
|
processingIssues: false,
|
||||||
|
processingAuthors: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
userCanDelete() {
|
userCanDelete() {
|
||||||
return this.$store.getters['user/getUserCanDelete']
|
return this.$store.getters['user/getUserCanDelete']
|
||||||
},
|
},
|
||||||
|
userCanUpdate() {
|
||||||
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
|
},
|
||||||
isPodcast() {
|
isPodcast() {
|
||||||
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast'
|
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast'
|
||||||
},
|
},
|
||||||
@@ -147,6 +159,35 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
async matchAllAuthors() {
|
||||||
|
this.processingAuthors = true
|
||||||
|
|
||||||
|
for (const author of this.authors) {
|
||||||
|
const payload = {}
|
||||||
|
if (author.asin) payload.asin = author.asin
|
||||||
|
else payload.q = author.name
|
||||||
|
console.log('Payload', payload, 'author', author)
|
||||||
|
|
||||||
|
this.$eventBus.$emit(`searching-author-${author.id}`, true)
|
||||||
|
|
||||||
|
var response = await this.$axios.$post(`/api/authors/${author.id}/match`, payload).catch((error) => {
|
||||||
|
console.error('Failed', error)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
if (!response) {
|
||||||
|
console.error(`Author ${author.name} not found`)
|
||||||
|
this.$toast.error(`Author ${author.name} not found`)
|
||||||
|
} else if (response.updated) {
|
||||||
|
if (response.author.imagePath) console.log(`Author ${response.author.name} was updated`)
|
||||||
|
else console.log(`Author ${response.author.name} was updated (no image found)`)
|
||||||
|
} else {
|
||||||
|
console.log(`No updates were made for Author ${response.author.name}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$eventBus.$emit(`searching-author-${author.id}`, false)
|
||||||
|
}
|
||||||
|
this.processingAuthors = false
|
||||||
|
},
|
||||||
removeAllIssues() {
|
removeAllIssues() {
|
||||||
if (confirm(`Are you sure you want to remove all library items with issues?\n\nNote: This will not delete any files`)) {
|
if (confirm(`Are you sure you want to remove all library items with issues?\n\nNote: This will not delete any files`)) {
|
||||||
this.processingIssues = true
|
this.processingIssues = true
|
||||||
|
|||||||
@@ -64,6 +64,11 @@ export default {
|
|||||||
title: 'Users',
|
title: 'Users',
|
||||||
path: '/config/users'
|
path: '/config/users'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'config-sessions',
|
||||||
|
title: 'Sessions',
|
||||||
|
path: '/config/sessions'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'config-backups',
|
id: 'config-backups',
|
||||||
title: 'Backups',
|
title: 'Backups',
|
||||||
|
|||||||
@@ -22,13 +22,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
|
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
|
||||||
|
|
||||||
<!-- Experimental Bookshelf Texture -->
|
|
||||||
<div v-show="showExperimentalFeatures && !isAlternativeBookshelfView" class="fixed bottom-4 right-28 z-40">
|
|
||||||
<div class="rounded-full py-1 bg-primary hover:bg-bg cursor-pointer px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent @click="showBookshelfTextureModal">
|
|
||||||
<p class="text-sm py-0.5">Texture</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -206,9 +199,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
showBookshelfTextureModal() {
|
|
||||||
this.$store.commit('globals/setShowBookshelfTextureModal', true)
|
|
||||||
},
|
|
||||||
clearFilter() {
|
clearFilter() {
|
||||||
this.$store.dispatch('user/updateUserSettings', { filterBy: 'all' })
|
this.$store.dispatch('user/updateUserSettings', { filterBy: 'all' })
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -73,6 +73,12 @@
|
|||||||
<p class="text-xs font-mono pb-0.5">{{ numIssues }}</p>
|
<p class="text-xs font-mono pb-0.5">{{ numIssues }}</p>
|
||||||
</div>
|
</div>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -82,6 +88,12 @@ export default {
|
|||||||
return {}
|
return {}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
Source() {
|
||||||
|
return this.$store.state.Source
|
||||||
|
},
|
||||||
|
isMobileLandscape() {
|
||||||
|
return this.$store.state.globals.isMobileLandscape
|
||||||
|
},
|
||||||
isShowingBookshelfToolbar() {
|
isShowingBookshelfToolbar() {
|
||||||
if (!this.$route.name) return false
|
if (!this.$route.name) return false
|
||||||
return this.$route.name.startsWith('library')
|
return this.$route.name.startsWith('library')
|
||||||
@@ -131,6 +143,21 @@ export default {
|
|||||||
},
|
},
|
||||||
numIssues() {
|
numIssues() {
|
||||||
return this.$store.state.libraries.issues || 0
|
return this.$store.state.libraries.issues || 0
|
||||||
|
},
|
||||||
|
versionData() {
|
||||||
|
return this.$store.state.versionData || {}
|
||||||
|
},
|
||||||
|
hasUpdate() {
|
||||||
|
return !!this.versionData.hasUpdate
|
||||||
|
},
|
||||||
|
latestVersion() {
|
||||||
|
return this.versionData.latestVersion
|
||||||
|
},
|
||||||
|
githubTagUrl() {
|
||||||
|
return this.versionData.githubTagUrl
|
||||||
|
},
|
||||||
|
streamLibraryItem() {
|
||||||
|
return this.$store.state.streamLibraryItem
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {},
|
methods: {},
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
<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">
|
||||||
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="absolute left-4 cursor-pointer" :style="{ top: bookCoverPosTop + 'px' }">
|
<div id="videoDock" />
|
||||||
|
<nuxt-link v-if="!playerHandler.isVideo" :to="`/item/${streamLibraryItem.id}`" class="absolute 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 pl-24 mb-6 md:mb-0">
|
<div class="flex items-start mb-6 md:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : '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-base sm:text-lg">
|
||||||
{{ title }}
|
{{ title }}
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
<div 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">{{ podcastAuthor }}</p>
|
||||||
<p v-else-if="authors.length" class="pl-1.5 text-sm sm:text-base">
|
<p v-else-if="authors.length" class="pl-1.5 text-sm sm:text-base">
|
||||||
@@ -25,7 +26,7 @@
|
|||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<span class="material-icons px-2 py-1 md:p-4 cursor-pointer" @click="closePlayer">close</span>
|
<span class="material-icons px-2 py-1 md:p-4 cursor-pointer" @click="closePlayer">close</span>
|
||||||
</div>
|
</div>
|
||||||
<audio-player
|
<player-ui
|
||||||
ref="audioPlayer"
|
ref="audioPlayer"
|
||||||
:chapters="chapters"
|
:chapters="chapters"
|
||||||
:paused="!isPlaying"
|
:paused="!isPlaying"
|
||||||
@@ -70,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: {
|
||||||
@@ -379,6 +381,10 @@ export default {
|
|||||||
},
|
},
|
||||||
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() {
|
||||||
|
|||||||
@@ -101,8 +101,16 @@ export default {
|
|||||||
this.$toast.info('No updates were made for Author')
|
this.$toast.info('No updates were made for Author')
|
||||||
}
|
}
|
||||||
this.searching = false
|
this.searching = false
|
||||||
|
},
|
||||||
|
setSearching(isSearching) {
|
||||||
|
this.searching = isSearching
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {
|
||||||
|
this.$eventBus.$on(`searching-author-${this.authorId}`, this.setSearching)
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
this.$eventBus.$off(`searching-author-${this.authorId}`, this.setSearching)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="wrapper" class="w-full p-2 border border-white border-opacity-10 rounded">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="w-16 min-w-16">
|
||||||
|
<div class="w-full h-16 bg-primary">
|
||||||
|
<img v-if="image" :src="image" class="w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-400 text-xxs pt-1 text-center">{{ numEpisodes }} Episodes</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow pl-2" :style="{ maxWidth: detailsWidth + 'px' }">
|
||||||
|
<p class="mb-1">{{ title }}</p>
|
||||||
|
<p class="text-xs mb-1 text-gray-300">{{ author }}</p>
|
||||||
|
<p class="text-xs mb-2 text-gray-200">{{ description }}</p>
|
||||||
|
<p class="text-xs truncate text-blue-200">
|
||||||
|
Folder: <span class="font-mono">{{ folderPath }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
feed: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
},
|
||||||
|
libraryFolderPath: String
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
width: 900
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
title() {
|
||||||
|
return this.metadata.title || 'No Title'
|
||||||
|
},
|
||||||
|
image() {
|
||||||
|
return this.metadata.imageUrl
|
||||||
|
},
|
||||||
|
description() {
|
||||||
|
return this.metadata.description || ''
|
||||||
|
},
|
||||||
|
author() {
|
||||||
|
return this.metadata.author || ''
|
||||||
|
},
|
||||||
|
metadata() {
|
||||||
|
return this.feed || {}
|
||||||
|
},
|
||||||
|
numEpisodes() {
|
||||||
|
return this.feed.numEpisodes || 0
|
||||||
|
},
|
||||||
|
folderPath() {
|
||||||
|
if (!this.libraryFolderPath) return ''
|
||||||
|
return `${this.libraryFolderPath}\\${this.$sanitizeFilename(this.title)}`
|
||||||
|
},
|
||||||
|
detailsWidth() {
|
||||||
|
return this.width - 85
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {},
|
||||||
|
updated() {
|
||||||
|
this.width = this.$refs.wrapper.clientWidth
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.width = this.$refs.wrapper.clientWidth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -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']
|
||||||
|
|||||||
@@ -65,6 +65,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center my-2 max-w-md">
|
||||||
|
<div class="w-1/2">
|
||||||
|
<p>Can Access Explicit Content</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-1/2">
|
||||||
|
<ui-toggle-switch v-model="newUser.permissions.accessExplicitContent" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center my-2 max-w-md">
|
<div class="flex items-center my-2 max-w-md">
|
||||||
<div class="w-1/2">
|
<div class="w-1/2">
|
||||||
<p>Can Access All Libraries</p>
|
<p>Can Access All Libraries</p>
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
<template>
|
|
||||||
<modals-modal v-model="show" name="textures" :width="'40vw'" :height="'unset'" :bg-opacity="10" :processing="processing">
|
|
||||||
<template #outer>
|
|
||||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
|
||||||
<p class="font-book text-3xl text-white truncate">Bookshelf Texture</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div class="px-4 w-full max-w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300" @mousedown.prevent @mouseup.prevent @mousemove.prevent>
|
|
||||||
<h1 class="text-2xl mb-2">Select a bookshelf texture (For testing only)</h1>
|
|
||||||
<div class="overflow-y-hidden overflow-x-auto">
|
|
||||||
<div class="flex -mx-1">
|
|
||||||
<template v-for="texture in textures">
|
|
||||||
<div :key="texture" class="relative mx-1" style="height: 180px; width: 180px; min-width: 180px" @mousedown.prevent @mouseup.prevent>
|
|
||||||
<img :src="texture" class="h-full object-cover cursor-pointer" @click="setTexture(texture)" />
|
|
||||||
<div v-if="texture === selectedBookshelfTexture" class="absolute top-0 left-0 flex items-center justify-center w-full h-full bg-black bg-opacity-10">
|
|
||||||
<span class="material-icons text-4xl text-success">check</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- <div class="flex pt-4">
|
|
||||||
<div class="flex-grow" />
|
|
||||||
<ui-btn color="success" type="submit">Submit</ui-btn>
|
|
||||||
</div> -->
|
|
||||||
</div>
|
|
||||||
</modals-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
textures: ['/textures/wood_default.jpg', '/textures/wood1.png', '/textures/wood2.png', '/textures/wood3.png', '/textures/wood4.png', '/textures/leather1.jpg'],
|
|
||||||
processing: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
show: {
|
|
||||||
handler(newVal) {
|
|
||||||
if (newVal) {
|
|
||||||
this.init()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
show: {
|
|
||||||
get() {
|
|
||||||
return this.$store.state.globals.showBookshelfTextureModal
|
|
||||||
},
|
|
||||||
set(val) {
|
|
||||||
this.$store.commit('globals/setShowBookshelfTextureModal', val)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
selectedBookshelfTexture() {
|
|
||||||
return this.$store.state.selectedBookshelfTexture
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
init() {},
|
|
||||||
setTexture(img) {
|
|
||||||
this.$store.dispatch('setBookshelfTexture', img)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<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 class="absolute top-5 right-5 h-12 w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300">
|
||||||
|
<span class="material-icons text-4xl">close</span>
|
||||||
|
</div>
|
||||||
|
<div ref="content" class="text-white">
|
||||||
|
<form v-if="selectedSeries" @submit.prevent="submitSeriesForm">
|
||||||
|
<div class="bg-bg rounded-lg p-8" @click.stop>
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex-grow p-1 min-w-80">
|
||||||
|
<ui-input-dropdown ref="newSeriesSelect" v-model="selectedSeries.name" :items="existingSeriesNames" :disabled="!selectedSeries.id.startsWith('new')" label="Series Name" />
|
||||||
|
</div>
|
||||||
|
<div class="w-40 p-1">
|
||||||
|
<ui-text-input-with-label v-model="selectedSeries.sequence" label="Sequence" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end mt-2 p-1">
|
||||||
|
<ui-btn type="submit">Save</ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: Boolean,
|
||||||
|
selectedSeries: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
},
|
||||||
|
existingSeriesNames: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
el: null,
|
||||||
|
content: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
show(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.$nextTick(this.setShow)
|
||||||
|
} else {
|
||||||
|
this.setHide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
submitSeriesForm() {
|
||||||
|
if (this.$refs.newSeriesSelect) {
|
||||||
|
this.$refs.newSeriesSelect.blur()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$emit('submit')
|
||||||
|
},
|
||||||
|
clickClose() {
|
||||||
|
this.show = false
|
||||||
|
},
|
||||||
|
hotkey(action) {
|
||||||
|
if (action === this.$hotkeys.Modal.CLOSE) {
|
||||||
|
this.show = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setShow() {
|
||||||
|
if (!this.el || !this.content) {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
if (!this.el || !this.content) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.appendChild(this.el)
|
||||||
|
setTimeout(() => {
|
||||||
|
this.content.style.transform = 'scale(1)'
|
||||||
|
}, 10)
|
||||||
|
document.documentElement.classList.add('modal-open')
|
||||||
|
|
||||||
|
this.$store.commit('setInnerModalOpen', true)
|
||||||
|
this.$eventBus.$on('modal-hotkey', this.hotkey)
|
||||||
|
},
|
||||||
|
setHide() {
|
||||||
|
if (this.content) this.content.style.transform = 'scale(0)'
|
||||||
|
if (this.el) this.el.remove()
|
||||||
|
document.documentElement.classList.remove('modal-open')
|
||||||
|
|
||||||
|
this.$store.commit('setInnerModalOpen', false)
|
||||||
|
this.$eventBus.$off('modal-hotkey', this.hotkey)
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
this.el = this.$refs.wrapper
|
||||||
|
this.content = this.$refs.content
|
||||||
|
if (this.content && this.el) {
|
||||||
|
this.el.classList.remove('hidden')
|
||||||
|
this.el.classList.add('flex')
|
||||||
|
this.content.style.transform = 'scale(0)'
|
||||||
|
this.content.style.transition = 'transform 0.25s cubic-bezier(0.16, 1, 0.3, 1)'
|
||||||
|
this.el.style.opacity = 1
|
||||||
|
this.el.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
<template>
|
||||||
|
<modals-modal v-model="show" name="listening-session-modal" :width="700" :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">Session {{ _session.id }}</p>
|
||||||
|
</div>
|
||||||
|
</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 class="flex items-center">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
|
||||||
|
|
||||||
|
<div class="flex flex-wrap mb-4">
|
||||||
|
<div class="w-full md:w-2/3">
|
||||||
|
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mb-2">Details</p>
|
||||||
|
<div class="flex items-center -mx-1 mb-1">
|
||||||
|
<div class="w-40 px-1 text-gray-200">Started At</div>
|
||||||
|
<div class="px-1">
|
||||||
|
{{ $formatDate(_session.startedAt, 'MMMM do, yyyy HH:mm') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center -mx-1 mb-1">
|
||||||
|
<div class="w-40 px-1 text-gray-200">Updated At</div>
|
||||||
|
<div class="px-1">
|
||||||
|
{{ $formatDate(_session.updatedAt, 'MMMM do, yyyy HH:mm') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center -mx-1 mb-1">
|
||||||
|
<div class="w-40 px-1 text-gray-200">Listened for</div>
|
||||||
|
<div class="px-1">
|
||||||
|
{{ $elapsedPrettyExtended(_session.timeListening) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center -mx-1 mb-1">
|
||||||
|
<div class="w-40 px-1 text-gray-200">Start Time</div>
|
||||||
|
<div class="px-1">
|
||||||
|
{{ $secondsToTimestamp(_session.startTime) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center -mx-1 mb-1">
|
||||||
|
<div class="w-40 px-1 text-gray-200">Last Time</div>
|
||||||
|
<div class="px-1">
|
||||||
|
{{ $secondsToTimestamp(_session.currentTime) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">Item</p>
|
||||||
|
<div v-if="_session.libraryId" class="flex items-center -mx-1 mb-1">
|
||||||
|
<div class="w-40 px-1 text-gray-200">Library Id</div>
|
||||||
|
<div class="px-1">
|
||||||
|
{{ _session.libraryId }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center -mx-1 mb-1">
|
||||||
|
<div class="w-40 px-1 text-gray-200">Library Item Id</div>
|
||||||
|
<div class="px-1">
|
||||||
|
{{ _session.libraryItemId }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="_session.episodeId" class="flex items-center -mx-1 mb-1">
|
||||||
|
<div class="w-40 px-1 text-gray-200">Episode Id</div>
|
||||||
|
<div class="px-1">
|
||||||
|
{{ _session.episodeId }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center -mx-1 mb-1">
|
||||||
|
<div class="w-40 px-1 text-gray-200">Media Type</div>
|
||||||
|
<div class="px-1">
|
||||||
|
{{ _session.mediaType }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center -mx-1 mb-1">
|
||||||
|
<div class="w-40 px-1 text-gray-200">Duration</div>
|
||||||
|
<div class="px-1">
|
||||||
|
{{ $elapsedPretty(_session.duration) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-full md:w-1/3">
|
||||||
|
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mb-2 mt-6 md:mt-0">User</p>
|
||||||
|
<p class="mb-1">{{ _session.userId }}</p>
|
||||||
|
|
||||||
|
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">Media Player</p>
|
||||||
|
<p class="mb-1">{{ playMethodName }}</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="deviceInfo.ipAddress" class="mb-1">{{ deviceInfo.ipAddress }}</p>
|
||||||
|
<p v-if="osDisplayName" class="mb-1">{{ osDisplayName }}</p>
|
||||||
|
<p v-if="deviceInfo.browserName" class="mb-1">{{ deviceInfo.browserName }}</p>
|
||||||
|
<p v-if="clientDisplayName" class="mb-1">{{ clientDisplayName }}</p>
|
||||||
|
<p v-if="deviceInfo.sdkVersion" class="mb-1">SDK Version: {{ deviceInfo.sdkVersion }}</p>
|
||||||
|
<p v-if="deviceInfo.deviceType" class="mb-1">Type: {{ deviceInfo.deviceType }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</modals-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: Boolean,
|
||||||
|
session: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_session() {
|
||||||
|
return this.session || {}
|
||||||
|
},
|
||||||
|
deviceInfo() {
|
||||||
|
return this._session.deviceInfo || {}
|
||||||
|
},
|
||||||
|
osDisplayName() {
|
||||||
|
if (!this.deviceInfo.osName) return null
|
||||||
|
return `${this.deviceInfo.osName} ${this.deviceInfo.osVersion}`
|
||||||
|
},
|
||||||
|
clientDisplayName() {
|
||||||
|
if (!this.deviceInfo.manufacturer || !this.deviceInfo.model) return null
|
||||||
|
return `${this.deviceInfo.manufacturer} ${this.deviceInfo.model}`
|
||||||
|
},
|
||||||
|
playMethodName() {
|
||||||
|
const playMethod = this._session.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'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -104,6 +104,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
hotkey(action) {
|
hotkey(action) {
|
||||||
|
if (this.$store.state.innerModalOpen) return
|
||||||
if (action === this.$hotkeys.Modal.CLOSE) {
|
if (action === this.$hotkeys.Modal.CLOSE) {
|
||||||
this.show = false
|
this.show = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-full overflow-hidden px-4 py-6 relative">
|
<div id="match-wrapper" class="w-full h-full overflow-hidden px-4 py-6 relative">
|
||||||
<form @submit.prevent="submitSearch">
|
<form @submit.prevent="submitSearch">
|
||||||
<div class="flex items-center justify-start -mx-1 h-20">
|
<div class="flex items-center justify-start -mx-1 h-20">
|
||||||
<div class="w-40 px-1">
|
<div class="w-40 px-1">
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
<div v-if="selectedMatch.series" class="flex items-center py-2">
|
<div v-if="selectedMatch.series" class="flex items-center py-2">
|
||||||
<ui-checkbox v-model="selectedMatchUsage.series" />
|
<ui-checkbox v-model="selectedMatchUsage.series" />
|
||||||
<div class="flex-grow ml-4">
|
<div class="flex-grow ml-4">
|
||||||
<ui-text-input-with-label v-model="selectedMatch.series" :disabled="!selectedMatchUsage.series" label="Series" />
|
<widgets-series-input-widget v-model="selectedMatch.series" />
|
||||||
<p v-if="mediaMetadata.seriesName" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.seriesName || '' }}</p>
|
<p v-if="mediaMetadata.seriesName" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.seriesName || '' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,6 +95,27 @@
|
|||||||
<ui-checkbox v-model="selectedMatchUsage.volumeNumber" />
|
<ui-checkbox v-model="selectedMatchUsage.volumeNumber" />
|
||||||
<ui-text-input-with-label v-model="selectedMatch.volumeNumber" :disabled="!selectedMatchUsage.volumeNumber" label="Volume Number" class="flex-grow ml-4" />
|
<ui-text-input-with-label v-model="selectedMatch.volumeNumber" :disabled="!selectedMatchUsage.volumeNumber" label="Volume Number" class="flex-grow ml-4" />
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="selectedMatch.genres" class="flex items-center py-2">
|
||||||
|
<ui-checkbox v-model="selectedMatchUsage.genres" />
|
||||||
|
<div class="flex-grow ml-4">
|
||||||
|
<ui-text-input-with-label v-model="selectedMatch.genres" :disabled="!selectedMatchUsage.genres" label="Genres" />
|
||||||
|
<p v-if="mediaMetadata.genresList" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.genresList || '' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedMatch.tags" class="flex items-center py-2">
|
||||||
|
<ui-checkbox v-model="selectedMatchUsage.tags" />
|
||||||
|
<div class="flex-grow ml-4">
|
||||||
|
<ui-text-input-with-label v-model="selectedMatch.tags" :disabled="!selectedMatchUsage.tags" label="Tags" />
|
||||||
|
<p v-if="mediaMetadata.tagsList" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.tagsList || '' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedMatch.language" class="flex items-center py-2">
|
||||||
|
<ui-checkbox v-model="selectedMatchUsage.language" />
|
||||||
|
<div class="flex-grow ml-4">
|
||||||
|
<ui-text-input-with-label v-model="selectedMatch.language" :disabled="!selectedMatchUsage.language" label="Language" />
|
||||||
|
<p v-if="mediaMetadata.language" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.language || '' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div v-if="selectedMatch.isbn" class="flex items-center py-2">
|
<div v-if="selectedMatch.isbn" class="flex items-center py-2">
|
||||||
<ui-checkbox v-model="selectedMatchUsage.isbn" />
|
<ui-checkbox v-model="selectedMatchUsage.isbn" />
|
||||||
<div class="flex-grow ml-4">
|
<div class="flex-grow ml-4">
|
||||||
@@ -177,6 +198,10 @@ export default {
|
|||||||
publishedYear: true,
|
publishedYear: true,
|
||||||
series: true,
|
series: true,
|
||||||
volumeNumber: true,
|
volumeNumber: true,
|
||||||
|
genres: true,
|
||||||
|
tags: true,
|
||||||
|
language: true,
|
||||||
|
explicit: true,
|
||||||
asin: true,
|
asin: true,
|
||||||
isbn: true,
|
isbn: true,
|
||||||
// Podcast specific
|
// Podcast specific
|
||||||
@@ -204,6 +229,22 @@ export default {
|
|||||||
this.$emit('update:processing', val)
|
this.$emit('update:processing', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
seriesItems: {
|
||||||
|
get() {
|
||||||
|
return this.selectedMatch.series.map((se) => {
|
||||||
|
return {
|
||||||
|
id: `new-${Math.floor(Math.random() * 10000)}`,
|
||||||
|
displayName: se.volumeNumber ? `${se.series} #${se.volumeNumber}` : se.series,
|
||||||
|
name: se.series,
|
||||||
|
sequence: se.volumeNumber || ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
console.log('set series items', val)
|
||||||
|
this.selectedMatch.series = val
|
||||||
|
}
|
||||||
|
},
|
||||||
bookCoverAspectRatio() {
|
bookCoverAspectRatio() {
|
||||||
return this.$store.getters['getBookCoverAspectRatio']
|
return this.$store.getters['getBookCoverAspectRatio']
|
||||||
},
|
},
|
||||||
@@ -294,6 +335,10 @@ export default {
|
|||||||
publishedYear: true,
|
publishedYear: true,
|
||||||
series: true,
|
series: true,
|
||||||
volumeNumber: true,
|
volumeNumber: true,
|
||||||
|
genres: true,
|
||||||
|
tags: true,
|
||||||
|
language: true,
|
||||||
|
explicit: true,
|
||||||
asin: true,
|
asin: true,
|
||||||
isbn: true,
|
isbn: true,
|
||||||
// Podcast specific
|
// Podcast specific
|
||||||
@@ -320,36 +365,75 @@ 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.series.length) {
|
||||||
|
delete match.series
|
||||||
|
} else {
|
||||||
|
match.series = match.series.map((se) => {
|
||||||
|
return {
|
||||||
|
id: `new-${Math.floor(Math.random() * 10000)}`,
|
||||||
|
displayName: se.volumeNumber ? `${se.series} #${se.volumeNumber}` : se.series,
|
||||||
|
name: se.series,
|
||||||
|
sequence: se.volumeNumber || ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.selectedMatch = match
|
this.selectedMatch = match
|
||||||
},
|
},
|
||||||
buildMatchUpdatePayload() {
|
buildMatchUpdatePayload() {
|
||||||
var updatePayload = {}
|
var updatePayload = {}
|
||||||
|
updatePayload.metadata = {}
|
||||||
|
|
||||||
var volumeNumber = this.selectedMatchUsage.volumeNumber ? this.selectedMatch.volumeNumber || null : null
|
var volumeNumber = this.selectedMatchUsage.volumeNumber ? this.selectedMatch.volumeNumber || null : null
|
||||||
for (const key in this.selectedMatchUsage) {
|
for (const key in this.selectedMatchUsage) {
|
||||||
if (this.selectedMatchUsage[key] && this.selectedMatch[key]) {
|
if (this.selectedMatchUsage[key] && this.selectedMatch[key]) {
|
||||||
if (key === 'series') {
|
if (key === 'series') {
|
||||||
var seriesItem = {
|
var seriesPayload = []
|
||||||
id: `new-${Math.floor(Math.random() * 10000)}`,
|
if (!Array.isArray(this.selectedMatch[key])) {
|
||||||
name: this.selectedMatch[key],
|
seriesPayload.push({
|
||||||
sequence: volumeNumber
|
id: `new-${Math.floor(Math.random() * 10000)}`,
|
||||||
|
name: this.selectedMatch[key],
|
||||||
|
sequence: volumeNumber
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
this.selectedMatch[key].forEach((seriesItem) =>
|
||||||
|
seriesPayload.push({
|
||||||
|
id: seriesItem.id,
|
||||||
|
name: seriesItem.name,
|
||||||
|
sequence: seriesItem.sequence
|
||||||
|
})
|
||||||
|
)
|
||||||
}
|
}
|
||||||
updatePayload.series = [seriesItem]
|
|
||||||
|
updatePayload.metadata.series = seriesPayload
|
||||||
} else if (key === 'author' && !this.isPodcast) {
|
} else if (key === 'author' && !this.isPodcast) {
|
||||||
var authorItem = {
|
if (!Array.isArray(this.selectedMatch[key])) {
|
||||||
id: `new-${Math.floor(Math.random() * 10000)}`,
|
this.selectedMatch[key] = this.selectedMatch[key].split(',').map((au) => au.trim())
|
||||||
name: this.selectedMatch[key]
|
|
||||||
}
|
}
|
||||||
updatePayload.authors = [authorItem]
|
var authorPayload = []
|
||||||
|
this.selectedMatch[key].forEach((authorName) =>
|
||||||
|
authorPayload.push({
|
||||||
|
id: `new-${Math.floor(Math.random() * 10000)}`,
|
||||||
|
name: authorName
|
||||||
|
})
|
||||||
|
)
|
||||||
|
updatePayload.metadata.authors = authorPayload
|
||||||
} else if (key === 'narrator') {
|
} else if (key === 'narrator') {
|
||||||
updatePayload.narrators = [this.selectedMatch[key]]
|
updatePayload.metadata.narrators = [this.selectedMatch[key]]
|
||||||
|
} else if (key === 'genres') {
|
||||||
|
updatePayload.metadata.genres = this.selectedMatch[key].split(',')
|
||||||
|
} else if (key === 'tags') {
|
||||||
|
updatePayload.tags = this.selectedMatch[key].split(',')
|
||||||
} else if (key === 'itunesId') {
|
} else if (key === 'itunesId') {
|
||||||
updatePayload.itunesId = Number(this.selectedMatch[key])
|
updatePayload.metadata.itunesId = Number(this.selectedMatch[key])
|
||||||
} else if (key !== 'volumeNumber') {
|
} else {
|
||||||
updatePayload[key] = this.selectedMatch[key]
|
updatePayload.metadata[key] = this.selectedMatch[key]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return updatePayload
|
return updatePayload
|
||||||
},
|
},
|
||||||
async submitMatchUpdate() {
|
async submitMatchUpdate() {
|
||||||
@@ -359,9 +443,9 @@ export default {
|
|||||||
}
|
}
|
||||||
this.isProcessing = true
|
this.isProcessing = true
|
||||||
|
|
||||||
if (updatePayload.cover) {
|
if (updatePayload.metadata.cover) {
|
||||||
var coverPayload = {
|
var coverPayload = {
|
||||||
url: updatePayload.cover
|
url: updatePayload.metadata.cover
|
||||||
}
|
}
|
||||||
var success = await this.$axios.$post(`/api/items/${this.libraryItemId}/cover`, coverPayload).catch((error) => {
|
var success = await this.$axios.$post(`/api/items/${this.libraryItemId}/cover`, coverPayload).catch((error) => {
|
||||||
console.error('Failed to update', error)
|
console.error('Failed to update', error)
|
||||||
@@ -373,13 +457,11 @@ export default {
|
|||||||
this.$toast.error('Item Cover Failed to Update')
|
this.$toast.error('Item Cover Failed to Update')
|
||||||
}
|
}
|
||||||
console.log('Updated cover')
|
console.log('Updated cover')
|
||||||
delete updatePayload.cover
|
delete updatePayload.metadata.cover
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(updatePayload).length) {
|
if (Object.keys(updatePayload).length) {
|
||||||
var mediaUpdatePayload = {
|
var mediaUpdatePayload = updatePayload
|
||||||
metadata: updatePayload
|
|
||||||
}
|
|
||||||
var updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, mediaUpdatePayload).catch((error) => {
|
var updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, mediaUpdatePayload).catch((error) => {
|
||||||
console.error('Failed to update', error)
|
console.error('Failed to update', error)
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div ref="wrapper" class="p-4 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
|
<div ref="wrapper" class="p-4 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-y-auto" style="max-height: 80vh">
|
||||||
<div class="flex flex-wrap">
|
<div class="flex flex-wrap">
|
||||||
<div class="w-1/5 p-1">
|
<div class="w-1/5 p-1">
|
||||||
<ui-text-input-with-label v-model="newEpisode.season" label="Season" />
|
<ui-text-input-with-label v-model="newEpisode.season" label="Season" />
|
||||||
@@ -25,8 +25,8 @@
|
|||||||
<div class="w-full p-1">
|
<div class="w-full p-1">
|
||||||
<ui-textarea-with-label v-model="newEpisode.subtitle" label="Subtitle" :rows="3" />
|
<ui-textarea-with-label v-model="newEpisode.subtitle" label="Subtitle" :rows="3" />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full p-1">
|
<div class="w-full p-1 default-style">
|
||||||
<ui-textarea-with-label v-model="newEpisode.description" label="Description" :rows="8" />
|
<ui-rich-text-editor v-if="show" label="Description" v-model="newEpisode.description" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end pt-4">
|
<div class="flex justify-end pt-4">
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
<template>
|
||||||
|
<modals-modal v-model="show" name="opml-feeds-modal" :width="1000" :height="'unset'" :processing="processing">
|
||||||
|
<template #outer>
|
||||||
|
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||||
|
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div ref="wrapper" class="p-4 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
|
||||||
|
<div class="w-full p-4">
|
||||||
|
<div class="flex items-center -mx-2 mb-2">
|
||||||
|
<div class="w-full md:w-2/3 p-2">
|
||||||
|
<ui-dropdown v-model="selectedFolderId" :items="folderItems" :disabled="processing" label="Folder" />
|
||||||
|
</div>
|
||||||
|
<div class="w-full md:w-1/3 p-2 pt-6">
|
||||||
|
<ui-checkbox v-model="autoDownloadEpisodes" label="Auto Download New Episodes" checkbox-bg="primary" border-color="gray-600" label-class="text-sm font-semibold pl-2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-lg font-semibold mb-2">Podcasts to Add</p>
|
||||||
|
|
||||||
|
<div class="w-full overflow-y-auto" style="max-height: 50vh">
|
||||||
|
<template v-for="(feed, index) in feedMetadata">
|
||||||
|
<cards-podcast-feed-summary-card :key="index" :feed="feed" :library-folder-path="selectedFolderPath" class="my-1" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center py-4">
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<ui-btn color="success" @click="submit">Add Podcasts</ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</modals-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: Boolean,
|
||||||
|
feeds: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
processing: false,
|
||||||
|
selectedFolderId: null,
|
||||||
|
fullPath: null,
|
||||||
|
autoDownloadEpisodes: false,
|
||||||
|
feedMetadata: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
show: {
|
||||||
|
immediate: true,
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
return 'OPML Feeds'
|
||||||
|
},
|
||||||
|
currentLibrary() {
|
||||||
|
return this.$store.getters['libraries/getCurrentLibrary']
|
||||||
|
},
|
||||||
|
folders() {
|
||||||
|
if (!this.currentLibrary) return []
|
||||||
|
return this.currentLibrary.folders || []
|
||||||
|
},
|
||||||
|
folderItems() {
|
||||||
|
return this.folders.map((fold) => {
|
||||||
|
return {
|
||||||
|
value: fold.id,
|
||||||
|
text: fold.fullPath
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
selectedFolder() {
|
||||||
|
return this.folders.find((f) => f.id === this.selectedFolderId)
|
||||||
|
},
|
||||||
|
selectedFolderPath() {
|
||||||
|
if (!this.selectedFolder) return ''
|
||||||
|
return this.selectedFolder.fullPath
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
toFeedMetadata(feed) {
|
||||||
|
var metadata = feed.metadata
|
||||||
|
return {
|
||||||
|
title: metadata.title,
|
||||||
|
author: metadata.author,
|
||||||
|
description: metadata.description,
|
||||||
|
releaseDate: '',
|
||||||
|
genres: [...metadata.categories],
|
||||||
|
feedUrl: metadata.feedUrl,
|
||||||
|
imageUrl: metadata.image,
|
||||||
|
itunesPageUrl: '',
|
||||||
|
itunesId: '',
|
||||||
|
itunesArtistId: '',
|
||||||
|
language: '',
|
||||||
|
numEpisodes: feed.numEpisodes
|
||||||
|
}
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
this.feedMetadata = this.feeds.map(this.toFeedMetadata)
|
||||||
|
|
||||||
|
if (this.folderItems[0]) {
|
||||||
|
this.selectedFolderId = this.folderItems[0].value
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async submit() {
|
||||||
|
this.processing = true
|
||||||
|
var newFeedPayloads = this.feedMetadata.map((metadata) => {
|
||||||
|
return {
|
||||||
|
path: `${this.selectedFolderPath}\\${this.$sanitizeFilename(metadata.title)}`,
|
||||||
|
folderId: this.selectedFolderId,
|
||||||
|
libraryId: this.currentLibrary.id,
|
||||||
|
media: {
|
||||||
|
metadata: {
|
||||||
|
...metadata
|
||||||
|
},
|
||||||
|
autoDownloadEpisodes: this.autoDownloadEpisodes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
console.log('New feed payloads', newFeedPayloads)
|
||||||
|
|
||||||
|
for (const podcastPayload of newFeedPayloads) {
|
||||||
|
await this.$axios
|
||||||
|
.$post('/api/podcasts', podcastPayload)
|
||||||
|
.then(() => {
|
||||||
|
this.$toast.success(`${podcastPayload.media.metadata.title}: Podcast created successfully`)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to create podcast'
|
||||||
|
console.error('Failed to create podcast', podcastPayload, error)
|
||||||
|
this.$toast.error(`${podcastPayload.media.metadata.title}: ${errorMsg}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
this.processing = false
|
||||||
|
this.show = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
#podcast-wrapper {
|
||||||
|
min-height: 400px;
|
||||||
|
max-height: 80vh;
|
||||||
|
}
|
||||||
|
#episodes-scroll {
|
||||||
|
max-height: calc(80vh - 200px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<template>
|
||||||
|
<modals-modal v-model="show" name="podcast-episode-view-modal" :width="800" :height="'unset'" :processing="processing">
|
||||||
|
<template #outer>
|
||||||
|
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||||
|
<p class="font-book text-3xl text-white truncate">Episode</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div ref="wrapper" class="p-4 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-y-auto" style="max-height: 80vh">
|
||||||
|
<div class="flex mb-4">
|
||||||
|
<div class="w-12 h-12">
|
||||||
|
<covers-book-cover :library-item="libraryItem" :width="48" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow px-2">
|
||||||
|
<p class="text-base mb-1">{{ podcastTitle }}</p>
|
||||||
|
<p class="text-xs text-gray-300">{{ podcastAuthor }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-lg font-semibold mb-6">{{ title }}</p>
|
||||||
|
<div v-if="description" class="default-style" v-html="description" />
|
||||||
|
<p v-else class="mb-2">No description</p>
|
||||||
|
</div>
|
||||||
|
</modals-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
processing: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.$store.state.globals.showViewPodcastEpisodeModal
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$store.commit('globals/setShowViewPodcastEpisodeModal', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
libraryItem() {
|
||||||
|
return this.$store.state.selectedLibraryItem
|
||||||
|
},
|
||||||
|
episode() {
|
||||||
|
return this.$store.state.globals.selectedEpisode || {}
|
||||||
|
},
|
||||||
|
episodeId() {
|
||||||
|
return this.episode.id
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
return this.episode.title || 'No Episode Title'
|
||||||
|
},
|
||||||
|
description() {
|
||||||
|
return this.episode.description || ''
|
||||||
|
},
|
||||||
|
media() {
|
||||||
|
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||||
|
},
|
||||||
|
mediaMetadata() {
|
||||||
|
return this.media.metadata || {}
|
||||||
|
},
|
||||||
|
podcastTitle() {
|
||||||
|
return this.mediaMetadata.title
|
||||||
|
},
|
||||||
|
podcastAuthor() {
|
||||||
|
return this.mediaMetadata.author
|
||||||
|
},
|
||||||
|
bookCoverAspectRatio() {
|
||||||
|
return this.$store.getters['getBookCoverAspectRatio']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex pt-4 pb-2 md:pt-0 md:pb-2">
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<template v-if="!loading">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<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-3xl">last_page</span>
|
||||||
|
</div>
|
||||||
|
<controls-playback-speed-control v-model="playbackRate" @input="playbackRateUpdated" @change="playbackRateChanged" />
|
||||||
|
</template>
|
||||||
|
<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">
|
||||||
|
<span class="material-icons">autorenew</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
loading: Boolean,
|
||||||
|
seekLoading: Boolean,
|
||||||
|
playbackRate: Number,
|
||||||
|
paused: Boolean,
|
||||||
|
hasNextChapter: Boolean
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {},
|
||||||
|
methods: {
|
||||||
|
playPause() {
|
||||||
|
this.$emit('playPause')
|
||||||
|
},
|
||||||
|
prevChapter() {
|
||||||
|
this.$emit('prevChapter')
|
||||||
|
},
|
||||||
|
nextChapter() {
|
||||||
|
if (!this.hasNextChapter) return
|
||||||
|
this.$emit('nextChapter')
|
||||||
|
},
|
||||||
|
jumpBackward() {
|
||||||
|
this.$emit('jumpBackward')
|
||||||
|
},
|
||||||
|
jumpForward() {
|
||||||
|
this.$emit('jumpForward')
|
||||||
|
},
|
||||||
|
playbackRateUpdated(playbackRate) {
|
||||||
|
this.$emit('setPlaybackRate', playbackRate)
|
||||||
|
},
|
||||||
|
playbackRateChanged(playbackRate) {
|
||||||
|
this.$emit('setPlaybackRate', playbackRate)
|
||||||
|
this.$store.dispatch('user/updateUserSettings', { playbackRate }).catch((err) => {
|
||||||
|
console.error('Failed to update settings', err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative">
|
||||||
|
<!-- Track -->
|
||||||
|
<div ref="track" class="w-full h-2 bg-gray-700 relative cursor-pointer transform duration-100 hover:scale-y-125 overflow-hidden" @mousemove="mousemoveTrack" @mouseleave="mouseleaveTrack" @click.stop="clickTrack">
|
||||||
|
<div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none" />
|
||||||
|
<div ref="bufferTrack" class="h-full bg-gray-500 absolute top-0 left-0 pointer-events-none" />
|
||||||
|
<div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-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>
|
||||||
|
<div ref="track" class="w-full h-2 relative overflow-hidden">
|
||||||
|
<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" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hover timestamp -->
|
||||||
|
<div ref="hoverTimestamp" class="absolute -top-8 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
|
||||||
|
<p ref="hoverTimestampText" class="text-xs font-mono text-center px-2 py-0.5 truncate whitespace-nowrap">00:00</p>
|
||||||
|
</div>
|
||||||
|
<div ref="hoverTimestampArrow" class="absolute -top-3 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
|
||||||
|
<div class="absolute -bottom-1.5 left-0 right-0 w-full flex justify-center">
|
||||||
|
<div class="arrow-down" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
loading: Boolean,
|
||||||
|
duration: Number,
|
||||||
|
chapters: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
trackWidth: 0,
|
||||||
|
currentTime: 0,
|
||||||
|
percentReady: 0,
|
||||||
|
bufferTime: 0,
|
||||||
|
chapterTicks: [],
|
||||||
|
trackOffsetLeft: 16, // Track is 16px from edge
|
||||||
|
playedTrackWidth: 0,
|
||||||
|
readyTrackWidth: 0,
|
||||||
|
bufferTrackWidth: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
duration: {
|
||||||
|
immediate: true,
|
||||||
|
handler() {
|
||||||
|
this.setChapterTicks()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {},
|
||||||
|
methods: {
|
||||||
|
clickTrack(e) {
|
||||||
|
if (this.loading) return
|
||||||
|
|
||||||
|
var offsetX = e.offsetX
|
||||||
|
var perc = offsetX / this.trackWidth
|
||||||
|
var time = perc * this.duration
|
||||||
|
if (isNaN(time) || time === null) {
|
||||||
|
console.error('Invalid time', perc, time)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.$emit('seek', time)
|
||||||
|
},
|
||||||
|
setBufferTime(time) {
|
||||||
|
this.bufferTime = time
|
||||||
|
this.updateBufferTrack()
|
||||||
|
},
|
||||||
|
updateBufferTrack() {
|
||||||
|
var bufferlen = (this.bufferTime / this.duration) * this.trackWidth
|
||||||
|
bufferlen = Math.round(bufferlen)
|
||||||
|
if (this.bufferTrackWidth === bufferlen || !this.$refs.bufferTrack) return
|
||||||
|
if (this.$refs.bufferTrack) this.$refs.bufferTrack.style.width = bufferlen + 'px'
|
||||||
|
this.bufferTrackWidth = bufferlen
|
||||||
|
},
|
||||||
|
setPercentageReady(percent) {
|
||||||
|
this.percentReady = percent
|
||||||
|
this.updateReadyTrack()
|
||||||
|
},
|
||||||
|
updateReadyTrack() {
|
||||||
|
var widthReady = Math.round(this.trackWidth * this.percentReady)
|
||||||
|
if (this.readyTrackWidth === widthReady) return
|
||||||
|
this.readyTrackWidth = widthReady
|
||||||
|
if (this.$refs.readyTrack) this.$refs.readyTrack.style.width = widthReady + 'px'
|
||||||
|
},
|
||||||
|
setCurrentTime(time) {
|
||||||
|
this.currentTime = time
|
||||||
|
this.updatePlayedTrackWidth()
|
||||||
|
},
|
||||||
|
updatePlayedTrackWidth() {
|
||||||
|
var perc = this.currentTime / this.duration
|
||||||
|
var ptWidth = Math.round(perc * this.trackWidth)
|
||||||
|
if (this.playedTrackWidth === ptWidth) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.$refs.playedTrack) this.$refs.playedTrack.style.width = ptWidth + 'px'
|
||||||
|
this.playedTrackWidth = ptWidth
|
||||||
|
},
|
||||||
|
setChapterTicks() {
|
||||||
|
this.chapterTicks = this.chapters.map((chap) => {
|
||||||
|
var perc = chap.start / this.duration
|
||||||
|
return {
|
||||||
|
title: chap.title,
|
||||||
|
left: perc * this.trackWidth
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
mousemoveTrack(e) {
|
||||||
|
var offsetX = e.offsetX
|
||||||
|
var time = (offsetX / this.trackWidth) * this.duration
|
||||||
|
|
||||||
|
console.log('Mousemove track', this.trackWidth, this.duration)
|
||||||
|
|
||||||
|
if (this.$refs.hoverTimestamp) {
|
||||||
|
var width = this.$refs.hoverTimestamp.clientWidth
|
||||||
|
this.$refs.hoverTimestamp.style.opacity = 1
|
||||||
|
var posLeft = offsetX - width / 2
|
||||||
|
if (posLeft + width + this.trackOffsetLeft > window.innerWidth) {
|
||||||
|
posLeft = window.innerWidth - width - this.trackOffsetLeft
|
||||||
|
} else if (posLeft < -this.trackOffsetLeft) {
|
||||||
|
posLeft = -this.trackOffsetLeft
|
||||||
|
}
|
||||||
|
this.$refs.hoverTimestamp.style.left = posLeft + 'px'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.$refs.hoverTimestampArrow) {
|
||||||
|
var width = this.$refs.hoverTimestampArrow.clientWidth
|
||||||
|
var posLeft = offsetX - width / 2
|
||||||
|
this.$refs.hoverTimestampArrow.style.opacity = 1
|
||||||
|
this.$refs.hoverTimestampArrow.style.left = posLeft + 'px'
|
||||||
|
}
|
||||||
|
if (this.$refs.hoverTimestampText) {
|
||||||
|
var hoverText = this.$secondsToTimestamp(time)
|
||||||
|
|
||||||
|
var chapter = this.chapters.find((chapter) => chapter.start <= time && time < chapter.end)
|
||||||
|
if (chapter && chapter.title) {
|
||||||
|
hoverText += ` - ${chapter.title}`
|
||||||
|
}
|
||||||
|
this.$refs.hoverTimestampText.innerText = hoverText
|
||||||
|
}
|
||||||
|
if (this.$refs.trackCursor) {
|
||||||
|
this.$refs.trackCursor.style.opacity = 1
|
||||||
|
this.$refs.trackCursor.style.left = offsetX - 1 + 'px'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mouseleaveTrack() {
|
||||||
|
if (this.$refs.hoverTimestamp) {
|
||||||
|
this.$refs.hoverTimestamp.style.opacity = 0
|
||||||
|
}
|
||||||
|
if (this.$refs.hoverTimestampArrow) {
|
||||||
|
this.$refs.hoverTimestampArrow.style.opacity = 0
|
||||||
|
}
|
||||||
|
if (this.$refs.trackCursor) {
|
||||||
|
this.$refs.trackCursor.style.opacity = 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setTrackWidth() {
|
||||||
|
if (this.$refs.track) {
|
||||||
|
this.trackWidth = this.$refs.track.clientWidth
|
||||||
|
} else {
|
||||||
|
console.error('Track not loaded', this.$refs)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
windowResize() {
|
||||||
|
this.setTrackWidth()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.setTrackWidth()
|
||||||
|
window.addEventListener('resize', this.windowResize)
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
window.removeEventListener('resize', this.windowResize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
<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 md:right-2 flex items-center h-full">
|
||||||
|
<!-- <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 mx-1 md:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showSleepTimer')">
|
||||||
@@ -21,57 +23,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex pt-4 pb-2 md:pt-0 md:pb-2">
|
<player-playback-controls :loading="loading" :seek-loading="seekLoading" :playback-rate="playbackRate" :paused="paused" :has-next-chapter="hasNextChapter" @prevChapter="prevChapter" @nextChapter="nextChapter" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setPlaybackRate="setPlaybackRate" @playPause="playPause" />
|
||||||
<div class="flex-grow" />
|
|
||||||
<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">
|
|
||||||
<span class="material-icons text-3xl">first_page</span>
|
|
||||||
</div>
|
|
||||||
<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>
|
|
||||||
</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">
|
|
||||||
<span class="material-icons">{{ seekLoading ? 'autorenew' : paused ? 'play_arrow' : 'pause' }}</span>
|
|
||||||
</div>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
<controls-playback-speed-control v-model="playbackRate" @input="playbackRateUpdated" @change="playbackRateChanged" />
|
|
||||||
</template>
|
|
||||||
<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">
|
|
||||||
<span class="material-icons">autorenew</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div class="flex-grow" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="relative">
|
|
||||||
<!-- Track -->
|
|
||||||
<div ref="track" class="w-full h-2 bg-gray-700 relative cursor-pointer transform duration-100 hover:scale-y-125 overflow-hidden" @mousemove="mousemoveTrack" @mouseleave="mouseleaveTrack" @click.stop="clickTrack">
|
|
||||||
<div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none" />
|
|
||||||
<div ref="bufferTrack" class="h-full bg-gray-400 absolute top-0 left-0 pointer-events-none" />
|
|
||||||
<div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-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>
|
|
||||||
<div ref="track" class="w-full h-2 relative overflow-hidden">
|
|
||||||
<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" />
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Hover timestamp -->
|
<player-track-bar ref="trackbar" :loading="loading" :chapters="chapters" :duration="duration" @seek="seek" />
|
||||||
<div ref="hoverTimestamp" class="absolute -top-8 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
|
|
||||||
<p ref="hoverTimestampText" class="text-xs font-mono text-center px-2 py-0.5 truncate whitespace-nowrap">00:00</p>
|
|
||||||
</div>
|
|
||||||
<div ref="hoverTimestampArrow" class="absolute -top-3 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
|
|
||||||
<div class="absolute -bottom-1.5 left-0 right-0 w-full flex justify-center">
|
|
||||||
<div class="arrow-down" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<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-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 text-gray-100 pointer-events-auto"> / {{ progressPercent }}%</p>
|
||||||
@@ -106,17 +62,11 @@ export default {
|
|||||||
return {
|
return {
|
||||||
volume: 1,
|
volume: 1,
|
||||||
playbackRate: 1,
|
playbackRate: 1,
|
||||||
trackWidth: 0,
|
|
||||||
playedTrackWidth: 0,
|
|
||||||
bufferTrackWidth: 0,
|
|
||||||
readyTrackWidth: 0,
|
|
||||||
audioEl: null,
|
audioEl: null,
|
||||||
seekLoading: false,
|
seekLoading: false,
|
||||||
showChaptersModal: false,
|
showChaptersModal: false,
|
||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
trackOffsetLeft: 16, // Track is 16px from edge
|
duration: 0
|
||||||
duration: 0,
|
|
||||||
chapterTicks: []
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -153,24 +103,46 @@ export default {
|
|||||||
},
|
},
|
||||||
currentChapterName() {
|
currentChapterName() {
|
||||||
return this.currentChapter ? this.currentChapter.title : ''
|
return this.currentChapter ? this.currentChapter.title : ''
|
||||||
|
},
|
||||||
|
isFullscreen() {
|
||||||
|
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: {
|
||||||
|
toggleFullscreen(isFullscreen) {
|
||||||
|
this.$store.commit('setPlayerIsFullscreen', isFullscreen)
|
||||||
|
|
||||||
|
var videoPlayerEl = document.getElementById('video-player')
|
||||||
|
if (videoPlayerEl) {
|
||||||
|
if (isFullscreen) {
|
||||||
|
videoPlayerEl.style.width = '100vw'
|
||||||
|
videoPlayerEl.style.height = '100vh'
|
||||||
|
videoPlayerEl.style.top = '0px'
|
||||||
|
videoPlayerEl.style.left = '0px'
|
||||||
|
} else {
|
||||||
|
videoPlayerEl.style.width = '384px'
|
||||||
|
videoPlayerEl.style.height = '216px'
|
||||||
|
videoPlayerEl.style.top = 'unset'
|
||||||
|
videoPlayerEl.style.bottom = '80px'
|
||||||
|
videoPlayerEl.style.left = '16px'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
setDuration(duration) {
|
setDuration(duration) {
|
||||||
this.duration = duration
|
this.duration = duration
|
||||||
|
|
||||||
this.chapterTicks = this.chapters.map((chap) => {
|
|
||||||
var perc = chap.start / this.duration
|
|
||||||
return {
|
|
||||||
title: chap.title,
|
|
||||||
left: perc * this.trackWidth
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
setCurrentTime(time) {
|
setCurrentTime(time) {
|
||||||
this.currentTime = time
|
this.currentTime = time
|
||||||
this.updateTimestamp()
|
this.updateTimestamp()
|
||||||
this.updatePlayedTrack()
|
if (this.$refs.trackbar) this.$refs.trackbar.setCurrentTime(time)
|
||||||
},
|
},
|
||||||
playPause() {
|
playPause() {
|
||||||
this.$emit('playPause')
|
this.$emit('playPause')
|
||||||
@@ -223,67 +195,28 @@ export default {
|
|||||||
seek(time) {
|
seek(time) {
|
||||||
this.$emit('seek', time)
|
this.$emit('seek', time)
|
||||||
},
|
},
|
||||||
playbackRateUpdated(playbackRate) {
|
|
||||||
this.setPlaybackRate(playbackRate)
|
|
||||||
},
|
|
||||||
playbackRateChanged(playbackRate) {
|
|
||||||
this.setPlaybackRate(playbackRate)
|
|
||||||
this.$store.dispatch('user/updateUserSettings', { playbackRate }).catch((err) => {
|
|
||||||
console.error('Failed to update settings', err)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
mousemoveTrack(e) {
|
|
||||||
var offsetX = e.offsetX
|
|
||||||
var time = (offsetX / this.trackWidth) * this.duration
|
|
||||||
if (this.$refs.hoverTimestamp) {
|
|
||||||
var width = this.$refs.hoverTimestamp.clientWidth
|
|
||||||
this.$refs.hoverTimestamp.style.opacity = 1
|
|
||||||
var posLeft = offsetX - width / 2
|
|
||||||
if (posLeft + width + this.trackOffsetLeft > window.innerWidth) {
|
|
||||||
posLeft = window.innerWidth - width - this.trackOffsetLeft
|
|
||||||
} else if (posLeft < -this.trackOffsetLeft) {
|
|
||||||
posLeft = -this.trackOffsetLeft
|
|
||||||
}
|
|
||||||
this.$refs.hoverTimestamp.style.left = posLeft + 'px'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.$refs.hoverTimestampArrow) {
|
|
||||||
var width = this.$refs.hoverTimestampArrow.clientWidth
|
|
||||||
var posLeft = offsetX - width / 2
|
|
||||||
this.$refs.hoverTimestampArrow.style.opacity = 1
|
|
||||||
this.$refs.hoverTimestampArrow.style.left = posLeft + 'px'
|
|
||||||
}
|
|
||||||
if (this.$refs.hoverTimestampText) {
|
|
||||||
var hoverText = this.$secondsToTimestamp(time)
|
|
||||||
|
|
||||||
var chapter = this.chapters.find((chapter) => chapter.start <= time && time < chapter.end)
|
|
||||||
if (chapter && chapter.title) {
|
|
||||||
hoverText += ` - ${chapter.title}`
|
|
||||||
}
|
|
||||||
this.$refs.hoverTimestampText.innerText = hoverText
|
|
||||||
}
|
|
||||||
if (this.$refs.trackCursor) {
|
|
||||||
this.$refs.trackCursor.style.opacity = 1
|
|
||||||
this.$refs.trackCursor.style.left = offsetX - 1 + 'px'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mouseleaveTrack() {
|
|
||||||
if (this.$refs.hoverTimestamp) {
|
|
||||||
this.$refs.hoverTimestamp.style.opacity = 0
|
|
||||||
}
|
|
||||||
if (this.$refs.hoverTimestampArrow) {
|
|
||||||
this.$refs.hoverTimestampArrow.style.opacity = 0
|
|
||||||
}
|
|
||||||
if (this.$refs.trackCursor) {
|
|
||||||
this.$refs.trackCursor.style.opacity = 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
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() {
|
||||||
this.readyTrackWidth = this.trackWidth
|
if (this.$refs.trackbar) this.$refs.trackbar.setPercentageReady(1)
|
||||||
this.$refs.readyTrack.style.width = this.trackWidth + 'px'
|
|
||||||
},
|
},
|
||||||
setChunksReady(chunks, numSegments) {
|
setChunksReady(chunks, numSegments) {
|
||||||
var largestSeg = 0
|
var largestSeg = 0
|
||||||
@@ -298,10 +231,7 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
var percentageReady = largestSeg / numSegments
|
var percentageReady = largestSeg / numSegments
|
||||||
var widthReady = Math.round(this.trackWidth * percentageReady)
|
if (this.$refs.trackbar) this.$refs.trackbar.setPercentageReady(percentageReady)
|
||||||
if (this.readyTrackWidth === widthReady) return
|
|
||||||
this.readyTrackWidth = widthReady
|
|
||||||
this.$refs.readyTrack.style.width = widthReady + 'px'
|
|
||||||
},
|
},
|
||||||
updateTimestamp() {
|
updateTimestamp() {
|
||||||
var ts = this.$refs.currentTimestamp
|
var ts = this.$refs.currentTimestamp
|
||||||
@@ -312,36 +242,9 @@ export default {
|
|||||||
var currTimeClean = this.$secondsToTimestamp(this.currentTime)
|
var currTimeClean = this.$secondsToTimestamp(this.currentTime)
|
||||||
ts.innerText = currTimeClean
|
ts.innerText = currTimeClean
|
||||||
},
|
},
|
||||||
updatePlayedTrack() {
|
|
||||||
var perc = this.currentTime / this.duration
|
|
||||||
var ptWidth = Math.round(perc * this.trackWidth)
|
|
||||||
if (this.playedTrackWidth === ptWidth) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.$refs.playedTrack.style.width = ptWidth + 'px'
|
|
||||||
this.playedTrackWidth = ptWidth
|
|
||||||
},
|
|
||||||
clickTrack(e) {
|
|
||||||
if (this.loading) return
|
|
||||||
|
|
||||||
var offsetX = e.offsetX
|
|
||||||
var perc = offsetX / this.trackWidth
|
|
||||||
var time = perc * this.duration
|
|
||||||
if (isNaN(time) || time === null) {
|
|
||||||
console.error('Invalid time', perc, time)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.seek(time)
|
|
||||||
},
|
|
||||||
setBufferTime(bufferTime) {
|
setBufferTime(bufferTime) {
|
||||||
if (!this.audioEl) {
|
if (this.$refs.trackbar) this.$refs.trackbar.setBufferTime(bufferTime)
|
||||||
return
|
|
||||||
}
|
|
||||||
var bufferlen = (bufferTime / this.duration) * this.trackWidth
|
|
||||||
bufferlen = Math.round(bufferlen)
|
|
||||||
if (this.bufferTrackWidth === bufferlen || !this.$refs.bufferTrack) return
|
|
||||||
this.$refs.bufferTrack.style.width = bufferlen + 'px'
|
|
||||||
this.bufferTrackWidth = bufferlen
|
|
||||||
},
|
},
|
||||||
showChapters() {
|
showChapters() {
|
||||||
if (!this.chapters.length) return
|
if (!this.chapters.length) return
|
||||||
@@ -350,14 +253,6 @@ export default {
|
|||||||
init() {
|
init() {
|
||||||
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
|
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
|
||||||
this.$emit('setPlaybackRate', this.playbackRate)
|
this.$emit('setPlaybackRate', this.playbackRate)
|
||||||
this.setTrackWidth()
|
|
||||||
},
|
|
||||||
setTrackWidth() {
|
|
||||||
if (this.$refs.track) {
|
|
||||||
this.trackWidth = this.$refs.track.clientWidth
|
|
||||||
} else {
|
|
||||||
console.error('Track not loaded', this.$refs)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
settingsUpdated(settings) {
|
settingsUpdated(settings) {
|
||||||
if (settings.playbackRate && this.playbackRate !== settings.playbackRate) {
|
if (settings.playbackRate && this.playbackRate !== settings.playbackRate) {
|
||||||
@@ -365,6 +260,11 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
closePlayer() {
|
closePlayer() {
|
||||||
|
if (this.isFullscreen) {
|
||||||
|
this.toggleFullscreen(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (this.loading) return
|
if (this.loading) return
|
||||||
this.$emit('close')
|
this.$emit('close')
|
||||||
},
|
},
|
||||||
@@ -379,19 +279,14 @@ export default {
|
|||||||
else if (action === this.$hotkeys.AudioPlayer.INCREASE_PLAYBACK_RATE) this.increasePlaybackRate()
|
else if (action === this.$hotkeys.AudioPlayer.INCREASE_PLAYBACK_RATE) this.increasePlaybackRate()
|
||||||
else if (action === this.$hotkeys.AudioPlayer.DECREASE_PLAYBACK_RATE) this.decreasePlaybackRate()
|
else if (action === this.$hotkeys.AudioPlayer.DECREASE_PLAYBACK_RATE) this.decreasePlaybackRate()
|
||||||
else if (action === this.$hotkeys.AudioPlayer.CLOSE) this.closePlayer()
|
else if (action === this.$hotkeys.AudioPlayer.CLOSE) this.closePlayer()
|
||||||
},
|
|
||||||
windowResize() {
|
|
||||||
this.setTrackWidth()
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
window.addEventListener('resize', this.windowResize)
|
|
||||||
this.$store.commit('user/addSettingsListener', { id: 'audioplayer', meth: this.settingsUpdated })
|
this.$store.commit('user/addSettingsListener', { id: 'audioplayer', meth: this.settingsUpdated })
|
||||||
this.init()
|
this.init()
|
||||||
this.$eventBus.$on('player-hotkey', this.hotkey)
|
this.$eventBus.$on('player-hotkey', this.hotkey)
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
window.removeEventListener('resize', this.windowResize)
|
|
||||||
this.$store.commit('user/removeSettingsListener', 'audioplayer')
|
this.$store.commit('user/removeSettingsListener', 'audioplayer')
|
||||||
this.$eventBus.$off('player-hotkey', this.hotkey)
|
this.$eventBus.$off('player-hotkey', this.hotkey)
|
||||||
}
|
}
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full px-2 py-3 overflow-hidden relative border-b border-white border-opacity-10" @mouseover="mouseover" @mouseleave="mouseleave">
|
<div class="w-full px-2 py-3 overflow-hidden relative border-b border-white border-opacity-10" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||||
<div v-if="episode" class="flex items-center h-24">
|
<div v-if="episode" class="flex items-center h-24 cursor-pointer" @click="$emit('view', episode)">
|
||||||
<div class="flex-grow px-2">
|
<div class="flex-grow px-2">
|
||||||
<p class="text-sm font-semibold">
|
<p class="text-sm font-semibold">
|
||||||
{{ title }}
|
{{ title }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5">{{ description }}</p>
|
|
||||||
|
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5">{{ subtitle }}</p>
|
||||||
|
|
||||||
<div class="flex items-center pt-2">
|
<div class="flex items-center pt-2">
|
||||||
<div class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer" :class="userIsFinished ? 'text-white text-opacity-40' : ''" @click="playClick">
|
<button class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer focus:outline-none" :class="userIsFinished ? 'text-white text-opacity-40' : ''" @click.stop="playClick">
|
||||||
<span class="material-icons" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
|
<span class="material-icons" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
|
||||||
<p class="pl-2 pr-1 text-sm font-semibold">{{ timeRemaining }}</p>
|
<p class="pl-2 pr-1 text-sm font-semibold">{{ timeRemaining }}</p>
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
<ui-tooltip :text="userIsFinished ? 'Mark as Not Finished' : 'Mark as Finished'" direction="top">
|
<ui-tooltip :text="userIsFinished ? 'Mark as Not Finished' : 'Mark as Finished'" direction="top">
|
||||||
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
|
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
|
||||||
@@ -66,10 +68,11 @@ export default {
|
|||||||
title() {
|
title() {
|
||||||
return this.episode.title || ''
|
return this.episode.title || ''
|
||||||
},
|
},
|
||||||
|
subtitle() {
|
||||||
|
return this.episode.subtitle || ''
|
||||||
|
},
|
||||||
description() {
|
description() {
|
||||||
if (this.episode.subtitle) return this.episode.subtitle
|
return this.episode.description || ''
|
||||||
var desc = this.episode.description || ''
|
|
||||||
return desc
|
|
||||||
},
|
},
|
||||||
duration() {
|
duration() {
|
||||||
return this.$secondsToTimestamp(this.episode.duration)
|
return this.$secondsToTimestamp(this.episode.duration)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<p v-if="!episodes.length" class="py-4 text-center text-lg">No Episodes</p>
|
<p v-if="!episodes.length" class="py-4 text-center text-lg">No Episodes</p>
|
||||||
<template v-for="episode in episodesSorted">
|
<template v-for="episode in episodesSorted">
|
||||||
<tables-podcast-episode-table-row :key="episode.id" :episode="episode" :library-item-id="libraryItem.id" class="item" @remove="removeEpisode" @edit="editEpisode" />
|
<tables-podcast-episode-table-row :key="episode.id" :episode="episode" :library-item-id="libraryItem.id" class="item" @remove="removeEpisode" @edit="editEpisode" @view="viewEpisode" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<modals-podcast-remove-episode v-model="showPodcastRemoveModal" :library-item="libraryItem" :episode="selectedEpisode" />
|
<modals-podcast-remove-episode v-model="showPodcastRemoveModal" :library-item="libraryItem" :episode="selectedEpisode" />
|
||||||
@@ -68,6 +68,11 @@ export default {
|
|||||||
this.$store.commit('globals/setSelectedEpisode', episode)
|
this.$store.commit('globals/setSelectedEpisode', episode)
|
||||||
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
|
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
|
||||||
},
|
},
|
||||||
|
viewEpisode(episode) {
|
||||||
|
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
|
||||||
|
this.$store.commit('globals/setSelectedEpisode', episode)
|
||||||
|
this.$store.commit('globals/setShowViewPodcastEpisodeModal', true)
|
||||||
|
},
|
||||||
init() {
|
init() {
|
||||||
this.episodesCopy = this.episodes.map((ep) => ({ ...ep }))
|
this.episodesCopy = this.episodes.map((ep) => ({ ...ep }))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export default {
|
|||||||
default: ''
|
default: ''
|
||||||
},
|
},
|
||||||
paddingX: Number,
|
paddingX: Number,
|
||||||
|
paddingY: Number,
|
||||||
small: Boolean,
|
small: Boolean,
|
||||||
loading: Boolean,
|
loading: Boolean,
|
||||||
disabled: Boolean
|
disabled: Boolean
|
||||||
@@ -48,14 +49,17 @@ export default {
|
|||||||
if (this.small) {
|
if (this.small) {
|
||||||
list.push('text-sm')
|
list.push('text-sm')
|
||||||
if (this.paddingX === undefined) list.push('px-4')
|
if (this.paddingX === undefined) list.push('px-4')
|
||||||
list.push('py-1')
|
if (this.paddingY === undefined) list.push('py-1')
|
||||||
} else {
|
} else {
|
||||||
if (this.paddingX === undefined) list.push('px-8')
|
if (this.paddingX === undefined) list.push('px-8')
|
||||||
list.push('py-2')
|
if (this.paddingY === undefined) list.push('py-2')
|
||||||
}
|
}
|
||||||
if (this.paddingX !== undefined) {
|
if (this.paddingX !== undefined) {
|
||||||
list.push(`px-${this.paddingX}`)
|
list.push(`px-${this.paddingX}`)
|
||||||
}
|
}
|
||||||
|
if (this.paddingY !== undefined) {
|
||||||
|
list.push(`py-${this.paddingY}`)
|
||||||
|
}
|
||||||
if (this.disabled) {
|
if (this.disabled) {
|
||||||
list.push('cursor-not-allowed')
|
list.push('cursor-not-allowed')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<input ref="fileInput" id="hidden-input" 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" type="text"><slot /></ui-btn>
|
||||||
</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) => {
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<p v-if="label" class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': disabled }">
|
||||||
|
{{ label }}
|
||||||
|
</p>
|
||||||
|
<ui-vue-trix v-model="content" :config="config" :disabled-editor="disabled" @trix-file-accept="trixFileAccept" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: String,
|
||||||
|
label: String,
|
||||||
|
disabled: Boolean
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
content: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
config() {
|
||||||
|
return {
|
||||||
|
toolbar: {
|
||||||
|
getDefaultHTML: () => ` <div class="trix-button-row">
|
||||||
|
<span class="trix-button-group trix-button-group--text-tools" data-trix-button-group="text-tools">
|
||||||
|
<button type="button" class="trix-button trix-button--icon trix-button--icon-bold" data-trix-attribute="bold" data-trix-key="b" title="#{lang.bold}" tabindex="-1">#{lang.bold}</button>
|
||||||
|
<button type="button" class="trix-button trix-button--icon trix-button--icon-italic" data-trix-attribute="italic" data-trix-key="i" title="#{lang.italic}" tabindex="-1">#{lang.italic}</button>
|
||||||
|
<button type="button" class="trix-button trix-button--icon trix-button--icon-strike" data-trix-attribute="strike" title="#{lang.strike}" tabindex="-1">#{lang.strike}</button>
|
||||||
|
<button type="button" class="trix-button trix-button--icon trix-button--icon-link" data-trix-attribute="href" data-trix-action="link" data-trix-key="k" title="#{lang.link}" tabindex="-1">#{lang.link}</button>
|
||||||
|
</span>
|
||||||
|
<span class="trix-button-group trix-button-group--block-tools" data-trix-button-group="block-tools">
|
||||||
|
<button type="button" class="trix-button trix-button--icon trix-button--icon-bullet-list" data-trix-attribute="bullet" title="#{lang.bullets}" tabindex="-1">#{lang.bullets}</button>
|
||||||
|
<button type="button" class="trix-button trix-button--icon trix-button--icon-number-list" data-trix-attribute="number" title="#{lang.numbers}" tabindex="-1">#{lang.numbers}</button>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="trix-button-group-spacer"></span>
|
||||||
|
<span class="trix-button-group trix-button-group--history-tools" data-trix-button-group="history-tools">
|
||||||
|
<button type="button" class="trix-button trix-button--icon trix-button--icon-undo" data-trix-action="undo" data-trix-key="z" title="#{lang.undo}" tabindex="-1">#{lang.undo}</button>
|
||||||
|
<button type="button" class="trix-button trix-button--icon trix-button--icon-redo" data-trix-action="redo" data-trix-key="shift+z" title="#{lang.redo}" tabindex="-1">#{lang.redo}</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="trix-dialogs" data-trix-dialogs>
|
||||||
|
<div class="trix-dialog trix-dialog--link" data-trix-dialog="href" data-trix-dialog-attribute="href">
|
||||||
|
<div class="trix-dialog__link-fields">
|
||||||
|
<input type="url" name="href" class="trix-input trix-input--dialog" placeholder="#{lang.urlPlaceholder}" aria-label="#{lang.url}" required data-trix-input>
|
||||||
|
<div class="trix-button-group">
|
||||||
|
<input type="button" class="trix-button trix-button--dialog" value="#{lang.link}" data-trix-method="setAttribute">
|
||||||
|
<input type="button" class="trix-button trix-button--dialog" value="#{lang.unlink}" data-trix-method="removeAttribute">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
trixFileAccept(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {},
|
||||||
|
beforeDestroy() {
|
||||||
|
console.log('Before destroy')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<trix-editor :contenteditable="!disabledEditor" :class="['trix-content']" ref="trix" :input="computedId" :placeholder="placeholder" @trix-change="handleContentChange" @trix-initialize="handleInitialize" @trix-focus="processTrixFocus" @trix-blur="processTrixBlur" />
|
||||||
|
<input type="hidden" :name="inputName" :id="computedId" :value="editorContent" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/*
|
||||||
|
ORIGINAL SOURCE: https://github.com/hanhdt/vue-trix
|
||||||
|
|
||||||
|
modified for audiobookshelf
|
||||||
|
*/
|
||||||
|
import Trix from 'trix'
|
||||||
|
import '@/assets/trix.css'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'vue-trix',
|
||||||
|
model: {
|
||||||
|
prop: 'srcContent',
|
||||||
|
event: 'update'
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
/**
|
||||||
|
* This prop will put the editor in read-only mode
|
||||||
|
*/
|
||||||
|
disabledEditor: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
default() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* This is referenced `id` of the hidden input field defined.
|
||||||
|
* It is optional and will be a random string by default.
|
||||||
|
*/
|
||||||
|
inputId: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default() {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* This is referenced `name` of the hidden input field defined,
|
||||||
|
* default value is `content`.
|
||||||
|
*/
|
||||||
|
inputName: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default() {
|
||||||
|
return 'content'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* The placeholder attribute specifies a short hint
|
||||||
|
* that describes the expected value of a editor.
|
||||||
|
*/
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default() {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* The source content is associcated to v-model directive.
|
||||||
|
*/
|
||||||
|
srcContent: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default() {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* The boolean attribute allows saving editor state into browser's localStorage
|
||||||
|
* (optional, default is `false`).
|
||||||
|
*/
|
||||||
|
localStorage: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
default() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Focuses cursor in the editor when attached to the DOM
|
||||||
|
* (optional, default is `false`).
|
||||||
|
*/
|
||||||
|
autofocus: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
default() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Object to override default editor configuration
|
||||||
|
*/
|
||||||
|
config: {
|
||||||
|
type: Object,
|
||||||
|
required: false,
|
||||||
|
default() {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
editorContent: this.srcContent,
|
||||||
|
isActived: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
editorContent: {
|
||||||
|
handler: 'emitEditorState'
|
||||||
|
},
|
||||||
|
initialContent: {
|
||||||
|
handler: 'handleInitialContentChange'
|
||||||
|
},
|
||||||
|
isDisabled: {
|
||||||
|
handler: 'decorateDisabledEditor'
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
handler: 'overrideConfig',
|
||||||
|
immediate: true,
|
||||||
|
deep: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
/**
|
||||||
|
* Compute a random id of hidden input
|
||||||
|
* when it haven't been specified.
|
||||||
|
*/
|
||||||
|
generateId() {
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||||
|
var r = (Math.random() * 16) | 0
|
||||||
|
var v = c === 'x' ? r : (r & 0x3) | 0x8
|
||||||
|
return v.toString(16)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
computedId() {
|
||||||
|
return this.inputId || this.generateId
|
||||||
|
},
|
||||||
|
initialContent() {
|
||||||
|
return this.srcContent
|
||||||
|
},
|
||||||
|
isDisabled() {
|
||||||
|
return this.disabledEditor
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
processTrixFocus(event) {
|
||||||
|
if (this.$refs.trix) {
|
||||||
|
this.isActived = true
|
||||||
|
this.$emit('trix-focus', this.$refs.trix.editor, event)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
processTrixBlur(event) {
|
||||||
|
if (this.$refs.trix) {
|
||||||
|
this.isActived = false
|
||||||
|
this.$emit('trix-blur', this.$refs.trix.editor, event)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handleContentChange(event) {
|
||||||
|
this.editorContent = event.srcElement ? event.srcElement.value : event.target.value
|
||||||
|
this.$emit('input', this.editorContent)
|
||||||
|
},
|
||||||
|
handleInitialize(event) {
|
||||||
|
/**
|
||||||
|
* If autofocus is true, manually set focus to
|
||||||
|
* beginning of content (consistent with Trix behavior)
|
||||||
|
*/
|
||||||
|
if (this.autofocus) {
|
||||||
|
this.$refs.trix.editor.setSelectedRange(0)
|
||||||
|
}
|
||||||
|
this.$emit('trix-initialize', this.emitInitialize)
|
||||||
|
},
|
||||||
|
handleInitialContentChange(newContent, oldContent) {
|
||||||
|
newContent = newContent === undefined ? '' : newContent
|
||||||
|
if (this.$refs.trix.editor && this.$refs.trix.editor.innerHTML !== newContent) {
|
||||||
|
/* Update editor's content when initial content changed */
|
||||||
|
this.editorContent = newContent
|
||||||
|
/**
|
||||||
|
* If user are typing, then don't reload the editor,
|
||||||
|
* hence keep cursor's position after typing.
|
||||||
|
*/
|
||||||
|
if (!this.isActived) {
|
||||||
|
this.reloadEditorContent(this.editorContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
emitEditorState(value) {
|
||||||
|
/**
|
||||||
|
* If localStorage is enabled,
|
||||||
|
* then save editor's content into storage
|
||||||
|
*/
|
||||||
|
if (this.localStorage) {
|
||||||
|
localStorage.setItem(this.storageId('VueTrix'), JSON.stringify(this.$refs.trix.editor))
|
||||||
|
}
|
||||||
|
this.$emit('update', this.editorContent)
|
||||||
|
},
|
||||||
|
storageId(component) {
|
||||||
|
if (this.inputId) {
|
||||||
|
return `${component}.${this.inputId}.content`
|
||||||
|
} else {
|
||||||
|
return `${component}.content`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
reloadEditorContent(newContent) {
|
||||||
|
// Reload HTML content
|
||||||
|
this.$refs.trix.editor.loadHTML(newContent)
|
||||||
|
// Move cursor to end of new content updated
|
||||||
|
this.$refs.trix.editor.setSelectedRange(this.getContentEndPosition())
|
||||||
|
},
|
||||||
|
getContentEndPosition() {
|
||||||
|
return this.$refs.trix.editor.getDocument().toString().length - 1
|
||||||
|
},
|
||||||
|
decorateDisabledEditor(editorState) {
|
||||||
|
/** Disable toolbar and editor by pointer events styling */
|
||||||
|
if (editorState) {
|
||||||
|
this.$refs.trix.toolbarElement.style['pointer-events'] = 'none'
|
||||||
|
this.$refs.trix.contentEditable = false
|
||||||
|
this.$refs.trix.style['background'] = '#e9ecef'
|
||||||
|
} else {
|
||||||
|
this.$refs.trix.toolbarElement.style['pointer-events'] = 'unset'
|
||||||
|
this.$refs.trix.style['pointer-events'] = 'unset'
|
||||||
|
this.$refs.trix.style['background'] = 'transparent'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
overrideConfig(config) {
|
||||||
|
Trix.config = this.deepMerge(Trix.config, config)
|
||||||
|
},
|
||||||
|
deepMerge(target, override) {
|
||||||
|
// deep merge the object into the target object
|
||||||
|
for (let prop in override) {
|
||||||
|
if (override.hasOwnProperty(prop)) {
|
||||||
|
if (Object.prototype.toString.call(override[prop]) === '[object Object]') {
|
||||||
|
// if the property is a nested object
|
||||||
|
target[prop] = this.deepMerge(target[prop], override[prop])
|
||||||
|
} else {
|
||||||
|
// for regular property
|
||||||
|
target[prop] = override[prop]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
/** Override editor configuration */
|
||||||
|
this.overrideConfig(this.config)
|
||||||
|
/** Check if editor read-only mode is required */
|
||||||
|
this.decorateDisabledEditor(this.disabledEditor)
|
||||||
|
this.$nextTick(() => {
|
||||||
|
/**
|
||||||
|
* If localStorage is enabled,
|
||||||
|
* then load editor's content from the beginning.
|
||||||
|
*/
|
||||||
|
if (this.localStorage) {
|
||||||
|
const savedValue = localStorage.getItem(this.storageId('VueTrix'))
|
||||||
|
if (savedValue && !this.srcContent) {
|
||||||
|
this.$refs.trix.editor.loadJSON(JSON.parse(savedValue))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="css" module>
|
||||||
|
.trix_container {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
.trix_container .trix-button-group {
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
.trix_container .trix-content {
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
<div class="flex mt-2 -mx-1">
|
<div class="flex mt-2 -mx-1">
|
||||||
<div class="flex-grow px-1">
|
<div class="flex-grow px-1">
|
||||||
<ui-multi-select-query-input ref="seriesSelect" v-model="seriesItems" text-key="displayName" label="Series" readonly show-edit @edit="editSeriesItem" @add="addNewSeries" />
|
<widgets-series-input-widget v-model="details.series" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -63,27 +63,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div v-if="showSeriesForm" class="absolute top-0 left-0 z-20 w-full h-full bg-black bg-opacity-50 rounded-lg flex items-center justify-center" @click="cancelSeriesForm">
|
|
||||||
<div class="absolute top-0 right-0 p-4">
|
|
||||||
<span class="material-icons text-gray-200 hover:text-white text-4xl cursor-pointer">close</span>
|
|
||||||
</div>
|
|
||||||
<form @submit.prevent="submitSeriesForm">
|
|
||||||
<div class="bg-bg rounded-lg p-8" @click.stop>
|
|
||||||
<div class="flex">
|
|
||||||
<div class="flex-grow p-1 min-w-80">
|
|
||||||
<ui-input-dropdown ref="newSeriesSelect" v-model="selectedSeries.name" :items="existingSeriesNames" :disabled="!selectedSeries.id.startsWith('new')" label="Series Name" />
|
|
||||||
</div>
|
|
||||||
<div class="w-40 p-1">
|
|
||||||
<ui-text-input-with-label v-model="selectedSeries.sequence" label="Sequence" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-end mt-2 p-1">
|
|
||||||
<ui-btn type="submit">Save</ui-btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -97,8 +76,6 @@ export default {
|
|||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
selectedSeries: {},
|
|
||||||
showSeriesForm: false,
|
|
||||||
details: {
|
details: {
|
||||||
title: null,
|
title: null,
|
||||||
subtitle: null,
|
subtitle: null,
|
||||||
@@ -146,24 +123,6 @@ export default {
|
|||||||
},
|
},
|
||||||
filterData() {
|
filterData() {
|
||||||
return this.$store.state.libraries.filterData || {}
|
return this.$store.state.libraries.filterData || {}
|
||||||
},
|
|
||||||
existingSeriesNames() {
|
|
||||||
// Only show series names not already selected
|
|
||||||
var alreadySelectedSeriesIds = this.details.series.map((se) => se.id)
|
|
||||||
return this.series.filter((se) => !alreadySelectedSeriesIds.includes(se.id)).map((se) => se.name)
|
|
||||||
},
|
|
||||||
seriesItems: {
|
|
||||||
get() {
|
|
||||||
return this.details.series.map((se) => {
|
|
||||||
return {
|
|
||||||
displayName: se.sequence ? `${se.name} #${se.sequence}` : se.name,
|
|
||||||
...se
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
set(val) {
|
|
||||||
this.details.series = val
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -214,50 +173,6 @@ export default {
|
|||||||
this.$refs.tagsSelect.forceBlur()
|
this.$refs.tagsSelect.forceBlur()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
cancelSeriesForm() {
|
|
||||||
this.showSeriesForm = false
|
|
||||||
},
|
|
||||||
editSeriesItem(series) {
|
|
||||||
var _series = this.details.series.find((se) => se.id === series.id)
|
|
||||||
if (!_series) return
|
|
||||||
this.selectedSeries = {
|
|
||||||
..._series
|
|
||||||
}
|
|
||||||
this.showSeriesForm = true
|
|
||||||
},
|
|
||||||
addNewSeries() {
|
|
||||||
this.selectedSeries = {
|
|
||||||
id: `new-${Date.now()}`,
|
|
||||||
name: '',
|
|
||||||
sequence: ''
|
|
||||||
}
|
|
||||||
this.showSeriesForm = true
|
|
||||||
},
|
|
||||||
submitSeriesForm() {
|
|
||||||
if (!this.selectedSeries.name) {
|
|
||||||
this.$toast.error('Must enter a series')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (this.$refs.newSeriesSelect) {
|
|
||||||
this.$refs.newSeriesSelect.blur()
|
|
||||||
}
|
|
||||||
var existingSeriesIndex = this.details.series.findIndex((se) => se.id === this.selectedSeries.id)
|
|
||||||
|
|
||||||
var seriesSameName = this.series.find((se) => se.name.toLowerCase() === this.selectedSeries.name.toLowerCase())
|
|
||||||
if (existingSeriesIndex < 0 && seriesSameName) {
|
|
||||||
this.selectedSeries.id = seriesSameName.id
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existingSeriesIndex >= 0) {
|
|
||||||
this.details.series.splice(existingSeriesIndex, 1, { ...this.selectedSeries })
|
|
||||||
} else {
|
|
||||||
this.details.series.push({
|
|
||||||
...this.selectedSeries
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
this.showSeriesForm = false
|
|
||||||
},
|
|
||||||
stringArrayEqual(array1, array2) {
|
stringArrayEqual(array1, array2) {
|
||||||
// return false if different
|
// return false if different
|
||||||
if (array1.length !== array2.length) return false
|
if (array1.length !== array2.length) return false
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<ui-multi-select-query-input v-model="seriesItems" text-key="displayName" label="Series" readonly show-edit @edit="editSeriesItem" @add="addNewSeries" />
|
||||||
|
|
||||||
|
<modals-edit-series-input-inner-modal v-model="showSeriesForm" :selected-series="selectedSeries" :existing-series-names="existingSeriesNames" @submit="submitSeriesForm" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
selectedSeries: null,
|
||||||
|
showSeriesForm: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
seriesItems: {
|
||||||
|
get() {
|
||||||
|
return (this.value || []).map((se) => {
|
||||||
|
return {
|
||||||
|
displayName: se.sequence ? `${se.name} #${se.sequence}` : se.name,
|
||||||
|
...se
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
series() {
|
||||||
|
return this.filterData.series || []
|
||||||
|
},
|
||||||
|
filterData() {
|
||||||
|
return this.$store.state.libraries.filterData || {}
|
||||||
|
},
|
||||||
|
existingSeriesNames() {
|
||||||
|
// Only show series names not already selected
|
||||||
|
var alreadySelectedSeriesIds = (this.value || []).map((se) => se.id)
|
||||||
|
return this.series.filter((se) => !alreadySelectedSeriesIds.includes(se.id)).map((se) => se.name)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
cancelSeriesForm() {
|
||||||
|
this.showSeriesForm = false
|
||||||
|
},
|
||||||
|
editSeriesItem(series) {
|
||||||
|
var _series = this.seriesItems.find((se) => se.id === series.id)
|
||||||
|
if (!_series) return
|
||||||
|
|
||||||
|
this.selectedSeries = {
|
||||||
|
..._series
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Selected series', this.selectedSeries)
|
||||||
|
this.showSeriesForm = true
|
||||||
|
},
|
||||||
|
addNewSeries() {
|
||||||
|
this.selectedSeries = {
|
||||||
|
id: `new-${Date.now()}`,
|
||||||
|
name: '',
|
||||||
|
sequence: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showSeriesForm = true
|
||||||
|
},
|
||||||
|
submitSeriesForm() {
|
||||||
|
console.log('submit series form', this.value, this.selectedSeries)
|
||||||
|
|
||||||
|
if (!this.selectedSeries.name) {
|
||||||
|
this.$toast.error('Must enter a series')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingSeriesIndex = this.seriesItems.findIndex((se) => se.id === this.selectedSeries.id)
|
||||||
|
|
||||||
|
var existingSeriesSameName = this.seriesItems.findIndex((se) => se.name.toLowerCase() === this.selectedSeries.name.toLowerCase())
|
||||||
|
if (existingSeriesSameName >= 0 && existingSeriesIndex < 0) {
|
||||||
|
console.error('Attempt to add duplicate series')
|
||||||
|
this.$toast.error('Cannot add two of the same series')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var seriesSameName = this.series.find((se) => se.name.toLowerCase() === this.selectedSeries.name.toLowerCase())
|
||||||
|
if (existingSeriesIndex < 0 && seriesSameName) {
|
||||||
|
this.selectedSeries.id = seriesSameName.id
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedSeriesCopy = { ...this.selectedSeries }
|
||||||
|
selectedSeriesCopy.displayName = selectedSeriesCopy.sequence ? `${selectedSeriesCopy.name} #${selectedSeriesCopy.sequence}` : selectedSeriesCopy.name
|
||||||
|
|
||||||
|
var seriesCopy = this.seriesItems.map((v) => ({ ...v }))
|
||||||
|
if (existingSeriesIndex >= 0) {
|
||||||
|
seriesCopy.splice(existingSeriesIndex, 1, selectedSeriesCopy)
|
||||||
|
this.seriesItems = seriesCopy
|
||||||
|
} else {
|
||||||
|
seriesCopy.push(selectedSeriesCopy)
|
||||||
|
this.seriesItems = seriesCopy
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showSeriesForm = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
+12
-18
@@ -12,8 +12,8 @@
|
|||||||
<modals-item-edit-modal />
|
<modals-item-edit-modal />
|
||||||
<modals-user-collections-modal />
|
<modals-user-collections-modal />
|
||||||
<modals-edit-collection-modal />
|
<modals-edit-collection-modal />
|
||||||
<modals-bookshelf-texture-modal />
|
|
||||||
<modals-podcast-edit-episode />
|
<modals-podcast-edit-episode />
|
||||||
|
<modals-podcast-view-episode />
|
||||||
<modals-authors-edit-modal />
|
<modals-authors-edit-modal />
|
||||||
<readers-reader />
|
<readers-reader />
|
||||||
</div>
|
</div>
|
||||||
@@ -515,23 +515,12 @@ export default {
|
|||||||
this.$store.commit('globals/updateWindowSize', { width: window.innerWidth, height: window.innerHeight })
|
this.$store.commit('globals/updateWindowSize', { width: window.innerWidth, height: window.innerHeight })
|
||||||
},
|
},
|
||||||
checkVersionUpdate() {
|
checkVersionUpdate() {
|
||||||
// Version check is only run if time since last check was 5 minutes
|
this.$store
|
||||||
const VERSION_CHECK_BUFF = 1000 * 60 * 5 // 5 minutes
|
.dispatch('checkForUpdate')
|
||||||
var lastVerCheck = localStorage.getItem('lastVerCheck') || 0
|
.then((res) => {
|
||||||
if (Date.now() - Number(lastVerCheck) > VERSION_CHECK_BUFF) {
|
if (res && res.hasUpdate) this.showUpdateToast(res)
|
||||||
this.$store
|
})
|
||||||
.dispatch('checkForUpdate')
|
.catch((err) => console.error(err))
|
||||||
.then((res) => {
|
|
||||||
localStorage.setItem('lastVerCheck', Date.now())
|
|
||||||
if (res && res.hasUpdate) this.showUpdateToast(res)
|
|
||||||
})
|
|
||||||
.catch((err) => console.error(err))
|
|
||||||
|
|
||||||
if (this.$route.query.error) {
|
|
||||||
this.$toast.error(this.$route.query.error)
|
|
||||||
this.$router.replace(this.$route.path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
beforeMount() {
|
beforeMount() {
|
||||||
@@ -551,6 +540,11 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.checkVersionUpdate()
|
this.checkVersionUpdate()
|
||||||
|
|
||||||
|
if (this.$route.query.error) {
|
||||||
|
this.$toast.error(this.$route.query.error)
|
||||||
|
this.$router.replace(this.$route.path)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
window.removeEventListener('resize', this.resize)
|
window.removeEventListener('resize', this.resize)
|
||||||
|
|||||||
Generated
+14
-3
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.0.14",
|
"version": "2.0.19",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.0.14",
|
"version": "2.0.17",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/axios": "^5.13.6",
|
"@nuxtjs/axios": "^5.13.6",
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
"libarchive.js": "^1.3.0",
|
"libarchive.js": "^1.3.0",
|
||||||
"nuxt": "^2.15.8",
|
"nuxt": "^2.15.8",
|
||||||
"nuxt-socket-io": "^1.1.18",
|
"nuxt-socket-io": "^1.1.18",
|
||||||
|
"trix": "^1.3.1",
|
||||||
"v-click-outside": "^3.1.2",
|
"v-click-outside": "^3.1.2",
|
||||||
"vue-pdf": "^4.3.0",
|
"vue-pdf": "^4.3.0",
|
||||||
"vue-toastification": "^1.7.11",
|
"vue-toastification": "^1.7.11",
|
||||||
@@ -15285,6 +15286,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||||
"integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o="
|
"integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o="
|
||||||
},
|
},
|
||||||
|
"node_modules/trix": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/trix/-/trix-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-BbH6mb6gk+AV4f2as38mP6Ucc1LE3OD6XxkZnAgPIduWXYtvg2mI3cZhIZSLqmMh9OITEpOBCCk88IVmyjU7bA=="
|
||||||
|
},
|
||||||
"node_modules/ts-pnp": {
|
"node_modules/ts-pnp": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-pnp/-/ts-pnp-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-pnp/-/ts-pnp-1.2.0.tgz",
|
||||||
@@ -29080,6 +29086,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||||
"integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o="
|
"integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o="
|
||||||
},
|
},
|
||||||
|
"trix": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/trix/-/trix-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-BbH6mb6gk+AV4f2as38mP6Ucc1LE3OD6XxkZnAgPIduWXYtvg2mI3cZhIZSLqmMh9OITEpOBCCk88IVmyjU7bA=="
|
||||||
|
},
|
||||||
"ts-pnp": {
|
"ts-pnp": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-pnp/-/ts-pnp-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-pnp/-/ts-pnp-1.2.0.tgz",
|
||||||
@@ -30624,4 +30635,4 @@
|
|||||||
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="
|
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.0.17",
|
"version": "2.0.19",
|
||||||
"description": "Self-hosted audiobook and podcast client",
|
"description": "Self-hosted audiobook and podcast client",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
"libarchive.js": "^1.3.0",
|
"libarchive.js": "^1.3.0",
|
||||||
"nuxt": "^2.15.8",
|
"nuxt": "^2.15.8",
|
||||||
"nuxt-socket-io": "^1.1.18",
|
"nuxt-socket-io": "^1.1.18",
|
||||||
|
"trix": "^1.3.1",
|
||||||
"v-click-outside": "^3.1.2",
|
"v-click-outside": "^3.1.2",
|
||||||
"vue-pdf": "^4.3.0",
|
"vue-pdf": "^4.3.0",
|
||||||
"vue-toastification": "^1.7.11",
|
"vue-toastification": "^1.7.11",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,16 @@
|
|||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center py-2">
|
||||||
|
<ui-toggle-switch v-model="newServerSettings.scannerPreferMatchedMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferMatchedMetadata', val)" />
|
||||||
|
<ui-tooltip :text="tooltips.scannerPreferMatchedMetadata">
|
||||||
|
<p class="pl-4 text-lg">
|
||||||
|
Scanner prefer matched metadata
|
||||||
|
<span class="material-icons icon-text">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.scannerDisableWatcher" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerDisableWatcher', val)" />
|
<ui-toggle-switch v-model="newServerSettings.scannerDisableWatcher" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerDisableWatcher', val)" />
|
||||||
<ui-tooltip :text="tooltips.scannerDisableWatcher">
|
<ui-tooltip :text="tooltips.scannerDisableWatcher">
|
||||||
@@ -226,6 +236,7 @@ export default {
|
|||||||
experimentalFeatures: 'Features in development that could use your feedback and help testing. Click to open github discussion.',
|
experimentalFeatures: 'Features in development that could use your feedback and help testing. Click to open github discussion.',
|
||||||
scannerDisableWatcher: 'Disables the automatic adding/updating of items when file changes are detected. *Requires server restart',
|
scannerDisableWatcher: 'Disables the automatic adding/updating of items when file changes are detected. *Requires server restart',
|
||||||
scannerPreferOpfMetadata: 'OPF file metadata will be used for book details over folder names',
|
scannerPreferOpfMetadata: 'OPF file metadata will be used for book details over folder names',
|
||||||
|
scannerPreferMatchedMetadata: 'Matched data will overide book details when using Quick Match',
|
||||||
scannerPreferAudioMetadata: 'Audio file ID3 meta tags will be used for book details over folder names',
|
scannerPreferAudioMetadata: 'Audio file ID3 meta tags will be used for book details over folder names',
|
||||||
scannerParseSubtitle: 'Extract subtitles from audiobook folder names.<br>Subtitle must be seperated by " - "<br>i.e. "Book Title - A Subtitle Here" has the subtitle "A Subtitle Here"',
|
scannerParseSubtitle: 'Extract subtitles from audiobook folder names.<br>Subtitle must be seperated by " - "<br>i.e. "Book Title - A Subtitle Here" has the subtitle "A Subtitle Here"',
|
||||||
sortingIgnorePrefix: 'i.e. for prefix "the" book title "The Book Title" would sort as "Book Title, The"',
|
sortingIgnorePrefix: 'i.e. for prefix "the" book title "The Book Title" would sort as "Book Title, The"',
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full h-full">
|
||||||
|
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-0 sm:p-4 mb-8">
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="flex items-center mb-1">
|
||||||
|
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Listening Sessions</h1>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
|
||||||
|
<ui-dropdown v-model="selectedUser" :items="userItems" label="Filter by User" small class="max-w-48" @input="updateUserFilter" />
|
||||||
|
</div>
|
||||||
|
<div v-if="listeningSessions.length">
|
||||||
|
<table class="userSessionsTable">
|
||||||
|
<tr class="bg-primary bg-opacity-40">
|
||||||
|
<th class="flex-grow text-left">Item</th>
|
||||||
|
<th class="w-20 text-left">User</th>
|
||||||
|
<th class="w-32 text-left hidden md:table-cell">Play Method</th>
|
||||||
|
<th class="w-40 text-left hidden sm:table-cell">Device Info</th>
|
||||||
|
<th class="w-20">Listened</th>
|
||||||
|
<th class="w-20">Last Time</th>
|
||||||
|
<th class="w-40 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">
|
||||||
|
<p class="text-sm text-gray-200">{{ session.displayTitle }}</p>
|
||||||
|
<p class="text-xs text-gray-400">{{ 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">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
|
||||||
|
</ui-tooltip>
|
||||||
|
</td>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
.userSessionsTable {
|
||||||
|
border-collapse: collapse;
|
||||||
|
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>
|
||||||
@@ -37,7 +37,11 @@
|
|||||||
<div class="flex flex-col md:flex-row overflow-hidden max-w-full">
|
<div class="flex flex-col md:flex-row overflow-hidden max-w-full">
|
||||||
<stats-daily-listening-chart :listening-stats="listeningStats" class="origin-top-left transform scale-75 lg:scale-100" />
|
<stats-daily-listening-chart :listening-stats="listeningStats" class="origin-top-left transform scale-75 lg:scale-100" />
|
||||||
<div class="w-80 my-6 mx-auto">
|
<div class="w-80 my-6 mx-auto">
|
||||||
<h1 class="text-2xl mb-4 font-book">Recent Listening Sessions</h1>
|
<div class="flex mb-4 items-center">
|
||||||
|
<h1 class="text-2xl font-book">Recent Sessions</h1>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<ui-btn :to="`/config/users/${user.id}/sessions`" class="text-xs" :padding-x="1.5" :padding-y="1">View All</ui-btn>
|
||||||
|
</div>
|
||||||
<p v-if="!mostRecentListeningSessions.length">No Listening Sessions</p>
|
<p v-if="!mostRecentListeningSessions.length">No Listening Sessions</p>
|
||||||
<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">
|
||||||
|
|||||||
@@ -22,6 +22,10 @@
|
|||||||
<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 Stats</h1>
|
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Listening Stats</h1>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<p class="text-sm text-gray-300">{{ listeningSessions.length }} Listening Sessions</p>
|
||||||
|
<ui-btn :to="`/config/users/${user.id}/sessions`" class="text-xs mx-2" :padding-x="1.5" :padding-y="1">View All</ui-btn>
|
||||||
|
</div>
|
||||||
<p class="text-sm text-gray-300">
|
<p class="text-sm text-gray-300">
|
||||||
Total Time Listened:
|
Total Time Listened:
|
||||||
<span class="font-mono text-base">{{ listeningTimePretty }}</span>
|
<span class="font-mono text-base">{{ listeningTimePretty }}</span>
|
||||||
@@ -33,12 +37,14 @@
|
|||||||
|
|
||||||
<div v-if="latestSession" class="mt-4">
|
<div v-if="latestSession" class="mt-4">
|
||||||
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Last Listening Session</h1>
|
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Last Listening Session</h1>
|
||||||
<p class="text-sm text-gray-300">{{ latestSession.audiobookTitle }} {{ $dateDistanceFromNow(latestSession.lastUpdate) }} for {{ $elapsedPrettyExtended(this.latestSession.timeListening) }}</p>
|
<p class="text-sm text-gray-300">
|
||||||
|
<strong>{{ latestSession.displayTitle }}</strong> {{ $dateDistanceFromNow(latestSession.updatedAt) }} for <span class="font-mono text-base">{{ $elapsedPrettyExtended(this.latestSession.timeListening) }}</span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<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">Item Progress</h1>
|
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Saved Media Progress</h1>
|
||||||
<table v-if="mediaProgress.length" class="userAudiobooksTable">
|
<table v-if="mediaProgress.length" class="userAudiobooksTable">
|
||||||
<tr class="bg-primary bg-opacity-40">
|
<tr class="bg-primary bg-opacity-40">
|
||||||
<th class="w-16 text-left">Item</th>
|
<th class="w-16 text-left">Item</th>
|
||||||
@@ -70,7 +76,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
<p v-else class="text-white text-opacity-50">Nothing read yet...</p>
|
<p v-else class="text-white text-opacity-50">Nothing listened to yet...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -132,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 []
|
||||||
})
|
})
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full h-full">
|
||||||
|
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-0 sm:p-4 mb-8">
|
||||||
|
<nuxt-link :to="`/config/users/${user.id}`" class="text-white text-opacity-70 hover:text-opacity-100 hover:bg-white hover:bg-opacity-5 cursor-pointer rounded-full px-2 sm:px-0">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="h-10 w-10 flex items-center justify-center">
|
||||||
|
<span class="material-icons text-2xl">arrow_back</span>
|
||||||
|
</div>
|
||||||
|
<p class="pl-1">Back to User</p>
|
||||||
|
</div>
|
||||||
|
</nuxt-link>
|
||||||
|
<div class="flex items-center mb-2 mt-4 px-2 sm:px-0">
|
||||||
|
<widgets-online-indicator :value="!!userOnline" />
|
||||||
|
<h1 class="text-xl pl-2">{{ username }}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full h-px bg-white bg-opacity-10 my-2" />
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Listening Sessions</h1>
|
||||||
|
<div v-if="listeningSessions.length">
|
||||||
|
<table class="userSessionsTable">
|
||||||
|
<tr class="bg-primary bg-opacity-40">
|
||||||
|
<th class="flex-grow text-left">Item</th>
|
||||||
|
<th class="w-32 text-left hidden md:table-cell">Play Method</th>
|
||||||
|
<th class="w-40 text-left hidden sm:table-cell">Device Info</th>
|
||||||
|
<th class="w-20">Listened</th>
|
||||||
|
<th class="w-20">Last Time</th>
|
||||||
|
<th class="w-40 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">
|
||||||
|
<p class="text-sm text-gray-200">{{ session.displayTitle }}</p>
|
||||||
|
<p class="text-xs text-gray-400">{{ session.displayAuthor }}</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">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
|
||||||
|
</ui-tooltip>
|
||||||
|
</td>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<modals-listening-session-modal v-model="showSessionModal" :session="selectedSession" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
async asyncData({ params, redirect, app }) {
|
||||||
|
var user = await app.$axios.$get(`/api/users/${params.id}`).catch((error) => {
|
||||||
|
console.error('Failed to get user', error)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
if (!user) return redirect('/config/users')
|
||||||
|
return {
|
||||||
|
user
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
showSessionModal: false,
|
||||||
|
selectedSession: null,
|
||||||
|
listeningSessions: [],
|
||||||
|
numPages: 0,
|
||||||
|
total: 0,
|
||||||
|
currentPage: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
username() {
|
||||||
|
return this.user.username
|
||||||
|
},
|
||||||
|
userOnline() {
|
||||||
|
return this.$store.getters['users/getIsUserOnline'](this.user.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
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) {
|
||||||
|
const data = await this.$axios.$get(`/api/users/${this.user.id}/listening-sessions?page=${page}&itemsPerPage=10`).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
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
this.loadSessions(0)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.userSessionsTable {
|
||||||
|
border-collapse: collapse;
|
||||||
|
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>
|
||||||
@@ -31,11 +31,13 @@
|
|||||||
<p v-if="bookSubtitle" class="sm:ml-4 text-gray-400 text-xl md:text-2xl">{{ bookSubtitle }}</p>
|
<p v-if="bookSubtitle" class="sm:ml-4 text-gray-400 text-xl md:text-2xl">{{ bookSubtitle }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="isPodcast" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">by {{ podcastAuthor || 'Unknown' }}</p>
|
<template v-if="!isVideo">
|
||||||
<p v-else-if="authors.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">
|
<p v-if="isPodcast" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">by {{ podcastAuthor || 'Unknown' }}</p>
|
||||||
by <nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link>
|
<p v-else-if="authors.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">
|
||||||
</p>
|
by <nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link>
|
||||||
<p v-else class="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</p>
|
</p>
|
||||||
|
<p v-else class="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
<nuxt-link v-for="_series in seriesList" :key="_series.id" :to="`/library/${libraryId}/series/${_series.id}`" class="hover:underline font-sans text-gray-300 text-lg leading-7"> {{ _series.text }}</nuxt-link>
|
<nuxt-link v-for="_series in seriesList" :key="_series.id" :to="`/library/${libraryId}/series/${_series.id}`" class="hover:underline font-sans text-gray-300 text-lg leading-7"> {{ _series.text }}</nuxt-link>
|
||||||
|
|
||||||
@@ -251,6 +253,9 @@ export default {
|
|||||||
isPodcast() {
|
isPodcast() {
|
||||||
return this.libraryItem.mediaType === 'podcast'
|
return this.libraryItem.mediaType === 'podcast'
|
||||||
},
|
},
|
||||||
|
isVideo() {
|
||||||
|
return this.libraryItem.mediaType === 'video'
|
||||||
|
},
|
||||||
isMissing() {
|
isMissing() {
|
||||||
return this.libraryItem.isMissing
|
return this.libraryItem.isMissing
|
||||||
},
|
},
|
||||||
@@ -258,11 +263,12 @@ export default {
|
|||||||
return this.libraryItem.isInvalid
|
return this.libraryItem.isInvalid
|
||||||
},
|
},
|
||||||
invalidAudioFiles() {
|
invalidAudioFiles() {
|
||||||
if (this.isPodcast) return []
|
if (this.isPodcast || this.isVideo) return []
|
||||||
return this.libraryItem.media.audioFiles.filter((af) => af.invalid)
|
return this.libraryItem.media.audioFiles.filter((af) => af.invalid)
|
||||||
},
|
},
|
||||||
showPlayButton() {
|
showPlayButton() {
|
||||||
if (this.isMissing || this.isInvalid) return false
|
if (this.isMissing || this.isInvalid) return false
|
||||||
|
if (this.isVideo) return !!this.videoFile
|
||||||
if (this.isPodcast) return this.podcastEpisodes.length
|
if (this.isPodcast) return this.podcastEpisodes.length
|
||||||
return this.tracks.length
|
return this.tracks.length
|
||||||
},
|
},
|
||||||
@@ -348,6 +354,9 @@ export default {
|
|||||||
ebookFile() {
|
ebookFile() {
|
||||||
return this.media.ebookFile
|
return this.media.ebookFile
|
||||||
},
|
},
|
||||||
|
videoFile() {
|
||||||
|
return this.media.videoFile
|
||||||
|
},
|
||||||
showExperimentalReadAlert() {
|
showExperimentalReadAlert() {
|
||||||
return !this.tracks.length && this.ebookFile && !this.showExperimentalFeatures && !this.enableEReader
|
return !this.tracks.length && this.ebookFile && !this.showExperimentalFeatures && !this.enableEReader
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
||||||
<app-book-shelf-toolbar is-home />
|
<app-book-shelf-toolbar page="authors" is-home :authors="authors" />
|
||||||
<div id="bookshelf" class="w-full h-full p-8 overflow-y-auto">
|
<div id="bookshelf" class="w-full h-full p-8 overflow-y-auto">
|
||||||
<div class="flex flex-wrap justify-center">
|
<div class="flex flex-wrap justify-center">
|
||||||
<template v-for="author in authors">
|
<template v-for="author in authors">
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<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 p-12 relative">
|
||||||
<div class="w-full max-w-3xl mx-auto">
|
<div class="w-full max-w-4xl mx-auto flex">
|
||||||
<form @submit.prevent="submit" class="flex">
|
<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" />
|
||||||
<ui-btn type="submit" :disabled="processing">Submit</ui-btn>
|
<ui-btn type="submit" :disabled="processing">Submit</ui-btn>
|
||||||
</form>
|
</form>
|
||||||
|
<ui-file-input ref="fileInput" :accept="'.opml, .txt'" class="mx-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">
|
||||||
@@ -32,6 +34,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<modals-podcast-new-modal v-model="showNewPodcastModal" :podcast-data="selectedPodcast" :podcast-feed-data="selectedPodcastFeed" />
|
<modals-podcast-new-modal v-model="showNewPodcastModal" :podcast-data="selectedPodcast" :podcast-feed-data="selectedPodcastFeed" />
|
||||||
|
<modals-podcast-opml-feeds-modal v-model="showOPMLFeedsModal" :feeds="opmlFeeds" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -62,7 +65,9 @@ export default {
|
|||||||
processing: false,
|
processing: false,
|
||||||
showNewPodcastModal: false,
|
showNewPodcastModal: false,
|
||||||
selectedPodcast: null,
|
selectedPodcast: null,
|
||||||
selectedPodcastFeed: null
|
selectedPodcastFeed: null,
|
||||||
|
showOPMLFeedsModal: false,
|
||||||
|
opmlFeeds: []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -71,6 +76,40 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
async opmlFileUpload(file) {
|
||||||
|
this.processing = true
|
||||||
|
var txt = await new Promise((resolve) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => {
|
||||||
|
resolve(reader.result)
|
||||||
|
}
|
||||||
|
reader.readAsText(file)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (this.$refs.fileInput) {
|
||||||
|
this.$refs.fileInput.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!txt || !txt.includes('<opml') || !txt.includes('<outline ')) {
|
||||||
|
// Quick lazy check for valid OPML
|
||||||
|
this.$toast.error('Invalid OPML file <opml> tag not found OR an <outline> tag was not found')
|
||||||
|
this.processing = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.$axios
|
||||||
|
.$post(`/api/podcasts/opml`, { opmlText: txt })
|
||||||
|
.then((data) => {
|
||||||
|
console.log(data)
|
||||||
|
this.opmlFeeds = data.feeds || []
|
||||||
|
this.showOPMLFeedsModal = true
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed', error)
|
||||||
|
this.$toast.error('Failed to parse OPML file')
|
||||||
|
})
|
||||||
|
this.processing = false
|
||||||
|
},
|
||||||
submit() {
|
submit() {
|
||||||
if (!this.searchInput) return
|
if (!this.searchInput) return
|
||||||
|
|
||||||
|
|||||||
@@ -64,8 +64,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input ref="fileInput" id="hidden-input" type="file" multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
|
<input ref="fileInput" type="file" multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
|
||||||
<input ref="fileFolderInput" id="hidden-input" type="file" webkitdirectory multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
|
<input ref="fileFolderInput" type="file" webkitdirectory multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -52,12 +52,12 @@ export default class CastPlayer extends EventEmitter {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// var currentItemId = media.currentItemId
|
|
||||||
var currentItemId = media.media.itemId
|
var currentItemId = media.media.itemId
|
||||||
if (currentItemId && this.currentTrackIndex !== currentItemId - 1) {
|
if (currentItemId && this.currentTrackIndex !== currentItemId - 1) {
|
||||||
this.currentTrackIndex = currentItemId - 1
|
this.currentTrackIndex = currentItemId - 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Emit finished event
|
||||||
if (media.playerState !== this.castPlayerState) {
|
if (media.playerState !== this.castPlayerState) {
|
||||||
this.emit('stateChange', media.playerState)
|
this.emit('stateChange', media.playerState)
|
||||||
this.castPlayerState = media.playerState
|
this.castPlayerState = media.playerState
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Hls from 'hls.js'
|
import Hls from 'hls.js'
|
||||||
import EventEmitter from 'events'
|
import EventEmitter from 'events'
|
||||||
|
|
||||||
export default class LocalPlayer extends EventEmitter {
|
export default class LocalAudioPlayer extends EventEmitter {
|
||||||
constructor(ctx) {
|
constructor(ctx) {
|
||||||
super()
|
super()
|
||||||
|
|
||||||
@@ -71,11 +71,12 @@ export default class LocalPlayer 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.playWhenReady = !this.player.paused
|
||||||
this.startTime = this.currentTrack.startOffset
|
this.startTime = this.currentTrack.startOffset
|
||||||
this.loadCurrentTrack()
|
this.loadCurrentTrack()
|
||||||
} else {
|
} else {
|
||||||
console.log(`[LocalPlayer] Ended`)
|
console.log(`[LocalPlayer] Ended`)
|
||||||
|
this.emit('finished')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
evtError(error) {
|
evtError(error) {
|
||||||
@@ -88,6 +89,7 @@ export default class LocalPlayer 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()
|
||||||
@@ -228,8 +230,11 @@ export default class LocalPlayer 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)
|
||||||
@@ -254,7 +259,6 @@ export default class LocalPlayer extends EventEmitter {
|
|||||||
this.player.currentTime = Math.max(0, offsetTime)
|
this.player.currentTime = Math.max(0, offsetTime)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setVolume(volume) {
|
setVolume(volume) {
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import Hls from 'hls.js'
|
||||||
|
import EventEmitter from 'events'
|
||||||
|
|
||||||
|
export default class LocalVideoPlayer extends EventEmitter {
|
||||||
|
constructor(ctx) {
|
||||||
|
super()
|
||||||
|
|
||||||
|
this.ctx = ctx
|
||||||
|
this.player = null
|
||||||
|
|
||||||
|
this.libraryItem = null
|
||||||
|
this.videoTrack = null
|
||||||
|
this.isHlsTranscode = null
|
||||||
|
this.hlsInstance = null
|
||||||
|
this.usingNativeplayer = false
|
||||||
|
this.startTime = 0
|
||||||
|
this.playWhenReady = false
|
||||||
|
this.defaultPlaybackRate = 1
|
||||||
|
|
||||||
|
this.playableMimeTypes = []
|
||||||
|
|
||||||
|
this.initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
initialize() {
|
||||||
|
if (document.getElementById('video-player')) {
|
||||||
|
document.getElementById('video-player').remove()
|
||||||
|
}
|
||||||
|
var videoEl = document.createElement('video')
|
||||||
|
videoEl.id = 'video-player'
|
||||||
|
// videoEl.style.display = 'none'
|
||||||
|
videoEl.className = 'absolute bg-black z-50'
|
||||||
|
videoEl.style.height = '216px'
|
||||||
|
videoEl.style.width = '384px'
|
||||||
|
videoEl.style.bottom = '80px'
|
||||||
|
videoEl.style.left = '16px'
|
||||||
|
document.body.appendChild(videoEl)
|
||||||
|
this.player = videoEl
|
||||||
|
|
||||||
|
this.player.addEventListener('play', this.evtPlay.bind(this))
|
||||||
|
this.player.addEventListener('pause', this.evtPause.bind(this))
|
||||||
|
this.player.addEventListener('progress', this.evtProgress.bind(this))
|
||||||
|
this.player.addEventListener('ended', this.evtEnded.bind(this))
|
||||||
|
this.player.addEventListener('error', this.evtError.bind(this))
|
||||||
|
this.player.addEventListener('loadedmetadata', this.evtLoadedMetadata.bind(this))
|
||||||
|
this.player.addEventListener('timeupdate', this.evtTimeupdate.bind(this))
|
||||||
|
|
||||||
|
var mimeTypes = ['video/mp4']
|
||||||
|
var mimeTypeCanPlayMap = {}
|
||||||
|
mimeTypes.forEach((mt) => {
|
||||||
|
var canPlay = this.player.canPlayType(mt)
|
||||||
|
mimeTypeCanPlayMap[mt] = canPlay
|
||||||
|
if (canPlay) this.playableMimeTypes.push(mt)
|
||||||
|
})
|
||||||
|
console.log(`[LocalVideoPlayer] Supported mime types`, mimeTypeCanPlayMap, this.playableMimeTypes)
|
||||||
|
}
|
||||||
|
|
||||||
|
evtPlay() {
|
||||||
|
this.emit('stateChange', 'PLAYING')
|
||||||
|
}
|
||||||
|
evtPause() {
|
||||||
|
this.emit('stateChange', 'PAUSED')
|
||||||
|
}
|
||||||
|
evtProgress() {
|
||||||
|
var lastBufferTime = this.getLastBufferedTime()
|
||||||
|
this.emit('buffertimeUpdate', lastBufferTime)
|
||||||
|
}
|
||||||
|
evtEnded() {
|
||||||
|
console.log(`[LocalVideoPlayer] Ended`)
|
||||||
|
this.emit('finished')
|
||||||
|
}
|
||||||
|
evtError(error) {
|
||||||
|
console.error('Player error', error)
|
||||||
|
this.emit('error', error)
|
||||||
|
}
|
||||||
|
evtLoadedMetadata(data) {
|
||||||
|
if (!this.isHlsTranscode) {
|
||||||
|
this.player.currentTime = this.startTime
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('stateChange', 'LOADED')
|
||||||
|
if (this.playWhenReady) {
|
||||||
|
this.playWhenReady = false
|
||||||
|
this.play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
evtTimeupdate() {
|
||||||
|
if (this.player.paused) {
|
||||||
|
this.emit('timeupdate', this.getCurrentTime())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.destroyHlsInstance()
|
||||||
|
if (this.player) {
|
||||||
|
this.player.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set(libraryItem, videoTrack, isHlsTranscode, startTime, playWhenReady = false) {
|
||||||
|
this.libraryItem = libraryItem
|
||||||
|
this.videoTrack = videoTrack
|
||||||
|
this.isHlsTranscode = isHlsTranscode
|
||||||
|
this.playWhenReady = playWhenReady
|
||||||
|
this.startTime = startTime
|
||||||
|
|
||||||
|
if (this.hlsInstance) {
|
||||||
|
this.destroyHlsInstance()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isHlsTranscode) {
|
||||||
|
this.setHlsStream()
|
||||||
|
} else {
|
||||||
|
this.setDirectPlay()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setHlsStream() {
|
||||||
|
// iOS does not support Media Elements but allows for HLS in the native video player
|
||||||
|
if (!Hls.isSupported()) {
|
||||||
|
console.warn('HLS is not supported - fallback to using video element')
|
||||||
|
this.usingNativeplayer = true
|
||||||
|
this.player.src = this.videoTrack.relativeContentUrl
|
||||||
|
this.player.currentTime = this.startTime
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var hlsOptions = {
|
||||||
|
startPosition: this.startTime || -1
|
||||||
|
// No longer needed because token is put in a query string
|
||||||
|
// xhrSetup: (xhr) => {
|
||||||
|
// xhr.setRequestHeader('Authorization', `Bearer ${this.token}`)
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
this.hlsInstance = new Hls(hlsOptions)
|
||||||
|
|
||||||
|
this.hlsInstance.attachMedia(this.player)
|
||||||
|
this.hlsInstance.on(Hls.Events.MEDIA_ATTACHED, () => {
|
||||||
|
this.hlsInstance.loadSource(this.videoTrack.relativeContentUrl)
|
||||||
|
|
||||||
|
this.hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||||
|
console.log('[HLS] Manifest Parsed')
|
||||||
|
})
|
||||||
|
|
||||||
|
this.hlsInstance.on(Hls.Events.ERROR, (e, data) => {
|
||||||
|
console.error('[HLS] Error', data.type, data.details, data)
|
||||||
|
if (data.details === Hls.ErrorDetails.BUFFER_STALLED_ERROR) {
|
||||||
|
console.error('[HLS] BUFFER STALLED ERROR')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
this.hlsInstance.on(Hls.Events.DESTROYING, () => {
|
||||||
|
console.log('[HLS] Destroying HLS Instance')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setDirectPlay() {
|
||||||
|
this.player.src = this.videoTrack.relativeContentUrl
|
||||||
|
console.log(`[LocalVideoPlayer] Loading track src ${this.videoTrack.relativeContentUrl}`)
|
||||||
|
this.player.load()
|
||||||
|
}
|
||||||
|
|
||||||
|
destroyHlsInstance() {
|
||||||
|
if (!this.hlsInstance) return
|
||||||
|
if (this.hlsInstance.destroy) {
|
||||||
|
var temp = this.hlsInstance
|
||||||
|
temp.destroy()
|
||||||
|
}
|
||||||
|
this.hlsInstance = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetStream(startTime) {
|
||||||
|
this.destroyHlsInstance()
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||||
|
this.set(this.libraryItem, this.videoTrack, this.isHlsTranscode, startTime, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
playPause() {
|
||||||
|
if (!this.player) return
|
||||||
|
if (this.player.paused) this.play()
|
||||||
|
else this.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
play() {
|
||||||
|
if (this.player) this.player.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
pause() {
|
||||||
|
if (this.player) this.player.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentTime() {
|
||||||
|
return this.player ? this.player.currentTime : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
getDuration() {
|
||||||
|
return this.videoTrack.duration
|
||||||
|
}
|
||||||
|
|
||||||
|
setPlaybackRate(playbackRate) {
|
||||||
|
if (!this.player) return
|
||||||
|
this.defaultPlaybackRate = playbackRate
|
||||||
|
this.player.playbackRate = playbackRate
|
||||||
|
}
|
||||||
|
|
||||||
|
seek(time) {
|
||||||
|
if (!this.player) return
|
||||||
|
this.player.currentTime = Math.max(0, time)
|
||||||
|
}
|
||||||
|
|
||||||
|
setVolume(volume) {
|
||||||
|
if (!this.player) return
|
||||||
|
this.player.volume = volume
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utils
|
||||||
|
isValidDuration(duration) {
|
||||||
|
if (duration && !isNaN(duration) && duration !== Number.POSITIVE_INFINITY && duration !== Number.NEGATIVE_INFINITY) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
getBufferedRanges() {
|
||||||
|
if (!this.player) return []
|
||||||
|
const ranges = []
|
||||||
|
const seekable = this.player.buffered || []
|
||||||
|
|
||||||
|
let offset = 0
|
||||||
|
|
||||||
|
for (let i = 0, length = seekable.length; i < length; i++) {
|
||||||
|
let start = seekable.start(i)
|
||||||
|
let end = seekable.end(i)
|
||||||
|
if (!this.isValidDuration(start)) {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
if (!this.isValidDuration(end)) {
|
||||||
|
end = 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ranges.push({
|
||||||
|
start: start + offset,
|
||||||
|
end: end + offset
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return ranges
|
||||||
|
}
|
||||||
|
|
||||||
|
getLastBufferedTime() {
|
||||||
|
var bufferedRanges = this.getBufferedRanges()
|
||||||
|
if (!bufferedRanges.length) return 0
|
||||||
|
|
||||||
|
var buff = bufferedRanges.find((buff) => buff.start < this.player.currentTime && buff.end > this.player.currentTime)
|
||||||
|
if (buff) return buff.end
|
||||||
|
|
||||||
|
var last = bufferedRanges[bufferedRanges.length - 1]
|
||||||
|
return last.end
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import LocalPlayer from './LocalPlayer'
|
import LocalAudioPlayer from './LocalAudioPlayer'
|
||||||
|
import LocalVideoPlayer from './LocalVideoPlayer'
|
||||||
import CastPlayer from './CastPlayer'
|
import CastPlayer from './CastPlayer'
|
||||||
import AudioTrack from './AudioTrack'
|
import AudioTrack from './AudioTrack'
|
||||||
|
import VideoTrack from './VideoTrack'
|
||||||
|
|
||||||
export default class PlayerHandler {
|
export default class PlayerHandler {
|
||||||
constructor(ctx) {
|
constructor(ctx) {
|
||||||
@@ -14,9 +16,11 @@ export default class PlayerHandler {
|
|||||||
this.player = null
|
this.player = null
|
||||||
this.playerState = 'IDLE'
|
this.playerState = 'IDLE'
|
||||||
this.isHlsTranscode = false
|
this.isHlsTranscode = false
|
||||||
|
this.isVideo = false
|
||||||
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
|
||||||
@@ -34,7 +38,7 @@ export default class PlayerHandler {
|
|||||||
return this.libraryItem && (this.player instanceof CastPlayer)
|
return this.libraryItem && (this.player instanceof CastPlayer)
|
||||||
}
|
}
|
||||||
get isPlayingLocalItem() {
|
get isPlayingLocalItem() {
|
||||||
return this.libraryItem && (this.player instanceof LocalPlayer)
|
return this.libraryItem && (this.player instanceof LocalAudioPlayer)
|
||||||
}
|
}
|
||||||
get userToken() {
|
get userToken() {
|
||||||
return this.ctx.$store.getters['user/getToken']
|
return this.ctx.$store.getters['user/getToken']
|
||||||
@@ -48,16 +52,17 @@ export default class PlayerHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
load(libraryItem, episodeId, playWhenReady, playbackRate) {
|
load(libraryItem, episodeId, playWhenReady, playbackRate) {
|
||||||
if (!this.player) this.switchPlayer()
|
|
||||||
|
|
||||||
this.libraryItem = libraryItem
|
this.libraryItem = libraryItem
|
||||||
this.episodeId = episodeId
|
this.episodeId = episodeId
|
||||||
this.playWhenReady = playWhenReady
|
this.playWhenReady = playWhenReady
|
||||||
this.initialPlaybackRate = playbackRate
|
this.initialPlaybackRate = playbackRate
|
||||||
this.prepare()
|
this.isVideo = libraryItem.mediaType === 'video'
|
||||||
|
|
||||||
|
if (!this.player) this.switchPlayer(playWhenReady)
|
||||||
|
else this.prepare()
|
||||||
}
|
}
|
||||||
|
|
||||||
switchPlayer() {
|
switchPlayer(playWhenReady) {
|
||||||
if (this.isCasting && !(this.player instanceof CastPlayer)) {
|
if (this.isCasting && !(this.player instanceof CastPlayer)) {
|
||||||
console.log('[PlayerHandler] Switching to cast player')
|
console.log('[PlayerHandler] Switching to cast player')
|
||||||
|
|
||||||
@@ -73,10 +78,10 @@ export default class PlayerHandler {
|
|||||||
|
|
||||||
if (this.libraryItem) {
|
if (this.libraryItem) {
|
||||||
// libraryItem was already loaded - prepare for cast
|
// libraryItem was already loaded - prepare for cast
|
||||||
this.playWhenReady = false
|
this.playWhenReady = playWhenReady
|
||||||
this.prepare()
|
this.prepare()
|
||||||
}
|
}
|
||||||
} else if (!this.isCasting && !(this.player instanceof LocalPlayer)) {
|
} else if (!this.isCasting && !(this.player instanceof LocalAudioPlayer) && !(this.player instanceof LocalVideoPlayer)) {
|
||||||
console.log('[PlayerHandler] Switching to local player')
|
console.log('[PlayerHandler] Switching to local player')
|
||||||
|
|
||||||
this.stopPlayInterval()
|
this.stopPlayInterval()
|
||||||
@@ -85,12 +90,18 @@ export default class PlayerHandler {
|
|||||||
if (this.player) {
|
if (this.player) {
|
||||||
this.player.destroy()
|
this.player.destroy()
|
||||||
}
|
}
|
||||||
this.player = new LocalPlayer(this.ctx)
|
|
||||||
|
if (this.isVideo) {
|
||||||
|
this.player = new LocalVideoPlayer(this.ctx)
|
||||||
|
} else {
|
||||||
|
this.player = new LocalAudioPlayer(this.ctx)
|
||||||
|
}
|
||||||
|
|
||||||
this.setPlayerListeners()
|
this.setPlayerListeners()
|
||||||
|
|
||||||
if (this.libraryItem) {
|
if (this.libraryItem) {
|
||||||
// libraryItem was already loaded - prepare for local play
|
// libraryItem was already loaded - prepare for local play
|
||||||
this.playWhenReady = false
|
this.playWhenReady = playWhenReady
|
||||||
this.prepare()
|
this.prepare()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,16 +112,27 @@ export default class PlayerHandler {
|
|||||||
this.player.on('timeupdate', this.playerTimeupdate.bind(this))
|
this.player.on('timeupdate', this.playerTimeupdate.bind(this))
|
||||||
this.player.on('buffertimeUpdate', this.playerBufferTimeUpdate.bind(this))
|
this.player.on('buffertimeUpdate', this.playerBufferTimeUpdate.bind(this))
|
||||||
this.player.on('error', this.playerError.bind(this))
|
this.player.on('error', this.playerError.bind(this))
|
||||||
|
this.player.on('finished', this.playerFinished.bind(this))
|
||||||
}
|
}
|
||||||
|
|
||||||
playerError() {
|
playerError() {
|
||||||
// Switch to HLS stream on error
|
// Switch to HLS stream on error
|
||||||
if (!this.isCasting && !this.currentStreamId && (this.player instanceof LocalPlayer)) {
|
if (!this.isCasting && !this.currentStreamId && (this.player instanceof LocalAudioPlayer)) {
|
||||||
console.log(`[PlayerHandler] Audio player error switching to HLS stream`)
|
console.log(`[PlayerHandler] Audio player error switching to HLS stream`)
|
||||||
this.prepare(true)
|
this.prepare(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
playerFinished() {
|
||||||
|
this.stopPlayInterval()
|
||||||
|
|
||||||
|
var currentTime = this.player.getCurrentTime()
|
||||||
|
this.ctx.setCurrentTime(currentTime)
|
||||||
|
|
||||||
|
// TODO: Add listening time between last sync and now?
|
||||||
|
this.sendProgressSync(currentTime)
|
||||||
|
}
|
||||||
|
|
||||||
playerStateChange(state) {
|
playerStateChange(state) {
|
||||||
console.log('[PlayerHandler] Player state change', state)
|
console.log('[PlayerHandler] Player state change', state)
|
||||||
this.playerState = state
|
this.playerState = state
|
||||||
@@ -144,7 +166,7 @@ export default class PlayerHandler {
|
|||||||
supportedMimeTypes: this.player.playableMimeTypes,
|
supportedMimeTypes: this.player.playableMimeTypes,
|
||||||
mediaPlayer: this.isCasting ? 'chromecast' : 'html5',
|
mediaPlayer: this.isCasting ? 'chromecast' : 'html5',
|
||||||
forceTranscode,
|
forceTranscode,
|
||||||
forceDirectPlay: this.isCasting // TODO: add transcode support for chromecast
|
forceDirectPlay: this.isCasting || this.isVideo // TODO: add transcode support for chromecast
|
||||||
}
|
}
|
||||||
|
|
||||||
var path = this.episodeId ? `/api/items/${this.libraryItem.id}/play/${this.episodeId}` : `/api/items/${this.libraryItem.id}/play`
|
var path = this.episodeId ? `/api/items/${this.libraryItem.id}/play/${this.episodeId}` : `/api/items/${this.libraryItem.id}/play`
|
||||||
@@ -155,31 +177,46 @@ 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()
|
|
||||||
|
|
||||||
this.libraryItem = session.libraryItem
|
this.libraryItem = session.libraryItem
|
||||||
|
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
|
||||||
this.displayAuthor = session.displayAuthor
|
this.displayAuthor = session.displayAuthor
|
||||||
|
|
||||||
console.log('[PlayerHandler] Preparing Session', session)
|
console.log('[PlayerHandler] Preparing Session', session)
|
||||||
var audioTracks = session.audioTracks.map(at => new AudioTrack(at, this.userToken))
|
|
||||||
|
|
||||||
this.ctx.playerLoading = true
|
if (session.videoTrack) {
|
||||||
this.isHlsTranscode = true
|
var videoTrack = new VideoTrack(session.videoTrack, this.userToken)
|
||||||
if (session.playMethod === this.ctx.$constants.PlayMethod.DIRECTPLAY) {
|
|
||||||
this.isHlsTranscode = false
|
this.ctx.playerLoading = true
|
||||||
|
this.isHlsTranscode = true
|
||||||
|
if (session.playMethod === this.ctx.$constants.PlayMethod.DIRECTPLAY) {
|
||||||
|
this.isHlsTranscode = false
|
||||||
|
}
|
||||||
|
|
||||||
|
this.player.set(this.libraryItem, videoTrack, this.isHlsTranscode, this.startTime, this.playWhenReady)
|
||||||
|
} else {
|
||||||
|
var audioTracks = session.audioTracks.map(at => new AudioTrack(at, this.userToken))
|
||||||
|
|
||||||
|
this.ctx.playerLoading = true
|
||||||
|
this.isHlsTranscode = true
|
||||||
|
if (session.playMethod === this.ctx.$constants.PlayMethod.DIRECTPLAY) {
|
||||||
|
this.isHlsTranscode = false
|
||||||
|
}
|
||||||
|
|
||||||
|
this.player.set(this.libraryItem, audioTracks, this.isHlsTranscode, this.startTime, this.playWhenReady)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.player.set(this.libraryItem, audioTracks, this.isHlsTranscode, this.startTime, this.playWhenReady)
|
|
||||||
|
|
||||||
// browser media session api
|
// browser media session api
|
||||||
this.ctx.setMediaSession()
|
this.ctx.setMediaSession()
|
||||||
}
|
}
|
||||||
@@ -251,8 +288,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
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
export default class VideoTrack {
|
||||||
|
constructor(track, userToken) {
|
||||||
|
this.index = track.index || 0
|
||||||
|
this.startOffset = track.startOffset || 0 // Total time of all previous tracks
|
||||||
|
this.duration = track.duration || 0
|
||||||
|
this.title = track.title || ''
|
||||||
|
this.contentUrl = track.contentUrl || null
|
||||||
|
this.mimeType = track.mimeType
|
||||||
|
this.metadata = track.metadata || {}
|
||||||
|
|
||||||
|
this.userToken = userToken
|
||||||
|
}
|
||||||
|
|
||||||
|
get fullContentUrl() {
|
||||||
|
if (!this.contentUrl || this.contentUrl.startsWith('http')) return this.contentUrl
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
return `${process.env.serverUrl}${this.contentUrl}?token=${this.userToken}`
|
||||||
|
}
|
||||||
|
return `${window.location.origin}${this.contentUrl}?token=${this.userToken}`
|
||||||
|
}
|
||||||
|
|
||||||
|
get relativeContentUrl() {
|
||||||
|
if (!this.contentUrl || this.contentUrl.startsWith('http')) return this.contentUrl
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
return `${process.env.serverUrl}${this.contentUrl}?token=${this.userToken}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.contentUrl + `?token=${this.userToken}`
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'],
|
audio: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', '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'],
|
||||||
@@ -28,7 +28,8 @@ const BookshelfView = {
|
|||||||
const PlayMethod = {
|
const PlayMethod = {
|
||||||
DIRECTPLAY: 0,
|
DIRECTPLAY: 0,
|
||||||
DIRECTSTREAM: 1,
|
DIRECTSTREAM: 1,
|
||||||
TRANSCODE: 2
|
TRANSCODE: 2,
|
||||||
|
LOCAL: 3
|
||||||
}
|
}
|
||||||
|
|
||||||
const Constants = {
|
const Constants = {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Vue from 'vue'
|
import Vue from 'vue'
|
||||||
|
import Path from 'path'
|
||||||
import vClickOutside from 'v-click-outside'
|
import vClickOutside from 'v-click-outside'
|
||||||
import { formatDistance, format, addDays, isDate } from 'date-fns'
|
import { formatDistance, format, addDays, isDate } from 'date-fns'
|
||||||
|
|
||||||
@@ -57,6 +58,7 @@ Vue.prototype.$elapsedPretty = (seconds, useFullNames = false) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Vue.prototype.$secondsToTimestamp = (seconds) => {
|
Vue.prototype.$secondsToTimestamp = (seconds) => {
|
||||||
|
if (!seconds) return '0:00'
|
||||||
var _seconds = seconds
|
var _seconds = seconds
|
||||||
var _minutes = Math.floor(seconds / 60)
|
var _minutes = Math.floor(seconds / 60)
|
||||||
_seconds -= _minutes * 60
|
_seconds -= _minutes * 60
|
||||||
@@ -118,20 +120,36 @@ Vue.prototype.$sanitizeFilename = (input, colonReplacement = ' - ') => {
|
|||||||
if (typeof input !== 'string') {
|
if (typeof input !== 'string') {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Max is actually 255-260 for windows but this leaves padding incase ext wasnt put on yet
|
||||||
|
const MAX_FILENAME_LEN = 240
|
||||||
|
|
||||||
var replacement = ''
|
var replacement = ''
|
||||||
var illegalRe = /[\/\?<>\\:\*\|"]/g;
|
var illegalRe = /[\/\?<>\\:\*\|"]/g
|
||||||
var controlRe = /[\x00-\x1f\x80-\x9f]/g;
|
var controlRe = /[\x00-\x1f\x80-\x9f]/g
|
||||||
var reservedRe = /^\.+$/;
|
var reservedRe = /^\.+$/
|
||||||
var windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i;
|
var windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i
|
||||||
var windowsTrailingRe = /[\. ]+$/;
|
var windowsTrailingRe = /[\. ]+$/
|
||||||
|
var lineBreaks = /[\n\r]/g
|
||||||
|
|
||||||
var sanitized = input
|
var sanitized = input
|
||||||
.replace(':', colonReplacement) // Replace first occurrence of a colon
|
.replace(':', colonReplacement) // Replace first occurrence of a colon
|
||||||
.replace(illegalRe, replacement)
|
.replace(illegalRe, replacement)
|
||||||
.replace(controlRe, replacement)
|
.replace(controlRe, replacement)
|
||||||
.replace(reservedRe, replacement)
|
.replace(reservedRe, replacement)
|
||||||
|
.replace(lineBreaks, replacement)
|
||||||
.replace(windowsReservedRe, replacement)
|
.replace(windowsReservedRe, replacement)
|
||||||
.replace(windowsTrailingRe, replacement);
|
.replace(windowsTrailingRe, replacement)
|
||||||
|
|
||||||
|
|
||||||
|
if (sanitized.length > MAX_FILENAME_LEN) {
|
||||||
|
var lenToRemove = sanitized.length - MAX_FILENAME_LEN
|
||||||
|
var ext = Path.extname(sanitized)
|
||||||
|
var basename = Path.basename(sanitized, ext)
|
||||||
|
basename = basename.slice(0, basename.length - lenToRemove)
|
||||||
|
sanitized = basename + ext
|
||||||
|
}
|
||||||
|
|
||||||
return sanitized
|
return sanitized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,14 +23,17 @@ function parseSemver(ver) {
|
|||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const currentVersion = packagejson.version
|
||||||
|
|
||||||
export async function checkForUpdate() {
|
export async function checkForUpdate() {
|
||||||
if (!packagejson.version) {
|
if (!packagejson.version) {
|
||||||
return
|
return null
|
||||||
}
|
}
|
||||||
var currVerObj = parseSemver('v' + packagejson.version)
|
var currVerObj = parseSemver('v' + packagejson.version)
|
||||||
if (!currVerObj) {
|
if (!currVerObj) {
|
||||||
console.error('Invalid version', packagejson.version)
|
console.error('Invalid version', packagejson.version)
|
||||||
return
|
return null
|
||||||
}
|
}
|
||||||
var largestVer = null
|
var largestVer = null
|
||||||
await axios.get(`https://api.github.com/repos/advplyr/audiobookshelf/releases`).then((res) => {
|
await axios.get(`https://api.github.com/repos/advplyr/audiobookshelf/releases`).then((res) => {
|
||||||
@@ -49,7 +52,7 @@ export async function checkForUpdate() {
|
|||||||
})
|
})
|
||||||
if (!largestVer) {
|
if (!largestVer) {
|
||||||
console.error('No valid version tags to compare with')
|
console.error('No valid version tags to compare with')
|
||||||
return
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 57 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 141 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 209 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 512 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 132 KiB |
@@ -6,11 +6,11 @@ export const state = () => ({
|
|||||||
showUserCollectionsModal: false,
|
showUserCollectionsModal: false,
|
||||||
showEditCollectionModal: false,
|
showEditCollectionModal: false,
|
||||||
showEditPodcastEpisode: false,
|
showEditPodcastEpisode: false,
|
||||||
|
showViewPodcastEpisodeModal: false,
|
||||||
showEditAuthorModal: false,
|
showEditAuthorModal: false,
|
||||||
selectedEpisode: null,
|
selectedEpisode: null,
|
||||||
selectedCollection: null,
|
selectedCollection: null,
|
||||||
selectedAuthor: null,
|
selectedAuthor: null,
|
||||||
showBookshelfTextureModal: false,
|
|
||||||
isCasting: false, // Actively casting
|
isCasting: false, // Actively casting
|
||||||
isChromecastInitialized: false // Script loaded
|
isChromecastInitialized: false // Script loaded
|
||||||
})
|
})
|
||||||
@@ -53,6 +53,9 @@ export const mutations = {
|
|||||||
setShowEditPodcastEpisodeModal(state, val) {
|
setShowEditPodcastEpisodeModal(state, val) {
|
||||||
state.showEditPodcastEpisode = val
|
state.showEditPodcastEpisode = val
|
||||||
},
|
},
|
||||||
|
setShowViewPodcastEpisodeModal(state, val) {
|
||||||
|
state.showViewPodcastEpisodeModal = val
|
||||||
|
},
|
||||||
setEditCollection(state, collection) {
|
setEditCollection(state, collection) {
|
||||||
state.selectedCollection = collection
|
state.selectedCollection = collection
|
||||||
state.showEditCollectionModal = true
|
state.showEditCollectionModal = true
|
||||||
@@ -60,9 +63,6 @@ export const mutations = {
|
|||||||
setSelectedEpisode(state, episode) {
|
setSelectedEpisode(state, episode) {
|
||||||
state.selectedEpisode = episode
|
state.selectedEpisode = episode
|
||||||
},
|
},
|
||||||
setShowBookshelfTextureModal(state, val) {
|
|
||||||
state.showBookshelfTextureModal = val
|
|
||||||
},
|
|
||||||
showEditAuthorModal(state, author) {
|
showEditAuthorModal(state, author) {
|
||||||
state.selectedAuthor = author
|
state.selectedAuthor = author
|
||||||
state.showEditAuthorModal = true
|
state.showEditAuthorModal = true
|
||||||
|
|||||||
+46
-18
@@ -1,4 +1,4 @@
|
|||||||
import { checkForUpdate } from '@/plugins/version'
|
import { checkForUpdate, currentVersion } from '@/plugins/version'
|
||||||
import Vue from 'vue'
|
import Vue from 'vue'
|
||||||
|
|
||||||
export const state = () => ({
|
export const state = () => ({
|
||||||
@@ -8,6 +8,7 @@ export const state = () => ({
|
|||||||
streamLibraryItem: null,
|
streamLibraryItem: null,
|
||||||
streamEpisodeId: null,
|
streamEpisodeId: null,
|
||||||
streamIsPlaying: false,
|
streamIsPlaying: false,
|
||||||
|
playerIsFullscreen: false,
|
||||||
editModalTab: 'details',
|
editModalTab: 'details',
|
||||||
showEditModal: false,
|
showEditModal: false,
|
||||||
showEReader: false,
|
showEReader: false,
|
||||||
@@ -20,7 +21,7 @@ export const state = () => ({
|
|||||||
backups: [],
|
backups: [],
|
||||||
bookshelfBookIds: [],
|
bookshelfBookIds: [],
|
||||||
openModal: null,
|
openModal: null,
|
||||||
selectedBookshelfTexture: '/textures/wood_default.jpg',
|
innerModalOpen: false,
|
||||||
lastBookshelfScrollData: {}
|
lastBookshelfScrollData: {}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -64,20 +65,44 @@ export const actions = {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
checkForUpdate({ commit }) {
|
checkForUpdate({ commit }) {
|
||||||
return checkForUpdate()
|
const VERSION_CHECK_BUFF = 1000 * 60 * 5 // 5 minutes
|
||||||
.then((res) => {
|
var lastVerCheck = localStorage.getItem('lastVerCheck') || 0
|
||||||
commit('setVersionData', res)
|
var savedVersionData = localStorage.getItem('versionData')
|
||||||
return res
|
if (savedVersionData) {
|
||||||
})
|
try {
|
||||||
.catch((error) => {
|
savedVersionData = JSON.parse(localStorage.getItem('versionData'))
|
||||||
console.error('Update check failed', error)
|
} catch (error) {
|
||||||
return false
|
console.error('Failed to parse version data', error)
|
||||||
})
|
savedVersionData = null
|
||||||
},
|
localStorage.removeItem('versionData')
|
||||||
setBookshelfTexture({ commit, state }, img) {
|
}
|
||||||
let root = document.documentElement;
|
}
|
||||||
commit('setBookshelfTexture', img)
|
|
||||||
root.style.setProperty('--bookshelf-texture-img', `url(${img})`);
|
var shouldCheckForUpdate = Date.now() - Number(lastVerCheck) > VERSION_CHECK_BUFF
|
||||||
|
if (!shouldCheckForUpdate && savedVersionData && savedVersionData.version !== currentVersion) {
|
||||||
|
// Version mismatch between saved data so check for update anyway
|
||||||
|
shouldCheckForUpdate = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldCheckForUpdate) {
|
||||||
|
return checkForUpdate()
|
||||||
|
.then((res) => {
|
||||||
|
if (res) {
|
||||||
|
localStorage.setItem('lastVerCheck', Date.now())
|
||||||
|
localStorage.setItem('versionData', JSON.stringify(res))
|
||||||
|
|
||||||
|
commit('setVersionData', res)
|
||||||
|
}
|
||||||
|
return res && res.hasUpdate
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Update check failed', error)
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
} else if (savedVersionData) {
|
||||||
|
commit('setVersionData', savedVersionData)
|
||||||
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +110,9 @@ export const mutations = {
|
|||||||
setSource(state, source) {
|
setSource(state, source) {
|
||||||
state.Source = source
|
state.Source = source
|
||||||
},
|
},
|
||||||
|
setPlayerIsFullscreen(state, val) {
|
||||||
|
state.playerIsFullscreen = val
|
||||||
|
},
|
||||||
setLastBookshelfScrollData(state, { scrollTop, path, name }) {
|
setLastBookshelfScrollData(state, { scrollTop, path, name }) {
|
||||||
state.lastBookshelfScrollData[name] = { scrollTop, path }
|
state.lastBookshelfScrollData[name] = { scrollTop, path }
|
||||||
},
|
},
|
||||||
@@ -177,7 +205,7 @@ export const mutations = {
|
|||||||
setOpenModal(state, val) {
|
setOpenModal(state, val) {
|
||||||
state.openModal = val
|
state.openModal = val
|
||||||
},
|
},
|
||||||
setBookshelfTexture(state, val) {
|
setInnerModalOpen(state, val) {
|
||||||
state.selectedBookshelfTexture = val
|
state.innerModalOpen = val
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,8 @@ module.exports = {
|
|||||||
'bg-red-600',
|
'bg-red-600',
|
||||||
'text-green-500',
|
'text-green-500',
|
||||||
'py-1.5',
|
'py-1.5',
|
||||||
'bg-info'
|
'bg-info',
|
||||||
|
'px-1.5'
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -37,6 +38,7 @@ module.exports = {
|
|||||||
minWidth: {
|
minWidth: {
|
||||||
'6': '1.5rem',
|
'6': '1.5rem',
|
||||||
'12': '3rem',
|
'12': '3rem',
|
||||||
|
'16': '4rem',
|
||||||
'24': '6rem',
|
'24': '6rem',
|
||||||
'32': '8rem',
|
'32': '8rem',
|
||||||
'48': '12rem',
|
'48': '12rem',
|
||||||
@@ -75,6 +77,9 @@ module.exports = {
|
|||||||
mono: ['Ubuntu Mono', ...defaultTheme.fontFamily.mono],
|
mono: ['Ubuntu Mono', ...defaultTheme.fontFamily.mono],
|
||||||
book: ['Gentium Book Basic', 'serif']
|
book: ['Gentium Book Basic', 'serif']
|
||||||
},
|
},
|
||||||
|
fontSize: {
|
||||||
|
xxs: '0.625rem'
|
||||||
|
},
|
||||||
zIndex: {
|
zIndex: {
|
||||||
'50': 50
|
'50': 50
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+255
-501
File diff suppressed because it is too large
Load Diff
+2
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.0.17",
|
"version": "2.0.19",
|
||||||
"description": "Self-hosted audiobook and podcast server",
|
"description": "Self-hosted audiobook and podcast server",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -38,6 +38,7 @@
|
|||||||
"fast-sort": "^3.1.1",
|
"fast-sort": "^3.1.1",
|
||||||
"fluent-ffmpeg": "^2.1.2",
|
"fluent-ffmpeg": "^2.1.2",
|
||||||
"fs-extra": "^10.0.0",
|
"fs-extra": "^10.0.0",
|
||||||
|
"htmlparser2": "^8.0.1",
|
||||||
"image-type": "^4.1.0",
|
"image-type": "^4.1.0",
|
||||||
"jsonwebtoken": "^8.5.1",
|
"jsonwebtoken": "^8.5.1",
|
||||||
"libgen": "^2.1.0",
|
"libgen": "^2.1.0",
|
||||||
@@ -49,8 +50,6 @@
|
|||||||
"read-chunk": "^3.1.0",
|
"read-chunk": "^3.1.0",
|
||||||
"recursive-readdir-async": "^1.1.8",
|
"recursive-readdir-async": "^1.1.8",
|
||||||
"socket.io": "^4.4.1",
|
"socket.io": "^4.4.1",
|
||||||
"string-strip-html": "^8.3.0",
|
|
||||||
"watcher": "^1.2.0",
|
|
||||||
"xml2js": "^0.4.23"
|
"xml2js": "^0.4.23"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
```
|
```
|
||||||
@@ -195,7 +196,7 @@ server
|
|||||||
proxy_redirect http:// https://;
|
proxy_redirect http:// https://;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Apache Reverse Proxy
|
### Apache Reverse Proxy
|
||||||
|
|
||||||
|
|||||||
@@ -104,7 +104,6 @@ class Auth {
|
|||||||
async login(req, res) {
|
async login(req, res) {
|
||||||
var username = (req.body.username || '').toLowerCase()
|
var username = (req.body.username || '').toLowerCase()
|
||||||
var password = req.body.password || ''
|
var password = req.body.password || ''
|
||||||
Logger.debug('Check Auth', username, !!password)
|
|
||||||
|
|
||||||
var user = this.users.find(u => u.username.toLowerCase() === username)
|
var user = this.users.find(u => u.username.toLowerCase() === username)
|
||||||
|
|
||||||
|
|||||||
@@ -428,6 +428,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 || []
|
||||||
|
|||||||
+3
-4
@@ -173,9 +173,6 @@ class Server {
|
|||||||
// Metadata folder static path
|
// Metadata folder static path
|
||||||
app.use('/metadata', this.authMiddleware.bind(this), express.static(global.MetadataPath))
|
app.use('/metadata', this.authMiddleware.bind(this), express.static(global.MetadataPath))
|
||||||
|
|
||||||
// TODO: Are these necessary?
|
|
||||||
// Downloads folder static path
|
|
||||||
// app.use('/downloads', this.authMiddleware.bind(this), express.static(this.downloadManager.downloadDirPath))
|
|
||||||
// Static folder
|
// Static folder
|
||||||
app.use(express.static(Path.join(global.appRoot, 'static')))
|
app.use(express.static(Path.join(global.appRoot, 'static')))
|
||||||
|
|
||||||
@@ -212,7 +209,8 @@ class Server {
|
|||||||
const dyanimicRoutes = [
|
const dyanimicRoutes = [
|
||||||
'/item/:id',
|
'/item/:id',
|
||||||
'/item/:id/manage',
|
'/item/:id/manage',
|
||||||
'/item/:id/chapters',
|
'/author/:id',
|
||||||
|
'/audiobook/:id/chapters',
|
||||||
'/audiobook/:id/edit',
|
'/audiobook/:id/edit',
|
||||||
'/library/:library',
|
'/library/:library',
|
||||||
'/library/:library/search',
|
'/library/:library/search',
|
||||||
@@ -220,6 +218,7 @@ class Server {
|
|||||||
'/library/:library/authors',
|
'/library/:library/authors',
|
||||||
'/library/:library/series/:id?',
|
'/library/:library/series/:id?',
|
||||||
'/config/users/:id',
|
'/config/users/:id',
|
||||||
|
'/config/users/:id/sessions',
|
||||||
'/collection/:id'
|
'/collection/:id'
|
||||||
]
|
]
|
||||||
dyanimicRoutes.forEach((route) => app.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html'))))
|
dyanimicRoutes.forEach((route) => app.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html'))))
|
||||||
|
|||||||
+1
-2
@@ -1,6 +1,5 @@
|
|||||||
const Path = require('path')
|
|
||||||
const EventEmitter = require('events')
|
const EventEmitter = require('events')
|
||||||
const Watcher = require('watcher')
|
const Watcher = require('./libs/watcher/watcher')
|
||||||
const Logger = require('./Logger')
|
const Logger = require('./Logger')
|
||||||
|
|
||||||
class FolderWatcher extends EventEmitter {
|
class FolderWatcher extends EventEmitter {
|
||||||
|
|||||||
@@ -485,8 +485,7 @@ class LibraryController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
middleware(req, res, next) {
|
middleware(req, res, next) {
|
||||||
var librariesAccessible = req.user.librariesAccessible || []
|
if (!req.user.checkCanAccessLibrary(req.params.id)) {
|
||||||
if (librariesAccessible && librariesAccessible.length && !librariesAccessible.includes(req.params.id)) {
|
|
||||||
Logger.warn(`[LibraryController] Library ${req.params.id} not accessible to user ${req.user.username}`)
|
Logger.warn(`[LibraryController] Library ${req.params.id} not accessible to user ${req.user.username}`)
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
}
|
}
|
||||||
@@ -497,7 +496,7 @@ class LibraryController {
|
|||||||
}
|
}
|
||||||
req.library = library
|
req.library = library
|
||||||
req.libraryItems = this.db.libraryItems.filter(li => {
|
req.libraryItems = this.db.libraryItems.filter(li => {
|
||||||
return li.libraryId === library.id && req.user.checkCanAccessLibraryItemWithTags(li.media.tags)
|
return li.libraryId === library.id && req.user.checkCanAccessLibraryItem(li)
|
||||||
})
|
})
|
||||||
next()
|
next()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ class LibraryItemController {
|
|||||||
|
|
||||||
var hasUpdates = libraryItem.update(req.body)
|
var hasUpdates = libraryItem.update(req.body)
|
||||||
if (hasUpdates) {
|
if (hasUpdates) {
|
||||||
|
|
||||||
// Turn on podcast auto download cron if not already on
|
// Turn on podcast auto download cron if not already on
|
||||||
if (libraryItem.mediaType == 'podcast' && req.body.media.autoDownloadEpisodes && !this.podcastManager.episodeScheduleTask) {
|
if (libraryItem.mediaType == 'podcast' && req.body.media.autoDownloadEpisodes && !this.podcastManager.episodeScheduleTask) {
|
||||||
this.podcastManager.schedulePodcastEpisodeCron()
|
this.podcastManager.schedulePodcastEpisodeCron()
|
||||||
@@ -185,12 +184,12 @@ class LibraryItemController {
|
|||||||
|
|
||||||
// POST: api/items/:id/play
|
// POST: api/items/:id/play
|
||||||
startPlaybackSession(req, res) {
|
startPlaybackSession(req, res) {
|
||||||
if (!req.libraryItem.media.numTracks) {
|
if (!req.libraryItem.media.numTracks && req.libraryItem.mediaType !== 'video') {
|
||||||
Logger.error(`[LibraryItemController] startPlaybackSession cannot playback ${req.libraryItem.id}`)
|
Logger.error(`[LibraryItemController] startPlaybackSession cannot playback ${req.libraryItem.id}`)
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
}
|
}
|
||||||
const options = req.body || {}
|
|
||||||
this.playbackSessionManager.startSessionRequest(req.user, req.libraryItem, null, options, res)
|
this.playbackSessionManager.startSessionRequest(req, res, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST: api/items/:id/play/:episodeId
|
// POST: api/items/:id/play/:episodeId
|
||||||
@@ -206,8 +205,7 @@ class LibraryItemController {
|
|||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = req.body || {}
|
this.playbackSessionManager.startSessionRequest(req, res, episodeId)
|
||||||
this.playbackSessionManager.startSessionRequest(req.user, libraryItem, episodeId, options, res)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PATCH: api/items/:id/tracks
|
// PATCH: api/items/:id/tracks
|
||||||
|
|||||||
@@ -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.id, req.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)
|
||||||
|
|||||||
@@ -104,15 +104,13 @@ class PodcastController {
|
|||||||
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(`[PdocastController] Podcast feed size ${(data.data.length / 1024 / 1024).toFixed(2)}MB`)
|
||||||
var payload = await parsePodcastRssFeedXml(data.data, 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')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!payload.podcast.metadata.feedUrl) {
|
// RSS feed may be a private RSS feed
|
||||||
// Not every RSS feed will put the feed url in their metadata
|
payload.podcast.metadata.feedUrl = url
|
||||||
payload.podcast.metadata.feedUrl = url
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(payload)
|
res.json(payload)
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
@@ -121,6 +119,15 @@ class PodcastController {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getOPMLFeeds(req, res) {
|
||||||
|
if (!req.body.opmlText) {
|
||||||
|
return res.sendStatus(400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rssFeedsData = await this.podcastManager.getOPMLFeeds(req.body.opmlText)
|
||||||
|
res.json(rssFeedsData)
|
||||||
|
}
|
||||||
|
|
||||||
async checkNewEpisodes(req, res) {
|
async checkNewEpisodes(req, res) {
|
||||||
if (!req.user.isAdminOrUp) {
|
if (!req.user.isAdminOrUp) {
|
||||||
Logger.error(`[PodcastController] Non-admin user attempted to check/download episodes`, req.user)
|
Logger.error(`[PodcastController] Non-admin user attempted to check/download episodes`, req.user)
|
||||||
@@ -212,7 +219,11 @@ class PodcastController {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
libraryItem.media.removeEpisode(episodeId)
|
// Remove episode from Podcast and library file
|
||||||
|
const episodeRemoved = libraryItem.media.removeEpisode(episodeId)
|
||||||
|
if (episodeRemoved && episodeRemoved.audioFile) {
|
||||||
|
libraryItem.removeLibraryFile(episodeRemoved.audioFile.ino)
|
||||||
|
}
|
||||||
|
|
||||||
await this.db.updateLibraryItem(libraryItem)
|
await this.db.updateLibraryItem(libraryItem)
|
||||||
this.emitter('item_updated', libraryItem.toJSONExpanded())
|
this.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||||
@@ -227,13 +238,8 @@ class PodcastController {
|
|||||||
return res.sendStatus(500)
|
return res.sendStatus(500)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check user can access this library
|
|
||||||
if (!req.user.checkCanAccessLibrary(item.libraryId)) {
|
|
||||||
return res.sendStatus(403)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check user can access this library item
|
// Check user can access this library item
|
||||||
if (!req.user.checkCanAccessLibraryItemWithTags(item.media.tags)) {
|
if (!req.user.checkCanAccessLibraryItem(item)) {
|
||||||
return res.sendStatus(403)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -166,14 +166,14 @@ class BookFinder {
|
|||||||
return this.iTunesApi.searchAudiobooks(title)
|
return this.iTunesApi.searchAudiobooks(title)
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAudibleResults(title, author) {
|
async getAudibleResults(title, author, asin) {
|
||||||
var books = await this.audible.search(title, author);
|
var books = await this.audible.search(title, author, asin);
|
||||||
if (this.verbose) Logger.debug(`Audible Book Search Results: ${books.length || 0}`)
|
if (this.verbose) Logger.debug(`Audible Book Search Results: ${books.length || 0}`)
|
||||||
if (!books) return []
|
if (!books) return []
|
||||||
return books
|
return books
|
||||||
}
|
}
|
||||||
|
|
||||||
async search(provider, title, author, options = {}) {
|
async search(provider, title, author, isbn, asin, options = {}) {
|
||||||
var books = []
|
var books = []
|
||||||
var maxTitleDistance = !isNaN(options.titleDistance) ? Number(options.titleDistance) : 4
|
var maxTitleDistance = !isNaN(options.titleDistance) ? Number(options.titleDistance) : 4
|
||||||
var maxAuthorDistance = !isNaN(options.authorDistance) ? Number(options.authorDistance) : 4
|
var maxAuthorDistance = !isNaN(options.authorDistance) ? Number(options.authorDistance) : 4
|
||||||
@@ -182,7 +182,7 @@ class BookFinder {
|
|||||||
if (provider === 'google') {
|
if (provider === 'google') {
|
||||||
return this.getGoogleBooksResults(title, author)
|
return this.getGoogleBooksResults(title, author)
|
||||||
} else if (provider === 'audible') {
|
} else if (provider === 'audible') {
|
||||||
return this.getAudibleResults(title, author)
|
return this.getAudibleResults(title, author, asin)
|
||||||
} else if (provider === 'itunes') {
|
} else if (provider === 'itunes') {
|
||||||
return this.getiTunesAudiobooksResults(title, author)
|
return this.getiTunesAudiobooksResults(title, author)
|
||||||
} else if (provider === 'libgen') {
|
} else if (provider === 'libgen') {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,174 @@
|
|||||||
|
// SOURCE: https://github.com/pbojinov/request-ip
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
function _typeof(obj) { if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") { _typeof = function _typeof(obj) { return typeof obj; }; } else { _typeof = function _typeof(obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; } return _typeof(obj); }
|
||||||
|
|
||||||
|
var is = require('./isJs');
|
||||||
|
/**
|
||||||
|
* Parse x-forwarded-for headers.
|
||||||
|
*
|
||||||
|
* @param {string} value - The value to be parsed.
|
||||||
|
* @return {string|null} First known IP address, if any.
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
function getClientIpFromXForwardedFor(value) {
|
||||||
|
if (!is.existy(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is.not.string(value)) {
|
||||||
|
throw new TypeError("Expected a string, got \"".concat(_typeof(value), "\""));
|
||||||
|
} // x-forwarded-for may return multiple IP addresses in the format:
|
||||||
|
// "client IP, proxy 1 IP, proxy 2 IP"
|
||||||
|
// Therefore, the right-most IP address is the IP address of the most recent proxy
|
||||||
|
// and the left-most IP address is the IP address of the originating client.
|
||||||
|
// source: http://docs.aws.amazon.com/elasticloadbalancing/latest/classic/x-forwarded-headers.html
|
||||||
|
// Azure Web App's also adds a port for some reason, so we'll only use the first part (the IP)
|
||||||
|
|
||||||
|
|
||||||
|
var forwardedIps = value.split(',').map(function (e) {
|
||||||
|
var ip = e.trim();
|
||||||
|
|
||||||
|
if (ip.includes(':')) {
|
||||||
|
var splitted = ip.split(':'); // make sure we only use this if it's ipv4 (ip:port)
|
||||||
|
|
||||||
|
if (splitted.length === 2) {
|
||||||
|
return splitted[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ip;
|
||||||
|
}); // Sometimes IP addresses in this header can be 'unknown' (http://stackoverflow.com/a/11285650).
|
||||||
|
// Therefore taking the left-most IP address that is not unknown
|
||||||
|
// A Squid configuration directive can also set the value to "unknown" (http://www.squid-cache.org/Doc/config/forwarded_for/)
|
||||||
|
|
||||||
|
return forwardedIps.find(is.ip);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Determine client IP address.
|
||||||
|
*
|
||||||
|
* @param req
|
||||||
|
* @returns {string} ip - The IP address if known, defaulting to empty string if unknown.
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
function getClientIp(req) {
|
||||||
|
// Server is probably behind a proxy.
|
||||||
|
if (req.headers) {
|
||||||
|
// Standard headers used by Amazon EC2, Heroku, and others.
|
||||||
|
if (is.ip(req.headers['x-client-ip'])) {
|
||||||
|
return req.headers['x-client-ip'];
|
||||||
|
} // Load-balancers (AWS ELB) or proxies.
|
||||||
|
|
||||||
|
|
||||||
|
var xForwardedFor = getClientIpFromXForwardedFor(req.headers['x-forwarded-for']);
|
||||||
|
|
||||||
|
if (is.ip(xForwardedFor)) {
|
||||||
|
return xForwardedFor;
|
||||||
|
} // Cloudflare.
|
||||||
|
// @see https://support.cloudflare.com/hc/en-us/articles/200170986-How-does-Cloudflare-handle-HTTP-Request-headers-
|
||||||
|
// CF-Connecting-IP - applied to every request to the origin.
|
||||||
|
|
||||||
|
|
||||||
|
if (is.ip(req.headers['cf-connecting-ip'])) {
|
||||||
|
return req.headers['cf-connecting-ip'];
|
||||||
|
} // Fastly and Firebase hosting header (When forwared to cloud function)
|
||||||
|
|
||||||
|
|
||||||
|
if (is.ip(req.headers['fastly-client-ip'])) {
|
||||||
|
return req.headers['fastly-client-ip'];
|
||||||
|
} // Akamai and Cloudflare: True-Client-IP.
|
||||||
|
|
||||||
|
|
||||||
|
if (is.ip(req.headers['true-client-ip'])) {
|
||||||
|
return req.headers['true-client-ip'];
|
||||||
|
} // Default nginx proxy/fcgi; alternative to x-forwarded-for, used by some proxies.
|
||||||
|
|
||||||
|
|
||||||
|
if (is.ip(req.headers['x-real-ip'])) {
|
||||||
|
return req.headers['x-real-ip'];
|
||||||
|
} // (Rackspace LB and Riverbed's Stingray)
|
||||||
|
// http://www.rackspace.com/knowledge_center/article/controlling-access-to-linux-cloud-sites-based-on-the-client-ip-address
|
||||||
|
// https://splash.riverbed.com/docs/DOC-1926
|
||||||
|
|
||||||
|
|
||||||
|
if (is.ip(req.headers['x-cluster-client-ip'])) {
|
||||||
|
return req.headers['x-cluster-client-ip'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is.ip(req.headers['x-forwarded'])) {
|
||||||
|
return req.headers['x-forwarded'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is.ip(req.headers['forwarded-for'])) {
|
||||||
|
return req.headers['forwarded-for'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is.ip(req.headers.forwarded)) {
|
||||||
|
return req.headers.forwarded;
|
||||||
|
}
|
||||||
|
} // Remote address checks.
|
||||||
|
|
||||||
|
|
||||||
|
if (is.existy(req.connection)) {
|
||||||
|
if (is.ip(req.connection.remoteAddress)) {
|
||||||
|
return req.connection.remoteAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is.existy(req.connection.socket) && is.ip(req.connection.socket.remoteAddress)) {
|
||||||
|
return req.connection.socket.remoteAddress;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is.existy(req.socket) && is.ip(req.socket.remoteAddress)) {
|
||||||
|
return req.socket.remoteAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is.existy(req.info) && is.ip(req.info.remoteAddress)) {
|
||||||
|
return req.info.remoteAddress;
|
||||||
|
} // AWS Api Gateway + Lambda
|
||||||
|
|
||||||
|
|
||||||
|
if (is.existy(req.requestContext) && is.existy(req.requestContext.identity) && is.ip(req.requestContext.identity.sourceIp)) {
|
||||||
|
return req.requestContext.identity.sourceIp;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Expose request IP as a middleware.
|
||||||
|
*
|
||||||
|
* @param {object} [options] - Configuration.
|
||||||
|
* @param {string} [options.attributeName] - Name of attribute to augment request object with.
|
||||||
|
* @return {*}
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
function mw(options) {
|
||||||
|
// Defaults.
|
||||||
|
var configuration = is.not.existy(options) ? {} : options; // Validation.
|
||||||
|
|
||||||
|
if (is.not.object(configuration)) {
|
||||||
|
throw new TypeError('Options must be an object!');
|
||||||
|
}
|
||||||
|
|
||||||
|
var attributeName = configuration.attributeName || 'clientIp';
|
||||||
|
return function (req, res, next) {
|
||||||
|
var ip = getClientIp(req);
|
||||||
|
Object.defineProperty(req, attributeName, {
|
||||||
|
get: function get() {
|
||||||
|
return ip;
|
||||||
|
},
|
||||||
|
configurable: true
|
||||||
|
});
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getClientIpFromXForwardedFor: getClientIpFromXForwardedFor,
|
||||||
|
getClientIp: getClientIp,
|
||||||
|
mw: mw
|
||||||
|
};
|
||||||
@@ -0,0 +1,874 @@
|
|||||||
|
/*
|
||||||
|
sanitize-html (Apostrophe Technologies)
|
||||||
|
SOURCE: https://github.com/apostrophecms/sanitize-html
|
||||||
|
LICENSE: https://github.com/apostrophecms/sanitize-html/blob/main/LICENSE
|
||||||
|
|
||||||
|
Modified for audiobookshelf
|
||||||
|
*/
|
||||||
|
|
||||||
|
const htmlparser = require('htmlparser2');
|
||||||
|
// const escapeStringRegexp = require('escape-string-regexp');
|
||||||
|
// const { isPlainObject } = require('is-plain-object');
|
||||||
|
// const deepmerge = require('deepmerge');
|
||||||
|
// const parseSrcset = require('parse-srcset');
|
||||||
|
// const { parse: postcssParse } = require('postcss');
|
||||||
|
// Tags that can conceivably represent stand-alone media.
|
||||||
|
|
||||||
|
// ABS UPDATE: Packages not necessary
|
||||||
|
// SOURCE: https://github.com/sindresorhus/escape-string-regexp/blob/main/index.js
|
||||||
|
function escapeStringRegexp(string) {
|
||||||
|
if (typeof string !== 'string') {
|
||||||
|
throw new TypeError('Expected a string');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape characters with special meaning either inside or outside character sets.
|
||||||
|
// Use a simple backslash escape when it’s always valid, and a `\xnn` escape when the simpler form would be disallowed by Unicode patterns’ stricter grammar.
|
||||||
|
return string
|
||||||
|
.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&')
|
||||||
|
.replace(/-/g, '\\x2d');
|
||||||
|
}
|
||||||
|
|
||||||
|
// SOURCE: https://github.com/jonschlinkert/is-plain-object/blob/master/is-plain-object.js
|
||||||
|
function isObject(o) {
|
||||||
|
return Object.prototype.toString.call(o) === '[object Object]';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(o) {
|
||||||
|
var ctor, prot;
|
||||||
|
|
||||||
|
if (isObject(o) === false) return false;
|
||||||
|
|
||||||
|
// If has modified constructor
|
||||||
|
ctor = o.constructor;
|
||||||
|
if (ctor === undefined) return true;
|
||||||
|
|
||||||
|
// If has modified prototype
|
||||||
|
prot = ctor.prototype;
|
||||||
|
if (isObject(prot) === false) return false;
|
||||||
|
|
||||||
|
// If constructor does not have an Object-specific method
|
||||||
|
if (prot.hasOwnProperty('isPrototypeOf') === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Most likely a plain Object
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const mediaTags = [
|
||||||
|
'img', 'audio', 'video', 'picture', 'svg',
|
||||||
|
'object', 'map', 'iframe', 'embed'
|
||||||
|
];
|
||||||
|
// Tags that are inherently vulnerable to being used in XSS attacks.
|
||||||
|
const vulnerableTags = ['script', 'style'];
|
||||||
|
|
||||||
|
function each(obj, cb) {
|
||||||
|
if (obj) {
|
||||||
|
Object.keys(obj).forEach(function (key) {
|
||||||
|
cb(obj[key], key);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avoid false positives with .__proto__, .hasOwnProperty, etc.
|
||||||
|
function has(obj, key) {
|
||||||
|
return ({}).hasOwnProperty.call(obj, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns those elements of `a` for which `cb(a)` returns truthy
|
||||||
|
function filter(a, cb) {
|
||||||
|
const n = [];
|
||||||
|
each(a, function (v) {
|
||||||
|
if (cb(v)) {
|
||||||
|
n.push(v);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEmptyObject(obj) {
|
||||||
|
for (const key in obj) {
|
||||||
|
if (has(obj, key)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifySrcset(parsedSrcset) {
|
||||||
|
return parsedSrcset.map(function (part) {
|
||||||
|
if (!part.url) {
|
||||||
|
throw new Error('URL missing');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
part.url +
|
||||||
|
(part.w ? ` ${part.w}w` : '') +
|
||||||
|
(part.h ? ` ${part.h}h` : '') +
|
||||||
|
(part.d ? ` ${part.d}x` : '')
|
||||||
|
);
|
||||||
|
}).join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = sanitizeHtml;
|
||||||
|
|
||||||
|
// A valid attribute name.
|
||||||
|
// We use a tolerant definition based on the set of strings defined by
|
||||||
|
// html.spec.whatwg.org/multipage/parsing.html#before-attribute-name-state
|
||||||
|
// and html.spec.whatwg.org/multipage/parsing.html#attribute-name-state .
|
||||||
|
// The characters accepted are ones which can be appended to the attribute
|
||||||
|
// name buffer without triggering a parse error:
|
||||||
|
// * unexpected-equals-sign-before-attribute-name
|
||||||
|
// * unexpected-null-character
|
||||||
|
// * unexpected-character-in-attribute-name
|
||||||
|
// We exclude the empty string because it's impossible to get to the after
|
||||||
|
// attribute name state with an empty attribute name buffer.
|
||||||
|
const VALID_HTML_ATTRIBUTE_NAME = /^[^\0\t\n\f\r /<=>]+$/;
|
||||||
|
|
||||||
|
// Ignore the _recursing flag; it's there for recursive
|
||||||
|
// invocation as a guard against this exploit:
|
||||||
|
// https://github.com/fb55/htmlparser2/issues/105
|
||||||
|
|
||||||
|
function sanitizeHtml(html, options, _recursing) {
|
||||||
|
if (html == null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = '';
|
||||||
|
// Used for hot swapping the result variable with an empty string in order to "capture" the text written to it.
|
||||||
|
let tempResult = '';
|
||||||
|
|
||||||
|
function Frame(tag, attribs) {
|
||||||
|
const that = this;
|
||||||
|
this.tag = tag;
|
||||||
|
this.attribs = attribs || {};
|
||||||
|
this.tagPosition = result.length;
|
||||||
|
this.text = ''; // Node inner text
|
||||||
|
this.mediaChildren = [];
|
||||||
|
|
||||||
|
this.updateParentNodeText = function () {
|
||||||
|
if (stack.length) {
|
||||||
|
const parentFrame = stack[stack.length - 1];
|
||||||
|
parentFrame.text += that.text;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.updateParentNodeMediaChildren = function () {
|
||||||
|
if (stack.length && mediaTags.includes(this.tag)) {
|
||||||
|
const parentFrame = stack[stack.length - 1];
|
||||||
|
parentFrame.mediaChildren.push(this.tag);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
options = Object.assign({}, sanitizeHtml.defaults, options);
|
||||||
|
options.parser = Object.assign({}, htmlParserDefaults, options.parser);
|
||||||
|
|
||||||
|
// vulnerableTags
|
||||||
|
vulnerableTags.forEach(function (tag) {
|
||||||
|
if (
|
||||||
|
options.allowedTags && options.allowedTags.indexOf(tag) > -1 &&
|
||||||
|
!options.allowVulnerableTags
|
||||||
|
) {
|
||||||
|
console.warn(`\n\n⚠️ Your \`allowedTags\` option includes, \`${tag}\`, which is inherently\nvulnerable to XSS attacks. Please remove it from \`allowedTags\`.\nOr, to disable this warning, add the \`allowVulnerableTags\` option\nand ensure you are accounting for this risk.\n\n`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tags that contain something other than HTML, or where discarding
|
||||||
|
// the text when the tag is disallowed makes sense for other reasons.
|
||||||
|
// If we are not allowing these tags, we should drop their content too.
|
||||||
|
// For other tags you would drop the tag but keep its content.
|
||||||
|
const nonTextTagsArray = options.nonTextTags || [
|
||||||
|
'script',
|
||||||
|
'style',
|
||||||
|
'textarea',
|
||||||
|
'option'
|
||||||
|
];
|
||||||
|
let allowedAttributesMap;
|
||||||
|
let allowedAttributesGlobMap;
|
||||||
|
if (options.allowedAttributes) {
|
||||||
|
allowedAttributesMap = {};
|
||||||
|
allowedAttributesGlobMap = {};
|
||||||
|
each(options.allowedAttributes, function (attributes, tag) {
|
||||||
|
allowedAttributesMap[tag] = [];
|
||||||
|
const globRegex = [];
|
||||||
|
attributes.forEach(function (obj) {
|
||||||
|
if (typeof obj === 'string' && obj.indexOf('*') >= 0) {
|
||||||
|
globRegex.push(escapeStringRegexp(obj).replace(/\\\*/g, '.*'));
|
||||||
|
} else {
|
||||||
|
allowedAttributesMap[tag].push(obj);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (globRegex.length) {
|
||||||
|
allowedAttributesGlobMap[tag] = new RegExp('^(' + globRegex.join('|') + ')$');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const allowedClassesMap = {};
|
||||||
|
const allowedClassesGlobMap = {};
|
||||||
|
const allowedClassesRegexMap = {};
|
||||||
|
each(options.allowedClasses, function (classes, tag) {
|
||||||
|
// Implicitly allows the class attribute
|
||||||
|
if (allowedAttributesMap) {
|
||||||
|
if (!has(allowedAttributesMap, tag)) {
|
||||||
|
allowedAttributesMap[tag] = [];
|
||||||
|
}
|
||||||
|
allowedAttributesMap[tag].push('class');
|
||||||
|
}
|
||||||
|
|
||||||
|
allowedClassesMap[tag] = [];
|
||||||
|
allowedClassesRegexMap[tag] = [];
|
||||||
|
const globRegex = [];
|
||||||
|
classes.forEach(function (obj) {
|
||||||
|
if (typeof obj === 'string' && obj.indexOf('*') >= 0) {
|
||||||
|
globRegex.push(escapeStringRegexp(obj).replace(/\\\*/g, '.*'));
|
||||||
|
} else if (obj instanceof RegExp) {
|
||||||
|
allowedClassesRegexMap[tag].push(obj);
|
||||||
|
} else {
|
||||||
|
allowedClassesMap[tag].push(obj);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (globRegex.length) {
|
||||||
|
allowedClassesGlobMap[tag] = new RegExp('^(' + globRegex.join('|') + ')$');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const transformTagsMap = {};
|
||||||
|
let transformTagsAll;
|
||||||
|
each(options.transformTags, function (transform, tag) {
|
||||||
|
let transFun;
|
||||||
|
if (typeof transform === 'function') {
|
||||||
|
transFun = transform;
|
||||||
|
} else if (typeof transform === 'string') {
|
||||||
|
transFun = sanitizeHtml.simpleTransform(transform);
|
||||||
|
}
|
||||||
|
if (tag === '*') {
|
||||||
|
transformTagsAll = transFun;
|
||||||
|
} else {
|
||||||
|
transformTagsMap[tag] = transFun;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let depth;
|
||||||
|
let stack;
|
||||||
|
let skipMap;
|
||||||
|
let transformMap;
|
||||||
|
let skipText;
|
||||||
|
let skipTextDepth;
|
||||||
|
let addedText = false;
|
||||||
|
|
||||||
|
initializeState();
|
||||||
|
|
||||||
|
const parser = new htmlparser.Parser({
|
||||||
|
onopentag: function (name, attribs) {
|
||||||
|
// If `enforceHtmlBoundary` is `true` and this has found the opening
|
||||||
|
// `html` tag, reset the state.
|
||||||
|
if (options.enforceHtmlBoundary && name === 'html') {
|
||||||
|
initializeState();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skipText) {
|
||||||
|
skipTextDepth++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const frame = new Frame(name, attribs);
|
||||||
|
stack.push(frame);
|
||||||
|
|
||||||
|
let skip = false;
|
||||||
|
const hasText = !!frame.text;
|
||||||
|
let transformedTag;
|
||||||
|
if (has(transformTagsMap, name)) {
|
||||||
|
transformedTag = transformTagsMap[name](name, attribs);
|
||||||
|
|
||||||
|
frame.attribs = attribs = transformedTag.attribs;
|
||||||
|
|
||||||
|
if (transformedTag.text !== undefined) {
|
||||||
|
frame.innerText = transformedTag.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name !== transformedTag.tagName) {
|
||||||
|
frame.name = name = transformedTag.tagName;
|
||||||
|
transformMap[depth] = transformedTag.tagName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (transformTagsAll) {
|
||||||
|
transformedTag = transformTagsAll(name, attribs);
|
||||||
|
|
||||||
|
frame.attribs = attribs = transformedTag.attribs;
|
||||||
|
if (name !== transformedTag.tagName) {
|
||||||
|
frame.name = name = transformedTag.tagName;
|
||||||
|
transformMap[depth] = transformedTag.tagName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((options.allowedTags && options.allowedTags.indexOf(name) === -1) || (options.disallowedTagsMode === 'recursiveEscape' && !isEmptyObject(skipMap)) || (options.nestingLimit != null && depth >= options.nestingLimit)) {
|
||||||
|
skip = true;
|
||||||
|
skipMap[depth] = true;
|
||||||
|
if (options.disallowedTagsMode === 'discard') {
|
||||||
|
if (nonTextTagsArray.indexOf(name) !== -1) {
|
||||||
|
skipText = true;
|
||||||
|
skipTextDepth = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
skipMap[depth] = true;
|
||||||
|
}
|
||||||
|
depth++;
|
||||||
|
if (skip) {
|
||||||
|
if (options.disallowedTagsMode === 'discard') {
|
||||||
|
// We want the contents but not this tag
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tempResult = result;
|
||||||
|
result = '';
|
||||||
|
}
|
||||||
|
result += '<' + name;
|
||||||
|
|
||||||
|
if (name === 'script') {
|
||||||
|
if (options.allowedScriptHostnames || options.allowedScriptDomains) {
|
||||||
|
frame.innerText = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allowedAttributesMap || has(allowedAttributesMap, name) || allowedAttributesMap['*']) {
|
||||||
|
each(attribs, function (value, a) {
|
||||||
|
if (!VALID_HTML_ATTRIBUTE_NAME.test(a)) {
|
||||||
|
// This prevents part of an attribute name in the output from being
|
||||||
|
// interpreted as the end of an attribute, or end of a tag.
|
||||||
|
delete frame.attribs[a];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let parsed;
|
||||||
|
// check allowedAttributesMap for the element and attribute and modify the value
|
||||||
|
// as necessary if there are specific values defined.
|
||||||
|
let passedAllowedAttributesMapCheck = false;
|
||||||
|
if (!allowedAttributesMap ||
|
||||||
|
(has(allowedAttributesMap, name) && allowedAttributesMap[name].indexOf(a) !== -1) ||
|
||||||
|
(allowedAttributesMap['*'] && allowedAttributesMap['*'].indexOf(a) !== -1) ||
|
||||||
|
(has(allowedAttributesGlobMap, name) && allowedAttributesGlobMap[name].test(a)) ||
|
||||||
|
(allowedAttributesGlobMap['*'] && allowedAttributesGlobMap['*'].test(a))) {
|
||||||
|
passedAllowedAttributesMapCheck = true;
|
||||||
|
} else if (allowedAttributesMap && allowedAttributesMap[name]) {
|
||||||
|
for (const o of allowedAttributesMap[name]) {
|
||||||
|
if (isPlainObject(o) && o.name && (o.name === a)) {
|
||||||
|
passedAllowedAttributesMapCheck = true;
|
||||||
|
let newValue = '';
|
||||||
|
if (o.multiple === true) {
|
||||||
|
// verify the values that are allowed
|
||||||
|
const splitStrArray = value.split(' ');
|
||||||
|
for (const s of splitStrArray) {
|
||||||
|
if (o.values.indexOf(s) !== -1) {
|
||||||
|
if (newValue === '') {
|
||||||
|
newValue = s;
|
||||||
|
} else {
|
||||||
|
newValue += ' ' + s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (o.values.indexOf(value) >= 0) {
|
||||||
|
// verified an allowed value matches the entire attribute value
|
||||||
|
newValue = value;
|
||||||
|
}
|
||||||
|
value = newValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (passedAllowedAttributesMapCheck) {
|
||||||
|
if (options.allowedSchemesAppliedToAttributes.indexOf(a) !== -1) {
|
||||||
|
if (naughtyHref(name, value)) {
|
||||||
|
delete frame.attribs[a];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'script' && a === 'src') {
|
||||||
|
|
||||||
|
let allowed = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(value);
|
||||||
|
|
||||||
|
if (options.allowedScriptHostnames || options.allowedScriptDomains) {
|
||||||
|
const allowedHostname = (options.allowedScriptHostnames || []).find(function (hostname) {
|
||||||
|
return hostname === parsed.hostname;
|
||||||
|
});
|
||||||
|
const allowedDomain = (options.allowedScriptDomains || []).find(function (domain) {
|
||||||
|
return parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`);
|
||||||
|
});
|
||||||
|
allowed = allowedHostname || allowedDomain;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
allowed = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allowed) {
|
||||||
|
delete frame.attribs[a];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'iframe' && a === 'src') {
|
||||||
|
let allowed = true;
|
||||||
|
try {
|
||||||
|
// Chrome accepts \ as a substitute for / in the // at the
|
||||||
|
// start of a URL, so rewrite accordingly to prevent exploit.
|
||||||
|
// Also drop any whitespace at that point in the URL
|
||||||
|
value = value.replace(/^(\w+:)?\s*[\\/]\s*[\\/]/, '$1//');
|
||||||
|
if (value.startsWith('relative:')) {
|
||||||
|
// An attempt to exploit our workaround for base URLs being
|
||||||
|
// mandatory for relative URL validation in the WHATWG
|
||||||
|
// URL parser, reject it
|
||||||
|
throw new Error('relative: exploit attempt');
|
||||||
|
}
|
||||||
|
// naughtyHref is in charge of whether protocol relative URLs
|
||||||
|
// are cool. Here we are concerned just with allowed hostnames and
|
||||||
|
// whether to allow relative URLs.
|
||||||
|
//
|
||||||
|
// Build a placeholder "base URL" against which any reasonable
|
||||||
|
// relative URL may be parsed successfully
|
||||||
|
let base = 'relative://relative-site';
|
||||||
|
for (let i = 0; (i < 100); i++) {
|
||||||
|
base += `/${i}`;
|
||||||
|
}
|
||||||
|
const parsed = new URL(value, base);
|
||||||
|
const isRelativeUrl = parsed && parsed.hostname === 'relative-site' && parsed.protocol === 'relative:';
|
||||||
|
if (isRelativeUrl) {
|
||||||
|
// default value of allowIframeRelativeUrls is true
|
||||||
|
// unless allowedIframeHostnames or allowedIframeDomains specified
|
||||||
|
allowed = has(options, 'allowIframeRelativeUrls')
|
||||||
|
? options.allowIframeRelativeUrls
|
||||||
|
: (!options.allowedIframeHostnames && !options.allowedIframeDomains);
|
||||||
|
} else if (options.allowedIframeHostnames || options.allowedIframeDomains) {
|
||||||
|
const allowedHostname = (options.allowedIframeHostnames || []).find(function (hostname) {
|
||||||
|
return hostname === parsed.hostname;
|
||||||
|
});
|
||||||
|
const allowedDomain = (options.allowedIframeDomains || []).find(function (domain) {
|
||||||
|
return parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`);
|
||||||
|
});
|
||||||
|
allowed = allowedHostname || allowedDomain;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Unparseable iframe src
|
||||||
|
allowed = false;
|
||||||
|
}
|
||||||
|
if (!allowed) {
|
||||||
|
delete frame.attribs[a];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (a === 'srcset') {
|
||||||
|
delete frame.attribs[a];
|
||||||
|
|
||||||
|
// ABS UPDATE: srcset not necessary
|
||||||
|
// try {
|
||||||
|
// parsed = parseSrcset(value);
|
||||||
|
// parsed.forEach(function (value) {
|
||||||
|
// if (naughtyHref('srcset', value.url)) {
|
||||||
|
// value.evil = true;
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
// parsed = filter(parsed, function (v) {
|
||||||
|
// return !v.evil;
|
||||||
|
// });
|
||||||
|
// if (!parsed.length) {
|
||||||
|
// delete frame.attribs[a];
|
||||||
|
// return;
|
||||||
|
// } else {
|
||||||
|
// value = stringifySrcset(filter(parsed, function (v) {
|
||||||
|
// return !v.evil;
|
||||||
|
// }));
|
||||||
|
// frame.attribs[a] = value;
|
||||||
|
// }
|
||||||
|
// } catch (e) {
|
||||||
|
// // Unparseable srcset
|
||||||
|
// delete frame.attribs[a];
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
if (a === 'class') {
|
||||||
|
const allowedSpecificClasses = allowedClassesMap[name];
|
||||||
|
const allowedWildcardClasses = allowedClassesMap['*'];
|
||||||
|
const allowedSpecificClassesGlob = allowedClassesGlobMap[name];
|
||||||
|
const allowedSpecificClassesRegex = allowedClassesRegexMap[name];
|
||||||
|
const allowedWildcardClassesGlob = allowedClassesGlobMap['*'];
|
||||||
|
const allowedClassesGlobs = [
|
||||||
|
allowedSpecificClassesGlob,
|
||||||
|
allowedWildcardClassesGlob
|
||||||
|
]
|
||||||
|
.concat(allowedSpecificClassesRegex)
|
||||||
|
.filter(function (t) {
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
if (allowedSpecificClasses && allowedWildcardClasses) {
|
||||||
|
// ABS UPDATE: classes and wildcard classes not necessary now
|
||||||
|
// value = filterClasses(value, deepmerge(allowedSpecificClasses, allowedWildcardClasses), allowedClassesGlobs);
|
||||||
|
} else {
|
||||||
|
value = filterClasses(value, allowedSpecificClasses || allowedWildcardClasses, allowedClassesGlobs);
|
||||||
|
}
|
||||||
|
if (!value.length) {
|
||||||
|
delete frame.attribs[a];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (a === 'style') {
|
||||||
|
delete frame.attribs[a];
|
||||||
|
|
||||||
|
// ABS UPDATE: Styles not necessary
|
||||||
|
// try {
|
||||||
|
// const abstractSyntaxTree = postcssParse(name + ' {' + value + '}');
|
||||||
|
// const filteredAST = filterCss(abstractSyntaxTree, options.allowedStyles);
|
||||||
|
|
||||||
|
// value = stringifyStyleAttributes(filteredAST);
|
||||||
|
|
||||||
|
// if (value.length === 0) {
|
||||||
|
// delete frame.attribs[a];
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
// } catch (e) {
|
||||||
|
// delete frame.attribs[a];
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
result += ' ' + a;
|
||||||
|
if (value && value.length) {
|
||||||
|
result += '="' + escapeHtml(value, true) + '"';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
delete frame.attribs[a];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (options.selfClosing.indexOf(name) !== -1) {
|
||||||
|
result += ' />';
|
||||||
|
} else {
|
||||||
|
result += '>';
|
||||||
|
if (frame.innerText && !hasText && !options.textFilter) {
|
||||||
|
result += escapeHtml(frame.innerText);
|
||||||
|
addedText = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (skip) {
|
||||||
|
result = tempResult + escapeHtml(result);
|
||||||
|
tempResult = '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ontext: function (text) {
|
||||||
|
if (skipText) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lastFrame = stack[stack.length - 1];
|
||||||
|
let tag;
|
||||||
|
|
||||||
|
if (lastFrame) {
|
||||||
|
tag = lastFrame.tag;
|
||||||
|
// If inner text was set by transform function then let's use it
|
||||||
|
text = lastFrame.innerText !== undefined ? lastFrame.innerText : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.disallowedTagsMode === 'discard' && ((tag === 'script') || (tag === 'style'))) {
|
||||||
|
// htmlparser2 gives us these as-is. Escaping them ruins the content. Allowing
|
||||||
|
// script tags is, by definition, game over for XSS protection, so if that's
|
||||||
|
// your concern, don't allow them. The same is essentially true for style tags
|
||||||
|
// which have their own collection of XSS vectors.
|
||||||
|
result += text;
|
||||||
|
} else {
|
||||||
|
const escaped = escapeHtml(text, false);
|
||||||
|
if (options.textFilter && !addedText) {
|
||||||
|
result += options.textFilter(escaped, tag);
|
||||||
|
} else if (!addedText) {
|
||||||
|
result += escaped;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (stack.length) {
|
||||||
|
const frame = stack[stack.length - 1];
|
||||||
|
frame.text += text;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onclosetag: function (name) {
|
||||||
|
|
||||||
|
if (skipText) {
|
||||||
|
skipTextDepth--;
|
||||||
|
if (!skipTextDepth) {
|
||||||
|
skipText = false;
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const frame = stack.pop();
|
||||||
|
if (!frame) {
|
||||||
|
// Do not crash on bad markup
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
skipText = options.enforceHtmlBoundary ? name === 'html' : false;
|
||||||
|
depth--;
|
||||||
|
const skip = skipMap[depth];
|
||||||
|
if (skip) {
|
||||||
|
delete skipMap[depth];
|
||||||
|
if (options.disallowedTagsMode === 'discard') {
|
||||||
|
frame.updateParentNodeText();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tempResult = result;
|
||||||
|
result = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transformMap[depth]) {
|
||||||
|
name = transformMap[depth];
|
||||||
|
delete transformMap[depth];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.exclusiveFilter && options.exclusiveFilter(frame)) {
|
||||||
|
result = result.substr(0, frame.tagPosition);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
frame.updateParentNodeMediaChildren();
|
||||||
|
frame.updateParentNodeText();
|
||||||
|
|
||||||
|
if (options.selfClosing.indexOf(name) !== -1) {
|
||||||
|
// Already output />
|
||||||
|
if (skip) {
|
||||||
|
result = tempResult;
|
||||||
|
tempResult = '';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
result += '</' + name + '>';
|
||||||
|
if (skip) {
|
||||||
|
result = tempResult + escapeHtml(result);
|
||||||
|
tempResult = '';
|
||||||
|
}
|
||||||
|
addedText = false;
|
||||||
|
}
|
||||||
|
}, options.parser);
|
||||||
|
parser.write(html);
|
||||||
|
parser.end();
|
||||||
|
|
||||||
|
return result;
|
||||||
|
|
||||||
|
function initializeState() {
|
||||||
|
result = '';
|
||||||
|
depth = 0;
|
||||||
|
stack = [];
|
||||||
|
skipMap = {};
|
||||||
|
transformMap = {};
|
||||||
|
skipText = false;
|
||||||
|
skipTextDepth = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s, quote) {
|
||||||
|
if (typeof (s) !== 'string') {
|
||||||
|
s = s + '';
|
||||||
|
}
|
||||||
|
if (options.parser.decodeEntities) {
|
||||||
|
s = s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
if (quote) {
|
||||||
|
s = s.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// TODO: this is inadequate because it will pass `&0;`. This approach
|
||||||
|
// will not work, each & must be considered with regard to whether it
|
||||||
|
// is followed by a 100% syntactically valid entity or not, and escaped
|
||||||
|
// if it is not. If this bothers you, don't set parser.decodeEntities
|
||||||
|
// to false. (The default is true.)
|
||||||
|
s = s.replace(/&(?![a-zA-Z0-9#]{1,20};)/g, '&') // Match ampersands not part of existing HTML entity
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
if (quote) {
|
||||||
|
s = s.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function naughtyHref(name, href) {
|
||||||
|
// Browsers ignore character codes of 32 (space) and below in a surprising
|
||||||
|
// number of situations. Start reading here:
|
||||||
|
// https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#Embedded_tab
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
href = href.replace(/[\x00-\x20]+/g, '');
|
||||||
|
// Clobber any comments in URLs, which the browser might
|
||||||
|
// interpret inside an XML data island, allowing
|
||||||
|
// a javascript: URL to be snuck through
|
||||||
|
href = href.replace(/<!--.*?-->/g, '');
|
||||||
|
// Case insensitive so we don't get faked out by JAVASCRIPT #1
|
||||||
|
// Allow more characters after the first so we don't get faked
|
||||||
|
// out by certain schemes browsers accept
|
||||||
|
const matches = href.match(/^([a-zA-Z][a-zA-Z0-9.\-+]*):/);
|
||||||
|
if (!matches) {
|
||||||
|
// Protocol-relative URL starting with any combination of '/' and '\'
|
||||||
|
if (href.match(/^[/\\]{2}/)) {
|
||||||
|
return !options.allowProtocolRelative;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No scheme
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const scheme = matches[1].toLowerCase();
|
||||||
|
|
||||||
|
if (has(options.allowedSchemesByTag, name)) {
|
||||||
|
return options.allowedSchemesByTag[name].indexOf(scheme) === -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !options.allowedSchemes || options.allowedSchemes.indexOf(scheme) === -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters user input css properties by allowlisted regex attributes.
|
||||||
|
* Modifies the abstractSyntaxTree object.
|
||||||
|
*
|
||||||
|
* @param {object} abstractSyntaxTree - Object representation of CSS attributes.
|
||||||
|
* @property {array[Declaration]} abstractSyntaxTree.nodes[0] - Each object cointains prop and value key, i.e { prop: 'color', value: 'red' }.
|
||||||
|
* @param {object} allowedStyles - Keys are properties (i.e color), value is list of permitted regex rules (i.e /green/i).
|
||||||
|
* @return {object} - The modified tree.
|
||||||
|
*/
|
||||||
|
// function filterCss(abstractSyntaxTree, allowedStyles) {
|
||||||
|
// if (!allowedStyles) {
|
||||||
|
// return abstractSyntaxTree;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const astRules = abstractSyntaxTree.nodes[0];
|
||||||
|
// let selectedRule;
|
||||||
|
|
||||||
|
// // Merge global and tag-specific styles into new AST.
|
||||||
|
// if (allowedStyles[astRules.selector] && allowedStyles['*']) {
|
||||||
|
// selectedRule = deepmerge(
|
||||||
|
// allowedStyles[astRules.selector],
|
||||||
|
// allowedStyles['*']
|
||||||
|
// );
|
||||||
|
// } else {
|
||||||
|
// selectedRule = allowedStyles[astRules.selector] || allowedStyles['*'];
|
||||||
|
// }
|
||||||
|
|
||||||
|
// if (selectedRule) {
|
||||||
|
// abstractSyntaxTree.nodes[0].nodes = astRules.nodes.reduce(filterDeclarations(selectedRule), []);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return abstractSyntaxTree;
|
||||||
|
// }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the style attributes from an AbstractSyntaxTree and formats those
|
||||||
|
* values in the inline style attribute format.
|
||||||
|
*
|
||||||
|
* @param {AbstractSyntaxTree} filteredAST
|
||||||
|
* @return {string} - Example: "color:yellow;text-align:center !important;font-family:helvetica;"
|
||||||
|
*/
|
||||||
|
function stringifyStyleAttributes(filteredAST) {
|
||||||
|
return filteredAST.nodes[0].nodes
|
||||||
|
.reduce(function (extractedAttributes, attrObject) {
|
||||||
|
extractedAttributes.push(
|
||||||
|
`${attrObject.prop}:${attrObject.value}${attrObject.important ? ' !important' : ''}`
|
||||||
|
);
|
||||||
|
return extractedAttributes;
|
||||||
|
}, [])
|
||||||
|
.join(';');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters the existing attributes for the given property. Discards any attributes
|
||||||
|
* which don't match the allowlist.
|
||||||
|
*
|
||||||
|
* @param {object} selectedRule - Example: { color: red, font-family: helvetica }
|
||||||
|
* @param {array} allowedDeclarationsList - List of declarations which pass the allowlist.
|
||||||
|
* @param {object} attributeObject - Object representing the current css property.
|
||||||
|
* @property {string} attributeObject.type - Typically 'declaration'.
|
||||||
|
* @property {string} attributeObject.prop - The CSS property, i.e 'color'.
|
||||||
|
* @property {string} attributeObject.value - The corresponding value to the css property, i.e 'red'.
|
||||||
|
* @return {function} - When used in Array.reduce, will return an array of Declaration objects
|
||||||
|
*/
|
||||||
|
function filterDeclarations(selectedRule) {
|
||||||
|
return function (allowedDeclarationsList, attributeObject) {
|
||||||
|
// If this property is allowlisted...
|
||||||
|
if (has(selectedRule, attributeObject.prop)) {
|
||||||
|
const matchesRegex = selectedRule[attributeObject.prop].some(function (regularExpression) {
|
||||||
|
return regularExpression.test(attributeObject.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (matchesRegex) {
|
||||||
|
allowedDeclarationsList.push(attributeObject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return allowedDeclarationsList;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterClasses(classes, allowed, allowedGlobs) {
|
||||||
|
if (!allowed) {
|
||||||
|
// The class attribute is allowed without filtering on this tag
|
||||||
|
return classes;
|
||||||
|
}
|
||||||
|
classes = classes.split(/\s+/);
|
||||||
|
return classes.filter(function (clss) {
|
||||||
|
return allowed.indexOf(clss) !== -1 || allowedGlobs.some(function (glob) {
|
||||||
|
return glob.test(clss);
|
||||||
|
});
|
||||||
|
}).join(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defaults are accessible to you so that you can use them as a starting point
|
||||||
|
// programmatically if you wish
|
||||||
|
|
||||||
|
const htmlParserDefaults = {
|
||||||
|
decodeEntities: true
|
||||||
|
};
|
||||||
|
sanitizeHtml.defaults = {
|
||||||
|
allowedTags: [
|
||||||
|
// Sections derived from MDN element categories and limited to the more
|
||||||
|
// benign categories.
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/HTML/Element
|
||||||
|
// Content sectioning
|
||||||
|
'address', 'article', 'aside', 'footer', 'header',
|
||||||
|
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hgroup',
|
||||||
|
'main', 'nav', 'section',
|
||||||
|
// Text content
|
||||||
|
'blockquote', 'dd', 'div', 'dl', 'dt', 'figcaption', 'figure',
|
||||||
|
'hr', 'li', 'main', 'ol', 'p', 'pre', 'ul',
|
||||||
|
// Inline text semantics
|
||||||
|
'a', 'abbr', 'b', 'bdi', 'bdo', 'br', 'cite', 'code', 'data', 'dfn',
|
||||||
|
'em', 'i', 'kbd', 'mark', 'q',
|
||||||
|
'rb', 'rp', 'rt', 'rtc', 'ruby',
|
||||||
|
's', 'samp', 'small', 'span', 'strong', 'sub', 'sup', 'time', 'u', 'var', 'wbr',
|
||||||
|
// Table content
|
||||||
|
'caption', 'col', 'colgroup', 'table', 'tbody', 'td', 'tfoot', 'th',
|
||||||
|
'thead', 'tr'
|
||||||
|
],
|
||||||
|
disallowedTagsMode: 'discard',
|
||||||
|
allowedAttributes: {
|
||||||
|
a: ['href', 'name', 'target'],
|
||||||
|
// We don't currently allow img itself by default, but
|
||||||
|
// these attributes would make sense if we did.
|
||||||
|
img: ['src', 'srcset', 'alt', 'title', 'width', 'height', 'loading']
|
||||||
|
},
|
||||||
|
// Lots of these won't come up by default because we don't allow them
|
||||||
|
selfClosing: ['img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta'],
|
||||||
|
// URL schemes we permit
|
||||||
|
allowedSchemes: ['http', 'https', 'ftp', 'mailto', 'tel'],
|
||||||
|
allowedSchemesByTag: {},
|
||||||
|
allowedSchemesAppliedToAttributes: ['href', 'src', 'cite'],
|
||||||
|
allowProtocolRelative: true,
|
||||||
|
enforceHtmlBoundary: false
|
||||||
|
};
|
||||||
|
|
||||||
|
sanitizeHtml.simpleTransform = function (newTagName, newAttribs, merge) {
|
||||||
|
merge = (merge === undefined) ? true : merge;
|
||||||
|
newAttribs = newAttribs || {};
|
||||||
|
|
||||||
|
return function (tagName, attribs) {
|
||||||
|
let attrib;
|
||||||
|
if (merge) {
|
||||||
|
for (attrib in newAttribs) {
|
||||||
|
attribs[attrib] = newAttribs[attrib];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
attribs = newAttribs;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tagName: newTagName,
|
||||||
|
attribs: attribs
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,19 @@
|
|||||||
|
"use strict";
|
||||||
|
/* IMPORT */
|
||||||
|
var signal_1 = require("./signal");
|
||||||
|
/* ABORT CONTROLLER */
|
||||||
|
var AbortController = /** @class */ (function () {
|
||||||
|
function AbortController() {
|
||||||
|
/* VARIABLES */
|
||||||
|
this.signal = new signal_1.default();
|
||||||
|
}
|
||||||
|
/* API */
|
||||||
|
AbortController.prototype.abort = function () {
|
||||||
|
return this.signal.abort();
|
||||||
|
};
|
||||||
|
return AbortController;
|
||||||
|
}());
|
||||||
|
/* EXPORT */
|
||||||
|
module.exports = AbortController;
|
||||||
|
module.exports.default = AbortController;
|
||||||
|
Object.defineProperty(module.exports, "__esModule", { value: true });
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"use strict";
|
||||||
|
/* IMPORT */
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
/* ABORT SIGNAL */
|
||||||
|
var AbortSignal = /** @class */ (function () {
|
||||||
|
function AbortSignal() {
|
||||||
|
/* VARIABLES */
|
||||||
|
this.aborted = false;
|
||||||
|
this.listeners = {};
|
||||||
|
}
|
||||||
|
/* EVENTS API */
|
||||||
|
AbortSignal.prototype.addEventListener = function (event, listener) {
|
||||||
|
var listeners = this.listeners[event] || (this.listeners[event] = []);
|
||||||
|
listeners.push(listener);
|
||||||
|
};
|
||||||
|
AbortSignal.prototype.removeEventListener = function (event, listener) {
|
||||||
|
var listeners = this.listeners[event];
|
||||||
|
if (!listeners)
|
||||||
|
return;
|
||||||
|
listeners.splice(listeners.indexOf(listener), 1);
|
||||||
|
};
|
||||||
|
AbortSignal.prototype.dispatchEvent = function (event) {
|
||||||
|
var listeners = this.listeners[event];
|
||||||
|
if (!listeners)
|
||||||
|
return true;
|
||||||
|
listeners.slice().forEach(function (listener) { return listener(); });
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
/* API */
|
||||||
|
AbortSignal.prototype.abort = function () {
|
||||||
|
if (this.aborted)
|
||||||
|
return;
|
||||||
|
this.aborted = true;
|
||||||
|
this.dispatchEvent('abort');
|
||||||
|
};
|
||||||
|
return AbortSignal;
|
||||||
|
}());
|
||||||
|
/* EXPORT */
|
||||||
|
exports.default = AbortSignal;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"use strict";
|
||||||
|
/* IMPORT */
|
||||||
|
var isPrimitive = require("./is-primitive");
|
||||||
|
/* ARE SHALLOW EQUAL */
|
||||||
|
var isNaN = Number.isNaN;
|
||||||
|
function areShallowEqual(x, y) {
|
||||||
|
if (x === y)
|
||||||
|
return true;
|
||||||
|
if (isNaN(x))
|
||||||
|
return isNaN(y);
|
||||||
|
if (isPrimitive(x) || isPrimitive(y))
|
||||||
|
return x === y;
|
||||||
|
for (var i in x)
|
||||||
|
if (!(i in y))
|
||||||
|
return false;
|
||||||
|
for (var i in y)
|
||||||
|
if (x[i] !== y[i])
|
||||||
|
return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
/* EXPORT */
|
||||||
|
module.exports = areShallowEqual;
|
||||||
|
module.exports.default = areShallowEqual;
|
||||||
|
Object.defineProperty(module.exports, "__esModule", { value: true });
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"use strict";
|
||||||
|
/* CONSTS */
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.NOOP = exports.LIMIT_FILES_DESCRIPTORS = exports.LIMIT_BASENAME_LENGTH = exports.IS_USER_ROOT = exports.IS_POSIX = exports.DEFAULT_TIMEOUT_SYNC = exports.DEFAULT_TIMEOUT_ASYNC = exports.DEFAULT_WRITE_OPTIONS = exports.DEFAULT_READ_OPTIONS = exports.DEFAULT_FOLDER_MODE = exports.DEFAULT_FILE_MODE = exports.DEFAULT_ENCODING = void 0;
|
||||||
|
const DEFAULT_ENCODING = 'utf8';
|
||||||
|
exports.DEFAULT_ENCODING = DEFAULT_ENCODING;
|
||||||
|
const DEFAULT_FILE_MODE = 0o666;
|
||||||
|
exports.DEFAULT_FILE_MODE = DEFAULT_FILE_MODE;
|
||||||
|
const DEFAULT_FOLDER_MODE = 0o777;
|
||||||
|
exports.DEFAULT_FOLDER_MODE = DEFAULT_FOLDER_MODE;
|
||||||
|
const DEFAULT_READ_OPTIONS = {};
|
||||||
|
exports.DEFAULT_READ_OPTIONS = DEFAULT_READ_OPTIONS;
|
||||||
|
const DEFAULT_WRITE_OPTIONS = {};
|
||||||
|
exports.DEFAULT_WRITE_OPTIONS = DEFAULT_WRITE_OPTIONS;
|
||||||
|
const DEFAULT_TIMEOUT_ASYNC = 5000;
|
||||||
|
exports.DEFAULT_TIMEOUT_ASYNC = DEFAULT_TIMEOUT_ASYNC;
|
||||||
|
const DEFAULT_TIMEOUT_SYNC = 100;
|
||||||
|
exports.DEFAULT_TIMEOUT_SYNC = DEFAULT_TIMEOUT_SYNC;
|
||||||
|
const IS_POSIX = !!process.getuid;
|
||||||
|
exports.IS_POSIX = IS_POSIX;
|
||||||
|
const IS_USER_ROOT = process.getuid ? !process.getuid() : false;
|
||||||
|
exports.IS_USER_ROOT = IS_USER_ROOT;
|
||||||
|
const LIMIT_BASENAME_LENGTH = 128; //TODO: fetch the real limit from the filesystem //TODO: fetch the whole-path length limit too
|
||||||
|
exports.LIMIT_BASENAME_LENGTH = LIMIT_BASENAME_LENGTH;
|
||||||
|
const LIMIT_FILES_DESCRIPTORS = 10000; //TODO: fetch the real limit from the filesystem
|
||||||
|
exports.LIMIT_FILES_DESCRIPTORS = LIMIT_FILES_DESCRIPTORS;
|
||||||
|
const NOOP = () => { };
|
||||||
|
exports.NOOP = NOOP;
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"use strict";
|
||||||
|
/* IMPORT */
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.writeFileSync = exports.writeFile = exports.readFileSync = exports.readFile = void 0;
|
||||||
|
const path = require("path");
|
||||||
|
const consts_1 = require("./consts");
|
||||||
|
const fs_1 = require("./utils/fs");
|
||||||
|
const lang_1 = require("./utils/lang");
|
||||||
|
const scheduler_1 = require("./utils/scheduler");
|
||||||
|
const temp_1 = require("./utils/temp");
|
||||||
|
function readFile(filePath, options = consts_1.DEFAULT_READ_OPTIONS) {
|
||||||
|
var _a;
|
||||||
|
if (lang_1.default.isString(options))
|
||||||
|
return readFile(filePath, { encoding: options });
|
||||||
|
const timeout = Date.now() + ((_a = options.timeout) !== null && _a !== void 0 ? _a : consts_1.DEFAULT_TIMEOUT_ASYNC);
|
||||||
|
return fs_1.default.readFileRetry(timeout)(filePath, options);
|
||||||
|
}
|
||||||
|
exports.readFile = readFile;
|
||||||
|
;
|
||||||
|
function readFileSync(filePath, options = consts_1.DEFAULT_READ_OPTIONS) {
|
||||||
|
var _a;
|
||||||
|
if (lang_1.default.isString(options))
|
||||||
|
return readFileSync(filePath, { encoding: options });
|
||||||
|
const timeout = Date.now() + ((_a = options.timeout) !== null && _a !== void 0 ? _a : consts_1.DEFAULT_TIMEOUT_SYNC);
|
||||||
|
return fs_1.default.readFileSyncRetry(timeout)(filePath, options);
|
||||||
|
}
|
||||||
|
exports.readFileSync = readFileSync;
|
||||||
|
;
|
||||||
|
const writeFile = (filePath, data, options, callback) => {
|
||||||
|
if (lang_1.default.isFunction(options))
|
||||||
|
return writeFile(filePath, data, consts_1.DEFAULT_WRITE_OPTIONS, options);
|
||||||
|
const promise = writeFileAsync(filePath, data, options);
|
||||||
|
if (callback)
|
||||||
|
promise.then(callback, callback);
|
||||||
|
return promise;
|
||||||
|
};
|
||||||
|
exports.writeFile = writeFile;
|
||||||
|
const writeFileAsync = async (filePath, data, options = consts_1.DEFAULT_WRITE_OPTIONS) => {
|
||||||
|
var _a;
|
||||||
|
if (lang_1.default.isString(options))
|
||||||
|
return writeFileAsync(filePath, data, { encoding: options });
|
||||||
|
const timeout = Date.now() + ((_a = options.timeout) !== null && _a !== void 0 ? _a : consts_1.DEFAULT_TIMEOUT_ASYNC);
|
||||||
|
let schedulerCustomDisposer = null, schedulerDisposer = null, tempDisposer = null, tempPath = null, fd = null;
|
||||||
|
try {
|
||||||
|
if (options.schedule)
|
||||||
|
schedulerCustomDisposer = await options.schedule(filePath);
|
||||||
|
schedulerDisposer = await scheduler_1.default.schedule(filePath);
|
||||||
|
filePath = await fs_1.default.realpathAttempt(filePath) || filePath;
|
||||||
|
[tempPath, tempDisposer] = temp_1.default.get(filePath, options.tmpCreate || temp_1.default.create, !(options.tmpPurge === false));
|
||||||
|
const useStatChown = consts_1.IS_POSIX && lang_1.default.isUndefined(options.chown), useStatMode = lang_1.default.isUndefined(options.mode);
|
||||||
|
if (useStatChown || useStatMode) {
|
||||||
|
const stat = await fs_1.default.statAttempt(filePath);
|
||||||
|
if (stat) {
|
||||||
|
options = { ...options };
|
||||||
|
if (useStatChown)
|
||||||
|
options.chown = { uid: stat.uid, gid: stat.gid };
|
||||||
|
if (useStatMode)
|
||||||
|
options.mode = stat.mode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const parentPath = path.dirname(filePath);
|
||||||
|
await fs_1.default.mkdirAttempt(parentPath, {
|
||||||
|
mode: consts_1.DEFAULT_FOLDER_MODE,
|
||||||
|
recursive: true
|
||||||
|
});
|
||||||
|
fd = await fs_1.default.openRetry(timeout)(tempPath, 'w', options.mode || consts_1.DEFAULT_FILE_MODE);
|
||||||
|
if (options.tmpCreated)
|
||||||
|
options.tmpCreated(tempPath);
|
||||||
|
if (lang_1.default.isString(data)) {
|
||||||
|
await fs_1.default.writeRetry(timeout)(fd, data, 0, options.encoding || consts_1.DEFAULT_ENCODING);
|
||||||
|
}
|
||||||
|
else if (!lang_1.default.isUndefined(data)) {
|
||||||
|
await fs_1.default.writeRetry(timeout)(fd, data, 0, data.length, 0);
|
||||||
|
}
|
||||||
|
if (options.fsync !== false) {
|
||||||
|
if (options.fsyncWait !== false) {
|
||||||
|
await fs_1.default.fsyncRetry(timeout)(fd);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
fs_1.default.fsyncAttempt(fd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await fs_1.default.closeRetry(timeout)(fd);
|
||||||
|
fd = null;
|
||||||
|
if (options.chown)
|
||||||
|
await fs_1.default.chownAttempt(tempPath, options.chown.uid, options.chown.gid);
|
||||||
|
if (options.mode)
|
||||||
|
await fs_1.default.chmodAttempt(tempPath, options.mode);
|
||||||
|
try {
|
||||||
|
await fs_1.default.renameRetry(timeout)(tempPath, filePath);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
if (error.code !== 'ENAMETOOLONG')
|
||||||
|
throw error;
|
||||||
|
await fs_1.default.renameRetry(timeout)(tempPath, temp_1.default.truncate(filePath));
|
||||||
|
}
|
||||||
|
tempDisposer();
|
||||||
|
tempPath = null;
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if (fd)
|
||||||
|
await fs_1.default.closeAttempt(fd);
|
||||||
|
if (tempPath)
|
||||||
|
temp_1.default.purge(tempPath);
|
||||||
|
if (schedulerCustomDisposer)
|
||||||
|
schedulerCustomDisposer();
|
||||||
|
if (schedulerDisposer)
|
||||||
|
schedulerDisposer();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const writeFileSync = (filePath, data, options = consts_1.DEFAULT_WRITE_OPTIONS) => {
|
||||||
|
var _a;
|
||||||
|
if (lang_1.default.isString(options))
|
||||||
|
return writeFileSync(filePath, data, { encoding: options });
|
||||||
|
const timeout = Date.now() + ((_a = options.timeout) !== null && _a !== void 0 ? _a : consts_1.DEFAULT_TIMEOUT_SYNC);
|
||||||
|
let tempDisposer = null, tempPath = null, fd = null;
|
||||||
|
try {
|
||||||
|
filePath = fs_1.default.realpathSyncAttempt(filePath) || filePath;
|
||||||
|
[tempPath, tempDisposer] = temp_1.default.get(filePath, options.tmpCreate || temp_1.default.create, !(options.tmpPurge === false));
|
||||||
|
const useStatChown = consts_1.IS_POSIX && lang_1.default.isUndefined(options.chown), useStatMode = lang_1.default.isUndefined(options.mode);
|
||||||
|
if (useStatChown || useStatMode) {
|
||||||
|
const stat = fs_1.default.statSyncAttempt(filePath);
|
||||||
|
if (stat) {
|
||||||
|
options = { ...options };
|
||||||
|
if (useStatChown)
|
||||||
|
options.chown = { uid: stat.uid, gid: stat.gid };
|
||||||
|
if (useStatMode)
|
||||||
|
options.mode = stat.mode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const parentPath = path.dirname(filePath);
|
||||||
|
fs_1.default.mkdirSyncAttempt(parentPath, {
|
||||||
|
mode: consts_1.DEFAULT_FOLDER_MODE,
|
||||||
|
recursive: true
|
||||||
|
});
|
||||||
|
fd = fs_1.default.openSyncRetry(timeout)(tempPath, 'w', options.mode || consts_1.DEFAULT_FILE_MODE);
|
||||||
|
if (options.tmpCreated)
|
||||||
|
options.tmpCreated(tempPath);
|
||||||
|
if (lang_1.default.isString(data)) {
|
||||||
|
fs_1.default.writeSyncRetry(timeout)(fd, data, 0, options.encoding || consts_1.DEFAULT_ENCODING);
|
||||||
|
}
|
||||||
|
else if (!lang_1.default.isUndefined(data)) {
|
||||||
|
fs_1.default.writeSyncRetry(timeout)(fd, data, 0, data.length, 0);
|
||||||
|
}
|
||||||
|
if (options.fsync !== false) {
|
||||||
|
if (options.fsyncWait !== false) {
|
||||||
|
fs_1.default.fsyncSyncRetry(timeout)(fd);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
fs_1.default.fsyncAttempt(fd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fs_1.default.closeSyncRetry(timeout)(fd);
|
||||||
|
fd = null;
|
||||||
|
if (options.chown)
|
||||||
|
fs_1.default.chownSyncAttempt(tempPath, options.chown.uid, options.chown.gid);
|
||||||
|
if (options.mode)
|
||||||
|
fs_1.default.chmodSyncAttempt(tempPath, options.mode);
|
||||||
|
try {
|
||||||
|
fs_1.default.renameSyncRetry(timeout)(tempPath, filePath);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
if (error.code !== 'ENAMETOOLONG')
|
||||||
|
throw error;
|
||||||
|
fs_1.default.renameSyncRetry(timeout)(tempPath, temp_1.default.truncate(filePath));
|
||||||
|
}
|
||||||
|
tempDisposer();
|
||||||
|
tempPath = null;
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if (fd)
|
||||||
|
fs_1.default.closeSyncAttempt(fd);
|
||||||
|
if (tempPath)
|
||||||
|
temp_1.default.purge(tempPath);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
exports.writeFileSync = writeFileSync;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"use strict";
|
||||||
|
/* IMPORT */
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.attemptifySync = exports.attemptifyAsync = void 0;
|
||||||
|
const consts_1 = require("../consts");
|
||||||
|
/* ATTEMPTIFY */
|
||||||
|
//TODO: Maybe publish this as a standalone package
|
||||||
|
//FIXME: The type castings here aren't exactly correct
|
||||||
|
const attemptifyAsync = (fn, onError = consts_1.NOOP) => {
|
||||||
|
return function () {
|
||||||
|
return fn.apply(undefined, arguments).catch(onError);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
exports.attemptifyAsync = attemptifyAsync;
|
||||||
|
const attemptifySync = (fn, onError = consts_1.NOOP) => {
|
||||||
|
return function () {
|
||||||
|
try {
|
||||||
|
return fn.apply(undefined, arguments);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
return onError(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
exports.attemptifySync = attemptifySync;
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"use strict";
|
||||||
|
/* IMPORT */
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
const fs = require("fs");
|
||||||
|
const util_1 = require("util");
|
||||||
|
const attemptify_1 = require("./attemptify");
|
||||||
|
const fs_handlers_1 = require("./fs_handlers");
|
||||||
|
const retryify_1 = require("./retryify");
|
||||||
|
/* FS */
|
||||||
|
const FS = {
|
||||||
|
chmodAttempt: attemptify_1.attemptifyAsync(util_1.promisify(fs.chmod), fs_handlers_1.default.onChangeError),
|
||||||
|
chownAttempt: attemptify_1.attemptifyAsync(util_1.promisify(fs.chown), fs_handlers_1.default.onChangeError),
|
||||||
|
closeAttempt: attemptify_1.attemptifyAsync(util_1.promisify(fs.close)),
|
||||||
|
fsyncAttempt: attemptify_1.attemptifyAsync(util_1.promisify(fs.fsync)),
|
||||||
|
mkdirAttempt: attemptify_1.attemptifyAsync(util_1.promisify(fs.mkdir)),
|
||||||
|
realpathAttempt: attemptify_1.attemptifyAsync(util_1.promisify(fs.realpath)),
|
||||||
|
statAttempt: attemptify_1.attemptifyAsync(util_1.promisify(fs.stat)),
|
||||||
|
unlinkAttempt: attemptify_1.attemptifyAsync(util_1.promisify(fs.unlink)),
|
||||||
|
closeRetry: retryify_1.retryifyAsync(util_1.promisify(fs.close), fs_handlers_1.default.isRetriableError),
|
||||||
|
fsyncRetry: retryify_1.retryifyAsync(util_1.promisify(fs.fsync), fs_handlers_1.default.isRetriableError),
|
||||||
|
openRetry: retryify_1.retryifyAsync(util_1.promisify(fs.open), fs_handlers_1.default.isRetriableError),
|
||||||
|
readFileRetry: retryify_1.retryifyAsync(util_1.promisify(fs.readFile), fs_handlers_1.default.isRetriableError),
|
||||||
|
renameRetry: retryify_1.retryifyAsync(util_1.promisify(fs.rename), fs_handlers_1.default.isRetriableError),
|
||||||
|
statRetry: retryify_1.retryifyAsync(util_1.promisify(fs.stat), fs_handlers_1.default.isRetriableError),
|
||||||
|
writeRetry: retryify_1.retryifyAsync(util_1.promisify(fs.write), fs_handlers_1.default.isRetriableError),
|
||||||
|
chmodSyncAttempt: attemptify_1.attemptifySync(fs.chmodSync, fs_handlers_1.default.onChangeError),
|
||||||
|
chownSyncAttempt: attemptify_1.attemptifySync(fs.chownSync, fs_handlers_1.default.onChangeError),
|
||||||
|
closeSyncAttempt: attemptify_1.attemptifySync(fs.closeSync),
|
||||||
|
mkdirSyncAttempt: attemptify_1.attemptifySync(fs.mkdirSync),
|
||||||
|
realpathSyncAttempt: attemptify_1.attemptifySync(fs.realpathSync),
|
||||||
|
statSyncAttempt: attemptify_1.attemptifySync(fs.statSync),
|
||||||
|
unlinkSyncAttempt: attemptify_1.attemptifySync(fs.unlinkSync),
|
||||||
|
closeSyncRetry: retryify_1.retryifySync(fs.closeSync, fs_handlers_1.default.isRetriableError),
|
||||||
|
fsyncSyncRetry: retryify_1.retryifySync(fs.fsyncSync, fs_handlers_1.default.isRetriableError),
|
||||||
|
openSyncRetry: retryify_1.retryifySync(fs.openSync, fs_handlers_1.default.isRetriableError),
|
||||||
|
readFileSyncRetry: retryify_1.retryifySync(fs.readFileSync, fs_handlers_1.default.isRetriableError),
|
||||||
|
renameSyncRetry: retryify_1.retryifySync(fs.renameSync, fs_handlers_1.default.isRetriableError),
|
||||||
|
statSyncRetry: retryify_1.retryifySync(fs.statSync, fs_handlers_1.default.isRetriableError),
|
||||||
|
writeSyncRetry: retryify_1.retryifySync(fs.writeSync, fs_handlers_1.default.isRetriableError)
|
||||||
|
};
|
||||||
|
/* EXPORT */
|
||||||
|
exports.default = FS;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"use strict";
|
||||||
|
/* IMPORT */
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
const consts_1 = require("../consts");
|
||||||
|
/* FS HANDLERS */
|
||||||
|
const Handlers = {
|
||||||
|
isChangeErrorOk: (error) => {
|
||||||
|
const { code } = error;
|
||||||
|
if (code === 'ENOSYS')
|
||||||
|
return true;
|
||||||
|
if (!consts_1.IS_USER_ROOT && (code === 'EINVAL' || code === 'EPERM'))
|
||||||
|
return true;
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
isRetriableError: (error) => {
|
||||||
|
const { code } = error;
|
||||||
|
if (code === 'EMFILE' || code === 'ENFILE' || code === 'EAGAIN' || code === 'EBUSY' || code === 'EACCESS' || code === 'EACCS' || code === 'EPERM')
|
||||||
|
return true;
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
onChangeError: (error) => {
|
||||||
|
if (Handlers.isChangeErrorOk(error))
|
||||||
|
return;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
/* EXPORT */
|
||||||
|
exports.default = Handlers;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"use strict";
|
||||||
|
/* LANG */
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
const Lang = {
|
||||||
|
isFunction: (x) => {
|
||||||
|
return typeof x === 'function';
|
||||||
|
},
|
||||||
|
isString: (x) => {
|
||||||
|
return typeof x === 'string';
|
||||||
|
},
|
||||||
|
isUndefined: (x) => {
|
||||||
|
return typeof x === 'undefined';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
/* EXPORT */
|
||||||
|
exports.default = Lang;
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"use strict";
|
||||||
|
/* IMPORT */
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.retryifySync = exports.retryifyAsync = void 0;
|
||||||
|
const retryify_queue_1 = require("./retryify_queue");
|
||||||
|
/* RETRYIFY */
|
||||||
|
const retryifyAsync = (fn, isRetriableError) => {
|
||||||
|
return function (timestamp) {
|
||||||
|
return function attempt() {
|
||||||
|
return retryify_queue_1.default.schedule().then(cleanup => {
|
||||||
|
return fn.apply(undefined, arguments).then(result => {
|
||||||
|
cleanup();
|
||||||
|
return result;
|
||||||
|
}, error => {
|
||||||
|
cleanup();
|
||||||
|
if (Date.now() >= timestamp)
|
||||||
|
throw error;
|
||||||
|
if (isRetriableError(error)) {
|
||||||
|
const delay = Math.round(100 + (400 * Math.random())), delayPromise = new Promise(resolve => setTimeout(resolve, delay));
|
||||||
|
return delayPromise.then(() => attempt.apply(undefined, arguments));
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
exports.retryifyAsync = retryifyAsync;
|
||||||
|
const retryifySync = (fn, isRetriableError) => {
|
||||||
|
return function (timestamp) {
|
||||||
|
return function attempt() {
|
||||||
|
try {
|
||||||
|
return fn.apply(undefined, arguments);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
if (Date.now() > timestamp)
|
||||||
|
throw error;
|
||||||
|
if (isRetriableError(error))
|
||||||
|
return attempt.apply(undefined, arguments);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
exports.retryifySync = retryifySync;
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"use strict";
|
||||||
|
/* IMPORT */
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
const consts_1 = require("../consts");
|
||||||
|
/* RETRYIFY QUEUE */
|
||||||
|
const RetryfyQueue = {
|
||||||
|
interval: 25,
|
||||||
|
intervalId: undefined,
|
||||||
|
limit: consts_1.LIMIT_FILES_DESCRIPTORS,
|
||||||
|
queueActive: new Set(),
|
||||||
|
queueWaiting: new Set(),
|
||||||
|
init: () => {
|
||||||
|
if (RetryfyQueue.intervalId)
|
||||||
|
return;
|
||||||
|
RetryfyQueue.intervalId = setInterval(RetryfyQueue.tick, RetryfyQueue.interval);
|
||||||
|
},
|
||||||
|
reset: () => {
|
||||||
|
if (!RetryfyQueue.intervalId)
|
||||||
|
return;
|
||||||
|
clearInterval(RetryfyQueue.intervalId);
|
||||||
|
delete RetryfyQueue.intervalId;
|
||||||
|
},
|
||||||
|
add: (fn) => {
|
||||||
|
RetryfyQueue.queueWaiting.add(fn);
|
||||||
|
if (RetryfyQueue.queueActive.size < (RetryfyQueue.limit / 2)) { // Active queue not under preassure, executing immediately
|
||||||
|
RetryfyQueue.tick();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
RetryfyQueue.init();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
remove: (fn) => {
|
||||||
|
RetryfyQueue.queueWaiting.delete(fn);
|
||||||
|
RetryfyQueue.queueActive.delete(fn);
|
||||||
|
},
|
||||||
|
schedule: () => {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const cleanup = () => RetryfyQueue.remove(resolver);
|
||||||
|
const resolver = () => resolve(cleanup);
|
||||||
|
RetryfyQueue.add(resolver);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
tick: () => {
|
||||||
|
if (RetryfyQueue.queueActive.size >= RetryfyQueue.limit)
|
||||||
|
return;
|
||||||
|
if (!RetryfyQueue.queueWaiting.size)
|
||||||
|
return RetryfyQueue.reset();
|
||||||
|
for (const fn of RetryfyQueue.queueWaiting) {
|
||||||
|
if (RetryfyQueue.queueActive.size >= RetryfyQueue.limit)
|
||||||
|
break;
|
||||||
|
RetryfyQueue.queueWaiting.delete(fn);
|
||||||
|
RetryfyQueue.queueActive.add(fn);
|
||||||
|
fn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
/* EXPORT */
|
||||||
|
exports.default = RetryfyQueue;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"use strict";
|
||||||
|
/* IMPORT */
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
/* VARIABLES */
|
||||||
|
const Queues = {};
|
||||||
|
/* SCHEDULER */
|
||||||
|
//TODO: Maybe publish this as a standalone package
|
||||||
|
const Scheduler = {
|
||||||
|
next: (id) => {
|
||||||
|
const queue = Queues[id];
|
||||||
|
if (!queue)
|
||||||
|
return;
|
||||||
|
queue.shift();
|
||||||
|
const job = queue[0];
|
||||||
|
if (job) {
|
||||||
|
job(() => Scheduler.next(id));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
delete Queues[id];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
schedule: (id) => {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
let queue = Queues[id];
|
||||||
|
if (!queue)
|
||||||
|
queue = Queues[id] = [];
|
||||||
|
queue.push(resolve);
|
||||||
|
if (queue.length > 1)
|
||||||
|
return;
|
||||||
|
resolve(() => Scheduler.next(id));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
/* EXPORT */
|
||||||
|
exports.default = Scheduler;
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"use strict";
|
||||||
|
/* IMPORT */
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
const path = require("path");
|
||||||
|
const consts_1 = require("../consts");
|
||||||
|
const fs_1 = require("./fs");
|
||||||
|
/* TEMP */
|
||||||
|
//TODO: Maybe publish this as a standalone package
|
||||||
|
const Temp = {
|
||||||
|
store: {},
|
||||||
|
create: (filePath) => {
|
||||||
|
const randomness = `000000${Math.floor(Math.random() * 16777215).toString(16)}`.slice(-6), // 6 random-enough hex characters
|
||||||
|
timestamp = Date.now().toString().slice(-10), // 10 precise timestamp digits
|
||||||
|
prefix = 'tmp-', suffix = `.${prefix}${timestamp}${randomness}`, tempPath = `${filePath}${suffix}`;
|
||||||
|
return tempPath;
|
||||||
|
},
|
||||||
|
get: (filePath, creator, purge = true) => {
|
||||||
|
const tempPath = Temp.truncate(creator(filePath));
|
||||||
|
if (tempPath in Temp.store)
|
||||||
|
return Temp.get(filePath, creator, purge); // Collision found, try again
|
||||||
|
Temp.store[tempPath] = purge;
|
||||||
|
const disposer = () => delete Temp.store[tempPath];
|
||||||
|
return [tempPath, disposer];
|
||||||
|
},
|
||||||
|
purge: (filePath) => {
|
||||||
|
if (!Temp.store[filePath])
|
||||||
|
return;
|
||||||
|
delete Temp.store[filePath];
|
||||||
|
fs_1.default.unlinkAttempt(filePath);
|
||||||
|
},
|
||||||
|
purgeSync: (filePath) => {
|
||||||
|
if (!Temp.store[filePath])
|
||||||
|
return;
|
||||||
|
delete Temp.store[filePath];
|
||||||
|
fs_1.default.unlinkSyncAttempt(filePath);
|
||||||
|
},
|
||||||
|
purgeSyncAll: () => {
|
||||||
|
for (const filePath in Temp.store) {
|
||||||
|
Temp.purgeSync(filePath);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
truncate: (filePath) => {
|
||||||
|
const basename = path.basename(filePath);
|
||||||
|
if (basename.length <= consts_1.LIMIT_BASENAME_LENGTH)
|
||||||
|
return filePath; //FIXME: Rough and quick attempt at detecting ok lengths
|
||||||
|
const truncable = /^(\.?)(.*?)((?:\.[^.]+)?(?:\.tmp-\d{10}[a-f0-9]{6})?)$/.exec(basename);
|
||||||
|
if (!truncable)
|
||||||
|
return filePath; //FIXME: No truncable part detected, can't really do much without also changing the parent path, which is unsafe, hoping for the best here
|
||||||
|
const truncationLength = basename.length - consts_1.LIMIT_BASENAME_LENGTH;
|
||||||
|
return `${filePath.slice(0, -basename.length)}${truncable[1]}${truncable[2].slice(0, -truncationLength)}${truncable[3]}`; //FIXME: The truncable part might be shorter than needed here
|
||||||
|
}
|
||||||
|
};
|
||||||
|
/* INIT */
|
||||||
|
process.on('exit', Temp.purgeSyncAll); // Ensuring purgeable temp files are purged on exit
|
||||||
|
/* EXPORT */
|
||||||
|
exports.default = Temp;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"use strict";
|
||||||
|
/* IMPORT */
|
||||||
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||||
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||||
|
};
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.RENAME_TIMEOUT = exports.POLLING_TIMEOUT = exports.POLLING_INTERVAL = exports.PLATFORM = exports.IS_WINDOWS = exports.IS_MAC = exports.IS_LINUX = exports.HAS_NATIVE_RECURSION = exports.DEPTH = exports.DEBOUNCE = void 0;
|
||||||
|
const os_1 = __importDefault(require("os"));
|
||||||
|
/* CONSTANTS */
|
||||||
|
const DEBOUNCE = 300;
|
||||||
|
exports.DEBOUNCE = DEBOUNCE;
|
||||||
|
const DEPTH = 20;
|
||||||
|
exports.DEPTH = DEPTH;
|
||||||
|
const PLATFORM = os_1.default.platform();
|
||||||
|
exports.PLATFORM = PLATFORM;
|
||||||
|
const IS_LINUX = (PLATFORM === 'linux');
|
||||||
|
exports.IS_LINUX = IS_LINUX;
|
||||||
|
const IS_MAC = (PLATFORM === 'darwin');
|
||||||
|
exports.IS_MAC = IS_MAC;
|
||||||
|
const IS_WINDOWS = (PLATFORM === 'win32');
|
||||||
|
exports.IS_WINDOWS = IS_WINDOWS;
|
||||||
|
const HAS_NATIVE_RECURSION = IS_MAC || IS_WINDOWS;
|
||||||
|
exports.HAS_NATIVE_RECURSION = HAS_NATIVE_RECURSION;
|
||||||
|
const POLLING_INTERVAL = 3000;
|
||||||
|
exports.POLLING_INTERVAL = POLLING_INTERVAL;
|
||||||
|
const POLLING_TIMEOUT = 20000;
|
||||||
|
exports.POLLING_TIMEOUT = POLLING_TIMEOUT;
|
||||||
|
const RENAME_TIMEOUT = 1250;
|
||||||
|
exports.RENAME_TIMEOUT = RENAME_TIMEOUT;
|
||||||
|
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY29uc3RhbnRzLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vc3JjL2NvbnN0YW50cy50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiO0FBQ0EsWUFBWTs7Ozs7O0FBRVosNENBQW9CO0FBRXBCLGVBQWU7QUFFZixNQUFNLFFBQVEsR0FBRyxHQUFHLENBQUM7QUFzQmIsNEJBQVE7QUFwQmhCLE1BQU0sS0FBSyxHQUFHLEVBQUUsQ0FBQztBQW9CQyxzQkFBSztBQWxCdkIsTUFBTSxRQUFRLEdBQUcsWUFBRSxDQUFDLFFBQVEsRUFBRyxDQUFDO0FBa0I2Qyw0QkFBUTtBQWhCckYsTUFBTSxRQUFRLEdBQUcsQ0FBRSxRQUFRLEtBQUssT0FBTyxDQUFFLENBQUM7QUFnQkssNEJBQVE7QUFkdkQsTUFBTSxNQUFNLEdBQUcsQ0FBRSxRQUFRLEtBQUssUUFBUSxDQUFFLENBQUM7QUFjZ0Isd0JBQU07QUFaL0QsTUFBTSxVQUFVLEdBQUcsQ0FBRSxRQUFRLEtBQUssT0FBTyxDQUFFLENBQUM7QUFZcUIsZ0NBQVU7QUFWM0UsTUFBTSxvQkFBb0IsR0FBRyxNQUFNLElBQUksVUFBVSxDQUFDO0FBVXpCLG9EQUFvQjtBQVI3QyxNQUFNLGdCQUFnQixHQUFHLElBQUksQ0FBQztBQVF5RCw0Q0FBZ0I7QUFOdkcsTUFBTSxlQUFlLEdBQUcsS0FBSyxDQUFDO0FBTTJFLDBDQUFlO0FBSnhILE1BQU0sY0FBYyxHQUFHLElBQUksQ0FBQztBQUk4Rix3Q0FBYyJ9
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Returns a function, that, as long as it continues to be invoked, will not
|
||||||
|
* be triggered. The function will be called after it stops being called for
|
||||||
|
* N milliseconds. If `immediate` is passed, trigger the function on the
|
||||||
|
* leading edge, instead of the trailing. The function also has a property 'clear'
|
||||||
|
* that is a function which will clear the timer to prevent previously scheduled executions.
|
||||||
|
*
|
||||||
|
* @source underscore.js
|
||||||
|
* @see http://unscriptable.com/2009/03/20/debouncing-javascript-methods/
|
||||||
|
* @param {Function} function to wrap
|
||||||
|
* @param {Number} timeout in ms (`100`)
|
||||||
|
* @param {Boolean} whether to execute at the beginning (`false`)
|
||||||
|
* @api public
|
||||||
|
*/
|
||||||
|
function debounce(func, wait, immediate) {
|
||||||
|
var timeout, args, context, timestamp, result;
|
||||||
|
if (null == wait) wait = 100;
|
||||||
|
|
||||||
|
function later() {
|
||||||
|
var last = Date.now() - timestamp;
|
||||||
|
|
||||||
|
if (last < wait && last >= 0) {
|
||||||
|
timeout = setTimeout(later, wait - last);
|
||||||
|
} else {
|
||||||
|
timeout = null;
|
||||||
|
if (!immediate) {
|
||||||
|
result = func.apply(context, args);
|
||||||
|
context = args = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var debounced = function () {
|
||||||
|
context = this;
|
||||||
|
args = arguments;
|
||||||
|
timestamp = Date.now();
|
||||||
|
var callNow = immediate && !timeout;
|
||||||
|
if (!timeout) timeout = setTimeout(later, wait);
|
||||||
|
if (callNow) {
|
||||||
|
result = func.apply(context, args);
|
||||||
|
context = args = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
debounced.clear = function () {
|
||||||
|
if (timeout) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
debounced.flush = function () {
|
||||||
|
if (timeout) {
|
||||||
|
result = func.apply(context, args);
|
||||||
|
context = args = null;
|
||||||
|
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return debounced;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Adds compatibility for ES modules
|
||||||
|
debounce.debounce = debounce;
|
||||||
|
|
||||||
|
module.exports = debounce;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
"use strict";
|
||||||
|
/* ENUMS */
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZW51bXMuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi9zcmMvZW51bXMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IjtBQUNBLFdBQVcifQ==
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user