Compare commits

..
55 Commits
Author SHA1 Message Date
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
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
Finn Dittmar dfce15a74c Add support that API-Keys authenticate sockets 2026-01-09 20:45:48 +01:00
48 changed files with 1275 additions and 180 deletions

No files matched your search

+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
+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"]
+4 -3
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 "--------------------"
@@ -48,7 +49,7 @@ Description: $DESCRIPTION"
echo "$controlfile" > dist/debian/DEBIAN/control;
# 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
+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.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf-client",
"version": "2.36.1",
"version": "2.37.0",
"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.0",
"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?",
+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?",
+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.0",
"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.
-5
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
+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' })
+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
}
+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
@@ -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')
}
@@ -7,7 +7,7 @@ const { ObjectDefineProperties, ObjectDefineProperty, ObjectSetPrototypeOf } = r
const stream = require('../../stream')
const { getDefaultEncoding } = require('../crypto/util')
const getDefaultEncoding = () => 'buffer'
module.exports = LazyTransform
+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}"`)
+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
+2 -1
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
}
}
+2 -8
View File
@@ -515,10 +515,7 @@ class LibraryScanner {
attributes: ['id', 'path'],
where: {
path: {
[sequelize.Op.not]: fullPath
},
path: {
[sequelize.Op.startsWith]: fullPath
[sequelize.Op.startsWith]: fullPath + '/'
}
}
})
@@ -531,10 +528,7 @@ class LibraryScanner {
attributes: ['id', 'path'],
where: {
path: {
[sequelize.Op.not]: altFullPath
},
path: {
[sequelize.Op.startsWith]: altFullPath
[sequelize.Op.startsWith]: altFullPath + '/'
}
}
})
+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) ?? ''
}
}
})
+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]
-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)
@@ -17,6 +17,7 @@ describe('MigrationManager', () => {
let fsMoveStub
let fsRemoveStub
let fsEnsureDirStub
let fsPathExistsStub
let processExitStub
let configPath = '/path/to/config'
+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)
})
})
})
+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"]
}