mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-11-13 12:23:34 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75d85e971b | ||
|
|
3e0333877b | ||
|
|
2dde0bf1c7 | ||
|
|
83dce682e8 | ||
|
|
f88fdf1321 | ||
|
|
20fd0cd700 | ||
|
|
9b055f80a6 | ||
|
|
04acd53667 | ||
|
|
49c0ef6c07 | ||
|
|
f57ca09f6e | ||
|
|
149bae7d73 | ||
|
|
d6d0cf6b8f | ||
|
|
087099faa3 | ||
|
|
ce41ce489c | ||
|
|
b31af26837 | ||
|
|
4072054e00 | ||
|
|
f1f57d6595 | ||
|
|
7aaa734d3e | ||
|
|
48d9309eb2 | ||
|
|
616c8ba152 | ||
|
|
edb1f7efc9 | ||
|
|
92517f473b | ||
|
|
9715c7a8f7 | ||
|
|
95cb5bad27 | ||
|
|
5cb461f9b5 | ||
|
|
16aa80ed5c | ||
|
|
743d915220 | ||
|
|
34715558df | ||
|
|
2a0507ffc6 | ||
|
|
cff295fdc9 | ||
|
|
6b60beff6e | ||
|
|
db433ce076 | ||
|
|
5edd20dfa3 | ||
|
|
3563d49424 | ||
|
|
06935b78b5 | ||
|
|
4aee64da07 | ||
|
|
02cf2da4fa | ||
|
|
366320bf94 | ||
|
|
a2cba8dd05 | ||
|
|
86bc18487b | ||
|
|
9e18cb41e9 | ||
|
|
7d10b8fc4a | ||
|
|
14bec248f1 | ||
|
|
ff57368f37 | ||
|
|
f589397008 | ||
|
|
fa4f6b58f3 | ||
|
|
1acfd2ae54 | ||
|
|
91717e3b96 | ||
|
|
282d8b8bf6 | ||
|
|
fb4c3ba984 | ||
|
|
72a1c656f9 | ||
|
|
273693e8ad | ||
|
|
d22c468215 | ||
|
|
c654de4630 | ||
|
|
aef33df8d3 | ||
|
|
63a883e595 | ||
|
|
b8b069eca6 | ||
|
|
0fd0483021 | ||
|
|
8463b8426a | ||
|
|
e874d31e64 | ||
|
|
557f56ff21 | ||
|
|
7f296a5135 | ||
|
|
b5134a3287 | ||
|
|
8fb7a856d5 | ||
|
|
ad8b235058 | ||
|
|
186733f6af | ||
|
|
1e5443f054 | ||
|
|
18e32410ad | ||
|
|
5077f87e0c | ||
|
|
608d3ddde8 | ||
|
|
4c27a7b68e | ||
|
|
a3b150d049 | ||
|
|
088c4f542f | ||
|
|
6acb4b9005 | ||
|
|
8781930ced | ||
|
|
4236eef5c4 | ||
|
|
b14224a6d8 | ||
|
|
a6128a6c98 | ||
|
|
8a2a45751e | ||
|
|
83e9a82f8f | ||
|
|
bf4f74cb93 | ||
|
|
1e88ff01e7 | ||
|
|
5ec4a8a46e | ||
|
|
99e2c45482 | ||
|
|
3c5d191c33 | ||
|
|
b67cbffbf9 | ||
|
|
eeaa408a2d | ||
|
|
eba1909c9c | ||
|
|
fbfb24c264 | ||
|
|
2440fb37c4 | ||
|
|
65c075283c | ||
|
|
198b2c27b8 | ||
|
|
d6398e54dd | ||
|
|
0571747fab | ||
|
|
ec2738cb0f | ||
|
|
a7ebb3e1af | ||
|
|
3f9afe57bc | ||
|
|
ac63b0c7fc | ||
|
|
94400acdcd | ||
|
|
dfce15a74c |
No files matched your search
@@ -1,40 +1,31 @@
|
||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node
|
||||
{
|
||||
"name": "Audiobookshelf",
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile",
|
||||
// Update 'VARIANT' to pick a Node version: 18, 16, 14.
|
||||
// Append -bullseye or -buster to pin to an OS version.
|
||||
// Use -bullseye variants on local arm64/Apple Silicon.
|
||||
"args": {
|
||||
"VARIANT": "20"
|
||||
}
|
||||
},
|
||||
"mounts": [
|
||||
"source=abs-server-node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume",
|
||||
"source=abs-client-node_modules,target=${containerWorkspaceFolder}/client/node_modules,type=volume"
|
||||
],
|
||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||
// "features": {},
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
"forwardPorts": [
|
||||
3000,
|
||||
3333
|
||||
],
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
"postCreateCommand": "sh .devcontainer/post-create.sh",
|
||||
// Configure tool-specific properties.
|
||||
"customizations": {
|
||||
// Configure properties specific to VS Code.
|
||||
"vscode": {
|
||||
// Add the IDs of extensions you want installed when the container is created.
|
||||
"extensions": [
|
||||
"dbaeumer.vscode-eslint",
|
||||
"octref.vetur"
|
||||
]
|
||||
}
|
||||
}
|
||||
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||
// "remoteUser": "root"
|
||||
}
|
||||
"name": "Audiobookshelf",
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile",
|
||||
// Update 'VARIANT' to pick a Node version: 18, 16, 14.
|
||||
// Append -bullseye or -buster to pin to an OS version.
|
||||
// Use -bullseye variants on local arm64/Apple Silicon.
|
||||
"args": {
|
||||
"VARIANT": "24"
|
||||
}
|
||||
},
|
||||
"mounts": ["source=abs-server-node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume", "source=abs-client-node_modules,target=${containerWorkspaceFolder}/client/node_modules,type=volume"],
|
||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||
// "features": {},
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
"forwardPorts": [3000, 3333],
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
"postCreateCommand": "sh .devcontainer/post-create.sh",
|
||||
// Configure tool-specific properties.
|
||||
"customizations": {
|
||||
// Configure properties specific to VS Code.
|
||||
"vscode": {
|
||||
// Add the IDs of extensions you want installed when the container is created.
|
||||
"extensions": ["dbaeumer.vscode-eslint", "octref.vetur"]
|
||||
}
|
||||
}
|
||||
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||
// "remoteUser": "root"
|
||||
}
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 24
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# Builds the v3 test image
|
||||
#
|
||||
# Moves the :v3 tag on Docker Hub and GHCR, the same way master
|
||||
# moves :edge. Using the server's v3 branch and react-client master.
|
||||
#
|
||||
# repository_dispatch (build-v3) is for triggering a build on updates to react-client
|
||||
|
||||
name: Build and Push v3 Docker Image
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [v3]
|
||||
repository_dispatch:
|
||||
types: [build-v3]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.repository == 'advplyr/audiobookshelf' && (github.event_name != 'push' || !contains(github.event.head_commit.message, 'skip ci')) }}
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- name: Check out server
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event_name == 'repository_dispatch' && 'v3' || github.sha }}
|
||||
|
||||
- name: Check out React client
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: audiobookshelf/audiobookshelf-client-react
|
||||
ref: master
|
||||
path: client-react
|
||||
|
||||
- name: Server sha
|
||||
id: server-sha
|
||||
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Client sha
|
||||
id: client-sha
|
||||
working-directory: client-react
|
||||
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: advplyr/audiobookshelf,ghcr.io/${{ github.repository_owner }}/audiobookshelf
|
||||
flavor: |
|
||||
latest=false
|
||||
tags: |
|
||||
type=raw,value=v3
|
||||
labels: |
|
||||
audiobookshelf.server.sha=${{ steps.server-sha.outputs.sha }}
|
||||
audiobookshelf.client.sha=${{ steps.client-sha.outputs.sha }}
|
||||
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Cache Docker layers
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
key: ${{ runner.os }}-buildx-v3-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-buildx-v3-
|
||||
|
||||
- name: Login to Dockerhub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
|
||||
- name: Login to ghcr
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GHCR_PASSWORD }}
|
||||
|
||||
- name: Build image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
file: client-react/Dockerfile
|
||||
context: .
|
||||
build-contexts: |
|
||||
abs-client=client-react
|
||||
abs-server=.
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
|
||||
|
||||
- name: Move cache
|
||||
run: |
|
||||
rm -rf /tmp/.buildx-cache
|
||||
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
|
||||
@@ -19,6 +19,8 @@ on:
|
||||
- server/**
|
||||
- index.js
|
||||
- package.json
|
||||
- tsconfig.server.json
|
||||
- Dockerfile
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
- name: Set up node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 24
|
||||
cache: 'npm'
|
||||
|
||||
# The only argument is the `directory`, which is where the i18n files are
|
||||
|
||||
@@ -12,6 +12,7 @@ on:
|
||||
- test/**
|
||||
- index.js
|
||||
- package.json
|
||||
- tsconfig.server.json
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -23,10 +24,10 @@ jobs:
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 24
|
||||
cache: 'npm'
|
||||
|
||||
- name: install pkg (using yao-pkg fork for targeting node20)
|
||||
- name: install pkg (using yao-pkg fork for targeting node24)
|
||||
run: npm install -g @yao-pkg/pkg
|
||||
|
||||
- name: get client dependencies
|
||||
@@ -37,11 +38,11 @@ jobs:
|
||||
working-directory: client
|
||||
run: npm run generate
|
||||
|
||||
- name: get server dependencies
|
||||
run: npm ci --only=production
|
||||
- name: get server dependencies and compile server
|
||||
run: npm ci && npm run build:server && npm prune --omit=dev
|
||||
|
||||
- name: build binary
|
||||
run: pkg -t node20-linux-x64 -o audiobookshelf .
|
||||
run: pkg --config package.json -t node24-linux-x64 -o audiobookshelf dist-server/index.js
|
||||
|
||||
- name: run audiobookshelf
|
||||
run: |
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
- name: Set up node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 24
|
||||
cache: 'npm'
|
||||
|
||||
# Install Redocly CLI
|
||||
|
||||
@@ -28,11 +28,14 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 24
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
@@ -11,6 +11,7 @@
|
||||
/client/.nuxt/
|
||||
/client/dist/
|
||||
/dist/
|
||||
/dist-server/
|
||||
/deploy/
|
||||
/coverage/
|
||||
/.nyc_output/
|
||||
|
||||
+16
-7
@@ -2,15 +2,23 @@ ARG NUSQLITE3_DIR="/usr/local/lib/nusqlite3"
|
||||
ARG NUSQLITE3_PATH="${NUSQLITE3_DIR}/libnusqlite3.so"
|
||||
|
||||
### STAGE 0: Build client ###
|
||||
FROM node:20-alpine AS build-client
|
||||
FROM node:24-alpine AS build-client
|
||||
|
||||
WORKDIR /client
|
||||
COPY /client /client
|
||||
RUN npm ci && npm cache clean --force
|
||||
RUN npm run generate
|
||||
|
||||
### STAGE 1: Build server ###
|
||||
FROM node:20-alpine AS build-server
|
||||
### STAGE 1: Compile server on the builder CPU (avoid QEMU SIGILL from tsc on arm64) ###
|
||||
FROM --platform=$BUILDPLATFORM node:24-alpine AS compile-server
|
||||
|
||||
WORKDIR /server
|
||||
COPY index.js package* tsconfig.server.json /server
|
||||
COPY /server /server/server
|
||||
RUN npm ci --include=dev --ignore-scripts && npm run build:server
|
||||
|
||||
### STAGE 2: Install native server deps for the target arch ###
|
||||
FROM node:24-alpine AS build-server
|
||||
|
||||
ARG NUSQLITE3_DIR
|
||||
ARG TARGETPLATFORM
|
||||
@@ -27,6 +35,7 @@ RUN apk add --no-cache --update \
|
||||
WORKDIR /server
|
||||
COPY index.js package* /server
|
||||
COPY /server /server/server
|
||||
COPY --from=compile-server /server/dist-server /server/dist-server
|
||||
|
||||
RUN case "$TARGETPLATFORM" in \
|
||||
"linux/amd64") \
|
||||
@@ -38,10 +47,10 @@ RUN case "$TARGETPLATFORM" in \
|
||||
unzip /tmp/library.zip -d $NUSQLITE3_DIR && \
|
||||
rm /tmp/library.zip
|
||||
|
||||
RUN npm ci --only=production
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
### STAGE 2: Create minimal runtime image ###
|
||||
FROM node:20-alpine
|
||||
### STAGE 3: Create minimal runtime image ###
|
||||
FROM node:24-alpine
|
||||
|
||||
ARG NUSQLITE3_DIR
|
||||
ARG NUSQLITE3_PATH
|
||||
@@ -70,4 +79,4 @@ ENV NUSQLITE3_DIR=${NUSQLITE3_DIR}
|
||||
ENV NUSQLITE3_PATH=${NUSQLITE3_PATH}
|
||||
|
||||
ENTRYPOINT ["tini", "--"]
|
||||
CMD ["node", "index.js"]
|
||||
CMD ["node", "dist-server/index.js"]
|
||||
@@ -11,6 +11,8 @@ ExecReload=/bin/kill -HUP $MAINPID
|
||||
Restart=always
|
||||
User=audiobookshelf
|
||||
Group=audiobookshelf
|
||||
Environment=HOME=/var/cache/audiobookshelf
|
||||
CacheDirectory=audiobookshelf
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
+10
-4
@@ -16,7 +16,7 @@ echo "--------------------"
|
||||
|
||||
cd client
|
||||
rm -rf node_modules
|
||||
npm ci --unsafe-perm=true --allow-root
|
||||
npm ci
|
||||
npm run generate
|
||||
cd ..
|
||||
|
||||
@@ -24,7 +24,8 @@ echo ">>> Building Server"
|
||||
echo "--------------------"
|
||||
|
||||
rm -rf node_modules
|
||||
npm ci --unsafe-perm=true --allow-root
|
||||
npm ci
|
||||
npm run build:server
|
||||
|
||||
echo ">>> Packaging"
|
||||
echo "--------------------"
|
||||
@@ -34,7 +35,6 @@ echo "--------------------"
|
||||
mkdir -p dist
|
||||
rm -rf dist/debian
|
||||
cp -R build/debian dist/debian
|
||||
chmod -R 775 dist/debian
|
||||
|
||||
controlfile="Package: audiobookshelf
|
||||
Version: $VERSION
|
||||
@@ -47,8 +47,14 @@ Description: $DESCRIPTION"
|
||||
|
||||
echo "$controlfile" > dist/debian/DEBIAN/control;
|
||||
|
||||
# Package contents: directories 0755, files 0644 (including the systemd unit).
|
||||
# Maintainer scripts must stay executable so dpkg can run them.
|
||||
find dist/debian -type d -exec chmod 755 {} +
|
||||
find dist/debian -type f -exec chmod 644 {} +
|
||||
chmod 755 dist/debian/DEBIAN/preinst dist/debian/DEBIAN/postinst dist/debian/DEBIAN/prerm
|
||||
|
||||
# Package debian
|
||||
pkg -t node20-linux-x64 -o dist/debian/usr/share/audiobookshelf/audiobookshelf .
|
||||
pkg --config package.json -t node24-linux-x64 -o dist/debian/usr/share/audiobookshelf/audiobookshelf dist-server/index.js
|
||||
|
||||
fakeroot dpkg-deb -Zxz --build dist/debian
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
<div v-if="userCanUpdate" class="mx-1" :class="isHovering ? '' : 'ml-6'">
|
||||
<ui-icon-btn icon="edit" borderless @click="clickEdit" />
|
||||
</div>
|
||||
<div v-if="userCanDelete" class="mx-1">
|
||||
<div v-if="userCanUpdate" class="mx-1">
|
||||
<ui-icon-btn icon="close" borderless @click="removeClick" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -79,8 +79,7 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
translateDistance() {
|
||||
if (!this.userCanUpdate && !this.userCanDelete) return 'translate-x-0'
|
||||
else if (!this.userCanUpdate || !this.userCanDelete) return '-translate-x-12'
|
||||
if (!this.userCanUpdate) return 'translate-x-0'
|
||||
return '-translate-x-24'
|
||||
},
|
||||
media() {
|
||||
@@ -144,9 +143,6 @@ export default {
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -60,6 +60,7 @@ module.exports = {
|
||||
proxy,
|
||||
|
||||
io: {
|
||||
server: false,
|
||||
sockets: [
|
||||
{
|
||||
name: 'dev',
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.36.1",
|
||||
"version": "2.37.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.36.1",
|
||||
"version": "2.37.2",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@nuxtjs/axios": "^5.13.6",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.36.1",
|
||||
"version": "2.37.2",
|
||||
"buildNumber": 1,
|
||||
"description": "Self-hosted audiobook and podcast client",
|
||||
"main": "index.js",
|
||||
|
||||
@@ -208,7 +208,7 @@
|
||||
"HeaderStatsLargestItems": "Největší položky",
|
||||
"HeaderStatsLongestItems": "Nejdelší položky (hod.)",
|
||||
"HeaderStatsMinutesListeningChart": "Poslech za posledních 7 dní (min)",
|
||||
"HeaderStatsRecentSessions": "Poslední relace",
|
||||
"HeaderStatsRecentSessions": "Nedávné relace",
|
||||
"HeaderStatsTop10Authors": "Top 10 autorů",
|
||||
"HeaderStatsTop5Genres": "Top 5 žánrů",
|
||||
"HeaderTableOfContents": "Obsah",
|
||||
@@ -1135,12 +1135,12 @@
|
||||
"ToastSeriesUpdateFailed": "Aktualizace série se nezdařila",
|
||||
"ToastSeriesUpdateSuccess": "Aktualizace série byla úspěšná",
|
||||
"ToastServerSettingsUpdateSuccess": "Nastavení serveru aktualizováno",
|
||||
"ToastSessionCloseFailed": "Chyba při ukončení",
|
||||
"ToastSessionCloseFailed": "Nepodařilo se ukončit relaci",
|
||||
"ToastSessionDeleteFailed": "Nepodařilo se smazat relaci",
|
||||
"ToastSessionDeleteSuccess": "Relace smazána",
|
||||
"ToastSleepTimerDone": "Uspání knížky ... zZzzZz",
|
||||
"ToastSlugMustChange": "Slug obsahuje chybné znaky",
|
||||
"ToastSlugRequired": "Slug (URL) je vyžadována",
|
||||
"ToastSlugRequired": "Slug (URL) je vyžadován",
|
||||
"ToastSocketConnected": "Socket připojen",
|
||||
"ToastSocketDisconnected": "Socket odpojen",
|
||||
"ToastSocketFailedToConnect": "Socket se nepodařilo připojit",
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"ButtonLatest": "Más recientes",
|
||||
"ButtonLibrary": "Biblioteca",
|
||||
"ButtonLogout": "Cerrar Sesión",
|
||||
"ButtonLogoutAllDevices": "Cerrar sesión en todos los dispositivos",
|
||||
"ButtonLookup": "Averiguar",
|
||||
"ButtonManageTracks": "Gestionar pistas",
|
||||
"ButtonMapChapterTitles": "Asignar Títulos a Capítulos",
|
||||
@@ -194,6 +195,7 @@
|
||||
"HeaderScheduleEpisodeDownloads": "Planificador de auto‐descargas de episodios",
|
||||
"HeaderScheduleLibraryScans": "Planificar Auto‐Escaneo de Biblioteca",
|
||||
"HeaderSession": "Sesión",
|
||||
"HeaderSessions": "Sesiones",
|
||||
"HeaderSetBackupSchedule": "Establecer Planificación de Respaldo",
|
||||
"HeaderSettings": "Ajustes",
|
||||
"HeaderSettingsDisplay": "Interfaz",
|
||||
@@ -415,6 +417,7 @@
|
||||
"LabelIntervalEveryHour": "Cada hora",
|
||||
"LabelIntervalEveryMinute": "Cada minuto",
|
||||
"LabelInvert": "Invertir",
|
||||
"LabelIpAddress": "Dirección IP",
|
||||
"LabelItem": "Elemento",
|
||||
"LabelJumpBackwardAmount": "Cantidad de saltos hacia atrás",
|
||||
"LabelJumpForwardAmount": "Cantidad de saltos hacia adelante",
|
||||
@@ -774,6 +777,7 @@
|
||||
"MessageConfirmDeleteSession": "¿Confirma que quiere eliminar esta sesión?",
|
||||
"MessageConfirmEmbedMetadataInAudioFiles": "¿Confirma que quiere incrustar metadatos en {0} archivos de audio?",
|
||||
"MessageConfirmForceReScan": "¿Confirma que quiere forzar un reescaneo?",
|
||||
"MessageConfirmLogoutDevice": "¿Seguro que quieres cerrar la sesión de «{0}»?",
|
||||
"MessageConfirmMarkAllEpisodesFinished": "¿Confirma que quiere marcar todos los episodios como terminados?",
|
||||
"MessageConfirmMarkAllEpisodesNotFinished": "¿Confirma que quiere marcar todos los episodios como no terminados?",
|
||||
"MessageConfirmMarkItemFinished": "¿Confirma que quiere marcar «{0}» como terminado?",
|
||||
|
||||
+12
-9
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"ButtonAdd": "Lisa",
|
||||
"ButtonAddApiKey": "Lisa API võti",
|
||||
"ButtonAddChapters": "Lisa peatükid",
|
||||
"ButtonAddDevice": "Lisa seade",
|
||||
"ButtonAddLibrary": "Lisa raamatukogu",
|
||||
@@ -53,7 +54,7 @@
|
||||
"ButtonOpenFeed": "Ava voog",
|
||||
"ButtonOpenManager": "Ava haldur",
|
||||
"ButtonPause": "Paus",
|
||||
"ButtonPlay": "Play",
|
||||
"ButtonPlay": "Mängi",
|
||||
"ButtonPlayAll": "Mängi kõik",
|
||||
"ButtonPlaying": "Mängib",
|
||||
"ButtonPlaylists": "Playlist",
|
||||
@@ -159,7 +160,7 @@
|
||||
"HeaderPlaylistItems": "Playlisti esemed",
|
||||
"HeaderPodcastsToAdd": "Lisatavad podcastid",
|
||||
"HeaderPreviewCover": "Eelvaate kaas",
|
||||
"HeaderRSSFeedGeneral": "RSS-i üksikasjad",
|
||||
"HeaderRSSFeedGeneral": "RSS detailid",
|
||||
"HeaderRSSFeedIsOpen": "RSS-voog on avatud",
|
||||
"HeaderRSSFeeds": "RSS-vooged",
|
||||
"HeaderRemoveEpisode": "Eemalda episood",
|
||||
@@ -177,7 +178,7 @@
|
||||
"HeaderSleepTimer": "Unetaimer",
|
||||
"HeaderStatsLargestItems": "Suurimad esemed",
|
||||
"HeaderStatsLongestItems": "Kõige pikemad esemed (tunnid)",
|
||||
"HeaderStatsMinutesListeningChart": "Kuulamise minutid (viimased 7 päeva)",
|
||||
"HeaderStatsMinutesListeningChart": "Kuulatud minutid (viimased 7 päeva)",
|
||||
"HeaderStatsRecentSessions": "Hiljutised sessioonid",
|
||||
"HeaderStatsTop10Authors": "Top 10 autorit",
|
||||
"HeaderStatsTop5Genres": "Top 5 žanrit",
|
||||
@@ -245,7 +246,7 @@
|
||||
"LabelConfirmPassword": "Kinnita parool",
|
||||
"LabelContinueListening": "Jätka kuulamist",
|
||||
"LabelContinueReading": "Jätka lugemist",
|
||||
"LabelContinueSeries": "Jätka seeriat",
|
||||
"LabelContinueSeries": "Jäta sarja",
|
||||
"LabelCover": "Ümbris",
|
||||
"LabelCoverImageURL": "Ümbrise pildi URL",
|
||||
"LabelCreatedAt": "Loodud",
|
||||
@@ -325,9 +326,10 @@
|
||||
"LabelIntervalEveryDay": "Iga päev",
|
||||
"LabelIntervalEveryHour": "Iga tunni tagant",
|
||||
"LabelInvert": "Pööra ümber",
|
||||
"LabelIpAddress": "IP-aadress",
|
||||
"LabelItem": "Kirje",
|
||||
"LabelLanguage": "Keel",
|
||||
"LabelLanguageDefaultServer": "Vaikeserveri keel",
|
||||
"LabelLanguageDefaultServer": "Serveri vaikekeel",
|
||||
"LabelLastBookAdded": "Viimati lisatud raamat",
|
||||
"LabelLastBookUpdated": "Viimati uuendatud raamat",
|
||||
"LabelLastSeen": "Viimati nähtud",
|
||||
@@ -368,8 +370,8 @@
|
||||
"LabelNarrators": "Jutustajad",
|
||||
"LabelNew": "Uus",
|
||||
"LabelNewPassword": "Uus parool",
|
||||
"LabelNewestAuthors": "Uuemad autorid",
|
||||
"LabelNewestEpisodes": "Uuemad episoodid",
|
||||
"LabelNewestAuthors": "Uusimad autorid",
|
||||
"LabelNewestEpisodes": "Uusimad episoodid",
|
||||
"LabelNextBackupDate": "Järgmine varukoopia kuupäev",
|
||||
"LabelNextScheduledRun": "Järgmine ajakava järgmine",
|
||||
"LabelNoEpisodesSelected": "Episoodid pole valitud",
|
||||
@@ -458,8 +460,8 @@
|
||||
"LabelSettingsFindCoversHelp": "Kui teie heliraamatul pole sisseehitatud ümbrist ega ümbrise pilti kaustas, proovib skanner leida ümbrist.<br>Märkus: see pikendab skaneerimisaega",
|
||||
"LabelSettingsHideSingleBookSeries": "Peida üksikute raamatute seeriad",
|
||||
"LabelSettingsHideSingleBookSeriesHelp": "Ühe raamatuga seeriaid peidetakse seeria lehelt ja avalehe riiulitelt.",
|
||||
"LabelSettingsHomePageBookshelfView": "Avaleht kasutage raamatukoguvaadet",
|
||||
"LabelSettingsLibraryBookshelfView": "Raamatukogu kasutamiseks kasutage raamatukoguvaadet",
|
||||
"LabelSettingsHomePageBookshelfView": "Avalehel kasuta raamaturiiulivaadet",
|
||||
"LabelSettingsLibraryBookshelfView": "Raamatukogus kasuta raamaturiiulivaadet",
|
||||
"LabelSettingsParseSubtitles": "Lugege subtiitreid",
|
||||
"LabelSettingsParseSubtitlesHelp": "Eraldage subtiitrid heliraamatu kaustade nimedest.<br>Subtiitrid peavad olema eraldatud kasutades \" - \".<br>Näiteks: \"Raamatu pealkiri - Siin on alapealkiri\" alapealkiri on \"Siin on alapealkiri\"",
|
||||
"LabelSettingsPreferMatchedMetadata": "Eelista sobitatud metaandmeid",
|
||||
@@ -565,6 +567,7 @@
|
||||
"MessageBookshelfNoCollectionsHelp": "Kollektsioonid on avalikud. Kõik kasutajad kellel on olemas ligipääs raamatukogule saavad neid näha.",
|
||||
"MessageBookshelfNoRSSFeeds": "Ühtegi RSS-i voogu pole avatud",
|
||||
"MessageBookshelfNoResultsForFilter": "Filtrile \"{0}: {1}\" pole tulemusi",
|
||||
"MessageBookshelfNoResultsForQuery": "Päringul ei ole tulemusi",
|
||||
"MessageBookshelfNoSeries": "Teil pole ühtegi seeriat",
|
||||
"MessageChapterEndIsAfter": "Peatüki lõpp on pärast teie heliraamatu lõppu",
|
||||
"MessageChapterErrorFirstNotZero": "Esimene peatükk peab algama 0-st",
|
||||
|
||||
@@ -233,7 +233,7 @@
|
||||
"LabelComplete": "Baigta",
|
||||
"LabelConfirmPassword": "Patvirtinkite slaptažodį",
|
||||
"LabelContinueListening": "Klausytis toliau",
|
||||
"LabelContinueReading": "Tęsti skaitymą",
|
||||
"LabelContinueReading": "Skaityti toliau",
|
||||
"LabelContinueSeries": "Tęsti seriją",
|
||||
"LabelCover": "Viršelis",
|
||||
"LabelCoverImageURL": "Viršelio paveikslėlio URL",
|
||||
@@ -395,7 +395,7 @@
|
||||
"LabelRead": "Skaityta",
|
||||
"LabelReadAgain": "Skaityti dar kartą",
|
||||
"LabelReadEbookWithoutProgress": "Skaityti e-knygą be pažangos saugojimo",
|
||||
"LabelRecentSeries": "Naujausiais įkeltos serijos",
|
||||
"LabelRecentSeries": "Naujausios serijos",
|
||||
"LabelRecentlyAdded": "Naujausiai įkelta",
|
||||
"LabelRecommended": "Rekomenduojama",
|
||||
"LabelRegion": "Regionas",
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"ButtonLatest": "Siste",
|
||||
"ButtonLibrary": "Bibliotek",
|
||||
"ButtonLogout": "Logg ut",
|
||||
"ButtonLogoutAllDevices": "Logg ut av alle enheter",
|
||||
"ButtonLookup": "Slå opp",
|
||||
"ButtonManageTracks": "Administrer spor",
|
||||
"ButtonMapChapterTitles": "Kartlegg kapittel titler",
|
||||
@@ -194,6 +195,7 @@
|
||||
"HeaderScheduleEpisodeDownloads": "Planlegg automatisk nedlasting av episoder",
|
||||
"HeaderScheduleLibraryScans": "Planlegg automatisk bibliotek skann",
|
||||
"HeaderSession": "Sesjon",
|
||||
"HeaderSessions": "Økter",
|
||||
"HeaderSetBackupSchedule": "Sett timeplan for sikkerhetskopi",
|
||||
"HeaderSettings": "Innstillinger",
|
||||
"HeaderSettingsDisplay": "Vis",
|
||||
@@ -415,6 +417,7 @@
|
||||
"LabelIntervalEveryHour": "Hver time",
|
||||
"LabelIntervalEveryMinute": "Hvert minutt",
|
||||
"LabelInvert": "Inverter",
|
||||
"LabelIpAddress": "IP Adresse",
|
||||
"LabelItem": "Enhet",
|
||||
"LabelJumpBackwardAmount": "Hopp bakover med",
|
||||
"LabelJumpForwardAmount": "Hopp forover med",
|
||||
@@ -774,6 +777,7 @@
|
||||
"MessageConfirmDeleteSession": "Er du sikker på at du vil slette denne sesjonen?",
|
||||
"MessageConfirmEmbedMetadataInAudioFiles": "Er du sikker på at du vil legge til metadata i {0} lyd-filer?",
|
||||
"MessageConfirmForceReScan": "Er du sikker på at du vil tvinge en ny skann?",
|
||||
"MessageConfirmLogoutDevice": "Er du sikker på at du vil logge ut av \"{0}\"?",
|
||||
"MessageConfirmMarkAllEpisodesFinished": "Er du sikker på at du vil markere alle episodene som fullført?",
|
||||
"MessageConfirmMarkAllEpisodesNotFinished": "Er du sikker på at du vil markere alle episodene som ikke fullført?",
|
||||
"MessageConfirmMarkItemFinished": "Er du sikker på at du vil markere {0} som ferdig?",
|
||||
|
||||
@@ -1113,7 +1113,7 @@
|
||||
"ToastProgressIsNotBeingSynced": "O progresso não está sendo sincronizado, reinicie a reprodução",
|
||||
"ToastProviderCreatedFailed": "Falha ao adicionar o provedor",
|
||||
"ToastProviderCreatedSuccess": "Novo provedor adicionado",
|
||||
"ToastProviderNameAndUrlRequired": "Nome e URL obrigatórios",
|
||||
"ToastProviderNameAndUrlRequired": "Nome e Url obrigatórios",
|
||||
"ToastProviderRemoveSuccess": "Fornecedor removido",
|
||||
"ToastRSSFeedCloseFailed": "Falha ao fechar feed RSS",
|
||||
"ToastRSSFeedCloseSuccess": "Feed RSS fechado",
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"ButtonLatest": "Senaste",
|
||||
"ButtonLibrary": "Bibliotek",
|
||||
"ButtonLogout": "Logga ut",
|
||||
"ButtonLogoutAllDevices": "Logga ut från alla enheter",
|
||||
"ButtonLookup": "Sök",
|
||||
"ButtonManageTracks": "Hantera spår",
|
||||
"ButtonMapChapterTitles": "Mappa kapitelrubriker",
|
||||
@@ -194,6 +195,7 @@
|
||||
"HeaderScheduleEpisodeDownloads": "Schemalägg automatisk hämtning av avsnitt",
|
||||
"HeaderScheduleLibraryScans": "Schemalägg automatiska biblioteksskanningar",
|
||||
"HeaderSession": "Lyssningstillfälle",
|
||||
"HeaderSessions": "Sessioner",
|
||||
"HeaderSetBackupSchedule": "Schemalägg säkerhetskopiering",
|
||||
"HeaderSettings": "Inställningar",
|
||||
"HeaderSettingsDisplay": "Visning",
|
||||
@@ -415,6 +417,7 @@
|
||||
"LabelIntervalEveryHour": "Varje timme",
|
||||
"LabelIntervalEveryMinute": "Varje minut",
|
||||
"LabelInvert": "Invertera",
|
||||
"LabelIpAddress": "IP-adress",
|
||||
"LabelItem": "Objekt",
|
||||
"LabelJumpBackwardAmount": "Inställning för \"hopp bakåt\"",
|
||||
"LabelJumpForwardAmount": "Inställning för \"hopp framåt\"",
|
||||
@@ -449,7 +452,7 @@
|
||||
"LabelLowestPriority": "Lägst prioritet",
|
||||
"LabelMatchConfidence": "Matchningsgrad",
|
||||
"LabelMatchExistingUsersBy": "Matcha befintliga användare med",
|
||||
"LabelMatchExistingUsersByDescription": "Används för att ansluta befintlig användare. När anslutningen sker kommer användaren att matchas med ett unikt ID från SSO-leverantören.",
|
||||
"LabelMatchExistingUsersByDescription": "Används för att ansluta befintliga användare. När anslutningen har gjorts matchas användarna med ett unikt ID från SSO-leverantören.",
|
||||
"LabelMaxEpisodesToDownload": "Maximalt antal avsnitt att hämta (0 = obegränsat).",
|
||||
"LabelMaxEpisodesToDownloadPerCheck": "Maximalt antal nya avsnitt att hämta per tillfälle",
|
||||
"LabelMaxEpisodesToKeep": "Maximalt antal avsnitt att behålla",
|
||||
@@ -590,7 +593,7 @@
|
||||
"LabelSettingsDateFormat": "Datumformat",
|
||||
"LabelSettingsEnableWatcher": "Bevaka biblioteken automatiskt efter ändringar",
|
||||
"LabelSettingsEnableWatcherForLibrary": "Bevaka biblioteket automatiskt efter ändringar",
|
||||
"LabelSettingsEnableWatcherHelp": "Upptäcker automatiskt när objekt<br>läggs till, uppdateras eller tas bort.<br>OBS: Servern måste startas om.",
|
||||
"LabelSettingsEnableWatcherHelp": "Objekt läggs till eller uppdateras automatiskt när filändringar upptäcks.<br>OBS: Servern måste startas om.",
|
||||
"LabelSettingsEpubsAllowScriptedContent": "Tillåt skriptat innehåll i EPUB-filer",
|
||||
"LabelSettingsEpubsAllowScriptedContentHelp": "Tillåt skript i EPUB-filer.<br>Alternativet bör vara inaktiverat<br>om du inte litar på filernas källa.",
|
||||
"LabelSettingsExperimentalFeatures": "Experimentella funktioner",
|
||||
@@ -774,6 +777,7 @@
|
||||
"MessageConfirmDeleteSession": "Är du säker på att du vill ta bort detta lyssningstillfälle?",
|
||||
"MessageConfirmEmbedMetadataInAudioFiles": "Är du säker på att du vill infoga metadata i {0} ljudfiler?",
|
||||
"MessageConfirmForceReScan": "Är du säker på att du vill starta en ny skanning?",
|
||||
"MessageConfirmLogoutDevice": "Är du säker på att du vill logga ut från ”{0}”?",
|
||||
"MessageConfirmMarkAllEpisodesFinished": "Är du säker på att du vill markera alla avsnitt som avslutade?",
|
||||
"MessageConfirmMarkAllEpisodesNotFinished": "Vill du markera alla avsnitt som inte avslutade?",
|
||||
"MessageConfirmMarkItemFinished": "Är du säker på att du vill markera \"{0}\" som avslutad?",
|
||||
@@ -936,7 +940,7 @@
|
||||
"MessageTaskTargetDirectoryNotWritable": "Det går inte att skriva till målmappen",
|
||||
"MessageThinking": "Tänker…",
|
||||
"MessageUploaderItemFailed": "Det gick inte att ladda upp",
|
||||
"MessageUploaderItemSuccess": "har laddats upp",
|
||||
"MessageUploaderItemSuccess": "Uppladdningen lyckades",
|
||||
"MessageUploading": "Laddar upp…",
|
||||
"MessageValidCronExpression": "Giltigt cron-uttryck",
|
||||
"MessageWatcherIsDisabledGlobally": "Bevakningen är inaktiverad i serverinställningarna",
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
const nodeGlobals = {
|
||||
AbortController: 'readonly',
|
||||
Buffer: 'readonly',
|
||||
TextDecoder: 'readonly',
|
||||
URL: 'readonly',
|
||||
URLSearchParams: 'readonly',
|
||||
__dirname: 'readonly',
|
||||
clearInterval: 'readonly',
|
||||
clearTimeout: 'readonly',
|
||||
console: 'readonly',
|
||||
process: 'readonly',
|
||||
setInterval: 'readonly',
|
||||
setTimeout: 'readonly',
|
||||
structuredClone: 'readonly'
|
||||
}
|
||||
|
||||
const mochaGlobals = {
|
||||
after: 'readonly',
|
||||
afterEach: 'readonly',
|
||||
before: 'readonly',
|
||||
beforeEach: 'readonly',
|
||||
describe: 'readonly',
|
||||
it: 'readonly'
|
||||
}
|
||||
|
||||
const rules = {
|
||||
'no-undef': 'error',
|
||||
'no-redeclare': 'error',
|
||||
'no-dupe-keys': 'error',
|
||||
'no-dupe-class-members': 'error',
|
||||
'no-global-assign': 'error'
|
||||
}
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ['**/node_modules/**', 'client/**', 'dist/**', 'dist-server/**', 'coverage/**', 'server/libs/**']
|
||||
},
|
||||
{
|
||||
files: ['index.js', 'prod.js', 'dev.js', 'server/**/*.js'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'commonjs',
|
||||
globals: nodeGlobals
|
||||
},
|
||||
rules
|
||||
},
|
||||
{
|
||||
files: ['test/**/*.js'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'commonjs',
|
||||
globals: {
|
||||
...nodeGlobals,
|
||||
...mochaGlobals
|
||||
}
|
||||
},
|
||||
rules
|
||||
}
|
||||
]
|
||||
@@ -16,7 +16,7 @@ const Path = require('path')
|
||||
process.env.NODE_ENV = options.dev ? 'development' : process.env.NODE_ENV || 'production'
|
||||
|
||||
const server = require('./server/Server')
|
||||
global.appRoot = __dirname
|
||||
global.appRoot = Path.basename(__dirname) === 'dist-server' ? Path.resolve(__dirname, '..') : __dirname
|
||||
|
||||
const isDev = process.env.NODE_ENV !== 'production'
|
||||
if (isDev || options['prod-with-dev-env']) {
|
||||
@@ -32,9 +32,7 @@ if (isDev || options['prod-with-dev-env']) {
|
||||
if (devEnv.BackupPath) process.env.BACKUP_PATH = devEnv.BackupPath
|
||||
if (devEnv.ReactClientPath) process.env.REACT_CLIENT_PATH = devEnv.ReactClientPath
|
||||
if (devEnv.AllowedDevOrigins) {
|
||||
process.env.ALLOWED_DEV_ORIGINS = Array.isArray(devEnv.AllowedDevOrigins)
|
||||
? devEnv.AllowedDevOrigins.join(',')
|
||||
: String(devEnv.AllowedDevOrigins)
|
||||
process.env.ALLOWED_DEV_ORIGINS = Array.isArray(devEnv.AllowedDevOrigins) ? devEnv.AllowedDevOrigins.join(',') : String(devEnv.AllowedDevOrigins)
|
||||
}
|
||||
process.env.SOURCE = 'local'
|
||||
process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath ?? '/audiobookshelf'
|
||||
|
||||
Generated
+929
-22
File diff suppressed because it is too large.
Load diff
+20
-15
@@ -1,35 +1,37 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.36.1",
|
||||
"version": "2.37.2",
|
||||
"buildNumber": 1,
|
||||
"description": "Self-hosted audiobook and podcast server",
|
||||
"main": "index.js",
|
||||
"main": "dist-server/index.js",
|
||||
"scripts": {
|
||||
"dev": "nodemon --watch server index.js -- --dev",
|
||||
"start": "node index.js",
|
||||
"start-dev": "node index.js --prod-with-dev-env",
|
||||
"build:server": "tsc -p tsconfig.server.json",
|
||||
"dev": "nodemon --watch server --watch index.js --watch test --watch dev.js --ext js,ts --exec \"npm run build:server && node dist-server/index.js\" -- --dev",
|
||||
"start": "node dist-server/index.js",
|
||||
"start-dev": "node dist-server/index.js --prod-with-dev-env",
|
||||
"client": "cd client && npm ci && npm run generate",
|
||||
"prod": "npm run client && npm ci && node index.js",
|
||||
"build-win": "npm run client && pkg -t node20-win-x64 -o ./dist/win/audiobookshelf -C GZip .",
|
||||
"build-win-no-compress": "npm run client && pkg -t node20-win-x64 -o ./dist/win/audiobookshelf .",
|
||||
"prod": "npm run client && npm ci && npm run build:server && npm run start",
|
||||
"build-win": "npm run client && npm run build:server && pkg --config package.json -t node24-win-x64 -o ./dist/win/audiobookshelf -C GZip dist-server/index.js",
|
||||
"build-win-no-compress": "npm run client && npm run build:server && pkg --config package.json -t node24-win-x64 -o ./dist/win/audiobookshelf dist-server/index.js",
|
||||
"build-linux": "build/linuxpackager",
|
||||
"docker": "docker buildx build --platform linux/amd64,linux/arm64 --push . -t advplyr/audiobookshelf",
|
||||
"docker-amd64-local": "docker buildx build --platform linux/amd64 --load . -t advplyr/audiobookshelf-amd64-local",
|
||||
"docker-arm64-local": "docker buildx build --platform linux/arm64 --load . -t advplyr/audiobookshelf-arm64-local",
|
||||
"deploy-linux": "node deploy/linux",
|
||||
"test": "mocha",
|
||||
"coverage": "nyc mocha"
|
||||
"test": "npm run build:server && mocha dist-server/test",
|
||||
"coverage": "npm run build:server && nyc mocha dist-server/test",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"bin": "index.js",
|
||||
"bin": "dist-server/index.js",
|
||||
"pkg": {
|
||||
"assets": [
|
||||
"client/dist/**/*",
|
||||
"node_modules/sqlite3/lib/binding/**/*.node",
|
||||
"server/migrations/*.js"
|
||||
"dist-server/server/migrations/*.js"
|
||||
],
|
||||
"scripts": [
|
||||
"index.js",
|
||||
"server/**/*.js"
|
||||
"dist-server/index.js",
|
||||
"dist-server/server/**/*.js"
|
||||
]
|
||||
},
|
||||
"mocha": {
|
||||
@@ -60,11 +62,14 @@
|
||||
"xml2js": "^0.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.6",
|
||||
"chai": "^4.3.10",
|
||||
"eslint": "^10.11.0",
|
||||
"mocha": "^10.2.0",
|
||||
"nodemon": "^2.0.20",
|
||||
"nyc": "^15.1.0",
|
||||
"sinon": "^17.0.1"
|
||||
"sinon": "^17.0.1",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"allowScripts": {
|
||||
"sqlite3@5.1.7": true
|
||||
|
||||
@@ -165,7 +165,7 @@ You are now ready to start development!
|
||||
|
||||
### Manual Environment Setup
|
||||
|
||||
If you don't want to use the dev container, you can still develop this project. First, you will need to install [NodeJs](https://nodejs.org/) (version 20) and [FFmpeg](https://ffmpeg.org/).
|
||||
If you don't want to use the dev container, you can still develop this project. First, you will need to install [NodeJs](https://nodejs.org/) (version 24) and [FFmpeg](https://ffmpeg.org/).
|
||||
|
||||
Next you will need to create a `dev.js` file in the project's root directory. This contains configuration information and paths unique to your development environment. You can find an example of this file in `.devcontainer/dev.js`.
|
||||
|
||||
@@ -183,7 +183,7 @@ cd ..
|
||||
|
||||
After setting up your development environment, either using the dev container or using your own custom environment, the following commands will help you run the server and client.
|
||||
|
||||
To run the server, you can use the command `npm run dev`. This will use the client that was built when you ran `npm run generate` in the client directory or when you started the dev container. If you make changes to the server, you will need to restart the server. If you make changes to the client, you will need to run the command `(cd client; npm run generate)` and then restart the server. By default the client runs at `localhost:3333`, though the port can be configured in `dev.js`.
|
||||
To run the server, you can use the command `npm run dev`. This will compile the server and use the client that was built when you ran `npm run generate` in the client directory or when you started the dev container. Server changes are compiled and restarted automatically. If you make changes to the client, you will need to run the command `(cd client; npm run generate)` and then restart the server. By default the client runs at `localhost:3333`, though the port can be configured in `dev.js`.
|
||||
|
||||
You can also build a version of the client that supports live reloading. To do this, start the server, then run the command `(cd client; npm run dev)`. This will run a separate instance of the client at `localhost:3000` that will be automatically updated as you make changes to the client.
|
||||
|
||||
|
||||
+13
-5
@@ -216,14 +216,15 @@ class Auth {
|
||||
* @returns {Object|null} - Returns error object if validation fails, null if successful
|
||||
*/
|
||||
paramsToCookies(req, res, authMethod = 'local') {
|
||||
const TWO_MINUTES = 120000 // 2 minutes in milliseconds
|
||||
// Cookie max age in milliseconds
|
||||
const TEN_MINUTES = 10 * 60 * 1000
|
||||
const callback = req.query.redirect_uri || req.query.callback
|
||||
|
||||
// Additional handling for non-API based authMethod
|
||||
if (!this.isAuthMethodAPIBased(authMethod)) {
|
||||
// Store 'auth_state' if present in the request
|
||||
if (req.query.state) {
|
||||
res.cookie('auth_state', req.query.state, { maxAge: TWO_MINUTES, httpOnly: true })
|
||||
res.cookie('auth_state', req.query.state, { maxAge: TEN_MINUTES, httpOnly: true })
|
||||
}
|
||||
|
||||
// Validate and store the callback URL
|
||||
@@ -239,7 +240,7 @@ class Auth {
|
||||
return { error: 'Invalid callback URL - must be same-origin' }
|
||||
}
|
||||
|
||||
res.cookie('auth_cb', callback, { maxAge: TWO_MINUTES, httpOnly: true })
|
||||
res.cookie('auth_cb', callback, { maxAge: TEN_MINUTES, httpOnly: true })
|
||||
}
|
||||
|
||||
// Store the authentication method for long
|
||||
@@ -273,7 +274,8 @@ class Auth {
|
||||
// TODO: Temporarily continue sending the old token as setToken
|
||||
res.redirect(302, `${req.cookies.auth_cb}?setToken=${userResponse.user.token}&accessToken=${userResponse.user.accessToken}${stateQuery}`)
|
||||
} else {
|
||||
res.status(400).send('No callback or already expired')
|
||||
Logger.error('[Auth] No callback or already expired')
|
||||
res.redirect(`/login?error=${encodeURIComponent('No callback or already expired')}&autoLaunch=0`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -385,7 +387,13 @@ class Auth {
|
||||
const sessionKey = this.oidcAuthStrategy.getStrategy()._key
|
||||
|
||||
if (!req.session[sessionKey]) {
|
||||
return res.status(400).send('No session')
|
||||
// Mobile clients send code_verifier and expect a status, not the web login page
|
||||
const isMobile = req.cookies.auth_method === 'openid-mobile' || !!req.query.code_verifier
|
||||
Logger.error('[Auth] /auth/openid/callback route: No session')
|
||||
if (isMobile) {
|
||||
return res.status(400).send('No session')
|
||||
}
|
||||
return res.redirect(`/login?error=${encodeURIComponent('No sign-in session')}&autoLaunch=0`)
|
||||
}
|
||||
|
||||
// If the client sends us a code_verifier, we will tell passport to use this to send this in the token request
|
||||
|
||||
+15
-8
@@ -102,11 +102,6 @@ class Database {
|
||||
return this.models.libraryItem
|
||||
}
|
||||
|
||||
/** @type {typeof import('./models/PodcastEpisode')} */
|
||||
get podcastEpisodeModel() {
|
||||
return this.models.podcastEpisode
|
||||
}
|
||||
|
||||
/** @type {typeof import('./models/MediaProgress')} */
|
||||
get mediaProgressModel() {
|
||||
return this.models.mediaProgress
|
||||
@@ -425,9 +420,21 @@ class Database {
|
||||
*/
|
||||
async createRootUser(username, pash, auth) {
|
||||
if (!this.sequelize) return false
|
||||
await this.userModel.createRootUser(username, pash, auth)
|
||||
this.hasRootUser = true
|
||||
return true
|
||||
const transaction = await this.sequelize.transaction()
|
||||
try {
|
||||
const rootCount = await this.userModel.count({ where: { type: 'root' }, transaction })
|
||||
if (rootCount > 0) {
|
||||
await transaction.rollback()
|
||||
return false
|
||||
}
|
||||
await this.userModel.createRootUser(username, pash, auth, transaction)
|
||||
await transaction.commit()
|
||||
this.hasRootUser = true
|
||||
return true
|
||||
} catch (error) {
|
||||
await transaction.rollback()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
updateServerSettings() {
|
||||
|
||||
+24
-13
@@ -4,7 +4,6 @@ const express = require('express')
|
||||
const http = require('http')
|
||||
const util = require('util')
|
||||
const fs = require('./libs/fsExtra')
|
||||
const fileUpload = require('./libs/expressFileupload')
|
||||
const cookieParser = require('cookie-parser')
|
||||
const axios = require('axios')
|
||||
|
||||
@@ -13,6 +12,7 @@ const { version } = require('../package.json')
|
||||
// Utils
|
||||
const is = require('./libs/requestIp/isJs')
|
||||
const fileUtils = require('./utils/fileUtils')
|
||||
const { sweepUploadTempFiles } = require('./utils/fileUploadMiddleware')
|
||||
const { toNumber } = require('./utils/index')
|
||||
const { getRequestOrigin } = require('./utils/requestUtils')
|
||||
const Logger = require('./Logger')
|
||||
@@ -46,6 +46,22 @@ const passport = require('passport')
|
||||
const expressSession = require('express-session')
|
||||
const MemoryStore = require('./libs/memorystore')
|
||||
|
||||
/**
|
||||
* Normalize to '' (root) or '/segment' with no trailing slash.
|
||||
* Matches audiobookshelf-client-react `src/lib/basePath.ts`.
|
||||
* @param {string|null|undefined} basePath
|
||||
* @returns {string}
|
||||
*/
|
||||
function normalizeBasePath(basePath) {
|
||||
if (!basePath) return ''
|
||||
|
||||
const trimmed = basePath.trim()
|
||||
if (!trimmed || trimmed === '/') return ''
|
||||
|
||||
const withLeadingSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`
|
||||
return withLeadingSlash.endsWith('/') ? withLeadingSlash.slice(0, -1) : withLeadingSlash
|
||||
}
|
||||
|
||||
/**
|
||||
* `next()` for a custom server is a NextCustomServer wrapper. After prepare(),
|
||||
* `.server` is another NextServer wrapper; the Node server with `nextConfig` is
|
||||
@@ -66,7 +82,7 @@ class Server {
|
||||
global.isWin = process.platform === 'win32'
|
||||
global.ConfigPath = fileUtils.filePathToPOSIX(Path.normalize(CONFIG_PATH))
|
||||
global.MetadataPath = fileUtils.filePathToPOSIX(Path.normalize(METADATA_PATH))
|
||||
global.RouterBasePath = ROUTER_BASE_PATH
|
||||
global.RouterBasePath = normalizeBasePath(ROUTER_BASE_PATH)
|
||||
global.XAccel = process.env.USE_X_ACCEL
|
||||
global.AllowCors = process.env.ALLOW_CORS === '1'
|
||||
|
||||
@@ -183,6 +199,7 @@ class Server {
|
||||
|
||||
await this.cleanUserData() // Remove invalid user item progress
|
||||
await CacheManager.ensureCachePaths()
|
||||
await sweepUploadTempFiles()
|
||||
|
||||
await ShareManager.init()
|
||||
await this.backupManager.init()
|
||||
@@ -320,16 +337,6 @@ class Server {
|
||||
|
||||
this.server = http.createServer(app)
|
||||
|
||||
// Skip file upload parsing for internal-api routes (Next.js proxies read multipart bodies).
|
||||
router.use(
|
||||
/^(?!\/internal-api).*/,
|
||||
fileUpload({
|
||||
defCharset: 'utf8',
|
||||
defParamCharset: 'utf8',
|
||||
useTempFiles: true,
|
||||
tempFileDir: Path.join(global.MetadataPath, 'tmp')
|
||||
})
|
||||
)
|
||||
router.use(express.urlencoded({ extended: true, limit: '5mb' }))
|
||||
|
||||
// Skip JSON parsing for internal-api routes
|
||||
@@ -472,7 +479,11 @@ class Server {
|
||||
const rootUsername = newRoot.username || 'root'
|
||||
const rootPash = newRoot.password ? await this.auth.localAuthStrategy.hashPassword(newRoot.password) : ''
|
||||
if (!rootPash) Logger.warn(`[Server] Creating root user with no password`)
|
||||
await Database.createRootUser(rootUsername, rootPash, this.auth)
|
||||
const created = await Database.createRootUser(rootUsername, rootPash, this.auth)
|
||||
if (!created) {
|
||||
Logger.error(`[Server] attempt to init server when server already has a root user`)
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
@@ -269,22 +269,58 @@ class SocketAuthority {
|
||||
async authenticateSocket(socket, token) {
|
||||
// we don't use passport to authenticate the jwt we get over the socket connection.
|
||||
// it's easier to directly verify/decode it.
|
||||
// TODO: Support API keys for web socket connections
|
||||
const token_data = TokenManager.validateAccessToken(token)
|
||||
|
||||
if (!token_data?.userId) {
|
||||
if (!token_data) {
|
||||
// Token invalid
|
||||
Logger.error('Cannot validate socket - invalid token')
|
||||
return socket.emit('auth_failed', { message: 'Invalid token' })
|
||||
}
|
||||
|
||||
// get the user via the id from the decoded jwt.
|
||||
const user = await Database.userModel.getUserByIdOrOldId(token_data.userId)
|
||||
if (!user) {
|
||||
// user not found
|
||||
Logger.error('Cannot validate socket - invalid token')
|
||||
return socket.emit('auth_failed', { message: 'Invalid token' })
|
||||
let user = null
|
||||
|
||||
if (token_data.type === 'api') {
|
||||
// Api key based authentication
|
||||
const apiKey = await Database.apiKeyModel.getById(token_data.keyId)
|
||||
|
||||
if (!apiKey?.isActive) {
|
||||
Logger.error('Cannot validate socket - API key not found or inactive')
|
||||
return socket.emit('auth_failed', { message: 'Invalid API key' })
|
||||
}
|
||||
|
||||
if (token_data.exp && token_data.exp < Date.now() / 1000) {
|
||||
apiKey.isActive = false
|
||||
await apiKey.save()
|
||||
Logger.info(`[SocketAuthority] API key ${apiKey.id} is expired - deactivated`)
|
||||
return socket.emit('auth_failed', { message: 'API key expired' })
|
||||
}
|
||||
|
||||
user = await Database.userModel.getUserById(apiKey.userId)
|
||||
if (!user) {
|
||||
Logger.error('Cannot validate socket - user not found for API key')
|
||||
return socket.emit('auth_failed', { message: 'Invalid API key' })
|
||||
}
|
||||
} else {
|
||||
// JWT based authentication
|
||||
if (!TokenManager.isBearerAccessTokenPayload(token_data)) {
|
||||
Logger.error('Cannot validate socket - invalid token')
|
||||
return socket.emit('auth_failed', { message: 'Invalid token' })
|
||||
}
|
||||
|
||||
if (token_data.exp && token_data.exp < Date.now() / 1000) {
|
||||
Logger.error('Cannot validate socket - token expired')
|
||||
return socket.emit('auth_failed', { message: 'Token expired' })
|
||||
}
|
||||
|
||||
// get the user via the id from the decoded jwt.
|
||||
user = await Database.userModel.getUserByIdOrOldId(token_data.userId)
|
||||
if (!user) {
|
||||
// user not found
|
||||
Logger.error('Cannot validate socket - invalid token')
|
||||
return socket.emit('auth_failed', { message: 'Invalid token' })
|
||||
}
|
||||
}
|
||||
|
||||
if (!user.isActive) {
|
||||
Logger.error('Cannot validate socket - user is not active')
|
||||
return socket.emit('auth_failed', { message: 'Invalid user' })
|
||||
|
||||
@@ -49,6 +49,12 @@ class LocalAuthStrategy {
|
||||
* @param {Function} done - Passport callback
|
||||
*/
|
||||
async verifyCredentials(req, username, password, done) {
|
||||
if (typeof username !== 'string' || typeof password !== 'string') {
|
||||
this.logFailedLoginAttempt(req, typeof username === 'string' ? username : '[non-string]', 'Invalid username or password')
|
||||
done(null, null)
|
||||
return
|
||||
}
|
||||
|
||||
// Load the user given it's username
|
||||
const user = await Database.userModel.getUserByUsername(username.toLowerCase())
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const { LRUCache } = require('lru-cache')
|
||||
const { Request, Response } = require('express')
|
||||
const passport = require('passport')
|
||||
const OpenIDClient = require('openid-client')
|
||||
@@ -15,7 +16,10 @@ class OidcAuthStrategy {
|
||||
this.strategy = null
|
||||
this.client = null
|
||||
// Map of openId sessions indexed by oauth2 state-variable
|
||||
this.openIdAuthSession = new Map()
|
||||
this.openIdAuthSession = new LRUCache({
|
||||
ttl: 10 * 60 * 1000,
|
||||
ttlAutopurge: true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -389,7 +393,15 @@ class OidcAuthStrategy {
|
||||
* @returns {string|null}
|
||||
*/
|
||||
getEndSessionUrl(req, idToken, authMethod) {
|
||||
const client = this.getClient()
|
||||
// Logout trusts the auth_method cookie. getClient() throws when OpenID is not
|
||||
// configured, and that throw is outside the request promise, so it exits the process.
|
||||
let client
|
||||
try {
|
||||
client = this.getClient()
|
||||
} catch (error) {
|
||||
Logger.error(`[OidcAuth] Cannot build end session URL - ${error.message}`)
|
||||
return null
|
||||
}
|
||||
|
||||
if (client.issuer.end_session_endpoint && client.issuer.end_session_endpoint.length > 0) {
|
||||
let postLogoutRedirectUri = null
|
||||
|
||||
@@ -87,16 +87,14 @@ class TokenManager {
|
||||
/**
|
||||
* Function to validate a jwt token for a given user
|
||||
* Used to authenticate socket connections
|
||||
* TODO: Support API keys for web socket connections
|
||||
*
|
||||
* @param {string} token
|
||||
* @returns {Object} tokens data
|
||||
*/
|
||||
static validateAccessToken(token) {
|
||||
try {
|
||||
const decoded = jwt.verify(token, TokenManager.TokenSecret)
|
||||
if (!TokenManager.isBearerAccessTokenPayload(decoded)) return null
|
||||
return decoded
|
||||
// Expiration is handled by the caller so expired API keys can be deactivated
|
||||
return jwt.verify(token, TokenManager.TokenSecret, { ignoreExpiration: true })
|
||||
} catch (err) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -96,12 +96,8 @@ class CollectionController {
|
||||
return res.status(500).send('Failed to create collection')
|
||||
}
|
||||
|
||||
// Load books expanded
|
||||
newCollection.books = await newCollection.getBooksExpandedWithLibraryItem()
|
||||
|
||||
// Note: The old collection model stores expanded libraryItems in the books property
|
||||
const jsonExpanded = newCollection.toOldJSONExpanded()
|
||||
SocketAuthority.emitter('collection_added', jsonExpanded)
|
||||
const jsonExpanded = await newCollection.getOldJsonExpanded(req.user)
|
||||
if (jsonExpanded) SocketAuthority.clientEmitter(req.user.id, 'collection_added', jsonExpanded)
|
||||
res.json(jsonExpanded)
|
||||
}
|
||||
|
||||
@@ -196,9 +192,9 @@ class CollectionController {
|
||||
}
|
||||
}
|
||||
|
||||
const jsonExpanded = await req.collection.getOldJsonExpanded()
|
||||
if (wasUpdated) {
|
||||
SocketAuthority.emitter('collection_updated', jsonExpanded)
|
||||
const jsonExpanded = await req.collection.getOldJsonExpanded(req.user)
|
||||
if (wasUpdated && jsonExpanded) {
|
||||
SocketAuthority.clientEmitter(req.user.id, 'collection_updated', jsonExpanded)
|
||||
}
|
||||
res.json(jsonExpanded)
|
||||
}
|
||||
@@ -212,14 +208,17 @@ class CollectionController {
|
||||
* @param {Response} res
|
||||
*/
|
||||
async delete(req, res) {
|
||||
const jsonExpanded = await req.collection.getOldJsonExpanded()
|
||||
const jsonExpanded = await req.collection.getOldJsonExpanded(req.user)
|
||||
|
||||
// Close rss feed - remove from db and emit socket event
|
||||
await RssFeedManager.closeFeedForEntityId(req.collection.id)
|
||||
|
||||
if (jsonExpanded) {
|
||||
SocketAuthority.clientEmitter(req.user.id, 'collection_removed', jsonExpanded)
|
||||
}
|
||||
|
||||
await req.collection.destroy()
|
||||
|
||||
SocketAuthority.emitter('collection_removed', jsonExpanded)
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
@@ -254,8 +253,8 @@ class CollectionController {
|
||||
bookId: libraryItem.mediaId,
|
||||
order: collectionBooks.length + 1
|
||||
})
|
||||
const jsonExpanded = await req.collection.getOldJsonExpanded()
|
||||
SocketAuthority.emitter('collection_updated', jsonExpanded)
|
||||
const jsonExpanded = await req.collection.getOldJsonExpanded(req.user)
|
||||
if (jsonExpanded) SocketAuthority.clientEmitter(req.user.id, 'collection_updated', jsonExpanded)
|
||||
res.json(jsonExpanded)
|
||||
}
|
||||
|
||||
@@ -299,10 +298,10 @@ class CollectionController {
|
||||
order++
|
||||
}
|
||||
|
||||
jsonExpanded = await req.collection.getOldJsonExpanded()
|
||||
SocketAuthority.emitter('collection_updated', jsonExpanded)
|
||||
jsonExpanded = await req.collection.getOldJsonExpanded(req.user)
|
||||
if (jsonExpanded) SocketAuthority.clientEmitter(req.user.id, 'collection_updated', jsonExpanded)
|
||||
} else {
|
||||
jsonExpanded = await req.collection.getOldJsonExpanded()
|
||||
jsonExpanded = await req.collection.getOldJsonExpanded(req.user)
|
||||
}
|
||||
res.json(jsonExpanded)
|
||||
}
|
||||
@@ -361,10 +360,10 @@ class CollectionController {
|
||||
if (hasUpdated) {
|
||||
await Database.collectionBookModel.bulkCreate(collectionBooksToAdd)
|
||||
|
||||
jsonExpanded = await req.collection.getOldJsonExpanded()
|
||||
SocketAuthority.emitter('collection_updated', jsonExpanded)
|
||||
jsonExpanded = await req.collection.getOldJsonExpanded(req.user)
|
||||
if (jsonExpanded) SocketAuthority.clientEmitter(req.user.id, 'collection_updated', jsonExpanded)
|
||||
} else {
|
||||
jsonExpanded = await req.collection.getOldJsonExpanded()
|
||||
jsonExpanded = await req.collection.getOldJsonExpanded(req.user)
|
||||
}
|
||||
res.json(jsonExpanded)
|
||||
}
|
||||
@@ -417,9 +416,9 @@ class CollectionController {
|
||||
order++
|
||||
}
|
||||
|
||||
let jsonExpanded = await req.collection.getOldJsonExpanded()
|
||||
if (hasUpdated) {
|
||||
SocketAuthority.emitter('collection_updated', jsonExpanded)
|
||||
let jsonExpanded = await req.collection.getOldJsonExpanded(req.user)
|
||||
if (hasUpdated && jsonExpanded) {
|
||||
SocketAuthority.clientEmitter(req.user.id, 'collection_updated', jsonExpanded)
|
||||
}
|
||||
res.json(jsonExpanded)
|
||||
}
|
||||
@@ -447,7 +446,7 @@ class CollectionController {
|
||||
if (req.method == 'DELETE' && !req.params.bookId && !req.user.canDelete) {
|
||||
Logger.warn(`[CollectionController] User "${req.user.username}" attempted to delete without permission`)
|
||||
return res.sendStatus(403)
|
||||
} else if ((req.method == 'PATCH' || req.method == 'POST') && !req.user.canUpdate) {
|
||||
} else if ((req.method == 'PATCH' || req.method == 'POST' || (req.method == 'DELETE' && req.params.bookId)) && !req.user.canUpdate) {
|
||||
Logger.warn(`[CollectionController] User "${req.user.username}" attempted to update without permission`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
@@ -782,7 +782,7 @@ class LibraryController {
|
||||
.filter((v) => !!v)
|
||||
|
||||
const series = await Database.seriesModel.findByPk(req.params.seriesId)
|
||||
if (!series) return res.sendStatus(404)
|
||||
if (!series || series.libraryId !== req.library.id) return res.sendStatus(404)
|
||||
|
||||
const libraryItemsInSeries = await libraryItemsBookFilters.getLibraryItemsForSeries(series, req.user)
|
||||
|
||||
|
||||
@@ -335,6 +335,9 @@ class LibraryItemController {
|
||||
/**
|
||||
* PATCH: /api/items/:id/cover
|
||||
*
|
||||
* This is used when updating the cover to a local image library file for the library item
|
||||
* TODO: In the next API this should be using an inode or similar instead of a file path
|
||||
*
|
||||
* @param {LibraryItemControllerRequest} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
@@ -343,6 +346,7 @@ class LibraryItemController {
|
||||
return res.status(400).send('Invalid request no cover path')
|
||||
}
|
||||
|
||||
// Must be a cover file path inside the libraryFiles array
|
||||
const validationResult = await CoverManager.validateCoverPath(req.body.cover, req.libraryItem)
|
||||
if (validationResult.error) {
|
||||
return res.status(500).send(validationResult.error)
|
||||
|
||||
@@ -495,7 +495,7 @@ class MeController {
|
||||
for (const mediaProgress of mediaProgressesInProgress) {
|
||||
const oldMediaProgress = mediaProgress.getOldMediaProgress()
|
||||
const libraryItem = libraryItems.find((li) => li.id === oldMediaProgress.libraryItemId)
|
||||
if (libraryItem) {
|
||||
if (libraryItem && req.user.checkCanAccessLibraryItem(libraryItem)) {
|
||||
if (oldMediaProgress.episodeId && libraryItem.isPodcast) {
|
||||
const episode = libraryItem.media.podcastEpisodes.find((ep) => ep.id === oldMediaProgress.episodeId)
|
||||
if (episode) {
|
||||
|
||||
@@ -44,7 +44,7 @@ class MiscController {
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
||||
const files = Object.values(req.files)
|
||||
const files = Object.values(req.files).flat()
|
||||
let { title, author, series, folder: folderId, library: libraryId } = req.body
|
||||
// Validate request body
|
||||
if (!libraryId || !folderId || typeof libraryId !== 'string' || typeof folderId !== 'string' || !title || typeof title !== 'string') {
|
||||
|
||||
@@ -57,7 +57,7 @@ class RSSFeedController {
|
||||
}
|
||||
|
||||
// Check item has audio tracks
|
||||
if (!itemExpanded.hasAudioTracks()) {
|
||||
if (!itemExpanded.hasAudioTracks) {
|
||||
Logger.error(`[RSSFeedController] Cannot open RSS feed for item "${itemExpanded.media.title}" because it has no audio tracks`)
|
||||
return res.status(400).send('Item has no audio tracks')
|
||||
}
|
||||
|
||||
@@ -361,6 +361,11 @@ class SessionController {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
if (playbackSession.userId !== req.user.id && !req.user.isAdminOrUp) {
|
||||
Logger.error(`[SessionController] Non-admin user "${req.user.username}" attempted to access session belonging to another user "${req.params.id}"`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
if (req.method == 'DELETE' && !req.user.canDelete) {
|
||||
Logger.warn(`[SessionController] User "${req.user.username}" attempted to delete without permission`)
|
||||
return res.sendStatus(403)
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ const { ObjectDefineProperties, ObjectDefineProperty, ObjectSetPrototypeOf } = r
|
||||
|
||||
const stream = require('../../stream')
|
||||
|
||||
const { getDefaultEncoding } = require('../crypto/util')
|
||||
const getDefaultEncoding = () => 'buffer'
|
||||
|
||||
module.exports = LazyTransform
|
||||
|
||||
|
||||
@@ -26,6 +26,18 @@ const waitFlushProperty = Symbol('wait flush property symbol');
|
||||
*/
|
||||
module.exports = (options, req, res, next) => {
|
||||
req.files = null;
|
||||
const pendingCleanups = [];
|
||||
const runPendingCleanups = () => {
|
||||
while (pendingCleanups.length) {
|
||||
const cleanupFn = pendingCleanups.pop();
|
||||
try {
|
||||
cleanupFn();
|
||||
} catch (err) {
|
||||
debugLog(options, `Temp file cleanup failed: ${err}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
res.once('close', runPendingCleanups);
|
||||
|
||||
// Build busboy options and init busboy instance.
|
||||
const busboyOptions = buildOptions(options, { headers: req.headers });
|
||||
@@ -62,6 +74,7 @@ module.exports = (options, req, res, next) => {
|
||||
} = options.useTempFiles
|
||||
? tempFileHandler(options, field, filename) // Upload into temporary file.
|
||||
: memHandler(options, field, filename); // Upload into RAM.
|
||||
pendingCleanups.push(cleanup);
|
||||
|
||||
const writePromise = options.useTempFiles
|
||||
? getWritePromise().catch(err => {
|
||||
@@ -161,6 +174,7 @@ module.exports = (options, req, res, next) => {
|
||||
|
||||
busboy.on('error', (err) => {
|
||||
debugLog(options, `Busboy error`);
|
||||
runPendingCleanups();
|
||||
next(err);
|
||||
});
|
||||
|
||||
|
||||
@@ -28,6 +28,18 @@ module.exports = (options, fieldname, filename) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
// cleanup() can run from an error path and again on response close.
|
||||
let cleanedUp = false;
|
||||
|
||||
// Windows cannot unlink while the write stream still holds the file.
|
||||
const closeWriteStream = () => new Promise((resolve) => {
|
||||
if (writeStream.destroyed || writeStream.closed) {
|
||||
return resolve();
|
||||
}
|
||||
writeStream.once('close', resolve);
|
||||
writeStream.once('error', resolve);
|
||||
writeStream.end();
|
||||
});
|
||||
|
||||
return {
|
||||
dataHandler: (data) => {
|
||||
@@ -51,13 +63,21 @@ module.exports = (options, fieldname, filename) => {
|
||||
return Buffer.concat([]);
|
||||
},
|
||||
cleanup: () => {
|
||||
if (cleanedUp) return Promise.resolve();
|
||||
cleanedUp = true;
|
||||
completed = true;
|
||||
debugLog(options, `Cleaning up temporary file ${tempFilePath}...`);
|
||||
writeStream.end();
|
||||
deleteFile(tempFilePath, err => (err
|
||||
? debugLog(options, `Cleaning up temporary file ${tempFilePath} failed: ${err}`)
|
||||
: debugLog(options, `Cleaning up temporary file ${tempFilePath} done.`)
|
||||
));
|
||||
return closeWriteStream().then(() => new Promise((resolve) => {
|
||||
// ENOENT: handler already mv()'d the file; that is success.
|
||||
deleteFile(tempFilePath, err => {
|
||||
if (err && err.code !== 'ENOENT') {
|
||||
debugLog(options, `Cleaning up temporary file ${tempFilePath} failed: ${err}`);
|
||||
} else {
|
||||
debugLog(options, `Cleaning up temporary file ${tempFilePath} done.`);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
}));
|
||||
},
|
||||
getWritePromise: () => writePromise
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
const fs = require('graceful-fs')
|
||||
const path = require('path')
|
||||
const { pipeline } = require('stream')
|
||||
const mkdirs = require('../mkdirs').mkdirs
|
||||
const pathExists = require('../path-exists').pathExists
|
||||
const utimesMillis = require('../util/utimes').utimesMillis
|
||||
@@ -98,10 +99,20 @@ function mayCopyFile (srcStat, src, dest, opts, cb) {
|
||||
}
|
||||
|
||||
function copyFile (srcStat, src, dest, opts, cb) {
|
||||
fs.copyFile(src, dest, err => {
|
||||
const finish = err => {
|
||||
if (err) return cb(err)
|
||||
if (opts.preserveTimestamps) return handleTimestampsAndMode(srcStat.mode, src, dest, cb)
|
||||
return setDestMode(dest, srcStat.mode, cb)
|
||||
}
|
||||
|
||||
fs.copyFile(src, dest, err => {
|
||||
if (err?.code !== 'EPERM' || !srcStat.isFile()) return finish(err)
|
||||
|
||||
// Stream the fallback so database copies do not require a full-file buffer.
|
||||
pipeline(fs.createReadStream(src, { highWaterMark: 1024 * 1024 }), fs.createWriteStream(dest, { highWaterMark: 1024 * 1024 }), streamError => {
|
||||
if (streamError) return fs.unlink(dest, () => cb(streamError))
|
||||
finish()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ function doRename (src, dest, overwrite, isChangingCase, cb) {
|
||||
function rename (src, dest, overwrite, cb) {
|
||||
fs.rename(src, dest, err => {
|
||||
if (!err) return cb()
|
||||
if (err.code !== 'EXDEV') return cb(err)
|
||||
if (err.code !== 'EXDEV' && err.code !== 'EPERM') return cb(err)
|
||||
return moveAcrossDevice(src, dest, overwrite, cb)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15,20 +15,23 @@ module.exports = (function () {
|
||||
proc.stdout.setEncoding('utf8')
|
||||
proc.stderr.setEncoding('utf8')
|
||||
|
||||
proc.stdout.on('data', function (data) { probeData.push(data) })
|
||||
proc.stderr.on('data', function (data) { errData.push(data) })
|
||||
proc.stdout.on('data', function (data) {
|
||||
probeData.push(data)
|
||||
})
|
||||
proc.stderr.on('data', function (data) {
|
||||
errData.push(data)
|
||||
})
|
||||
|
||||
proc.on('exit', code => { exitCode = code })
|
||||
proc.on('error', err => reject(err))
|
||||
proc.on('error', (err) => reject(err))
|
||||
proc.on('close', () => {
|
||||
try {
|
||||
resolve(JSON.parse(probeData.join('')))
|
||||
resolve(JSON.parse(probeData.join('')))
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return doProbe
|
||||
})()
|
||||
})()
|
||||
@@ -5,8 +5,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.WatcherStats = exports.Stats = void 0;
|
||||
const ripstat_1 = require("ripstat");
|
||||
const ripstat_1 = require("./ripstat");
|
||||
Object.defineProperty(exports, "Stats", { enumerable: true, get: function () { return ripstat_1.Stats; } });
|
||||
const watcher_stats_1 = __importDefault(require("./watcher_stats"));
|
||||
exports.WatcherStats = watcher_stats_1.default;
|
||||
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidHlwZXMuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi9zcmMvdHlwZXMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IjtBQUNBLFlBQVk7Ozs7OztBQUdaLHFDQUE4QjtBQWlGbUUsc0ZBakZ6RixlQUFLLE9BaUZ5RjtBQTlFdEcsb0VBQTJDO0FBOEUwSyx1QkE5RTlNLHVCQUFZLENBOEU4TSJ9
|
||||
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidHlwZXMuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi9zcmMvdHlwZXMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IjtBQUNBLFlBQVk7Ozs7OztBQUdaLHFDQUE4QjtBQWlGbUUsc0ZBakZ6RixlQUFLLE9BaUZ5RjtBQTlFdEcsb0VBQTJDO0FBOEUwSyx1QkE5RTlNLHVCQUFZLENBOEU4TSJ9
|
||||
@@ -272,7 +272,7 @@ class AudioMetadataMangaer {
|
||||
this.tasksRunning = this.tasksRunning.filter((t) => t.id !== task.id)
|
||||
|
||||
if (this.tasksRunning.length < this.MAX_CONCURRENT_TASKS && this.tasksQueued.length) {
|
||||
Logger.info(`[AudioMetadataManager] Task finished and dequeueing next task. ${this.tasksQueued} tasks queued.`)
|
||||
Logger.info(`[AudioMetadataManager] Task finished and dequeueing next task. ${this.tasksQueued.length} tasks queued.`)
|
||||
const nextTask = this.tasksQueued.shift()
|
||||
SocketAuthority.emitter('metadata_embed_queue_update', {
|
||||
libraryItemId: nextTask.data.libraryItemId,
|
||||
|
||||
@@ -112,7 +112,7 @@ class BackupManager {
|
||||
.mv(tempPath)
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
Logger.error('[BackupManager] Failed to move backup file', path, error)
|
||||
Logger.error('[BackupManager] Failed to move backup file', tempPath, error)
|
||||
return false
|
||||
})
|
||||
if (!success) {
|
||||
|
||||
@@ -369,8 +369,8 @@ class BinaryManager {
|
||||
* @param {Binary} binary
|
||||
*/
|
||||
async removeBinary(destination, binary) {
|
||||
const binaryPath = path.join(destination, binary.fileName)
|
||||
try {
|
||||
const binaryPath = path.join(destination, binary.fileName)
|
||||
if (await fs.pathExists(binaryPath)) {
|
||||
Logger.debug(`[BinaryManager] Removing binary: ${binaryPath}`)
|
||||
await fs.remove(binaryPath)
|
||||
|
||||
@@ -4,8 +4,13 @@ const stream = require('stream')
|
||||
const Logger = require('../Logger')
|
||||
const { resizeImage } = require('../utils/ffmpegHelpers')
|
||||
const { encodeUriPath } = require('../utils/fileUtils')
|
||||
const { isUUID } = require('../utils/index')
|
||||
const Database = require('../Database')
|
||||
|
||||
function isValidCacheDimension(dimension) {
|
||||
return Number.isSafeInteger(dimension) && dimension > 0
|
||||
}
|
||||
|
||||
class CacheManager {
|
||||
constructor() {
|
||||
this.CachePath = null
|
||||
@@ -40,7 +45,9 @@ class CacheManager {
|
||||
const width = options.width || 400
|
||||
const height = options.height || null
|
||||
|
||||
if (!['webp', 'jpeg', 'png'].includes(format)) return res.sendStatus(400)
|
||||
if (!['webp', 'jpeg', 'png'].includes(format) || !isUUID(libraryItemId) || !isValidCacheDimension(width) || (height !== null && !isValidCacheDimension(height))) {
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
res.type(`image/${format}`)
|
||||
|
||||
const cachePath = Path.join(this.CoverCachePath, `${libraryItemId}_${width}${height ? `x${height}` : ''}`) + '.' + format
|
||||
@@ -150,7 +157,9 @@ class CacheManager {
|
||||
const width = options.width || 400
|
||||
const height = options.height || null
|
||||
|
||||
if (!['webp', 'jpeg', 'png'].includes(format)) return res.sendStatus(400)
|
||||
if (!['webp', 'jpeg', 'png'].includes(format) || !isUUID(authorId) || !isValidCacheDimension(width) || (height !== null && !isValidCacheDimension(height))) {
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
res.type(`image/${format}`)
|
||||
|
||||
var cachePath = Path.join(this.ImageCachePath, `${authorId}_${width}${height ? `x${height}` : ''}`) + '.' + format
|
||||
|
||||
@@ -138,6 +138,16 @@ class CoverManager {
|
||||
}
|
||||
}
|
||||
coverPath = filePathToPOSIX(coverPath)
|
||||
|
||||
// Must already have been scanned in for the library item
|
||||
const isLibraryItemFile = libraryItem.libraryFiles?.some((lf) => filePathToPOSIX(lf.metadata?.path) === coverPath)
|
||||
if (!isLibraryItemFile) {
|
||||
Logger.error(`[CoverManager] validate cover path is not a library item file "${coverPath}"`)
|
||||
return {
|
||||
error: 'Invalid cover path'
|
||||
}
|
||||
}
|
||||
|
||||
// Cover path already set on media
|
||||
if (libraryItem.media.coverPath == coverPath) {
|
||||
Logger.debug(`[CoverManager] validate cover path already set "${coverPath}"`)
|
||||
|
||||
+26
-5
@@ -439,6 +439,7 @@ class Book extends Model {
|
||||
*/
|
||||
async updateAuthorsFromRequest(authors, libraryId) {
|
||||
if (!Array.isArray(authors)) return null
|
||||
authors = parseNameString.dedupeNames(authors)
|
||||
|
||||
if (!this.authors) {
|
||||
throw new Error(`[Book] Cannot update authors because authors are not loaded for book ${this.id}`)
|
||||
@@ -466,7 +467,15 @@ class Book extends Model {
|
||||
const authorsAdded = []
|
||||
for (const authorName of newAuthorNames) {
|
||||
const { author, created } = await authorModel.findOrCreateByNameAndLibrary(authorName, libraryId)
|
||||
await bookAuthorModel.create({ bookId: this.id, authorId: author.id })
|
||||
// findOrCreate matches case-insensitively, so a second spelling can resolve to an author just linked
|
||||
if (this.authors.some((au) => au.id === author.id)) continue
|
||||
try {
|
||||
await bookAuthorModel.create({ bookId: this.id, authorId: author.id })
|
||||
} catch (error) {
|
||||
if (error.name !== 'SequelizeUniqueConstraintError') throw error
|
||||
this.authors.push(author)
|
||||
continue
|
||||
}
|
||||
if (created) {
|
||||
SocketAuthority.emitter('author_added', author.toOldJSON())
|
||||
} else {
|
||||
@@ -522,11 +531,23 @@ class Book extends Model {
|
||||
}
|
||||
} else {
|
||||
const series = await seriesModel.findOrCreateByNameAndLibrary(seriesObj.name, libraryId)
|
||||
series.bookSeries = await bookSeriesModel.create({ bookId: this.id, seriesId: series.id, sequence: seriesObjSequence })
|
||||
try {
|
||||
series.bookSeries = await bookSeriesModel.create({ bookId: this.id, seriesId: series.id, sequence: seriesObjSequence })
|
||||
seriesAdded.push(series)
|
||||
hasUpdates = true
|
||||
Logger.debug(`[Book] "${this.title}" Added series "${series.name}"`)
|
||||
} catch (error) {
|
||||
if (error.name !== 'SequelizeUniqueConstraintError') throw error
|
||||
series.bookSeries = await bookSeriesModel.findOne({ where: { bookId: this.id, seriesId: series.id } })
|
||||
if (!series.bookSeries) throw error
|
||||
if (series.bookSeries.sequence !== seriesObjSequence) {
|
||||
series.bookSeries.sequence = seriesObjSequence
|
||||
await series.bookSeries.save()
|
||||
hasUpdates = true
|
||||
Logger.debug(`[Book] "${this.title}" Updated series "${series.name}" sequence ${seriesObjSequence}`)
|
||||
}
|
||||
}
|
||||
this.series.push(series)
|
||||
seriesAdded.push(series)
|
||||
hasUpdates = true
|
||||
Logger.debug(`[Book] "${this.title}" Added series "${series.name}"`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+15
-3
@@ -87,6 +87,18 @@ class Feed extends Model {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} slug
|
||||
* @param {string|null|undefined} coverPath
|
||||
* @param {Date|null|undefined} entityUpdatedAt
|
||||
* @returns {string}
|
||||
*/
|
||||
static getFeedImageURL(slug, coverPath, entityUpdatedAt) {
|
||||
if (!coverPath) return '/Logo.png'
|
||||
const cacheBuster = entityUpdatedAt != null ? `?ts=${entityUpdatedAt.valueOf()}` : ''
|
||||
return `/feed/${slug}/cover${Path.extname(coverPath)}${cacheBuster}`
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} userId
|
||||
@@ -119,7 +131,7 @@ class Feed extends Model {
|
||||
entityUpdatedAt,
|
||||
serverAddress,
|
||||
feedURL: `/feed/${slug}`,
|
||||
imageURL: media.coverPath ? `/feed/${slug}/cover${Path.extname(media.coverPath)}` : `/Logo.png`,
|
||||
imageURL: Feed.getFeedImageURL(slug, media.coverPath, entityUpdatedAt),
|
||||
siteURL: `/item/${libraryItem.id}`,
|
||||
title: media.title,
|
||||
description: media.description,
|
||||
@@ -212,7 +224,7 @@ class Feed extends Model {
|
||||
entityUpdatedAt,
|
||||
serverAddress,
|
||||
feedURL: `/feed/${slug}`,
|
||||
imageURL: firstBookWithCover?.coverPath ? `/feed/${slug}/cover${Path.extname(firstBookWithCover.coverPath)}` : `/Logo.png`,
|
||||
imageURL: Feed.getFeedImageURL(slug, firstBookWithCover?.coverPath, entityUpdatedAt),
|
||||
siteURL: `/collection/${collectionExpanded.id}`,
|
||||
title: collectionExpanded.name,
|
||||
description: collectionExpanded.description || '',
|
||||
@@ -301,7 +313,7 @@ class Feed extends Model {
|
||||
entityUpdatedAt,
|
||||
serverAddress,
|
||||
feedURL: `/feed/${slug}`,
|
||||
imageURL: firstBookWithCover?.coverPath ? `/feed/${slug}/cover${Path.extname(firstBookWithCover.coverPath)}` : `/Logo.png`,
|
||||
imageURL: Feed.getFeedImageURL(slug, firstBookWithCover?.coverPath, entityUpdatedAt),
|
||||
siteURL: `/library/${booksWithTracks[0].libraryItem.libraryId}/series/${seriesExpanded.id}`,
|
||||
title: seriesExpanded.name,
|
||||
description: seriesExpanded.description || '',
|
||||
|
||||
@@ -491,11 +491,7 @@ class LibraryItem extends Model {
|
||||
}
|
||||
Logger.debug(`Loaded ${newestAuthorsPayload.authors.length} of ${newestAuthorsPayload.count} authors for "Newest Authors" in ${newestAuthorsResult.elapsedSeconds}s`)
|
||||
} else if (library.isPodcast) {
|
||||
const [newestEpisodesResult, mostRecentResult, mediaFinishedResult] = await Promise.all([
|
||||
timed(() => libraryFilters.getNewestPodcastEpisodes(library, user, limit)),
|
||||
timed(() => libraryFilters.getLibraryItemsMostRecentlyAdded(library, user, include, limit)),
|
||||
timed(() => libraryFilters.getMediaFinished(library, user, include, limit))
|
||||
])
|
||||
const [newestEpisodesResult, mostRecentResult, mediaFinishedResult] = await Promise.all([timed(() => libraryFilters.getNewestPodcastEpisodes(library, user, limit)), timed(() => libraryFilters.getLibraryItemsMostRecentlyAdded(library, user, include, limit)), timed(() => libraryFilters.getMediaFinished(library, user, include, limit))])
|
||||
|
||||
const newestEpisodesPayload = newestEpisodesResult.payload
|
||||
// "Newest Episodes" shelf
|
||||
@@ -852,8 +848,18 @@ class LibraryItem extends Model {
|
||||
get isPodcast() {
|
||||
return this.mediaType === 'podcast'
|
||||
}
|
||||
/**
|
||||
* Check if book or podcast library item has audio tracks.
|
||||
* Requires expanded library item (media loaded).
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get hasAudioTracks() {
|
||||
return this.media.hasAudioTracks()
|
||||
if (!this.media) {
|
||||
Logger.error(`[LibraryItem] hasAudioTracks: Library item "${this.id}" does not have media`)
|
||||
return false
|
||||
}
|
||||
return this.media.hasAudioTracks
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -904,24 +910,6 @@ class LibraryItem extends Model {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if book or podcast library item has audio tracks
|
||||
* Requires expanded library item
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasAudioTracks() {
|
||||
if (!this.media) {
|
||||
Logger.error(`[LibraryItem] hasAudioTracks: Library item "${this.id}" does not have media`)
|
||||
return false
|
||||
}
|
||||
if (this.isBook) {
|
||||
return this.media.audioFiles?.length > 0
|
||||
} else {
|
||||
return this.media.podcastEpisodes?.length > 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} ino
|
||||
|
||||
+15
-5
@@ -75,11 +75,21 @@ class Series extends Model {
|
||||
static async findOrCreateByNameAndLibrary(seriesName, libraryId) {
|
||||
const series = await this.getByNameAndLibrary(seriesName, libraryId)
|
||||
if (series) return series
|
||||
return this.create({
|
||||
name: seriesName,
|
||||
nameIgnorePrefix: getTitleIgnorePrefix(seriesName),
|
||||
libraryId
|
||||
})
|
||||
try {
|
||||
return await this.create({
|
||||
name: seriesName,
|
||||
nameIgnorePrefix: getTitleIgnorePrefix(seriesName),
|
||||
libraryId
|
||||
})
|
||||
} catch (error) {
|
||||
if (error.name !== 'SequelizeUniqueConstraintError') throw error
|
||||
// The unique index is on the exact name. lower() can miss that row.
|
||||
const existingSeries = await this.findOne({
|
||||
where: { name: seriesName, libraryId }
|
||||
})
|
||||
if (!existingSeries) throw error
|
||||
return existingSeries
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+35
-6
@@ -189,7 +189,7 @@ class User extends Model {
|
||||
* @param {import('../Auth')} auth
|
||||
* @returns {Promise<User>}
|
||||
*/
|
||||
static async createRootUser(username, pash, auth) {
|
||||
static async createRootUser(username, pash, auth, transaction = null) {
|
||||
const userId = uuidv4()
|
||||
|
||||
const token = auth.generateAccessToken({ id: userId, username })
|
||||
@@ -207,7 +207,7 @@ class User extends Model {
|
||||
seriesHideFromContinueListening: []
|
||||
}
|
||||
}
|
||||
return this.create(newUser)
|
||||
return this.create(newUser, { transaction })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -743,10 +743,10 @@ class User extends Model {
|
||||
},
|
||||
{
|
||||
model: this.sequelize.models.podcast,
|
||||
attributes: ['id', 'title'],
|
||||
attributes: ['id', 'title', 'explicit', 'tags'],
|
||||
include: {
|
||||
model: this.sequelize.models.libraryItem,
|
||||
attributes: ['id']
|
||||
attributes: ['id', 'libraryId', 'mediaType']
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -758,15 +758,38 @@ class User extends Model {
|
||||
statusCode: 404
|
||||
}
|
||||
}
|
||||
const episodeLibraryItem = podcastEpisode.podcast?.libraryItem
|
||||
if (!episodeLibraryItem) {
|
||||
Logger.error(`[User] createUpdateMediaProgress: library item for episode ${progressPayload.episodeId} not found`)
|
||||
return {
|
||||
error: 'Library item not found',
|
||||
statusCode: 404
|
||||
}
|
||||
}
|
||||
if (progressPayload.libraryItemId && episodeLibraryItem.id !== progressPayload.libraryItemId) {
|
||||
Logger.error(`[User] createUpdateMediaProgress: episode ${progressPayload.episodeId} does not belong to library item ${progressPayload.libraryItemId}`)
|
||||
return {
|
||||
error: 'Episode does not belong to library item',
|
||||
statusCode: 400
|
||||
}
|
||||
}
|
||||
episodeLibraryItem.media = podcastEpisode.podcast
|
||||
if (!this.checkCanAccessLibraryItem(episodeLibraryItem)) {
|
||||
Logger.error(`[User] createUpdateMediaProgress: user "${this.username}" attempted to update progress for inaccessible library item "${episodeLibraryItem.id}"`)
|
||||
return {
|
||||
error: 'User does not have access to this library item',
|
||||
statusCode: 403
|
||||
}
|
||||
}
|
||||
mediaItemId = podcastEpisode.id
|
||||
mediaProgress = podcastEpisode.mediaProgresses?.[0]
|
||||
podcastId = podcastEpisode.podcastId
|
||||
} else {
|
||||
const libraryItem = await this.sequelize.models.libraryItem.findByPk(progressPayload.libraryItemId, {
|
||||
attributes: ['id', 'mediaId', 'mediaType'],
|
||||
attributes: ['id', 'libraryId', 'mediaId', 'mediaType'],
|
||||
include: {
|
||||
model: this.sequelize.models.book,
|
||||
attributes: ['id', 'title'],
|
||||
attributes: ['id', 'title', 'explicit', 'tags'],
|
||||
required: false,
|
||||
include: {
|
||||
model: this.sequelize.models.mediaProgress,
|
||||
@@ -787,6 +810,12 @@ class User extends Model {
|
||||
error: 'Library item is not a book',
|
||||
statusCode: 400
|
||||
}
|
||||
} else if (!this.checkCanAccessLibraryItem(libraryItem)) {
|
||||
Logger.error(`[User] createUpdateMediaProgress: user "${this.username}" attempted to update progress for inaccessible library item "${libraryItem.id}"`)
|
||||
return {
|
||||
error: 'User does not have access to this library item',
|
||||
statusCode: 403
|
||||
}
|
||||
}
|
||||
|
||||
mediaItemId = libraryItem.media.id
|
||||
|
||||
@@ -83,7 +83,8 @@ class Audible {
|
||||
duration: runtimeLengthMin && !isNaN(runtimeLengthMin) ? Number(runtimeLengthMin) : 0,
|
||||
region: item.region || null,
|
||||
rating: item.rating || null,
|
||||
abridged: formatType === 'abridged'
|
||||
abridged: formatType === 'abridged',
|
||||
explicit: !!item.isAdult
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,7 +160,7 @@ class Audible {
|
||||
timeout
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res?.data?.products) return null
|
||||
if (!res?.data?.products) return []
|
||||
return Promise.all(res.data.products.map((result) => this.asinSearch(result.asin, region, timeout)))
|
||||
})
|
||||
.catch((error) => {
|
||||
|
||||
@@ -27,6 +27,55 @@ const htmlSanitizer = require('../utils/htmlSanitizer')
|
||||
* @property {boolean} explicit
|
||||
*/
|
||||
|
||||
/**
|
||||
* Split a list of authors on commas. A piece with no space is a credential and is glued onto the previous name.
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function parseAuthorPieces(text) {
|
||||
const authors = []
|
||||
for (const piece of (text || '').split(',')) {
|
||||
const name = piece.trim()
|
||||
if (!name) continue
|
||||
if (!name.includes(' ') && authors.length) {
|
||||
authors[authors.length - 1] = `${authors[authors.length - 1]} ${name}`
|
||||
} else {
|
||||
authors.push(name)
|
||||
}
|
||||
}
|
||||
return authors
|
||||
}
|
||||
|
||||
function stripCommasFromName(name) {
|
||||
return (name || '').replace(/,/g, '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Apple lists authors as "Name1, Name2 & Name3", but a comma can also sit inside one name ("Rick Hanson, PhD").
|
||||
* A comma-separated piece with no space is treated as a credential and glued onto the previous name.
|
||||
* The text after the last " & " is a single author, so commas there are removed.
|
||||
* @see https://github.com/advplyr/audiobookshelf/issues/1022
|
||||
* @see https://github.com/advplyr/audiobookshelf/issues/2661
|
||||
*
|
||||
* @param {string} artistName
|
||||
* @returns {string}
|
||||
*/
|
||||
function cleanAuthor(artistName) {
|
||||
const raw = (artistName || '').trim()
|
||||
if (!raw) return ''
|
||||
|
||||
const lastAmpIndex = raw.lastIndexOf(' & ')
|
||||
if (lastAmpIndex === -1) {
|
||||
return stripCommasFromName(raw)
|
||||
}
|
||||
|
||||
const authors = parseAuthorPieces(raw.slice(0, lastAmpIndex))
|
||||
const lastAuthor = stripCommasFromName(raw.slice(lastAmpIndex + 3))
|
||||
if (lastAuthor) authors.push(lastAuthor)
|
||||
return authors.join(', ')
|
||||
}
|
||||
|
||||
class iTunes {
|
||||
#responseTimeout = 10000
|
||||
|
||||
@@ -103,15 +152,11 @@ class iTunes {
|
||||
}
|
||||
|
||||
cleanAudiobook(data) {
|
||||
// artistName can be "Name1, Name2 & Name3" so we refactor this to "Name1, Name2, Name3"
|
||||
// see: https://github.com/advplyr/audiobookshelf/issues/1022
|
||||
const author = (data.artistName || '').split(' & ').join(', ')
|
||||
|
||||
return {
|
||||
id: data.collectionId,
|
||||
artistId: data.artistId,
|
||||
title: data.collectionName,
|
||||
author,
|
||||
author: cleanAuthor(data.artistName),
|
||||
description: data.description || null,
|
||||
publishedYear: data.releaseDate ? data.releaseDate.split('-')[0] : null,
|
||||
genres: data.primaryGenreName ? [data.primaryGenreName] : null,
|
||||
|
||||
@@ -11,6 +11,7 @@ const date = require('../libs/dateAndTime')
|
||||
|
||||
const CacheManager = require('../managers/CacheManager')
|
||||
const RssFeedManager = require('../managers/RssFeedManager')
|
||||
const { fileUploadParser, requireUploadPermission } = require('../utils/fileUploadMiddleware')
|
||||
|
||||
const LibraryController = require('../controllers/LibraryController')
|
||||
const UserController = require('../controllers/UserController')
|
||||
@@ -110,7 +111,7 @@ class ApiRouter {
|
||||
this.router.get('/items/:id/download', LibraryItemController.middleware.bind(this), LibraryItemController.download.bind(this))
|
||||
this.router.patch('/items/:id/media', LibraryItemController.middleware.bind(this), LibraryItemController.updateMedia.bind(this))
|
||||
this.router.get('/items/:id/cover', LibraryItemController.getCover.bind(this))
|
||||
this.router.post('/items/:id/cover', LibraryItemController.middleware.bind(this), LibraryItemController.uploadCover.bind(this))
|
||||
this.router.post('/items/:id/cover', LibraryItemController.middleware.bind(this), requireUploadPermission, fileUploadParser, LibraryItemController.uploadCover.bind(this))
|
||||
this.router.patch('/items/:id/cover', LibraryItemController.middleware.bind(this), LibraryItemController.updateCover.bind(this))
|
||||
this.router.delete('/items/:id/cover', LibraryItemController.middleware.bind(this), LibraryItemController.removeCover.bind(this))
|
||||
this.router.post('/items/:id/match', LibraryItemController.middleware.bind(this), LibraryItemController.match.bind(this))
|
||||
@@ -202,7 +203,7 @@ class ApiRouter {
|
||||
this.router.delete('/backups/:id', BackupController.middleware.bind(this), BackupController.delete.bind(this))
|
||||
this.router.get('/backups/:id/download', BackupController.middleware.bind(this), BackupController.download.bind(this))
|
||||
this.router.get('/backups/:id/apply', BackupController.middleware.bind(this), BackupController.apply.bind(this))
|
||||
this.router.post('/backups/upload', BackupController.middleware.bind(this), BackupController.upload.bind(this))
|
||||
this.router.post('/backups/upload', BackupController.middleware.bind(this), fileUploadParser, BackupController.upload.bind(this))
|
||||
this.router.patch('/backups/path', BackupController.middleware.bind(this), BackupController.updatePath.bind(this))
|
||||
|
||||
//
|
||||
@@ -343,7 +344,7 @@ class ApiRouter {
|
||||
//
|
||||
// Misc Routes
|
||||
//
|
||||
this.router.post('/upload', MiscController.handleUpload.bind(this))
|
||||
this.router.post('/upload', requireUploadPermission, fileUploadParser, MiscController.handleUpload.bind(this))
|
||||
this.router.get('/tasks', MiscController.getTasks.bind(this))
|
||||
this.router.patch('/settings', MiscController.updateServerSettings.bind(this))
|
||||
this.router.patch('/sorting-prefixes', MiscController.updateSortingPrefixes.bind(this))
|
||||
|
||||
@@ -224,12 +224,16 @@ class BookScanner {
|
||||
if (!media.authors.some((au) => au.name === authorName)) {
|
||||
const existingAuthorId = await Database.getAuthorIdByName(libraryItemData.libraryId, authorName)
|
||||
if (existingAuthorId) {
|
||||
await Database.bookAuthorModel.create({
|
||||
bookId: media.id,
|
||||
authorId: existingAuthorId
|
||||
})
|
||||
libraryScan.authorsNumBooksChangedIds.add(existingAuthorId)
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" added author "${authorName}"`)
|
||||
try {
|
||||
await Database.bookAuthorModel.create({
|
||||
bookId: media.id,
|
||||
authorId: existingAuthorId
|
||||
})
|
||||
libraryScan.authorsNumBooksChangedIds.add(existingAuthorId)
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" added author "${authorName}"`)
|
||||
} catch (error) {
|
||||
if (error.name !== 'SequelizeUniqueConstraintError') throw error
|
||||
}
|
||||
authorsUpdated = true
|
||||
} else {
|
||||
const newAuthor = await Database.authorModel.create({
|
||||
@@ -262,22 +266,51 @@ class BookScanner {
|
||||
if (!existingBookSeries) {
|
||||
const existingSeriesId = await Database.getSeriesIdByName(libraryItemData.libraryId, seriesObj.name)
|
||||
if (existingSeriesId) {
|
||||
await Database.bookSeriesModel.create({
|
||||
bookId: media.id,
|
||||
seriesId: existingSeriesId,
|
||||
sequence: seriesObj.sequence
|
||||
})
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" added series "${seriesObj.name}"${seriesObj.sequence ? ` with sequence "${seriesObj.sequence}"` : ''}`)
|
||||
try {
|
||||
await Database.bookSeriesModel.create({
|
||||
bookId: media.id,
|
||||
seriesId: existingSeriesId,
|
||||
sequence: seriesObj.sequence
|
||||
})
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" added series "${seriesObj.name}"${seriesObj.sequence ? ` with sequence "${seriesObj.sequence}"` : ''}`)
|
||||
} catch (error) {
|
||||
if (error.name !== 'SequelizeUniqueConstraintError') throw error
|
||||
}
|
||||
seriesUpdated = true
|
||||
} else {
|
||||
const newSeries = await Database.seriesModel.create({
|
||||
name: seriesObj.name,
|
||||
nameIgnorePrefix: getTitleIgnorePrefix(seriesObj.name),
|
||||
libraryId: libraryItemData.libraryId
|
||||
})
|
||||
await media.addSeries(newSeries, { through: { sequence: seriesObj.sequence } })
|
||||
Database.addSeriesToFilterData(libraryItemData.libraryId, newSeries.name, newSeries.id)
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" added new series "${seriesObj.name}"${seriesObj.sequence ? ` with sequence "${seriesObj.sequence}"` : ''}`)
|
||||
let newSeries
|
||||
let seriesCreated = false
|
||||
try {
|
||||
newSeries = await Database.seriesModel.create({
|
||||
name: seriesObj.name,
|
||||
nameIgnorePrefix: getTitleIgnorePrefix(seriesObj.name),
|
||||
libraryId: libraryItemData.libraryId
|
||||
})
|
||||
seriesCreated = true
|
||||
} catch (error) {
|
||||
if (error.name !== 'SequelizeUniqueConstraintError') throw error
|
||||
newSeries = await Database.seriesModel.findOne({
|
||||
where: { name: seriesObj.name, libraryId: libraryItemData.libraryId }
|
||||
})
|
||||
if (!newSeries) throw error
|
||||
}
|
||||
if (seriesCreated) {
|
||||
Database.addSeriesToFilterData(libraryItemData.libraryId, newSeries.name, newSeries.id)
|
||||
}
|
||||
try {
|
||||
await Database.bookSeriesModel.create({
|
||||
bookId: media.id,
|
||||
seriesId: newSeries.id,
|
||||
sequence: seriesObj.sequence
|
||||
})
|
||||
if (seriesCreated) {
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" added new series "${seriesObj.name}"${seriesObj.sequence ? ` with sequence "${seriesObj.sequence}"` : ''}`)
|
||||
} else {
|
||||
libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" added series "${seriesObj.name}"${seriesObj.sequence ? ` with sequence "${seriesObj.sequence}"` : ''}`)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.name !== 'SequelizeUniqueConstraintError') throw error
|
||||
}
|
||||
seriesUpdated = true
|
||||
}
|
||||
} else if (seriesObj.sequence && existingBookSeries.bookSeries.sequence !== seriesObj.sequence) {
|
||||
@@ -507,24 +540,38 @@ class BookScanner {
|
||||
}
|
||||
}
|
||||
if (bookMetadata.series.length) {
|
||||
const linkedSeriesIds = new Set()
|
||||
for (const seriesObj of bookMetadata.series) {
|
||||
if (!seriesObj.name) continue
|
||||
const matchingSeriesId = await Database.getSeriesIdByName(libraryItemData.libraryId, seriesObj.name)
|
||||
if (matchingSeriesId) {
|
||||
bookObject.bookSeries.push({
|
||||
seriesId: matchingSeriesId,
|
||||
sequence: seriesObj.sequence
|
||||
})
|
||||
} else {
|
||||
bookObject.bookSeries.push({
|
||||
sequence: seriesObj.sequence,
|
||||
series: {
|
||||
let seriesId = await Database.getSeriesIdByName(libraryItemData.libraryId, seriesObj.name)
|
||||
if (!seriesId) {
|
||||
let series
|
||||
let seriesCreated = false
|
||||
try {
|
||||
series = await Database.seriesModel.create({
|
||||
name: seriesObj.name,
|
||||
nameIgnorePrefix: getTitleIgnorePrefix(seriesObj.name),
|
||||
libraryId: libraryItemData.libraryId
|
||||
}
|
||||
})
|
||||
})
|
||||
seriesCreated = true
|
||||
} catch (error) {
|
||||
if (error.name !== 'SequelizeUniqueConstraintError') throw error
|
||||
series = await Database.seriesModel.findOne({
|
||||
where: { name: seriesObj.name, libraryId: libraryItemData.libraryId }
|
||||
})
|
||||
if (!series) throw error
|
||||
}
|
||||
seriesId = series.id
|
||||
if (seriesCreated) {
|
||||
Database.addSeriesToFilterData(libraryItemData.libraryId, series.name, series.id)
|
||||
}
|
||||
}
|
||||
if (linkedSeriesIds.has(seriesId)) continue
|
||||
linkedSeriesIds.add(seriesId)
|
||||
bookObject.bookSeries.push({
|
||||
seriesId,
|
||||
sequence: seriesObj.sequence
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -703,6 +750,15 @@ class BookScanner {
|
||||
bookMetadata.description = htmlSanitizer.sanitize(bookMetadata.description)
|
||||
}
|
||||
|
||||
// One dedupe after every metadata source. A repeated name inserts the same bookAuthor or bookSeries twice and aborts the scan.
|
||||
bookMetadata.authors = parseNameString.dedupeNames(bookMetadata.authors)
|
||||
const seenSeriesNames = new Set()
|
||||
bookMetadata.series = bookMetadata.series.filter((series) => {
|
||||
if (!series?.name || seenSeriesNames.has(series.name)) return false
|
||||
seenSeriesNames.add(series.name)
|
||||
return true
|
||||
})
|
||||
|
||||
return bookMetadata
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,9 @@ class LibraryScan {
|
||||
/** @type {string[]} */
|
||||
this.seriesRemovedFromBooks = []
|
||||
|
||||
/** @type {string} item label while a library item is being scanned, so a thrown error can name it */
|
||||
this.failureItem = null
|
||||
|
||||
this.logs = []
|
||||
}
|
||||
|
||||
|
||||
@@ -116,7 +116,8 @@ class LibraryScanner {
|
||||
} catch (err) {
|
||||
libraryScan.setComplete()
|
||||
|
||||
Logger.error(`[LibraryScanner] Library scan ${libraryScan.id} failed after ${libraryScan.elapsedTimestamp} | ${libraryScan.resultStats}.`, err)
|
||||
const failedItem = libraryScan.failureItem ? ` while scanning ${libraryScan.failureItem}` : ''
|
||||
Logger.error(`[LibraryScanner] Library scan ${libraryScan.id} failed after ${libraryScan.elapsedTimestamp} | ${libraryScan.resultStats}${failedItem}.`, err)
|
||||
|
||||
task.data.scanResults = libraryScan.scanResults
|
||||
const taskFailedString = {
|
||||
@@ -169,6 +170,7 @@ class LibraryScanner {
|
||||
const libraryItemIdsMissing = []
|
||||
let libraryItemsUpdated = []
|
||||
for (const existingLibraryItem of existingLibraryItems) {
|
||||
libraryScan.failureItem = scanItemFailureLabel(existingLibraryItem.title, existingLibraryItem.path)
|
||||
// First try to find matching library item with exact file path
|
||||
let libraryItemData = libraryItemDataFound.find((lid) => lid.path === existingLibraryItem.path)
|
||||
if (!libraryItemData) {
|
||||
@@ -221,6 +223,8 @@ class LibraryScanner {
|
||||
}
|
||||
}
|
||||
|
||||
libraryScan.failureItem = null
|
||||
|
||||
// Emit item updates in chunks of 10 to client
|
||||
if (libraryItemsUpdated.length === 10) {
|
||||
SocketAuthority.libraryItemsEmitter('items_updated', libraryItemsUpdated)
|
||||
@@ -259,12 +263,14 @@ class LibraryScanner {
|
||||
if (libraryItemDataFound.length) {
|
||||
let newLibraryItems = []
|
||||
for (const libraryItemData of libraryItemDataFound) {
|
||||
libraryScan.failureItem = scanItemFailureLabel(libraryItemData.mediaMetadata?.title, libraryItemData.path)
|
||||
const newLibraryItem = await LibraryItemScanner.scanNewLibraryItem(libraryItemData, libraryScan.library.settings, libraryScan)
|
||||
if (newLibraryItem) {
|
||||
newLibraryItems.push(newLibraryItem)
|
||||
|
||||
libraryScan.resultsAdded++
|
||||
}
|
||||
libraryScan.failureItem = null
|
||||
|
||||
// Emit new items in chunks of 10 to client
|
||||
if (newLibraryItems.length === 10) {
|
||||
@@ -515,10 +521,7 @@ class LibraryScanner {
|
||||
attributes: ['id', 'path'],
|
||||
where: {
|
||||
path: {
|
||||
[sequelize.Op.not]: fullPath
|
||||
},
|
||||
path: {
|
||||
[sequelize.Op.startsWith]: fullPath
|
||||
[sequelize.Op.startsWith]: fullPath + '/'
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -531,10 +534,7 @@ class LibraryScanner {
|
||||
attributes: ['id', 'path'],
|
||||
where: {
|
||||
path: {
|
||||
[sequelize.Op.not]: altFullPath
|
||||
},
|
||||
path: {
|
||||
[sequelize.Op.startsWith]: altFullPath
|
||||
[sequelize.Op.startsWith]: altFullPath + '/'
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -603,7 +603,12 @@ class LibraryScanner {
|
||||
}
|
||||
// Scan library item for updates
|
||||
Logger.debug(`[LibraryScanner] Folder update for relative path "${itemDir}" is in library item "${existingLibraryItem.media.title}" with id "${existingLibraryItem.id}" - scan for updates`)
|
||||
itemGroupingResults[itemDir] = await LibraryItemScanner.scanLibraryItem(existingLibraryItem.id, updatedLibraryItemDetails)
|
||||
try {
|
||||
itemGroupingResults[itemDir] = await LibraryItemScanner.scanLibraryItem(existingLibraryItem.id, updatedLibraryItemDetails)
|
||||
} catch (err) {
|
||||
Logger.error(`[LibraryScanner] Failed while scanning ${scanItemFailureLabel(existingLibraryItem.media?.title, existingLibraryItem.path)}`)
|
||||
throw err
|
||||
}
|
||||
continue
|
||||
} else if (library.settings.audiobooksOnly && !hasAudioFiles(fileUpdateGroup, itemDir)) {
|
||||
Logger.debug(`[LibraryScanner] Folder update for relative path "${itemDir}" has no audio files`)
|
||||
@@ -632,7 +637,13 @@ class LibraryScanner {
|
||||
|
||||
Logger.debug(`[LibraryScanner] Folder update group must be a new item "${itemDir}" in library "${library.name}"`)
|
||||
const isSingleMediaItem = isSingleMediaFile(fileUpdateGroup, itemDir)
|
||||
const newLibraryItem = await LibraryItemScanner.scanPotentialNewLibraryItem(fullPath, library, folder, isSingleMediaItem)
|
||||
let newLibraryItem
|
||||
try {
|
||||
newLibraryItem = await LibraryItemScanner.scanPotentialNewLibraryItem(fullPath, library, folder, isSingleMediaItem)
|
||||
} catch (err) {
|
||||
Logger.error(`[LibraryScanner] Failed while scanning ${scanItemFailureLabel(null, fullPath)}`)
|
||||
throw err
|
||||
}
|
||||
if (newLibraryItem) {
|
||||
SocketAuthority.libraryItemEmitter('item_added', newLibraryItem)
|
||||
}
|
||||
@@ -644,6 +655,16 @@ class LibraryScanner {
|
||||
}
|
||||
module.exports = new LibraryScanner()
|
||||
|
||||
/**
|
||||
* @param {string} title
|
||||
* @param {string} path
|
||||
* @returns {string}
|
||||
*/
|
||||
function scanItemFailureLabel(title, path) {
|
||||
if (title && path) return `"${title}" at "${path}"`
|
||||
return `"${path || title || 'unknown item'}"`
|
||||
}
|
||||
|
||||
function ItemToFileInoMatch(libraryItem1, libraryItem2) {
|
||||
return libraryItem1.isFile && libraryItem2.libraryFiles.some((lf) => lf.ino === libraryItem1.ino)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
const Path = require('path')
|
||||
const fs = require('../libs/fsExtra')
|
||||
const fileUpload = require('../libs/expressFileupload')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
/**
|
||||
* Multipart parser for the three routes that read req.files.
|
||||
* Reads MetadataPath per request so route registration does not depend on it.
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
* @param {import('express').NextFunction} next
|
||||
*/
|
||||
function fileUploadParser(req, res, next) {
|
||||
fileUpload({
|
||||
defCharset: 'utf8',
|
||||
defParamCharset: 'utf8',
|
||||
useTempFiles: true,
|
||||
tempFileDir: Path.join(global.MetadataPath, 'tmp')
|
||||
})(req, res, next)
|
||||
}
|
||||
module.exports.fileUploadParser = fileUploadParser
|
||||
|
||||
/**
|
||||
* Reject users without upload permission before the multipart parser writes temp files.
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
* @param {import('express').NextFunction} next
|
||||
*/
|
||||
function requireUploadPermission(req, res, next) {
|
||||
if (!req.user?.canUpload) {
|
||||
Logger.warn(`User "${req.user?.username || 'unknown'}" attempted to upload without permission`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
next()
|
||||
}
|
||||
module.exports.requireUploadPermission = requireUploadPermission
|
||||
|
||||
/**
|
||||
* Remove leftover upload temp files (tmp-*) from metadata/tmp.
|
||||
* Comic extraction uses the same directory with other names; those stay.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function sweepUploadTempFiles() {
|
||||
const tmpDir = Path.join(global.MetadataPath, 'tmp')
|
||||
let names
|
||||
try {
|
||||
names = await fs.readdir(tmpDir)
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') return
|
||||
Logger.warn(`[FileUpload] Failed to read upload temp directory "${tmpDir}": ${error.message}`)
|
||||
return
|
||||
}
|
||||
|
||||
let removed = 0
|
||||
for (const name of names) {
|
||||
if (!name.startsWith('tmp-')) continue
|
||||
const fullPath = Path.join(tmpDir, name)
|
||||
try {
|
||||
const stat = await fs.lstat(fullPath)
|
||||
if (!stat.isFile()) continue
|
||||
await fs.unlink(fullPath)
|
||||
removed++
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') continue
|
||||
Logger.warn(`[FileUpload] Failed to remove leftover upload temp file "${fullPath}": ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (removed) {
|
||||
Logger.info(`[FileUpload] Removed ${removed} leftover upload temp file(s) from "${tmpDir}"`)
|
||||
}
|
||||
}
|
||||
module.exports.sweepUploadTempFiles = sweepUploadTempFiles
|
||||
@@ -1,5 +1,6 @@
|
||||
const Logger = require('../../Logger')
|
||||
const parseSeriesString = require('../parsers/parseSeriesString')
|
||||
const { dedupeNames } = require('../parsers/parseNameString')
|
||||
|
||||
const mediaTypeKeys = {
|
||||
book: {
|
||||
@@ -140,8 +141,7 @@ function validateMetadataValue(key, value, expectedType) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const cleanedArray = value.filter((t) => typeof t === 'string')
|
||||
return [...new Set(cleanedArray.map((t) => t.trim()).filter((t) => t))]
|
||||
return dedupeNames(value)
|
||||
}
|
||||
|
||||
Logger.warn(`[abmetadataGenerator] Unknown expected type "${expectedType}" for key "${key}"`)
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
const { createNewSortInstance } = require('../libs/fastSort')
|
||||
const Database = require('../Database')
|
||||
const Logger = require('../Logger')
|
||||
const { getTitlePrefixAtEnd, isNullOrNaN, getTitleIgnorePrefix } = require('../utils/index')
|
||||
const naturalSort = createNewSortInstance({
|
||||
comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
|
||||
})
|
||||
|
||||
const VALID_COLLAPSE_SUBSERIES_SORTS = new Set(['addedAt', 'size', 'birthtimeMs', 'mtimeMs', 'media.duration', 'media.metadata.publishedYear', 'media.metadata.authorName', 'media.metadata.authorNameLF', 'media.metadata.title', 'sequence', 'progress', 'progress.createdAt', 'progress.finishedAt', 'random'])
|
||||
|
||||
module.exports = {
|
||||
/**
|
||||
*
|
||||
@@ -100,6 +103,11 @@ module.exports = {
|
||||
* @returns {Object[]}
|
||||
*/
|
||||
async handleCollapseSubseries(payload, seriesId, user, library) {
|
||||
if (payload.sortBy && !VALID_COLLAPSE_SUBSERIES_SORTS.has(payload.sortBy)) {
|
||||
Logger.warn(`[libraryHelpers] Invalid "sort" query string "${payload.sortBy}"`)
|
||||
payload.sortBy = undefined
|
||||
}
|
||||
|
||||
const seriesWithBooks = await Database.seriesModel.findByPk(seriesId, {
|
||||
include: {
|
||||
model: Database.bookModel,
|
||||
@@ -196,7 +204,9 @@ module.exports = {
|
||||
return li.collapsedSeries?.name || li.media.title
|
||||
}
|
||||
} else {
|
||||
return payload.sortBy.split('.').reduce((a, b) => a[b], li)
|
||||
if (payload.sortBy === 'media.metadata.authorName') return li.authorNamesFirstLast ?? ''
|
||||
if (payload.sortBy === 'media.metadata.authorNameLF') return li.authorNamesLastFirst ?? ''
|
||||
return payload.sortBy.split('.').reduce((a, b) => a?.[b], li) ?? ''
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -35,6 +35,28 @@ module.exports.nameToLastFirst = (firstLast) => {
|
||||
return `${nameObj.last_name}, ${nameObj.first_name}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim names, drop blanks, and drop exact repeats. The first spelling is kept.
|
||||
* Callers that build bookAuthors rows should use this instead of deduping per metadata source.
|
||||
*
|
||||
* @param {string[]} names
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function dedupeNames(names) {
|
||||
if (!Array.isArray(names)) return []
|
||||
const seen = new Set()
|
||||
const deduped = []
|
||||
for (const name of names) {
|
||||
if (typeof name !== 'string') continue
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed || seen.has(trimmed)) continue
|
||||
seen.add(trimmed)
|
||||
deduped.push(trimmed)
|
||||
}
|
||||
return deduped
|
||||
}
|
||||
module.exports.dedupeNames = dedupeNames
|
||||
|
||||
/**
|
||||
* Parses a name string into an array of names
|
||||
*
|
||||
@@ -62,7 +84,7 @@ module.exports.parse = (nameString) => {
|
||||
// If names are in Chinese,Japanese and Korean languages, return as is.
|
||||
if (/[\u4e00-\u9fff\u3040-\u30ff\u31f0-\u31ff]/.test(splitNames[0])) {
|
||||
return {
|
||||
names: splitNames
|
||||
names: dedupeNames(splitNames)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +124,7 @@ module.exports.parse = (nameString) => {
|
||||
names = names.filter((n) => n.first_name || n.last_name)
|
||||
|
||||
// Set name strings and remove duplicates
|
||||
const namesArray = [...new Set(names.map((a) => (a.first_name ? `${a.first_name} ${a.last_name}` : a.last_name)))]
|
||||
const namesArray = dedupeNames(names.map((a) => (a.first_name ? `${a.first_name} ${a.last_name}` : a.last_name)))
|
||||
|
||||
return {
|
||||
names: namesArray // Array of first last
|
||||
|
||||
@@ -69,7 +69,7 @@ function extractStringOrStringify(json) {
|
||||
return json[Object.keys(json)[0]][0]
|
||||
}
|
||||
// Handles case where html was included without being wrapped in CDATA
|
||||
return JSON.stringify(value)
|
||||
return JSON.stringify(json)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
|
||||
+14
-14
@@ -12,22 +12,22 @@ function tryGrabBitRate(stream, all_streams, total_bit_rate) {
|
||||
}
|
||||
|
||||
// Attempt to get bitrate from bps tags
|
||||
var bps = stream.tags.BPS || stream.tags['BPS-eng'] || stream.tags['BPS_eng']
|
||||
const bps = stream.tags.BPS || stream.tags['BPS-eng'] || stream.tags['BPS_eng']
|
||||
if (bps && !isNaN(bps)) {
|
||||
return Number(bps)
|
||||
}
|
||||
|
||||
var tagDuration = stream.tags.DURATION || stream.tags['DURATION-eng'] || stream.tags['DURATION_eng']
|
||||
var tagBytes = stream.tags.NUMBER_OF_BYTES || stream.tags['NUMBER_OF_BYTES-eng'] || stream.tags['NUMBER_OF_BYTES_eng']
|
||||
const tagDuration = stream.tags.DURATION || stream.tags['DURATION-eng'] || stream.tags['DURATION_eng']
|
||||
const tagBytes = stream.tags.NUMBER_OF_BYTES || stream.tags['NUMBER_OF_BYTES-eng'] || stream.tags['NUMBER_OF_BYTES_eng']
|
||||
if (tagDuration && tagBytes && !isNaN(tagDuration) && !isNaN(tagBytes)) {
|
||||
var bps = Math.floor((Number(tagBytes) * 8) / Number(tagDuration))
|
||||
if (bps && !isNaN(bps)) {
|
||||
return bps
|
||||
const calculatedBps = Math.floor((Number(tagBytes) * 8) / Number(tagDuration))
|
||||
if (calculatedBps && !isNaN(calculatedBps)) {
|
||||
return calculatedBps
|
||||
}
|
||||
}
|
||||
|
||||
if (total_bit_rate && stream.codec_type === 'video') {
|
||||
var estimated_bit_rate = total_bit_rate
|
||||
let estimated_bit_rate = total_bit_rate
|
||||
all_streams.forEach((stream) => {
|
||||
if (stream.bit_rate && !isNaN(stream.bit_rate)) {
|
||||
estimated_bit_rate -= Number(stream.bit_rate)
|
||||
@@ -46,9 +46,9 @@ function tryGrabBitRate(stream, all_streams, total_bit_rate) {
|
||||
}
|
||||
|
||||
function tryGrabFrameRate(stream) {
|
||||
var avgFrameRate = stream.avg_frame_rate || stream.r_frame_rate
|
||||
let avgFrameRate = stream.avg_frame_rate || stream.r_frame_rate
|
||||
if (!avgFrameRate) return null
|
||||
var parts = avgFrameRate.split('/')
|
||||
const parts = avgFrameRate.split('/')
|
||||
if (parts.length === 2) {
|
||||
avgFrameRate = Number(parts[0]) / Number(parts[1])
|
||||
} else {
|
||||
@@ -59,13 +59,13 @@ function tryGrabFrameRate(stream) {
|
||||
}
|
||||
|
||||
function tryGrabSampleRate(stream) {
|
||||
var sample_rate = stream.sample_rate
|
||||
const sample_rate = stream.sample_rate
|
||||
if (!isNaN(sample_rate)) return Number(sample_rate)
|
||||
return null
|
||||
}
|
||||
|
||||
function tryGrabChannelLayout(stream) {
|
||||
var layout = stream.channel_layout
|
||||
const layout = stream.channel_layout
|
||||
if (!layout) return null
|
||||
return String(layout).split('(').shift()
|
||||
}
|
||||
@@ -81,7 +81,7 @@ function tryGrabTags(stream, ...tags) {
|
||||
}
|
||||
|
||||
function parseMediaStreamInfo(stream, all_streams, total_bit_rate) {
|
||||
var info = {
|
||||
const info = {
|
||||
index: stream.index,
|
||||
type: stream.codec_type,
|
||||
codec: stream.codec_name || null,
|
||||
@@ -95,7 +95,7 @@ function parseMediaStreamInfo(stream, all_streams, total_bit_rate) {
|
||||
if (stream.tags) info.tags = stream.tags
|
||||
|
||||
if (info.type === 'audio' || info.type === 'subtitle') {
|
||||
var disposition = stream.disposition || {}
|
||||
const disposition = stream.disposition || {}
|
||||
info.is_default = disposition.default === 1 || disposition.default === '1'
|
||||
}
|
||||
|
||||
@@ -230,7 +230,7 @@ function parseTags(format, verbose) {
|
||||
function getDefaultAudioStream(audioStreams) {
|
||||
if (!audioStreams || !audioStreams.length) return null
|
||||
if (audioStreams.length === 1) return audioStreams[0]
|
||||
var defaultStream = audioStreams.find((a) => a.is_default)
|
||||
const defaultStream = audioStreams.find((a) => a.is_default)
|
||||
if (!defaultStream) return audioStreams[0]
|
||||
return defaultStream
|
||||
}
|
||||
|
||||
@@ -11,7 +11,12 @@ const naturalSort = createNewSortInstance({
|
||||
|
||||
module.exports = {
|
||||
decode(text) {
|
||||
return Buffer.from(decodeURIComponent(text), 'base64').toString()
|
||||
try {
|
||||
return Buffer.from(decodeURIComponent(text), 'base64').toString()
|
||||
} catch (error) {
|
||||
Logger.warn(`[libraryFilters] Failed to decode filter value "${text}": ${error.message}`)
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,7 +5,12 @@ const libraryItemsBookFilters = require('./libraryItemsBookFilters')
|
||||
|
||||
module.exports = {
|
||||
decode(text) {
|
||||
return Buffer.from(decodeURIComponent(text), 'base64').toString()
|
||||
try {
|
||||
return Buffer.from(decodeURIComponent(text), 'base64').toString()
|
||||
} catch (error) {
|
||||
Logger.warn(`[seriesFilters] Failed to decode filter value "${text}": ${error.message}`)
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
+19
-19
@@ -149,16 +149,16 @@ module.exports.buildLibraryFile = buildLibraryFile
|
||||
function getBookDataFromDir(relPath, parseSubtitle = false) {
|
||||
const splitDir = relPath.split('/')
|
||||
|
||||
var folder = splitDir.pop() // Audio files will always be in the directory named for the title
|
||||
series = splitDir.length > 1 ? splitDir.pop() : null // If there are at least 2 more directories, next furthest will be the series
|
||||
author = splitDir.length > 0 ? splitDir.pop() : null // There could be many more directories, but only the top 3 are used for naming /author/series/title/
|
||||
const titleFolder = splitDir.pop() // Audio files will always be in the directory named for the title
|
||||
const series = splitDir.length > 1 ? splitDir.pop() : null // If there are at least 2 more directories, next furthest will be the series
|
||||
const author = splitDir.length > 0 ? splitDir.pop() : null // There could be many more directories, but only the top 3 are used for naming /author/series/title/
|
||||
|
||||
// The may contain various other pieces of metadata, these functions extract it.
|
||||
var [folder, asin] = getASIN(folder)
|
||||
var [folder, narrators] = getNarrator(folder)
|
||||
var [folder, sequence] = series ? getSequence(folder) : [folder, null]
|
||||
var [folder, publishedYear] = getPublishedYear(folder)
|
||||
var [title, subtitle] = parseSubtitle ? getSubtitle(folder) : [folder, null]
|
||||
// Each extractor strips one piece of metadata and returns the remaining folder name.
|
||||
const [afterAsin, asin] = getASIN(titleFolder)
|
||||
const [afterNarrators, narrators] = getNarrator(afterAsin)
|
||||
const [afterSequence, sequence] = series ? getSequence(afterNarrators) : [afterNarrators, null]
|
||||
const [afterYear, publishedYear] = getPublishedYear(afterSequence)
|
||||
const [title, subtitle] = parseSubtitle ? getSubtitle(afterYear) : [afterYear, null]
|
||||
|
||||
return {
|
||||
title,
|
||||
@@ -180,8 +180,8 @@ module.exports.getBookDataFromDir = getBookDataFromDir
|
||||
* @returns {[string, string]} [folder, narrator]
|
||||
*/
|
||||
function getNarrator(folder) {
|
||||
let pattern = /^(?<title>.*) \{(?<narrators>.*)\}$/
|
||||
let match = folder.match(pattern)
|
||||
const pattern = /^(?<title>.*) \{(?<narrators>.*)\}$/
|
||||
const match = folder.match(pattern)
|
||||
return match ? [match.groups.title, match.groups.narrators] : [folder, null]
|
||||
}
|
||||
|
||||
@@ -204,12 +204,12 @@ function getNarrator(folder) {
|
||||
*/
|
||||
function getSequence(folder) {
|
||||
// Matches a valid volume string. Also matches a book whose title starts with a 1 to 3 digit number. Will handle that later.
|
||||
let pattern = /^(?<volumeLabel>vol\.? |volume |book )?(?<sequence>\d{0,3}(?:\.\d{1,2})?)(?<trailingDot>\.?)(?: (?<suffix>.*))?$/i
|
||||
const pattern = /^(?<volumeLabel>vol\.? |volume |book )?(?<sequence>\d{0,3}(?:\.\d{1,2})?)(?<trailingDot>\.?)(?: (?<suffix>.*))?$/i
|
||||
|
||||
let volumeNumber = null
|
||||
let parts = folder.split(' - ')
|
||||
const parts = folder.split(' - ')
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
let match = parts[i].match(pattern)
|
||||
const match = parts[i].match(pattern)
|
||||
// This excludes '101 Dalmations' but includes '101. Dalmations'
|
||||
if (match && !(match.groups.suffix && !(match.groups.volumeLabel || match.groups.trailingDot))) {
|
||||
volumeNumber = isNaN(match.groups.sequence) ? match.groups.sequence : Number(match.groups.sequence).toString()
|
||||
@@ -232,10 +232,10 @@ function getSequence(folder) {
|
||||
* @returns {[string, string]} [folder, publishedYear]
|
||||
*/
|
||||
function getPublishedYear(folder) {
|
||||
var publishedYear = null
|
||||
let publishedYear = null
|
||||
|
||||
pattern = /^ *\(?([0-9]{4})\)? * - *(.+)/ //Matches #### - title or (####) - title
|
||||
var match = folder.match(pattern)
|
||||
const pattern = /^ *\(?([0-9]{4})\)? * - *(.+)/ //Matches #### - title or (####) - title
|
||||
const match = folder.match(pattern)
|
||||
if (match) {
|
||||
publishedYear = match[1]
|
||||
folder = match[2]
|
||||
@@ -252,7 +252,7 @@ function getPublishedYear(folder) {
|
||||
*/
|
||||
function getSubtitle(folder) {
|
||||
// Subtitle is everything after " - "
|
||||
var splitTitle = folder.split(' - ')
|
||||
const splitTitle = folder.split(' - ')
|
||||
return [splitTitle.shift(), splitTitle.join(' - ')]
|
||||
}
|
||||
|
||||
@@ -265,7 +265,7 @@ function getSubtitle(folder) {
|
||||
function getASIN(folder) {
|
||||
let asin = null
|
||||
|
||||
let pattern = /(?: |^)\[([A-Z0-9]{10})](?= |$)/ // Matches "[B0015T963C]"
|
||||
const pattern = /(?: |^)\[([A-Z0-9]{10})](?= |$)/ // Matches "[B0015T963C]"
|
||||
const match = folder.match(pattern)
|
||||
if (match) {
|
||||
asin = match[1]
|
||||
|
||||
@@ -75,3 +75,26 @@ describe('OidcAuthStrategy - isValidWebCallbackUrl', () => {
|
||||
expect(strategy.isValidWebCallbackUrl('http://books.example.com/login', req)).to.equal(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('OidcAuthStrategy - openIdAuthSession', () => {
|
||||
/** @type {OidcAuthStrategy} */
|
||||
let strategy
|
||||
|
||||
beforeEach(() => {
|
||||
global.RouterBasePath = ''
|
||||
strategy = new OidcAuthStrategy()
|
||||
})
|
||||
|
||||
it('configures a 10 minute TTL with autopurge so abandoned mobile logins are cleaned up', () => {
|
||||
expect(strategy.openIdAuthSession.ttl).to.equal(10 * 60 * 1000)
|
||||
expect(strategy.openIdAuthSession.ttlAutopurge).to.equal(true)
|
||||
})
|
||||
|
||||
it('still supports the same set/has/get/delete usage as a plain Map', () => {
|
||||
strategy.openIdAuthSession.set('some-state', { mobile_redirect_uri: 'myapp://callback' })
|
||||
expect(strategy.openIdAuthSession.has('some-state')).to.equal(true)
|
||||
expect(strategy.openIdAuthSession.get('some-state')).to.deep.equal({ mobile_redirect_uri: 'myapp://callback' })
|
||||
strategy.openIdAuthSession.delete('some-state')
|
||||
expect(strategy.openIdAuthSession.has('some-state')).to.equal(false)
|
||||
})
|
||||
})
|
||||
@@ -28,11 +28,6 @@ describe('TokenManager', () => {
|
||||
})
|
||||
|
||||
describe('validateAccessToken', () => {
|
||||
it('rejects refresh tokens', () => {
|
||||
const refreshToken = jwt.sign({ userId, type: 'refresh' }, secret, { expiresIn: 3600 })
|
||||
expect(TokenManager.validateAccessToken(refreshToken)).to.equal(null)
|
||||
})
|
||||
|
||||
it('accepts access tokens', () => {
|
||||
const accessToken = jwt.sign({ userId, type: 'access' }, secret, { expiresIn: 3600 })
|
||||
const decoded = TokenManager.validateAccessToken(accessToken)
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
const { expect } = require('chai')
|
||||
const sinon = require('sinon')
|
||||
const fs = require('graceful-fs')
|
||||
const os = require('os')
|
||||
const path = require('path')
|
||||
const fsExtra = require('../../server/libs/fsExtra')
|
||||
|
||||
describe('fsExtra copy fallback', () => {
|
||||
let dir
|
||||
let source
|
||||
let dest
|
||||
let contents
|
||||
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'abs-copy-'))
|
||||
source = path.join(dir, 'source')
|
||||
dest = path.join(dir, 'dest')
|
||||
contents = Buffer.alloc(2 * 1024 * 1024 + 17, 0x61)
|
||||
fs.writeFileSync(source, contents)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('copies through streams when asynchronous copyFile is denied', async () => {
|
||||
sinon.stub(fs, 'copyFile').callsFake((src, target, callback) => {
|
||||
callback(Object.assign(new Error('copyfile denied'), { code: 'EPERM' }))
|
||||
})
|
||||
|
||||
await fsExtra.copy(source, dest)
|
||||
|
||||
expect(fs.readFileSync(dest).equals(contents)).to.be.true
|
||||
})
|
||||
|
||||
it('moves across devices when asynchronous copyFile is denied', async () => {
|
||||
sinon.stub(fs, 'rename').callsFake((src, target, callback) => {
|
||||
callback(Object.assign(new Error('cross-device link'), { code: 'EXDEV' }))
|
||||
})
|
||||
sinon.stub(fs, 'copyFile').callsFake((src, target, callback) => {
|
||||
callback(Object.assign(new Error('copyfile denied'), { code: 'EPERM' }))
|
||||
})
|
||||
|
||||
await fsExtra.move(source, dest)
|
||||
|
||||
expect(fs.existsSync(source)).to.be.false
|
||||
expect(fs.readFileSync(dest).equals(contents)).to.be.true
|
||||
})
|
||||
|
||||
it('moves over an existing file when asynchronous rename and copyFile are denied', async () => {
|
||||
fs.writeFileSync(dest, 'old database')
|
||||
sinon.stub(fs, 'rename').callsFake((src, target, callback) => {
|
||||
callback(Object.assign(new Error('rename denied'), { code: 'EPERM' }))
|
||||
})
|
||||
sinon.stub(fs, 'copyFile').callsFake((src, target, callback) => {
|
||||
callback(Object.assign(new Error('copyfile denied'), { code: 'EPERM' }))
|
||||
})
|
||||
|
||||
await fsExtra.move(source, dest, { overwrite: true })
|
||||
|
||||
expect(fs.existsSync(source)).to.be.false
|
||||
expect(fs.readFileSync(dest).equals(contents)).to.be.true
|
||||
})
|
||||
|
||||
it('keeps the source when a move cannot copy it', async () => {
|
||||
sinon.stub(fs, 'rename').callsFake((src, target, callback) => {
|
||||
callback(Object.assign(new Error('rename denied'), { code: 'EPERM' }))
|
||||
})
|
||||
sinon.stub(fs, 'copyFile').callsFake((src, target, callback) => {
|
||||
callback(Object.assign(new Error('read failed'), { code: 'EIO' }))
|
||||
})
|
||||
|
||||
try {
|
||||
await fsExtra.move(source, dest)
|
||||
expect.fail('Expected move to fail')
|
||||
} catch (error) {
|
||||
expect(error.code).to.equal('EIO')
|
||||
}
|
||||
expect(fs.readFileSync(source).equals(contents)).to.be.true
|
||||
})
|
||||
|
||||
it('propagates unrelated copy errors', async () => {
|
||||
sinon.stub(fs, 'copyFile').callsFake((src, target, callback) => {
|
||||
callback(Object.assign(new Error('file missing'), { code: 'ENOENT' }))
|
||||
})
|
||||
|
||||
try {
|
||||
await fsExtra.copy(source, dest)
|
||||
expect.fail('Expected copy to fail')
|
||||
} catch (error) {
|
||||
expect(error.code).to.equal('ENOENT')
|
||||
}
|
||||
expect(fs.existsSync(dest)).to.be.false
|
||||
})
|
||||
})
|
||||
@@ -17,6 +17,7 @@ describe('MigrationManager', () => {
|
||||
let fsMoveStub
|
||||
let fsRemoveStub
|
||||
let fsEnsureDirStub
|
||||
let fsPathExistsStub
|
||||
let processExitStub
|
||||
let configPath = '/path/to/config'
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
const { expect } = require('chai')
|
||||
const { Sequelize } = require('sequelize')
|
||||
const sinon = require('sinon')
|
||||
|
||||
const Database = require('../../../server/Database')
|
||||
const Logger = require('../../../server/Logger')
|
||||
|
||||
describe('Book author and series updates', () => {
|
||||
/** @type {import('../../../server/models/Library')} */
|
||||
let library
|
||||
/** @type {import('../../../server/models/Book')} */
|
||||
let book
|
||||
|
||||
beforeEach(async () => {
|
||||
global.ServerSettings = {}
|
||||
Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
|
||||
Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '')
|
||||
await Database.buildModels()
|
||||
|
||||
sinon.stub(Logger, 'info')
|
||||
sinon.stub(Logger, 'debug')
|
||||
sinon.stub(Logger, 'warn')
|
||||
sinon.stub(Logger, 'error')
|
||||
|
||||
library = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' })
|
||||
book = await Database.bookModel.create({ title: 'Test Book', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
|
||||
book.authors = []
|
||||
book.series = []
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
sinon.restore()
|
||||
await Database.sequelize.sync({ force: true })
|
||||
})
|
||||
|
||||
it('does not throw when the bookSeries link already exists', async () => {
|
||||
const series = await Database.seriesModel.create({ name: 'Crescent City', libraryId: library.id })
|
||||
await Database.bookSeriesModel.create({ bookId: book.id, seriesId: series.id, sequence: '3' })
|
||||
|
||||
const result = await book.updateSeriesFromRequest([{ name: 'Crescent City', sequence: '3' }], library.id)
|
||||
|
||||
expect(result.hasUpdates).to.be.false
|
||||
const links = await Database.bookSeriesModel.findAll({ where: { bookId: book.id, seriesId: series.id } })
|
||||
expect(links).to.have.lengthOf(1)
|
||||
})
|
||||
|
||||
it('reuses an existing series when create hits the name unique constraint', async () => {
|
||||
const series = await Database.seriesModel.create({ name: 'Crescent City', libraryId: library.id })
|
||||
sinon.stub(Database.seriesModel, 'getByNameAndLibrary').resolves(null)
|
||||
|
||||
const result = await book.updateSeriesFromRequest([{ name: 'Crescent City', sequence: '3' }], library.id)
|
||||
|
||||
expect(result.seriesAdded).to.have.lengthOf(1)
|
||||
expect(result.seriesAdded[0].id).to.equal(series.id)
|
||||
const seriesRows = await Database.seriesModel.findAll({ where: { libraryId: library.id, name: 'Crescent City' } })
|
||||
expect(seriesRows).to.have.lengthOf(1)
|
||||
const links = await Database.bookSeriesModel.findAll({ where: { bookId: book.id, seriesId: series.id } })
|
||||
expect(links).to.have.lengthOf(1)
|
||||
})
|
||||
|
||||
it('does not throw when the bookAuthor link already exists', async () => {
|
||||
const author = await Database.authorModel.create({ name: 'Sarah Maas', libraryId: library.id })
|
||||
await Database.bookAuthorModel.create({ bookId: book.id, authorId: author.id })
|
||||
|
||||
const result = await book.updateAuthorsFromRequest(['Sarah Maas'], library.id)
|
||||
|
||||
expect(result.authorsAdded).to.have.lengthOf(0)
|
||||
const links = await Database.bookAuthorModel.findAll({ where: { bookId: book.id, authorId: author.id } })
|
||||
expect(links).to.have.lengthOf(1)
|
||||
})
|
||||
})
|
||||
@@ -45,4 +45,26 @@ describe('Audible', () => {
|
||||
expect(result).to.equal('.5')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cleanResult', () => {
|
||||
const baseItem = {
|
||||
title: 'Some Book',
|
||||
formatType: 'unabridged'
|
||||
}
|
||||
|
||||
it('should set explicit to true when isAdult is true', () => {
|
||||
const result = audible.cleanResult({ ...baseItem, isAdult: true })
|
||||
expect(result.explicit).to.equal(true)
|
||||
})
|
||||
|
||||
it('should set explicit to false when isAdult is false', () => {
|
||||
const result = audible.cleanResult({ ...baseItem, isAdult: false })
|
||||
expect(result.explicit).to.equal(false)
|
||||
})
|
||||
|
||||
it('should set explicit to false when isAdult is not present', () => {
|
||||
const result = audible.cleanResult({ ...baseItem })
|
||||
expect(result.explicit).to.equal(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
const iTunes = require('../../../server/providers/iTunes')
|
||||
const { expect } = require('chai')
|
||||
|
||||
describe('iTunes', () => {
|
||||
let itunes
|
||||
|
||||
beforeEach(() => {
|
||||
itunes = new iTunes()
|
||||
})
|
||||
|
||||
describe('cleanAudiobook', () => {
|
||||
function authorFrom(artistName) {
|
||||
return itunes.cleanAudiobook({ artistName }).author
|
||||
}
|
||||
|
||||
it('should return an empty string if artistName is missing', () => {
|
||||
expect(authorFrom()).to.equal('')
|
||||
expect(authorFrom('')).to.equal('')
|
||||
})
|
||||
|
||||
it('should keep a single author without commas', () => {
|
||||
expect(authorFrom('Joy Fielding')).to.equal('Joy Fielding')
|
||||
})
|
||||
|
||||
it('should strip commas from a single author with a credential', () => {
|
||||
expect(authorFrom('Rick Hanson, PhD')).to.equal('Rick Hanson PhD')
|
||||
})
|
||||
|
||||
it('should split two authors joined by an ampersand', () => {
|
||||
expect(authorFrom('Name1 & Name2')).to.equal('Name1, Name2')
|
||||
})
|
||||
|
||||
it('should split three authors using commas and an ampersand', () => {
|
||||
expect(authorFrom('Keltie Knight, Becca Tobin & Jac Vanek')).to.equal('Keltie Knight, Becca Tobin, Jac Vanek')
|
||||
})
|
||||
|
||||
it('should glue a one-word credential onto the previous author before the ampersand', () => {
|
||||
expect(authorFrom('Rick Hanson, PhD & Jane Doe')).to.equal('Rick Hanson PhD, Jane Doe')
|
||||
})
|
||||
|
||||
it('should glue several one-word credentials in a row', () => {
|
||||
expect(authorFrom('Rick Hanson, PhD, MD & Jane Doe')).to.equal('Rick Hanson PhD MD, Jane Doe')
|
||||
})
|
||||
|
||||
it('should strip commas from the last author', () => {
|
||||
expect(authorFrom('Rick Hanson & Jane Doe, PhD')).to.equal('Rick Hanson, Jane Doe PhD')
|
||||
})
|
||||
|
||||
it('should glue credentials before the ampersand and strip commas from the last author', () => {
|
||||
expect(authorFrom('Rick Hanson, PhD & Jane Doe, PhD')).to.equal('Rick Hanson PhD, Jane Doe PhD')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
const { expect } = require('chai')
|
||||
const { Sequelize } = require('sequelize')
|
||||
const sinon = require('sinon')
|
||||
|
||||
const Database = require('../../../server/Database')
|
||||
const Logger = require('../../../server/Logger')
|
||||
const BookScanner = require('../../../server/scanner/BookScanner')
|
||||
const LibraryScan = require('../../../server/scanner/LibraryScan')
|
||||
|
||||
describe('BookScanner.rescanExistingBookLibraryItem', () => {
|
||||
/** @type {import('../../../server/models/Library')} */
|
||||
let library
|
||||
/** @type {import('../../../server/models/LibraryItem')} */
|
||||
let libraryItem
|
||||
/** @type {import('../../../server/models/Book')} */
|
||||
let book
|
||||
/** @type {LibraryScan} */
|
||||
let libraryScan
|
||||
|
||||
const librarySettings = { audiobooksOnly: true }
|
||||
|
||||
beforeEach(async () => {
|
||||
global.ServerSettings = {}
|
||||
Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
|
||||
Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '')
|
||||
await Database.buildModels()
|
||||
|
||||
sinon.stub(Logger, 'info')
|
||||
sinon.stub(Logger, 'debug')
|
||||
sinon.stub(Logger, 'warn')
|
||||
sinon.stub(Logger, 'error')
|
||||
|
||||
library = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' })
|
||||
const libraryFolder = await Database.libraryFolderModel.create({ path: '/test', libraryId: library.id })
|
||||
book = await Database.bookModel.create({ title: 'Test Book', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [], coverPath: '/cover.jpg' })
|
||||
libraryItem = await Database.libraryItemModel.create({
|
||||
libraryFiles: [],
|
||||
mediaId: book.id,
|
||||
mediaType: 'book',
|
||||
libraryId: library.id,
|
||||
libraryFolderId: libraryFolder.id
|
||||
})
|
||||
libraryItem.isMissing = true
|
||||
libraryItem.isFile = true
|
||||
// Stale associations: the rows exist in the database, but this scan's loaded book does not see them
|
||||
sinon.stub(libraryItem, 'getMedia').callsFake(async () => {
|
||||
book.authors = []
|
||||
book.series = []
|
||||
book.audioFiles = []
|
||||
book.coverPath = '/cover.jpg'
|
||||
return book
|
||||
})
|
||||
|
||||
libraryScan = new LibraryScan()
|
||||
libraryScan.library = library
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
sinon.restore()
|
||||
await Database.sequelize.sync({ force: true })
|
||||
})
|
||||
|
||||
function scanData() {
|
||||
return {
|
||||
hasAudioFileChanges: false,
|
||||
audioLibraryFiles: [],
|
||||
imageLibraryFiles: [],
|
||||
imageLibraryFilesRemoved: [],
|
||||
imageLibraryFilesModified: [],
|
||||
ebookLibraryFiles: [],
|
||||
ebookLibraryFilesModified: [],
|
||||
libraryId: library.id
|
||||
}
|
||||
}
|
||||
|
||||
it('does not throw when the bookSeries link already exists', async () => {
|
||||
const series = await Database.seriesModel.create({ name: 'Crescent City', libraryId: library.id })
|
||||
await Database.bookSeriesModel.create({ bookId: book.id, seriesId: series.id, sequence: '3' })
|
||||
sinon.stub(BookScanner, 'getBookMetadataFromScanData').resolves({
|
||||
title: 'Test Book',
|
||||
authors: [],
|
||||
series: [{ name: 'Crescent City', sequence: '3' }]
|
||||
})
|
||||
|
||||
const result = await BookScanner.rescanExistingBookLibraryItem(libraryItem, scanData(), librarySettings, libraryScan)
|
||||
|
||||
expect(result.wasUpdated).to.be.true
|
||||
const links = await Database.bookSeriesModel.findAll({ where: { bookId: book.id, seriesId: series.id } })
|
||||
expect(links).to.have.lengthOf(1)
|
||||
})
|
||||
|
||||
it('reuses an existing series when create hits the name unique constraint', async () => {
|
||||
const series = await Database.seriesModel.create({ name: 'Crescent City', libraryId: library.id })
|
||||
sinon.stub(Database, 'getSeriesIdByName').resolves(null)
|
||||
sinon.stub(BookScanner, 'getBookMetadataFromScanData').resolves({
|
||||
title: 'Test Book',
|
||||
authors: [],
|
||||
series: [{ name: 'Crescent City', sequence: '3' }]
|
||||
})
|
||||
|
||||
await BookScanner.rescanExistingBookLibraryItem(libraryItem, scanData(), librarySettings, libraryScan)
|
||||
|
||||
const seriesRows = await Database.seriesModel.findAll({ where: { libraryId: library.id, name: 'Crescent City' } })
|
||||
expect(seriesRows).to.have.lengthOf(1)
|
||||
const links = await Database.bookSeriesModel.findAll({ where: { bookId: book.id, seriesId: series.id } })
|
||||
expect(links).to.have.lengthOf(1)
|
||||
})
|
||||
|
||||
it('does not throw when the bookAuthor link already exists', async () => {
|
||||
const author = await Database.authorModel.create({ name: 'Sarah Maas', libraryId: library.id })
|
||||
await Database.bookAuthorModel.create({ bookId: book.id, authorId: author.id })
|
||||
sinon.stub(BookScanner, 'getBookMetadataFromScanData').resolves({
|
||||
title: 'Test Book',
|
||||
authors: ['Sarah Maas'],
|
||||
series: []
|
||||
})
|
||||
|
||||
await BookScanner.rescanExistingBookLibraryItem(libraryItem, scanData(), librarySettings, libraryScan)
|
||||
|
||||
const links = await Database.bookAuthorModel.findAll({ where: { bookId: book.id, authorId: author.id } })
|
||||
expect(links).to.have.lengthOf(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,221 @@
|
||||
const { expect } = require('chai')
|
||||
const express = require('express')
|
||||
const fs = require('fs')
|
||||
const http = require('http')
|
||||
const os = require('os')
|
||||
const path = require('path')
|
||||
const sinon = require('sinon')
|
||||
|
||||
const Logger = require('../../../server/Logger')
|
||||
const { fileUploadParser, requireUploadPermission, sweepUploadTempFiles } = require('../../../server/utils/fileUploadMiddleware')
|
||||
|
||||
function listTmpFiles(dir) {
|
||||
if (!fs.existsSync(dir)) return []
|
||||
return fs.readdirSync(dir).filter((name) => name.startsWith('tmp-'))
|
||||
}
|
||||
|
||||
function sendMultipart(port, urlPath, extraHeaders = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const boundary = '----AbsUploadTestBoundary'
|
||||
const payload = [
|
||||
`--${boundary}`,
|
||||
'Content-Disposition: form-data; name="f"; filename="probe.txt"',
|
||||
'Content-Type: text/plain',
|
||||
'',
|
||||
'probe',
|
||||
`--${boundary}--`,
|
||||
''
|
||||
].join('\r\n')
|
||||
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: '127.0.0.1',
|
||||
port,
|
||||
path: urlPath,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||||
'Content-Length': Buffer.byteLength(payload),
|
||||
...extraHeaders
|
||||
}
|
||||
},
|
||||
(res) => {
|
||||
res.resume()
|
||||
res.on('end', () => resolve(res.statusCode))
|
||||
}
|
||||
)
|
||||
req.on('error', reject)
|
||||
req.write(payload)
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
function listen(app) {
|
||||
const server = http.createServer(app)
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
resolve({ server, port: server.address().port })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function closeServer(server) {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.close((err) => (err ? reject(err) : resolve()))
|
||||
})
|
||||
}
|
||||
|
||||
async function waitFor(predicate, timeoutMs = 2000) {
|
||||
const started = Date.now()
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
if (predicate()) return
|
||||
await new Promise((resolve) => setTimeout(resolve, 25))
|
||||
}
|
||||
throw new Error('Timed out waiting for condition')
|
||||
}
|
||||
|
||||
describe('fileUploadMiddleware', () => {
|
||||
let metadataDir
|
||||
let tmpDir
|
||||
let previousMetadataPath
|
||||
|
||||
beforeEach(() => {
|
||||
previousMetadataPath = global.MetadataPath
|
||||
metadataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'abs-upload-meta-'))
|
||||
tmpDir = path.join(metadataDir, 'tmp')
|
||||
global.MetadataPath = metadataDir
|
||||
sinon.stub(Logger, 'info')
|
||||
sinon.stub(Logger, 'warn')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
global.MetadataPath = previousMetadataPath
|
||||
fs.rmSync(metadataDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('requireUploadPermission', () => {
|
||||
it('returns 403 when the user cannot upload', () => {
|
||||
const req = { user: { canUpload: false, username: 'guest' } }
|
||||
const res = { sendStatus: sinon.stub() }
|
||||
const next = sinon.stub()
|
||||
|
||||
requireUploadPermission(req, res, next)
|
||||
|
||||
expect(res.sendStatus.calledOnceWithExactly(403)).to.equal(true)
|
||||
expect(next.called).to.equal(false)
|
||||
})
|
||||
|
||||
it('calls next when the user can upload', () => {
|
||||
const req = { user: { canUpload: true, username: 'uploader' } }
|
||||
const res = { sendStatus: sinon.stub() }
|
||||
const next = sinon.stub()
|
||||
|
||||
requireUploadPermission(req, res, next)
|
||||
|
||||
expect(next.calledOnce).to.equal(true)
|
||||
expect(res.sendStatus.called).to.equal(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sweepUploadTempFiles', () => {
|
||||
it('removes leftover tmp-* files and leaves other names', async () => {
|
||||
fs.mkdirSync(tmpDir, { recursive: true })
|
||||
fs.writeFileSync(path.join(tmpDir, 'tmp-1-123'), 'stale')
|
||||
fs.writeFileSync(path.join(tmpDir, 'tmp-2-456'), 'stale')
|
||||
fs.writeFileSync(path.join(tmpDir, 'comic-extract-keep'), 'keep')
|
||||
fs.mkdirSync(path.join(tmpDir, 'tmp-not-a-file'))
|
||||
|
||||
await sweepUploadTempFiles()
|
||||
|
||||
expect(fs.existsSync(path.join(tmpDir, 'tmp-1-123'))).to.equal(false)
|
||||
expect(fs.existsSync(path.join(tmpDir, 'tmp-2-456'))).to.equal(false)
|
||||
expect(fs.existsSync(path.join(tmpDir, 'comic-extract-keep'))).to.equal(true)
|
||||
expect(fs.existsSync(path.join(tmpDir, 'tmp-not-a-file'))).to.equal(true)
|
||||
})
|
||||
|
||||
it('returns when the temp directory does not exist', async () => {
|
||||
await sweepUploadTempFiles()
|
||||
expect(fs.existsSync(tmpDir)).to.equal(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('upload parser mounting', () => {
|
||||
it('does not write tmp-* files for a route without the parser', async () => {
|
||||
const app = express()
|
||||
app.use((req, res) => res.sendStatus(404))
|
||||
const { server, port } = await listen(app)
|
||||
|
||||
try {
|
||||
const status = await sendMultipart(port, '/not-a-route')
|
||||
expect(status).to.equal(404)
|
||||
expect(listTmpFiles(tmpDir)).to.deep.equal([])
|
||||
} finally {
|
||||
await closeServer(server)
|
||||
}
|
||||
})
|
||||
|
||||
it('does not write tmp-* files when permission is rejected before parsing', async () => {
|
||||
const app = express()
|
||||
app.use((req, res, next) => {
|
||||
req.user = { canUpload: false, username: 'guest' }
|
||||
next()
|
||||
})
|
||||
app.post('/upload', requireUploadPermission, fileUploadParser, (req, res) => {
|
||||
res.sendStatus(200)
|
||||
})
|
||||
const { server, port } = await listen(app)
|
||||
|
||||
try {
|
||||
const status = await sendMultipart(port, '/upload')
|
||||
expect(status).to.equal(403)
|
||||
expect(listTmpFiles(tmpDir)).to.deep.equal([])
|
||||
} finally {
|
||||
await closeServer(server)
|
||||
}
|
||||
})
|
||||
|
||||
it('removes the temp file when the handler rejects without moving it', async () => {
|
||||
const app = express()
|
||||
app.use((req, res, next) => {
|
||||
req.user = { canUpload: true, username: 'uploader' }
|
||||
next()
|
||||
})
|
||||
app.post('/upload', requireUploadPermission, fileUploadParser, (req, res) => {
|
||||
res.status(400).send('Invalid request no file or url')
|
||||
})
|
||||
const { server, port } = await listen(app)
|
||||
|
||||
try {
|
||||
const status = await sendMultipart(port, '/upload')
|
||||
expect(status).to.equal(400)
|
||||
await waitFor(() => listTmpFiles(tmpDir).length === 0)
|
||||
} finally {
|
||||
await closeServer(server)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a moved file and leaves no tmp-* leftover', async () => {
|
||||
const dest = path.join(metadataDir, 'moved.txt')
|
||||
const app = express()
|
||||
app.use((req, res, next) => {
|
||||
req.user = { canUpload: true, username: 'uploader' }
|
||||
next()
|
||||
})
|
||||
app.post('/upload', requireUploadPermission, fileUploadParser, async (req, res) => {
|
||||
await req.files.f.mv(dest)
|
||||
res.sendStatus(200)
|
||||
})
|
||||
const { server, port } = await listen(app)
|
||||
|
||||
try {
|
||||
const status = await sendMultipart(port, '/upload')
|
||||
expect(status).to.equal(200)
|
||||
expect(fs.readFileSync(dest, 'utf8')).to.equal('probe')
|
||||
await waitFor(() => listTmpFiles(tmpDir).length === 0)
|
||||
} finally {
|
||||
await closeServer(server)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
const chai = require('chai')
|
||||
const expect = chai.expect
|
||||
const { parse, nameToLastFirst } = require('../../../../server/utils/parsers/parseNameString')
|
||||
const { parse, nameToLastFirst, dedupeNames } = require('../../../../server/utils/parsers/parseNameString')
|
||||
|
||||
describe('parseNameString', () => {
|
||||
describe('parse', () => {
|
||||
@@ -80,6 +80,20 @@ describe('parseNameString', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('dedupeNames', () => {
|
||||
it('trims, drops blanks, and keeps the first spelling of an exact repeat', () => {
|
||||
expect(dedupeNames([' Stephen King ', 'Stephen King', '', ' ', 'stephen king'])).to.deep.equal(['Stephen King', 'stephen king'])
|
||||
})
|
||||
|
||||
it('preserves order and ignores non-strings', () => {
|
||||
expect(dedupeNames(['Amy Yao', null, 'Amy Yao', 3, 'Raymond Chandler'])).to.deep.equal(['Amy Yao', 'Raymond Chandler'])
|
||||
})
|
||||
|
||||
it('returns an empty array for non-arrays', () => {
|
||||
expect(dedupeNames(null)).to.deep.equal([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('nameToLastFirst', () => {
|
||||
it('converts First Last to Last, First format', () => {
|
||||
const result = nameToLastFirst('John Smith')
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"rootDir": ".",
|
||||
"outDir": "dist-server",
|
||||
"allowJs": true,
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmitOnError": true,
|
||||
"incremental": true,
|
||||
"tsBuildInfoFile": "dist-server/.tsbuildinfo",
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["index.js", "dev.js", "server/**/*.js", "server/**/*.ts", "test/**/*.js", "test/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist-server", "client", "**/*.d.ts"]
|
||||
}
|
||||
Reference in new issue
Block a user