Compare commits

..
100 Commits
Author SHA1 Message Date
advplyr 75d85e971b Update remove book from collection endpoint to require Can Update permission instead of Can Delete 2026-11-12 17:11:08 -06:00
advplyr 3e0333877b Fix server crash on logout 2026-11-11 19:09:00 -06:00
advplyr 2dde0bf1c7 Validate credentials are string 2026-11-10 19:33:57 -06:00
advplyr 83dce682e8 Add library access checks on media progress endpoints 2026-11-09 17:10:22 -06:00
advplyr f88fdf1321 Merge pull request #5715 from mikiher/fix-book-edit-race
Fix book edit crash when a series or book link already exists
2026-11-09 16:47:57 -06:00
advplyr 20fd0cd700 Merge pull request #5714 from mikiher/fix-library-scan-race
Fix library scan crash when a series or book link already exists
2026-11-09 16:40:20 -06:00
mikiher 9b055f80a6 Fix book edit crash when a series or book link already exists 2026-11-09 19:09:36 +02:00
mikiher 04acd53667 Fix library scan crash when a series or book link already exists 2026-11-09 18:47:44 +02:00
advplyr 49c0ef6c07 Update collection controller to only emit collection socket events to the user making the request 2026-11-08 18:15:32 -06:00
advplyr f57ca09f6e Update session middleware to check session userId with current user 2026-11-05 17:50:21 -06:00
advplyr 149bae7d73 Merge pull request #5694 from mikiher/fix-itunes-parsing
Keep iTunes author credentials attached to the name
2026-11-04 17:51:41 -06:00
advplyr d6d0cf6b8f Merge pull request #5686 from mikiher/fix-upload-auth
Stop unauthenticated multipart uploads from leaving temp files on disk
2026-11-04 17:50:04 -06:00
mikiher 087099faa3 Keep iTunes author credentials attached to the name 2026-11-02 10:04:19 +02:00
advplyr ce41ce489c Merge pull request #5691 from mikiher/dedupe-authors
Dedupe repeated book authors
2026-11-01 17:26:38 -06:00
mikiher b31af26837 Name the library item in the server log when a scan fails on it 2026-11-01 09:14:58 +02:00
mikiher 4072054e00 Dedupe book authors before inserting links so a repeated name does not abort the scan 2026-11-01 09:12:50 +02:00
mikiher f1f57d6595 Add one name dedupe function and use it for parsed names and metadata.json 2026-11-01 09:10:45 +02:00
mikiher 7aaa734d3e Parse multipart uploads only after auth and permission checks 2026-10-31 23:19:19 +02:00
mikiher 48d9309eb2 Delete unmoved upload temp files when the response closes 2026-10-31 23:18:01 +02:00
advplyr 616c8ba152 Github workflow for building docker images with v3 tag 2026-10-31 11:53:20 -05:00
advplyr edb1f7efc9 Version bump v2.37.2 2026-10-23 14:36:19 -05:00
advplyr 92517f473b Update devcontainer.json node variant to 24 2026-10-23 14:19:39 -05:00
advplyr 9715c7a8f7 Merge pull request #5668 from nichwall/fix/issue-5637-copy-fallback
Add EPERM copy fallback
2026-10-22 17:36:02 -05:00
advplyr 95cb5bad27 Remove fs-extra sync updates 2026-10-22 17:34:09 -05:00
Nicholas Wallace 5cb461f9b5 Add EPERM move fallback 2026-10-20 21:55:23 -07:00
Nicholas Wallace 16aa80ed5c Add EPERM copy fallback 2026-10-20 21:39:26 -07:00
advplyr 743d915220 Normalize RouterBasePath env variable 2026-10-05 17:45:14 -05:00
advplyr 34715558df Use transaction and check root on server init 2026-10-04 20:03:56 -05:00
advplyr 2a0507ffc6 Update linuxpackager to only set 0775 on required files #5629 2026-10-02 17:19:38 -05:00
advplyr cff295fdc9 Merge pull request #5418 from SharonZhu0122/fix-5306-feed-cover-cache
Fix RSS feed cover images not updating after cover change (#5306)
2026-10-01 18:13:31 -05:00
advplyr 6b60beff6e Extract out rss feed image URL construction into a separate function 2026-10-01 18:13:09 -05:00
advplyr db433ce076 Merge pull request #5430 from boqiaok/fix/issue-5176-audible-products
Fix Audible search when products are null
2026-10-01 17:56:26 -05:00
advplyr 5edd20dfa3 Merge pull request #5437 from rtainaan/codex/handle-repeated-upload-files
Fix upload crash with repeated file fields
2026-10-01 17:52:59 -05:00
advplyr 3563d49424 Version bump v2.37.1 2026-09-29 17:30:00 -05:00
advplyr 06935b78b5 Merge pull request #5615 from weblate/weblate-audiobookshelf-abs-web-client
Translations update from Hosted Weblate
2026-09-29 17:24:44 -05:00
Gabriel 4aee64da07 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (1167 of 1167 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/pt_BR/
2026-09-30 00:18:46 +02:00
Martynas 02cf2da4fa Translated using Weblate (Lithuanian)
Currently translated at 60.1% (702 of 1167 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/lt/
2026-09-30 00:18:46 +02:00
Indrek Ankur 366320bf94 Translated using Weblate (Estonian)
Currently translated at 64.2% (750 of 1167 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/et/
2026-09-30 00:18:45 +02:00
advplyr a2cba8dd05 OIDC extend cookie max age to 10 minutes and redirect to login page when no session #5614 2026-09-29 17:18:38 -05:00
advplyr 86bc18487b Merge pull request #5606 from SharonZhu0122/fix-5604-oidc-session-cleanup
Expire abandoned OIDC mobile login sessions
2026-09-29 16:51:30 -05:00
advplyr 9e18cb41e9 Fix EACCES error for Debian install #5613
- Update systemd service file to define HOME path and CacheDirectory to support Node24 change
2026-09-29 16:33:13 -05:00
advplyr 7d10b8fc4a Version bump v2.37.0 2026-09-28 16:46:32 -05:00
advplyr 14bec248f1 Merge pull request #5577 from weblate/weblate-audiobookshelf-abs-web-client
Translations update from Hosted Weblate
2026-09-28 16:38:13 -05:00
Juan Benites ff57368f37 Translated using Weblate (Spanish)
Currently translated at 100.0% (1167 of 1167 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/es/
2026-09-27 01:06:47 +02:00
Mediokris f589397008 Translated using Weblate (Norwegian Bokmål)
Currently translated at 100.0% (1167 of 1167 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/nb_NO/
2026-09-27 01:06:47 +02:00
Danol fa4f6b58f3 Translated using Weblate (Czech)
Currently translated at 99.9% (1166 of 1167 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/cs/
2026-09-27 01:06:47 +02:00
Daniel Nylander 1acfd2ae54 Translated using Weblate (Swedish)
Currently translated at 100.0% (1167 of 1167 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sv/
2026-09-27 01:06:47 +02:00
advplyr 91717e3b96 Merge pull request #4974 from Vito0912/feat/addAPISocket
Add support that API-Keys authenticate sockets
2026-09-26 18:06:38 -05:00
advplyr 282d8b8bf6 Update TokenManager test 2026-09-26 18:03:39 -05:00
advplyr fb4c3ba984 Merge master 2026-09-26 18:00:58 -05:00
advplyr 72a1c656f9 Update jwt verify for sockets to ignore expiration 2026-09-26 17:52:08 -05:00
zsh 273693e8ad Expire abandoned OIDC mobile login sessions 2026-09-25 17:38:37 +12:00
advplyr d22c468215 Merge pull request #5575 from SharonZhu0122/fix-5535-explicit-flag-from-provider
Set explicit flag from Audible match results
2026-09-23 17:41:50 -05:00
advplyr c654de4630 Fix server crash decoding invalid filter groups 2026-09-22 18:00:08 -05:00
advplyr aef33df8d3 Update get series for library endpoint to ensure series belongs to library 2026-09-22 17:49:53 -05:00
advplyr 63a883e595 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2026-09-21 17:02:46 -05:00
advplyr b8b069eca6 Fix server crash when collapse sub series and sort by author name #4993 & add whitelist for collapse subseries sort 2026-09-21 17:02:34 -05:00
advplyr 0fd0483021 Merge commit from fork
Add UUID check for cache manager
2026-09-21 16:10:29 -05:00
advplyr 8463b8426a Update library item cover PATCH endpoint to require the cover path to be in the library item libraryFiles array 2026-09-21 16:07:41 -05:00
advplyr e874d31e64 Merge pull request #5578 from nichwall/upgrade-to-node24
Upgrade to node24
2026-09-20 16:49:09 -05:00
advplyr 557f56ff21 Add npm run lint to unit-tests gh workflow 2026-09-20 16:45:33 -05:00
advplyr 7f296a5135 Update @types/node to 24, update readme node version 2026-09-20 16:38:40 -05:00
advplyr b5134a3287 Cleanup prober.js and fix redeclare 2026-09-20 15:36:15 -05:00
advplyr 8fb7a856d5 Fix LibraryItem dupe class member hasAudioFiles 2026-09-20 15:32:20 -05:00
advplyr ad8b235058 Add eslint for server 2026-09-20 15:25:30 -05:00
advplyr 186733f6af Remove duplicate podcastEpisodeModel get in Database 2026-09-20 14:44:21 -05:00
advplyr 1e5443f054 Clean up invalid where objects in LibraryScanner 2026-09-20 14:43:39 -05:00
advplyr 18e32410ad Merge pull request #5586 from binyaminyblatt/missing_declaration
fix: resolve ReferenceErrors in scandir.js under strict mode
2026-09-19 16:59:43 -05:00
advplyr 5077f87e0c Fix undefined variable in podcastUtils 2026-09-19 16:59:02 -05:00
advplyr 608d3ddde8 Fix undefined binaryPath in BinaryManager 2026-09-19 16:57:01 -05:00
advplyr 4c27a7b68e Merge branch 'missing_declaration' of https://github.com/binyaminyblatt/audiobookshelf into missing_declaration 2026-09-19 16:48:50 -05:00
advplyr a3b150d049 Fix undefined var in nodeFfprobe 2026-09-19 16:48:45 -05:00
advplyr 088c4f542f Cleanup scandir variables 2026-09-19 16:47:40 -05:00
Binyamin Blatt 6acb4b9005 fix no-undef in BackupManager 2026-09-20 00:45:24 +03:00
Binyamin Blatt 8781930ced Fix undeclared variables 2026-09-20 00:01:56 +03:00
Finn Dittmar 4236eef5c4 Add UUID check for cache manager 2026-09-19 17:23:40 +02:00
Nicholas Wallace b14224a6d8 Update remaining workflows to node 24 2026-09-18 14:52:42 -07:00
Nicholas Wallace a6128a6c98 Remove socket warning from web client 2026-09-18 14:38:01 -07:00
Nicholas Wallace 8a2a45751e Update dockerfile to use node 24 2026-09-18 11:09:13 -07:00
Nicholas Wallace 83e9a82f8f Update build scripts to target node 24 2026-09-18 10:32:21 -07:00
zsh bf4f74cb93 Set explicit flag from Audible match results 2026-09-19 01:54:54 +12:00
advplyr 1e88ff01e7 Update Dockerfile fix tsc not work on arm64 2026-09-17 17:23:34 -05:00
advplyr 5ec4a8a46e Merge pull request #5510 from nichwall/typescript-migration-begin
Typescript initial migration framework
2026-09-17 17:11:58 -05:00
advplyr 99e2c45482 Remove buildServer and update docker and workflows 2026-09-17 17:09:00 -05:00
advplyr 3c5d191c33 Fix MigrationManager undefined var 2026-09-17 16:57:58 -05:00
advplyr b67cbffbf9 Update tsconfig to use allowJs to skip buildServer.js step 2026-09-17 16:54:31 -05:00
advplyr eeaa408a2d Merge pull request #5526 from mikiher/fix-logging-error
AudioMetadataManager: Fix logging error
2026-09-17 15:20:18 -05:00
mikiher eba1909c9c AudioMetadataManager: Fix logging error 2026-09-03 09:45:15 +03:00
nichwall fbfb24c264 Fix: new packaging warnings 2026-08-27 23:53:57 -07:00
nichwall 2440fb37c4 Ensure client is included in packaging after directory change 2026-08-27 23:42:06 -07:00
nichwall 65c075283c Fix appRoot directory 2026-08-27 23:30:22 -07:00
nichwall 198b2c27b8 Remove test files from docker image 2026-08-27 22:46:04 -07:00
nichwall d6398e54dd Fix: docker build 2026-08-27 21:40:09 -07:00
nichwall 0571747fab Remove deprecated npm ci flags 2026-08-23 21:56:29 -07:00
nichwall ec2738cb0f Fix: linux packager building client as typescript 2026-08-23 15:49:15 -07:00
nichwall a7ebb3e1af Add: typescript compile step and update packaging 2026-08-23 15:34:08 -07:00
Raymond (Kai) Tainaan 3f9afe57bc Fix upload crash with repeated file fields
The multipart middleware returns an array when a field is repeated, but handleUpload treats every value as one file. Flatten the values before moving them so repeated and unique field names both work.

Signed-off-by: Raymond (Kai) Tainaan <282025710+rtainaan@users.noreply.github.com>
2026-08-06 23:36:07 -07:00
Asher Jin ac63b0c7fc Fix Audible search when products are null 2026-08-04 14:10:32 +12:00
zsh 94400acdcd fix: add cache-busting timestamp to RSS feed cover image URLs
Feed cover image URLs were built only from the item's slug and file
extension, so the URL never changed when the cover image itself
changed. Podcast clients that cache by URL would keep showing the old
cover after an update.

Add a '?ts=' query parameter using the already-computed
entityUpdatedAt value, matching the same cache-busting convention
already used elsewhere in the app (see the updatedAt comment in
LibraryItemController.updateCover).

Applies to all three feed types: single item, collection, and series.

Fixes #5306
2026-07-31 10:28:21 +12:00
Finn Dittmar dfce15a74c Add support that API-Keys authenticate sockets 2026-01-09 20:45:48 +01:00
85 changed files with 2554 additions and 359 deletions

No files matched your search

+28 -37
View File
@@ -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"
}
+1 -1
View File
@@ -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
+102
View File
@@ -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
+2
View File
@@ -19,6 +19,8 @@ on:
- server/**
- index.js
- package.json
- tsconfig.server.json
- Dockerfile
jobs:
build:
+1 -1
View File
@@ -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
+6 -5
View File
@@ -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: |
+1 -1
View File
@@ -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
+4 -1
View File
@@ -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
+1
View File
@@ -11,6 +11,7 @@
/client/.nuxt/
/client/dist/
/dist/
/dist-server/
/deploy/
/coverage/
/.nyc_output/
+16 -7
View File
@@ -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
View File
@@ -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: {
+1
View File
@@ -60,6 +60,7 @@ module.exports = {
proxy,
io: {
server: false,
sockets: [
{
name: 'dev',
+2 -2
View File
@@ -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
View File
@@ -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",
+3 -3
View File
@@ -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",
+4
View File
@@ -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
View File
@@ -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",
+2 -2
View File
@@ -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",
+4
View File
@@ -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?",
+1 -1
View File
@@ -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",
+7 -3
View File
@@ -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",
+59
View File
@@ -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
}
]
+2 -4
View File
@@ -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'
+929 -22
View File
File diff suppressed because it is too large. Load diff
+20 -15
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
+44 -8
View File
@@ -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' })
+6
View File
@@ -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())
+14 -2
View File
@@ -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
+2 -4
View File
@@ -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
}
+22 -23
View File
@@ -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)
}
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -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') {
+1 -1
View File
@@ -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')
}
+5
View File
@@ -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)
@@ -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
};
+12 -1
View File
@@ -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()
})
})
}
+1 -1
View File
@@ -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)
})
}
+10 -7
View File
@@ -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
})()
})()
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -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)
+11 -2
View File
@@ -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
+10
View File
@@ -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
View File
@@ -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
View File
@@ -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 || '',
+12 -24
View File
@@ -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
View File
@@ -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
View File
@@ -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
+3 -2
View File
@@ -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) => {
+50 -5
View File
@@ -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,
+4 -3
View File
@@ -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))
+88 -32
View File
@@ -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
}
+3
View File
@@ -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 = []
}
+32 -11
View File
@@ -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)
}
+76
View File
@@ -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}"`)
+11 -1
View File
@@ -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) ?? ''
}
}
})
+24 -2
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
}
+6 -1
View File
@@ -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
}
},
/**
+6 -1
View File
@@ -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
View File
@@ -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]
+23
View File
@@ -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)
})
})
-5
View File
@@ -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)
+96
View File
@@ -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'
+71
View File
@@ -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)
})
})
+22
View File
@@ -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)
})
})
})
+53
View File
@@ -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')
})
})
})
+123
View File
@@ -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')
+20
View File
@@ -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"]
}